diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6cc358fb..849294d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,7 @@ jobs: exit 1 fi - run: npm test + - run: npm run test:workspaces bundle-smoke: name: esbuild bundle smoke test (Node ${{ matrix.node-version }}) @@ -145,34 +146,3 @@ jobs: - run: npm run build - name: npm pack -> npm install tarball -> switchbot --version / policy new / policy validate run: npm run smoke:pack-install - - policy-schema-sync: - name: Policy schema sync with skill repo - runs-on: ubuntu-latest - needs: test - steps: - - uses: actions/checkout@v4 - - name: Fetch skill repo's mirrored schema - run: | - HTTP=$(curl -o /tmp/skill-policy.schema.json -w "%{http_code}" -fsSL --retry 3 \ - https://raw.githubusercontent.com/OpenWonderLabs/openclaw-switchbot-skill/main/examples/policy.schema.json \ - 2>/dev/null || echo "000") - if [ "$HTTP" = "404" ] || [ "$HTTP" = "000" ]; then - echo "SKIP: skill repo schema not yet published (HTTP $HTTP). Skipping drift check." - exit 0 - fi - if [ "$HTTP" != "200" ]; then - echo "WARN: unexpected HTTP $HTTP fetching skill schema. Skipping drift check." - exit 0 - fi - echo "Fetched skill schema (HTTP $HTTP). Diffing against CLI v0.2 source of truth..." - if ! diff -u /tmp/skill-policy.schema.json src/policy/schema/v0.2.json; then - echo "" - echo "FAIL: policy schema drift detected." - echo " CLI source: src/policy/schema/v0.2.json" - echo " Skill copy: https://github.com/OpenWonderLabs/openclaw-switchbot-skill/blob/main/examples/policy.schema.json" - echo "" - echo "Sync the skill's examples/policy.schema.json from the CLI file and cut a matching skill release." - exit 1 - fi - echo "OK: policy schema matches skill repo." diff --git a/.github/workflows/npm-published-smoke.yml b/.github/workflows/npm-published-smoke.yml index 6c0668f9..13ddabef 100644 --- a/.github/workflows/npm-published-smoke.yml +++ b/.github/workflows/npm-published-smoke.yml @@ -6,6 +6,9 @@ on: types: [completed] workflow_dispatch: inputs: + package: + description: 'Package to verify (defaults: matrix runs all published packages)' + required: false version: description: 'Published npm version to verify (defaults to package.json from checked-out commit)' required: false @@ -19,6 +22,16 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + strategy: + fail-fast: false + matrix: + include: + - package: '@switchbot/openapi-cli' + version_path: 'package.json' + kind: cli + - package: '@switchbot/codex-plugin' + version_path: 'packages/codex-plugin/package.json' + kind: plugin steps: - uses: actions/checkout@v4 with: @@ -29,81 +42,110 @@ jobs: node-version: 20.x registry-url: https://registry.npmjs.org - - name: Verify credentials present + - name: Verify npm token present env: - TOKEN: ${{ secrets.SWITCHBOT_TOKEN }} - SECRET: ${{ secrets.SWITCHBOT_SECRET }} + TOKEN: ${{ secrets.NPM_TOKEN }} run: | - if [ -z "$TOKEN" ] || [ -z "$SECRET" ]; then - echo "SWITCHBOT_TOKEN / SWITCHBOT_SECRET not set in repo secrets" + if [ -z "$TOKEN" ]; then + echo "NPM_TOKEN not set in repo secrets" exit 1 fi - - name: Verify npm token present + - name: Verify SwitchBot credentials present (cli only) + if: matrix.kind == 'cli' env: - TOKEN: ${{ secrets.NPM_TOKEN }} + TOKEN: ${{ secrets.SWITCHBOT_TOKEN }} + SECRET: ${{ secrets.SWITCHBOT_SECRET }} run: | - if [ -z "$TOKEN" ]; then - echo "NPM_TOKEN not set in repo secrets" + if [ -z "$TOKEN" ] || [ -z "$SECRET" ]; then + echo "SWITCHBOT_TOKEN / SWITCHBOT_SECRET not set in repo secrets (required for cli live smoke)" exit 1 fi - name: Resolve target version id: version + env: + VERSION_PATH: ${{ matrix.version_path }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_PACKAGE: ${{ inputs.package }} + MATRIX_PACKAGE: ${{ matrix.package }} run: | - if [ -n "${{ inputs.version }}" ]; then - VERSION="${{ inputs.version }}" + # workflow_dispatch with explicit package: skip other matrix entries. + if [ -n "$INPUT_PACKAGE" ] && [ "$INPUT_PACKAGE" != "$MATRIX_PACKAGE" ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Skipping $MATRIX_PACKAGE — workflow_dispatch targeted $INPUT_PACKAGE" + exit 0 + fi + if [ -n "$INPUT_VERSION" ] && [ -n "$INPUT_PACKAGE" ]; then + VERSION="$INPUT_VERSION" else - VERSION=$(node -p "require('./package.json').version") + VERSION=$(node -p "require('./$VERSION_PATH').version") fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "target_version=$VERSION" + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "target_version=$VERSION ($MATRIX_PACKAGE)" - name: Resolve current latest dist-tag + if: steps.version.outputs.skip != 'true' id: latest + env: + PACKAGE: ${{ matrix.package }} run: | - LATEST=$(npm view @switchbot/openapi-cli dist-tags.latest) + LATEST=$(npm view "$PACKAGE" dist-tags.latest 2>/dev/null || echo "") echo "version=$LATEST" >> "$GITHUB_OUTPUT" echo "current_latest=$LATEST" - name: Wait for npm package to become available + if: steps.version.outputs.skip != 'true' id: wait_package env: VERSION: ${{ steps.version.outputs.version }} + PACKAGE: ${{ matrix.package }} run: | for i in $(seq 1 24); do if [ "${{ github.event_name }}" = "workflow_run" ]; then - FOUND=$(npm view "@switchbot/openapi-cli@next" version 2>/dev/null || true) + FOUND=$(npm view "${PACKAGE}@next" version 2>/dev/null || true) if [ "$FOUND" = "$VERSION" ]; then echo "npm package is available on next: $FOUND" exit 0 fi - echo "waiting for @switchbot/openapi-cli@$VERSION to appear on npm dist-tag next ($i/24); current next=$FOUND" + # Tolerate the case where this matrix entry's package wasn't republished + # in this release (publish.yml's detect-versions step skipped it). + FOUND_AT_VERSION=$(npm view "${PACKAGE}@${VERSION}" version 2>/dev/null || true) + if [ "$FOUND_AT_VERSION" = "$VERSION" ]; then + echo "npm package version already exists (not republished this release): $FOUND_AT_VERSION — skipping smoke" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "waiting for ${PACKAGE}@${VERSION} on npm dist-tag next ($i/24); current next=$FOUND" else - FOUND=$(npm view "@switchbot/openapi-cli@$VERSION" version 2>/dev/null || true) + FOUND=$(npm view "${PACKAGE}@${VERSION}" version 2>/dev/null || true) if [ "$FOUND" = "$VERSION" ]; then echo "npm package version is available: $FOUND" exit 0 fi - echo "waiting for @switchbot/openapi-cli@$VERSION to appear on npm ($i/24)" + echo "waiting for ${PACKAGE}@${VERSION} to appear on npm ($i/24)" fi sleep 10 done - echo "Timed out waiting for @switchbot/openapi-cli@$VERSION on npm" + echo "Timed out waiting for ${PACKAGE}@${VERSION} on npm" exit 1 - name: Install published package in a clean temp project + if: steps.version.outputs.skip != 'true' && steps.wait_package.outputs.skip != 'true' id: install_package env: VERSION: ${{ steps.version.outputs.version }} + PACKAGE: ${{ matrix.package }} run: | TMPDIR=$(mktemp -d) echo "TMPDIR=$TMPDIR" >> "$GITHUB_ENV" cd "$TMPDIR" npm init -y >/dev/null 2>&1 - npm install "@switchbot/openapi-cli@$VERSION" + npm install "${PACKAGE}@${VERSION}" - - name: Binary and offline smoke + - name: Binary and offline smoke (cli only) + if: matrix.kind == 'cli' && steps.version.outputs.skip != 'true' && steps.wait_package.outputs.skip != 'true' id: offline_smoke env: TMPDIR: ${{ env.TMPDIR }} @@ -116,7 +158,8 @@ jobs: npx --no-install switchbot schema export --compact >/dev/null npx --no-install switchbot capabilities --json | jq -e '.data.commandMeta != null' >/dev/null - - name: Live smoke with configured credentials + - name: Live smoke with configured credentials (cli only) + if: matrix.kind == 'cli' && steps.version.outputs.skip != 'true' && steps.wait_package.outputs.skip != 'true' id: live_smoke env: TMPDIR: ${{ env.TMPDIR }} @@ -127,30 +170,74 @@ jobs: npx --no-install switchbot doctor --json | jq -e '.data.summary != null' >/dev/null npx --no-install switchbot devices list --json | jq -e '.data.deviceList != null or .data.infraredRemoteList != null' >/dev/null + - name: Plugin tarball-shape smoke (plugins only) + if: matrix.kind == 'plugin' && steps.version.outputs.skip != 'true' && steps.wait_package.outputs.skip != 'true' + id: plugin_smoke + env: + TMPDIR: ${{ env.TMPDIR }} + PACKAGE: ${{ matrix.package }} + VERSION: ${{ steps.version.outputs.version }} + run: | + cd "$TMPDIR" + MANIFEST="node_modules/${PACKAGE}/package.json" + if [ ! -f "$MANIFEST" ]; then + echo "FAIL: $MANIFEST missing" + exit 1 + fi + # peerDep must be a concrete range, not a workspace:* leak + PEER=$(node -p "require('./$MANIFEST').peerDependencies?.['@switchbot/openapi-cli'] || ''") + if [ -z "$PEER" ] || echo "$PEER" | grep -q "workspace:"; then + echo "FAIL: $PACKAGE peerDep missing or unrewritten — got: '$PEER'" + exit 1 + fi + echo "OK: $PACKAGE peerDep = '$PEER'" + # All declared bin entries must point to readable files + node -e " + const pkg = require('./$MANIFEST'); + const fs = require('fs'); + const path = require('path'); + const root = path.dirname('$MANIFEST'); + const bins = pkg.bin || {}; + for (const [name, target] of Object.entries(bins)) { + const p = path.join(root, target); + fs.accessSync(p); + console.log('OK: bin ' + name + ' -> ' + target); + } + " + - name: Promote verified version to latest - if: success() + if: success() && steps.version.outputs.skip != 'true' && steps.wait_package.outputs.skip != 'true' env: VERSION: ${{ steps.version.outputs.version }} + PACKAGE: ${{ matrix.package }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - npm dist-tag add "@switchbot/openapi-cli@$VERSION" latest - echo "Promoted @switchbot/openapi-cli@$VERSION to dist-tag latest" + npm dist-tag add "${PACKAGE}@${VERSION}" latest + echo "Promoted ${PACKAGE}@${VERSION} to dist-tag latest" - name: Deprecate failed version if: > failure() && steps.wait_package.outcome == 'success' && + steps.wait_package.outputs.skip != 'true' && ( steps.install_package.outcome == 'failure' || - steps.offline_smoke.outcome == 'failure' + steps.offline_smoke.outcome == 'failure' || + steps.plugin_smoke.outcome == 'failure' ) env: VERSION: ${{ steps.version.outputs.version }} PREVIOUS_LATEST: ${{ steps.latest.outputs.version }} + PACKAGE: ${{ matrix.package }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - npm deprecate "@switchbot/openapi-cli@$VERSION" "Published to dist-tag next but failed package smoke tests. Install @switchbot/openapi-cli@${PREVIOUS_LATEST} or use dist-tag latest." - echo "Deprecated @switchbot/openapi-cli@$VERSION after package smoke failure" + if [ -n "$PREVIOUS_LATEST" ]; then + MSG="Published to dist-tag next but failed package smoke tests. Install ${PACKAGE}@${PREVIOUS_LATEST} or use dist-tag latest." + else + MSG="Published to dist-tag next but failed package smoke tests. No prior latest exists; investigate before re-publishing." + fi + npm deprecate "${PACKAGE}@${VERSION}" "$MSG" + echo "Deprecated ${PACKAGE}@${VERSION} after package smoke failure" - name: Cleanup temp project if: always() diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1a68cbd6..983d2263 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,17 +20,115 @@ jobs: - run: npm ci - run: npm run build - run: npm test - - name: Verify tag matches package.json version + - run: npm run test:workspaces + + - name: Verify tag matches root CLI package.json version run: | PKG_VERSION=$(node -p "require('./package.json').version") TAG_VERSION="${GITHUB_REF_NAME#v}" if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then - echo "Tag $TAG_VERSION does not match package.json version $PKG_VERSION" + echo "Tag $TAG_VERSION does not match root package.json version $PKG_VERSION" + echo "Plugin versions are independent and read from packages/*/package.json directly." exit 1 fi - - name: Smoke test packed npm artifact + + - name: Show resolved versions + 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 skill: @switchbot/openclaw-skill@$OPENCLAW_VERSION" + { + echo "cli_version=$CLI_VERSION" + echo "codex_version=$CODEX_VERSION" + echo "openclaw_version=$OPENCLAW_VERSION" + } >> "$GITHUB_OUTPUT" + id: versions + + - name: Detect which packages need publishing + id: detect + 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() { + local name="$1" + local version="$2" + local found + found=$(npm view "${name}@${version}" version 2>/dev/null || true) + if [ "$found" = "$version" ]; then + echo "false" + else + echo "true" + fi + } + 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: Publish package to npm dist-tag next + + - 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: | + TARBALL=$(npm pack -w @switchbot/codex-plugin --pack-destination /tmp/ 2>&1 | tail -1) + PEER=$(tar -xOzf "/tmp/$TARBALL" package/package.json | node -e " + const p = JSON.parse(require('fs').readFileSync(0, 'utf8')); + console.log(p.peerDependencies?.['@switchbot/openapi-cli'] || ''); + ") + if [ -z "$PEER" ] || echo "$PEER" | grep -q "workspace:"; then + echo "FAIL: codex-plugin peerDep is missing or unrewritten workspace:* — got: '$PEER'" + exit 1 + fi + echo "OK: codex-plugin peerDep = '$PEER'" + rm -f "/tmp/$TARBALL" + + - name: Publish root CLI to npm dist-tag next + if: steps.detect.outputs.cli_publish == 'true' run: npm publish --tag next --provenance --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Publish codex-plugin to npm dist-tag next + id: publish_codex + if: steps.detect.outputs.codex_publish == 'true' + continue-on-error: true + run: npm publish -w @switchbot/codex-plugin --tag next --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: Publish openclaw-skill to npm dist-tag next + id: publish_openclaw + if: steps.detect.outputs.openclaw_publish == 'true' + continue-on-error: true + run: npm publish -w @switchbot/openclaw-skill --tag next --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Annotate openclaw-skill 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 9b4bebd4..57ae751b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ All notable changes to `@switchbot/openapi-cli` are documented in this file. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Monorepo absorption** — `@switchbot/codex-plugin` (formerly `@cly-org/switchbot-codex-plugin`) now ships from this repository under `packages/codex-plugin/`. A single GitHub Release publishes any package whose version was bumped since the previous release. +- **Codex command group** — `switchbot codex setup`, `switchbot codex doctor`, `switchbot codex repair` orchestrate Codex plugin install, the 7-check health summary, and end-to-end repair. `switchbot install --agent codex` is the register-only sibling. +- **`codex setup` 6-step flow** — adds `install-codex-plugin` between `install-switchbot-cli` and `register-plugin`, so a single `npx @switchbot/openapi-cli codex setup` invocation can bootstrap a brand-new machine end-to-end without a separate `npm install -g @switchbot/codex-plugin`. Both install steps are skippable via `--skip`. + +### Changed + +- **Plugin package name** — `@cly-org/switchbot-codex-plugin` → `@switchbot/codex-plugin`. The old name was verification-stage with no published users; no `npm deprecate` notice is necessary. +- **Codex plugin `onInstall` hook** — now best-effort: it always exits 0 so a missing or broken SwitchBot CLI never rolls back the Codex plugin install. When the CLI is present it runs `switchbot codex setup --yes` to fast-path setup; when absent it prints a hint pointing at `npx @switchbot/openapi-cli codex setup`. +- **Plugin version reset to `0.1.0`** for first publish under the new scope. +- **Publish workflow** — `.github/workflows/publish.yml` gains a `detect-versions` step and per-package guards so plugin failures (or unbumped versions) do not block CLI promotion. Plugin steps run `continue-on-error: true` and surface failures as workflow annotations. +- **Smoke workflow** — `.github/workflows/npm-published-smoke.yml` is now a per-package matrix. CLI keeps offline + live smoke; plugins get tarball-shape checks (concrete peerDep, executable bin entries) without live smoke. +- **CI** — `policy-schema-sync` cross-repo job removed; the skill consumer is now in this monorepo. + +### Fixed + +- **Codex `register-plugin` on Windows / npm scoped install dirs** — `codex plugin marketplace add` from `/@switchbot/codex-plugin` failed with `--ref is only supported for git marketplace sources` because codex CLI 0.133.0 misparses local paths containing `@` as `owner/repo@ref`. Registration now bridges via a junction at `%LOCALAPPDATA%\switchbot\codex-plugin-marketplace` (fallback `~/.switchbot/codex-plugin-marketplace`); divergent junctions are repaired in place, and any other state at the alias path surfaces an error instead of silently registering the broken `@` path. +- **Codex plugin marketplace metadata** — `packages/codex-plugin/.agents/plugins/marketplace.json` was missing from the published tarball (file untracked + omitted from `files`). Now committed and listed under `files`, with the local-source path corrected to `../../` so `codex plugin add switchbot@codex-plugin` resolves the plugin manifest at `packageRoot/.codex-plugin/plugin.json`. + +### Removed + +- Sibling repository `openclaw-switchbot-skill` is archived; future development happens here. + ## [3.7.1] ### Fixed diff --git a/README.md b/README.md index 56508407..ac8aaf47 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Under the hood every surface shares the same catalog, cache, and HMAC client — ## Table of contents - [Features](#features) · [Supported devices](#supported-devices) · [Requirements](#requirements) · [Installation](#installation) -- [Quick start](#quick-start) +- [Quick start](#quick-start) · [Codex integration](#codex-integration) - [Credentials](#credentials) - [Policy](#policy) · [Rules engine](#rules-engine) - [Global options](#global-options) @@ -119,6 +119,8 @@ switchbot --help ## Quick start +> **Using Codex?** Skip this section and jump to [Codex integration](#codex-integration) — Codex uses a separate plugin package, not the `--skill-path` link below. + The fast path (credentials + policy + skill link, with rollback on failure): ```bash @@ -167,6 +169,75 @@ See [Policy](#policy) for the authoring flow, [Rules engine](#rules-engine) for automations, and [`docs/agent-guide.md`](./docs/agent-guide.md) for the agent surface. +## Codex integration + +For [OpenAI Codex CLI](https://github.com/openai/codex) users, the SwitchBot integration ships as a separate plugin package — `@switchbot/codex-plugin` — registered via `codex plugin add`. The `switchbot codex` command group orchestrates setup, health checks, and repair end-to-end. + +### Prerequisites + +- **Codex CLI** installed and on `$PATH` (see the link above) +- **Node.js ≥ 18** (same as the base CLI) + +### One-command bootstrap (recommended) + +On a brand-new machine — no SwitchBot CLI installed yet — use `npx`: + +```bash +npx @switchbot/openapi-cli codex setup +``` + +This works because `setup` runs six steps in order, the package-install steps fix the chicken-and-egg: + +1. `check-codex-cli` — verify `codex` is on `$PATH` +2. `install-switchbot-cli` — if `@switchbot/openapi-cli` is not in `npm list -g`, install it globally (so the `switchbot` binary stays after the `npx` invocation exits) +3. `install-codex-plugin` — if `@switchbot/codex-plugin` is not in `npm list -g`, install it globally +4. `register-plugin` — `codex plugin marketplace add` + `codex plugin add` for `@switchbot/codex-plugin` +5. `auth` — prompt for SwitchBot credentials if missing (spawns `switchbot auth login`, inheriting your active `--profile` and `--config`) +6. `doctor-verify` — run 7 checks (4 base: node, path, credentials, mcp + 3 codex: cli, npm package, plugin registered) + +Restart Codex when complete, then verify: + +```bash +switchbot devices list +``` + +### Manual install + +If you prefer step-by-step control: + +```bash +npm install -g @switchbot/openapi-cli @switchbot/codex-plugin +switchbot install --agent codex # registers the already-installed plugin +switchbot auth login # if you don't already have credentials +``` + +`switchbot install --agent codex` is **register-only** — it fails fast if `@switchbot/codex-plugin` is not present in `npm list -g`. Use `codex setup` instead if you want the package install bundled in. + +### Health checks and repair + +```bash +switchbot codex doctor # 7-check health summary; exits 1 on any fail +switchbot codex repair # re-verify, re-auth, re-register, re-check +``` + +`codex repair` runs five steps: `verify-cli` → `re-auth` → `remove-plugin` (best-effort) → `register-plugin` → `doctor-verify`. Both `setup` and `repair` support: + +- `--dry-run` — print the step list without mutating anything +- `--json` — machine-readable outcome +- `--yes` — non-interactive; auth prompts become `failed` instead of spawning `auth login` +- `--skip ` — comma-separated. Only `install-switchbot-cli` / `install-codex-plugin` / `auth` (setup) and `re-auth` / `remove-plugin` (repair) are skippable; passing any other step name exits 2. + +### Profile and config scope + +All `codex` subcommands honor the global `--profile` and `--config` flags. Auth credentials are written to (and read from) the active profile, and the spawned `auth login` inherits both: + +```bash +switchbot --profile staging codex setup +switchbot --config /path/to/cfg.json codex doctor +``` + +> Run `switchbot codex setup --help` (or `repair --help`, `doctor --help`) for the full flag list. + ## Credentials The CLI reads credentials in this order (first match wins): diff --git a/docs/superpowers/specs/2026-05-23-codex-commands-design.md b/docs/superpowers/specs/2026-05-23-codex-commands-design.md new file mode 100644 index 00000000..8504c658 --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-codex-commands-design.md @@ -0,0 +1,369 @@ +# Design: `switchbot codex` Command Group + +**Date:** 2026-05-23 +**Status:** Approved +**Scope:** switchbot-cli + openclaw-switchbot-skill (plugin deprecation) + +--- + +## Problem + +The current Codex integration requires users to know and run multiple separate commands across two distinct entry points (`switchbot-codex-install` from the plugin package, then various `switchbot` subcommands). There is no single "is my Codex setup healthy?" check, and no guided repair path when things break. + +Three gaps: +1. No `switchbot install --agent codex` — users must read plugin docs to find `switchbot-codex-install` +2. No Codex-specific health checks in `switchbot doctor` (plugin registered? Codex CLI on PATH?) +3. No repair command — users must manually sequence `auth logout/login`, `doctor`, `codex plugin remove/add` + +--- + +## Approach: Option C — Extend existing commands + thin `codex` namespace + +- `switchbot install --agent codex` — extend existing install step engine (not a new command) +- `switchbot codex doctor` — thin alias over `doctor` CHECK_REGISTRY with Codex-specific section +- `switchbot codex repair` — new command, sequential repair steps without rollback chain +- `switchbot-codex-install` binary — deprecated to a one-line redirect + +--- + +## File Layout + +``` +src/ + commands/ + codex.ts # NEW: registers `codex` parent command with doctor/repair subcommands + install.ts # MODIFY: add 'codex' to AgentName, route to stepRegisterCodexPlugin + install/ + default-steps.ts # MODIFY: add stepRegisterCodexPlugin() + codex-checks.ts # NEW: 3 Codex-specific check functions (used only by codex doctor) + # + runCodexPluginRegistration() shared utility + # NOT added to global CHECK_REGISTRY + commands/doctor.ts # MODIFY: export Check interface, CHECK_REGISTRY, runDoctorChecks() + # NO new check entries — Codex checks stay out of CHECK_REGISTRY + program-builder.ts # MODIFY: registerCodexCommand(program) + +tests/ + commands/codex.test.ts # NEW: codex doctor / repair integration tests + install/codex-checks.test.ts # NEW: unit tests for 4 Codex checks +``` + +--- + +## Section 1: `switchbot install --agent codex` (register only — not full bootstrap) + +> **P0-C constraint:** This command registers an already-installed npm package with the Codex CLI. It is NOT a full one-step installer. All user-facing text (command description, `--help`, `--dry-run` output, error messages) must use "register" semantics, never "install Codex integration". The prerequisite `npm install -g @switchbot/codex-plugin` must be stated wherever the command is documented. + +### AgentName extension + +`src/install/default-steps.ts` (and `install.ts`): + +```typescript +export type AgentName = 'claude-code' | 'cursor' | 'copilot' | 'codex' | 'none'; +``` + +### Step routing + +`install.ts` replaces the current hardcoded `stepSymlinkSkill` with a conditional: + +```typescript +const agentStep = ctx.agent === 'codex' + ? stepRegisterCodexPlugin() + : stepSymlinkSkill({ force }); + +const allSteps: InstallStep[] = [ + stepPromptCredentials(), + stepWriteKeychain(), + stepScaffoldPolicy(), + agentStep, +]; +``` + +### `stepRegisterCodexPlugin` interface + +Step name and description must use "register" language. Prerequisite (npm package installed globally) is enforced by preflight as a **fail** (not warn) — this step only performs plugin registration and will not be reached if the package is absent. + +```typescript +// src/install/default-steps.ts +export function stepRegisterCodexPlugin(): InstallStep { + return { + name: 'register-codex-plugin', + description: 'Register @switchbot/codex-plugin with the Codex CLI (marketplace add + plugin add)', + async execute(ctx) { + const result = await registerCodexPlugin(); // 共享 helper,封装 4 步:npm root -g → packageRoot → resolvePluginId → runCodexPluginRegistration + if (!result.ok) throw new Error(result.error); + ctx.codexPluginRegistered = true; + ctx.codexPluginIdentifier = result.pluginId; + }, + async undo(ctx) { + if (ctx.codexPluginIdentifier) { + spawnSync('codex', ['plugin', 'remove', ctx.codexPluginIdentifier]); + } + }, + }; +} +``` + +> 本方法体不允许直接调用 `npm root -g` 或 `runCodexPluginRegistration`;统一通过 `registerCodexPlugin()`(见 `src/install/codex-checks.ts`)。同一个 helper 也被 `codex repair` 与 `codex setup` 调用,三处共享单一实现。 + +**`resolvePluginId(packageRoot): string` — single authoritative implementation** + +Defined in `src/install/codex-checks.ts` and imported by both `default-steps.ts` (Task 3) and `codex.ts` repair step (Task 7). No duplicate implementations in any file. Logic mirrors `install.js:resolvePluginIdentifier`: + +```typescript +// src/install/codex-checks.ts +export function resolvePluginId(packageRoot: string): string { + const manifestPath = path.join(packageRoot, '.codex-plugin', 'plugin.json'); + if (fs.existsSync(manifestPath)) { + try { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as { name?: string }; + if (manifest.name) return `${manifest.name}@${path.basename(packageRoot)}`; + } catch { /* fall through */ } + } + return `switchbot@${path.basename(packageRoot)}`; +} +``` + +`InstallContext` gains two optional fields: +```typescript +codexPluginRegistered?: boolean; +codexPluginIdentifier?: string; // e.g. 'switchbot@codex-plugin' +``` + +### Preflight additions + +`src/install/preflight.ts` adds a codex-specific preflight check when `agent === 'codex'`: +- `codex` binary on PATH (fail if missing — user must install Codex first) +- `@switchbot/codex-plugin` npm package installed globally (**fail** if missing — user must `npm install -g @switchbot/codex-plugin` first; this is a register-only command) + +### `--dry-run` / `--json` / rollback + +All inherited from the existing `runInstall` framework — no changes required. + +> **关系:** `switchbot install --agent codex` 是底层 register-only 命令;面向终端用户的一键 bootstrap 是 `switchbot codex setup`(详见 `2026-05-23-codex-install-paths-design.md`)。两者通过共享 helper `registerCodexPlugin()`(见 `src/install/codex-checks.ts`)协作执行注册步骤,不存在职责重复,也不允许内联实现。 + +--- + +## Section 2: Codex-specific Doctor Checks + +### New file: `src/install/codex-checks.ts` + +Four exported check functions, each returning `Check` (the same interface used throughout `doctor.ts`): + +**`checkCodexCli()`** +- Runs `which codex` (POSIX) / `where codex` (Windows) +- `ok` → `{ path, version }` (version from `codex --version` stdout) +- `fail` → hint pointing to Codex install docs + +**`checkCodexPluginNpm()`** +- Runs `npm list -g --json @switchbot/codex-plugin` +- Parses stdout to extract version and package root +- `ok` → `{ version, packageRoot }` +- `warn` → `{ message: 'not installed — run: npm install -g @switchbot/codex-plugin && switchbot install --agent codex' }` + +**`checkCodexPluginRegistered()`** +- Runs `codex plugin list --json` (or plain text fallback) +- Checks if any entry matches `switchbot` +- If `codex` not on PATH: returns `warn` with `{ reason: 'codex-cli-missing' }` (not `fail`, to avoid double-failing with checkCodexCli) +- `ok` → `{ pluginName }` +- `warn` → `{ message: 'switchbot not in codex plugin list — run: npm install -g @switchbot/codex-plugin && switchbot install --agent codex' }` + +**`runCodexPluginRegistration(packageRoot, pluginId)` — shared utility** +Extracted from `stepRegisterCodexPlugin` and reused verbatim by repair step 4. Runs `codex plugin marketplace add` then `codex plugin add`. Returns `{ ok, exitCode, stderr }`. Neither the install step nor the repair step contain this logic inline — both call this function. + +**No `checkCodexMcpStart` check added.** +`switchbot codex doctor` reuses the existing `'mcp'` entry from CHECK_REGISTRY directly. + +### Codex checks are NOT added to global `CHECK_REGISTRY` (P0-A) + +> **Hard constraint:** `switchbot doctor` (the global health check) must not include any Codex-specific checks. Non-Codex users must see no Codex-related output, and scripts/CI consuming `switchbot doctor --json` must be unaffected. + +The three Codex check functions (`checkCodexCli`, `checkCodexPluginNpm`, `checkCodexPluginRegistered`) are exported from `src/install/codex-checks.ts` and called **only** inside `switchbot codex doctor`. They are never appended to `CHECK_REGISTRY` in `doctor.ts`. + +`doctor.ts` changes are limited to: export the `Check` interface, export `CHECK_REGISTRY`, add `runDoctorChecks()` helper. **No new entries are added to CHECK_REGISTRY.** + +--- + +## Section 3: `switchbot codex` Command Group + +### `src/commands/codex.ts` structure + +```typescript +export function registerCodexCommand(program: Command): void { + const codex = program.command('codex').description('Codex integration management'); + registerCodexDoctorSubcommand(codex); + registerCodexRepairSubcommand(codex); +} +``` + +### `switchbot codex doctor` + +Section list (fixed, not user-configurable in this command). The three Codex-specific checks are called directly (not via CHECK_REGISTRY): + +```typescript +const CODEX_DOCTOR_SECTIONS = [ + 'node', 'path', 'credentials', 'mcp', // from CHECK_REGISTRY via runDoctorChecks() +] as const; + +// Codex-specific checks run separately (not in CHECK_REGISTRY): +// checkCodexCli(), checkCodexPluginNpm(), checkCodexPluginRegistered() +``` + +Implementation: extracts the base subset from CHECK_REGISTRY via `runDoctorChecks()`, then calls the three Codex check functions directly. Output formatting must be **extracted into a shared `formatDoctorChecks(checks, quiet)` function in `doctor.ts`** (not inlined in the command handler) so `codex.ts` can import and reuse it without duplicating the output contract. Supports `--json` and `-q/--quiet`. Exits 1 on any `fail`. + +### `switchbot codex repair` + +**Repair steps** (sequential, no rollback, failures continue): + +| # | Name | Action | Skippable | +|---|------|--------|-----------| +| 1 | `verify-cli` | `doctor --section node,path` (silent) | No | +| 2 | `re-auth` | **Interactive mode**: spawn auth login via `process.execPath` + `cliPath`, forwarding all active scope flags. Build argv as: `[...profileArgs, ...configArgs, 'auth', 'login']` where `profileArgs = profile !== 'default' ? ['--profile', profile] : []` and `configArgs = ctx.configPath ? ['--config', ctx.configPath] : []`. This ensures credentials round-trip to the correct scope regardless of whether `--profile`, `--config`, both, or neither are active. **`--yes` mode**: check credentials only, return `failed` + `{ reason: 'credentials-missing' }` if absent | Yes | +| 3 | `remove-plugin` | `codex plugin remove ` (best-effort, exit ≠ 0 is non-fatal) | Yes | +| 4 | `register-plugin` | `codex plugin marketplace add` + `codex plugin add` (calls `runCodexPluginRegistration` + `resolvePluginId`) | No | +| 5 | `doctor-verify` | Run 7 checks: 4 base from CHECK_REGISTRY (node, path, credentials, mcp) + 3 Codex (`checkCodexCli`, `checkCodexPluginNpm`, `checkCodexPluginRegistered`). Print summary | No | + +> **Strong constraint (P1-B):** In interactive mode (no `--yes`), the `re-auth` step MUST actually execute `switchbot auth login` when credentials are missing. Returning a hint message is only acceptable in `--yes` / non-interactive mode. "Repair" means repair, not diagnose. + +**Repair step interface:** + +```typescript +interface RepairContext { + profile: string; // active profile (--profile or 'default') + configPath?: string; // active --config override path, if any + codexPluginId?: string; // resolved from npm list, e.g. 'switchbot@codex-plugin' + nonInteractive: boolean; // true when --yes is passed +} + +interface RepairStep { + name: string; + description: string; + run(ctx: RepairContext): Promise; +} + +interface RepairOutcome { + step: string; + status: 'ok' | 'skipped' | 'failed'; + message?: string; +} +``` + +**CLI options:** + +``` +switchbot codex repair + --skip Comma-separated step names to skip (e.g. "re-auth,remove-plugin"). + Only skippable steps may be named. Passing a non-skippable step name + (verify-cli, register-plugin, doctor-verify) is a usage error → exit 2 + with message: "invalid --skip: '' is not skippable" + --yes Non-interactive: skip re-auth (check only, report failed if missing) + --json Emit repair report as JSON + (inherits global --dry-run) +``` + +**Exit codes:** + +| Code | Meaning | +|------|---------| +| 0 | All steps ok or skipped | +| 1 | At least one step failed | +| 2 | `verify-cli` preflight failed; no further steps ran | + +**Human-readable output example:** + +``` +Repairing Codex integration... + +✓ verify-cli node 22.x, switchbot on PATH +✓ re-auth credentials ok (keychain) +↺ remove-plugin codex plugin remove returned exit 1 (non-fatal, continuing) +✓ register-plugin marketplace add + plugin add succeeded +✓ doctor-verify 7 ok, 0 warn, 0 fail + +Repair complete. Restart Codex and run: switchbot devices list +``` + +--- + +## Section 4: Plugin Deprecation (transition phase — P0-B) + +> **Hard constraint:** `switchbot-codex-install` must remain functional until `switchbot install --agent codex` covers all 5 responsibilities currently in `install.js` (CLI self-install, marketplace add, plugin add, auth verification, doctor check). In this PR, it becomes a **deprecated wrapper**: prints a warning, then continues to run all existing logic unchanged. + +### `packages/codex-plugin/bin/install.js` + +Add a deprecation banner at the top of the `install()` function (before any other logic): + +```js +process.stderr.write( + '[switchbot-codex] WARNING: switchbot-codex-install is deprecated.\n' + + '[switchbot-codex] Preferred: npx @switchbot/openapi-cli codex setup\n' + + '[switchbot-codex] This binary will continue to work during the transition period.\n' +); +// existing install logic continues below... +``` + +The binary must exit with the same codes as today. Final no-op redirect happens in a future PR after CLI coverage of all 5 steps is verified. + +### `manifest.json` + +```json +"codexPlugin": { + "install": "npx @switchbot/openapi-cli codex setup" +} +``` + +### `SKILL.md` / `CODEX_INSTALL.md` + +Update install instructions to show the new recommended path as primary: +``` +Recommended: npx @switchbot/openapi-cli codex setup +Legacy (still works): switchbot-codex-install +``` + +--- + +## Error Handling + +| Scenario | Behavior | +|----------|----------| +| `codex` not on PATH during install | Preflight `fail` → exit 2, hint: install Codex first | +| `codex plugin add` exits non-zero | Step `failed`, rollback attempts `codex plugin remove`, exits 3 | +| credentials missing during install | `stepPromptCredentials` handles interactively or fails with hint | +| `codex plugin list` exits non-zero in doctor | `codex-plugin-registered` returns `warn`, not `fail` | +| `codex plugin remove` fails in repair | Step outcome = `failed` (non-fatal), repair continues | +| repair `verify-cli` fails | exit 2, remaining steps not run | + +--- + +## Testing + +### Unit tests (`tests/install/codex-checks.test.ts`) + +- `checkCodexCli`: mock `spawnSync`; test ok/fail/version-parse paths +- `checkCodexPluginNpm`: mock `spawnSync npm list`; test installed/missing/malformed-json paths +- `checkCodexPluginRegistered`: mock `spawnSync codex plugin list`; test found/missing/codex-not-on-PATH paths + +### Integration tests (`tests/commands/codex.test.ts`) + +- `codex doctor --json`: stub all 7 checks, assert section subset is correct, assert JSON contract +- `codex doctor --quiet`: assert only warn/fail lines printed +- `codex repair --dry-run`: assert step list printed, no mutations +- `codex repair --json`: stub repair steps, assert outcome array shape +- `codex repair --skip re-auth`: assert step 2 has status `skipped` +- `codex repair --skip verify-cli`: assert exit 2 with message `"invalid --skip: 'verify-cli' is not skippable"` +- `codex repair --config ` with credentials missing: assert re-auth spawn argv includes `['--config', path]`, not the default keychain path +- `codex doctor` with npm package absent: assert warning message contains `npm install -g @switchbot/codex-plugin` + +### install --agent codex + +- Extend `tests/commands/install.test.ts` with `agent=codex` path +- Mock `stepRegisterCodexPlugin.execute` and `.rollback` +- Assert rollback is called when `register-plugin` throws + +--- + +## Constraints & Non-goals + +- `switchbot install --agent codex` does **not** install the `@switchbot/codex-plugin` npm package; it assumes the package is globally installed (preflight **fails** if not — this is a register-only command). `switchbot codex setup` is the user-facing bootstrap path and installs the package if missing before registration. +- `switchbot codex` does not handle Cursor / Copilot / Claude Code — those remain under `--agent` flags. +- No changes to `switchbot uninstall` in this phase; Codex-specific uninstall is out of scope. +- **`switchbot doctor` default run is unaffected.** No Codex-specific checks are added to the global CHECK_REGISTRY. Codex health is only visible via `switchbot codex doctor`. diff --git a/docs/superpowers/specs/2026-05-23-codex-install-paths-design.md b/docs/superpowers/specs/2026-05-23-codex-install-paths-design.md new file mode 100644 index 00000000..9bc5d6dc --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-codex-install-paths-design.md @@ -0,0 +1,273 @@ +# Codex Install Paths Design + +**Date:** 2026-05-23 +**Status:** Approved +**Scope:** switchbot-cli (feat+codex-commands branch) + openclaw-switchbot-skill (packages/codex-plugin) + +--- + +## Problem + +Two install paths need to work end-to-end for the Codex plugin: + +- **Path A** — Codex native marketplace install (user clicks "Add Plugin" in Codex UI) +- **Path B** — In-Codex conversation bootstrap (user copy-pastes from docs, or types natural language) + +Path A already has an `onInstall` hook but it only runs auth and lacks CLI presence checks. Path B has no supported mechanism today: bare Codex has no SwitchBot context, and there is no authoritative single command to run after initial CLI install. + +--- + +## Solution Overview + +Introduce `switchbot codex setup` as the single authoritative post-CLI-install command, update the plugin's `AGENTS.md` to serve as both the loaded skill context and the paste-able bootstrap prompt, update `README.md` for the copy-paste path, and harden the `onInstall` hook to call setup non-interactively. + +--- + +## Component 1: `switchbot codex setup` subcommand + +**File:** `src/commands/codex.ts` (new subcommand registered in `registerCodexCommand`) + +### Purpose + +Full install from a known-CLI-present state: detect missing switchbot CLI → install if needed → detect missing Codex plugin package → install if needed → register plugin → auth → verify. Serves as the single entry point for both new users (via `npx @switchbot/openapi-cli codex setup`) and existing users (re-setup / repair). + +Differs from `repair` in that it skips `remove-plugin` and adds package installation steps. + +### Steps + +| Name | skippable | Description | +|---|---|---| +| `check-codex-cli` | no | Verify `codex` is on PATH. Always written to outcomes. | +| `install-switchbot-cli` | yes | `npm list -g --json --depth=0 @switchbot/openapi-cli` → install if absent | +| `install-codex-plugin` | yes | `npm list -g --json --depth=0 @switchbot/codex-plugin` → install if absent | +| `register-plugin` | no | `resolveCodexPackageRoot` + `runCodexPluginRegistration` (marketplace add + plugin add) | +| `auth` | yes | Check credentials; spawn CLI auth login if missing (non-interactive under `--yes`) | +| `doctor-verify` | no | `runDoctorChecks(['node','path','credentials','mcp'])` + `checkCodexCli()` + `checkCodexPluginNpm()` + `checkCodexPluginRegistered()` (4 base + 3 Codex = 7 checks) | + +### `--skip` contract + +Allowed values: `install-switchbot-cli`, `install-codex-plugin`, `auth` only. + +Any other name passed to `--skip` → exit 2 with message: +``` +invalid --skip: '' is not skippable +``` + +### `check-codex-cli` preflight + +- Always writes outcome to `outcomes[]` before stopping +- On failure: exit 2 (preflight failure) +- `--json` output still includes the outcome entry + +### `install-switchbot-cli` detection + +```sh +npm list -g --json --depth=0 @switchbot/openapi-cli +``` + +- Parse JSON; absent `dependencies['@switchbot/openapi-cli']` → treat as not installed +- JSON parse failure → treat as not installed (npm itself may be broken, but attempt install anyway) +- On install: `npm install -g @switchbot/openapi-cli@latest` +- Install failure → record `failed`, continue (non-preflight) + +### `install-codex-plugin` detection + +```sh +npm list -g --json --depth=0 @switchbot/codex-plugin +``` + +- Parse JSON; absent `dependencies['@switchbot/codex-plugin']` → treat as not installed +- JSON parse failure → treat as not installed +- On install: `npm install -g @switchbot/codex-plugin@latest` +- Install failure → record `failed`, continue (non-preflight) +- This step must run before `register-plugin`; otherwise a brand-new `npx @switchbot/openapi-cli codex setup` flow cannot resolve the package root that registration needs. + +### `auth` step — exact failure shape + +| Condition | Status | Output | +|---|---|---| +| Credentials present | `ok` | `credentials present` | +| Missing + `--yes` + `--json` | `failed` | `outcome.error = { reason: 'credentials-missing', hint: 'run: switchbot auth login' }` | +| Missing + `--yes` + text | `failed` | `✗ auth credentials missing — run: switchbot auth login` | +| Missing + interactive | spawn `process.execPath [cliPath, ...(profile !== 'default' ? ['--profile', profile] : []), ...(configPath ? ['--config', configPath] : []), 'auth', 'login']`; non-zero → `failed` | + +`profile` 与 `configPath` 都来自当前命令上下文(全局 `--profile` / `--config`);与 `codex repair re-auth` 复用同一个 `buildAuthLoginArgv` 助手,A1 / B4 修订只需改一处。 + +Auth always spawns via `process.execPath` + known `cliPath` to inherit the correct binary and `--profile` / `--config`. Never via bare `switchbot` on PATH. + +### `doctor-verify` scope + +Runs (matches `codex repair doctor-verify` — same semantics across both commands): + +- `runDoctorChecks(['node', 'path', 'credentials', 'mcp'])` → 4 base checks +- `checkCodexCli()` + `checkCodexPluginNpm()` + `checkCodexPluginRegistered()` → 3 Codex checks + +**Total: 7 checks.** Codex integration health depends on CLI base health (PATH, credentials, MCP supervisor); a green `register-plugin` step is meaningless if any base check is red, so both `codex setup` and `codex repair` confirm the full picture before declaring success. + +### Exit codes + +| Condition | Code | +|---|---| +| `check-codex-cli` failed | 2 | +| Invalid `--skip` value | 2 | +| Any other step failed | 1 | +| All steps ok or skipped | 0 | + +### Options + +- `--yes` — Non-interactive: auth missing → `failed` with hint (no spawn) +- `--skip ` — Comma-separated; only skippable steps allowed +- `--dry-run` (global) — Print step list with skip annotations, no execution +- `--json` (global) — Emit `{ ok, preflightFailed, outcomes }` to stdout + +### 与 `switchbot install --agent codex` 的关系 + +`install --agent codex` 与 `codex setup` 是同一栈的两层,通过同一个共享 helper 协作: + +| 命令 | 定位 | 前置条件 | 行为 | +|---|---|---|---| +| `switchbot install --agent codex` | 底层 register-only | 用户已自行 `npm install -g @switchbot/codex-plugin` | 仅做 marketplace add + plugin add;npm 包缺失则 preflight `fail`。 | +| `switchbot codex setup` | 一键 bootstrap | 仅需 Codex CLI 已安装 | 自动 `npm install -g @switchbot/openapi-cli` / `@switchbot/codex-plugin`(若缺)+ 调用相同的注册 helper + auth + Codex 专属 doctor。 | + +**实现共享:** 三处注册步骤(`install --agent codex` 的 `stepRegisterCodexPlugin`、`codex repair` 的 `repairStepRegisterPlugin`、`codex setup` 的 `register-plugin`)都**必须**调用 `src/install/codex-checks.ts` 导出的 `registerCodexPlugin()` helper,禁止各自再内联 `npm root -g` 解析、pluginId 拼接或 marketplace/plugin add 调用顺序。 + +--- + +## Component 2: AGENTS.md bootstrap section + +**File:** `packages/codex-plugin/AGENTS.md` + +### Purpose + +Serves two roles: +1. When loaded by Codex (after marketplace install): gives Codex context to respond to natural language "install/set up SwitchBot" +2. As paste-able inline context for bare Codex sessions (copied from README bootstrap prompt) + +### Content — `## Setup` section + +```markdown +## Setup + +When asked to install, set up, or repair the SwitchBot integration, run the +following steps in order. Stop and report the error if any step fails. + +1. Bootstrap the SwitchBot CLI and Codex plugin packages: + ```sh + npx @switchbot/openapi-cli codex setup + ``` + +2. Restart Codex so the plugin is loaded, then verify: + ```sh + switchbot codex doctor + switchbot devices list + ``` +``` + +### Design decisions + +- Step 1 covers new users (zero SwitchBot prerequisites); existing CLI users can run `switchbot codex setup` directly +- `switchbot codex setup` is the single authoritative command; AGENTS.md does not duplicate step logic +- "Restart Codex" is explicit to prevent confusion when plugin loads but skill is not yet in context +- Section name `## Setup` is intentionally broad — covers first install and repair alike + +--- + +## Component 3: README bootstrap prompt + +**File:** `packages/codex-plugin/README.md` + +### New section (after Quick Start / Installation) + +```markdown +## Bootstrap from a Codex conversation + +If you don't have the SwitchBot CLI installed yet, paste the following block +into any Codex conversation, then say **"set up SwitchBot"**: + +--- + +> **SwitchBot setup instructions** +> +> When asked to set up SwitchBot, run in order: +> +> 1. `npx @switchbot/openapi-cli codex setup` +> 2. Restart Codex, then confirm with `switchbot codex doctor` + +--- + +If the SwitchBot CLI is already installed, skip the paste and run directly: + +```sh +switchbot codex setup +``` +``` + +### Design decisions + +- Two clear paths: new user (paste block) vs existing user (one command) +- Paste block is a compressed version of `AGENTS.md ## Setup` — same commands, shorter prose +- Auth and policy steps are not mentioned; `switchbot codex setup` handles them internally +- No credentials or secrets appear in README + +--- + +## Component 4: `onInstall` hook hardening + +**File:** `packages/codex-plugin/bin/auth.js` + +> Verified against `packages/codex-plugin/.codex-plugin/hooks.json` — `onInstall.args = ["../bin/auth.js", "--hook"]`. Pinned to this path; no fallback to `install.js`. + +### Current behavior + +Runs interactive auth only. + +### New behavior + +``` +1. Check whether `switchbot` is on PATH + → Found: run `switchbot codex setup --yes` + exit 0 regardless of setup outcome (see rationale below) + → Not found: print hint to stdout: + "SwitchBot CLI not found. Run: npx @switchbot/openapi-cli codex setup" + exit 0 +``` + +### Rationale for always exit 0 + +A non-zero exit from `onInstall` causes Codex to roll back the entire plugin install. If setup fails (e.g., credentials not yet configured), the user loses the plugin and must re-add it from marketplace. It is better to complete the plugin install and let the user run `switchbot codex setup` manually. The hook's job is best-effort configuration, not a hard gate. + +### Constraints + +- No `npm install -g` in hook — silently installing global packages in a hook context is unsafe +- Uses `--yes` flag — hook runs without a TTY; no interactive prompts +- Spawn via `process.execPath` + known CLI path, not bare `switchbot` on PATH + +--- + +## Invariants + +1. `check-codex-cli` is the only preflight; all other failures are non-blocking to the step chain +2. `--skip` rejects non-skippable step names at parse time (exit 2), not silently +3. `doctor-verify` in `setup` only checks Codex integration, never general CLI health +4. Auth spawns via `process.execPath` to ensure consistent binary and profile inheritance +5. `onInstall` hook always exits 0 to protect the marketplace install from partial state rollback +6. AGENTS.md `## Setup` section and README bootstrap prompt reference the same commands; AGENTS.md is canonical + +--- + +## Affected files + +| Repo | File | Change | +|---|---|---| +| switchbot-cli | `src/commands/codex.ts` | Add `registerCodexSetupSubcommand` + register in `registerCodexCommand` | +| openclaw-switchbot-skill | `packages/codex-plugin/AGENTS.md` | Add `## Setup` section | +| openclaw-switchbot-skill | `packages/codex-plugin/README.md` | Add bootstrap prompt section | +| openclaw-switchbot-skill | `packages/codex-plugin/bin/auth.js` | Harden onInstall hook | + +--- + +## Out of scope + +- `switchbot codex setup` does not install `codex` CLI itself (user must install Codex before running any `switchbot codex` command) +- No `npx`-based bootstrap entry point (Approach C was evaluated and deferred) +- No changes to `switchbot codex doctor` or `switchbot codex repair` beyond the hint added in commit `c066238` diff --git a/docs/superpowers/specs/2026-05-23-monorepo-migration-design.md b/docs/superpowers/specs/2026-05-23-monorepo-migration-design.md new file mode 100644 index 00000000..77549f11 --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-monorepo-migration-design.md @@ -0,0 +1,333 @@ +# Design: Monorepo Migration — Absorbing `codex-plugin` and `openclaw-skill` + +**Date:** 2026-05-23 +**Status:** Draft (pending review) +**Scope:** switchbot-openapi-cli + sibling repo `openclaw-switchbot-skill` + +--- + +## Problem + +Two SwitchBot agent integration packages live in a sibling repo today: + +- `@cly-org/switchbot-codex-plugin` — Codex CLI plugin (≈ 500 LoC) +- `@cly-org/switchbot-openclaw-skill` — OpenClaw skill (≈ 500 LoC) + +Both are thin shells over this CLI's MCP server and `install --agent ` routing. Their coupling to the CLI is structural, not philosophical: + +- `peerDependencies: { "@switchbot/openapi-cli": ">=3.7.1" }` — version locked +- CLI hardcodes `path.join(npmRoot, '@cly-org', 'switchbot-codex-plugin')` in `src/install/codex-checks.ts` +- The two plugins share `lib/error-messages.js` (de-facto monorepo today) +- `README.md` already documents installing both (`npm install -g @switchbot/openapi-cli @cly-org/switchbot-codex-plugin`) + +Three concrete consequences of the current split: + +1. **README points at vapor**: `@cly-org/switchbot-codex-plugin` is not published to npm. The installation instructions documented in this PR cannot succeed. +2. **No end-to-end testing**: CLI changes its MCP API → plugin breaks. Cross-repo CI doesn't catch this until manual sync. +3. **Cross-repo PR coordination tax**: every CLI surface change that affects plugins needs paired PRs in two repos. + +The sibling repo was a verification-stage experiment. There are no users, governance is unified under OpenWonderLabs (chenliuyun is a member), and there is no migration cost to renaming packages. + +--- + +## Goal + +Consolidate all three packages into this repository as an npm workspaces monorepo, rename the `@cly-org/*` packages to `@switchbot/*`, and update the CLI + README to reference the new names. + +--- + +## Non-Goals + +- Moving the existing CLI source into `packages/cli/`. The CLI stays at the repo root. +- Preserving the `@cly-org/*` package names as aliases / re-exports. No transition period. +- Lock-step versioning across CLI and plugins. Each package keeps its own version. +- Migrating sibling repo git history beyond what `git subtree`/`git filter-repo` produces in PR #1 (decision deferred — see Prerequisites). + +--- + +## Decision: Hybrid Monorepo (CLI at root, plugins under `packages/`) + +``` +switchbot-openapi-cli/ +├── src/ ← existing CLI source, unchanged +├── package.json ← add "workspaces": ["packages/*"] +├── packages/ +│ ├── codex-plugin/ ← PR #1 +│ ├── openclaw-skill/ ← PR #2 +│ └── shared/ ← PR #2 (private, internal-only) +├── tests/ +├── docs/ +└── ... (unchanged) +``` + +### Why hybrid (not `packages/cli/`)? + +A pure monorepo (CLI also under `packages/`) would touch every import path, the esbuild config, `smoke:pack-install`, `verify:release`, and every GitHub Action. The CLI is ≈ 30k LoC; plugins are ≈ 500 LoC each. Risk asymmetry says: leave the dominant package in place, scaffold workspaces around it. Reversible later if the structure grows asymmetric. + +npm workspaces handle root + `packages/*` coexistence natively. + +--- + +## Implementation: Three Sequential PRs + +### PR #1 — Enable workspaces, import `codex-plugin` + +**Branch:** `feat/monorepo-codex-plugin` +**Depends on:** none (assumes `feat/codex-commands` already merged) +**Goal:** Repo runs as a monorepo. `codex-plugin` builds, tests, packs from `packages/codex-plugin/` under the `@switchbot/codex-plugin` name. CLI resolves the new path. README install command works (against a locally-packed tarball). + +#### Changes + +| Category | File(s) | Operation | +|---|---|---| +| Workspaces | `package.json` (root) | Add `"workspaces": ["packages/*"]`. **Do not change** the existing `test` / `typecheck` / `build` / `smoke:pack-install` / `verify:*` scripts (root CLI is not under `packages/*`, those scripts continue to target the root package only). | +| Workspace-aware aggregates | `package.json` (root, new scripts) | Add four new scripts: `"test:workspaces": "npm test --workspaces --if-present"`, `"test:all": "npm test && npm run test:workspaces"`, `"typecheck:workspaces": "npm run typecheck --workspaces --if-present"`, `"typecheck:all": "npm run typecheck && npm run typecheck:workspaces"`. The existing `npm test` and `npm run typecheck` keep their current scope (root only); CI and the verification matrix call `:all`. | +| Plugin source | `packages/codex-plugin/**` | Import 19 files from sibling repo. Use `git subtree add` if history is preserved (see Prerequisites). | +| Package rename | `packages/codex-plugin/package.json` | `name: "@switchbot/codex-plugin"`, `version: "0.1.0"` (new scope, version reset), `peerDependencies: { "@switchbot/openapi-cli": ">=3.7.1" }`, `repository`/`homepage` → this repo. Add `scripts.test` and `scripts.typecheck` so the new aggregate scripts find it. **Note**: do **not** use `"workspace:*"` here. In a hybrid monorepo (root CLI is not a workspace member, only `packages/*` are), npm cannot resolve `workspace:*` against the root package and `npm install` fails with `EUNSUPPORTEDPROTOCOL: Unsupported URL Type "workspace:"`. The plugin only needs the CLI as a runtime peer (it spawns the `switchbot` binary, never `import`s the package), so a plain semver range is sufficient and matches what the sibling repo uses today. | +| CLI hardcoded path | `src/install/codex-checks.ts` | `path.join(npmRoot, '@cly-org', 'switchbot-codex-plugin')` → `path.join(npmRoot, '@switchbot', 'codex-plugin')` (in `resolveCodexPackageRoot()`) | +| Doctor warning text | `src/install/codex-checks.ts` (`checkCodexPluginNpm`, `checkCodexPluginRegistered`) | `npm install -g @cly-org/switchbot-codex-plugin` → `npm install -g @switchbot/codex-plugin` in repair recipes | +| Test expectations | `tests/install/codex-checks.test.ts` | Two `expect(msg).toContain(...)` strings updated. **Plus**: `pluginId` default value changes — `resolvePluginId` derives from dirname, so `switchbot@switchbot-codex-plugin` → `switchbot@codex-plugin`. Update all assertions. | +| Test expectations | `tests/commands/codex.test.ts` | Same plugin-id propagation. | +| Capabilities meta | `src/commands/capabilities.ts` (`COMMAND_META` is unchanged; `codex doctor`/`codex repair`/`codex setup` already registered) | No changes needed. | +| README | `README.md` | Replace every `@cly-org/switchbot-codex-plugin` with `@switchbot/codex-plugin`. | +| CI — test job | `.github/workflows/ci.yml` (the `test` job at line 35) | Replace `npm test` with `npm run test:all`; replace `npm run typecheck` with `npm run typecheck:all` if/when typecheck appears in CI. (No new job, no matrix expansion — same job runs both packages now.) | +| Smoke test | `scripts/smoke-pack-install.mjs` | No change required for PR #1 — it packs and installs the root CLI only. Plugin smoke deferred to PR #3. | + +#### Plugin id change + +`resolvePluginId(pluginRoot)` (in `src/install/codex-checks.ts`) returns `${name}@${path.basename(pluginRoot)}`. With the directory rename (`switchbot-codex-plugin` → `codex-plugin`), the default plugin id changes: + +- Before: `switchbot@switchbot-codex-plugin` +- After: `switchbot@codex-plugin` + +Every test asserting the old id needs updating. Since there are no users, no `codex plugin remove` migration is required. + +#### Verification (PR #1) + +1. `npm install` at root creates `node_modules/@switchbot/codex-plugin` symlinked to `packages/codex-plugin/`. +2. `npm run test:all` passes the existing 2715 CLI tests **and** any plugin tests imported from sibling. +3. `npm run typecheck:all` passes both packages. +4. `npm pack -w packages/codex-plugin` produces a valid tarball. +5. **Hard check — published peerDep is a concrete range**: `npm pack -w packages/codex-plugin`, extract the tarball (`tar -xzf` into a temp dir), open the bundled `package.json`, and confirm `peerDependencies["@switchbot/openapi-cli"]` is a valid semver range (e.g. `">=3.7.1"`) and **not** any of: `"workspace:*"`, `"file:..."`, `""`, missing. The `workspace:*` failure mode was the original design's biggest hazard; this gate rules it out for any future drift (e.g. someone reverts the spec change). +6. **Scoped grep — no `@cly-org` in shipping surfaces**: `grep -r "@cly-org" src/ packages/ .github/ tests/ scripts/ README.md` returns zero hits. **Do not** include `docs/` or `CHANGELOG.md` — those legitimately reference the old name as historical record (this spec, prior specs, and any migration changelog entry all mention `@cly-org/*` deliberately). The check is "no live code or release surface points at the old scope," not "the string never appears in the repo." +7. Manual: on a fresh machine, `npm install -g ` then `switchbot install --agent codex` registers the plugin successfully. + +#### Commit granularity (within the PR) + +``` +feat(monorepo): enable npm workspaces, scaffold packages/ dir +chore(scripts): add workspace-aware test:all and typecheck:all aggregates +feat(codex-plugin): import codex-plugin sources to packages/codex-plugin +refactor(codex-plugin): rename package to @switchbot/codex-plugin +refactor(install): update CLI to resolve @switchbot/codex-plugin path +docs(readme): switch codex install command to @switchbot/codex-plugin +ci: switch test job to npm run test:all to cover packages/* +``` + +--- + +### PR #2 — Import `openclaw-skill`, extract shared code + +**Branch:** `feat/monorepo-openclaw-skill` +**Depends on:** PR #1 merged +**Goal:** OpenClaw skill lives at `packages/openclaw-skill/` under `@switchbot/openclaw-skill`. Both plugins consume `error-messages` and any other duplicated utilities from `packages/shared/`. + +#### Changes + +| Category | File(s) | Operation | +|---|---|---| +| Skill source | `packages/openclaw-skill/**` | Import from sibling repo. | +| Package rename | `packages/openclaw-skill/package.json` | `name: "@switchbot/openclaw-skill"`, `version: "0.1.0"`, repository/homepage → this repo. Keep existing peerDependencies (none on CLI today, verify). | +| Shared package | `packages/shared/package.json` | `name: "@switchbot/agent-shared"`, `"private": true`, exports `lib/error-messages.js` and any other duplicated utilities. | +| Shared sources | `packages/shared/lib/error-messages.js` (and friends) | Move from one of the plugins; the other plugin's copy is deleted. | +| Plugin imports | `packages/codex-plugin/lib/error-messages.js` | Delete; consumers `import { ... } from '@switchbot/agent-shared'`. | +| Plugin imports | `packages/openclaw-skill/lib/error-messages.js` | Same. | +| **Cross-repo CI dependency** | `.github/workflows/ci.yml` (the `policy-schema-sync` job at line 149) | The job currently fetches `examples/policy.schema.json` from `OpenWonderLabs/openclaw-switchbot-skill` over raw.githubusercontent.com and diffs it against `src/policy/schema/v0.2.json`. Once openclaw-skill lives in `packages/openclaw-skill/` this becomes meaningless. **Replace** the URL fetch with a local `diff -u packages/openclaw-skill/examples/policy.schema.json src/policy/schema/v0.2.json` (or wherever the skill keeps its mirrored schema after import). If the skill drops the mirrored schema entirely (it can just import the CLI source), **delete** the job. Decision deferred to the PR but the job must not remain in its current form. | +| README | Add a short "OpenClaw skill" subsection with `npm install -g @switchbot/openclaw-skill` (or the link-based flow if that is how the skill installs). | + +#### Open question: bundling vs publishing `@switchbot/agent-shared` + +`@switchbot/agent-shared` is `private: true`, but two **public** plugins depend on it. At publish time this fails — npm won't publish a package that depends on a private one. + +Two options: + +- **A. Bundle at pack time** (preferred): plugin build step inlines `@switchbot/agent-shared` via esbuild bundling, so the published tarball has no runtime dependency on it. Shared package stays private. +- **B. Publish `@switchbot/agent-shared` as public**: simpler, but adds a third public package surface that consumers don't need to know about. + +**Recommendation: A.** The shared module is small, internal, and not part of the plugin's public API. Bundling avoids leaking implementation details. Add an esbuild step (or tsup) to each plugin's `prepack` script. + +#### Verification (PR #2) + +1. `npm install` + `npm test` green. +2. Both plugins resolve `@switchbot/agent-shared` via workspaces symlink. +3. `npm pack -w packages/openclaw-skill` and `-w packages/codex-plugin` produce valid tarballs. +4. **Critical**: extract a packed plugin tarball and confirm `node_modules/` is empty / `package.json#dependencies` does not list `@switchbot/agent-shared` — i.e. bundling worked. + +#### Commit granularity + +``` +feat(openclaw-skill): import openclaw-skill sources to packages/ +refactor(openclaw-skill): rename package to @switchbot/openclaw-skill +feat(shared): extract error-messages to packages/shared +refactor(plugins): consume shared from @switchbot/agent-shared +build(plugins): bundle @switchbot/agent-shared at pack time +ci: switch policy-schema-sync to local diff (or delete if obsolete) +docs(readme): document openclaw-skill installation +``` + +--- + +### PR #3 — Publish matrix + sibling repo deprecation + +**Branch:** `chore/monorepo-publish-flow` +**Depends on:** PR #2 merged +**Goal:** A single GitHub Release triggers `publish.yml` to push **all three** packages to npm; `npm-published-smoke.yml` verifies and promotes them; sibling repo carries an archive notice pointing to this repo. + +#### Background — actual publish topology + +The current publish + smoke flow is implemented entirely in GitHub Actions (no local `release` script): + +- `.github/workflows/publish.yml` — fires on `release: [published]`. Runs `npm ci` → `npm run build` → `npm test` → version-tag check → `npm run smoke:pack-install` → `npm publish --tag next --provenance --access public` (line 34). **Publishes the root CLI only.** No per-workspace publish today. +- `.github/workflows/npm-published-smoke.yml` — fires on `workflow_run: ['Publish to npm']: [completed]`. Waits for `@switchbot/openapi-cli@` to appear on dist-tag `next` (line 76, 88), installs it in a temp project, runs offline smoke (`switchbot --version`, `--help`, `schema export`, `capabilities`) and live smoke (`doctor`, `devices list`), then promotes to `latest` via `npm dist-tag add` (line 136) or deprecates on failure (line 152). **Every step is hardcoded to `@switchbot/openapi-cli`.** No matrix, no plugin awareness. + +PR #3 must update **both** workflows. The single-package assumption is everywhere. + +#### Changes + +| Category | File(s) | Operation | +|---|---|---| +| Publish workflow — root CLI step | `.github/workflows/publish.yml` line 34 | **Keep** the existing `npm publish --tag next --provenance --access public` step but wrap it in an `if: steps.detect.outputs.cli_publish == 'true'` guard (see new "detect" step below). The root CLI is **not** under `packages/*`; it is the root package. Do not change this line into `npm publish -w @switchbot/openapi-cli ...` — that form would resolve to nothing. | +| Publish workflow — detect-versions step (new) | `.github/workflows/publish.yml` (insert before line 34) | New step `id: detect`. For each of the three packages, read `package.json#version`, query `npm view @ version` (returns empty if not yet published), set `_publish=true` if version is unpublished. Outputs: `cli_publish`, `codex_publish`, `openclaw_publish`. Idempotent — running the workflow twice on the same release publishes nothing the second time. | +| Publish workflow — new plugin steps | `.github/workflows/publish.yml` (after the root CLI publish step) | Add two steps. Each gated by its own `if: steps.detect.outputs._publish == 'true'` and uses `continue-on-error: true`: `npm publish -w packages/codex-plugin --tag next --provenance --access public`, then `npm publish -w packages/openclaw-skill --tag next --provenance --access public`. **`continue-on-error` is critical**: a plugin publish failure (npm outage, transient registry error, or a never-should-happen duplicate) must not fail the workflow — that would gate `npm-published-smoke.yml` (which only runs on `workflow_run.conclusion == 'success'`) and block root CLI promotion to `latest`. Add a follow-up `if: failure() || steps.codex_publish.outcome == 'failure'` step that emits a clear annotation in the workflow summary so a failed plugin publish is loud, not silent. | +| Publish workflow — version verification | `.github/workflows/publish.yml` lines 23-30 | The current `Verify tag matches package.json version` step compares `GITHUB_REF_NAME` against the **root** `package.json#version`. Since plugins have independent versions, we cannot use the git tag for plugin version verification. Decision: tag is authoritative for the **root CLI version only**; plugin versions are taken from their own `package.json` at the time of release. Add a new step `Show resolved versions` that prints all three `package.json#version` values into the workflow log so the release notes can quote them. Do **not** add a tag-vs-plugin-version check — there is no shared tag for plugins. | +| Publish workflow — pre-publish smoke | `.github/workflows/publish.yml` line 32 (`npm run smoke:pack-install`) | Keep — root CLI smoke is unchanged. Add **two** new steps after it: `npm pack -w packages/codex-plugin` and `npm pack -w packages/openclaw-skill`, each followed by a tarball-extraction check that confirms `peerDependencies["@switchbot/openapi-cli"]` is a concrete semver range (same gate as PR #1 hard verification step #5). Run this before the publish steps so a malformed peerDep is caught before anything ships. | +| Smoke workflow — package matrix | `.github/workflows/npm-published-smoke.yml` | **Restructure** the `smoke` job into a matrix over the three packages. Each matrix entry runs only if its package was actually published (re-run the same `detect-versions` logic, or pass detect outputs through). The matrix entry decides: package name, smoke commands, whether the package gets promoted on success. Concretely: | +| Smoke workflow — root CLI matrix entry | (same file) | `package: @switchbot/openapi-cli`, smoke = current offline + live commands (lines 106-128), promote = yes (line 130 keeps as-is for this entry). Skipped if the publish workflow's `cli_publish` was false. | +| Smoke workflow — plugin matrix entries | (same file) | `package: @switchbot/codex-plugin` and `package: @switchbot/openclaw-skill`. Skipped if their publish step did not run or did not succeed. Smoke commands per plugin: install in temp project, confirm `package.json#peerDependencies["@switchbot/openapi-cli"]` is a concrete range, confirm bin entries (`switchbot-codex-auth`, `switchbot-codex-install`) are executable. **No live smoke** for plugins — they need a working CLI install + Codex CLI on PATH; the runner has neither and the value-add is low. Promote on success: yes (same `dist-tag add` pattern). Deprecate on failure: yes. | +| Smoke workflow — wait step generalization | `.github/workflows/npm-published-smoke.yml` lines 69-93 | The wait loop is keyed on `@switchbot/openapi-cli`. Refactor to use the matrix `package` variable everywhere; the `next` dist-tag gate works the same for all three. | +| Smoke workflow — gate selection | `.github/workflows/npm-published-smoke.yml` lines 64-67 (`Resolve current latest dist-tag`) | Generalize to `npm view ${{ matrix.package }} dist-tags.latest`. | +| Sibling repo (`openclaw-switchbot-skill`) | `README.md` (sibling) | Add deprecation notice at top: "This repository has been merged into [switchbot-openapi-cli](...). Future development happens there. Existing tags are preserved for history." | +| Sibling repo | GitHub settings | Mark as archived (Settings → Danger Zone → Archive). | +| CHANGELOG | `CHANGELOG.md` (this repo) | Add an entry for the monorepo absorption with the `@cly-org/*` → `@switchbot/*` rename mapping. | + +#### Versioning model recap + +- Root CLI: version still lives in root `package.json#version`. Git tag (`v3.7.x`) authoritative for this package. `publish.yml`'s tag-check still applies to the root publish step. +- Plugins: independent versions in `packages/*/package.json#version`. **Not** keyed off the git tag. +- Practical operating mode: bump whichever packages changed since the last release in their respective `package.json`, cut a single GitHub Release tagged with the root CLI version, let `publish.yml` push only what changed. +- **How "publish only what changed" actually works**: the new `detect-versions` step queries npm for each package's `@` and sets a per-package `_publish` output to `true` only if the version is unpublished. Each `npm publish` step is then guarded by `if: steps.detect.outputs._publish == 'true'`. **Do not rely on `npm publish` failing on duplicates as a "harmless" no-op** — a failed `npm publish` step in the current workflow returns non-zero, fails the workflow, and `npm-published-smoke.yml` (gated on `workflow_run.conclusion == 'success'`) does not run. Plugin steps additionally use `continue-on-error: true` so an unexpected publish failure (transient registry error, an inconsistency the detect step missed) does not block CLI promotion to `latest`. A failed plugin publish surfaces as a workflow annotation, not a hard fail. + +#### Verification (PR #3) + +1. Cut a **dry-run** release (e.g. tag `v3.7.99-dryrun` on a throwaway branch, then delete) and confirm: + - `publish.yml` runs the `detect-versions` step and outputs `cli_publish=true`, `codex_publish=true`, `openclaw_publish=true` (first time all three are at unpublished versions). + - All three publish steps execute. + - Each plugin tarball passes the peerDep concrete-range gate before publish. + - All three packages appear on dist-tag `next` within the timeout. + - `npm-published-smoke.yml` runs the matrix; root CLI passes offline + live; plugins pass their tarball-shape checks. + - `dist-tag add ... latest` is invoked for all three. +2. **Re-run the same workflow without bumping versions** (workflow_dispatch on the same release). Confirm: + - `detect-versions` outputs `_publish=false` for everything. + - All publish steps are skipped via `if:`. + - The workflow exits successfully with no annotations. + - This proves the changed-package guard prevents the "rerun blocks promotion" failure mode. +3. Delete the dry-run versions immediately after via `npm unpublish` (within npm's 72-hour window) and remove the throwaway tag. +4. Sibling repo README renders the deprecation notice on GitHub. +5. `npm view @cly-org/switchbot-codex-plugin` still 404s (we never published the old name). +6. `npm view @switchbot/codex-plugin` and `npm view @switchbot/openclaw-skill` show the new packages. + +#### Commit granularity + +``` +ci(publish): add detect-versions gate, publish CLI + plugins from one workflow +ci(publish): make plugin publish steps continue-on-error +ci(publish): add per-plugin peerDep concrete-range gate before publish +ci(smoke): convert npm-published-smoke to per-package matrix with detect-aware filtering +docs(changelog): document monorepo absorption and @cly-org→@switchbot rename +``` + +--- + +## Prerequisites (Block Before Starting PR #1) + +### P1 — Confirm `@switchbot` npm scope ownership + +Run `npm view @switchbot/openapi-cli`. If the scope is owned by OpenWonderLabs and the current publisher account has push rights, proceed. If `@switchbot` is taken by an unrelated party, choose a fallback: + +- `@switchbot-cli/openapi-cli` + `@switchbot-cli/codex-plugin` +- `@switchbot-openapi/cli` + `@switchbot-openapi/codex-plugin` + +Whichever is chosen, the rename is a global search-replace, applied uniformly across all three PRs. + +### P2 — Decide git history preservation strategy + +Two options for moving plugin sources from sibling repo: + +- **A. `git subtree add` / `git filter-repo`**: preserves per-file `git blame` and commit history. Costs ≈ 1 hour of one-time setup, produces a cleaner archaeology trail. +- **B. Plain copy**: drops history. Faster (≈ 5 min), acceptable given the sibling was verification-stage and most recent commits are this week's work. + +**Recommendation: A** for `codex-plugin` (some of the recent debugging context is valuable), **B** for `openclaw-skill` (less mature, less diagnostic history worth carrying). + +This is reversible — even option B can be supplemented later by importing the sibling repo's history as a separate branch for archaeological lookup. + +--- + +## Risks and Mitigations + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| `@switchbot` scope unavailable | Low (P1 verifies upfront) | Medium (mass rename) | P1 — verify before any PR | +| Hidden `@cly-org/...` reference missed in code/docs | Medium | Low (test failure or doctor warning shows the wrong recipe) | PR #1 verification step #6: full-tree grep | +| Plugin id change breaks existing user installs | Zero (no users) | N/A | None needed | +| Bundling `@switchbot/agent-shared` regresses | Medium | Medium (runtime require fails) | PR #2 verification step #4: inspect packed tarball | +| Cross-repo PR coordination during the transition | Low (PRs land in 2-3 days end-to-end) | Low | Don't touch sibling repo source until PR #3; freeze sibling repo on day 1 | +| Plugin publish fails (transient registry error, malformed peerDep, etc.) and silently blocks CLI promotion | Medium (new CI path) | High (CLI on `next` but never reaches `latest`; users on `latest` stay on the old version indefinitely) | Three-layer mitigation: (1) `detect-versions` step skips the publish entirely if the version is already on npm — eliminates the most common cause (rerun on same release); (2) `continue-on-error: true` on plugin publish steps so a failure annotates the workflow but does not gate `workflow_run.conclusion == 'success'`; (3) follow-up step that fails the workflow **after** smoke promotion has run if any plugin step's outcome was `failure` — surfaces the issue loudly without blocking the CLI promotion that already happened. | +| Plugin publish step accidentally publishes a malformed peerDep range | Low (gated in CI) | High (consumer install fails) | Pre-publish step in `publish.yml` extracts each plugin tarball and verifies `peerDependencies["@switchbot/openapi-cli"]` is a valid semver range. Step fails fast before `npm publish` is invoked. Same gate as PR #1 verification step #5, replicated in CI. | + +--- + +## Out of Scope + +- Moving the CLI itself into `packages/cli/`. +- Adding new agent integrations (Cursor, Continue, etc.) — covered by future plans, not this migration. +- Changing the CLI's public API surface as part of the move — pure mechanical relocation. +- Republishing existing `@cly-org/*` packages with a deprecation notice on npm. Since they are unpublished, no action needed. +- Lock-step versioning. Each package keeps its own version. + +--- + +## Decision Log + +- **2026-05-23**: Hybrid monorepo (CLI at root) chosen over pure monorepo (`packages/cli/`) due to risk asymmetry. +- **2026-05-23**: Independent versioning preferred over lock-step. CLI is `3.x`, plugins start at `0.1.0`. +- **2026-05-23**: `@switchbot/agent-shared` will be private + bundled (option A), not published. +- **2026-05-23**: No transition period. `@cly-org/*` names are abandoned outright (sibling repo is verification-stage with no users). +- **2026-05-23 (review pass)**: Root scripts (`test`, `typecheck`) keep root-only scope; new `test:all` / `typecheck:all` aggregates added in PR #1 to cover `packages/*`. Avoids changing pre-commit/pre-push hook timings on day one. +- **2026-05-23 (review pass)**: Publish entry point is `.github/workflows/publish.yml`, not a `scripts/release.*` file (the latter does not exist). Root CLI publish stays as `npm publish` (root package, no `-w`); plugins use `npm publish -w packages/`. +- **2026-05-23 (review pass)**: `npm-published-smoke.yml` converts to a per-package matrix. Plugins get tarball-shape smoke (peerDeps resolved, bin entries executable) but **no live smoke** — the runner has no Codex CLI installed. Plugins still go through the `next` → `latest` promote gate. +- **2026-05-23 (review pass)**: `ci.yml`'s `policy-schema-sync` (cross-repo URL fetch) becomes a local `diff` once openclaw-skill moves into `packages/openclaw-skill/` (or is deleted if the skill drops the mirrored schema). Lands in PR #2. +- **2026-05-23 (review pass)**: `workspace:*` peerDep rewrite check promoted from a Risk row to a hard verification step in PR #1 (and re-asserted in PR #3's publish workflow). It is the single highest-leverage failure mode of this migration; demoting it to a risk row was a misjudgment. +- **2026-05-23 (review pass 2)**: `peerDependencies` uses literal `">=3.7.1"`, **not** `"workspace:*"`. The original spec assumed npm would rewrite `workspace:*` at pack/publish time, which is true for full monorepos but **not** for hybrid layouts where the depended-on package (root CLI) is not a workspace member — `npm install` fails with `EUNSUPPORTEDPROTOCOL` before pack ever runs. Verified by minimal repro. The plugin spawns the `switchbot` binary rather than `import`-ing it, so a literal peerDep range is functionally equivalent. +- **2026-05-23 (review pass 2)**: Publish workflow uses a `detect-versions` step + per-package `if:` guards + `continue-on-error: true` on plugin steps. The original spec claimed `npm publish` "fails harmlessly on duplicates" — false; a non-zero exit fails the workflow, which gates `npm-published-smoke.yml` (it requires `workflow_run.conclusion == 'success'`), which blocks CLI promotion to `latest`. The new design makes plugin failure observable without coupling it to CLI promotion. +- **2026-05-23 (review pass 2)**: `@cly-org` grep verification scoped to `src/ packages/ .github/ tests/ scripts/ README.md`. The original "all `*.ts/*.md/*.json/*.yml`" form would never pass — this spec, prior specs, and any migration changelog entry legitimately reference the old name. +- **2026-05-23 (PR #1 execution)**: Used **plain copy** for `codex-plugin` rather than `git subtree add` (deviation from P2 recommendation A). Reason: sibling repo working tree had 3 untracked test files and 1 modified file at execution time; `git subtree split` only includes committed history, so subtree would silently miss in-flight code. Plain copy captures the actual current state. Per P2's note ("reversible — option B can be supplemented later by importing the sibling repo's history as a separate branch"), history can still be brought in later as an archive branch if needed. +- **2026-05-23 (PR #1 execution)**: Dropped three sibling-orchestration tests during import: `codex-mcp-config.test.js`, `codex-setup.test.js`, `install-scripts.test.js`. They imported sibling-repo-level scripts (`../../../scripts/codex-mcp-config.mjs`, `codex-setup.js`, `install.ps1`) that have no analog in this monorepo — that whole orchestration path is superseded by `switchbot codex setup` in this CLI. The remaining 4 plugin test files (auth, error-messages, install, setup) all pass. +- **2026-05-23 (PR #1 execution)**: Added `lib/` to plugin `package.json#files`. The sibling's package.json omitted it, but `lib/error-messages.js` is a runtime import of `bin/auth.js` and `setup/check-credentials.js`. Shipping without `lib/` would have broken npm-installed plugins. Caught at the tarball-content audit step. +- **2026-05-23 (PR #2 execution)**: Used **plain copy** for `openclaw-skill` (same reason as PR #1 — sibling working tree state captured directly, not via subtree). +- **2026-05-23 (PR #2 execution)**: **Skipped `packages/shared/` (`@switchbot/agent-shared`) entirely** — neither option A (private + esbuild bundle) nor option B (publish public) was implemented. Reverses the 2026-05-23 decision above. Reason: after physically importing both plugins, the only candidate for sharing is `lib/error-messages.js`, which is ~30 lines of static error strings. Codex's copy is a strict superset of OpenClaw's (2 codex-only entries). Pulling 5 of those entries into a shared module would still leave 2 codex-only entries needing somewhere to live, defeating "single source of truth." Either option also adds non-trivial machinery (esbuild prepack hook, or a third public package surface) for ~30 lines of duplication that rarely changes. Drift risk is real but small; if either catalog grows materially, revisit then. Updated risks: **PR #2's** `Bundling regresses` row no longer applies; PR #2 ships without an agent-shared step. PR #2 verification step #4 (tarball must not list `@switchbot/agent-shared` in deps) is now trivially satisfied. +- **2026-05-23 (PR #2 execution)**: `policy-schema-sync` CI job left in place for PR #2; will be removed in PR #3 alongside sibling-repo deprecation. The `curl … || skip-on-404` guard already makes the job safe once the sibling URL goes dead, so it is not a release blocker. +- **2026-05-23 (PR #3 execution)**: Version bump deferred. Spec calls out `4.0.0` as the unified release version, but PR #3 leaves `package.json` at `3.7.1` and CHANGELOG keyed `[Unreleased]`. The version is decided at tag/release time per repo convention; this PR ships the publish/smoke workflow restructure only. CHANGELOG `[Unreleased]` will be renamed to the chosen version in the release commit, not here. +- **2026-05-23 (PR #3 execution)**: `npm-published-smoke.yml` matrix entries skip themselves when the matching package was not republished in this release (detect-versions step decided `_publish=false`). The skip is implemented inside the `wait_package` step — when an exact `@` already exists on the registry but `next` does not point at it, set `skip=true` and short-circuit the rest of the matrix entry. This avoids a permanent `failure()` on stable plugin versions while still verifying the CLI on every release. +- **2026-05-23 (PR #3 execution)**: Sibling repo archive notice is **out of scope of this PR's commit** because the sibling repo is a separate git remote. The deprecation README change must be authored in the sibling working tree (`E:\workspace\claudcode\openclaw-switchbot-skill`) and pushed there. Tracked as a follow-up step before the unified release goes live, not as code in this monorepo. + +--- + +## Estimated Effort + +| PR | Wall-clock estimate | Risk profile | +|---|---|---| +| PR #1 | ~ half day | Medium (path hardcoding, test assertion sweep) | +| PR #2 | ~ half day | Low (pattern established by PR #1) | +| PR #3 | ~ 1-2 hours | Low (scripts + docs) | + +End-to-end: ~ 2 days of focused work, 3 PRs, all on top of the merged `feat/codex-commands` branch. diff --git a/package-lock.json b/package-lock.json index a7b5a2e4..0a3c67f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "@switchbot/openapi-cli", "version": "3.7.1", "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "axios": "^1.7.9", "mqtt": "^5.3.0", @@ -1118,6 +1121,33 @@ "win32" ] }, + "node_modules/@switchbot/codex-plugin": { + "resolved": "packages/codex-plugin", + "link": true + }, + "node_modules/@switchbot/openapi-cli": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@switchbot/openapi-cli/-/openapi-cli-3.7.1.tgz", + "integrity": "sha512-ez8xSNWF+IAawGLW8wZwOqMZDG+/P0wEDe7UdqYBiuHLqGtBQSW1G6ypEC3wne0l0F6svkquqqozthgHjcsYag==", + "license": "MIT", + "peer": true, + "dependencies": { + "axios": "^1.7.9", + "mqtt": "^5.3.0", + "open": "^10.2.0", + "pino": "^9.0.0" + }, + "bin": { + "switchbot": "dist/index.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@switchbot/openclaw-skill": { + "resolved": "packages/openclaw-skill", + "link": true + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -6575,6 +6605,44 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } + }, + "packages/codex-plugin": { + "name": "@switchbot/codex-plugin", + "version": "0.1.0", + "license": "MIT", + "bin": { + "switchbot-codex-auth": "bin/auth.js", + "switchbot-codex-install": "bin/install.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@switchbot/openapi-cli": ">=3.7.1" + }, + "peerDependenciesMeta": { + "@switchbot/openapi-cli": { + "optional": true + } + } + }, + "packages/openclaw-skill": { + "name": "@switchbot/openclaw-skill", + "version": "0.1.0", + "license": "MIT", + "bin": { + "switchbot-openclaw": "bin/start.js", + "switchbot-policy-edit": "bin/policy-edit.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "open": "^10.0.0" + }, + "peerDependencies": { + "@switchbot/openapi-cli": ">=3.7.1" + } } } } diff --git a/package.json b/package.json index 44353582..e0d655c4 100644 --- a/package.json +++ b/package.json @@ -35,8 +35,13 @@ "publishConfig": { "access": "public" }, + "workspaces": [ + "packages/*" + ], "scripts": { "typecheck": "tsc --noEmit", + "typecheck:workspaces": "npm run typecheck --workspaces --if-present", + "typecheck:all": "npm run typecheck && npm run typecheck:workspaces", "build": "node scripts/build.mjs", "dev": "tsx src/index.ts", "hooks:install": "node scripts/install-git-hooks.mjs", @@ -46,14 +51,17 @@ "prepare": "node scripts/install-git-hooks.mjs", "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", "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:release": "node scripts/verify-release.mjs", - "prepublishOnly": "npm test && npm run build && npm run smoke:pack-install" + "prepublishOnly": "npm test && npm run build && npm run smoke:pack-install && npm run smoke:codex-pack-install" }, "dependencies": { "axios": "^1.7.9", diff --git a/packages/codex-plugin/.agents/plugins/marketplace.json b/packages/codex-plugin/.agents/plugins/marketplace.json new file mode 100644 index 00000000..4a25a436 --- /dev/null +++ b/packages/codex-plugin/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "codex-plugin", + "interface": { + "displayName": "SwitchBot" + }, + "plugins": [ + { + "name": "switchbot", + "source": { + "source": "local", + "path": "../../" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/packages/codex-plugin/.codex-plugin/hooks.json b/packages/codex-plugin/.codex-plugin/hooks.json new file mode 100644 index 00000000..5508ae59 --- /dev/null +++ b/packages/codex-plugin/.codex-plugin/hooks.json @@ -0,0 +1,6 @@ +{ + "onInstall": { + "command": "node", + "args": ["../bin/auth.js", "--hook"] + } +} diff --git a/packages/codex-plugin/.codex-plugin/plugin.json b/packages/codex-plugin/.codex-plugin/plugin.json new file mode 100644 index 00000000..6abd9fad --- /dev/null +++ b/packages/codex-plugin/.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/.mcp.json b/packages/codex-plugin/.mcp.json new file mode 100644 index 00000000..f27b53b9 --- /dev/null +++ b/packages/codex-plugin/.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/README.md b/packages/codex-plugin/README.md new file mode 100644 index 00000000..5d427f9f --- /dev/null +++ b/packages/codex-plugin/README.md @@ -0,0 +1,155 @@ +# SwitchBot Codex Plugin + +Codex plugin for SwitchBot smart-home control through the authoritative +`switchbot` CLI MCP server. + +## What it installs + +- A Codex skill at `skills/switchbot/SKILL.md` +- An MCP server definition that runs `switchbot mcp serve --tools all` +- A best-effort `onInstall` hook that runs non-interactive setup when the CLI is present +- A bootstrap binary: `switchbot-codex-install` + +## Requirements + +- Node.js `>=18` +- `@switchbot/openapi-cli >=3.7.1` +- Codex with plugin marketplace support + +## Install + +### Recommended + +```bash +npx @switchbot/openapi-cli codex setup +``` + +`switchbot codex setup` installs or upgrades the CLI and Codex plugin packages +if needed, registers the plugin with Codex, prompts for credentials when +needed, then verifies the integration. + +### Direct Codex install + +If you install the plugin from Codex itself, enable plugin hooks for automatic +best-effort setup: + +```toml +[features] +plugin_hooks = true +``` + +The hook never blocks plugin installation. If setup needs credentials or the +SwitchBot CLI is missing, finish with: + +```bash +npx @switchbot/openapi-cli codex setup +``` + +## Verify + +Run: + +```bash +switchbot --version +switchbot doctor +switchbot devices list +``` + +Then restart Codex and ask: + +> List my SwitchBot devices and tell me which ones are currently on. + +## If install succeeds but you are not logged in + +This usually means the plugin installed, but the browser auth step did not run +or did not complete. + +Run: + +```bash +switchbot-codex-auth +``` + +If you prefer the CLI directly: + +```bash +switchbot auth login +switchbot doctor +``` + +Do not paste your token or secret into Codex chat. The login flow stores +credentials in your OS keychain. + +## Re-login + +Use this when `switchbot doctor` reports auth failures, the token was rotated, +or Codex says credentials exist but are rejected. + +```bash +switchbot auth logout +switchbot auth login +switchbot doctor +switchbot devices list +``` + +After re-login, restart Codex and retry: + +> List my SwitchBot devices and tell me which ones are currently on. + +## Verify End-To-End + +For a full release-style verification, run: + +```bash +switchbot --version +switchbot doctor +switchbot devices list +``` + +Expected result: + +- `switchbot --version` is `3.7.1` or newer +- `switchbot doctor` completes without credential failures +- `switchbot devices list` returns your devices +- Codex can answer the device prompt without asking for secrets + +## Uninstall + +Remove the plugin entry you installed. Common Codex plugin IDs are: + +```bash +codex plugin remove switchbot@switchbot-skill +codex plugin remove switchbot@codex-plugin +``` + +Repo-marketplace installs usually use `switchbot@switchbot-skill`. Package-local +marketplace installs use `switchbot@codex-plugin` (matches the package directory +name). + +If you installed the npm package globally and also want to remove the helper +commands: + +```bash +npm uninstall -g @switchbot/codex-plugin +``` + +## Full Uninstall + +To remove the plugin, local policy files, and stored login state: + +```bash +codex plugin remove switchbot@switchbot-skill +codex plugin remove switchbot@codex-plugin +switchbot auth logout +``` + +Optional cleanup: + +```bash +npm uninstall -g @switchbot/codex-plugin +npm uninstall -g @switchbot/openapi-cli +``` + +Key detail: deleting local files under `~/.switchbot` does not automatically +remove credentials from the OS keychain. Use `switchbot auth logout` when you +want a true logout. diff --git a/packages/codex-plugin/bin/auth.js b/packages/codex-plugin/bin/auth.js new file mode 100644 index 00000000..2d2cc44a --- /dev/null +++ b/packages/codex-plugin/bin/auth.js @@ -0,0 +1,94 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import { checkCli as defaultCheckCli } from '../setup/check-cli.js'; +import { checkCredentials as defaultCheckCredentials } from '../setup/check-credentials.js'; +import { formatError } from '../lib/error-messages.js'; + +function defaultRunInherit(cmd, args) { + return new Promise((resolve) => { + const p = spawn(cmd, args, { stdio: 'inherit' }); + p.on('close', code => resolve(code ?? 0)); + p.on('error', () => resolve(127)); + }); +} + +export function makeRunAuth({ checkCli, checkCredentials, runInherit }) { + return async function runAuth() { + const cliCheck = await checkCli(); + if (!cliCheck.ok) { + process.stderr.write(`[switchbot-codex] ${cliCheck.message}\n`); + return 1; + } + process.stderr.write(`[switchbot-codex] CLI ${cliCheck.version} detected.\n`); + + const credCheck = await checkCredentials(); + if (credCheck.ok) { + process.stderr.write(`[switchbot-codex] Credentials present (${credCheck.source}). Skipping login.\n`); + return 0; + } + + process.stderr.write('[switchbot-codex] Starting browser login...\n'); + const loginCode = await runInherit('switchbot', ['auth', 'login']); + if (loginCode !== 0) { + process.stderr.write(`[switchbot-codex] ${formatError('auth-login-failed')}\n`); + return loginCode; + } + + process.stderr.write('[switchbot-codex] Verifying credentials via doctor...\n'); + const doctorCode = await runInherit('switchbot', ['doctor']); + if (doctorCode !== 0) { + const postLoginCheck = await checkCredentials(); + const errorMessage = postLoginCheck.ok + ? formatError('doctor-check-failed') + : postLoginCheck.message ?? formatError(postLoginCheck.errorKey ?? 'doctor-check-failed'); + process.stderr.write(`[switchbot-codex] ${errorMessage}\n`); + return doctorCode; + } + + process.stderr.write('[switchbot-codex] Setup complete.\n'); + return 0; + }; +} + +export function makeRunOnInstall({ checkCli, runInherit }) { + return async function runOnInstall() { + const cliCheck = await checkCli(); + if (!cliCheck.ok) { + process.stderr.write( + '[switchbot-codex] SwitchBot CLI not found. Plugin install will continue.\n' + + '[switchbot-codex] To finish setup, run: npx @switchbot/openapi-cli codex setup\n' + ); + return 0; + } + + process.stderr.write(`[switchbot-codex] CLI ${cliCheck.version} detected. Running non-interactive setup...\n`); + const setupCode = await runInherit('switchbot', ['codex', 'setup', '--yes']); + if (setupCode !== 0) { + process.stderr.write( + '[switchbot-codex] Setup needs attention, but plugin install will continue.\n' + + '[switchbot-codex] To finish setup, run: switchbot codex setup\n' + ); + } else { + process.stderr.write('[switchbot-codex] Non-interactive setup complete.\n'); + } + return 0; + }; +} + +const isMain = process.argv[1]?.replace(/\\/g, '/').endsWith('bin/auth.js'); +if (isMain) { + const common = { + checkCli: defaultCheckCli, + runInherit: defaultRunInherit, + }; + const run = process.argv.includes('--hook') + ? makeRunOnInstall(common) + : makeRunAuth({ + ...common, + checkCredentials: defaultCheckCredentials, + }); + run().then(code => process.exit(code)).catch(err => { + process.stderr.write(`[switchbot-codex] Fatal: ${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); + }); +} diff --git a/packages/codex-plugin/bin/install.js b/packages/codex-plugin/bin/install.js new file mode 100644 index 00000000..8f91986a --- /dev/null +++ b/packages/codex-plugin/bin/install.js @@ -0,0 +1,178 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve, basename, join } from 'node:path'; +import { existsSync, readFileSync, realpathSync, symlinkSync, lstatSync, unlinkSync, mkdirSync } from 'node:fs'; +import os from 'node:os'; +import { checkCli as defaultCheckCli } from '../setup/check-cli.js'; +import { checkCredentials as defaultCheckCredentials } from '../setup/check-credentials.js'; +import { makeRunAuth } from './auth.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const defaultPackageRoot = resolve(__dirname, '..'); +const defaultFsDeps = { lstatSync, realpathSync, symlinkSync, unlinkSync, mkdirSync }; + +function defaultRunInherit(cmd, args) { + return new Promise((resolveFn) => { + const p = spawn(cmd, args, { stdio: 'inherit', shell: true }); + p.on('close', code => resolveFn(code ?? 0)); + p.on('error', () => resolveFn(127)); + }); +} + +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 {} + } + + let pluginName = 'switchbot'; + const pluginManifestPath = join(packageRoot, '.codex-plugin', 'plugin.json'); + if (existsSync(pluginManifestPath)) { + try { + const pluginManifest = JSON.parse(readFileSync(pluginManifestPath, 'utf8')); + if (pluginManifest?.name) { + pluginName = pluginManifest.name; + } + } catch {} + } + + return `${pluginName}@${marketplaceName}`; +} + +function computeAliasPath() { + const localAppData = process.env.LOCALAPPDATA; + if (localAppData) { + return join(localAppData, 'switchbot', 'codex-plugin-marketplace'); + } + return join(os.homedir(), '.switchbot', 'codex-plugin-marketplace'); +} + +export function resolveMarketplaceSourceRoot(packageRoot, deps = defaultFsDeps) { + if (process.platform !== 'win32' || !/^[A-Za-z]:[\\/].*[\\/]@[^\\/]+[\\/]/.test(packageRoot)) { + return packageRoot; + } + + const aliasRoot = computeAliasPath(); + deps.mkdirSync(dirname(aliasRoot), { recursive: true }); + + const stat = deps.lstatSync(aliasRoot, { throwIfNoEntry: false }); + if (!stat) { + deps.symlinkSync(packageRoot, aliasRoot, 'junction'); + return aliasRoot; + } + + if (stat.isSymbolicLink()) { + const aliasReal = deps.realpathSync(aliasRoot); + const packageReal = deps.realpathSync(packageRoot); + if (aliasReal.toLowerCase() === packageReal.toLowerCase()) { + return aliasRoot; + } + deps.unlinkSync(aliasRoot); + deps.symlinkSync(packageRoot, aliasRoot, 'junction'); + return aliasRoot; + } + + throw new Error(`alias path ${aliasRoot} exists and is not a junction; remove it manually and retry`); +} + +function formatCodexFailure(step) { + return [ + `[switchbot-codex] Codex CLI not found while running ${step}.`, + '[switchbot-codex] Install or open Codex first, then re-run switchbot-codex-install.', + ].join('\n'); +} + +export function makeInstall({ checkCli, runInherit, packageRoot, runAuth }) { + return async function install() { + process.stderr.write( + '[switchbot-codex] WARNING: switchbot-codex-install is deprecated.\n' + + '[switchbot-codex] Preferred: npx @switchbot/openapi-cli codex setup\n' + + '[switchbot-codex] This binary continues to work during the transition period.\n' + ); + const cliCheck = await checkCli(); + if (!cliCheck.ok) { + process.stderr.write('[switchbot-codex] CLI not found. Installing @switchbot/openapi-cli...\n'); + const installCode = await runInherit('npm', ['install', '-g', '@switchbot/openapi-cli@latest']); + if (installCode !== 0) { + process.stderr.write('[switchbot-codex] CLI install failed. Run manually: npm install -g @switchbot/openapi-cli@latest\n'); + return installCode; + } + } else { + process.stderr.write(`[switchbot-codex] CLI ${cliCheck.version} detected.\n`); + } + + const marketplaceRoot = resolveMarketplaceSourceRoot(packageRoot); + process.stderr.write(`[switchbot-codex] Registering plugin at ${marketplaceRoot}...\n`); + const marketplaceCode = await runInherit('codex', ['plugin', 'marketplace', 'add', marketplaceRoot]); + if (marketplaceCode !== 0) { + if (marketplaceCode === 127) { + process.stderr.write(`${formatCodexFailure('codex plugin marketplace add')}\n`); + return marketplaceCode; + } + process.stderr.write('[switchbot-codex] Marketplace registration failed.\n'); + return marketplaceCode; + } + + const pluginName = resolvePluginIdentifier(packageRoot); + process.stderr.write(`[switchbot-codex] Adding plugin ${pluginName}...\n`); + const pluginCode = await runInherit('codex', ['plugin', 'add', pluginName]); + if (pluginCode !== 0) { + if (pluginCode === 127) { + process.stderr.write(`${formatCodexFailure('codex plugin add')}\n`); + return pluginCode; + } + process.stderr.write( + '[switchbot-codex] "codex plugin add" failed — your Codex version may not support it.\n' + + '[switchbot-codex] Fallback: follow the legacy install steps in CODEX_INSTALL.md.\n' + ); + return pluginCode; + } + + process.stderr.write('[switchbot-codex] Verifying credentials after install...\n'); + const authCode = await runAuth(); + if (authCode !== 0) { + process.stderr.write( + '[switchbot-codex] Plugin installed, but authentication still needs attention.\n' + ); + return authCode; + } + + process.stderr.write('[switchbot-codex] Running final doctor check...\n'); + const doctorCode = await runInherit('switchbot', ['doctor']); + if (doctorCode !== 0) { + process.stderr.write( + '[switchbot-codex] Install completed, but the CLI health check still has failures.\n' + + '[switchbot-codex] Fix: switchbot doctor\n' + ); + return doctorCode; + } + + process.stderr.write('[switchbot-codex] Install complete. Restart Codex and try listing your devices.\n'); + return 0; + }; +} + +const isMain = process.argv[1]?.replace(/\\/g, '/').endsWith('bin/install.js'); +if (isMain) { + const install = makeInstall({ + checkCli: defaultCheckCli, + runInherit: defaultRunInherit, + packageRoot: defaultPackageRoot, + runAuth: makeRunAuth({ + checkCli: defaultCheckCli, + checkCredentials: defaultCheckCredentials, + runInherit: defaultRunInherit, + }), + }); + install().then(code => process.exit(code)).catch(err => { + process.stderr.write(`[switchbot-codex] Fatal: ${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); + }); +} diff --git a/packages/codex-plugin/lib/error-messages.js b/packages/codex-plugin/lib/error-messages.js new file mode 100644 index 00000000..c37e2b93 --- /dev/null +++ b/packages/codex-plugin/lib/error-messages.js @@ -0,0 +1,48 @@ +// packages/codex-plugin/lib/error-messages.js +export const ERRORS = { + 'auth-not-configured': { + reason: 'SwitchBot credentials are not configured.', + fix: 'switchbot auth login', + hint: 'Run the fix command, then restart your MCP client.', + }, + 'auth-login-failed': { + reason: 'Login failed — the CLI returned a non-zero exit code.', + fix: 'switchbot auth login', + hint: 'Check your network connection and try again.', + }, + 'token-expired': { + reason: 'Credentials exist but doctor check failed — token may be expired.', + fix: 'switchbot auth logout && switchbot auth login', + hint: 'After re-login, run `switchbot doctor` to verify.', + }, + 'credentials-invalid': { + reason: 'Credentials were found, but SwitchBot rejected them.', + fix: 'switchbot auth logout && switchbot auth login', + hint: 'Use this when install succeeded but the browser login did not complete cleanly, or the token was rotated.', + }, + 'doctor-check-failed': { + reason: 'The CLI could not complete the post-login health check.', + fix: 'switchbot doctor', + hint: 'Inspect the doctor output for network, API, or proxy failures before retrying login.', + }, + 'cli-not-installed': { + reason: 'switchbot CLI is not installed or not in PATH.', + fix: 'npm install -g @switchbot/openapi-cli', + hint: 'After install, run `switchbot doctor` to confirm.', + }, + 'cli-version-too-low': { + reason: 'switchbot CLI version is below the required minimum (3.7.1).', + fix: 'npm install -g @switchbot/openapi-cli@latest', + hint: 'After upgrade, re-run setup.', + }, +}; + +export function formatError(key) { + const e = ERRORS[key]; + if (!e) throw new Error(`unknown error key: ${key}`); + return [ + `Error: ${e.reason}`, + ` Fix: ${e.fix}`, + ` Hint: ${e.hint}`, + ].join('\n'); +} diff --git a/packages/codex-plugin/package.json b/packages/codex-plugin/package.json new file mode 100644 index 00000000..a9af1911 --- /dev/null +++ b/packages/codex-plugin/package.json @@ -0,0 +1,45 @@ +{ + "name": "@switchbot/codex-plugin", + "version": "0.1.0", + "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", + "repository": { + "type": "git", + "url": "git+https://github.com/OpenWonderLabs/switchbot-openapi-cli.git", + "directory": "packages/codex-plugin" + }, + "bugs": "https://github.com/OpenWonderLabs/switchbot-openapi-cli/issues", + "license": "MIT", + "keywords": ["codex", "switchbot", "smart-home", "iot", "mcp"], + "engines": { "node": ">=18" }, + "bin": { + "switchbot-codex-auth": "./bin/auth.js", + "switchbot-codex-install": "./bin/install.js" + }, + "files": [ + "bin/", + "lib/", + "setup/", + "skills/", + ".codex-plugin/", + ".agents/", + ".mcp.json", + "README.md" + ], + "peerDependencies": { + "@switchbot/openapi-cli": ">=3.7.1" + }, + "peerDependenciesMeta": { + "@switchbot/openapi-cli": { + "optional": true + } + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "test": "node --test tests/*.test.js", + "typecheck": "node --check bin/auth.js && node --check bin/install.js" + } +} diff --git a/packages/codex-plugin/setup/check-cli.js b/packages/codex-plugin/setup/check-cli.js new file mode 100644 index 00000000..ca28080f --- /dev/null +++ b/packages/codex-plugin/setup/check-cli.js @@ -0,0 +1,57 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const MIN_VERSION = '3.7.1'; + +function versionAtLeast(have, need) { + const a = have.split('.').map(n => parseInt(n, 10) || 0); + const b = need.split('.').map(n => parseInt(n, 10) || 0); + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const ai = a[i] ?? 0; + const bi = b[i] ?? 0; + if (ai > bi) return true; + if (ai < bi) return false; + } + return true; +} + +export function makeCheckCli(exec) { + return async function checkCli() { + let version; + try { + const { stdout } = await exec('switchbot', ['--version'], { timeout: 8000 }); + const m = stdout.trim().match(/\d+\.\d+\.\d+/); + version = m ? m[0] : null; + } catch (err) { + if (err?.code === 'ENOENT') { + return { + ok: false, + message: 'switchbot CLI not found. Install with: npm install -g @switchbot/openapi-cli@latest', + }; + } + return { + ok: false, + message: `Failed to run switchbot --version: ${err instanceof Error ? err.message : String(err)}`, + }; + } + + if (!version) { + return { + ok: false, + message: `Could not parse CLI version string. Upgrade with: npm install -g @switchbot/openapi-cli@latest`, + }; + } + + if (!versionAtLeast(version, MIN_VERSION)) { + return { + ok: false, + message: `CLI version ${version} is below the required minimum ${MIN_VERSION}. Upgrade with: npm install -g @switchbot/openapi-cli@latest`, + }; + } + + return { ok: true, version }; + }; +} + +const defaultExec = promisify(execFile); +export const checkCli = makeCheckCli(defaultExec); diff --git a/packages/codex-plugin/setup/check-credentials.js b/packages/codex-plugin/setup/check-credentials.js new file mode 100644 index 00000000..8020f867 --- /dev/null +++ b/packages/codex-plugin/setup/check-credentials.js @@ -0,0 +1,125 @@ +// packages/codex-plugin/setup/check-credentials.js +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { formatError } from '../lib/error-messages.js'; + +const AUTH_FAILURE_PATTERNS = [ + /\b401\b/i, + /\b403\b/i, + /\bunauthorized\b/i, + /\bforbidden\b/i, + /\bauth(?:entication)? failed\b/i, + /\btoken\b.*\bexpired\b/i, + /\bexpired\b.*\btoken\b/i, + /\binvalid\b.*\b(token|secret|credential|credentials)\b/i, + /\b(credentials?|login)\b.*\b(required|invalid|expired|missing|failed)\b/i, +]; + +const NOT_CONFIGURED_PATTERNS = [ + /\bnot configured\b/i, + /\bnot logged in\b/i, + /\bno credentials?\b/i, + /\blogin required\b/i, + /\bmissing credentials?\b/i, +]; + +const NETWORK_FAILURE_PATTERNS = [ + /\bnetwork\b/i, + /\bfetch failed\b/i, + /\bdns\b/i, + /\btimed? out\b/i, + /\btimeout\b/i, + /\betimedout\b/i, + /\beconnreset\b/i, + /\beconnrefused\b/i, + /\benotfound\b/i, + /\behostunreach\b/i, +]; + +function normalizeErrorText(err) { + return [ + err?.stdout, + err?.stderr, + err?.message, + ] + .filter(Boolean) + .map(value => Buffer.isBuffer(value) ? value.toString('utf8') : String(value)) + .join('\n'); +} + +function matchesAny(text, patterns) { + return patterns.some(pattern => pattern.test(text)); +} + +function classifyDoctorFailure(text, hasKeychain) { + if (matchesAny(text, NOT_CONFIGURED_PATTERNS)) { + return hasKeychain ? 'credentials-invalid' : 'auth-not-configured'; + } + + if (matchesAny(text, AUTH_FAILURE_PATTERNS)) { + return hasKeychain ? 'credentials-invalid' : 'auth-not-configured'; + } + + if (matchesAny(text, NETWORK_FAILURE_PATTERNS)) { + return 'doctor-check-failed'; + } + + return hasKeychain ? 'doctor-check-failed' : 'auth-not-configured'; +} + +async function tryDoctor(exec) { + try { + const { stdout } = await exec('switchbot', ['doctor', '--json'], { timeout: 10000 }); + const parsed = JSON.parse(stdout); + const data = parsed?.data ?? parsed; + return data?.credentials?.configured === true + ? { ok: true } + : { ok: false, reason: 'not-configured' }; + } catch (err) { + if (err?.code === 'ENOENT') throw err; + return { + ok: false, + reason: 'doctor-failed', + detail: normalizeErrorText(err), + }; + } +} + +async function tryKeychainDescribe(exec) { + try { + await exec('switchbot', ['auth', 'keychain', 'describe', '--json'], { timeout: 8000 }); + return true; + } catch { + return false; + } +} + +export function makeCheckCredentials(exec) { + return async function checkCredentials() { + let doctorResult = null; + try { + doctorResult = await tryDoctor(exec); + if (doctorResult.ok) return { ok: true, source: 'doctor' }; + } catch { + // CLI missing — fall through to keychain + } + + const hasKeychainCredentials = await tryKeychainDescribe(exec); + + if (doctorResult?.reason === 'doctor-failed') { + const errorKey = classifyDoctorFailure(doctorResult.detail ?? '', hasKeychainCredentials); + return { ok: false, errorKey, message: formatError(errorKey) }; + } + + if (hasKeychainCredentials) return { ok: true, source: 'keychain' }; + + return { + ok: false, + errorKey: 'auth-not-configured', + message: formatError('auth-not-configured'), + }; + }; +} + +const defaultExec = promisify(execFile); +export const checkCredentials = makeCheckCredentials(defaultExec); diff --git a/packages/codex-plugin/skills/switchbot/SKILL.md b/packages/codex-plugin/skills/switchbot/SKILL.md new file mode 100644 index 00000000..d1d2eeac --- /dev/null +++ b/packages/codex-plugin/skills/switchbot/SKILL.md @@ -0,0 +1,610 @@ +--- +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. + +--- + +## 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/tests/auth.test.js b/packages/codex-plugin/tests/auth.test.js new file mode 100644 index 00000000..cd89dcf4 --- /dev/null +++ b/packages/codex-plugin/tests/auth.test.js @@ -0,0 +1,177 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeRunAuth, makeRunOnInstall } from '../bin/auth.js'; + +function makeOkCliCheck(version = '3.7.1') { + return async () => ({ ok: true, version }); +} +function makeFailCliCheck(msg = 'not found') { + return async () => ({ ok: false, message: msg }); +} +function makeOkCredCheck(source = 'keychain') { + return async () => ({ ok: true, source }); +} +function makeFailCredCheck() { + return async () => ({ ok: false, message: 'no creds' }); +} +function makeSpawn(exitCode = 0) { + const calls = []; + const spawn = (cmd, args) => { + calls.push({ cmd, args }); + return Promise.resolve(exitCode); + }; + return { spawn, calls }; +} + +async function captureStderr(fn) { + const originalWrite = process.stderr.write; + let output = ''; + process.stderr.write = ((chunk, encoding, callback) => { + output += String(chunk); + if (typeof encoding === 'function') encoding(); + if (typeof callback === 'function') callback(); + return true; + }); + + try { + return { code: await fn(), output }; + } finally { + process.stderr.write = originalWrite; + } +} + +describe('runAuth', () => { + it('exits 0 immediately when credentials already present', async () => { + const { spawn, calls } = makeSpawn(0); + const runAuth = makeRunAuth({ + checkCli: makeOkCliCheck(), + checkCredentials: makeOkCredCheck('config'), + runInherit: spawn, + }); + const code = await runAuth(); + assert.equal(code, 0); + assert.equal(calls.length, 0); + }); + + it('exits non-zero when CLI check fails', async () => { + const { spawn } = makeSpawn(0); + const runAuth = makeRunAuth({ + checkCli: makeFailCliCheck('CLI not found'), + checkCredentials: makeFailCredCheck(), + runInherit: spawn, + }); + const code = await runAuth(); + assert.notEqual(code, 0); + }); + + it('calls auth login then doctor when no credentials', async () => { + const { spawn, calls } = makeSpawn(0); + const runAuth = makeRunAuth({ + checkCli: makeOkCliCheck(), + checkCredentials: makeFailCredCheck(), + runInherit: spawn, + }); + const code = await runAuth(); + assert.equal(code, 0); + assert.equal(calls.length, 2); + assert.deepEqual(calls[0], { cmd: 'switchbot', args: ['auth', 'login'] }); + assert.deepEqual(calls[1], { cmd: 'switchbot', args: ['doctor'] }); + }); + + it('exits with login exit code when auth login fails', async () => { + const { spawn, calls } = makeSpawn(1); + const runAuth = makeRunAuth({ + checkCli: makeOkCliCheck(), + checkCredentials: makeFailCredCheck(), + runInherit: spawn, + }); + const code = await runAuth(); + assert.equal(code, 1); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0], { cmd: 'switchbot', args: ['auth', 'login'] }); + }); + + it('exits with doctor exit code when verification fails', async () => { + let callCount = 0; + const spawn = (cmd, args) => { + callCount++; + return Promise.resolve(callCount === 1 ? 0 : 2); + }; + const runAuth = makeRunAuth({ + checkCli: makeOkCliCheck(), + checkCredentials: makeFailCredCheck(), + runInherit: spawn, + }); + const code = await runAuth(); + assert.equal(code, 2); + }); + + it('prints the classified follow-up error when doctor fails after login', async () => { + let callCount = 0; + const spawn = async () => { + callCount++; + return callCount === 1 ? 0 : 2; + }; + let credentialChecks = 0; + const checkCredentials = async () => { + credentialChecks++; + if (credentialChecks === 1) return { ok: false, message: 'missing creds' }; + return { + ok: false, + errorKey: 'doctor-check-failed', + message: 'Error: The CLI could not complete the post-login health check.', + }; + }; + const runAuth = makeRunAuth({ + checkCli: makeOkCliCheck(), + checkCredentials, + runInherit: spawn, + }); + + const result = await captureStderr(() => runAuth()); + assert.equal(result.code, 2); + assert.equal(credentialChecks, 2); + assert.match(result.output, /post-login health check/i); + }); +}); + +describe('runOnInstall', () => { + it('exits 0 and prints setup hint when CLI is missing', async () => { + const { spawn, calls } = makeSpawn(0); + const runOnInstall = makeRunOnInstall({ + checkCli: makeFailCliCheck('CLI not found'), + runInherit: spawn, + }); + + const result = await captureStderr(() => runOnInstall()); + assert.equal(result.code, 0); + assert.equal(calls.length, 0); + assert.match(result.output, /npx @switchbot\/openapi-cli codex setup/); + }); + + it('runs codex setup --yes but still exits 0 when setup fails', async () => { + const { spawn, calls } = makeSpawn(1); + const runOnInstall = makeRunOnInstall({ + checkCli: makeOkCliCheck(), + runInherit: spawn, + }); + + const result = await captureStderr(() => runOnInstall()); + assert.equal(result.code, 0); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0], { cmd: 'switchbot', args: ['codex', 'setup', '--yes'] }); + assert.match(result.output, /plugin install will continue/i); + }); + + it('runs codex setup --yes and exits 0 when setup succeeds', async () => { + const { spawn, calls } = makeSpawn(0); + const runOnInstall = makeRunOnInstall({ + checkCli: makeOkCliCheck(), + runInherit: spawn, + }); + + const code = await runOnInstall(); + assert.equal(code, 0); + assert.deepEqual(calls[0], { cmd: 'switchbot', args: ['codex', 'setup', '--yes'] }); + }); +}); diff --git a/packages/codex-plugin/tests/error-messages.test.js b/packages/codex-plugin/tests/error-messages.test.js new file mode 100644 index 00000000..299c9d4f --- /dev/null +++ b/packages/codex-plugin/tests/error-messages.test.js @@ -0,0 +1,43 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { ERRORS, formatError } from '../lib/error-messages.js'; + +describe('codex-plugin ERRORS registry', () => { + const EXPECTED_KEYS = [ + 'auth-not-configured', + 'auth-login-failed', + 'token-expired', + 'credentials-invalid', + 'doctor-check-failed', + 'cli-not-installed', + 'cli-version-too-low', + ]; + + it('defines all required keys', () => { + for (const key of EXPECTED_KEYS) { + assert.ok(key in ERRORS, `missing key: ${key}`); + } + }); + + it('each entry has reason, fix, and hint', () => { + for (const [key, entry] of Object.entries(ERRORS)) { + assert.ok(entry.reason, `${key}: missing reason`); + assert.ok(entry.fix, `${key}: missing fix`); + assert.ok(entry.hint, `${key}: missing hint`); + } + }); +}); + +describe('formatError (codex-plugin)', () => { + it('returns structured output for auth-login-failed', () => { + const out = formatError('auth-login-failed'); + assert.match(out, /Error:/); + assert.match(out, /Fix:/); + assert.match(out, /Hint:/); + assert.ok(out.includes('auth login'), out); + }); + + it('throws for unknown key', () => { + assert.throws(() => formatError('no-such-key'), /unknown error key/); + }); +}); diff --git a/packages/codex-plugin/tests/install.test.js b/packages/codex-plugin/tests/install.test.js new file mode 100644 index 00000000..c0c76e81 --- /dev/null +++ b/packages/codex-plugin/tests/install.test.js @@ -0,0 +1,181 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeInstall, resolvePluginIdentifier } from '../bin/install.js'; + +function makeOkCliCheck(version = '3.7.1') { + return async () => ({ ok: true, version }); +} +function makeFailCliCheck() { + return async () => ({ ok: false, message: 'CLI not found' }); +} +function makeRunAuth(exitCode = 0) { + const calls = []; + const runAuth = async () => { + calls.push({ fn: 'runAuth' }); + return exitCode; + }; + return { runAuth, calls }; +} +function makeSpawn(exitCode = 0) { + const calls = []; + const spawn = (cmd, args) => { + calls.push({ cmd, args }); + return Promise.resolve(exitCode); + }; + return { spawn, calls }; +} + +const TEST_ROOT = '/fake/codex-plugin'; + +describe('makeInstall', () => { + it('skips npm install when CLI is already present', async () => { + const { spawn, calls } = makeSpawn(0); + const auth = makeRunAuth(0); + const install = makeInstall({ + checkCli: makeOkCliCheck(), + runInherit: spawn, + packageRoot: TEST_ROOT, + runAuth: auth.runAuth, + }); + const code = await install(); + assert.equal(code, 0); + assert.equal(calls.length, 3); + assert.deepEqual(calls[0], { cmd: 'codex', args: ['plugin', 'marketplace', 'add', TEST_ROOT] }); + assert.deepEqual(calls[1], { cmd: 'codex', args: ['plugin', 'add', 'switchbot@codex-plugin'] }); + assert.deepEqual(calls[2], { cmd: 'switchbot', args: ['doctor'] }); + assert.equal(auth.calls.length, 1); + }); + + it('runs npm install first when CLI is missing, then registers and adds plugin', async () => { + const { spawn, calls } = makeSpawn(0); + const auth = makeRunAuth(0); + const install = makeInstall({ + checkCli: makeFailCliCheck(), + runInherit: spawn, + packageRoot: TEST_ROOT, + runAuth: auth.runAuth, + }); + const code = await install(); + assert.equal(code, 0); + assert.equal(calls.length, 4); + 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', 'add', 'switchbot@codex-plugin'] }); + assert.deepEqual(calls[3], { cmd: 'switchbot', args: ['doctor'] }); + assert.equal(auth.calls.length, 1); + }); + + it('exits with npm install exit code and stops when CLI install fails', async () => { + const { spawn, calls } = makeSpawn(1); + const auth = makeRunAuth(0); + const install = makeInstall({ + checkCli: makeFailCliCheck(), + runInherit: spawn, + packageRoot: TEST_ROOT, + runAuth: auth.runAuth, + }); + const code = await install(); + assert.equal(code, 1); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0], { cmd: 'npm', args: ['install', '-g', '@switchbot/openapi-cli@latest'] }); + assert.equal(auth.calls.length, 0); + }); + + it('exits with marketplace add exit code and stops when registration fails', async () => { + let callCount = 0; + const auth = makeRunAuth(0); + const spawn = (cmd, args) => { + callCount++; + return Promise.resolve(callCount === 1 ? 2 : 0); + }; + const install = makeInstall({ + checkCli: makeOkCliCheck(), + runInherit: spawn, + packageRoot: TEST_ROOT, + runAuth: auth.runAuth, + }); + const code = await install(); + assert.equal(code, 2); + assert.equal(callCount, 1); + assert.equal(auth.calls.length, 0); + }); + + it('propagates plugin add exit code', async () => { + let callCount = 0; + const auth = makeRunAuth(0); + const spawn = (cmd, args) => { + callCount++; + return Promise.resolve(callCount === 2 ? 3 : 0); + }; + const install = makeInstall({ + checkCli: makeOkCliCheck(), + runInherit: spawn, + packageRoot: TEST_ROOT, + runAuth: auth.runAuth, + }); + const code = await install(); + assert.equal(code, 3); + assert.equal(callCount, 2); + assert.equal(auth.calls.length, 0); + }); + + it('propagates auth exit code after plugin install succeeds', async () => { + const { spawn, calls } = makeSpawn(0); + const auth = makeRunAuth(4); + const install = makeInstall({ + checkCli: makeOkCliCheck(), + runInherit: spawn, + packageRoot: TEST_ROOT, + runAuth: auth.runAuth, + }); + const code = await install(); + assert.equal(code, 4); + assert.equal(calls.length, 2); + assert.equal(auth.calls.length, 1); + }); + + it('propagates final doctor exit code after auth succeeds', async () => { + const calls = []; + const spawn = (cmd, args) => { + calls.push({ cmd, args }); + return Promise.resolve(cmd === 'switchbot' ? 5 : 0); + }; + const auth = makeRunAuth(0); + const install = makeInstall({ + checkCli: makeOkCliCheck(), + runInherit: spawn, + packageRoot: TEST_ROOT, + runAuth: auth.runAuth, + }); + const code = await install(); + assert.equal(code, 5); + assert.equal(calls.length, 3); + assert.deepEqual(calls[2], { cmd: 'switchbot', args: ['doctor'] }); + assert.equal(auth.calls.length, 1); + }); + + it('returns 127 with a codex-specific message when codex is missing', async () => { + let callCount = 0; + const auth = makeRunAuth(0); + const spawn = () => { + callCount++; + return Promise.resolve(127); + }; + const install = makeInstall({ + checkCli: makeOkCliCheck(), + runInherit: spawn, + packageRoot: TEST_ROOT, + runAuth: auth.runAuth, + }); + const code = await install(); + assert.equal(code, 127); + assert.equal(callCount, 1); + assert.equal(auth.calls.length, 0); + }); +}); + +describe('resolvePluginIdentifier', () => { + it('falls back to basename when the plugin manifest is unavailable', () => { + assert.equal(resolvePluginIdentifier('/fake/codex-plugin'), 'switchbot@codex-plugin'); + }); +}); diff --git a/packages/codex-plugin/tests/resolve-marketplace-source-root.test.js b/packages/codex-plugin/tests/resolve-marketplace-source-root.test.js new file mode 100644 index 00000000..ed85fb9c --- /dev/null +++ b/packages/codex-plugin/tests/resolve-marketplace-source-root.test.js @@ -0,0 +1,84 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { resolveMarketplaceSourceRoot } from '../bin/install.js'; + +const SCOPED_ROOT = 'C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@switchbot\\codex-plugin'; + +function makeDeps(overrides = {}) { + return { + mkdirSync: () => undefined, + lstatSync: () => null, + realpathSync: (p) => p, + symlinkSync: () => undefined, + unlinkSync: () => undefined, + ...overrides, + }; +} + +describe('resolveMarketplaceSourceRoot', () => { + it('returns packageRoot unchanged on non-Windows or non-scoped paths', () => { + const calls = []; + const deps = makeDeps({ + mkdirSync: () => calls.push('mkdir'), + symlinkSync: () => calls.push('symlink'), + }); + if (process.platform === 'win32') { + assert.equal(resolveMarketplaceSourceRoot('C:\\plain\\path', deps), 'C:\\plain\\path'); + } else { + assert.equal(resolveMarketplaceSourceRoot(SCOPED_ROOT, deps), SCOPED_ROOT); + } + assert.deepEqual(calls, []); + }); + + it('creates a junction when the alias is missing (win32 only)', { skip: process.platform !== 'win32' }, () => { + const created = []; + const deps = makeDeps({ + lstatSync: () => null, + mkdirSync: (p) => created.push(['mkdir', p]), + symlinkSync: (target, link) => created.push(['symlink', target, link]), + }); + const resolved = resolveMarketplaceSourceRoot(SCOPED_ROOT, deps); + assert.match(resolved, /codex-plugin-marketplace$/); + assert.equal(created.length, 2); + assert.equal(created[0][0], 'mkdir'); + assert.equal(created[1][0], 'symlink'); + assert.equal(created[1][1], SCOPED_ROOT); + }); + + it('reuses a healthy junction (win32 only)', { skip: process.platform !== 'win32' }, () => { + const calls = []; + const deps = makeDeps({ + lstatSync: () => ({ isSymbolicLink: () => true }), + realpathSync: () => SCOPED_ROOT, + symlinkSync: () => calls.push('symlink'), + unlinkSync: () => calls.push('unlink'), + }); + const resolved = resolveMarketplaceSourceRoot(SCOPED_ROOT, deps); + assert.match(resolved, /codex-plugin-marketplace$/); + assert.deepEqual(calls, []); + }); + + it('repairs a stale junction (win32 only)', { skip: process.platform !== 'win32' }, () => { + const calls = []; + const realpaths = ['D:\\old\\@switchbot\\codex-plugin', SCOPED_ROOT]; + const deps = makeDeps({ + lstatSync: () => ({ isSymbolicLink: () => true }), + realpathSync: () => realpaths.shift(), + unlinkSync: (p) => calls.push(['unlink', p]), + symlinkSync: (target, link) => calls.push(['symlink', target, link]), + }); + const resolved = resolveMarketplaceSourceRoot(SCOPED_ROOT, deps); + assert.match(resolved, /codex-plugin-marketplace$/); + assert.equal(calls.length, 2); + assert.equal(calls[0][0], 'unlink'); + assert.equal(calls[1][0], 'symlink'); + assert.equal(calls[1][1], SCOPED_ROOT); + }); + + it('throws on a real directory at the alias path (win32 only)', { skip: process.platform !== 'win32' }, () => { + const deps = makeDeps({ + lstatSync: () => ({ isSymbolicLink: () => false }), + }); + assert.throws(() => resolveMarketplaceSourceRoot(SCOPED_ROOT, deps), /exists and is not a junction/); + }); +}); diff --git a/packages/codex-plugin/tests/setup.test.js b/packages/codex-plugin/tests/setup.test.js new file mode 100644 index 00000000..180c6b7b --- /dev/null +++ b/packages/codex-plugin/tests/setup.test.js @@ -0,0 +1,145 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { makeCheckCli } from '../setup/check-cli.js'; + +describe('checkCli', () => { + it('returns ok:true when CLI is >= 3.7.1', async () => { + const fakeExec = async () => ({ stdout: '3.7.1\n' }); + const checkCli = makeCheckCli(fakeExec); + const result = await checkCli(); + assert.deepEqual(result, { ok: true, version: '3.7.1' }); + }); + + it('returns ok:false when CLI is below minimum', async () => { + const fakeExec = async () => ({ stdout: '3.2.9\n' }); + const checkCli = makeCheckCli(fakeExec); + const result = await checkCli(); + assert.equal(result.ok, false); + assert.match(result.message, /3\.2\.9/); + assert.match(result.message, /3\.7\.1/); + }); + + it('returns ok:false when CLI is missing (ENOENT)', async () => { + const err = Object.assign(new Error('not found'), { code: 'ENOENT' }); + const fakeExec = async () => { throw err; }; + const checkCli = makeCheckCli(fakeExec); + const result = await checkCli(); + assert.equal(result.ok, false); + assert.match(result.message, /not found/i); + }); + + it('returns ok:false when version string is unparseable', async () => { + const fakeExec = async () => ({ stdout: 'development\n' }); + const checkCli = makeCheckCli(fakeExec); + const result = await checkCli(); + assert.equal(result.ok, false); + assert.match(result.message, /Upgrade/); + }); + + it('returns ok:true when CLI is above minimum (e.g. 5.0.0)', async () => { + const fakeExec = async () => ({ stdout: '5.0.0\n' }); + const checkCli = makeCheckCli(fakeExec); + const result = await checkCli(); + assert.deepEqual(result, { ok: true, version: '5.0.0' }); + }); + + it('returns ok:false on non-ENOENT exec error', async () => { + const fakeExec = async () => { throw new Error('permission denied'); }; + const checkCli = makeCheckCli(fakeExec); + const result = await checkCli(); + assert.equal(result.ok, false); + assert.match(result.message, /permission denied/); + }); +}); + +import { makeCheckCredentials } from '../setup/check-credentials.js'; + +describe('checkCredentials', () => { + it('returns ok:true source:doctor when doctor reports credentials.configured:true', async () => { + const fakeExec = async (cmd, args) => { + if (args.includes('doctor')) { + return { stdout: JSON.stringify({ data: { credentials: { configured: true } } }) }; + } + throw new Error('unexpected call'); + }; + const check = makeCheckCredentials(fakeExec); + const result = await check(); + assert.deepEqual(result, { ok: true, source: 'doctor' }); + }); + + it('returns credentials-invalid when doctor reports an auth failure and keychain credentials exist', async () => { + const fakeExec = async (cmd, args) => { + if (args.includes('doctor')) { + const err = new Error('401 Unauthorized'); + err.stderr = 'HTTP 401 unauthorized'; + throw err; + } + if (args.includes('describe')) return { stdout: '{}' }; + throw new Error('unexpected'); + }; + const check = makeCheckCredentials(fakeExec); + const result = await check(); + assert.equal(result.ok, false); + assert.equal(result.errorKey, 'credentials-invalid'); + assert.match(result.message, /rejected/i); + assert.match(result.message, /switchbot auth logout/); + }); + + it('returns doctor-check-failed when doctor errors look like network failures', async () => { + const fakeExec = async (cmd, args) => { + if (args.includes('doctor')) { + const err = new Error('ETIMEDOUT'); + err.stderr = 'connect ETIMEDOUT api.switch-bot.com'; + throw err; + } + if (args.includes('describe')) return { stdout: '{}' }; + throw new Error('unexpected'); + }; + const check = makeCheckCredentials(fakeExec); + const result = await check(); + assert.equal(result.ok, false); + assert.equal(result.errorKey, 'doctor-check-failed'); + assert.match(result.message, /health check/i); + assert.match(result.message, /switchbot doctor/); + }); + + it('returns ok:false when both doctor and keychain describe fail', async () => { + const fakeExec = async () => { throw new Error('all fail'); }; + const check = makeCheckCredentials(fakeExec); + const result = await check(); + assert.equal(result.ok, false); + assert.equal(result.errorKey, 'auth-not-configured'); + assert.match(result.message, /switchbot auth login/); + }); + + it('never passes token or secret values to exec', async () => { + const passedArgs = []; + const fakeExec = async (cmd, args) => { + passedArgs.push(...args); + if (args.includes('doctor')) { + return { stdout: JSON.stringify({ data: { credentials: { configured: true } } }) }; + } + throw new Error('unexpected'); + }; + const check = makeCheckCredentials(fakeExec); + await check(); + const sensitive = passedArgs.filter( + (a) => typeof a === 'string' && (a.includes('token') || a.includes('secret')) + ); + assert.deepEqual(sensitive, [], `Sensitive args leaked: ${sensitive.join(', ')}`); + }); + + it('falls back to keychain when doctor returns credentials.configured:false', async () => { + const fakeExec = async (cmd, args) => { + if (args.includes('doctor')) { + return { stdout: JSON.stringify({ data: { credentials: { configured: false } } }) }; + } + if (args.includes('describe')) return { stdout: '{}' }; + throw new Error('unexpected'); + }; + const check = makeCheckCredentials(fakeExec); + const result = await check(); + assert.deepEqual(result, { ok: true, source: 'keychain' }); + }); +}); diff --git a/packages/openclaw-skill/.claude-plugin/plugin.json b/packages/openclaw-skill/.claude-plugin/plugin.json new file mode 100644 index 00000000..9f8caa3f --- /dev/null +++ b/packages/openclaw-skill/.claude-plugin/plugin.json @@ -0,0 +1,15 @@ +{ + "name": "switchbot", + "version": "0.1.0", + "description": "Control SwitchBot smart-home devices (lights, locks, curtains, sensors, plugs, IR appliances) from an OpenClaw agent via MCP. Drives @switchbot/openapi-cli >= 3.7.1.", + "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli/tree/main/packages/openclaw-skill", + "repository": "https://github.com/OpenWonderLabs/switchbot-openapi-cli", + "license": "MIT", + "keywords": [ + "switchbot", + "smart-home", + "iot", + "mcp", + "openclaw" + ] +} diff --git a/packages/openclaw-skill/.mcp.json b/packages/openclaw-skill/.mcp.json new file mode 100644 index 00000000..41cc48a2 --- /dev/null +++ b/packages/openclaw-skill/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "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)" + } + } +} diff --git a/packages/openclaw-skill/README.md b/packages/openclaw-skill/README.md new file mode 100644 index 00000000..f4defa9e --- /dev/null +++ b/packages/openclaw-skill/README.md @@ -0,0 +1,82 @@ +# @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. + +## Prerequisites + +- Node.js 18+ +- SwitchBot API credentials (`switchbot config set-token`) — the CLI itself is **auto-installed** on first launch + +## Installation + +```bash +# Via OpenClaw plugin manager (recommended) +openclaw plugins install @switchbot/openclaw-skill + +# Or global npm +npm install -g @switchbot/openclaw-skill + +# Either way, then bootstrap the underlying CLI + credentials: +switchbot-openclaw setup +``` + +`switchbot-openclaw setup` verifies `@switchbot/openapi-cli` is +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: + +| Tool | Description | +|---|---| +| `devices_list` | List all devices in the account | +| `devices_status` | Get current status of a device | +| `devices_describe` | List supported commands for a device type | +| `devices_command` | Send a command (turnOn, turnOff, setBrightness, …) | +| `scenes_list` | List all saved scenes | +| `scenes_run` | Execute a scene by ID | +| `rules_list` | List automation rules | +| `rules_suggest` | Ask AI to suggest a new rule based on intent | +| `rules_explain` | Explain why a rule fired or was blocked (with trace) | +| `rules_simulate` | Replay rule against historical events before enabling | +| `daemon_start` / `daemon_stop` / `daemon_status` | Control the automation rule engine | +| `audit_query` | Query the audit log for device/rule history | + +Full tool reference: `switchbot mcp tools` + +## Usage + +The server communicates over **stdio** (MCP protocol). OpenClaw launches +the MCP server via the declarations in: + +- `.claude-plugin/plugin.json` — bundle identity +- `.mcp.json` — stdio launcher (`node ${pluginDir}/bin/start.js`) + +**First launch auto-setup**: if `@switchbot/openapi-cli` is not installed, +`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. + +To start manually (for debugging): + +```bash +switchbot-openclaw +``` + +## Policy editor + +A local browser-based editor for `~/.config/openclaw/switchbot/policy.yaml`: + +```bash +switchbot-policy-edit +# Opens http://localhost:18799 +``` + +## Configuration + +Edit `~/.config/openclaw/switchbot/policy.yaml` to set device aliases, quiet hours, and confirmation rules. See the [Policy section](https://github.com/OpenWonderLabs/switchbot-openapi-cli#policy) of the CLI README. + +## License + +MIT diff --git a/packages/openclaw-skill/bin/policy-edit.js b/packages/openclaw-skill/bin/policy-edit.js new file mode 100644 index 00000000..c2e61514 --- /dev/null +++ b/packages/openclaw-skill/bin/policy-edit.js @@ -0,0 +1,12 @@ +#!/usr/bin/env node +// packages/openclaw-skill/bin/policy-edit.js — invoked as `switchbot-policy-edit` +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const { startEditorServer } = await import(join(__dirname, '../policy-editor/server.js')); + +const server = await startEditorServer({ port: 18799 }); +console.log(`Policy editor: http://localhost:${server.port}`); +const open = (await import('open').catch(() => null))?.default; +if (open) await open(`http://localhost:${server.port}`); diff --git a/packages/openclaw-skill/bin/setup-flow.js b/packages/openclaw-skill/bin/setup-flow.js new file mode 100644 index 00000000..575aa759 --- /dev/null +++ b/packages/openclaw-skill/bin/setup-flow.js @@ -0,0 +1,132 @@ +// packages/openclaw-skill/bin/setup-flow.js +// Interactive setup for the SwitchBot OpenClaw plugin. Guides the user +// through installing the underlying @switchbot/openapi-cli, verifying +// version >= 3.7.1, and confirming credentials via `switchbot doctor`. +// +// This module is invoked only via `switchbot-openclaw setup`. It is +// never loaded on the MCP-server path, so it is free to write to stdout. + +import { execFile, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; +import { formatError } from '../lib/error-messages.js'; + +const exec = promisify(execFile); +const REQUIRED_CLI = '3.7.1'; + +async function hasCli() { + try { + await exec('switchbot', ['--version'], { timeout: 5000 }); + return true; + } catch (err) { + if (err && err.code === 'ENOENT') return false; + // Non-ENOENT errors still mean the binary ran; treat as present. + return true; + } +} + +async function cliVersion() { + try { + const { stdout } = await exec('switchbot', ['--version'], { timeout: 5000 }); + const m = stdout.trim().match(/\d+\.\d+\.\d+/); + return m ? m[0] : null; + } catch { + return null; + } +} + +function versionAtLeast(have, need) { + const a = have.split('.').map((n) => parseInt(n, 10) || 0); + const b = need.split('.').map((n) => parseInt(n, 10) || 0); + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const ai = a[i] ?? 0; + const bi = b[i] ?? 0; + if (ai > bi) return true; + if (ai < bi) return false; + } + return true; +} + +async function npmPrefix() { + try { + const { stdout } = await exec('npm', ['config', 'get', 'prefix'], { timeout: 5000 }); + return stdout.trim(); + } catch { + return null; + } +} + +function prefixLikelyNeedsSudo(prefix) { + if (!prefix) return false; + if (process.platform === 'win32') return false; + return /^\/usr(\/|$)/.test(prefix) || /^\/opt(\/|$)/.test(prefix); +} + +function runInherit(cmd, args) { + return new Promise((resolve) => { + const p = spawn(cmd, args, { stdio: 'inherit' }); + p.on('close', (code) => resolve(code ?? 0)); + p.on('error', () => resolve(127)); + }); +} + +export async function runSetup() { + console.log('SwitchBot plugin setup'); + console.log('======================'); + console.log(''); + + // Step 1: CLI on PATH? + if (!(await hasCli())) { + console.log('[1/3] SwitchBot CLI not found on PATH.'); + console.log(''); + console.log(formatError('cli-not-installed')); + const prefix = await npmPrefix(); + if (prefixLikelyNeedsSudo(prefix)) { + console.log(''); + console.log(`Your npm global prefix is system-owned (${prefix}), so the install`); + console.log('will fail with EACCES unless you pick one of:'); + console.log(' sudo npm install -g @switchbot/openapi-cli@latest'); + console.log(' — or change the prefix first:'); + console.log(' npm config set prefix ~/.npm-global'); + console.log(' export PATH="$HOME/.npm-global/bin:$PATH"'); + } + console.log(''); + console.log('Then re-run: switchbot-openclaw setup'); + process.exit(1); + } + + const version = await cliVersion(); + console.log(`[1/3] SwitchBot CLI detected (version: ${version ?? 'unknown'}).`); + + // Step 2: version gate + if (!version) { + console.log(''); + console.log(formatError('cli-version-too-low')); + process.exit(1); + } + if (!versionAtLeast(version, REQUIRED_CLI)) { + console.log(''); + console.log(`[2/3] CLI ${version} is below the ${REQUIRED_CLI} minimum required by this plugin.`); + console.log(formatError('cli-version-too-low')); + process.exit(1); + } + console.log(`[2/3] Version satisfies >= ${REQUIRED_CLI}.`); + console.log(''); + + // Step 3: auth / connectivity via doctor + console.log('[3/3] Running `switchbot doctor` to verify credentials and connectivity...'); + console.log(''); + const code = await runInherit('switchbot', ['doctor']); + if (code !== 0) { + console.log(''); + console.log(formatError('token-expired')); + console.log(''); + console.log('Then re-run: switchbot-openclaw setup'); + process.exit(1); + } + + console.log(''); + console.log('Setup complete.'); + console.log('Restart your MCP host (OpenClaw / Claude Desktop / Cursor / …) to'); + console.log('pick up the switchbot plugin. The MCP server starts automatically'); + console.log('when invoked with no arguments: `switchbot-openclaw`.'); +} diff --git a/packages/openclaw-skill/bin/start.js b/packages/openclaw-skill/bin/start.js new file mode 100644 index 00000000..9925faeb --- /dev/null +++ b/packages/openclaw-skill/bin/start.js @@ -0,0 +1,74 @@ +#!/usr/bin/env node +// packages/openclaw-skill/bin/start.js +// +// Default entry point for the @switchbot/openclaw-skill plugin. +// +// Subcommands: +// setup Interactive bootstrap: verify CLI installed + >=3.7.1, +// run `switchbot doctor` to confirm auth. +// --version Print the plugin version. +// --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). +// Only credential setup requires user action; everything else is automatic. + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { execFileSync } from 'node:child_process'; + +const cmd = process.argv[2]; + +if (cmd === 'setup') { + const { runSetup } = await import('./setup-flow.js'); + await runSetup(); +} else if (cmd === '--version' || cmd === '-v') { + const here = dirname(fileURLToPath(import.meta.url)); + const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')); + console.log(pkg.version); +} else if (cmd === '--help' || cmd === '-h' || cmd === 'help') { + console.log(`switchbot-openclaw — MCP plugin for SwitchBot smart-home control + +Usage: + switchbot-openclaw Start the stdio MCP server (default; used by MCP hosts). + switchbot-openclaw setup Interactive CLI install + token bootstrap. + switchbot-openclaw --version Print the plugin version. + switchbot-openclaw --help Show this help. + +See https://github.com/OpenWonderLabs/switchbot-openapi-cli for full docs.`); +} else { + // Default: bootstrap wrapper — auto-configure then hand off to switchbot mcp serve. + // MCP mode must NOT write to stdout (it's the JSON-RPC channel); use stderr for logs. + + const { checkCli } = await import('../setup/check-cli.js'); + const { checkCredentials } = await import('../setup/check-credentials.js'); + const { checkDaemon } = await import('../setup/check-daemon.js'); + + function setupRequired(message) { + process.stderr.write(`[switchbot-channel] Setup required: ${message}\n`); + process.stdout.write(JSON.stringify({ setupRequired: true, message }) + '\n'); + process.exit(1); + } + + // [1] CLI installed? → auto-install if missing + const cliCheck = await checkCli(); + if (!cliCheck.ok) setupRequired(cliCheck.message); + + // [2] Credentials configured? (must be done by user — can't automate token input) + const credCheck = await checkCredentials(); + if (!credCheck.ok) setupRequired(credCheck.message); + + // [3] Daemon needed? → auto-start if automation rules are active + await checkDaemon(); + + // [4] Hand off to switchbot mcp serve — exposes all 24 MCP tools + try { + execFileSync('switchbot', ['mcp', 'serve'], { stdio: 'inherit' }); + } catch (err) { + process.stderr.write( + `[switchbot-channel] switchbot mcp serve exited: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(1); + } +} diff --git a/packages/openclaw-skill/cli.js b/packages/openclaw-skill/cli.js new file mode 100644 index 00000000..4f8e422e --- /dev/null +++ b/packages/openclaw-skill/cli.js @@ -0,0 +1,108 @@ +// 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 new file mode 100644 index 00000000..ac35b93e --- /dev/null +++ b/packages/openclaw-skill/index.js @@ -0,0 +1,19 @@ +// packages/openclaw-skill/index.js +import { buildCliArgs, runCli } from './cli.js'; + +const TOOLS = [ + 'devices_list', + 'devices_status', + 'devices_describe', + 'devices_command', + 'scenes_list', + 'scenes_run', +]; + +export function createServer() { + const _registeredTools = {}; + for (const tool of TOOLS) { + _registeredTools[tool] = (params) => runCli(buildCliArgs({ tool, params })); + } + return { _registeredTools }; +} diff --git a/packages/openclaw-skill/lib/error-messages.js b/packages/openclaw-skill/lib/error-messages.js new file mode 100644 index 00000000..003ea28b --- /dev/null +++ b/packages/openclaw-skill/lib/error-messages.js @@ -0,0 +1,37 @@ +export const ERRORS = { + 'auth-not-configured': { + reason: 'SwitchBot credentials are not configured.', + fix: 'switchbot auth login', + hint: 'Run the fix command, then restart your MCP client.', + }, + 'auth-login-failed': { + reason: 'Login failed — the CLI returned a non-zero exit code.', + fix: 'switchbot auth login', + hint: 'Check your network connection and try again.', + }, + 'token-expired': { + reason: 'Credentials exist but doctor check failed — token may be expired.', + fix: 'switchbot auth logout && switchbot auth login', + hint: 'After re-login, run `switchbot doctor` to verify.', + }, + 'cli-not-installed': { + reason: 'switchbot CLI is not installed or not in PATH.', + fix: 'npm install -g @switchbot/openapi-cli', + hint: 'After install, run `switchbot doctor` to confirm.', + }, + 'cli-version-too-low': { + reason: 'switchbot CLI version is below the required minimum (3.7.1).', + fix: 'npm install -g @switchbot/openapi-cli@latest', + hint: 'After upgrade, re-run setup.', + }, +}; + +export function formatError(key) { + const e = ERRORS[key]; + if (!e) throw new Error(`unknown error key: ${key}`); + return [ + `Error: ${e.reason}`, + ` Fix: ${e.fix}`, + ` Hint: ${e.hint}`, + ].join('\n'); +} diff --git a/packages/openclaw-skill/package.json b/packages/openclaw-skill/package.json new file mode 100644 index 00000000..3bcb34de --- /dev/null +++ b/packages/openclaw-skill/package.json @@ -0,0 +1,57 @@ +{ + "name": "@switchbot/openclaw-skill", + "version": "0.1.0", + "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.", + "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli/tree/main/packages/openclaw-skill", + "repository": { + "type": "git", + "url": "git+https://github.com/OpenWonderLabs/switchbot-openapi-cli.git", + "directory": "packages/openclaw-skill" + }, + "bugs": "https://github.com/OpenWonderLabs/switchbot-openapi-cli/issues", + "main": "index.js", + "bin": { + "switchbot-openclaw": "./bin/start.js", + "switchbot-policy-edit": "./bin/policy-edit.js" + }, + "keywords": [ + "openclaw", + "clawhub", + "switchbot", + "mcp", + "smart-home", + "iot", + "claude-plugin" + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "dependencies": {}, + "optionalDependencies": { + "open": "^10.0.0" + }, + "peerDependencies": { + "@switchbot/openapi-cli": ">=3.7.1" + }, + "files": [ + "index.js", + "cli.js", + ".claude-plugin/", + ".mcp.json", + "bin/", + "lib/", + "setup/", + "policy-editor/", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "test": "node --test tests/*.test.js", + "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" + } +} diff --git a/packages/openclaw-skill/policy-editor/editor.js b/packages/openclaw-skill/policy-editor/editor.js new file mode 100644 index 00000000..39da5ab8 --- /dev/null +++ b/packages/openclaw-skill/policy-editor/editor.js @@ -0,0 +1,136 @@ +// policy-editor/editor.js +const $ = id => document.getElementById(id); + +async function loadPolicy() { + const res = await fetch('/policy'); + return await res.text(); +} + +async function savePolicy(yaml) { + const res = await fetch('/policy', { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: yaml, + }); + return res.ok; +} + +function parseField(yaml, key) { + const m = yaml.match(new RegExp(`^\\s*${key}:\\s*(.+)$`, 'm')); + return m ? m[1].trim().replace(/^"|"$/g, '') : ''; +} + +function setField(yaml, key, value) { + const re = new RegExp(`(^\\s*${key}:).*$`, 'm'); + return re.test(yaml) ? yaml.replace(re, `$1 "${value}"`) : yaml; +} + +// ── Aliases ────────────────────────────────────────────────────────────────── + +function parseAliases(yaml) { + const section = yaml.match(/^aliases:\s*\n((?:[ \t]+.+\n)*)/m); + if (!section) return {}; + const aliases = {}; + for (const line of section[1].split('\n')) { + const m = line.match(/^\s+"?([^":]+)"?\s*:\s*"?([^"]+)"?\s*$/); + if (m) aliases[m[1].trim()] = m[2].trim(); + } + return aliases; +} + +function renderAliases(aliases) { + const list = $('aliases-list'); + list.innerHTML = ''; + for (const [alias, deviceId] of Object.entries(aliases)) { + const row = document.createElement('div'); + row.className = 'alias-row'; + row.innerHTML = `${alias} + ${deviceId} + `; + list.appendChild(row); + } + list.querySelectorAll('.alias-delete').forEach(btn => { + btn.addEventListener('click', () => { + const currentAliases = parseAliases($('raw-yaml').value); + delete currentAliases[btn.dataset.alias]; + $('raw-yaml').value = writeAliases($('raw-yaml').value, currentAliases); + renderAliases(currentAliases); + }); + }); +} + +function writeAliases(yaml, aliases) { + const entries = Object.entries(aliases) + .map(([a, id]) => ` "${a}": "${id}"`) + .join('\n'); + const block = entries ? `aliases:\n${entries}\n` : `aliases:\n`; + return yaml.replace(/^aliases:\s*\n((?:[ \t]+.+\n)*)*/m, block); +} + +$('add-alias').addEventListener('click', () => { + const alias = prompt('Friendly name (e.g. "bedroom light"):'); + if (!alias?.trim()) return; + const deviceId = prompt(`Device ID for "${alias.trim()}":`); + if (!deviceId?.trim()) return; + const currentAliases = parseAliases($('raw-yaml').value); + currentAliases[alias.trim()] = deviceId.trim(); + $('raw-yaml').value = writeAliases($('raw-yaml').value, currentAliases); + renderAliases(currentAliases); +}); + +// ── Confirm-lock ───────────────────────────────────────────────────────────── + +function setConfirmLock(yaml, enabled) { + if (enabled) { + // Ensure lock and unlock appear in always_confirm + if (!/always_confirm/.test(yaml)) { + yaml = yaml.replace(/(confirmations:\s*\n)/, '$1 always_confirm: ["lock", "unlock"]\n'); + } else { + yaml = yaml.replace(/^(\s*always_confirm:\s*\[)\]/m, '$1"lock", "unlock"]'); + } + } else { + yaml = yaml.replace(/"lock",?\s*/g, '').replace(/"unlock",?\s*/g, ''); + yaml = yaml.replace(/,\s*\]/g, ']'); + } + return yaml; +} + +// ── Initialise ──────────────────────────────────────────────────────────────── + +const rawYaml = await loadPolicy(); +$('raw-yaml').value = rawYaml; + +const quietStart = parseField(rawYaml, 'start'); +const quietEnd = parseField(rawYaml, 'end'); +if (quietStart) $('quiet-start').value = quietStart; +if (quietEnd) $('quiet-end').value = quietEnd; + +$('confirm-lock').checked = /always_confirm[^]]*lock/.test(rawYaml); + +renderAliases(parseAliases(rawYaml)); + +// ── Save ────────────────────────────────────────────────────────────────────── + +$('save-btn').addEventListener('click', async () => { + let yaml = $('raw-yaml').value; + yaml = setField(yaml, 'start', $('quiet-start').value); + yaml = setField(yaml, 'end', $('quiet-end').value); + yaml = setConfirmLock(yaml, $('confirm-lock').checked); + $('raw-yaml').value = yaml; + const ok = await savePolicy(yaml); + $('status').textContent = ok ? 'Saved' : 'Error'; + setTimeout(() => { $('status').textContent = ''; }, 2000); +}); + +['quiet-start', 'quiet-end'].forEach(id => { + $(id).addEventListener('change', () => { + let yaml = $('raw-yaml').value; + yaml = setField(yaml, 'start', $('quiet-start').value); + yaml = setField(yaml, 'end', $('quiet-end').value); + $('raw-yaml').value = yaml; + }); +}); + +$('confirm-lock').addEventListener('change', () => { + $('raw-yaml').value = setConfirmLock($('raw-yaml').value, $('confirm-lock').checked); +}); diff --git a/packages/openclaw-skill/policy-editor/index.html b/packages/openclaw-skill/policy-editor/index.html new file mode 100644 index 00000000..c0dda262 --- /dev/null +++ b/packages/openclaw-skill/policy-editor/index.html @@ -0,0 +1,37 @@ + + + + + + SwitchBot Policy Editor + + + +

SwitchBot Policy

+
+
+

Device Aliases

+
+ +
+
+

Confirmations

+ +
+
+

Quiet Hours

+ + +
+
+

Raw YAML

+ +
+
+ + +
+
+ + + diff --git a/packages/openclaw-skill/policy-editor/server.js b/packages/openclaw-skill/policy-editor/server.js new file mode 100644 index 00000000..d9f794f2 --- /dev/null +++ b/packages/openclaw-skill/policy-editor/server.js @@ -0,0 +1,70 @@ +// policy-editor/server.js +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const POLICY_PATH = process.env.SWITCHBOT_POLICY_PATH + ?? path.join(process.env.HOME ?? process.env.USERPROFILE, '.config/openclaw/switchbot/policy.yaml'); + +export function startEditorServer({ port = 18799, dryRun = false } = {}) { + const server = http.createServer((req, res) => { + if (req.method === 'GET' && req.url === '/') { + const html = fs.readFileSync(path.join(__dirname, 'index.html')); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(html); + } else if (req.method === 'GET' && req.url === '/policy') { + const content = fs.existsSync(POLICY_PATH) + ? fs.readFileSync(POLICY_PATH, 'utf8') + : ''; + res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end(content); + } else if (req.method === 'POST' && req.url === '/policy') { + let body = ''; + const MAX_BYTES = 512 * 1024; // 512 KB — policy.yaml should never exceed this + req.on('data', c => { + if (body.length + c.length > MAX_BYTES) { + res.writeHead(413); res.end('payload too large'); + req.destroy(); + return; + } + body += c; + }); + req.on('end', () => { + if (res.headersSent) return; + if (!dryRun) { + fs.mkdirSync(path.dirname(POLICY_PATH), { recursive: true }); + fs.writeFileSync(POLICY_PATH, body, 'utf8'); + } + res.writeHead(200); res.end('saved'); + }); + } else if (req.method === 'GET' && /^\/(editor\.js|style\.css)$/.test(req.url)) { + const file = path.join(__dirname, req.url); + const mime = req.url.endsWith('.js') ? 'application/javascript' : 'text/css'; + res.writeHead(200, { 'Content-Type': mime }); + res.end(fs.readFileSync(file)); + } else { + res.writeHead(404); res.end(); + } + }); + + return new Promise(resolve => { + server.listen(dryRun ? 0 : port, '127.0.0.1', () => { + const addr = server.address(); + server.port = addr.port; + resolve(server); + }); + }); +} + +// CLI entry point +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const server = await startEditorServer(); + const url = `http://localhost:${server.port}`; + console.log(`Policy editor running at ${url}`); + try { + const open = (await import('open')).default; + await open(url); + } catch { console.log(`Open your browser: ${url}`); } +} diff --git a/packages/openclaw-skill/policy-editor/style.css b/packages/openclaw-skill/policy-editor/style.css new file mode 100644 index 00000000..48b3f394 --- /dev/null +++ b/packages/openclaw-skill/policy-editor/style.css @@ -0,0 +1,10 @@ +body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; } +header h1 { font-size: 1.4rem; margin-bottom: 1.5rem; } +section { margin-bottom: 2rem; border: 1px solid #e0e0e0; border-radius: 6px; padding: 1rem; } +h2 { margin-top: 0; font-size: 1rem; color: #555; } +label { display: block; margin: 0.4rem 0; } +input[type="time"] { margin: 0 0.5rem; } +textarea { width: 100%; font-family: monospace; font-size: 0.85rem; border: 1px solid #ccc; border-radius: 4px; padding: 0.5rem; box-sizing: border-box; } +button { padding: 0.5rem 1rem; border-radius: 4px; border: 1px solid #ccc; cursor: pointer; } +#save-btn { background: #1a56db; color: white; border-color: #1a56db; } +#status { margin-left: 1rem; color: #555; } diff --git a/packages/openclaw-skill/setup/check-cli.js b/packages/openclaw-skill/setup/check-cli.js new file mode 100644 index 00000000..deeadb0d --- /dev/null +++ b/packages/openclaw-skill/setup/check-cli.js @@ -0,0 +1,68 @@ +// setup/check-cli.js — verify switchbot CLI is installed; auto-install via npm if missing +import { execFile, execFileSync } from 'node:child_process'; +import { promisify } from 'node:util'; +import { formatError } from '../lib/error-messages.js'; + +const exec = promisify(execFile); + +const SHELL = process.platform === 'win32'; + +async function cliExists() { + try { + await exec('switchbot', ['--version'], { timeout: 8000, shell: SHELL }); + return true; + } catch { + return false; + } +} + +function npmExists() { + try { + execFileSync('npm', ['--version'], { timeout: 8000, stdio: 'pipe', shell: SHELL }); + return true; + } catch { + return false; + } +} + +export async function checkCli() { + if (await cliExists()) return { ok: true }; + + if (!npmExists()) { + return { + ok: false, + message: + 'Error: Node.js / npm is not installed.\n' + + ' Fix: Install Node.js from https://nodejs.org, then reopen the SwitchBot channel.\n' + + ' Hint: Node 18 or later is required.', + }; + } + + process.stderr.write('[switchbot-channel] CLI not found — auto-installing @switchbot/openapi-cli…\n'); + try { + execFileSync('npm', ['install', '-g', '@switchbot/openapi-cli'], { + stdio: 'inherit', + timeout: 120_000, + shell: SHELL, + }); + } catch (err) { + return { + ok: false, + message: + `Error: CLI installation failed: ${err instanceof Error ? err.message : String(err)}\n` + + ` Fix: npm install -g @switchbot/openapi-cli\n` + + ` Hint: Check your network connection and npm permissions.`, + }; + } + + if (!(await cliExists())) { + return { + ok: false, + message: formatError('cli-not-installed') + + '\n (CLI was installed but `switchbot` is still not on PATH — reopen your terminal.)', + }; + } + + process.stderr.write('[switchbot-channel] CLI installed.\n'); + return { ok: true }; +} diff --git a/packages/openclaw-skill/setup/check-credentials.js b/packages/openclaw-skill/setup/check-credentials.js new file mode 100644 index 00000000..be7fe06c --- /dev/null +++ b/packages/openclaw-skill/setup/check-credentials.js @@ -0,0 +1,51 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { formatError } from '../lib/error-messages.js'; + +async function tryDoctor(exec) { + try { + const { stdout } = await exec('switchbot', ['doctor', '--json'], { timeout: 10000 }); + const parsed = JSON.parse(stdout); + const data = parsed?.data ?? parsed; + return data?.credentials?.configured === true + ? { ok: true } + : { ok: false, reason: 'not-configured' }; + } catch (err) { + if (err?.code === 'ENOENT') throw err; + return { ok: false, reason: 'doctor-failed' }; + } +} + +async function tryKeychainDescribe(exec) { + try { + await exec('switchbot', ['auth', 'keychain', 'describe', '--json'], { timeout: 8000 }); + return true; + } catch { + return false; + } +} + +export function makeCheckCredentials(exec) { + return async function checkCredentials() { + let doctorResult = null; + try { + doctorResult = await tryDoctor(exec); + if (doctorResult.ok) return { ok: true }; + } catch { + // CLI missing — fall through to keychain + } + + // Doctor ran but exited non-zero → likely token expired + if (doctorResult?.reason === 'doctor-failed') { + return { ok: false, message: formatError('token-expired') }; + } + + // Doctor says not configured, or CLI missing — try keychain as fallback + if (await tryKeychainDescribe(exec)) return { ok: true }; + + return { ok: false, message: formatError('auth-not-configured') }; + }; +} + +const defaultExec = promisify(execFile); +export const checkCredentials = makeCheckCredentials(defaultExec); diff --git a/packages/openclaw-skill/setup/check-daemon.js b/packages/openclaw-skill/setup/check-daemon.js new file mode 100644 index 00000000..b4ccb492 --- /dev/null +++ b/packages/openclaw-skill/setup/check-daemon.js @@ -0,0 +1,59 @@ +// setup/check-daemon.js — 若 policy 启用了 automation,确保 daemon 正在运行 +import { execFile, execFileSync } from 'node:child_process'; +import { promisify } from 'node:util'; + +const exec = promisify(execFile); + +async function automationEnabled() { + try { + const { stdout } = await exec('switchbot', ['daemon', 'status', '--json'], { timeout: 8000 }); + const data = JSON.parse(stdout); + // daemon 已在运行,无需再检查 policy + if (data?.data?.running === true || data?.running === true) return { needed: false }; + } catch { + // daemon 命令失败或未运行,继续检查 policy + } + + // 读 policy 确认 automation.enabled + try { + const { stdout } = await exec( + 'switchbot', ['policy', 'validate', '--json'], + { timeout: 8000 }, + ); + const data = JSON.parse(stdout); + // policy validate 不直接暴露 automation.enabled;用 rules list 替代 + void data; + } catch { + // 忽略 policy 读取错误,不阻塞启动 + } + + // 尝试读取 rules list 来判断是否有激活的规则 + try { + const { stdout } = await exec('switchbot', ['rules', 'list', '--json'], { timeout: 8000 }); + const data = JSON.parse(stdout); + const rules = data?.data ?? data ?? []; + if (Array.isArray(rules) && rules.length > 0) return { needed: true }; + } catch { + // 无法确认,保守处理:不强行启动 + } + + return { needed: false }; +} + +export async function checkDaemon() { + const { needed } = await automationEnabled(); + if (!needed) return { ok: true }; + + process.stderr.write('[switchbot-channel] 检测到自动化规则,正在启动规则引擎 daemon…\n'); + try { + execFileSync('switchbot', ['daemon', 'start'], { stdio: 'inherit', timeout: 30_000 }); + process.stderr.write('[switchbot-channel] daemon 已启动。\n'); + } catch (err) { + // daemon 启动失败不应阻塞 channel,仅打印警告 + process.stderr.write( + `[switchbot-channel] 警告:daemon 启动失败(${err instanceof Error ? err.message : String(err)})。自动化规则暂不生效,可手动运行 switchbot daemon start。\n`, + ); + } + + return { ok: true }; +} diff --git a/packages/openclaw-skill/tests/check-credentials.test.js b/packages/openclaw-skill/tests/check-credentials.test.js new file mode 100644 index 00000000..ea926421 --- /dev/null +++ b/packages/openclaw-skill/tests/check-credentials.test.js @@ -0,0 +1,72 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeCheckCredentials } from '../setup/check-credentials.js'; + +function execReturningDoctor(configured) { + return async (_cmd, args) => { + if (args[0] === 'doctor') { + return { stdout: JSON.stringify({ data: { credentials: { configured } } }) }; + } + throw Object.assign(new Error('keychain error'), { code: 1 }); + }; +} + +function execDoctorFails() { + return async (_cmd, args) => { + if (args[0] === 'doctor') { + const err = new Error('non-zero exit'); + err.code = 1; + throw err; + } + throw Object.assign(new Error('keychain error'), { code: 1 }); + }; +} + +function execCliMissing() { + return async () => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }; +} + +function execKeychainOk() { + return async (_cmd, args) => { + if (args[0] === 'doctor') throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + if (args[1] === 'keychain') return { stdout: '{}' }; + throw new Error('unexpected'); + }; +} + +describe('makeCheckCredentials', () => { + it('returns ok:true when doctor says configured', async () => { + const check = makeCheckCredentials(execReturningDoctor(true)); + assert.deepEqual(await check(), { ok: true }); + }); + + it('returns auth-not-configured when doctor says not configured and keychain fails', async () => { + const check = makeCheckCredentials(execReturningDoctor(false)); + const result = await check(); + assert.equal(result.ok, false); + assert.match(result.message, /credentials are not configured/); + assert.match(result.message, /switchbot auth login/); + }); + + it('returns token-expired when doctor command exits non-zero', async () => { + const check = makeCheckCredentials(execDoctorFails()); + const result = await check(); + assert.equal(result.ok, false); + assert.match(result.message, /doctor check failed/); + assert.match(result.message, /auth logout/); + }); + + it('returns ok:true when CLI missing but keychain succeeds', async () => { + const check = makeCheckCredentials(execKeychainOk()); + assert.deepEqual(await check(), { ok: true }); + }); + + it('returns auth-not-configured when CLI missing and keychain fails', async () => { + const check = makeCheckCredentials(execCliMissing()); + const result = await check(); + assert.equal(result.ok, false); + assert.match(result.message, /credentials are not configured/); + }); +}); diff --git a/packages/openclaw-skill/tests/cli-args.test.js b/packages/openclaw-skill/tests/cli-args.test.js new file mode 100644 index 00000000..f5b995eb --- /dev/null +++ b/packages/openclaw-skill/tests/cli-args.test.js @@ -0,0 +1,84 @@ +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/error-messages.test.js b/packages/openclaw-skill/tests/error-messages.test.js new file mode 100644 index 00000000..0ffdb731 --- /dev/null +++ b/packages/openclaw-skill/tests/error-messages.test.js @@ -0,0 +1,51 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { ERRORS, formatError } from '../lib/error-messages.js'; + +describe('ERRORS registry', () => { + const EXPECTED_KEYS = [ + 'auth-not-configured', + 'auth-login-failed', + 'token-expired', + 'cli-not-installed', + 'cli-version-too-low', + ]; + + it('defines all required keys', () => { + for (const key of EXPECTED_KEYS) { + assert.ok(key in ERRORS, `missing key: ${key}`); + } + }); + + it('each entry has reason, fix, and hint', () => { + for (const [key, entry] of Object.entries(ERRORS)) { + assert.ok(entry.reason, `${key}: missing reason`); + assert.ok(entry.fix, `${key}: missing fix`); + assert.ok(entry.hint, `${key}: missing hint`); + } + }); +}); + +describe('formatError', () => { + it('returns a string with Error:, Fix:, and Hint: lines', () => { + const out = formatError('auth-not-configured'); + assert.match(out, /^Error:/m); + assert.match(out, /Fix:/m); + assert.match(out, /Hint:/m); + }); + + it('embeds the reason for auth-not-configured', () => { + const out = formatError('auth-not-configured'); + assert.ok(out.includes('credentials are not configured'), out); + }); + + it('fix for token-expired contains logout && login', () => { + const out = formatError('token-expired'); + assert.ok(out.includes('auth logout'), out); + assert.ok(out.includes('auth login'), out); + }); + + it('throws for unknown key', () => { + assert.throws(() => formatError('no-such-key'), /unknown error key/); + }); +}); diff --git a/packages/openclaw-skill/tests/policy-editor.test.js b/packages/openclaw-skill/tests/policy-editor.test.js new file mode 100644 index 00000000..fa20bdf7 --- /dev/null +++ b/packages/openclaw-skill/tests/policy-editor.test.js @@ -0,0 +1,38 @@ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { startEditorServer } from '../policy-editor/server.js'; + +describe('policy editor server', () => { + let server; + before(async () => { + server = await startEditorServer({ port: 0, dryRun: true }); + }); + after(() => server.close()); + + it('starts on a random port', () => { + assert.ok(server.port > 0); + }); + + it('GET / returns HTML', async () => { + const res = await fetch(`http://127.0.0.1:${server.port}/`); + assert.equal(res.status, 200); + const text = await res.text(); + assert.ok(text.includes(' { + const res = await fetch(`http://127.0.0.1:${server.port}/policy`); + assert.equal(res.status, 200); + }); + + it('POST /policy returns saved', async () => { + const res = await fetch(`http://127.0.0.1:${server.port}/policy`, { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: 'version: "0.2"', + }); + assert.equal(res.status, 200); + const text = await res.text(); + assert.equal(text, 'saved'); + }); +}); diff --git a/packages/openclaw-skill/tests/server.test.js b/packages/openclaw-skill/tests/server.test.js new file mode 100644 index 00000000..83db426e --- /dev/null +++ b/packages/openclaw-skill/tests/server.test.js @@ -0,0 +1,15 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from '../index.js'; + +describe('MCP server', () => { + it('registers all required tools', () => { + 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'); + }); +}); diff --git a/scripts/smoke-codex-pack-install.mjs b/scripts/smoke-codex-pack-install.mjs new file mode 100644 index 00000000..924d07d6 --- /dev/null +++ b/scripts/smoke-codex-pack-install.mjs @@ -0,0 +1,150 @@ +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'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.dirname(scriptDir); +const workDir = mkdtempSync(path.join(os.tmpdir(), 'switchbot-codex-pack-smoke-')); +const packed = []; + +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(file) { + return JSON.parse(readFileSync(file, 'utf-8')); +} + +try { + const cliTarball = pack([]); + const pluginTarball = pack(['--workspace', '@switchbot/codex-plugin']); + + runNpm(['init', '-y'], { + cwd: workDir, + stdio: 'ignore', + }); + runNpm(['install', cliTarball, pluginTarball], { + cwd: workDir, + stdio: 'inherit', + }); + + const cliPkg = readJson(path.join(repoRoot, 'package.json')); + const installedCliPkg = readJson(path.join(workDir, 'node_modules', '@switchbot', 'openapi-cli', 'package.json')); + if (installedCliPkg.version !== cliPkg.version) { + throw new Error(`installed CLI version mismatch: expected ${cliPkg.version}, got ${installedCliPkg.version}`); + } + + const pluginRoot = path.join(workDir, 'node_modules', '@switchbot', 'codex-plugin'); + const pluginPkg = readJson(path.join(pluginRoot, 'package.json')); + const peer = pluginPkg.peerDependencies?.['@switchbot/openapi-cli']; + if (!peer || peer.includes('workspace:')) { + throw new Error(`codex plugin peerDependency is not publishable: ${peer ?? ''}`); + } + + for (const requiredPath of [ + '.agents/plugins/marketplace.json', + '.codex-plugin/plugin.json', + '.codex-plugin/hooks.json', + '.mcp.json', + 'skills/switchbot/SKILL.md', + 'bin/auth.js', + 'bin/install.js', + ]) { + const fullPath = path.join(pluginRoot, ...requiredPath.split('/')); + if (!existsSync(fullPath)) { + throw new Error(`codex plugin tarball missing ${requiredPath}`); + } + } + + const pluginManifest = readJson(path.join(pluginRoot, '.codex-plugin', 'plugin.json')); + if (pluginManifest?.interface?.displayName !== 'SwitchBot') { + 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 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 ?? ''}`); + } + + const hooks = readJson(path.join(pluginRoot, '.codex-plugin', 'hooks.json')); + const hookArgs = hooks?.onInstall?.args ?? []; + if (!Array.isArray(hookArgs) || !hookArgs.includes('--hook')) { + throw new Error(`onInstall hook must run auth.js --hook, got ${JSON.stringify(hookArgs)}`); + } + + const switchbotBin = process.platform === 'win32' + ? path.join(workDir, 'node_modules', '.bin', 'switchbot.cmd') + : path.join(workDir, 'node_modules', '.bin', 'switchbot'); + const setupOut = execFileSync( + switchbotBin, + ['codex', 'setup', '--dry-run', '--json'], + { + cwd: workDir, + encoding: 'utf-8', + shell: process.platform === 'win32', + }, + ); + const setup = JSON.parse(setupOut); + const steps = setup.data?.steps ?? setup.steps ?? []; + const names = steps.map((s) => s.name); + for (const expected of [ + 'check-codex-cli', + 'install-switchbot-cli', + 'install-codex-plugin', + 'register-plugin', + 'auth', + 'doctor-verify', + ]) { + if (!names.includes(expected)) { + throw new Error(`codex setup dry-run missing step ${expected}; got ${names.join(', ')}`); + } + } + + const envPath = `${path.join(workDir, 'node_modules', '.bin')}${path.delimiter}${process.env.PATH ?? ''}`; + const hook = spawnSync( + process.execPath, + [path.join(pluginRoot, 'bin', 'auth.js'), '--hook'], + { + cwd: workDir, + env: { ...process.env, PATH: envPath }, + encoding: 'utf-8', + shell: process.platform === 'win32', + timeout: 30_000, + }, + ); + if ((hook.status ?? 1) !== 0) { + 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 includes plugin install, hook is non-blocking'); +} finally { + for (const tarball of packed) { + rmSync(tarball, { force: true }); + } + rmSync(workDir, { recursive: true, force: true }); +} diff --git a/src/auth/browser-login.ts b/src/auth/browser-login.ts index 3c49bc9d..0e22d9d4 100644 --- a/src/auth/browser-login.ts +++ b/src/auth/browser-login.ts @@ -30,7 +30,7 @@ export async function browserLogin(options: BrowserLoginOptions = {}): Promise; + /** Immediately close the server and release the port. */ + close(): void; } function successHtml(): string { @@ -56,6 +58,16 @@ export async function bindCallbackServer( }); let finished = false; + let timer!: ReturnType; + + const shutdown = (err: Error) => { + if (finished) return; + finished = true; + clearTimeout(timer); + server.closeAllConnections(); + server.close(); + rejectResult(err); + }; const server = http.createServer((req, res) => { const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`); @@ -117,13 +129,9 @@ export async function bindCallbackServer( }); }); - const timer = setTimeout(() => { - if (finished) return; - finished = true; - server.closeAllConnections(); - server.close(); - rejectResult(new Error('Login timed out. Please run `switchbot auth login` again.')); + timer = setTimeout(() => { + shutdown(new Error('Login timed out. Please run `switchbot auth login` again.')); }, timeoutMs); - return { port: actualPort, wait: () => resultPromise }; + return { port: actualPort, wait: () => resultPromise, close: () => shutdown(new Error('Login cancelled')) }; } diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts index 522a6303..7bcbf867 100644 --- a/src/commands/capabilities.ts +++ b/src/commands/capabilities.ts @@ -215,6 +215,9 @@ export const COMMAND_META: Record = { 'status-sync stop': ACTION_LOCAL, 'status-sync status': READ_LOCAL, 'reset': ACTION_LOCAL, + 'codex doctor': READ_LOCAL, + 'codex repair': ACTION_LOCAL, + 'codex setup': ACTION_LOCAL, 'uninstall': ACTION_LOCAL, 'upgrade-check': READ_REMOTE, 'webhook setup': ACTION_REMOTE, diff --git a/src/commands/codex.ts b/src/commands/codex.ts new file mode 100644 index 00000000..7cf3533f --- /dev/null +++ b/src/commands/codex.ts @@ -0,0 +1,554 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { spawnSync } from 'node:child_process'; +import { runDoctorChecks, formatDoctorChecks } from './doctor.js'; +import { + checkCodexCli, + checkCodexPluginNpm, + checkCodexPluginRegistered, + registerCodexPlugin, + resolvePluginId, + resolveCodexPackageRoot, + type Check, +} from '../install/codex-checks.js'; +import { isJsonMode, printJson } from '../utils/output.js'; +import { getActiveProfile } from '../lib/request-context.js'; +import { getConfigPath } from '../utils/flags.js'; + +const CODEX_BASE_SECTIONS = ['node', 'path', 'credentials', 'mcp'] as const; +const SWITCHBOT_CLI_PACKAGE = '@switchbot/openapi-cli'; +const CODEX_PLUGIN_PACKAGE = '@switchbot/codex-plugin'; + +async function runAllCodexDoctorChecks(): Promise { + const base = await runDoctorChecks(CODEX_BASE_SECTIONS); + const codexChecks: Check[] = [ + checkCodexCli(), + checkCodexPluginNpm(), + checkCodexPluginRegistered(), + ]; + return [...base, ...codexChecks]; +} + +function registerCodexDoctorSubcommand(codex: Command): void { + codex + .command('doctor') + .description( + 'Check Codex integration health (7 checks: node, path, credentials, mcp, codex-cli, npm plugin, registered)', + ) + .option('-q, --quiet', 'Only show warn/fail checks') + .action(async (opts: { quiet?: boolean }) => { + const checks = await runAllCodexDoctorChecks(); + const summary = { + ok: checks.filter((c) => c.status === 'ok').length, + warn: checks.filter((c) => c.status === 'warn').length, + fail: checks.filter((c) => c.status === 'fail').length, + }; + const hasFail = summary.fail > 0; + const overall: 'ok' | 'warn' | 'fail' = + hasFail ? 'fail' : summary.warn > 0 ? 'warn' : 'ok'; + + if (isJsonMode()) { + printJson({ ok: !hasFail, overall, summary, checks }); + } else { + formatDoctorChecks(checks, Boolean(opts.quiet)); + console.log(''); + console.log(`${summary.ok} ok, ${summary.warn} warn, ${summary.fail} fail`); + if (hasFail || summary.warn > 0) { + console.log(chalk.dim('Run: switchbot codex repair')); + } + } + process.exit(hasFail ? 1 : 0); + }); +} + +// ─── shared helpers ────────────────────────────────────────────────────────── + +/** + * argv builder shared by `codex repair re-auth` and `codex setup auth`. + * Spawns the current `switchbot` binary (via process.execPath + cliPath) so + * the subprocess inherits `--profile` / `--config` from the active scope and + * credentials are written/read against the correct profile. + */ +function buildAuthLoginArgv(profile: string, configPath?: string): string[] { + const cliPath = process.argv[1] ?? ''; + return [ + cliPath, + ...(profile !== 'default' ? ['--profile', profile] : []), + ...(configPath ? ['--config', configPath] : []), + 'auth', 'login', + ]; +} + +interface StepOutcome { + step: string; + status: 'ok' | 'skipped' | 'failed'; + message?: string; +} + +async function credentialsPresent(): Promise { + try { + const { tryLoadConfig } = await import('../config.js'); + const cfg = tryLoadConfig(); + return Boolean(cfg && cfg.token && cfg.secret); + } catch { + return false; + } +} + +// ─── repair ────────────────────────────────────────────────────────────────── + +interface RepairContext { + profile: string; + configPath?: string; + codexPluginId?: string; + packageRoot?: string | null; + nonInteractive: boolean; +} + +type RepairOutcome = StepOutcome; + +async function repairStepVerifyCli(_ctx: RepairContext): Promise { + const checks = await runDoctorChecks(['node', 'path']); + const fail = checks.find((c) => c.status === 'fail'); + if (fail) { + const msg = typeof fail.detail === 'string' + ? fail.detail + : (fail.detail as { message?: string }).message ?? JSON.stringify(fail.detail); + return { step: 'verify-cli', status: 'failed', message: msg }; + } + return { step: 'verify-cli', status: 'ok', message: 'node + path ok' }; +} + +async function repairStepReAuth(ctx: RepairContext): Promise { + if (await credentialsPresent()) { + return { step: 're-auth', status: 'ok', message: 'credentials present' }; + } + if (ctx.nonInteractive) { + return { + step: 're-auth', + status: 'failed', + message: JSON.stringify({ reason: 'credentials-missing', hint: 'run: switchbot auth login' }), + }; + } + const argv = buildAuthLoginArgv(ctx.profile, ctx.configPath); + const r = spawnSync(process.execPath, argv, { stdio: 'inherit' }); + if ((r.status ?? 1) !== 0) { + return { step: 're-auth', status: 'failed', message: `auth login exited ${r.status ?? 1}` }; + } + return { step: 're-auth', status: 'ok', message: 'auth login completed' }; +} + +function repairStepRemovePlugin(ctx: RepairContext): RepairOutcome { + let pluginId = ctx.codexPluginId; + if (!pluginId) { + const root = resolveCodexPackageRoot(); + pluginId = root.ok ? resolvePluginId(root.packageRoot) : 'switchbot@codex-plugin'; + ctx.codexPluginId = pluginId; + } + const r = spawnSync( + 'codex', ['plugin', 'remove', pluginId], + { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 15000 }, + ); + if ((r.status ?? 1) !== 0) { + return { step: 'remove-plugin', status: 'failed', message: `exit ${r.status ?? 1} (non-fatal)` }; + } + return { step: 'remove-plugin', status: 'ok' }; +} + +function stepRegisterPluginShared(stepName: string, ctx: { codexPluginId?: string; packageRoot?: string | null }): StepOutcome { + const r = registerCodexPlugin(); + if (!r.ok) { + return { step: stepName, status: 'failed', message: r.error }; + } + ctx.codexPluginId = r.pluginId; + ctx.packageRoot = r.packageRoot; + return { step: stepName, status: 'ok', message: 'marketplace add + plugin add succeeded' }; +} + +function repairStepRegisterPlugin(ctx: RepairContext): RepairOutcome { + return stepRegisterPluginShared('register-plugin', ctx); +} + +async function repairStepDoctorVerify(): Promise { + const checks = await runAllCodexDoctorChecks(); + const summary = { + ok: checks.filter((c) => c.status === 'ok').length, + warn: checks.filter((c) => c.status === 'warn').length, + fail: checks.filter((c) => c.status === 'fail').length, + }; + return { + step: 'doctor-verify', + status: summary.fail > 0 ? 'failed' : 'ok', + message: `${summary.ok} ok, ${summary.warn} warn, ${summary.fail} fail`, + }; +} + +interface StepDef { + name: string; + description: string; + skippable: boolean; +} + +const REPAIR_STEPS: readonly StepDef[] = [ + { name: 'verify-cli', description: 'Verify node and switchbot binary on PATH', skippable: false }, + { name: 're-auth', description: 'Check credentials; spawn auth login if missing', skippable: true }, + { name: 'remove-plugin', description: 'codex plugin remove (best-effort, non-fatal)', skippable: true }, + { name: 'register-plugin', description: 'codex plugin marketplace add + plugin add', skippable: false }, + { name: 'doctor-verify', description: 'Run Codex doctor checks and report health', skippable: false }, +]; + +function validateSkip(stepDefs: readonly StepDef[], skip: Set): { ok: true } | { ok: false; offending: string } { + const skippableNames = new Set(stepDefs.filter((s) => s.skippable).map((s) => s.name)); + for (const name of skip) { + if (!skippableNames.has(name)) { + return { ok: false, offending: name }; + } + } + return { ok: true }; +} + +async function runRepair( + skip: Set, + ctx: RepairContext, +): Promise<{ outcomes: RepairOutcome[]; anyFailed: boolean; preflightFailed: boolean }> { + const outcomes: RepairOutcome[] = []; + let preflightFailed = false; + + for (const step of REPAIR_STEPS) { + if (skip.has(step.name)) { + outcomes.push({ step: step.name, status: 'skipped' }); + continue; + } + let outcome: RepairOutcome; + if (step.name === 'verify-cli') outcome = await repairStepVerifyCli(ctx); + else if (step.name === 're-auth') outcome = await repairStepReAuth(ctx); + else if (step.name === 'remove-plugin') outcome = repairStepRemovePlugin(ctx); + else if (step.name === 'register-plugin') outcome = repairStepRegisterPlugin(ctx); + else outcome = await repairStepDoctorVerify(); + outcomes.push(outcome); + if (step.name === 'verify-cli' && outcome.status === 'failed') { + preflightFailed = true; + break; + } + } + const anyFailed = outcomes.some((o) => o.status === 'failed'); + return { outcomes, anyFailed, preflightFailed }; +} + +function registerCodexRepairSubcommand(codex: Command): void { + codex + .command('repair') + .description('Repair the Codex integration: re-check auth, re-register plugin, verify health') + .option('--skip ', 'Comma-separated step names to skip (e.g. "re-auth,remove-plugin")') + .option('--yes', 'Non-interactive mode: skip spawning auth login, return machine-readable error if credentials missing') + .action(async (opts: { skip?: string; yes?: boolean }, command: Command) => { + const skip = new Set( + (opts.skip ?? '').split(',').map((s) => s.trim()).filter(Boolean), + ); + const skipCheck = validateSkip(REPAIR_STEPS, skip); + if (!skipCheck.ok) { + console.error(`invalid --skip: '${skipCheck.offending}' is not skippable`); + process.exit(2); + return; + } + const globalOpts = command.parent?.parent?.opts() ?? {}; + const dryRun = Boolean(globalOpts.dryRun); + const profile = getActiveProfile() ?? 'default'; + const configPath = getConfigPath(); + + if (dryRun) { + if (isJsonMode()) { + printJson({ + dryRun: true, + steps: REPAIR_STEPS.map((s) => ({ + name: s.name, + description: s.description, + skippable: s.skippable, + willSkip: skip.has(s.name), + })), + }); + } else { + console.error(chalk.bold('switchbot codex repair — dry run')); + console.error(''); + console.error(chalk.bold('Steps (in order):')); + for (const s of REPAIR_STEPS) { + const tag = skip.has(s.name) ? chalk.dim(' · (skip)') : ' •'; + console.error(`${tag} ${s.name.padEnd(18)} ${s.description}`); + } + console.error(''); + console.error(chalk.dim('No changes made. Re-run without --dry-run to apply.')); + } + process.exit(0); + return; + } + + const ctx: RepairContext = { + profile, + configPath, + nonInteractive: Boolean(opts.yes), + }; + + if (!isJsonMode()) console.log(chalk.bold('Repairing Codex integration...')); + if (!isJsonMode()) console.log(''); + + const { outcomes, anyFailed, preflightFailed } = await runRepair(skip, ctx); + + if (isJsonMode()) { + printJson({ ok: !anyFailed, preflightFailed, outcomes }); + } else { + for (const o of outcomes) { + const icon = + o.status === 'ok' ? chalk.green('✓') : + o.status === 'skipped' ? chalk.dim('·') : + chalk.red('✗'); + console.log(`${icon} ${o.step.padEnd(18)} ${o.message ?? ''}`); + } + console.log(''); + if (!anyFailed) { + console.log(chalk.green('Repair complete. Restart Codex and run: switchbot devices list')); + } 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.')); + } + } + if (preflightFailed) process.exit(2); + if (anyFailed) process.exit(1); + process.exit(0); + }); +} + +// ─── setup ─────────────────────────────────────────────────────────────────── + +interface SetupContext { + profile: string; + configPath?: string; + codexPluginId?: string; + packageRoot?: string | null; + nonInteractive: boolean; +} + +type SetupOutcome = StepOutcome; + +const SETUP_STEPS: readonly StepDef[] = [ + { name: 'check-codex-cli', description: 'Verify codex CLI on PATH', skippable: false }, + { name: 'install-switchbot-cli', description: 'Install @switchbot/openapi-cli if missing', skippable: true }, + { name: 'install-codex-plugin', description: 'Install @switchbot/codex-plugin if missing', skippable: true }, + { name: 'register-plugin', description: 'Register plugin via shared registerCodexPlugin()', skippable: false }, + { name: 'auth', description: 'Verify credentials; spawn auth login if missing', skippable: true }, + { name: 'doctor-verify', description: 'Run 4 base + 3 Codex checks and report health', skippable: false }, +]; + +function setupStepCheckCodexCli(): SetupOutcome { + const c = checkCodexCli(); + if (c.status === 'fail') { + const msg = typeof c.detail === 'string' + ? c.detail + : (c.detail as { message?: string }).message ?? JSON.stringify(c.detail); + return { step: 'check-codex-cli', status: 'failed', message: msg }; + } + const detail = typeof c.detail === 'object' && c.detail !== null + ? c.detail as { path?: string; version?: string | null } + : {}; + const where = detail.path ? `${detail.path}${detail.version ? ` (${detail.version})` : ''}` : 'on PATH'; + return { step: 'check-codex-cli', status: 'ok', message: where }; +} + +function setupStepInstallSwitchbotCli(): SetupOutcome { + return setupStepInstallGlobalPackage( + 'install-switchbot-cli', + SWITCHBOT_CLI_PACKAGE, + ); +} + +function setupStepInstallCodexPlugin(): SetupOutcome { + return setupStepInstallGlobalPackage( + 'install-codex-plugin', + CODEX_PLUGIN_PACKAGE, + ); +} + +function setupStepInstallGlobalPackage(step: string, packageName: string): SetupOutcome { + const list = spawnSync( + 'npm', ['list', '-g', '--json', '--depth=0', packageName], + { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 15000 }, + ); + let installed = false; + try { + const parsed = JSON.parse(list.stdout ?? '{}') as { dependencies?: Record }; + installed = Boolean(parsed?.dependencies?.[packageName]); + } catch { /* treat as not installed */ } + if (installed) { + return { step, status: 'ok', message: 'already installed' }; + } + const inst = spawnSync( + 'npm', ['install', '-g', `${packageName}@latest`], + { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 120000 }, + ); + if ((inst.status ?? 1) !== 0) { + return { + step, + status: 'failed', + message: `npm install -g failed (exit ${inst.status ?? 1}): ${inst.stderr ?? ''}`, + }; + } + return { step, status: 'ok', message: `installed ${packageName}@latest` }; +} + +function setupStepRegisterPlugin(ctx: SetupContext): SetupOutcome { + return stepRegisterPluginShared('register-plugin', ctx); +} + +async function setupStepAuth(ctx: SetupContext): Promise { + if (await credentialsPresent()) { + return { step: 'auth', status: 'ok', message: 'credentials present' }; + } + if (ctx.nonInteractive) { + return { + step: 'auth', + status: 'failed', + message: JSON.stringify({ reason: 'credentials-missing', hint: 'run: switchbot auth login' }), + }; + } + const argv = buildAuthLoginArgv(ctx.profile, ctx.configPath); + const r = spawnSync(process.execPath, argv, { stdio: 'inherit' }); + if ((r.status ?? 1) !== 0) { + return { step: 'auth', status: 'failed', message: `auth login exited ${r.status ?? 1}` }; + } + return { step: 'auth', status: 'ok', message: 'auth login completed' }; +} + +async function setupStepDoctorVerify(): Promise { + const checks = await runAllCodexDoctorChecks(); + const summary = { + ok: checks.filter((c) => c.status === 'ok').length, + warn: checks.filter((c) => c.status === 'warn').length, + fail: checks.filter((c) => c.status === 'fail').length, + }; + return { + step: 'doctor-verify', + status: summary.fail > 0 ? 'failed' : 'ok', + message: `${summary.ok} ok, ${summary.warn} warn, ${summary.fail} fail`, + }; +} + +async function runSetup( + skip: Set, + ctx: SetupContext, +): Promise<{ outcomes: SetupOutcome[]; anyFailed: boolean; preflightFailed: boolean }> { + const outcomes: SetupOutcome[] = []; + let preflightFailed = false; + + for (const step of SETUP_STEPS) { + if (skip.has(step.name)) { + outcomes.push({ step: step.name, status: 'skipped' }); + continue; + } + let outcome: SetupOutcome; + if (step.name === 'check-codex-cli') outcome = setupStepCheckCodexCli(); + else if (step.name === 'install-switchbot-cli') outcome = setupStepInstallSwitchbotCli(); + else if (step.name === 'install-codex-plugin') outcome = setupStepInstallCodexPlugin(); + else if (step.name === 'register-plugin') outcome = setupStepRegisterPlugin(ctx); + else if (step.name === 'auth') outcome = await setupStepAuth(ctx); + else outcome = await setupStepDoctorVerify(); + outcomes.push(outcome); + if (step.name === 'check-codex-cli' && outcome.status === 'failed') { + preflightFailed = true; + break; + } + } + const anyFailed = outcomes.some((o) => o.status === 'failed'); + return { outcomes, anyFailed, preflightFailed }; +} + +function registerCodexSetupSubcommand(codex: Command): void { + codex + .command('setup') + .description('Bootstrap the Codex integration end-to-end: install packages if missing, register plugin, auth, verify') + .option('--skip ', 'Comma-separated step names to skip (only "install-switchbot-cli", "install-codex-plugin", or "auth" allowed)') + .option('--yes', 'Non-interactive mode: do not spawn auth login, fail fast if credentials missing') + .action(async (opts: { skip?: string; yes?: boolean }, command: Command) => { + const skip = new Set( + (opts.skip ?? '').split(',').map((s) => s.trim()).filter(Boolean), + ); + const skipCheck = validateSkip(SETUP_STEPS, skip); + if (!skipCheck.ok) { + console.error(`invalid --skip: '${skipCheck.offending}' is not skippable`); + process.exit(2); + return; + } + const globalOpts = command.parent?.parent?.opts() ?? {}; + const dryRun = Boolean(globalOpts.dryRun); + const profile = getActiveProfile() ?? 'default'; + const configPath = getConfigPath(); + + if (dryRun) { + if (isJsonMode()) { + printJson({ + dryRun: true, + steps: SETUP_STEPS.map((s) => ({ + name: s.name, + description: s.description, + skippable: s.skippable, + willSkip: skip.has(s.name), + })), + }); + } else { + console.error(chalk.bold('switchbot codex setup — dry run')); + console.error(''); + console.error(chalk.bold('Steps (in order):')); + for (const s of SETUP_STEPS) { + const tag = skip.has(s.name) ? chalk.dim(' · (skip)') : ' •'; + console.error(`${tag} ${s.name.padEnd(22)} ${s.description}`); + } + console.error(''); + console.error(chalk.dim('No changes made. Re-run without --dry-run to apply.')); + } + process.exit(0); + return; + } + + const ctx: SetupContext = { + profile, + configPath, + nonInteractive: Boolean(opts.yes), + }; + + if (!isJsonMode()) console.log(chalk.bold('Setting up Codex integration...')); + if (!isJsonMode()) console.log(''); + + const { outcomes, anyFailed, preflightFailed } = await runSetup(skip, ctx); + + if (isJsonMode()) { + printJson({ ok: !anyFailed, preflightFailed, outcomes }); + } else { + for (const o of outcomes) { + const icon = + o.status === 'ok' ? chalk.green('✓') : + o.status === 'skipped' ? chalk.dim('·') : + chalk.red('✗'); + console.log(`${icon} ${o.step.padEnd(22)} ${o.message ?? ''}`); + } + console.log(''); + if (!anyFailed) { + console.log(chalk.green('Setup complete. Restart Codex and run: switchbot devices list')); + } 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.')); + } + } + if (preflightFailed) process.exit(2); + if (anyFailed) process.exit(1); + process.exit(0); + }); +} + +export function registerCodexCommand(program: Command): void { + const codex = program + .command('codex') + .description('Codex integration management (setup, register, health, repair)'); + registerCodexDoctorSubcommand(codex); + registerCodexRepairSubcommand(codex); + registerCodexSetupSubcommand(codex); +} diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 385f9a3c..b2a2706e 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; +import chalk from 'chalk'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -26,7 +27,7 @@ import { getActiveProfile } from '../lib/request-context.js'; import { readDaemonState, DAEMON_PID_FILE } from '../lib/daemon-state.js'; import { isPidAlive, readPidFile } from '../rules/pid-file.js'; -interface Check { +export interface Check { name: string; status: 'ok' | 'warn' | 'fail'; detail: string | Record; @@ -1106,7 +1107,7 @@ interface DoctorRunOpts { probe: boolean; } -const CHECK_REGISTRY: CheckDef[] = [ +export const CHECK_REGISTRY: CheckDef[] = [ { name: 'node', description: 'Node.js version compatibility', run: () => checkNodeVersion() }, { name: 'path', description: 'switchbot binary reachable on PATH', run: () => checkPathDiscoverability() }, { name: 'credentials', description: 'credentials file present and parseable', run: () => checkCredentials() }, @@ -1194,6 +1195,41 @@ interface DoctorCliOptions { quiet?: boolean; } +export async function runDoctorChecks( + sections: readonly string[], + opts: DoctorRunOpts = { probe: false }, +): Promise { + const selected = CHECK_REGISTRY.filter((c) => sections.includes(c.name)); + const checks: Check[] = []; + for (const def of selected) { + checks.push(await def.run(opts)); + } + return checks; +} + +/** + * Shared check-list formatter used by `switchbot doctor` (when run via this + * file's CLI handler, see below) and by `switchbot codex doctor` / + * `switchbot codex repair` / `switchbot codex setup`. Produces the chalk + * coloured tick/bang/cross output with a 24-wide name column. + */ +export function formatDoctorChecks(checks: Check[], quiet: boolean): void { + for (const c of checks) { + if (quiet && c.status === 'ok') continue; + const icon = + c.status === 'ok' ? chalk.green('✓') : + c.status === 'warn' ? chalk.yellow('!') : + chalk.red('✗'); + const detailStr = + typeof c.detail === 'string' + ? c.detail + : typeof (c.detail as { message?: unknown }).message === 'string' + ? (c.detail as { message: string }).message + : JSON.stringify(c.detail); + console.log(`${icon} ${c.name.padEnd(24)} ${detailStr}`); + } +} + export function registerDoctorCommand(program: Command): void { program .command('doctor') diff --git a/src/commands/install.ts b/src/commands/install.ts index b2d57525..de17f7b0 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -28,6 +28,7 @@ import { stepWriteKeychain, stepScaffoldPolicy, stepSymlinkSkill, + stepRegisterCodexPlugin, stepDoctorVerify, type AgentName, type InstallContext, @@ -36,7 +37,7 @@ import { isJsonMode, printJson } from '../utils/output.js'; import { getActiveProfile } from '../lib/request-context.js'; import chalk from 'chalk'; -const AGENT_VALUES: readonly AgentName[] = ['claude-code', 'cursor', 'copilot', 'none'] as const; +const AGENT_VALUES: readonly AgentName[] = ['claude-code', 'cursor', 'copilot', 'codex', 'none'] as const; interface InstallCliOptions { agent?: string; @@ -89,6 +90,13 @@ function printRecipe(ctx: InstallContext): void { ' # openclaw-switchbot-skill/docs/agents/copilot.md', ); break; + case 'codex': + lines.push( + ' # Prerequisite: npm install -g @switchbot/codex-plugin', + ' # Codex plugin was registered with the Codex CLI.', + ' # To re-register: switchbot install --agent codex', + ); + break; case 'none': lines.push(' (none — skill step skipped)'); break; @@ -195,11 +203,15 @@ Examples: nonInteractive: !process.stdin.isTTY && !tokenFile, }; + const agentStep = ctx.agent === 'codex' + ? stepRegisterCodexPlugin() + : stepSymlinkSkill({ force }); + const allSteps: InstallStep[] = [ stepPromptCredentials(), stepWriteKeychain(), stepScaffoldPolicy(), - stepSymlinkSkill({ force }), + agentStep, ]; const steps = allSteps.filter((s) => !skip.has(s.name)); diff --git a/src/install/codex-checks.ts b/src/install/codex-checks.ts new file mode 100644 index 00000000..c80ea71c --- /dev/null +++ b/src/install/codex-checks.ts @@ -0,0 +1,266 @@ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; + +export interface Check { + name: string; + status: 'ok' | 'warn' | 'fail'; + detail: string | Record; +} + +export interface RegistrationResult { + ok: boolean; + exitCode: number; + stderr: string; + stage: 'marketplace-add' | 'plugin-add'; +} + +export interface RegisterCodexPluginResult { + ok: boolean; + pluginId: string; + packageRoot: string; + error?: string; + exitCode?: number; + stderr?: string; +} + +function spawnStr(cmd: string, args: string[]): { status: number; stdout: string; stderr: string } { + const r = spawnSync(cmd, args, { + encoding: 'utf-8', + shell: process.platform === 'win32', + timeout: 10000, + }); + return { status: r.status ?? -1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }; +} + +function readJsonObject(filePath: string): Record | null { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record; + } catch { + return null; + } +} + +function resolveMarketplaceName(packageRoot: string): string { + const marketplacePath = path.join(packageRoot, '.agents', 'plugins', 'marketplace.json'); + if (fs.existsSync(marketplacePath)) { + const marketplace = readJsonObject(marketplacePath); + if (typeof marketplace?.name === 'string' && marketplace.name) { + return marketplace.name; + } + } + return path.basename(packageRoot); +} + +function resolvePluginName(packageRoot: string): string { + const manifestPath = path.join(packageRoot, '.codex-plugin', 'plugin.json'); + if (fs.existsSync(manifestPath)) { + const manifest = readJsonObject(manifestPath); + if (typeof manifest?.name === 'string' && manifest.name) { + return manifest.name; + } + } + return 'switchbot'; +} + +/** + * Codex 0.133.0 misclassifies Windows local paths with scoped npm segments + * like `...\node_modules\@switchbot\codex-plugin` as ref-bearing sources. + * Bridge through a junction at a stable, app-owned location so the registered + * marketplace path contains no `@` segment and survives across runs. + */ +function computeAliasPath(): string { + const localAppData = process.env.LOCALAPPDATA; + if (localAppData) { + return path.join(localAppData, 'switchbot', 'codex-plugin-marketplace'); + } + return path.join(os.homedir(), '.switchbot', 'codex-plugin-marketplace'); +} + +export function resolveMarketplaceSourceRoot(packageRoot: string): string { + if (process.platform !== 'win32' || !/^[A-Za-z]:[\\/].*[\\/]@[^\\/]+[\\/]/.test(packageRoot)) { + return packageRoot; + } + + const aliasRoot = computeAliasPath(); + fs.mkdirSync(path.dirname(aliasRoot), { recursive: true }); + + const stat = fs.lstatSync(aliasRoot, { throwIfNoEntry: false }); + if (!stat) { + fs.symlinkSync(packageRoot, aliasRoot, 'junction'); + return aliasRoot; + } + + if (stat.isSymbolicLink()) { + const aliasReal = fs.realpathSync(aliasRoot); + const packageReal = fs.realpathSync(packageRoot); + if (aliasReal.toLowerCase() === packageReal.toLowerCase()) { + return aliasRoot; + } + fs.unlinkSync(aliasRoot); + fs.symlinkSync(packageRoot, aliasRoot, 'junction'); + return aliasRoot; + } + + throw new Error(`alias path ${aliasRoot} exists and is not a junction; remove it manually and retry`); +} + +/** Single authoritative plugin ID resolver. Mirrors install.js:resolvePluginIdentifier. */ +export function resolvePluginId(packageRoot: string): string { + return `${resolvePluginName(packageRoot)}@${resolveMarketplaceName(packageRoot)}`; +} + +export function checkCodexCli(): Check { + const lookupCmd = process.platform === 'win32' ? 'where' : 'which'; + const lookup = spawnStr(lookupCmd, ['codex']); + if (lookup.status !== 0) { + return { + name: 'codex-cli', + status: 'fail', + detail: { + message: 'codex CLI not found on PATH. Install from https://github.com/openai/codex', + hint: 'Install Codex, then re-run: switchbot install --agent codex', + }, + }; + } + const resolvedPath = lookup.stdout.trim().split(/\r?\n/)[0] ?? ''; + const ver = spawnStr('codex', ['--version']); + const version = ver.status === 0 ? ver.stdout.trim() : null; + return { + name: 'codex-cli', + status: 'ok', + detail: { path: resolvedPath, version }, + }; +} + +export function checkCodexPluginNpm(): Check { + const list = spawnStr('npm', ['list', '-g', '--json', '@switchbot/codex-plugin']); + let parsed: { dependencies?: Record } = {}; + try { + parsed = JSON.parse(list.stdout) as typeof parsed; + } catch { + return { name: 'codex-plugin-npm', status: 'warn', detail: { message: 'npm list output could not be parsed' } }; + } + const pkg = parsed?.dependencies?.['@switchbot/codex-plugin']; + if (!pkg) { + return { + name: 'codex-plugin-npm', + status: 'warn', + detail: { message: 'not installed — run: npm install -g @switchbot/codex-plugin && switchbot install --agent codex' }, + }; + } + let packageRoot: string | null = null; + const rootResult = spawnStr('npm', ['root', '-g']); + if (rootResult.status === 0) { + packageRoot = path.join(rootResult.stdout.trim(), '@switchbot', 'codex-plugin'); + } + return { + name: 'codex-plugin-npm', + status: 'ok', + detail: { version: pkg.version ?? 'unknown', packageRoot }, + }; +} + +export function checkCodexPluginRegistered(): Check { + const lookupCmd = process.platform === 'win32' ? 'where' : 'which'; + const lookup = spawnStr(lookupCmd, ['codex']); + if (lookup.status !== 0) { + return { + name: 'codex-plugin-registered', + status: 'warn', + detail: { reason: 'codex-cli-missing', message: 'skipped: codex CLI not on PATH' }, + }; + } + const listResult = spawnStr('codex', ['plugin', 'list']); + if (listResult.status !== 0) { + return { + name: 'codex-plugin-registered', + status: 'warn', + detail: { message: 'codex plugin list failed — plugin registration unknown' }, + }; + } + const raw = listResult.stdout; + let found = false; + let pluginName = ''; + try { + const arr = JSON.parse(raw) as unknown[]; + const match = arr.find( + (p) => typeof p === 'object' && p !== null && 'name' in p && + String((p as Record).name).includes('switchbot'), + ); + found = Boolean(match); + pluginName = found ? String((match as Record).name) : ''; + } catch { + const line = raw.split('\n').find((l) => l.toLowerCase().includes('switchbot')); + found = Boolean(line); + pluginName = line?.trim() ?? ''; + } + if (!found) { + return { + name: 'codex-plugin-registered', + status: 'warn', + detail: { message: 'switchbot not in codex plugin list — run: npm install -g @switchbot/codex-plugin && switchbot install --agent codex' }, + }; + } + if (/switchbot@/i.test(pluginName) && (/\bnot installed\b/i.test(pluginName) || !/\binstalled\b/i.test(pluginName))) { + return { + name: 'codex-plugin-registered', + status: 'warn', + detail: { + pluginName, + message: 'switchbot appears in codex plugin list but is not installed — run: switchbot codex repair', + }, + }; + } + return { name: 'codex-plugin-registered', status: 'ok', detail: { pluginName } }; +} + +export function runCodexPluginRegistration(packageRoot: string, pluginId: string): RegistrationResult { + const marketplaceRoot = resolveMarketplaceSourceRoot(packageRoot); + 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 any stale registration first so codex does a fresh install rather than + // an update-with-backup. The backup step hits ACCESS_DENIED on Windows junction paths. + spawnStr('codex', ['plugin', 'remove', pluginId]); + const add = spawnStr('codex', ['plugin', 'add', pluginId]); + return { ok: add.status === 0, exitCode: add.status, stderr: add.stderr, stage: 'plugin-add' }; +} + +export function resolveCodexPackageRoot(): { ok: true; packageRoot: string } | { ok: false; error: string } { + const r = spawnSync('npm', ['root', '-g'], { + encoding: 'utf-8', shell: process.platform === 'win32', timeout: 10000, + }); + if (!r || (r.status ?? 1) !== 0) { + return { ok: false, error: `npm root -g failed (exit ${r?.status ?? 1}): ${r?.stderr ?? ''}` }; + } + const packageRoot = path.join((r.stdout ?? '').trim(), '@switchbot', 'codex-plugin'); + return { ok: true, packageRoot }; +} + +/** + * 共享注册 helper:封装 resolveCodexPackageRoot → resolvePluginId → runCodexPluginRegistration。 + * `install --agent codex`、`codex repair`、`codex setup` 三处注册步骤都通过此函数执行, + * 禁止再各自内联 `npm root -g` 或 pluginId 拼接。 + */ +export function registerCodexPlugin(): RegisterCodexPluginResult { + const root = resolveCodexPackageRoot(); + if (!root.ok) { + return { ok: false, pluginId: '', packageRoot: '', error: root.error }; + } + const pluginId = resolvePluginId(root.packageRoot); + const r = runCodexPluginRegistration(root.packageRoot, pluginId); + if (!r.ok) { + return { + ok: false, + pluginId, + packageRoot: root.packageRoot, + error: `${r.stage} exit ${r.exitCode}: ${r.stderr}`, + exitCode: r.exitCode, + stderr: r.stderr, + }; + } + return { ok: true, pluginId, packageRoot: root.packageRoot }; +} diff --git a/src/install/default-steps.ts b/src/install/default-steps.ts index da3fc12e..0d9bd9e2 100644 --- a/src/install/default-steps.ts +++ b/src/install/default-steps.ts @@ -24,8 +24,9 @@ import { } from '../commands/policy.js'; import { promptTokenAndSecret, readCredentialsFile } from '../commands/config.js'; import { selectCredentialStore, type CredentialStore, type CredentialBundle } from '../credentials/keychain.js'; +import { registerCodexPlugin } from './codex-checks.js'; -export type AgentName = 'claude-code' | 'cursor' | 'copilot' | 'none'; +export type AgentName = 'claude-code' | 'cursor' | 'copilot' | 'codex' | 'none'; export interface InstallContext { /** Profile to write credentials under (default `default`). */ @@ -52,6 +53,8 @@ export interface InstallContext { skillRecipePrinted?: boolean; doctorOk?: boolean; doctorReport?: unknown; + codexPluginRegistered?: boolean; + codexPluginIdentifier?: string; } // --------------------------------------------------------------------------- @@ -327,3 +330,29 @@ export function stepDoctorVerify(opts: { cliPath: string; spawner?: DoctorSpawne }, }; } + +// --------------------------------------------------------------------------- +// Step 6: register @switchbot/codex-plugin with the Codex CLI +// --------------------------------------------------------------------------- + +export function stepRegisterCodexPlugin(): InstallStep { + return { + name: 'register-codex-plugin', + description: 'Register @switchbot/codex-plugin with the Codex CLI (marketplace add + plugin add)', + async execute(ctx) { + const r = registerCodexPlugin(); + if (!r.ok) { + throw new Error(`Codex plugin registration failed: ${r.error}`); + } + ctx.codexPluginRegistered = true; + ctx.codexPluginIdentifier = r.pluginId; + }, + async undo(ctx) { + if (!ctx.codexPluginIdentifier) return; + spawnSync( + 'codex', ['plugin', 'remove', ctx.codexPluginIdentifier], + { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 10000 }, + ); + }, + }; +} diff --git a/src/install/preflight.ts b/src/install/preflight.ts index c32f39fe..6f5b9b70 100644 --- a/src/install/preflight.ts +++ b/src/install/preflight.ts @@ -15,6 +15,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; +import { spawnSync } from 'node:child_process'; import { resolvePolicyPath, loadPolicyFile, PolicyFileNotFoundError } from '../policy/load.js'; import { validateLoadedPolicy } from '../policy/validate.js'; import { selectCredentialStore } from '../credentials/keychain.js'; @@ -54,7 +55,7 @@ export interface PreflightOptions { * the later symlink-skill step fails fast with a clear message. * Unset (or `none`/`cursor`/`copilot`) skips the check. */ - agent?: 'claude-code' | 'cursor' | 'copilot' | 'none'; + agent?: 'claude-code' | 'cursor' | 'copilot' | 'codex' | 'none'; /** * Whether this install run will actually attempt to create the Claude * skill link. When false, the agent-skills-dir check is skipped even if @@ -243,6 +244,46 @@ function checkAgentSkillDirWritable(opts: PreflightOptions): PreflightCheck | nu } } +function checkCodexCliForPreflight(opts: PreflightOptions): PreflightCheck | null { + if (opts.agent !== 'codex') return null; + const isWin = (opts.platform ?? process.platform) === 'win32'; + const r = spawnSync(isWin ? 'where' : 'which', ['codex'], { + encoding: 'utf-8', timeout: 3000, + }); + if ((r.status ?? 1) !== 0) { + return { + name: 'codex-cli', + status: 'fail', + message: 'codex CLI not found on PATH', + hint: 'Install Codex (https://github.com/openai/codex), then re-run switchbot install --agent codex', + }; + } + return { name: 'codex-cli', status: 'ok', message: 'codex CLI found on PATH' }; +} + +function checkCodexPluginForPreflight(opts: PreflightOptions): PreflightCheck | null { + if (opts.agent !== 'codex') return null; + const r = spawnSync('npm', ['list', '-g', '--json', '@switchbot/codex-plugin'], { + encoding: 'utf-8', timeout: 10000, shell: process.platform === 'win32', + }); + let installed = false; + try { + const parsed = JSON.parse(r.stdout ?? '{}') as { + dependencies?: Record; + }; + installed = Boolean(parsed.dependencies?.['@switchbot/codex-plugin']); + } catch { /* treat as not installed */ } + if (!installed) { + return { + name: 'codex-plugin-npm', + status: 'fail', + message: '@switchbot/codex-plugin not installed globally', + hint: 'Run: npm install -g @switchbot/codex-plugin (this command only registers an already-installed package)', + }; + } + return { name: 'codex-plugin-npm', status: 'ok', message: '@switchbot/codex-plugin installed' }; +} + /** * Run every pre-flight check and return a combined result. Safe to * call multiple times; no state is cached. @@ -255,6 +296,10 @@ export async function runPreflight(options: PreflightOptions = {}): Promise c.status !== 'fail'); return { checks, ok }; } diff --git a/src/program-builder.ts b/src/program-builder.ts index 56bb1e59..c8102569 100644 --- a/src/program-builder.ts +++ b/src/program-builder.ts @@ -30,6 +30,7 @@ import { registerStatusSyncCommand } from './commands/status-sync.js'; import { registerHealthCommand } from './commands/health.js'; import { registerUpgradeCheckCommand } from './commands/upgrade-check.js'; import { registerDaemonCommand } from './commands/daemon.js'; +import { registerCodexCommand } from './commands/codex.js'; const require = createRequire(import.meta.url); @@ -37,7 +38,7 @@ export const TOP_LEVEL_COMMANDS = [ 'config', 'devices', 'scenes', 'webhook', 'completion', 'mcp', 'quota', 'catalog', 'cache', 'events', 'doctor', 'schema', 'history', 'plan', 'capabilities', 'agent-bootstrap', 'install', 'uninstall', 'status-sync', - 'health', 'upgrade-check', 'daemon', 'reset', + 'health', 'upgrade-check', 'daemon', 'reset', 'codex', ] as const; const cacheModeArg = (value: string): string => { @@ -121,6 +122,7 @@ export function buildProgram(): Command { registerHealthCommand(program); registerUpgradeCheckCommand(program); registerDaemonCommand(program); + registerCodexCommand(program); return program; } diff --git a/tests/commands/codex.test.ts b/tests/commands/codex.test.ts new file mode 100644 index 00000000..fa061952 --- /dev/null +++ b/tests/commands/codex.test.ts @@ -0,0 +1,600 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { runCli } from '../helpers/cli.js'; +import { registerCodexCommand } from '../../src/commands/codex.js'; + +const spawnSyncRepairMock = vi.hoisted(() => vi.fn()); +vi.mock('node:child_process', () => ({ spawnSync: spawnSyncRepairMock })); + +const runDoctorChecksMock = vi.hoisted(() => vi.fn()); +vi.mock('../../src/commands/doctor.js', async (importOriginal) => { + const actual = await importOriginal() as Record; + return { ...actual, runDoctorChecks: runDoctorChecksMock }; +}); + +const checkCodexCliMock = vi.hoisted(() => vi.fn()); +const checkCodexPluginNpmMock = vi.hoisted(() => vi.fn()); +const checkCodexPluginRegisteredMock = vi.hoisted(() => vi.fn()); +const registerCodexPluginMock = vi.hoisted(() => vi.fn()); +vi.mock('../../src/install/codex-checks.js', async (importOriginal) => { + const actual = await importOriginal() as Record; + return { + ...actual, + checkCodexCli: checkCodexCliMock, + checkCodexPluginNpm: checkCodexPluginNpmMock, + checkCodexPluginRegistered: checkCodexPluginRegisteredMock, + registerCodexPlugin: registerCodexPluginMock, + }; +}); + +const tryLoadConfigMock = vi.hoisted(() => vi.fn()); +vi.mock('../../src/config.js', async (importOriginal) => { + const actual = await importOriginal() as Record; + return { ...actual, tryLoadConfig: tryLoadConfigMock }; +}); + +function makeBaseChecks() { + return [ + { name: 'node', status: 'ok' as const, detail: 'Node 22.x' }, + { name: 'path', status: 'ok' as const, detail: 'switchbot on PATH' }, + { name: 'credentials', status: 'ok' as const, detail: 'file: ~/.switchbot/config.json' }, + { name: 'mcp', status: 'ok' as const, detail: '5 tools registered' }, + ]; +} + +function makeChecks(overrides: Array<{ name: string; status: 'ok' | 'warn' | 'fail'; detail: unknown }> = []) { + const base = [ + { name: 'node', status: 'ok' as const, detail: 'ok' }, + { name: 'path', status: 'ok' as const, detail: 'ok' }, + { name: 'credentials', status: 'ok' as const, detail: 'ok' }, + { name: 'mcp', status: 'ok' as const, detail: 'ok' }, + { name: 'codex-cli', status: 'ok' as const, detail: 'ok' }, + { name: 'codex-plugin-npm', status: 'ok' as const, detail: 'ok' }, + { name: 'codex-plugin-registered', status: 'ok' as const, detail: 'ok' }, + ]; + for (const override of overrides) { + const idx = base.findIndex((c) => c.name === override.name); + if (idx >= 0) base[idx] = override as typeof base[0]; + } + return base; +} + +beforeEach(() => { + runDoctorChecksMock.mockReset(); + checkCodexCliMock.mockReset(); + checkCodexPluginNpmMock.mockReset(); + checkCodexPluginRegisteredMock.mockReset(); + registerCodexPluginMock.mockReset(); + tryLoadConfigMock.mockReset(); +}); + +describe('switchbot codex doctor', () => { + function setupAllOk() { + runDoctorChecksMock.mockResolvedValue(makeBaseChecks()); + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: 'found' }); + checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'v0.8.2' }); + checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: 'switchbot@pkg' }); + } + + it('exits 0 and prints 7 ok when all checks pass', async () => { + setupAllOk(); + const { exitCode, stdout } = await runCli(registerCodexCommand, ['codex', 'doctor']); + expect(exitCode).toBe(0); + expect(stdout.join('\n')).toMatch(/7 ok/); + }); + + it('exits 1 when a codex-specific check fails', async () => { + runDoctorChecksMock.mockResolvedValue(makeBaseChecks()); + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'fail', detail: { message: 'not found on PATH' } }); + checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'v0.8.2' }); + checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: 'x' }); + const { exitCode } = await runCli(registerCodexCommand, ['codex', 'doctor']); + expect(exitCode).toBe(1); + }); + + it('--json emits ok, overall, summary, checks keys', async () => { + setupAllOk(); + const { stdout } = await runCli(registerCodexCommand, ['codex', 'doctor', '--json']); + const parsed = JSON.parse(stdout.join('')) as Record; + const data = (parsed.data ?? parsed) as Record; + expect(data).toHaveProperty('ok', true); + expect(data).toHaveProperty('overall', 'ok'); + expect(data).toHaveProperty('summary'); + expect(Array.isArray(data.checks)).toBe(true); + }); + + it('--quiet hides passing checks', async () => { + runDoctorChecksMock.mockResolvedValue(makeBaseChecks()); + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: 'found' }); + checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'x' }); + checkCodexPluginRegisteredMock.mockReturnValue({ + name: 'codex-plugin-registered', status: 'warn', detail: { message: 'not registered' }, + }); + const { stdout } = await runCli(registerCodexCommand, ['codex', 'doctor', '--quiet']); + const out = stdout.join('\n'); + expect(out).toContain('codex-plugin-registered'); + expect(out).not.toContain('node'); + }); + + it('calls runDoctorChecks with exactly 4 base sections and codex checks directly', async () => { + setupAllOk(); + await runCli(registerCodexCommand, ['codex', 'doctor']); + const sections: string[] = runDoctorChecksMock.mock.calls[0][0] as string[]; + expect(sections).toHaveLength(4); + expect(sections).toContain('node'); + expect(sections).toContain('path'); + expect(sections).toContain('credentials'); + expect(sections).toContain('mcp'); + expect(checkCodexCliMock).toHaveBeenCalledOnce(); + expect(checkCodexPluginNpmMock).toHaveBeenCalledOnce(); + expect(checkCodexPluginRegisteredMock).toHaveBeenCalledOnce(); + }); +}); + +describe('switchbot codex repair', () => { + beforeEach(() => { + spawnSyncRepairMock.mockReset(); + runDoctorChecksMock.mockReset(); + checkCodexCliMock.mockReset(); + checkCodexPluginNpmMock.mockReset(); + checkCodexPluginRegisteredMock.mockReset(); + registerCodexPluginMock.mockReset(); + tryLoadConfigMock.mockReset(); + }); + + it('--dry-run prints step list without running spawnSync', async () => { + const { exitCode, stderr } = await runCli( + registerCodexCommand, + ['codex', 'repair', '--dry-run'], + ); + expect(exitCode).toBe(0); + const out = stderr.join('\n'); + expect(out).toContain('verify-cli'); + expect(out).toContain('re-auth'); + expect(out).toContain('remove-plugin'); + expect(out).toContain('register-plugin'); + expect(out).toContain('doctor-verify'); + expect(out).toContain('No changes made'); + expect(spawnSyncRepairMock).not.toHaveBeenCalled(); + }); + + it('exits 2 when verify-cli finds a fail-level check', async () => { + runDoctorChecksMock.mockResolvedValueOnce([ + { name: 'node', status: 'fail', detail: 'Node 16 < required v18' }, + { name: 'path', status: 'ok', detail: 'ok' }, + ]); + const { exitCode } = await runCli(registerCodexCommand, ['codex', 'repair', '--skip', 're-auth,remove-plugin,register-plugin,doctor-verify']); + expect(exitCode).toBe(2); + }); + + it('--skip re-auth,remove-plugin marks those steps as skipped in --json output', async () => { + // verify-cli: node+path ok + runDoctorChecksMock.mockResolvedValueOnce([ + { name: 'node', status: 'ok', detail: 'ok' }, + { name: 'path', status: 'ok', detail: 'ok' }, + ]); + // C4.3: register-plugin now mocks at the registerCodexPlugin boundary, not spawnSync. + registerCodexPluginMock.mockReturnValueOnce({ + ok: true, + pluginId: 'switchbot@codex-plugin', + packageRoot: '/usr/local/lib/node_modules/@switchbot/codex-plugin', + }); + // doctor-verify: all 7 checks ok (via runAllCodexDoctorChecks which calls runDoctorChecks + 3 codex checks) + runDoctorChecksMock.mockResolvedValueOnce(makeBaseChecks()); // base 4 for doctor-verify + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: 'ok' }); + checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'ok' }); + checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: 'ok' }); + + const { exitCode, stdout } = await runCli( + registerCodexCommand, + ['codex', 'repair', '--json', '--skip', 're-auth,remove-plugin'], + ); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.join('')) as { data?: { outcomes: Array<{ step: string; status: string }> }; outcomes?: Array<{ step: string; status: string }> }; + const outcomes = parsed.data?.outcomes ?? parsed.outcomes ?? []; + const reAuth = outcomes.find((o) => o.step === 're-auth'); + const removePl = outcomes.find((o) => o.step === 'remove-plugin'); + expect(reAuth?.status).toBe('skipped'); + expect(removePl?.status).toBe('skipped'); + // C4.3: confirm we hit the shared helper (and only once for register-plugin) + expect(registerCodexPluginMock).toHaveBeenCalledOnce(); + }); + + it('exits 1 when register-plugin step fails', async () => { + runDoctorChecksMock.mockResolvedValueOnce([ + { name: 'node', status: 'ok', detail: 'ok' }, + { name: 'path', status: 'ok', detail: 'ok' }, + ]); + // C4.3: register-plugin failure surfaces via the shared helper's normalized error. + registerCodexPluginMock.mockReturnValueOnce({ + ok: false, + pluginId: 'switchbot@codex-plugin', + packageRoot: '/usr/local/lib/node_modules/@switchbot/codex-plugin', + error: 'exit 1: marketplace error', + exitCode: 1, + stderr: 'marketplace error', + }); + // doctor-verify still runs after register-plugin fails — mock its runDoctorChecks call + runDoctorChecksMock.mockResolvedValueOnce(makeBaseChecks()); + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: 'ok' }); + checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'ok' }); + checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: 'ok' }); + + const { exitCode } = await runCli( + registerCodexCommand, + ['codex', 'repair', '--skip', 're-auth,remove-plugin'], + ); + // register-plugin failed → anyFailed=true → exit 1 + expect(exitCode).toBe(1); + }); + + // A1: re-auth must spawn the running switchbot binary (process.execPath + cliPath) + // and forward both --profile and --config so credentials land in the correct scope. + it('re-auth spawns process.execPath with --config forwarded when credentials are missing', async () => { + // verify-cli passes + runDoctorChecksMock.mockResolvedValueOnce([ + { name: 'node', status: 'ok', detail: 'ok' }, + { name: 'path', status: 'ok', detail: 'ok' }, + ]); + // credentials missing + tryLoadConfigMock.mockReturnValue(null); + // re-auth spawn returns 0 → ok + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); + // remove-plugin is skipped via --skip; register-plugin is not skippable → mock helper + registerCodexPluginMock.mockReturnValueOnce({ + ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: '/some/path', + }); + // doctor-verify still runs + runDoctorChecksMock.mockResolvedValueOnce(makeBaseChecks()); + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: 'ok' }); + checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'ok' }); + checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: 'ok' }); + + const { exitCode } = await runCli( + registerCodexCommand, + ['--profile', 'staging', '--config', '/tmp/sb.json', + 'codex', 'repair', '--skip', 'remove-plugin'], + ); + expect(exitCode).toBe(0); + // Find the spawn call corresponding to re-auth (stdio: 'inherit' is the marker) + const reAuthCall = spawnSyncRepairMock.mock.calls.find( + (call) => (call[2] as { stdio?: string } | undefined)?.stdio === 'inherit', + ); + expect(reAuthCall).toBeDefined(); + if (!reAuthCall) return; + const [exe, argv] = reAuthCall as [string, string[], unknown]; + expect(exe).toBe(process.execPath); + expect(argv).toContain('--config'); + expect(argv).toContain('/tmp/sb.json'); + expect(argv).toContain('--profile'); + expect(argv).toContain('staging'); + expect(argv.slice(-2)).toEqual(['auth', 'login']); + }); + + // A1 (negative): no --config passed → argv must NOT contain --config + it('re-auth omits --config from argv when no global --config is set', async () => { + runDoctorChecksMock.mockResolvedValueOnce([ + { name: 'node', status: 'ok', detail: 'ok' }, + { name: 'path', status: 'ok', detail: 'ok' }, + ]); + tryLoadConfigMock.mockReturnValue(null); + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); + registerCodexPluginMock.mockReturnValueOnce({ + ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: '/some/path', + }); + runDoctorChecksMock.mockResolvedValueOnce(makeBaseChecks()); + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: 'ok' }); + checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'ok' }); + checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: 'ok' }); + + await runCli( + registerCodexCommand, + ['codex', 'repair', '--skip', 'remove-plugin'], + ); + const reAuthCall = spawnSyncRepairMock.mock.calls.find( + (call) => (call[2] as { stdio?: string } | undefined)?.stdio === 'inherit', + ); + expect(reAuthCall).toBeDefined(); + if (!reAuthCall) return; + const argv = reAuthCall[1] as string[]; + expect(argv).not.toContain('--config'); + // default profile → also no --profile in argv + expect(argv).not.toContain('--profile'); + }); +}); + +// ─── codex setup (C5) ──────────────────────────────────────────────────────── + +describe('switchbot codex setup', () => { + beforeEach(() => { + spawnSyncRepairMock.mockReset(); + runDoctorChecksMock.mockReset(); + checkCodexCliMock.mockReset(); + checkCodexPluginNpmMock.mockReset(); + checkCodexPluginRegisteredMock.mockReset(); + registerCodexPluginMock.mockReset(); + tryLoadConfigMock.mockReset(); + }); + + it('--dry-run prints the 6-step list without mutating', async () => { + const { exitCode, stderr } = await runCli( + registerCodexCommand, + ['codex', 'setup', '--dry-run'], + ); + expect(exitCode).toBe(0); + const out = stderr.join('\n'); + expect(out).toContain('check-codex-cli'); + expect(out).toContain('install-switchbot-cli'); + expect(out).toContain('install-codex-plugin'); + expect(out).toContain('register-plugin'); + expect(out).toContain('auth'); + expect(out).toContain('doctor-verify'); + expect(out).toContain('No changes made'); + expect(spawnSyncRepairMock).not.toHaveBeenCalled(); + expect(registerCodexPluginMock).not.toHaveBeenCalled(); + }); + + it('--dry-run --json emits 6 ordered steps with skippable flags', async () => { + const { exitCode, stdout } = await runCli( + registerCodexCommand, + ['codex', 'setup', '--dry-run', '--json'], + ); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.join('')) as { + data?: { dryRun: boolean; steps: Array<{ name: string; skippable: boolean; willSkip: boolean }> }; + dryRun?: boolean; + steps?: Array<{ name: string; skippable: boolean; willSkip: boolean }>; + }; + const data = parsed.data ?? parsed; + expect(data.dryRun).toBe(true); + expect(data.steps).toHaveLength(6); + expect(data.steps?.map((s) => s.name)).toEqual([ + 'check-codex-cli', 'install-switchbot-cli', 'install-codex-plugin', 'register-plugin', 'auth', 'doctor-verify', + ]); + const skippable = Object.fromEntries(data.steps!.map((s) => [s.name, s.skippable])); + expect(skippable['install-switchbot-cli']).toBe(true); + expect(skippable['install-codex-plugin']).toBe(true); + expect(skippable['auth']).toBe(true); + expect(skippable['check-codex-cli']).toBe(false); + expect(skippable['register-plugin']).toBe(false); + expect(skippable['doctor-verify']).toBe(false); + }); + + it('exits 2 with "not skippable" when --skip targets a non-skippable step', async () => { + const { exitCode, stderr } = await runCli( + registerCodexCommand, + ['codex', 'setup', '--skip', 'register-plugin'], + ); + expect(exitCode).toBe(2); + expect(stderr.join('\n')).toContain("invalid --skip: 'register-plugin' is not skippable"); + }); + + it('exits 2 when check-codex-cli fails (preflight)', async () => { + checkCodexCliMock.mockReturnValueOnce({ + name: 'codex-cli', status: 'fail', detail: { message: 'codex CLI not found on PATH' }, + }); + const { exitCode, stdout } = await runCli( + registerCodexCommand, + ['codex', 'setup', '--json'], + ); + expect(exitCode).toBe(2); + const parsed = JSON.parse(stdout.join('')) as { + data?: { ok: boolean; preflightFailed: boolean; outcomes: Array<{ step: string; status: string }> }; + }; + const data = parsed.data!; + expect(data.preflightFailed).toBe(true); + // Only the first step ran — subsequent steps must be absent + expect(data.outcomes).toHaveLength(1); + expect(data.outcomes[0].step).toBe('check-codex-cli'); + expect(data.outcomes[0].status).toBe('failed'); + // register-plugin must NOT have been called when preflight short-circuits + expect(registerCodexPluginMock).not.toHaveBeenCalled(); + }); + + it('--yes with credentials missing returns failed auth without spawning auth login', async () => { + checkCodexCliMock.mockReturnValueOnce({ + name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex', version: 'codex 1.2.3' }, + }); + // install-switchbot-cli step: npm list -g returns the package as already installed + spawnSyncRepairMock.mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: '1.0.0' } } }), + stderr: '', + }); + // install-codex-plugin step: npm list -g returns the package as already installed + spawnSyncRepairMock.mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '0.1.0' } } }), + stderr: '', + }); + // register-plugin succeeds + registerCodexPluginMock.mockReturnValueOnce({ + ok: true, pluginId: 'switchbot@codex-plugin', + packageRoot: '/usr/local/lib/node_modules/@switchbot/codex-plugin', + }); + // credentials missing + tryLoadConfigMock.mockReturnValue(null); + // doctor-verify still runs (4 base + 3 codex) + runDoctorChecksMock.mockResolvedValueOnce(makeBaseChecks()); + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: 'ok' }); + checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'ok' }); + checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: 'ok' }); + + const { exitCode, stdout } = await runCli( + registerCodexCommand, + ['codex', 'setup', '--yes', '--json'], + ); + expect(exitCode).toBe(1); // anyFailed → 1, not preflight (which would be 2) + const parsed = JSON.parse(stdout.join('')) as { + data?: { outcomes: Array<{ step: string; status: string; message?: string }> }; + }; + const auth = parsed.data!.outcomes.find((o) => o.step === 'auth'); + expect(auth?.status).toBe('failed'); + expect(auth?.message).toContain('credentials-missing'); + // No interactive spawn (stdio: 'inherit') was made + const inheritCall = spawnSyncRepairMock.mock.calls.find( + (call) => (call[2] as { stdio?: string } | undefined)?.stdio === 'inherit', + ); + expect(inheritCall).toBeUndefined(); + }); + + it('forwards --config and --profile to spawned auth login (interactive mode)', async () => { + checkCodexCliMock.mockReturnValueOnce({ + name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, + }); + // install-switchbot-cli: already installed + spawnSyncRepairMock.mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: '1.0.0' } } }), + stderr: '', + }); + // install-codex-plugin: already installed + spawnSyncRepairMock.mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '0.1.0' } } }), + stderr: '', + }); + registerCodexPluginMock.mockReturnValueOnce({ + ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: '/some/path', + }); + // credentials missing → spawn auth login + tryLoadConfigMock.mockReturnValue(null); + // The auth-login spawn returns ok + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); + // doctor-verify + runDoctorChecksMock.mockResolvedValueOnce(makeBaseChecks()); + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: 'ok' }); + checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'ok' }); + checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: 'ok' }); + + await runCli( + registerCodexCommand, + ['--profile', 'prod', '--config', '/etc/sb.json', 'codex', 'setup', '--json'], + ); + const inheritCall = spawnSyncRepairMock.mock.calls.find( + (call) => (call[2] as { stdio?: string } | undefined)?.stdio === 'inherit', + ); + expect(inheritCall).toBeDefined(); + if (!inheritCall) return; + const [exe, argv] = inheritCall as [string, string[], unknown]; + expect(exe).toBe(process.execPath); + expect(argv).toContain('--profile'); + expect(argv).toContain('prod'); + expect(argv).toContain('--config'); + expect(argv).toContain('/etc/sb.json'); + expect(argv.slice(-2)).toEqual(['auth', 'login']); + }); + + it('--skip install-switchbot-cli marks the step as skipped and continues', async () => { + checkCodexCliMock.mockReturnValueOnce({ + name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, + }); + // install-codex-plugin still runs when only install-switchbot-cli is skipped + spawnSyncRepairMock.mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '0.1.0' } } }), + stderr: '', + }); + registerCodexPluginMock.mockReturnValueOnce({ + ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: '/some/path', + }); + // credentials present → auth ok without spawn + tryLoadConfigMock.mockReturnValue({ token: 't', secret: 's' }); + runDoctorChecksMock.mockResolvedValueOnce(makeBaseChecks()); + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: 'ok' }); + checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'ok' }); + checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: 'ok' }); + + const { exitCode, stdout } = await runCli( + registerCodexCommand, + ['codex', 'setup', '--skip', 'install-switchbot-cli', '--json'], + ); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.join('')) as { + data?: { outcomes: Array<{ step: string; status: string }> }; + }; + const step = parsed.data!.outcomes.find((o) => o.step === 'install-switchbot-cli'); + expect(step?.status).toBe('skipped'); + // npm list -g was NOT spawned for install-switchbot-cli; the only npm call was the plugin check. + expect(spawnSyncRepairMock).toHaveBeenCalledTimes(1); + expect(spawnSyncRepairMock.mock.calls[0][1]).toContain('@switchbot/codex-plugin'); + }); + + it('install-switchbot-cli failure exits 1 (not 2 — only check-codex-cli is preflight)', async () => { + checkCodexCliMock.mockReturnValueOnce({ + name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, + }); + // npm list -g says not installed + spawnSyncRepairMock.mockReturnValueOnce({ status: 1, stdout: '{}', stderr: '' }); + // npm install -g fails + spawnSyncRepairMock.mockReturnValueOnce({ status: 1, stdout: '', stderr: 'EACCES' }); + // install-codex-plugin still runs + spawnSyncRepairMock.mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '0.1.0' } } }), + stderr: '', + }); + // register-plugin still runs (continues after non-preflight failure) + registerCodexPluginMock.mockReturnValueOnce({ + ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: '/some/path', + }); + // auth: credentials present + tryLoadConfigMock.mockReturnValue({ token: 't', secret: 's' }); + // doctor-verify + runDoctorChecksMock.mockResolvedValueOnce(makeBaseChecks()); + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: 'ok' }); + checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'ok' }); + checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: 'ok' }); + + const { exitCode, stdout } = await runCli( + registerCodexCommand, + ['codex', 'setup', '--json'], + ); + expect(exitCode).toBe(1); // anyFailed but not preflight → 1 + const parsed = JSON.parse(stdout.join('')) as { + data?: { preflightFailed: boolean; outcomes: Array<{ step: string; status: string }> }; + }; + expect(parsed.data!.preflightFailed).toBe(false); + expect(parsed.data!.outcomes).toHaveLength(6); // all 6 steps ran (no preflight halt) + expect(parsed.data!.outcomes.find((o) => o.step === 'install-switchbot-cli')?.status).toBe('failed'); + // register-plugin still got called despite the earlier failure + expect(registerCodexPluginMock).toHaveBeenCalledOnce(); + }); + + it('installs @switchbot/codex-plugin before registering when missing', async () => { + checkCodexCliMock.mockReturnValueOnce({ + name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, + }); + // switchbot CLI already installed + spawnSyncRepairMock.mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: '1.0.0' } } }), + stderr: '', + }); + // codex plugin missing, then install succeeds + spawnSyncRepairMock.mockReturnValueOnce({ status: 1, stdout: '{}', stderr: '' }); + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); + registerCodexPluginMock.mockReturnValueOnce({ + ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: '/some/path', + }); + tryLoadConfigMock.mockReturnValue({ token: 't', secret: 's' }); + runDoctorChecksMock.mockResolvedValueOnce(makeBaseChecks()); + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: 'ok' }); + checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'ok' }); + checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: 'ok' }); + + const { exitCode, stdout } = await runCli( + registerCodexCommand, + ['codex', 'setup', '--json'], + ); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout.join('')) as { + data?: { outcomes: Array<{ step: string; status: string; message?: string }> }; + }; + const pluginStep = parsed.data!.outcomes.find((o) => o.step === 'install-codex-plugin'); + expect(pluginStep?.status).toBe('ok'); + expect(pluginStep?.message).toContain('installed @switchbot/codex-plugin@latest'); + expect(spawnSyncRepairMock.mock.calls[1][1]).toEqual(['list', '-g', '--json', '--depth=0', '@switchbot/codex-plugin']); + expect(spawnSyncRepairMock.mock.calls[2][1]).toEqual(['install', '-g', '@switchbot/codex-plugin@latest']); + expect(registerCodexPluginMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/install/codex-checks.test.ts b/tests/install/codex-checks.test.ts new file mode 100644 index 00000000..006e0e2e --- /dev/null +++ b/tests/install/codex-checks.test.ts @@ -0,0 +1,368 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { stepRegisterCodexPlugin } from '../../src/install/default-steps.js'; +import type { InstallContext } from '../../src/install/default-steps.js'; + +const spawnSyncMock = vi.hoisted(() => vi.fn()); +vi.mock('node:child_process', () => ({ spawnSync: spawnSyncMock })); + +const existsSyncMock = vi.hoisted(() => vi.fn()); +const readFileSyncMock = vi.hoisted(() => vi.fn()); +const realpathSyncMock = vi.hoisted(() => vi.fn()); +const symlinkSyncMock = vi.hoisted(() => vi.fn()); +const lstatSyncMock = vi.hoisted(() => vi.fn()); +const unlinkSyncMock = vi.hoisted(() => vi.fn()); +const mkdirSyncMock = vi.hoisted(() => vi.fn()); +vi.mock('node:fs', () => ({ + default: { + existsSync: existsSyncMock, + readFileSync: readFileSyncMock, + realpathSync: realpathSyncMock, + symlinkSync: symlinkSyncMock, + lstatSync: lstatSyncMock, + unlinkSync: unlinkSyncMock, + mkdirSync: mkdirSyncMock, + }, + existsSync: existsSyncMock, + readFileSync: readFileSyncMock, + realpathSync: realpathSyncMock, + symlinkSync: symlinkSyncMock, + lstatSync: lstatSyncMock, + unlinkSync: unlinkSyncMock, + mkdirSync: mkdirSyncMock, +})); + +import { + checkCodexCli, + checkCodexPluginNpm, + checkCodexPluginRegistered, + runCodexPluginRegistration, + resolveMarketplaceSourceRoot, + resolvePluginId, + registerCodexPlugin, +} from '../../src/install/codex-checks.js'; + +function makeSpawnResult(status: number, stdout: string, stderr = ''): ReturnType { + return { status, stdout, stderr, error: undefined } as ReturnType; +} + +beforeEach(() => { + spawnSyncMock.mockReset(); + existsSyncMock.mockReset(); + readFileSyncMock.mockReset(); + realpathSyncMock.mockReset(); + symlinkSyncMock.mockReset(); + lstatSyncMock.mockReset(); + unlinkSyncMock.mockReset(); + mkdirSyncMock.mockReset(); +}); + +describe('checkCodexCli', () => { + it('returns ok when codex is on PATH and version parses', () => { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/bin/codex\n')) // which/where + .mockReturnValueOnce(makeSpawnResult(0, 'codex 1.2.3\n')); // codex --version + const result = checkCodexCli(); + expect(result.status).toBe('ok'); + expect((result.detail as Record).path).toBe('/usr/local/bin/codex'); + expect((result.detail as Record).version).toBe('codex 1.2.3'); + }); + + it('returns fail when codex is not on PATH', () => { + spawnSyncMock.mockReturnValueOnce(makeSpawnResult(1, '', 'not found')); + const result = checkCodexCli(); + expect(result.status).toBe('fail'); + expect((result.detail as Record).message).toContain('not found on PATH'); + }); +}); + +describe('checkCodexPluginNpm', () => { + it('returns ok when package is installed globally', () => { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, JSON.stringify({ + dependencies: { '@switchbot/codex-plugin': { version: '0.8.2' } } + }))) + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')); // npm root -g + const result = checkCodexPluginNpm(); + expect(result.status).toBe('ok'); + expect((result.detail as Record).version).toBe('0.8.2'); + }); + + it('returns warn when package is not in npm list output', () => { + spawnSyncMock.mockReturnValueOnce(makeSpawnResult(1, '{}', '')); + const result = checkCodexPluginNpm(); + expect(result.status).toBe('warn'); + const msg = String((result.detail as Record).message); + // A4: warning must include the full repair recipe (npm install + switchbot install) + expect(msg).toContain('npm install -g @switchbot/codex-plugin'); + expect(msg).toContain('switchbot install --agent codex'); + }); + + it('returns warn when npm list json is malformed', () => { + spawnSyncMock.mockReturnValueOnce(makeSpawnResult(0, 'not-json')); + const result = checkCodexPluginNpm(); + expect(result.status).toBe('warn'); + }); +}); + +describe('checkCodexPluginRegistered', () => { + it('returns ok when switchbot appears in codex plugin list', () => { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/bin/codex\n')) // which codex + .mockReturnValueOnce(makeSpawnResult(0, 'switchbot@codex-plugin installed, enabled 0.1.0\n')); // codex plugin list + const result = checkCodexPluginRegistered(); + expect(result.status).toBe('ok'); + expect((result.detail as Record).pluginName).toContain('switchbot'); + }); + + it('returns warn when switchbot is listed but not installed', () => { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/bin/codex\n')) + .mockReturnValueOnce(makeSpawnResult(0, 'switchbot@codex-plugin not installed /tmp/switchbot\n')); + const result = checkCodexPluginRegistered(); + expect(result.status).toBe('warn'); + expect(String((result.detail as Record).message)).toContain('not installed'); + }); + + it('returns warn when switchbot is not in list', () => { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/bin/codex\n')) + .mockReturnValueOnce(makeSpawnResult(0, 'some-other-plugin\n')); + const result = checkCodexPluginRegistered(); + expect(result.status).toBe('warn'); + const msg = String((result.detail as Record).message); + // A4: warning must include the full repair recipe (npm install + switchbot install) + expect(msg).toContain('npm install -g @switchbot/codex-plugin'); + expect(msg).toContain('switchbot install --agent codex'); + }); + + it('returns warn with reason codex-cli-missing when codex is not on PATH', () => { + spawnSyncMock.mockReturnValueOnce(makeSpawnResult(1, '', 'not found')); // which codex fails + const result = checkCodexPluginRegistered(); + expect(result.status).toBe('warn'); + expect((result.detail as Record).reason).toBe('codex-cli-missing'); + }); + + it('returns warn when codex plugin list fails', () => { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/bin/codex\n')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'error')); + const result = checkCodexPluginRegistered(); + expect(result.status).toBe('warn'); + }); +}); + +describe('runCodexPluginRegistration', () => { + it('returns ok when both marketplace add and plugin add succeed', () => { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (pre-clean) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + const result = runCodexPluginRegistration('/some/path', 'switchbot@pkg'); + expect(result.ok).toBe(true); + expect(result.exitCode).toBe(0); + }); + + it('returns failure when marketplace add exits non-zero', () => { + spawnSyncMock.mockReturnValueOnce(makeSpawnResult(1, '', 'marketplace error')); + const result = runCodexPluginRegistration('/some/path', 'switchbot@pkg'); + expect(result.ok).toBe(false); + expect(result.stderr).toBe('marketplace error'); + expect(result.stage).toBe('marketplace-add'); + }); + + it('returns failure when plugin add exits non-zero', () => { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (pre-clean) + .mockReturnValueOnce(makeSpawnResult(1, '', 'plugin add error')); + const result = runCodexPluginRegistration('/some/path', 'switchbot@pkg'); + expect(result.ok).toBe(false); + expect(result.stderr).toBe('plugin add error'); + expect(result.stage).toBe('plugin-add'); + }); +}); + +describe('registerCodexPlugin (shared helper)', () => { + it('returns ok with pluginId and packageRoot when both inner steps succeed', () => { + 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 (pre-clean) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + const r = registerCodexPlugin(); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.pluginId).toBe('switchbot@codex-plugin'); + expect(r.packageRoot).toMatch(/codex-plugin/); + } + }); + + it('returns failure with normalized error when npm root -g fails', () => { + spawnSyncMock.mockReturnValueOnce(makeSpawnResult(1, '', 'npm error')); + const r = registerCodexPlugin(); + expect(r.ok).toBe(false); + expect(r.error).toMatch(/npm root -g failed/); + expect(r.pluginId).toBe(''); + expect(r.packageRoot).toBe(''); + }); + + it('returns failure with normalized error when registration step fails', () => { + existsSyncMock.mockReturnValue(false); + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')) // npm root -g + .mockReturnValueOnce(makeSpawnResult(1, '', 'marketplace add error')); // marketplace add + const r = registerCodexPlugin(); + expect(r.ok).toBe(false); + expect(r.pluginId).toBe('switchbot@codex-plugin'); + expect(r.error).toMatch(/marketplace-add exit 1: marketplace add error/); + expect(r.exitCode).toBe(1); + }); +}); + +describe('resolvePluginId', () => { + it('returns default id when .codex-plugin/plugin.json does not exist', () => { + existsSyncMock.mockReturnValue(false); + expect(resolvePluginId('/some/path/codex-plugin')).toBe('switchbot@codex-plugin'); + }); + + it('uses plugin.json name when available', () => { + existsSyncMock.mockImplementation((p: string) => p.endsWith('plugin.json')); + readFileSyncMock.mockReturnValue('{"name":"myplugin"}'); + expect(resolvePluginId('/some/path/codex-plugin')).toBe('myplugin@codex-plugin'); + }); + + it('uses marketplace.json name when available', () => { + existsSyncMock.mockImplementation((p: string) => p.endsWith('marketplace.json') || p.endsWith('plugin.json')); + readFileSyncMock.mockImplementation((p: string) => ( + p.endsWith('marketplace.json') ? '{"name":"switchbot-market"}' : '{"name":"myplugin"}' + )); + expect(resolvePluginId('/some/path/package-root')).toBe('myplugin@switchbot-market'); + }); + + it('falls back to default when plugin.json has no name', () => { + existsSyncMock.mockImplementation((p: string) => p.endsWith('plugin.json')); + readFileSyncMock.mockReturnValue('{}'); + expect(resolvePluginId('/some/path/codex-plugin')).toBe('switchbot@codex-plugin'); + }); +}); + +describe('resolveMarketplaceSourceRoot', () => { + const SCOPED_ROOT = 'C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@switchbot\\codex-plugin'; + + function makeStat(isSymlink: boolean) { + return { isSymbolicLink: () => isSymlink } as unknown as ReturnType; + } + + it('non-Windows or non-scoped paths short-circuit to packageRoot', () => { + if (process.platform === 'win32') { + expect(resolveMarketplaceSourceRoot('C:\\plain\\path')).toBe('C:\\plain\\path'); + } else { + expect(resolveMarketplaceSourceRoot(SCOPED_ROOT)).toBe(SCOPED_ROOT); + } + expect(symlinkSyncMock).not.toHaveBeenCalled(); + expect(unlinkSyncMock).not.toHaveBeenCalled(); + }); + + it('creates a junction when the alias path is missing', () => { + if (process.platform !== 'win32') return; + lstatSyncMock.mockReturnValue(null); + const resolved = resolveMarketplaceSourceRoot(SCOPED_ROOT); + expect(mkdirSyncMock).toHaveBeenCalledWith(expect.stringMatching(/switchbot$/), { recursive: true }); + expect(symlinkSyncMock).toHaveBeenCalledWith(SCOPED_ROOT, expect.stringMatching(/codex-plugin-marketplace$/), 'junction'); + expect(unlinkSyncMock).not.toHaveBeenCalled(); + expect(resolved).toMatch(/codex-plugin-marketplace$/); + }); + + it('reuses an existing junction that points at the current packageRoot', () => { + if (process.platform !== 'win32') return; + lstatSyncMock.mockReturnValue(makeStat(true)); + realpathSyncMock + .mockReturnValueOnce(SCOPED_ROOT) // alias real + .mockReturnValueOnce(SCOPED_ROOT); // package real + const resolved = resolveMarketplaceSourceRoot(SCOPED_ROOT); + expect(unlinkSyncMock).not.toHaveBeenCalled(); + expect(symlinkSyncMock).not.toHaveBeenCalled(); + expect(resolved).toMatch(/codex-plugin-marketplace$/); + }); + + it('repairs a stale junction pointing elsewhere', () => { + if (process.platform !== 'win32') return; + lstatSyncMock.mockReturnValue(makeStat(true)); + realpathSyncMock + .mockReturnValueOnce('D:\\old\\node_modules\\@switchbot\\codex-plugin') + .mockReturnValueOnce(SCOPED_ROOT); + const resolved = resolveMarketplaceSourceRoot(SCOPED_ROOT); + expect(unlinkSyncMock).toHaveBeenCalledWith(expect.stringMatching(/codex-plugin-marketplace$/)); + expect(symlinkSyncMock).toHaveBeenCalledWith(SCOPED_ROOT, expect.stringMatching(/codex-plugin-marketplace$/), 'junction'); + expect(resolved).toMatch(/codex-plugin-marketplace$/); + }); + + it('throws when the alias path is a real directory', () => { + if (process.platform !== 'win32') return; + lstatSyncMock.mockReturnValue(makeStat(false)); + expect(() => resolveMarketplaceSourceRoot(SCOPED_ROOT)).toThrow(/exists and is not a junction/); + expect(unlinkSyncMock).not.toHaveBeenCalled(); + expect(symlinkSyncMock).not.toHaveBeenCalled(); + }); +}); + +describe('stepRegisterCodexPlugin', () => { + function makeCtx(overrides: Partial = {}): InstallContext { + return { + profile: 'default', + agent: 'codex', + policyPath: '/tmp/policy.yaml', + nonInteractive: true, + ...overrides, + }; + } + + it('sets codexPluginRegistered and codexPluginIdentifier on success', async () => { + spawnSyncMock + .mockReturnValueOnce({ status: 0, stdout: '/usr/local/lib/node_modules\n', stderr: '' }) // npm root -g + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // marketplace add + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // plugin remove (pre-clean) + .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'); + }); + + it('throws when npm root -g fails', async () => { + spawnSyncMock.mockReturnValueOnce({ status: 1, stdout: '', stderr: 'npm error' }); + const step = stepRegisterCodexPlugin(); + const ctx = makeCtx(); + await expect(step.execute(ctx)).rejects.toThrow('npm root -g failed'); + }); + + it('throws when runCodexPluginRegistration fails', async () => { + spawnSyncMock + .mockReturnValueOnce({ status: 0, stdout: '/usr/local/lib/node_modules\n', stderr: '' }) + .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'marketplace error' }); + const step = stepRegisterCodexPlugin(); + const ctx = makeCtx(); + await expect(step.execute(ctx)).rejects.toThrow('Codex plugin registration failed'); + }); + + it('undo calls codex plugin remove when codexPluginIdentifier is set', async () => { + spawnSyncMock.mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); + const step = stepRegisterCodexPlugin(); + const ctx = makeCtx({ codexPluginIdentifier: 'switchbot@codex-plugin' }); + await step.undo(ctx); + expect(spawnSyncMock).toHaveBeenCalledWith( + 'codex', + ['plugin', 'remove', 'switchbot@codex-plugin'], + expect.objectContaining({ encoding: 'utf-8' }), + ); + }); + + it('undo is a no-op when codexPluginIdentifier is not set', async () => { + const step = stepRegisterCodexPlugin(); + const ctx = makeCtx(); + await step.undo(ctx); + expect(spawnSyncMock).not.toHaveBeenCalled(); + }); +});