diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 00000000..105dd31f --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "switchbot", + "plugins": [ + { + "name": "switchbot", + "source": "./packages/codex-plugin/plugins/switchbot", + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..36f7a5d2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,19 @@ +# Normalize line endings to LF in the repository. +# On checkout, Git will not convert to CRLF (eol=lf for text files). +# This eliminates phantom "modified" entries on Windows when core.autocrlf=true. + +* text=auto eol=lf + +# Explicitly binary — never touch line endings. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary +*.ttf binary +*.eot binary +*.zip binary +*.gz binary +*.tar binary diff --git a/.githooks/pre-push b/.githooks/pre-push index 8b24ba99..ef6cec72 100644 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -4,5 +4,5 @@ set -eu REPO_ROOT="$(git rev-parse --show-toplevel)" cd "$REPO_ROOT" -echo "[pre-push] tarball install smoke" -npm run verify:pre-push +echo "[pre-push] release gate" +npm run verify:release-gate diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 849294d6..33e52711 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,3 +146,20 @@ jobs: - run: npm run build - name: npm pack -> npm install tarball -> switchbot --version / policy new / policy validate run: npm run smoke:pack-install + + codex-release-gates: + name: Codex release gates (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + needs: test + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: npm + - run: npm ci + - run: npm run verify:release-gate diff --git a/.github/workflows/npm-published-smoke.yml b/.github/workflows/npm-published-smoke.yml index 13ddabef..737f0561 100644 --- a/.github/workflows/npm-published-smoke.yml +++ b/.github/workflows/npm-published-smoke.yml @@ -32,6 +32,9 @@ jobs: - package: '@switchbot/codex-plugin' version_path: 'packages/codex-plugin/package.json' kind: plugin + - package: '@switchbot/openclaw-skill' + version_path: 'packages/openclaw-skill/package.json' + kind: plugin steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 04881f21..ca1c4407 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,9 +18,7 @@ jobs: registry-url: https://registry.npmjs.org cache: npm - run: npm ci - - run: npm run build - - run: npm test - - run: npm run test:workspaces + - run: npm run verify:release-gate - name: Verify tag matches root CLI package.json version run: | @@ -36,11 +34,14 @@ jobs: run: | 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" { echo "cli_version=$CLI_VERSION" echo "codex_version=$CODEX_VERSION" + echo "openclaw_version=$OPENCLAW_VERSION" } >> "$GITHUB_OUTPUT" id: versions @@ -49,6 +50,7 @@ jobs: env: CLI_VERSION: ${{ steps.versions.outputs.cli_version }} CODEX_VERSION: ${{ steps.versions.outputs.codex_version }} + OPENCLAW_VERSION: ${{ steps.versions.outputs.openclaw_version }} run: | # For each package, query npm; if the exact version is already published, skip. check_unpublished() { @@ -64,21 +66,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") echo "cli_publish=$CLI_PUBLISH" echo "codex_publish=$CODEX_PUBLISH" + echo "openclaw_publish=$OPENCLAW_PUBLISH" { echo "cli_publish=$CLI_PUBLISH" echo "codex_publish=$CODEX_PUBLISH" + echo "openclaw_publish=$OPENCLAW_PUBLISH" } >> "$GITHUB_OUTPUT" - - name: Smoke test packed root CLI artifact - if: steps.detect.outputs.cli_publish == 'true' - run: npm run smoke:pack-install - - - name: Smoke test packed Codex install path - if: steps.detect.outputs.cli_publish == 'true' || steps.detect.outputs.codex_publish == 'true' - run: npm run smoke:codex-pack-install - - name: Verify codex-plugin tarball peerDep is a concrete range if: steps.detect.outputs.codex_publish == 'true' run: | @@ -94,6 +91,21 @@ jobs: echo "OK: codex-plugin peerDep = '$PEER'" rm -f "/tmp/$TARBALL" + - name: Verify openclaw-skill tarball peerDep is a concrete range + if: steps.detect.outputs.openclaw_publish == 'true' + run: | + TARBALL=$(npm pack -w @switchbot/openclaw-skill --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: openclaw-skill peerDep is missing or unrewritten workspace:* — got: '$PEER'" + exit 1 + fi + echo "OK: openclaw-skill 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 @@ -108,7 +120,20 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Publish openclaw-skill to npm + id: publish_openclaw + if: steps.detect.outputs.openclaw_publish == 'true' + continue-on-error: true + run: npm publish -w @switchbot/openclaw-skill --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: | echo "::warning::codex-plugin publish step failed; root CLI promotion is unaffected. Investigate before next release." + + - name: Annotate OpenClaw plugin publish failures + 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." diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f696359..d24d64c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed + +- **OpenClaw plugin publish path** — `@switchbot/openclaw-skill` is back in the monorepo npm publish matrix. `publish.yml` now version-checks and publishes it alongside the CLI and Codex plugin, while `npm-published-smoke.yml` verifies the published tarball through the shared plugin smoke path. +- **OpenClaw package metadata** — `packages/openclaw-skill/README.md`, `.mcp.json`, and package metadata now describe the actual runtime contract: OpenClaw bootstraps via `bin/start.js`, then delegates to `switchbot mcp serve` for the CLI-owned MCP tool surface. + ## [3.7.3] ### Added diff --git a/package-lock.json b/package-lock.json index ba578171..6c58a82a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@switchbot/openapi-cli", - "version": "3.7.3", + "version": "3.7.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@switchbot/openapi-cli", - "version": "3.7.3", + "version": "3.7.4", "license": "MIT", "workspaces": [ "packages/*" @@ -6608,7 +6608,7 @@ }, "packages/codex-plugin": { "name": "@switchbot/codex-plugin", - "version": "0.1.0", + "version": "0.1.2", "license": "MIT", "bin": { "switchbot-codex-auth": "bin/auth.js", @@ -6628,7 +6628,7 @@ }, "packages/openclaw-skill": { "name": "@switchbot/openclaw-skill", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "bin": { "switchbot-openclaw": "bin/start.js", diff --git a/package.json b/package.json index 9d86d540..5c49879f 100644 --- a/package.json +++ b/package.json @@ -52,16 +52,19 @@ "start": "node dist/index.js", "smoke:pack-install": "node scripts/smoke-pack-install.mjs", "smoke:codex-pack-install": "node scripts/smoke-codex-pack-install.mjs", + "smoke:codex-git-sparse": "node scripts/smoke-codex-git-sparse.mjs", + "smoke:codex-temp-prefix-route-a": "node scripts/smoke-codex-temp-prefix-route-a.mjs", "test": "vitest run", "test:workspaces": "npm test --workspaces --if-present", "test:all": "npm test && npm run test:workspaces", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:release-smoke:manual": "npm test -- tests/commands/policy.test.ts tests/commands/devices.test.ts tests/commands/explain.test.ts tests/commands/doctor.test.ts tests/commands/mcp.test.ts tests/commands/health-check.test.ts tests/commands/quota.test.ts tests/commands/status-sync.test.ts tests/status-sync/smoke.test.ts tests/commands/watch.test.ts tests/commands/events.test.ts tests/devices/catalog-fidelity.test.ts tests/commands/schema.test.ts tests/commands/auth.test.ts tests/commands/config.test.ts tests/commands/scenes.test.ts tests/commands/batch.test.ts tests/commands/history.test.ts tests/commands/expand.test.ts tests/commands/webhook.test.ts tests/commands/daemon.test.ts tests/commands/upgrade-check.test.ts tests/commands/install.test.ts tests/commands/uninstall.test.ts tests/commands/rules.test.ts tests/commands/plan.test.ts", - "verify:pre-commit": "npm run build && npm test -- tests/version.test.ts", - "verify:pre-push": "npm run build && npm test -- tests/version.test.ts && npm run smoke:pack-install", + "verify:pre-commit": "npm run build && npm test -- tests/version.test.ts tests/install/codex-checks.test.ts tests/commands/codex.test.ts && npm run test:workspaces", + "verify:release-gate": "npm run build && npm test -- tests/version.test.ts tests/install/codex-checks.test.ts tests/commands/codex.test.ts && npm run test:workspaces && npm run smoke:pack-install && npm run smoke:codex-pack-install && npm run smoke:codex-git-sparse && npm run smoke:codex-temp-prefix-route-a", + "verify:pre-push": "npm run verify:release-gate", "verify:release": "node scripts/verify-release.mjs", - "prepublishOnly": "npm test && npm run build && npm run smoke:pack-install && npm run smoke:codex-pack-install" + "prepublishOnly": "npm run verify:release-gate" }, "dependencies": { "axios": "^1.7.9", diff --git a/packages/codex-plugin/.claude-plugin/marketplace.json b/packages/codex-plugin/.claude-plugin/marketplace.json new file mode 100644 index 00000000..48844e43 --- /dev/null +++ b/packages/codex-plugin/.claude-plugin/marketplace.json @@ -0,0 +1,15 @@ +{ + "$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 a4f16e48..85322f3d 100644 --- a/packages/codex-plugin/bin/install.js +++ b/packages/codex-plugin/bin/install.js @@ -22,14 +22,22 @@ function defaultRunInherit(cmd, args) { export function resolvePluginIdentifier(packageRoot) { let marketplaceName = basename(packageRoot); - const marketplacePath = join(packageRoot, '.agents', 'plugins', 'marketplace.json'); - if (existsSync(marketplacePath)) { - try { - const marketplace = JSON.parse(readFileSync(marketplacePath, 'utf8')); - if (marketplace?.name) { - marketplaceName = marketplace.name; - } - } catch {} + + // Check manifest paths in priority order; stop at first valid name found. + // 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'), + ]; + for (const p of manifestPaths) { + if (existsSync(p)) { + try { + const m = JSON.parse(readFileSync(p, 'utf8')); + if (m?.name) { marketplaceName = m.name; break; } + } catch {} + } } let pluginName = 'switchbot'; @@ -54,6 +62,21 @@ function computeAliasPath() { return join(os.homedir(), '.switchbot', 'codex-plugin-marketplace'); } +function createAlias(src, dest, type, deps) { + try { + deps.symlinkSync(src, dest, type); + } catch (err) { + if (err && err.code === 'EPERM') { + throw new Error( + `Cannot create ${type} at ${dest}: permission denied (EPERM). ` + + `On Windows, run the installer from an elevated terminal, ` + + `or install to a path without @-scoped segments.`, + ); + } + throw err; + } +} + export function resolveMarketplaceSourceRoot(packageRoot, deps = defaultFsDeps) { // NOTE: This function is FROZEN. The canonical implementation lives in // src/install/codex-checks.ts. Do NOT sync new changes here. @@ -72,7 +95,7 @@ export function resolveMarketplaceSourceRoot(packageRoot, deps = defaultFsDeps) const stat = deps.lstatSync(aliasRoot, { throwIfNoEntry: false }); if (!stat) { - deps.symlinkSync(packageRoot, aliasRoot, linkType); + createAlias(packageRoot, aliasRoot, linkType, deps); return aliasRoot; } @@ -85,7 +108,7 @@ export function resolveMarketplaceSourceRoot(packageRoot, deps = defaultFsDeps) } catch { // Dangling symlink: target was deleted (e.g. nvm switch, npm uninstall). deps.unlinkSync(aliasRoot); - deps.symlinkSync(packageRoot, aliasRoot, linkType); + createAlias(packageRoot, aliasRoot, linkType, deps); return aliasRoot; } const pathsMatch = process.platform === 'win32' @@ -95,7 +118,7 @@ export function resolveMarketplaceSourceRoot(packageRoot, deps = defaultFsDeps) return aliasRoot; } deps.unlinkSync(aliasRoot); - deps.symlinkSync(packageRoot, aliasRoot, linkType); + createAlias(packageRoot, aliasRoot, linkType, deps); return aliasRoot; } @@ -110,7 +133,8 @@ function formatCodexFailure(step) { ].join('\n'); } -const CODEX_PLUGIN_LEGACY_IDS = ['switchbot@switchbot-skill']; +const CODEX_PLUGIN_LEGACY_IDS = ['switchbot@codex-plugin', 'switchbot@switchbot-skill']; +const CODEX_MARKETPLACE_LEGACY_NAMES = ['switchbot', 'codex-plugin', 'switchbot-skill']; export function makeInstall({ checkCli, runInherit, packageRoot, runAuth, resolveRoot = resolveMarketplaceSourceRoot }) { return async function install() { @@ -131,6 +155,18 @@ export function makeInstall({ checkCli, runInherit, packageRoot, runAuth, resolv process.stderr.write(`[switchbot-codex] CLI ${cliCheck.version} detected.\n`); } + const pluginName = resolvePluginIdentifier(packageRoot); + // Pre-clean before marketplace add: removing the last plugin from a marketplace + // causes Codex to auto-delete the marketplace entry, so removes must happen + // before we register the new marketplace — not after. + for (const id of [...new Set([pluginName, ...CODEX_PLUGIN_LEGACY_IDS])]) { + process.stderr.write(`[switchbot-codex] Removing stale plugin ${id} if present...\n`); + const removeCode = await runInherit('codex', ['plugin', 'remove', id]); + if (removeCode !== 0) { + process.stderr.write(`[switchbot-codex] Warning: plugin remove exited ${removeCode}; continuing.\n`); + } + } + let marketplaceRoot; try { marketplaceRoot = resolveRoot(packageRoot); @@ -138,6 +174,15 @@ export function makeInstall({ checkCli, runInherit, packageRoot, runAuth, resolv process.stderr.write(`[switchbot-codex] Cannot prepare marketplace path: ${err.message}\n`); return 1; } + // Also remove marketplace records: stale DB entries cause marketplace add to say + // "already added" without recreating the directory, making plugin add fail. + for (const name of CODEX_MARKETPLACE_LEGACY_NAMES) { + process.stderr.write(`[switchbot-codex] Removing stale marketplace ${name} if present...\n`); + const mktRemoveCode = await runInherit('codex', ['plugin', 'marketplace', 'remove', name]); + if (mktRemoveCode !== 0) { + process.stderr.write(`[switchbot-codex] Warning: marketplace remove exited ${mktRemoveCode}; continuing.\n`); + } + } process.stderr.write(`[switchbot-codex] Registering plugin at ${marketplaceRoot}...\n`); const marketplaceCode = await runInherit('codex', ['plugin', 'marketplace', 'add', marketplaceRoot]); if (marketplaceCode !== 0) { @@ -149,14 +194,6 @@ export function makeInstall({ checkCli, runInherit, packageRoot, runAuth, resolv return marketplaceCode; } - const pluginName = resolvePluginIdentifier(packageRoot); - for (const id of [pluginName, ...CODEX_PLUGIN_LEGACY_IDS]) { - process.stderr.write(`[switchbot-codex] Removing stale plugin ${id} if present...\n`); - const removeCode = await runInherit('codex', ['plugin', 'remove', id]); - if (removeCode !== 0) { - process.stderr.write(`[switchbot-codex] Warning: plugin remove exited ${removeCode}; continuing.\n`); - } - } process.stderr.write(`[switchbot-codex] Adding plugin ${pluginName}...\n`); const pluginCode = await runInherit('codex', ['plugin', 'add', pluginName]); if (pluginCode !== 0) { diff --git a/packages/codex-plugin/.agents/plugins/marketplace.json b/packages/codex-plugin/marketplace.json similarity index 83% rename from packages/codex-plugin/.agents/plugins/marketplace.json rename to packages/codex-plugin/marketplace.json index 4a25a436..a3aa32aa 100644 --- a/packages/codex-plugin/.agents/plugins/marketplace.json +++ b/packages/codex-plugin/marketplace.json @@ -1,5 +1,5 @@ { - "name": "codex-plugin", + "name": "switchbot", "interface": { "displayName": "SwitchBot" }, @@ -8,7 +8,7 @@ "name": "switchbot", "source": { "source": "local", - "path": "../../" + "path": "./plugins/switchbot" }, "policy": { "installation": "AVAILABLE", diff --git a/packages/codex-plugin/package.json b/packages/codex-plugin/package.json index 96c33bd1..a9366aa3 100644 --- a/packages/codex-plugin/package.json +++ b/packages/codex-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/codex-plugin", - "version": "0.1.2", + "version": "0.1.3", "type": "module", "description": "SwitchBot Codex plugin — wires Codex to the SwitchBot CLI MCP server (24 tools, zero Node.js dependencies)", "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli/tree/main/packages/codex-plugin", @@ -31,7 +31,8 @@ "setup/", "skills/", ".codex-plugin/", - ".agents/", + ".claude-plugin/", + "plugins/", ".mcp.json", "README.md" ], @@ -47,7 +48,7 @@ "access": "public" }, "scripts": { - "test": "node --test tests/*.test.js", + "test": "node --test", "typecheck": "node --check bin/auth.js && node --check bin/install.js" } } diff --git a/packages/codex-plugin/plugins/switchbot/.codex-plugin/hooks.json b/packages/codex-plugin/plugins/switchbot/.codex-plugin/hooks.json new file mode 100644 index 00000000..68276b35 --- /dev/null +++ b/packages/codex-plugin/plugins/switchbot/.codex-plugin/hooks.json @@ -0,0 +1,6 @@ +{ + "onInstall": { + "command": "node", + "args": ["../../../bin/auth.js", "--hook"] + } +} diff --git a/packages/codex-plugin/plugins/switchbot/.codex-plugin/plugin.json b/packages/codex-plugin/plugins/switchbot/.codex-plugin/plugin.json new file mode 100644 index 00000000..6abd9fad --- /dev/null +++ b/packages/codex-plugin/plugins/switchbot/.codex-plugin/plugin.json @@ -0,0 +1,37 @@ +{ + "name": "switchbot", + "version": "0.1.0", + "description": "Control SwitchBot smart-home devices and scenes from Codex via the SwitchBot CLI and MCP server.", + "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli/tree/main/packages/codex-plugin", + "repository": "https://github.com/OpenWonderLabs/switchbot-openapi-cli", + "license": "MIT", + "keywords": [ + "switchbot", + "smart-home", + "iot", + "home-automation", + "mcp", + "codex" + ], + "skills": "./skills/", + "mcpServers": "./.mcp.json", + "hooks": "./.codex-plugin/hooks.json", + "interface": { + "displayName": "SwitchBot", + "shortDescription": "Control SwitchBot devices and scenes from Codex.", + "longDescription": "Use the SwitchBot OpenAPI CLI through a Codex skill and MCP server to inspect devices, run scenes, control smart-home hardware, and respect policy-based safety gates.", + "category": "Productivity", + "capabilities": [ + "Interactive", + "Read", + "Write" + ], + "websiteURL": "https://github.com/OpenWonderLabs/switchbot-openapi-cli", + "defaultPrompt": [ + "List my SwitchBot devices.", + "Check the status of my lights.", + "Run a SwitchBot scene safely." + ], + "brandColor": "#E7462E" + } +} diff --git a/packages/codex-plugin/plugins/switchbot/.mcp.json b/packages/codex-plugin/plugins/switchbot/.mcp.json new file mode 100644 index 00000000..f27b53b9 --- /dev/null +++ b/packages/codex-plugin/plugins/switchbot/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "switchbot": { + "command": "switchbot", + "args": ["mcp", "serve", "--tools", "all"], + "description": "SwitchBot smart-home MCP server (24 tools, via CLI)" + } + } +} diff --git a/packages/codex-plugin/plugins/switchbot/skills/switchbot/SKILL.md b/packages/codex-plugin/plugins/switchbot/skills/switchbot/SKILL.md new file mode 100644 index 00000000..3986b84f --- /dev/null +++ b/packages/codex-plugin/plugins/switchbot/skills/switchbot/SKILL.md @@ -0,0 +1,618 @@ +--- +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 + +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. + +--- + +## 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` | +| 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` (or the Windows equivalent) | +| Is my quota OK? | `switchbot --json quota status` | +| Is the setup healthy? | `switchbot doctor --json` | +| What automation rules does the user have? | `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 …]` | +| 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. + +--- + +## 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. + +--- + +## Required bootstrap (run this first, every session) + +Before you take any action, establish context: + +```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. + +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 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: + +```bash +switchbot policy validate +``` + +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. + +--- + +## 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`): + +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. +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). + +--- + +## 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. + +--- + +## Policy compliance + +Before executing any mutation or destructive action, check whether the user +has a policy file: + +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. + +--- + +## 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 +``` + +--- + +## 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`. + +--- + +## Streaming events + +If the user wants real-time reactions (motion, door contact, button +press), start the MQTT stream: + +```bash +switchbot events mqtt-tail --json +``` + +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. + +--- + +## 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: + +```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. + +### 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: + +```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 +``` + +Show the user the diff before writing. After they approve, validate + +reload: + +```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) +``` + +### 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. + +**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 +``` +`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. + +--- + +## Things to never do + +- 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. + +--- + +## If the CLI returns an error + +The envelope looks like: + +```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 +``` + +`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. + +`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. + +--- + +## 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 +``` + +Run `switchbot rules replay --since 24h --json` regularly to surface misfires. + +--- + +## 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 +``` + +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. + +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/plugins/switchbot/skills/switchbot/references/codex-network.md b/packages/codex-plugin/plugins/switchbot/skills/switchbot/references/codex-network.md new file mode 100644 index 00000000..cc69a80c --- /dev/null +++ b/packages/codex-plugin/plugins/switchbot/skills/switchbot/references/codex-network.md @@ -0,0 +1,34 @@ +# Codex network access for SwitchBot setup + +Read this file when `switchbot codex setup` fails with a network error, or when the user asks why setup is failing or how to enable network access in Codex. + +## Why network access is required + +`switchbot codex setup` performs three network operations: + +1. **npm registry probe** — checks for the latest `@switchbot/openapi-cli` version +2. **npm install -g** — installs or upgrades the CLI if outdated +3. **codex plugin marketplace add** — clones the plugin from GitHub + +All three require outbound internet access. Codex workspaces are offline by default. + +## How to enable network access in Codex + +Add the following to `~/.codex/config.toml` (create the file if it does not exist): + +```toml +[sandbox_workspace_write] +network_access = true +``` + +Then **restart Codex** and re-run setup: + +``` +switchbot codex setup +``` + +## Notes + +- `network_access = true` enables outbound internet for `workspace-write` sandbox mode only. +- It does **not** reduce approval prompts on its own. Set `approval_policy = "on-request"` separately if you want fewer prompts. +- If setup still fails after enabling network, run `switchbot codex doctor` to see which checks are failing. diff --git a/packages/codex-plugin/tests/install.test.js b/packages/codex-plugin/tests/install.test.js index 2c91c59a..1248e527 100644 --- a/packages/codex-plugin/tests/install.test.js +++ b/packages/codex-plugin/tests/install.test.js @@ -1,6 +1,8 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { makeInstall, resolvePluginIdentifier } from '../bin/install.js'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { makeInstall, resolvePluginIdentifier, resolveMarketplaceSourceRoot } from '../bin/install.js'; function makeOkCliCheck(version = '3.7.1') { return async () => ({ ok: true, version }); @@ -26,6 +28,7 @@ function makeSpawn(exitCode = 0) { } const TEST_ROOT = '/fake/codex-plugin'; +const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); describe('makeInstall', () => { it('skips npm install when CLI is already present', async () => { @@ -39,12 +42,16 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 0); - assert.equal(calls.length, 5); - assert.deepEqual(calls[0], { cmd: 'codex', args: ['plugin', 'marketplace', 'add', TEST_ROOT] }); - assert.deepEqual(calls[1], { cmd: 'codex', args: ['plugin', 'remove', 'switchbot@codex-plugin'] }); - assert.deepEqual(calls[2], { cmd: 'codex', args: ['plugin', 'remove', 'switchbot@switchbot-skill'] }); - assert.deepEqual(calls[3], { cmd: 'codex', args: ['plugin', 'add', 'switchbot@codex-plugin'] }); - assert.deepEqual(calls[4], { cmd: 'switchbot', args: ['doctor'] }); + // Order: plugin remove×2, marketplace remove×3, marketplace add, plugin add, doctor + assert.equal(calls.length, 8); + assert.deepEqual(calls[0], { cmd: 'codex', args: ['plugin', 'remove', 'switchbot@codex-plugin'] }); + assert.deepEqual(calls[1], { cmd: 'codex', args: ['plugin', 'remove', 'switchbot@switchbot-skill'] }); + assert.deepEqual(calls[2], { cmd: 'codex', args: ['plugin', 'marketplace', 'remove', 'switchbot'] }); + assert.deepEqual(calls[3], { cmd: 'codex', args: ['plugin', 'marketplace', 'remove', 'codex-plugin'] }); + assert.deepEqual(calls[4], { cmd: 'codex', args: ['plugin', 'marketplace', 'remove', 'switchbot-skill'] }); + assert.deepEqual(calls[5], { cmd: 'codex', args: ['plugin', 'marketplace', 'add', TEST_ROOT] }); + assert.deepEqual(calls[6], { cmd: 'codex', args: ['plugin', 'add', 'switchbot@codex-plugin'] }); + assert.deepEqual(calls[7], { cmd: 'switchbot', args: ['doctor'] }); assert.equal(auth.calls.length, 1); }); @@ -59,13 +66,17 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 0); - assert.equal(calls.length, 6); + // Order: npm install, plugin remove×2, marketplace remove×3, marketplace add, plugin add, doctor + assert.equal(calls.length, 9); assert.deepEqual(calls[0], { cmd: 'npm', args: ['install', '-g', '@switchbot/openapi-cli@latest'] }); - assert.deepEqual(calls[1], { cmd: 'codex', args: ['plugin', 'marketplace', 'add', TEST_ROOT] }); - assert.deepEqual(calls[2], { cmd: 'codex', args: ['plugin', 'remove', 'switchbot@codex-plugin'] }); - assert.deepEqual(calls[3], { cmd: 'codex', args: ['plugin', 'remove', 'switchbot@switchbot-skill'] }); - assert.deepEqual(calls[4], { cmd: 'codex', args: ['plugin', 'add', 'switchbot@codex-plugin'] }); - assert.deepEqual(calls[5], { cmd: 'switchbot', args: ['doctor'] }); + assert.deepEqual(calls[1], { cmd: 'codex', args: ['plugin', 'remove', 'switchbot@codex-plugin'] }); + assert.deepEqual(calls[2], { cmd: 'codex', args: ['plugin', 'remove', 'switchbot@switchbot-skill'] }); + assert.deepEqual(calls[3], { cmd: 'codex', args: ['plugin', 'marketplace', 'remove', 'switchbot'] }); + assert.deepEqual(calls[4], { cmd: 'codex', args: ['plugin', 'marketplace', 'remove', 'codex-plugin'] }); + assert.deepEqual(calls[5], { cmd: 'codex', args: ['plugin', 'marketplace', 'remove', 'switchbot-skill'] }); + assert.deepEqual(calls[6], { cmd: 'codex', args: ['plugin', 'marketplace', 'add', TEST_ROOT] }); + assert.deepEqual(calls[7], { cmd: 'codex', args: ['plugin', 'add', 'switchbot@codex-plugin'] }); + assert.deepEqual(calls[8], { cmd: 'switchbot', args: ['doctor'] }); assert.equal(auth.calls.length, 1); }); @@ -90,7 +101,9 @@ describe('makeInstall', () => { const auth = makeRunAuth(0); const spawn = (cmd, args) => { callCount++; - return Promise.resolve(callCount === 1 ? 2 : 0); + // calls 1-2: plugin removes (warn+continue), 3-5: marketplace removes (warn+continue) + // call 6: marketplace add — fail + return Promise.resolve(callCount === 6 ? 2 : 0); }; const install = makeInstall({ checkCli: makeOkCliCheck(), @@ -100,7 +113,7 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 2); - assert.equal(callCount, 1); + assert.equal(callCount, 6); assert.equal(auth.calls.length, 0); }); @@ -109,8 +122,8 @@ describe('makeInstall', () => { const auth = makeRunAuth(0); const spawn = (cmd, args) => { callCount++; - // calls: 1=marketplace add, 2=remove current, 3=remove legacy, 4=plugin add - return Promise.resolve(callCount === 4 ? 3 : 0); + // calls: 1-2=plugin removes, 3-5=marketplace removes, 6=marketplace add, 7=plugin add + return Promise.resolve(callCount === 7 ? 3 : 0); }; const install = makeInstall({ checkCli: makeOkCliCheck(), @@ -120,7 +133,7 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 3); - assert.equal(callCount, 4); + assert.equal(callCount, 7); assert.equal(auth.calls.length, 0); }); @@ -135,7 +148,8 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 4); - assert.equal(calls.length, 4); + // plugin remove×2, marketplace remove×3, marketplace add, plugin add (no doctor — auth failed) + assert.equal(calls.length, 7); assert.equal(auth.calls.length, 1); }); @@ -154,8 +168,9 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 5); - assert.equal(calls.length, 5); - assert.deepEqual(calls[4], { cmd: 'switchbot', args: ['doctor'] }); + // plugin remove×2, marketplace remove×3, marketplace add, plugin add, doctor + assert.equal(calls.length, 8); + assert.deepEqual(calls[7], { cmd: 'switchbot', args: ['doctor'] }); assert.equal(auth.calls.length, 1); }); @@ -174,7 +189,8 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 127); - assert.equal(callCount, 1); + // plugin remove×2 and marketplace remove×3 warn+continue; marketplace add returns 127 → stops + assert.equal(callCount, 6); assert.equal(auth.calls.length, 0); }); @@ -211,7 +227,7 @@ describe('makeInstall', () => { let callCount = 0; const spawn = (cmd, args) => { callCount++; - // calls: 1=marketplace add, 2=remove current → failure, 3=remove legacy, 4=plugin add, 5=doctor + // calls: 1=plugin remove current, 2=plugin remove legacy → fail, rest succeed return Promise.resolve(callCount === 2 ? 1 : 0); }; const auth = makeRunAuth(0); @@ -233,17 +249,144 @@ describe('makeInstall', () => { } assert.equal(code, 0, 'install should still succeed'); - assert.equal(callCount, 5, 'all five spawn calls should be made'); + // plugin remove×2, marketplace remove×3, marketplace add, plugin add, doctor + assert.equal(callCount, 8, 'all eight spawn calls should be made'); const combined = errChunks.join(''); assert.ok( combined.includes('Warning') && combined.includes('remove') && combined.includes('exited'), `expected warning about remove exit code in: ${combined}`, ); }); + + it('fresh install from the current package root registers switchbot@switchbot', async () => { + const { spawn, calls } = makeSpawn(0); + const auth = makeRunAuth(0); + const install = makeInstall({ + checkCli: makeFailCliCheck(), + runInherit: spawn, + packageRoot: PACKAGE_ROOT, + runAuth: auth.runAuth, + }); + const code = await install(); + assert.equal(code, 0); + assert.ok( + calls.some(({ cmd, args }) => + cmd === 'codex' && + args[0] === 'plugin' && + args[1] === 'add' && + args[2] === 'switchbot@switchbot'), + `expected plugin add switchbot@switchbot in calls: ${JSON.stringify(calls)}`, + ); + assert.ok( + calls.some(({ cmd, args }) => + cmd === 'codex' && + args[0] === 'plugin' && + args[1] === 'marketplace' && + args[2] === 'add' && + args[3] === PACKAGE_ROOT), + `expected marketplace add ${PACKAGE_ROOT} in calls: ${JSON.stringify(calls)}`, + ); + }); }); describe('resolvePluginIdentifier', () => { it('falls back to basename when the plugin manifest is unavailable', () => { assert.equal(resolvePluginIdentifier('/fake/codex-plugin'), 'switchbot@codex-plugin'); }); + + it('uses the published marketplace manifest from the current package root', () => { + assert.equal(resolvePluginIdentifier(PACKAGE_ROOT), 'switchbot@switchbot'); + }); +}); + +describe('resolveMarketplaceSourceRoot', () => { + it('resolveMarketplaceSourceRoot throws a helpful message when symlinkSync fails with EPERM', () => { + const epermErr = Object.assign(new Error('operation not permitted'), { code: 'EPERM' }); + const deps = { + mkdirSync: () => {}, + lstatSync: (_p, _opts) => null, // alias does not exist → first-create branch + symlinkSync: (_src, _dest, _type) => { throw epermErr; }, + realpathSync: (p) => p, + unlinkSync: () => {}, + }; + + // Use a path that triggers needsAlias on any platform + const scopedPath = process.platform === 'win32' + ? 'C:\\Users\\test\\node_modules\\@switchbot\\codex-plugin' + : '/home/user/node_modules/@switchbot/codex-plugin'; + + assert.throws( + () => resolveMarketplaceSourceRoot(scopedPath, deps), + (err) => { + assert.ok(err instanceof Error, 'should throw an Error'); + assert.ok(err.message.includes('EPERM'), `message should mention EPERM, got: ${err.message}`); + assert.ok( + err.message.includes('permission denied') || err.message.includes('elevated'), + `message should be actionable, got: ${err.message}`, + ); + return true; + }, + ); + }); + + it('throws a helpful EPERM message when symlinkSync fails on dangling-symlink branch', () => { + const epermErr = Object.assign(new Error('operation not permitted'), { code: 'EPERM' }); + const deps = { + mkdirSync: () => {}, + lstatSync: (_p, _opts) => ({ isSymbolicLink: () => true }), // alias exists as symlink + symlinkSync: (_src, _dest, _type) => { throw epermErr; }, + realpathSync: (_p) => { throw new Error('ENOENT: no such file'); }, // dangling → target gone + unlinkSync: () => {}, + }; + + const scopedPath = process.platform === 'win32' + ? 'C:\\Users\\test\\node_modules\\@switchbot\\codex-plugin' + : '/home/user/node_modules/@switchbot/codex-plugin'; + + assert.throws( + () => resolveMarketplaceSourceRoot(scopedPath, deps), + (err) => { + assert.ok(err instanceof Error, 'should throw an Error'); + assert.ok(err.message.includes('EPERM'), `message should mention EPERM, got: ${err.message}`); + assert.ok( + err.message.includes('permission denied') || err.message.includes('elevated'), + `message should be actionable, got: ${err.message}`, + ); + return true; + }, + ); + }); + + it('throws a helpful EPERM message when symlinkSync fails on stale-target branch', () => { + const epermErr = Object.assign(new Error('operation not permitted'), { code: 'EPERM' }); + let realCallCount = 0; + const deps = { + mkdirSync: () => {}, + lstatSync: (_p, _opts) => ({ isSymbolicLink: () => true }), // alias exists as symlink + symlinkSync: (_src, _dest, _type) => { throw epermErr; }, + realpathSync: (p) => { + realCallCount++; + // First call = aliasRoot, second call = packageRoot — return different values so paths differ + return realCallCount === 1 ? p + '-old' : p + '-new'; + }, + unlinkSync: () => {}, + }; + + const scopedPath = process.platform === 'win32' + ? 'C:\\Users\\test\\node_modules\\@switchbot\\codex-plugin' + : '/home/user/node_modules/@switchbot/codex-plugin'; + + assert.throws( + () => resolveMarketplaceSourceRoot(scopedPath, deps), + (err) => { + assert.ok(err instanceof Error, 'should throw an Error'); + assert.ok(err.message.includes('EPERM'), `message should mention EPERM, got: ${err.message}`); + assert.ok( + err.message.includes('permission denied') || err.message.includes('elevated'), + `message should be actionable, got: ${err.message}`, + ); + return true; + }, + ); + }); }); diff --git a/packages/openclaw-skill/.mcp.json b/packages/openclaw-skill/.mcp.json index 41cc48a2..a5c24585 100644 --- a/packages/openclaw-skill/.mcp.json +++ b/packages/openclaw-skill/.mcp.json @@ -3,7 +3,7 @@ "switchbot": { "command": "node", "args": ["${pluginDir}/bin/start.js"], - "description": "SwitchBot smart-home MCP server (devices_list, devices_status, devices_describe, devices_command, scenes_list, scenes_run)" + "description": "SwitchBot smart-home MCP server bootstrapper; launches the full tool surface exposed by `switchbot mcp serve`" } } } diff --git a/packages/openclaw-skill/README.md b/packages/openclaw-skill/README.md index f4defa9e..3b74ecec 100644 --- a/packages/openclaw-skill/README.md +++ b/packages/openclaw-skill/README.md @@ -1,6 +1,6 @@ # @switchbot/openclaw-skill -SwitchBot smart-home skill for [OpenClaw](https://openclaw.ai) — proxies all 24 MCP tools from `@switchbot/openapi-cli` so AI agents can control devices, run scenes, manage automation rules, and query audit logs. +SwitchBot smart-home skill for [OpenClaw](https://openclaw.ai) — bootstraps the local SwitchBot CLI and delegates to `switchbot mcp serve`, so AI agents get the full MCP tool surface exposed by the installed `@switchbot/openapi-cli`. ## Prerequisites @@ -25,7 +25,7 @@ installed, at `>=3.7.1`, and authenticated. Safe to re-run. ## MCP Tools -All 24 tools exposed by `switchbot mcp serve` are available. Key groups: +This plugin does not maintain its own hand-written tool subset. OpenClaw launches `bin/start.js`, and that wrapper hands off to `switchbot mcp serve`, so the exact tool list stays aligned with the installed CLI version. Key groups include: | Tool | Description | |---|---| @@ -56,7 +56,7 @@ the MCP server via the declarations in: `bin/start.js` installs it automatically. If credentials are missing, it outputs a `setupRequired` prompt asking you to run `switchbot config set-token`. Once configured, the plugin stays out of the -way and proxies the full 24-tool MCP server on every launch. +way and delegates every launch to the CLI-owned MCP server. To start manually (for debugging): diff --git a/packages/openclaw-skill/bin/start.js b/packages/openclaw-skill/bin/start.js index 9925faeb..0ff36e83 100644 --- a/packages/openclaw-skill/bin/start.js +++ b/packages/openclaw-skill/bin/start.js @@ -10,7 +10,7 @@ // --help Print this help. // // Default (no args): bootstrap wrapper — auto-installs CLI, verifies credentials, -// starts daemon if needed, then exec switchbot mcp serve (exposes all 24 MCP tools). +// starts daemon if needed, then exec switchbot mcp serve (the CLI-owned MCP surface). // Only credential setup requires user action; everything else is automatic. import { readFileSync } from 'node:fs'; @@ -62,7 +62,7 @@ See https://github.com/OpenWonderLabs/switchbot-openapi-cli for full docs.`); // [3] Daemon needed? → auto-start if automation rules are active await checkDaemon(); - // [4] Hand off to switchbot mcp serve — exposes all 24 MCP tools + // [4] Hand off to switchbot mcp serve — the CLI owns the MCP tool surface try { execFileSync('switchbot', ['mcp', 'serve'], { stdio: 'inherit' }); } catch (err) { diff --git a/packages/openclaw-skill/cli.js b/packages/openclaw-skill/cli.js deleted file mode 100644 index 4f8e422e..00000000 --- a/packages/openclaw-skill/cli.js +++ /dev/null @@ -1,108 +0,0 @@ -// packages/openclaw-skill/cli.js -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; -import { formatError } from './lib/error-messages.js'; - -const exec = promisify(execFile); - -// Read-only tools that should always bypass the CLI cache. The upstream -// @switchbot/openapi-cli 3.3.0 cache can serve stale values for batch / -// long-lived read paths — forcing --no-cache here absorbs the bug so -// agents and users don't have to remember it per-call. Mutation tools -// must NOT use --no-cache: they're write paths that don't hit the cache -// and passing the flag would just be noise. -const READ_TOOLS = new Set([ - 'devices_list', - 'devices_status', - 'devices_describe', - 'scenes_list', -]); - -export function buildCliArgs({ tool, params = {} }) { - const flags = READ_TOOLS.has(tool) ? ['--no-cache', '--json'] : ['--json']; - switch (tool) { - case 'devices_list': - return ['devices', 'list', ...flags]; - case 'devices_status': - return ['devices', 'status', params.deviceId, ...flags]; - case 'devices_describe': - return ['devices', 'describe', params.deviceId, ...flags]; - case 'devices_command': { - const args = ['--audit-log', 'devices', 'command', params.deviceId, params.command, ...flags]; - if (params.params) { - args.push('--params', JSON.stringify(params.params)); - } - return args; - } - case 'scenes_list': - return ['scenes', 'list', ...flags]; - case 'scenes_run': - return ['--audit-log', 'scenes', 'run', params.sceneId, ...flags]; - default: - throw new Error(`unknown tool: ${tool}`); - } -} - -// Substring patterns that indicate the CLI is installed but lacks -// credentials. We match on text (not a fixed envelope) because auth -// failures surface both from the CLI's own pre-flight checks and from -// the upstream SwitchBot API, and neither path conforms to the v0.2 -// envelope in every release. -const AUTH_ERROR_PATTERNS = [ - /token\s+not\s+(set|configured|found)/i, - /credentials?\s+not\s+(set|configured|found)/i, - /no\s+credentials/i, - /\b401\b/, - /unauthorized/i, - /missing\s+(token|credentials)/i, - /switchbot\s+config\s+set-token/i, -]; - -export function looksLikeAuthError(text) { - if (!text) return false; - return AUTH_ERROR_PATTERNS.some((re) => re.test(text)); -} - -function setupRequired(reason, message) { - return { - error: { - kind: 'setup-required', - reason, - message, - nextStep: 'Run `switchbot-openclaw setup` in a terminal to bootstrap the CLI.', - }, - }; -} - -export async function runCli(args) { - try { - const { stdout } = await exec('switchbot', args, { timeout: 15000 }); - return JSON.parse(stdout); - } catch (err) { - if (err && err.code === 'ENOENT') { - return setupRequired( - 'cli-missing', - formatError('cli-not-installed'), - ); - } - const raw = (err && (err.stdout ?? err.stderr ?? err.message)) ?? String(err); - let parsed = null; - try { parsed = JSON.parse(raw); } catch { /* non-JSON failure */ } - - const envelopeKind = parsed?.error?.kind; - if (envelopeKind === 'auth' || envelopeKind === 'credentials' || envelopeKind === 'unauthorized') { - return setupRequired( - 'auth-missing', - formatError('auth-not-configured'), - ); - } - if (!parsed && looksLikeAuthError(raw)) { - return setupRequired( - 'auth-missing', - formatError('auth-not-configured'), - ); - } - if (parsed) return parsed; - return { error: { kind: 'internal', message: raw } }; - } -} diff --git a/packages/openclaw-skill/index.js b/packages/openclaw-skill/index.js index ac35b93e..250ecfe6 100644 --- a/packages/openclaw-skill/index.js +++ b/packages/openclaw-skill/index.js @@ -1,19 +1,23 @@ // packages/openclaw-skill/index.js -import { buildCliArgs, runCli } from './cli.js'; +// +// This package does not register a hand-maintained JavaScript tool subset. +// OpenClaw launches the stdio entry declared in `.mcp.json`, which executes +// `bin/start.js`; that bootstrapper then delegates to `switchbot mcp serve`. -const TOOLS = [ - 'devices_list', - 'devices_status', - 'devices_describe', - 'devices_command', - 'scenes_list', - 'scenes_run', -]; +export const MCP_SERVER_NAME = 'switchbot'; +export const MCP_TRANSPORT = 'stdio'; +export const MCP_ENTRY_COMMAND = 'node'; +export const MCP_ENTRY_ARGS = ['${pluginDir}/bin/start.js']; +export const MCP_DELEGATE_COMMAND = ['switchbot', 'mcp', 'serve']; export function createServer() { - const _registeredTools = {}; - for (const tool of TOOLS) { - _registeredTools[tool] = (params) => runCli(buildCliArgs({ tool, params })); - } - return { _registeredTools }; + return { + name: MCP_SERVER_NAME, + transport: MCP_TRANSPORT, + command: MCP_ENTRY_COMMAND, + args: [...MCP_ENTRY_ARGS], + delegateCommand: [...MCP_DELEGATE_COMMAND], + }; } + +export default createServer; diff --git a/packages/openclaw-skill/package.json b/packages/openclaw-skill/package.json index 3bcb34de..e98d3e78 100644 --- a/packages/openclaw-skill/package.json +++ b/packages/openclaw-skill/package.json @@ -1,8 +1,8 @@ { "name": "@switchbot/openclaw-skill", - "version": "0.1.0", + "version": "0.1.1", "type": "module", - "description": "OpenClaw plugin that drives @switchbot/openapi-cli >= 3.7.1. Exposes 6 MCP tools (devices_list/status/describe/command, scenes_list/run) for SwitchBot smart-home control.", + "description": "OpenClaw plugin that bootstraps and delegates to `switchbot mcp serve`, exposing the full SwitchBot MCP tool surface through @switchbot/openapi-cli >= 3.7.1.", "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli/tree/main/packages/openclaw-skill", "repository": { "type": "git", @@ -37,7 +37,6 @@ }, "files": [ "index.js", - "cli.js", ".claude-plugin/", ".mcp.json", "bin/", @@ -50,8 +49,8 @@ "access": "public" }, "scripts": { - "test": "node --test tests/*.test.js", + "test": "node --test", "start": "node bin/start.js", - "typecheck": "node --check bin/start.js && node --check bin/policy-edit.js && node --check cli.js && node --check index.js" + "typecheck": "node --check bin/start.js && node --check bin/policy-edit.js && node --check index.js" } } diff --git a/packages/openclaw-skill/tests/cli-args.test.js b/packages/openclaw-skill/tests/cli-args.test.js deleted file mode 100644 index f5b995eb..00000000 --- a/packages/openclaw-skill/tests/cli-args.test.js +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { buildCliArgs, looksLikeAuthError } from '../cli.js'; - -describe('buildCliArgs', () => { - it('devices_list passes --no-cache', () => { - const args = buildCliArgs({ tool: 'devices_list', params: {} }); - assert.ok(args.includes('--no-cache'), 'read tool must use --no-cache'); - assert.deepEqual(args, ['devices', 'list', '--no-cache', '--json']); - }); - - it('devices_status passes --no-cache and forwards deviceId', () => { - const args = buildCliArgs({ tool: 'devices_status', params: { deviceId: 'ABC' } }); - assert.ok(args.includes('--no-cache')); - assert.deepEqual(args, ['devices', 'status', 'ABC', '--no-cache', '--json']); - }); - - it('devices_describe passes --no-cache', () => { - const args = buildCliArgs({ tool: 'devices_describe', params: { deviceId: 'ABC' } }); - assert.ok(args.includes('--no-cache')); - assert.deepEqual(args, ['devices', 'describe', 'ABC', '--no-cache', '--json']); - }); - - it('devices_command does NOT pass --no-cache', () => { - const args = buildCliArgs({ - tool: 'devices_command', - params: { deviceId: 'ABC', command: 'turnOn' }, - }); - assert.ok(!args.includes('--no-cache'), 'mutation must not use --no-cache'); - assert.deepEqual(args, ['--audit-log', 'devices', 'command', 'ABC', 'turnOn', '--json']); - }); - - it('devices_command forwards --params when provided', () => { - const args = buildCliArgs({ - tool: 'devices_command', - params: { deviceId: 'ABC', command: 'setBrightness', params: { value: 70 } }, - }); - assert.ok(!args.includes('--no-cache')); - const paramsIdx = args.indexOf('--params'); - assert.notEqual(paramsIdx, -1, '--params flag missing'); - assert.equal(args[paramsIdx + 1], JSON.stringify({ value: 70 })); - }); - - it('scenes_list passes --no-cache', () => { - const args = buildCliArgs({ tool: 'scenes_list', params: {} }); - assert.ok(args.includes('--no-cache')); - assert.deepEqual(args, ['scenes', 'list', '--no-cache', '--json']); - }); - - it('scenes_run does NOT pass --no-cache', () => { - const args = buildCliArgs({ tool: 'scenes_run', params: { sceneId: 'SCENE1' } }); - assert.ok(!args.includes('--no-cache'), 'mutation must not use --no-cache'); - assert.deepEqual(args, ['--audit-log', 'scenes', 'run', 'SCENE1', '--json']); - }); - - it('throws for unknown tool', () => { - assert.throws(() => buildCliArgs({ tool: 'unknown_tool', params: {} }), /unknown tool/); - }); -}); - -describe('looksLikeAuthError', () => { - it('matches "token not set"', () => { - assert.ok(looksLikeAuthError('Error: token not set')); - }); - - it('matches "401" as standalone code', () => { - assert.ok(looksLikeAuthError('HTTP 401 Unauthorized')); - }); - - it('matches "credentials not configured"', () => { - assert.ok(looksLikeAuthError('credentials not configured')); - }); - - it('matches the hint "switchbot config set-token"', () => { - assert.ok(looksLikeAuthError('run `switchbot config set-token` first')); - }); - - it('does not match unrelated errors', () => { - assert.ok(!looksLikeAuthError('device is offline')); - assert.ok(!looksLikeAuthError('connection timeout after 15s')); - assert.ok(!looksLikeAuthError('')); - assert.ok(!looksLikeAuthError(null)); - }); -}); diff --git a/packages/openclaw-skill/tests/server.test.js b/packages/openclaw-skill/tests/server.test.js index 83db426e..acb8c5db 100644 --- a/packages/openclaw-skill/tests/server.test.js +++ b/packages/openclaw-skill/tests/server.test.js @@ -1,15 +1,21 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { createServer } from '../index.js'; +import { + createServer, + MCP_SERVER_NAME, + MCP_TRANSPORT, + MCP_ENTRY_COMMAND, + MCP_ENTRY_ARGS, + MCP_DELEGATE_COMMAND, +} from '../index.js'; describe('MCP server', () => { - it('registers all required tools', () => { + it('describes the stdio launcher that delegates to the CLI MCP server', () => { const server = createServer(); - const names = Object.keys(server._registeredTools); - const required = ['devices_list', 'devices_status', 'devices_describe', 'devices_command', 'scenes_list', 'scenes_run']; - for (const name of required) { - assert.ok(names.includes(name), `tool "${name}" not registered`); - } - assert.equal(names.length, required.length, 'unexpected extra tools'); + assert.equal(server.name, MCP_SERVER_NAME); + assert.equal(server.transport, MCP_TRANSPORT); + assert.equal(server.command, MCP_ENTRY_COMMAND); + assert.deepEqual(server.args, MCP_ENTRY_ARGS); + assert.deepEqual(server.delegateCommand, MCP_DELEGATE_COMMAND); }); }); diff --git a/scripts/smoke-codex-git-sparse.mjs b/scripts/smoke-codex-git-sparse.mjs new file mode 100644 index 00000000..06d9f0b4 --- /dev/null +++ b/scripts/smoke-codex-git-sparse.mjs @@ -0,0 +1,84 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.dirname(scriptDir); +const workDir = mkdtempSync(path.join(os.tmpdir(), 'switchbot-codex-git-sparse-')); +const stagingDir = path.join(workDir, 'marketplace-add'); + +function runGit(args, options = {}) { + return execFileSync('git', args, { + cwd: repoRoot, + encoding: 'utf-8', + ...options, + }); +} + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, 'utf-8')); +} + +try { + let ref = process.env.CODEX_GIT_MARKETPLACE_REF; + if (!ref) { + try { + const abbrev = runGit(['rev-parse', '--abbrev-ref', 'HEAD']).trim(); + ref = abbrev === 'HEAD' + ? runGit(['rev-parse', 'HEAD']).trim() // detached HEAD: use full SHA + : abbrev; + } catch (err) { + throw new Error(`Failed to determine git ref for smoke test: ${err.message}`); + } + } + + // Clone: use --branch only for named branches; for SHAs clone without --branch then checkout + if (ref.match(/^[0-9a-fA-F]{40}$/)) { + runGit(['clone', '--no-checkout', repoRoot, stagingDir], { cwd: workDir }); + } else { + runGit(['clone', '--no-checkout', '--branch', ref, repoRoot, stagingDir], { cwd: workDir }); + } + runGit(['-C', stagingDir, 'sparse-checkout', 'init', '--cone'], { cwd: workDir }); + runGit(['-C', stagingDir, 'sparse-checkout', 'set', '.claude-plugin', 'packages/codex-plugin'], { cwd: workDir }); + 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 pluginMcpPath = path.join(stagingDir, 'packages', 'codex-plugin', 'plugins', 'switchbot', '.mcp.json'); + + for (const requiredPath of [rootMarketplacePath, packageMarketplacePath, pluginMcpPath]) { + if (!existsSync(requiredPath)) { + throw new Error(`sparse checkout missing ${path.relative(stagingDir, requiredPath)}`); + } + } + + const rootMarketplace = readJson(rootMarketplacePath); + if (rootMarketplace?.name !== 'switchbot') { + throw new Error(`root marketplace name must be switchbot, got ${rootMarketplace?.name ?? ''}`); + } + const rootPlugin = rootMarketplace?.plugins?.find((plugin) => plugin?.name === 'switchbot'); + if (rootPlugin?.source !== './packages/codex-plugin/plugins/switchbot') { + throw new Error( + `root marketplace switchbot source must be ./packages/codex-plugin/plugins/switchbot, got ${rootPlugin?.source ?? ''}`, + ); + } + + const packageMarketplace = readJson(packageMarketplacePath); + if (packageMarketplace?.name !== 'switchbot') { + throw new Error(`package marketplace name must be switchbot, got ${packageMarketplace?.name ?? ''}`); + } + const packagePlugin = packageMarketplace?.plugins?.find((plugin) => plugin?.name === 'switchbot'); + if (packagePlugin?.source !== './plugins/switchbot') { + throw new Error(`package marketplace switchbot source must be ./plugins/switchbot, got ${packagePlugin?.source ?? ''}`); + } + + console.log(`codex git sparse smoke ok: ref ${ref} exposes root and package marketplace manifests with switchbot sources`); +} finally { + try { + rmSync(workDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } catch (error) { + process.stderr.write(`[smoke-codex-git-sparse] cleanup warning: ${error.message}\n`); + } +} diff --git a/scripts/smoke-codex-pack-install.mjs b/scripts/smoke-codex-pack-install.mjs index 92a5184b..c0fc0203 100644 --- a/scripts/smoke-codex-pack-install.mjs +++ b/scripts/smoke-codex-pack-install.mjs @@ -2,7 +2,7 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.dirname(scriptDir); @@ -63,7 +63,7 @@ try { } for (const requiredPath of [ - '.agents/plugins/marketplace.json', + '.claude-plugin/marketplace.json', '.codex-plugin/plugin.json', '.codex-plugin/hooks.json', '.mcp.json', @@ -82,13 +82,13 @@ try { throw new Error(`plugin displayName must be SwitchBot, got ${pluginManifest?.interface?.displayName ?? ''}`); } - const marketplace = readJson(path.join(pluginRoot, '.agents', 'plugins', 'marketplace.json')); - if (marketplace?.name !== 'codex-plugin') { - throw new Error(`marketplace name must be codex-plugin so switchbot@codex-plugin resolves, got ${marketplace?.name ?? ''}`); + const marketplace = readJson(path.join(pluginRoot, '.claude-plugin', 'marketplace.json')); + if (marketplace?.name !== 'switchbot') { + throw new Error(`marketplace name must be switchbot so switchbot@switchbot resolves, got ${marketplace?.name ?? ''}`); } const switchbotEntry = marketplace?.plugins?.find((p) => p?.name === 'switchbot'); - if (switchbotEntry?.source?.path !== '../../') { - throw new Error(`marketplace switchbot plugin source.path must be '../../' (codex resolves it from .agents/plugins/marketplace.json up to packageRoot), got ${switchbotEntry?.source?.path ?? ''}`); + if (switchbotEntry?.source !== './plugins/switchbot') { + throw new Error(`marketplace switchbot plugin source must be './plugins/switchbot', got ${switchbotEntry?.source ?? ''}`); } const hooks = readJson(path.join(pluginRoot, '.codex-plugin', 'hooks.json')); @@ -140,7 +140,67 @@ try { throw new Error(`codex plugin onInstall hook must exit 0; got ${hook.status ?? 1}\nstderr:\n${hook.stderr}`); } - console.log('codex pack-install smoke ok: tarballs install, setup dry-run has 5 steps, hook is non-blocking'); + const { makeInstall, resolvePluginIdentifier, resolveMarketplaceSourceRoot } = await import( + pathToFileURL(path.join(pluginRoot, 'bin', 'install.js')).href + ); + const registrationCalls = []; + const installCode = await makeInstall({ + checkCli: async () => ({ ok: false, message: 'CLI not found' }), + runInherit: async (cmd, args) => { + registrationCalls.push({ cmd, args }); + return 0; + }, + packageRoot: pluginRoot, + runAuth: async () => 0, + })(); + if (installCode !== 0) { + throw new Error(`installed codex plugin makeInstall returned ${installCode}`); + } + + const pluginIdentifier = resolvePluginIdentifier(pluginRoot); + if (pluginIdentifier !== 'switchbot@switchbot') { + throw new Error(`resolved plugin identifier must be switchbot@switchbot, got ${pluginIdentifier}`); + } + const marketplaceSourceRoot = resolveMarketplaceSourceRoot(pluginRoot); + const registrationSequence = registrationCalls.map(({ cmd, args }) => `${cmd} ${args.join(' ')}`); + for (const expected of [ + 'npm install -g @switchbot/openapi-cli@latest', + 'codex plugin remove switchbot@switchbot', + 'codex plugin remove switchbot@codex-plugin', + 'codex plugin remove switchbot@switchbot-skill', + 'codex plugin marketplace remove switchbot', + 'codex plugin marketplace remove codex-plugin', + 'codex plugin marketplace remove switchbot-skill', + `codex plugin marketplace add ${marketplaceSourceRoot}`, + 'codex plugin add switchbot@switchbot', + 'switchbot doctor', + ]) { + if (!registrationSequence.includes(expected)) { + throw new Error(`installed codex plugin registration flow missing "${expected}"\nactual:\n${registrationSequence.join('\n')}`); + } + } + + // Verify ordering: all plugin removes must come before marketplace add + const marketplaceAddIdx = registrationSequence.indexOf( + `codex plugin marketplace add ${marketplaceSourceRoot}` + ); + const pluginAddIdx = registrationSequence.indexOf('codex plugin add switchbot@switchbot'); + const pluginRemoveIds = ['switchbot@switchbot', 'switchbot@codex-plugin', 'switchbot@switchbot-skill']; + for (const id of pluginRemoveIds) { + const removeIdx = registrationSequence.indexOf(`codex plugin remove ${id}`); + if (removeIdx === -1 || removeIdx >= marketplaceAddIdx) { + throw new Error( + `codex plugin remove ${id} must happen before marketplace add (remove idx: ${removeIdx}, marketplace add idx: ${marketplaceAddIdx})` + ); + } + } + if (pluginAddIdx <= marketplaceAddIdx) { + throw new Error( + `codex plugin add must happen after marketplace add (add idx: ${pluginAddIdx}, marketplace add idx: ${marketplaceAddIdx})` + ); + } + + console.log('codex pack-install smoke ok: tarballs install, setup dry-run is present, hook is non-blocking, fresh install registration uses switchbot@switchbot'); } finally { for (const tarball of packed) { rmSync(tarball, { force: true }); diff --git a/scripts/smoke-codex-temp-prefix-route-a.mjs b/scripts/smoke-codex-temp-prefix-route-a.mjs new file mode 100644 index 00000000..42b42beb --- /dev/null +++ b/scripts/smoke-codex-temp-prefix-route-a.mjs @@ -0,0 +1,122 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.dirname(scriptDir); +const workDir = mkdtempSync(path.join(os.tmpdir(), 'switchbot-codex-prefix-')); +const prefixDir = path.join(workDir, 'prefix'); +const packed = []; +const expectedPluginVersion = JSON.parse( + readFileSync(path.join(repoRoot, 'packages', 'codex-plugin', 'package.json'), 'utf-8') +).version; + +function runNpm(args, options = {}) { + const npmExecPath = process.env.npm_execpath; + if (npmExecPath) { + return execFileSync(process.execPath, [npmExecPath, ...args], options); + } + const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + return execFileSync(npmCmd, args, options); +} + +function pack(args) { + const out = runNpm(['pack', '--json', ...args], { + cwd: repoRoot, + encoding: 'utf-8', + }); + const [result] = JSON.parse(out); + if (!result?.filename) { + throw new Error(`npm pack did not return a filename: ${out}`); + } + const tarball = path.join(repoRoot, result.filename); + packed.push(tarball); + return tarball; +} + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, 'utf-8')); +} + +try { + const cliTarball = pack([]); + const pluginTarball = pack(['--workspace', '@switchbot/codex-plugin']); + + runNpm(['install', '-g', '--prefix', prefixDir, cliTarball, pluginTarball], { + cwd: repoRoot, + stdio: 'inherit', + }); + + // On Linux/macOS, `npm install -g --prefix` puts packages under lib/node_modules/; + // on Windows they go directly under node_modules/. + const nodeModulesDir = + process.platform === 'win32' + ? path.join(prefixDir, 'node_modules') + : path.join(prefixDir, 'lib', 'node_modules'); + const pluginRoot = path.join(nodeModulesDir, '@switchbot', 'codex-plugin'); + const cliRoot = path.join(nodeModulesDir, '@switchbot', 'openapi-cli'); + if (!existsSync(pluginRoot)) { + throw new Error(`installed plugin root not found: ${pluginRoot}`); + } + if (!existsSync(cliRoot)) { + throw new Error(`installed CLI root not found: ${cliRoot}`); + } + + const pluginPkg = readJson(path.join(pluginRoot, 'package.json')); + const cliPkg = readJson(path.join(cliRoot, 'package.json')); + if (pluginPkg.version !== expectedPluginVersion) { + throw new Error(`expected installed codex-plugin version ${expectedPluginVersion}, got ${pluginPkg.version}`); + } + + const { + makeInstall, + resolvePluginIdentifier, + resolveMarketplaceSourceRoot, + } = await import(pathToFileURL(path.join(pluginRoot, 'bin', 'install.js')).href); + + const pluginId = resolvePluginIdentifier(pluginRoot); + if (pluginId !== 'switchbot@switchbot') { + throw new Error(`installed plugin resolved ${pluginId}, expected switchbot@switchbot`); + } + + const marketplaceRoot = resolveMarketplaceSourceRoot(pluginRoot); + const calls = []; + const installCode = await makeInstall({ + checkCli: async () => ({ ok: true, version: cliPkg.version }), + runInherit: async (cmd, args) => { + calls.push({ cmd, args }); + return 0; + }, + packageRoot: pluginRoot, + runAuth: async () => 0, + })(); + if (installCode !== 0) { + throw new Error(`installed Route A makeInstall exited ${installCode}`); + } + + const sequence = calls.map(({ cmd, args }) => `${cmd} ${args.join(' ')}`); + for (const expected of [ + 'codex plugin remove switchbot@switchbot', + 'codex plugin remove switchbot@codex-plugin', + 'codex plugin remove switchbot@switchbot-skill', + 'codex plugin marketplace remove switchbot', + 'codex plugin marketplace remove codex-plugin', + 'codex plugin marketplace remove switchbot-skill', + `codex plugin marketplace add ${marketplaceRoot}`, + 'codex plugin add switchbot@switchbot', + 'switchbot doctor', + ]) { + if (!sequence.includes(expected)) { + throw new Error(`installed Route A flow missing "${expected}"\nactual:\n${sequence.join('\n')}`); + } + } + + console.log(`codex temp-prefix Route A smoke ok: installed @switchbot/codex-plugin@${pluginPkg.version} resolves ${pluginId}`); +} finally { + for (const tarball of packed) { + rmSync(tarball, { force: true }); + } + rmSync(workDir, { recursive: true, force: true }); +} diff --git a/src/commands/codex.ts b/src/commands/codex.ts index 129f7690..35e0534d 100644 --- a/src/commands/codex.ts +++ b/src/commands/codex.ts @@ -9,6 +9,7 @@ import { registerCodexPluginAuto, resolvePluginId, resolveCodexPackageRoot, + CODEX_PLUGIN_DEFAULT_ID, CODEX_PLUGIN_LEGACY_IDS, type Check, } from '../install/codex-checks.js'; @@ -172,10 +173,10 @@ function repairStepRemovePlugin(ctx: RepairContext): RepairOutcome { let pluginId = ctx.codexPluginId; if (!pluginId) { const root = resolveCodexPackageRoot(); - pluginId = root.ok ? resolvePluginId(root.packageRoot) : 'switchbot@codex-plugin'; + pluginId = root.ok ? resolvePluginId(root.packageRoot) : CODEX_PLUGIN_DEFAULT_ID; ctx.codexPluginId = pluginId; } - for (const id of [pluginId, ...CODEX_PLUGIN_LEGACY_IDS]) { + for (const id of [...new Set([pluginId, ...CODEX_PLUGIN_LEGACY_IDS])]) { const r = spawnSync( 'codex', ['plugin', 'remove', id], { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 15000 }, @@ -346,12 +347,17 @@ function registerCodexRepairSubcommand(codex: Command): void { console.log(`${icon} ${o.step.padEnd(18)} ${o.message ?? ''}`); } console.log(''); + printRepairStatusSummary(outcomes); + console.log(''); if (!anyFailed) { - console.log(chalk.green('Repair complete. Restart Codex and run: switchbot devices list')); + console.log(chalk.green('Repair complete.')); + console.log(chalk.dim('Restart Codex Desktop to reload the SwitchBot skill and MCP tools.')); + console.log(chalk.dim('After restart, ask: "List my SwitchBot devices."')); } else if (preflightFailed) { console.log(chalk.red('Preflight failed — fix the above issue and re-run.')); } else { - console.log(chalk.yellow('Repair finished with failures. Review the output above.')); + console.log(chalk.yellow('Repair finished with failures.')); + console.log(chalk.dim('Run: switchbot codex repair --skip re-auth')); } } if (preflightFailed) process.exit(2); @@ -360,6 +366,49 @@ function registerCodexRepairSubcommand(codex: Command): void { }); } +// ─── status summary helpers ─────────────────────────────────────────────────── + +function statusLine(label: string, ok: boolean | null, note?: string): void { + const icon = ok === true ? chalk.green('✓') : ok === false ? chalk.red('✗') : chalk.yellow('?'); + const color = ok === true ? chalk.green : ok === false ? chalk.red : chalk.yellow; + const suffix = note ? chalk.dim(` — ${note}`) : ''; + console.log(` ${icon} ${color(label)}${suffix}`); +} + +function printSetupStatusSummary(outcomes: SetupOutcome[]): void { + const find = (name: string) => outcomes.find((o) => o.step === name); + const isOk = (name: string) => { + const o = find(name); + return o ? (o.status === 'ok' || o.status === 'skipped') : null; + }; + const cliOk = isOk('install-switchbot-cli'); + const authOk = isOk('auth'); + const pluginOk = outcomes.find((o) => o.step === 'register-plugin')?.status === 'ok' ? true + : outcomes.find((o) => o.step === 'register-plugin')?.status === 'failed' ? false + : null; + console.log(chalk.bold('Component status:')); + statusLine('CLI installed (switchbot)', cliOk, cliOk === false ? 'run: npm install -g @switchbot/openapi-cli@latest' : undefined); + statusLine('Credentials (API token)', authOk, authOk === false ? 'run: switchbot auth login' : undefined); + statusLine('Codex plugin (switchbot)', pluginOk, pluginOk === false ? 'run: switchbot codex repair' : undefined); +} + +function printRepairStatusSummary(outcomes: RepairOutcome[]): void { + const find = (name: string) => outcomes.find((o) => o.step === name); + const isOk = (name: string) => { + const o = find(name); + return o ? (o.status === 'ok' || o.status === 'skipped') : null; + }; + const cliOk = isOk('verify-cli'); + const authOk = isOk('re-auth'); + const pluginOk = outcomes.find((o) => o.step === 'register-plugin')?.status === 'ok' ? true + : outcomes.find((o) => o.step === 'register-plugin')?.status === 'failed' ? false + : null; + console.log(chalk.bold('Component status:')); + statusLine('CLI installed (switchbot)', cliOk); + statusLine('Credentials (API token)', authOk, authOk === false ? 'run: switchbot auth login' : undefined); + statusLine('Codex plugin (switchbot)', pluginOk, pluginOk === false ? 'run: switchbot codex repair' : undefined); +} + // ─── setup ─────────────────────────────────────────────────────────────────── interface SetupContext { @@ -652,12 +701,17 @@ Environment variables: console.log(`${icon} ${o.step.padEnd(22)} ${o.message ?? ''}`); } console.log(''); + printSetupStatusSummary(outcomes); + console.log(''); if (!anyFailed) { - console.log(chalk.green('Setup complete. Restart Codex and run: switchbot devices list')); + console.log(chalk.green('Setup complete.')); + console.log(chalk.dim('Restart Codex Desktop to load the SwitchBot skill and MCP tools.')); + console.log(chalk.dim('After restart, ask: "List my SwitchBot devices."')); } else if (preflightFailed) { console.log(chalk.red('Preflight failed — install Codex CLI first, then re-run.')); } else { - console.log(chalk.yellow('Setup finished with failures. Review the output above.')); + console.log(chalk.yellow('Setup finished with failures.')); + console.log(chalk.dim('Run: switchbot codex repair')); } } if (preflightFailed) process.exit(2); diff --git a/src/install/codex-checks.ts b/src/install/codex-checks.ts index 6e3ad76c..c51ce77d 100644 --- a/src/install/codex-checks.ts +++ b/src/install/codex-checks.ts @@ -43,9 +43,26 @@ function readJsonObject(filePath: string): Record | null { } function resolveMarketplaceName(packageRoot: string): string { - const marketplacePath = path.join(packageRoot, '.agents', 'plugins', 'marketplace.json'); - if (fs.existsSync(marketplacePath)) { - const marketplace = readJsonObject(marketplacePath); + // 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); + 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) + const rootManifestPath = path.join(packageRoot, 'marketplace.json'); + if (fs.existsSync(rootManifestPath)) { + const manifest = readJsonObject(rootManifestPath); + if (typeof manifest?.name === 'string' && manifest.name) { + 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; } @@ -78,6 +95,22 @@ function computeAliasPath(): string { return path.join(os.homedir(), '.switchbot', 'codex-plugin-marketplace'); } +function createAlias(src: string, dest: string, type: fs.symlink.Type): void { + try { + fs.symlinkSync(src, dest, type); + } catch (err: unknown) { + const nodeErr = err as NodeJS.ErrnoException; + if (nodeErr.code === 'EPERM') { + throw new Error( + `Cannot create ${type} at ${dest}: permission denied (EPERM). ` + + `On Windows, run the installer from an elevated terminal, ` + + `or install to a path without @-scoped segments.`, + ); + } + throw err; + } +} + export function resolveMarketplaceSourceRoot(packageRoot: string): string { // Codex misclassifies local paths containing `@`-scoped npm segments // (e.g. `…/node_modules/@switchbot/codex-plugin`) as ref-bearing git sources, @@ -97,7 +130,7 @@ export function resolveMarketplaceSourceRoot(packageRoot: string): string { const stat = fs.lstatSync(aliasRoot, { throwIfNoEntry: false }); if (!stat) { - fs.symlinkSync(packageRoot, aliasRoot, linkType); + createAlias(packageRoot, aliasRoot, linkType); return aliasRoot; } @@ -111,7 +144,7 @@ export function resolveMarketplaceSourceRoot(packageRoot: string): string { // Dangling symlink: target was deleted (e.g. nvm switch, npm uninstall). // Recreate it pointing at the current packageRoot. fs.unlinkSync(aliasRoot); - fs.symlinkSync(packageRoot, aliasRoot, linkType); + createAlias(packageRoot, aliasRoot, linkType); return aliasRoot; } const pathsMatch = process.platform === 'win32' @@ -119,7 +152,7 @@ export function resolveMarketplaceSourceRoot(packageRoot: string): string { : aliasReal === packageReal; if (pathsMatch) return aliasRoot; fs.unlinkSync(aliasRoot); - fs.symlinkSync(packageRoot, aliasRoot, linkType); + createAlias(packageRoot, aliasRoot, linkType); return aliasRoot; } @@ -239,14 +272,22 @@ export function checkCodexPluginRegistered(): Check { export function runCodexPluginRegistration(packageRoot: string, pluginId: string): RegistrationResult { const marketplaceRoot = resolveMarketplaceSourceRoot(packageRoot); + // Pre-clean before marketplace add: removing the last plugin from a marketplace + // causes Codex to auto-delete the marketplace entry, so removes must happen + // before we register the new marketplace — not after. + for (const id of [...new Set([pluginId, ...CODEX_PLUGIN_LEGACY_IDS])]) { + spawnStr('codex', ['plugin', 'remove', id]); + } + // Also remove the marketplace records: when plugin remove deletes the last plugin, + // Codex removes the directory but leaves a stale DB record. The next `marketplace add` + // then says "already added" without recreating the directory, causing `plugin add` to fail. + for (const name of CODEX_MARKETPLACE_LEGACY_NAMES) { + spawnStr('codex', ['plugin', 'marketplace', 'remove', name]); + } const mkt = spawnStr('codex', ['plugin', 'marketplace', 'add', marketplaceRoot]); if (mkt.status !== 0) { return { ok: false, exitCode: mkt.status, stderr: mkt.stderr, stage: 'marketplace-add' }; } - // Remove current and legacy IDs; ignore exit codes (best-effort pre-clean). - for (const id of [pluginId, ...CODEX_PLUGIN_LEGACY_IDS]) { - spawnStr('codex', ['plugin', 'remove', id]); - } const add = spawnStr('codex', ['plugin', 'add', pluginId]); return { ok: add.status === 0, exitCode: add.status, stderr: add.stderr, stage: 'plugin-add' }; } @@ -288,11 +329,17 @@ 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_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@codex-plugin'; +export const CODEX_PLUGIN_DEFAULT_ID = 'switchbot@switchbot'; // Known IDs from pre-release installs; cleaned up by both Route A and Route B. -export const CODEX_PLUGIN_LEGACY_IDS = ['switchbot@switchbot-skill']; +export const CODEX_PLUGIN_LEGACY_IDS = ['switchbot@codex-plugin', 'switchbot@switchbot-skill']; +// Marketplace names derived from legacy IDs (pluginName@marketplaceName → marketplaceName). +// Removed before marketplace add to clear stale Codex internal state — when plugin remove +// deletes the last plugin in a marketplace Codex removes the directory but leaves a DB record, +// causing the next `marketplace add` to say "already added" without recreating the directory. +export const CODEX_MARKETPLACE_LEGACY_NAMES = ['switchbot', 'codex-plugin', 'switchbot-skill']; export function runCodexPluginRegistrationGit(pluginId: string): RegistrationResult { const ref = process.env['CODEX_GIT_MARKETPLACE_REF'] || CODEX_GIT_MARKETPLACE_REF; @@ -305,20 +352,33 @@ export function runCodexPluginRegistrationGit(pluginId: string): RegistrationRes ); } const timeout = _timeoutValid ? _parsedTimeout : 60000; + // Pre-clean before marketplace add: removing the last plugin from a marketplace + // causes Codex to auto-delete the marketplace entry, so removes must happen + // before we register the new marketplace — not after. + for (const id of [...new Set([pluginId, ...CODEX_PLUGIN_LEGACY_IDS])]) { + spawnStr('codex', ['plugin', 'remove', id]); + } + // Also remove the marketplace records to clear stale Codex DB state (see Route A counterpart). + for (const name of CODEX_MARKETPLACE_LEGACY_NAMES) { + spawnStr('codex', ['plugin', 'marketplace', 'remove', name]); + } // git clone via marketplace add can take >10 s on slow networks; use 60 s - const mkt = spawnStr('codex', [ + const mktArgs = [ 'plugin', 'marketplace', 'add', CODEX_GIT_MARKETPLACE_REPO, '--sparse', CODEX_GIT_MARKETPLACE_SPARSE, + '--sparse', CODEX_GIT_MARKETPLACE_SPARSE2, '--ref', ref, - ], timeout); + ]; + let mkt = spawnStr('codex', mktArgs, timeout); + // On Windows, git holds file handles briefly after clone; retry once after 10 s. + 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 (mkt.status !== 0) { return { ok: false, exitCode: mkt.status, stderr: mkt.stderr, stage: 'marketplace-add' }; } - // Pre-clean: remove current ID and any known legacy IDs; ignore exit codes - for (const id of [pluginId, ...CODEX_PLUGIN_LEGACY_IDS]) { - spawnStr('codex', ['plugin', 'remove', id]); - } const add = spawnStr('codex', ['plugin', 'add', pluginId]); return { ok: add.status === 0, exitCode: add.status, stderr: add.stderr, stage: 'plugin-add' }; } diff --git a/tests/commands/codex.test.ts b/tests/commands/codex.test.ts index de47bff8..53983269 100644 --- a/tests/commands/codex.test.ts +++ b/tests/commands/codex.test.ts @@ -382,19 +382,21 @@ describe('switchbot codex repair', () => { expect(removedIds).toContain('switchbot@switchbot-skill'); }); - it('remove-plugin falls back to default ID "switchbot@codex-plugin" when npm root -g fails', async () => { + it('remove-plugin falls back to default ID "switchbot@switchbot" when npm root -g fails', async () => { // verify-cli passes runDoctorChecksMock.mockResolvedValueOnce([ { name: 'node', status: 'ok', detail: 'ok' }, { name: 'path', status: 'ok', detail: 'ok' }, ]); - // remove-plugin: npm root -g fails → fallback to 'switchbot@codex-plugin' + // remove-plugin: npm root -g fails → fallback to 'switchbot@switchbot' spawnSyncRepairMock - .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'npm error' }) // npm root -g fails - .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // remove switchbot@codex-plugin - .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); // remove legacy id + .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'npm error' }) // npm root -g fails + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // remove switchbot@switchbot + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // remove switchbot@codex-plugin + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // remove switchbot@switchbot-skill + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); // extra buffer // register-plugin: ok - registerCodexPluginMock.mockReturnValueOnce({ ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: null }); + registerCodexPluginMock.mockReturnValueOnce({ ok: true, pluginId: 'switchbot@switchbot', packageRoot: null }); // doctor-verify runDoctorChecksMock.mockResolvedValueOnce(makeBaseChecks()); checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: 'ok' }); @@ -407,6 +409,7 @@ describe('switchbot codex repair', () => { (call) => (call[1] as string[]).includes('remove'), ); const removedIds = removeCalls.map((call) => (call[1] as string[])[2]); + expect(removedIds).toContain('switchbot@switchbot'); expect(removedIds).toContain('switchbot@codex-plugin'); expect(removedIds).toContain('switchbot@switchbot-skill'); }); diff --git a/tests/install/codex-checks.test.ts b/tests/install/codex-checks.test.ts index ede9be59..46639b10 100644 --- a/tests/install/codex-checks.test.ts +++ b/tests/install/codex-checks.test.ts @@ -42,6 +42,8 @@ import { registerCodexPlugin, registerCodexPluginGit, registerCodexPluginAuto, + CODEX_GIT_MARKETPLACE_SPARSE, + CODEX_GIT_MARKETPLACE_SPARSE2, } from '../../src/install/codex-checks.js'; function makeSpawnResult(status: number, stdout: string, stderr = ''): ReturnType { @@ -164,9 +166,13 @@ describe('checkCodexPluginRegistered', () => { describe('runCodexPluginRegistration', () => { it('returns ok when both marketplace add and plugin add succeed', () => { spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id: switchbot@pkg) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id 1: switchbot@codex-plugin) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id 2: switchbot@switchbot-skill) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove (switchbot) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove (codex-plugin) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove (switchbot-skill) .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const result = runCodexPluginRegistration('/some/path', 'switchbot@pkg'); expect(result.ok).toBe(true); @@ -174,7 +180,14 @@ describe('runCodexPluginRegistration', () => { }); it('returns failure when marketplace add exits non-zero', () => { - spawnSyncMock.mockReturnValueOnce(makeSpawnResult(1, '', 'marketplace error')); + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id: switchbot@pkg) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id 1: switchbot@codex-plugin) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id 2: switchbot@switchbot-skill) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'marketplace error')); const result = runCodexPluginRegistration('/some/path', 'switchbot@pkg'); expect(result.ok).toBe(false); expect(result.stderr).toBe('marketplace error'); @@ -183,9 +196,13 @@ describe('runCodexPluginRegistration', () => { it('returns failure when plugin add exits non-zero', () => { spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id: switchbot@pkg) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id 1: switchbot@codex-plugin) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id 2: switchbot@switchbot-skill) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 .mockReturnValueOnce(makeSpawnResult(0, '')) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add .mockReturnValueOnce(makeSpawnResult(1, '', 'plugin add error')); const result = runCodexPluginRegistration('/some/path', 'switchbot@pkg'); expect(result.ok).toBe(false); @@ -199,10 +216,13 @@ describe('registerCodexPlugin (shared helper)', () => { existsSyncMock.mockReturnValue(false); // no .codex-plugin/plugin.json spawnSyncMock .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')) // npm root -g - .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) - .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id: switchbot@codex-plugin) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id: switchbot@switchbot-skill) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const r = registerCodexPlugin(); expect(r.ok).toBe(true); if (r.ok) { @@ -224,6 +244,11 @@ describe('registerCodexPlugin (shared helper)', () => { existsSyncMock.mockReturnValue(false); spawnSyncMock .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')) // npm root -g + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) .mockReturnValueOnce(makeSpawnResult(1, '', 'marketplace add error')); // marketplace add const r = registerCodexPlugin(); expect(r.ok).toBe(false); @@ -434,9 +459,12 @@ describe('resolveMarketplaceSourceRoot — Linux @-scoped path handling', () => describe('runCodexPluginRegistrationGit', () => { it('returns ok when marketplace add and plugin add both succeed', () => { spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id: switchbot@codex-plugin) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id: switchbot@switchbot-skill) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (git) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const r = runCodexPluginRegistrationGit('switchbot@codex-plugin'); expect(r.ok).toBe(true); @@ -444,7 +472,13 @@ describe('runCodexPluginRegistrationGit', () => { }); it('returns failure when marketplace add exits non-zero', () => { - spawnSyncMock.mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')); + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id: switchbot@codex-plugin) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id: switchbot@switchbot-skill) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')); const r = runCodexPluginRegistrationGit('switchbot@codex-plugin'); expect(r.ok).toBe(false); expect(r.stderr).toBe('git clone failed'); @@ -453,9 +487,12 @@ describe('runCodexPluginRegistrationGit', () => { it('returns failure when plugin add exits non-zero', () => { spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id: switchbot@codex-plugin) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id: switchbot@switchbot-skill) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) .mockReturnValueOnce(makeSpawnResult(1, '', 'plugin add error')); const r = runCodexPluginRegistrationGit('switchbot@codex-plugin'); expect(r.ok).toBe(false); @@ -467,7 +504,13 @@ describe('runCodexPluginRegistrationGit', () => { const origEnv = process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']; process.env['CODEX_MARKETPLACE_ADD_TIMEOUT'] = '0'; const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - spawnSyncMock.mockReturnValueOnce(makeSpawnResult(1, '', 'fail')); + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'fail')); runCodexPluginRegistrationGit('switchbot@codex-plugin'); expect(spy).toHaveBeenCalledWith(expect.stringContaining('CODEX_MARKETPLACE_ADD_TIMEOUT')); spy.mockRestore(); @@ -479,7 +522,13 @@ describe('runCodexPluginRegistrationGit', () => { const origEnv = process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']; delete process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']; const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - spawnSyncMock.mockReturnValueOnce(makeSpawnResult(1, '', 'fail')); + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'fail')); runCodexPluginRegistrationGit('switchbot@codex-plugin'); expect(spy).not.toHaveBeenCalled(); spy.mockRestore(); @@ -490,7 +539,13 @@ describe('runCodexPluginRegistrationGit', () => { const origEnv = process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']; process.env['CODEX_MARKETPLACE_ADD_TIMEOUT'] = ''; const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - spawnSyncMock.mockReturnValueOnce(makeSpawnResult(1, '', 'fail')); + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'fail')); runCodexPluginRegistrationGit('switchbot@codex-plugin'); expect(spy).toHaveBeenCalledWith(expect.stringContaining('CODEX_MARKETPLACE_ADD_TIMEOUT')); spy.mockRestore(); @@ -502,13 +557,16 @@ describe('runCodexPluginRegistrationGit', () => { const origEnv = process.env['CODEX_GIT_MARKETPLACE_REF']; process.env['CODEX_GIT_MARKETPLACE_REF'] = ''; spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add runCodexPluginRegistrationGit('switchbot@codex-plugin'); const calls = spawnSyncMock.mock.calls as [string, string[]][]; - const mktCall = calls.find(([cmd, args]) => cmd === 'codex' && args.includes('marketplace')); + const mktCall = calls.find(([cmd, args]) => cmd === 'codex' && args.includes('marketplace') && args.includes('add')); const refIdx = mktCall?.[1].indexOf('--ref') ?? -1; expect(refIdx).toBeGreaterThan(-1); expect(mktCall?.[1][refIdx + 1]).toBe('main'); @@ -520,27 +578,81 @@ describe('runCodexPluginRegistrationGit', () => { const origEnv = process.env['CODEX_GIT_MARKETPLACE_REF']; process.env['CODEX_GIT_MARKETPLACE_REF'] = 'feat/my-branch'; spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add runCodexPluginRegistrationGit('switchbot@codex-plugin'); const calls = spawnSyncMock.mock.calls as [string, string[]][]; - const mktCall = calls.find(([cmd, args]) => cmd === 'codex' && args.includes('marketplace')); + const mktCall = calls.find(([cmd, args]) => cmd === 'codex' && args.includes('marketplace') && args.includes('add')); const refIdx = mktCall?.[1].indexOf('--ref') ?? -1; expect(refIdx).toBeGreaterThan(-1); expect(mktCall?.[1][refIdx + 1]).toBe('feat/my-branch'); if (origEnv === undefined) delete process.env['CODEX_GIT_MARKETPLACE_REF']; else process.env['CODEX_GIT_MARKETPLACE_REF'] = origEnv; }); + + it('passes both --sparse flags (packages/codex-plugin and .claude-plugin) to marketplace add', () => { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id: switchbot@switchbot) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id: switchbot@codex-plugin) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id: switchbot@switchbot-skill) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (git) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + runCodexPluginRegistrationGit('switchbot@switchbot'); + const calls = spawnSyncMock.mock.calls as [string, string[]][]; + 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(mktArgs[sparseIndices[0] + 1]).toBe(CODEX_GIT_MARKETPLACE_SPARSE); + expect(mktArgs[sparseIndices[1] + 1]).toBe(CODEX_GIT_MARKETPLACE_SPARSE2); + }); }); describe('registerCodexPluginAuto', () => { + // Helper: 3 plugin removes + 3 marketplace removes for Route B pre-clean + function routeBPreClean() { + return [ + makeSpawnResult(0, ''), // plugin remove (switchbot@switchbot) + makeSpawnResult(0, ''), // plugin remove (switchbot@codex-plugin) + makeSpawnResult(0, ''), // plugin remove (switchbot@switchbot-skill) + makeSpawnResult(0, ''), // marketplace remove (switchbot) + makeSpawnResult(0, ''), // marketplace remove (codex-plugin) + makeSpawnResult(0, ''), // marketplace remove (switchbot-skill) + ] as const; + } + // Helper: 2 plugin removes + 3 marketplace removes for Route A pre-clean + // (pluginId=switchbot@codex-plugin deduplicates with LEGACY_IDS[0] → 2 plugin removes) + function routeAPreClean() { + return [ + makeSpawnResult(0, ''), // plugin remove (switchbot@codex-plugin) + makeSpawnResult(0, ''), // plugin remove (switchbot@switchbot-skill) + makeSpawnResult(0, ''), // marketplace remove (switchbot) + makeSpawnResult(0, ''), // marketplace remove (codex-plugin) + makeSpawnResult(0, ''), // marketplace remove (switchbot-skill) + ] as const; + } + it('returns git result when Route B succeeds', () => { spawnSyncMock + .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) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (git) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const r = registerCodexPluginAuto(); expect(r.ok).toBe(true); @@ -550,12 +662,21 @@ describe('registerCodexPluginAuto', () => { it('falls back to local npm path when Route B fails', () => { existsSyncMock.mockReturnValue(true); spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // marketplace add (git) — fails - .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n', '')) // npm root -g - .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) - .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B: marketplace add — fails + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')) // npm root -g (Route A) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route A: plugin remove ×2 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route A: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route A: marketplace add (local) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const r = registerCodexPluginAuto(); expect(r.ok).toBe(true); expect(r.packageRoot).toMatch(/codex-plugin/); @@ -565,16 +686,25 @@ describe('registerCodexPluginAuto', () => { existsSyncMock.mockReturnValue(true); const installedJson = JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '1.0.0' } } }); spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // marketplace add (git) — fails - .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // npm root -g fails (Route A) - .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list -g: not installed - .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g: succeeds - .mockReturnValueOnce(makeSpawnResult(0, installedJson, '')) // post-install npm list: package found - .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n', '')) // npm root -g (retry) - .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) - .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B: marketplace add — fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // npm root -g fails (Route A initial) + .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list -g: not installed + .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g: succeeds + .mockReturnValueOnce(makeSpawnResult(0, installedJson, '')) // post-install npm list: package found + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')) // npm root -g (retry) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route A retry: plugin remove ×2 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route A retry: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const r = registerCodexPluginAuto(); expect(r.ok).toBe(true); expect(r.packageRoot).toMatch(/codex-plugin/); @@ -582,7 +712,13 @@ describe('registerCodexPluginAuto', () => { it('returns failure when Route B fails, Route A fails, and on-demand install also fails', () => { spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // marketplace add (git) — fails + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B: marketplace add — fails .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // npm root -g fails (Route A) .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list -g: not installed .mockReturnValueOnce(makeSpawnResult(1, '', 'EACCES')); // npm install -g: fails @@ -596,7 +732,13 @@ 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, '', 'git clone failed')) // marketplace add (git) — fails + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B: marketplace add — fails .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // npm root -g fails (Route A) .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list -g: not installed .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g: succeeds @@ -614,33 +756,51 @@ describe('registerCodexPluginAuto', () => { '{"dependencies":{"@switchbot/codex-plugin":{"version":"1.0.0"}}}'; existsSyncMock.mockReturnValue(true); spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A fails .mockReturnValueOnce(makeSpawnResult(0, npmListWithWarnings, '')) // npm list → found .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')) // npm root -g retry + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route A retry: plugin remove ×2 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route A retry: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const r = registerCodexPluginAuto(); - // npm install -g must NOT have been called const calls = spawnSyncMock.mock.calls as [string, string[]][]; const installCall = calls.find(([cmd, args]) => cmd === 'npm' && args.includes('install')); expect(installCall).toBeUndefined(); expect(r.ok).toBe(true); }); + it('skips npm install when npm list exits non-zero but JSON output shows package is installed', () => { const installedJson = JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '1.0.0' } } }); existsSyncMock.mockReturnValue(true); spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails - .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A fails + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A fails .mockReturnValueOnce(makeSpawnResult(1, installedJson, 'peer-dep warning')) // npm list exits 1 but JSON shows package - .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')) // npm root -g retry - .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) - .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')) // npm root -g retry + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route A retry: plugin remove ×2 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route A retry: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const r = registerCodexPluginAuto(); const calls = spawnSyncMock.mock.calls as [string, string[]][]; const installCall = calls.find(([cmd, args]) => cmd === 'npm' && args.includes('install')); @@ -650,11 +810,17 @@ describe('registerCodexPluginAuto', () => { it('returns npm-prefix-mismatch error when post-install npm list still shows package absent', () => { spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails - .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A fails - .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list: not installed - .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g: succeeds - .mockReturnValueOnce(makeSpawnResult(1, '{}', 'peer-dep-warning')); // post-install npm list: still absent + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A fails + .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list: not installed + .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g: succeeds + .mockReturnValueOnce(makeSpawnResult(1, '{}', 'peer-dep-warning')); // post-install npm list: still absent const r = registerCodexPluginAuto(); expect(r.ok).toBe(false); expect(r.error).toMatch(/npm prefix mismatch/i); @@ -663,10 +829,16 @@ 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, '', 'git clone failed')) // Route B fails - .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A: npm root -g fails - .mockReturnValueOnce(makeSpawnResult(0, installedJson, '')) // npm list: ALREADY installed (no install ran) - .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error 2')); // npm root -g fails (retry) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A: npm root -g fails + .mockReturnValueOnce(makeSpawnResult(0, installedJson, '')) // npm list: ALREADY installed (no install ran) + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error 2')); // npm root -g fails (retry) const r = registerCodexPluginAuto(); expect(r.ok).toBe(false); expect(r.error).not.toMatch(/installed @switchbot/i); @@ -675,10 +847,16 @@ describe('registerCodexPluginAuto', () => { it('returns error when post-install verify spawnSync times out (status null)', () => { spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails - .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A: npm root -g fails - .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list: not installed - .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g: succeeds + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A: npm root -g fails + .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list: not installed + .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g: succeeds .mockReturnValueOnce({ status: null, stdout: null, stderr: '', signal: 'SIGTERM' }); // verify times out const r = registerCodexPluginAuto(); expect(r.ok).toBe(false); @@ -689,16 +867,25 @@ describe('registerCodexPluginAuto', () => { const installedJson = JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '1.0.0' } } }); existsSyncMock.mockReturnValue(true); spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails - .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A: npm root -g fails + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A: npm root -g fails .mockReturnValueOnce(makeSpawnResult(0, 'npm warn something\nnot-json', '')) // npm list: invalid JSON → fall through - .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g succeeds - .mockReturnValueOnce(makeSpawnResult(0, installedJson, '')) // post-install npm list: ok - .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n', '')) // npm root -g retry - .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) - .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g succeeds + .mockReturnValueOnce(makeSpawnResult(0, installedJson, '')) // post-install npm list: ok + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')) // npm root -g retry + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route A retry: plugin remove ×2 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route A retry: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const r = registerCodexPluginAuto(); const calls = spawnSyncMock.mock.calls as [string, string[]][]; const installCall = calls.find(([cmd, args]) => cmd === 'npm' && args.includes('install')); @@ -709,16 +896,25 @@ describe('registerCodexPluginAuto', () => { it('proceeds to Route A retry when post-install verify stdout is unparseable (inconclusive)', () => { existsSyncMock.mockReturnValue(true); spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails - .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A: npm root -g fails - .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list: not installed - .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g succeeds - .mockReturnValueOnce(makeSpawnResult(0, 'garbage\n', '')) // post-install verify: invalid JSON - .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n', '')) // npm root -g retry - .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) - .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A: npm root -g fails + .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list: not installed + .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g succeeds + .mockReturnValueOnce(makeSpawnResult(0, 'garbage\n', '')) // post-install verify: invalid JSON + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')) // npm root -g retry + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route A retry: plugin remove ×2 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route A retry: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const r = registerCodexPluginAuto(); expect(r.ok).toBe(true); }); @@ -737,19 +933,29 @@ describe('stepRegisterCodexPlugin', () => { it('sets codexPluginRegistered and codexPluginIdentifier on success', async () => { spawnSyncMock + .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) + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // marketplace remove ×3 + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // marketplace add (git) - .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // plugin remove (current id) - .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // plugin remove (legacy id) .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); // plugin add const step = stepRegisterCodexPlugin(); const ctx = makeCtx(); await step.execute(ctx); expect(ctx.codexPluginRegistered).toBe(true); - expect(ctx.codexPluginIdentifier).toBe('switchbot@codex-plugin'); + expect(ctx.codexPluginIdentifier).toBe('switchbot@switchbot'); }); it('throws when runCodexPluginRegistration fails', async () => { spawnSyncMock + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // plugin remove (Route B pre-clean ×3) + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // marketplace remove ×3 + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'marketplace error' }) // marketplace add (git) — Route B fails .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'npm root error' }) // npm root -g — Route A fails .mockReturnValueOnce({ status: 1, stdout: '{}', stderr: '' }) // npm list -g: not installed