From 0ea044e28cacaed98b5961b9389b2caee83372dd Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Fri, 29 May 2026 11:12:23 +0800 Subject: [PATCH 01/11] fix(claude-code): use claude mcp add instead of direct settings.json edit registerMcp() now calls Added stdio MCP server switchbot with command: switchbot to user config File modified: C:\Users\CLY\.claude.json via subprocess. checkMcpRegistered() parses output. Both approaches use the official Claude Code API and write to the correct location (~/.claude.json) rather than the unsupported mcpServers field in settings.json. Co-Authored-By: Claude Sonnet 4.6 --- src/install/claude-code-checks.ts | 88 +++++++++ tests/commands/claude-code.test.ts | 276 +++++++++++++++++++++++++++++ 2 files changed, 364 insertions(+) create mode 100644 src/install/claude-code-checks.ts create mode 100644 tests/commands/claude-code.test.ts diff --git a/src/install/claude-code-checks.ts b/src/install/claude-code-checks.ts new file mode 100644 index 00000000..3c44ef35 --- /dev/null +++ b/src/install/claude-code-checks.ts @@ -0,0 +1,88 @@ +import { spawnSync } from 'node:child_process'; + +export interface Check { + name: string; + status: 'ok' | 'warn' | 'fail'; + detail: string | Record; +} + +export interface RegisterMcpResult { + ok: boolean; + alreadyRegistered?: boolean; + error?: string; +} + +const CLAUDE_CMD = 'claude'; +const MCP_SERVER_NAME = 'switchbot'; +const MCP_ADD_ARGS = ['switchbot', 'mcp', 'serve', '--tools', 'all']; + +function spawnStr(cmd: string, args: string[], timeout = 10_000) { + const r = spawnSync(cmd, args, { + encoding: 'utf-8', + shell: process.platform === 'win32', + timeout, + }); + return { status: r.status ?? -1, stdout: r.stdout ?? '', stderr: r.stderr ?? '', error: r.error }; +} + +// ── Checks ─────────────────────────────────────────────────────────────────── + +export function checkClaudeCodeCli(): Check { + const r = spawnStr(CLAUDE_CMD, ['--version'], 8_000); + if (r.status !== 0 || r.error) { + return { + name: 'check-claude-cli', + status: 'fail', + detail: 'claude CLI not found on PATH — install Claude Code first (https://claude.ai/claude-code)', + }; + } + const version = r.stdout.trim().split('\n')[0] ?? ''; + return { + name: 'check-claude-cli', + status: 'ok', + detail: { version: version || 'unknown' }, + }; +} + +export function checkMcpRegistered(): Check { + const r = spawnStr(CLAUDE_CMD, ['mcp', 'list'], 10_000); + if (r.status !== 0 || r.error) { + return { + name: 'check-mcp-registered', + status: 'fail', + detail: `claude mcp list failed — is claude CLI installed? (exit ${r.status})`, + }; + } + if (!r.stdout.toLowerCase().includes(MCP_SERVER_NAME)) { + return { + name: 'check-mcp-registered', + status: 'fail', + detail: `"${MCP_SERVER_NAME}" not found in \`claude mcp list\` output`, + }; + } + return { name: 'check-mcp-registered', status: 'ok', detail: `${MCP_SERVER_NAME} MCP server registered` }; +} + +// ── MCP registration ───────────────────────────────────────────────────────── + +export function registerMcp(): RegisterMcpResult { + // Fast path: already registered + const listR = spawnStr(CLAUDE_CMD, ['mcp', 'list'], 10_000); + if (listR.status === 0 && !listR.error && listR.stdout.toLowerCase().includes(MCP_SERVER_NAME)) { + return { ok: true, alreadyRegistered: true }; + } + + // Register via `claude mcp add --scope user switchbot -- switchbot mcp serve --tools all` + const addR = spawnStr( + CLAUDE_CMD, + ['mcp', 'add', '--scope', 'user', MCP_SERVER_NAME, '--', ...MCP_ADD_ARGS], + 15_000, + ); + if (addR.status !== 0 || addR.error) { + return { + ok: false, + error: `claude mcp add failed (exit ${addR.status}): ${addR.stderr.trim() || (addR.error?.message ?? '')}`, + }; + } + return { ok: true }; +} diff --git a/tests/commands/claude-code.test.ts b/tests/commands/claude-code.test.ts new file mode 100644 index 00000000..3025fd8f --- /dev/null +++ b/tests/commands/claude-code.test.ts @@ -0,0 +1,276 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { runCli } from '../helpers/cli.js'; +import { registerClaudeCodeCommand } from '../../src/commands/claude-code.js'; + +const spawnSyncMock = vi.hoisted(() => vi.fn()); +vi.mock('node:child_process', () => ({ spawnSync: spawnSyncMock })); + +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 checkClaudeCodeCliMock = vi.hoisted(() => vi.fn()); +const checkMcpRegisteredMock = vi.hoisted(() => vi.fn()); +const registerMcpMock = vi.hoisted(() => vi.fn()); +vi.mock('../../src/install/claude-code-checks.js', async (importOriginal) => { + const actual = await importOriginal() as Record; + return { + ...actual, + checkClaudeCodeCli: checkClaudeCodeCliMock, + checkMcpRegistered: checkMcpRegisteredMock, + registerMcp: registerMcpMock, + }; +}); + +const tryLoadConfigMock = vi.hoisted(() => vi.fn()); +vi.mock('../../src/config.js', async (importOriginal) => { + const actual = await importOriginal() as Record; + return { ...actual, tryLoadConfig: tryLoadConfigMock }; +}); + +const okCli = { name: 'check-claude-cli', status: 'ok' as const, detail: { version: '1.0.0' } }; +const failCli = { name: 'check-claude-cli', status: 'fail' as const, detail: 'claude not found' }; +const okMcp = { name: 'check-mcp-registered', status: 'ok' as const, detail: 'registered' }; +const failMcp = { name: 'check-mcp-registered', status: 'fail' as const, detail: 'not registered' }; + +function baseChecks() { + return [ + { 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' }, + ]; +} + +function mockSpawnOk() { + spawnSyncMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'npm' && args[0] === 'ping') return { status: 0, stdout: '', stderr: '' }; + if (cmd === 'npm' && args[0] === 'list') return { + status: 0, + stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: '3.7.6' } } }), + stderr: '', + }; + return { status: 0, stdout: '', stderr: '' }; + }); +} + +beforeEach(() => { + spawnSyncMock.mockReset(); + runDoctorChecksMock.mockReset(); + checkClaudeCodeCliMock.mockReset(); + checkMcpRegisteredMock.mockReset(); + registerMcpMock.mockReset(); + tryLoadConfigMock.mockReset(); +}); + +function run(extraArgs: string[] = []) { + return runCli(registerClaudeCodeCommand, ['claude-code', 'setup', '--yes', ...extraArgs]); +} + +// ── Preflight ───────────────────────────────────────────────────────────────── + +describe('claude-code setup — check-claude-cli (preflight)', () => { + it('exits 2 and reports failed when claude CLI not found', async () => { + checkClaudeCodeCliMock.mockReturnValue(failCli); + const res = await run(); + expect(res.exitCode).toBe(2); + expect(res.stdout.join(' ')).toMatch(/Preflight failed|install Claude Code/i); + }); + + it('exits 0 when already configured (fast path)', async () => { + checkClaudeCodeCliMock.mockReturnValue(okCli); + checkMcpRegisteredMock.mockReturnValue(okMcp); + tryLoadConfigMock.mockReturnValue({ token: 'tok', secret: 'sec' }); + + const res = await run(); + expect(res.exitCode).toBe(0); + expect(res.stdout.join(' ')).toMatch(/already configured/i); + }); +}); + +// ── register-mcp ───────────────────────────────────────────────────────────── + +describe('claude-code setup — register-mcp step', () => { + beforeEach(() => { + checkClaudeCodeCliMock.mockReturnValue(okCli); + // First call: isAlreadyConfigured → fail (triggers full pipeline) + // Subsequent calls (doctor-verify): ok + checkMcpRegisteredMock + .mockReturnValueOnce(failMcp) + .mockReturnValue(okMcp); + tryLoadConfigMock.mockReturnValue({ token: 'tok', secret: 'sec' }); + runDoctorChecksMock.mockResolvedValue(baseChecks()); + mockSpawnOk(); + }); + + it('exits 0 when register-mcp succeeds (calls claude mcp add)', async () => { + registerMcpMock.mockReturnValue({ ok: true }); + const res = await run(); + expect(res.exitCode).toBe(0); + expect(registerMcpMock).toHaveBeenCalledOnce(); + }); + + it('exits 1 when register-mcp fails', async () => { + registerMcpMock.mockReturnValue({ ok: false, error: 'claude mcp add failed (exit 1): permission denied' }); + const res = await run(); + expect(res.exitCode).toBe(1); + expect(res.stdout.join(' ')).toMatch(/permission denied/i); + }); + + it('shows alreadyRegistered message when already in mcp list', async () => { + registerMcpMock.mockReturnValue({ ok: true, alreadyRegistered: true }); + const res = await run(); + expect(res.exitCode).toBe(0); + expect(res.stdout.join(' ')).toMatch(/already registered/i); + }); +}); + +// ── auth step ───────────────────────────────────────────────────────────────── + +describe('claude-code setup — auth step', () => { + beforeEach(() => { + checkClaudeCodeCliMock.mockReturnValue(okCli); + checkMcpRegisteredMock + .mockReturnValueOnce(failMcp) + .mockReturnValue(okMcp); + runDoctorChecksMock.mockResolvedValue(baseChecks()); + registerMcpMock.mockReturnValue({ ok: true }); + mockSpawnOk(); + }); + + it('skips auth login when credentials already present', async () => { + tryLoadConfigMock.mockReturnValue({ token: 'tok', secret: 'sec' }); + const res = await run(); + expect(res.exitCode).toBe(0); + expect(res.stdout.join(' ')).toMatch(/credentials present/i); + }); + + it('reports failed with --yes when credentials missing', async () => { + tryLoadConfigMock.mockReturnValue(null); + const res = await run(); + expect(res.exitCode).toBe(1); + expect(res.stdout.join(' ')).toMatch(/credentials-missing/i); + }); +}); + +// ── --skip validation ───────────────────────────────────────────────────────── + +describe('claude-code setup — --skip validation', () => { + it('exits 2 for non-skippable step name', async () => { + const res = await runCli(registerClaudeCodeCommand, ['claude-code', 'setup', '--skip', 'register-mcp']); + expect(res.exitCode).toBe(2); + expect(res.stderr.join(' ')).toMatch(/invalid --skip/i); + }); + + it('skips skippable steps and still completes ok', async () => { + checkClaudeCodeCliMock.mockReturnValue(okCli); + // skip.size > 0 → fast path not called → no mockReturnValueOnce needed + checkMcpRegisteredMock.mockReturnValue(okMcp); + tryLoadConfigMock.mockReturnValue({ token: 'tok', secret: 'sec' }); + runDoctorChecksMock.mockResolvedValue(baseChecks()); + registerMcpMock.mockReturnValue({ ok: true }); + mockSpawnOk(); + + const res = await runCli(registerClaudeCodeCommand, [ + 'claude-code', 'setup', '--yes', '--skip', 'auth,install-switchbot-cli', + ]); + expect(res.exitCode).toBe(0); + // Skipped steps still appear in output (with · symbol), non-skipped steps show ✓ + expect(res.stdout.join(' ')).toMatch(/install-switchbot-cli/i); + }); +}); + +// ── --json output ───────────────────────────────────────────────────────────── + +describe('claude-code setup — JSON output', () => { + it('emits ok:true on success', async () => { + checkClaudeCodeCliMock.mockReturnValue(okCli); + checkMcpRegisteredMock + .mockReturnValueOnce(failMcp) + .mockReturnValue(okMcp); + tryLoadConfigMock.mockReturnValue({ token: 'tok', secret: 'sec' }); + runDoctorChecksMock.mockResolvedValue(baseChecks()); + registerMcpMock.mockReturnValue({ ok: true }); + mockSpawnOk(); + + const res = await runCli(registerClaudeCodeCommand, ['--json', 'claude-code', 'setup', '--yes']); + expect(res.exitCode).toBe(0); + const parsed = JSON.parse(res.stdout[0]) as Record; + const data = (parsed['data'] ?? parsed) as Record; + expect(data).toHaveProperty('ok', true); + expect(data).toHaveProperty('outcomes'); + }); +}); + +// ── unit: checkMcpRegistered ────────────────────────────────────────────────── + +describe('checkMcpRegistered (unit — real implementation)', () => { + it('returns ok when switchbot appears in claude mcp list output', async () => { + const { checkMcpRegistered } = await vi.importActual( + '../../src/install/claude-code-checks.js', + ); + spawnSyncMock.mockReturnValue({ status: 0, stdout: 'switchbot stdio switchbot mcp serve\n', stderr: '' }); + expect(checkMcpRegistered().status).toBe('ok'); + }); + + it('returns fail when switchbot absent from output', async () => { + const { checkMcpRegistered } = await vi.importActual( + '../../src/install/claude-code-checks.js', + ); + spawnSyncMock.mockReturnValue({ status: 0, stdout: 'other-server stdio other-cmd\n', stderr: '' }); + expect(checkMcpRegistered().status).toBe('fail'); + }); + + it('returns fail when claude mcp list exits non-zero', async () => { + const { checkMcpRegistered } = await vi.importActual( + '../../src/install/claude-code-checks.js', + ); + spawnSyncMock.mockReturnValue({ status: 1, stdout: '', stderr: 'error' }); + expect(checkMcpRegistered().status).toBe('fail'); + }); +}); + +// ── unit: registerMcp ───────────────────────────────────────────────────────── + +describe('registerMcp (unit — real implementation)', () => { + it('returns alreadyRegistered when switchbot already in mcp list', async () => { + const { registerMcp } = await vi.importActual( + '../../src/install/claude-code-checks.js', + ); + spawnSyncMock.mockReturnValue({ status: 0, stdout: 'switchbot stdio\n', stderr: '' }); + const r = registerMcp(); + expect(r.ok).toBe(true); + expect(r.alreadyRegistered).toBe(true); + }); + + it('calls claude mcp add when not registered and returns ok on success', async () => { + const { registerMcp } = await vi.importActual( + '../../src/install/claude-code-checks.js', + ); + spawnSyncMock + .mockReturnValueOnce({ status: 0, stdout: 'other-server\n', stderr: '' }) // mcp list + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); // mcp add + const r = registerMcp(); + expect(r.ok).toBe(true); + expect(r.alreadyRegistered).toBeUndefined(); + const addCall = spawnSyncMock.mock.calls[1] as [string, string[]]; + expect(addCall[0]).toBe('claude'); + expect(addCall[1]).toContain('add'); + expect(addCall[1]).toContain('--scope'); + expect(addCall[1]).toContain('user'); + }); + + it('returns error when claude mcp add fails', async () => { + const { registerMcp } = await vi.importActual( + '../../src/install/claude-code-checks.js', + ); + spawnSyncMock + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // mcp list → not found + .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'access denied' }); // mcp add → fail + const r = registerMcp(); + expect(r.ok).toBe(false); + expect(r.error).toMatch(/access denied/); + }); +}); From da18b71b2cab1c6888b5e665aff09e4ef50d4afc Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Fri, 29 May 2026 11:18:55 +0800 Subject: [PATCH 02/11] feat(claude-code): add claude-code setup command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds — a zero-terminal-required setup flow for Claude Code, mirroring . Users paste into chat: Please set up my SwitchBot integration by running: npx @switchbot/openapi-cli claude-code setup Pipeline: check-claude-cli → check-network → install-switchbot-cli → register-mcp (via claude mcp add --scope user) → auth → doctor-verify. Supports --yes, --skip, --dry-run, --json. Fast path when already configured. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 28 ++-- src/commands/claude-code.ts | 324 ++++++++++++++++++++++++++++++++++++ src/program-builder.ts | 4 +- 3 files changed, 344 insertions(+), 12 deletions(-) create mode 100644 src/commands/claude-code.ts diff --git a/README.md b/README.md index aaf8c244..4f36749c 100644 --- a/README.md +++ b/README.md @@ -121,27 +121,33 @@ switchbot auth login # browser OAuth — saves to OS keychain ## Claude Code integration -**Step 0 — verify first.** If the MCP server is already configured (e.g. via a managed environment or prior setup), no additional steps are needed: +**Already configured? Paste into Claude Code chat:** + +``` +Is my SwitchBot integration working? Please check and list my devices. +``` + +Claude will call `account_overview` or `switchbot doctor` to verify — no terminal needed. + +**Fresh setup — paste into Claude Code chat:** -```bash -switchbot doctor --json +``` +Please set up my SwitchBot integration by running: +npx @switchbot/openapi-cli claude-code setup ``` -If 24 tools are visible and the integration is healthy, you're done. +Claude will run the setup command via the Bash tool. It installs the CLI if missing, registers the MCP server via `claude mcp add --scope user`, and opens a browser login if credentials are not yet configured. Restart Claude Code afterwards to load the MCP tools. -**Fresh setup:** +**Or run manually in your terminal:** ```bash npm install -g @switchbot/openapi-cli -claude mcp add switchbot -- switchbot mcp serve --tools all -switchbot auth login # browser OAuth — saves to OS keychain +switchbot claude-code setup ``` -Run `switchbot doctor --json` afterwards to confirm everything is working. - -The optional skill package [`@switchbot/claude-code-plugin`](https://www.npmjs.com/package/@switchbot/claude-code-plugin) bundles the SKILL.md context document and install hook. Install it only if your environment does not already load the skill automatically. +The optional skill package [`@switchbot/claude-code-plugin`](https://www.npmjs.com/package/@switchbot/claude-code-plugin) bundles the SKILL.md context document. Install it only if your environment does not already load the skill automatically. -**Note:** The root `marketplace.json` file in this repo is for Codex CLI Route B (git sparse clone) and points to the Codex plugin at `packages/codex-plugin/plugins/switchbot`. Claude Code users register via `claude mcp add` and do not use this file. +**Note:** The root `marketplace.json` file in this repo is for Codex CLI Route B (git sparse clone) and points to the Codex plugin at `packages/codex-plugin/plugins/switchbot`. Claude Code users register via `switchbot claude-code setup` and do not use this file. --- diff --git a/src/commands/claude-code.ts b/src/commands/claude-code.ts new file mode 100644 index 00000000..43e1256f --- /dev/null +++ b/src/commands/claude-code.ts @@ -0,0 +1,324 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { spawnSync } from 'node:child_process'; +import { runDoctorChecks } from './doctor.js'; +import { + checkClaudeCodeCli, + checkMcpRegistered, + registerMcp, + type Check, +} from '../install/claude-code-checks.js'; +import { isJsonMode, printJson } from '../utils/output.js'; +import { getActiveProfile } from '../lib/request-context.js'; +import { getConfigPath } from '../utils/flags.js'; + +const SWITCHBOT_CLI_PACKAGE = '@switchbot/openapi-cli'; + +// ── Shared helpers ──────────────────────────────────────────────────────────── + +async function credentialsPresent(): Promise { + try { + const { tryLoadConfig } = await import('../config.js'); + const cfg = tryLoadConfig(); + return Boolean(cfg?.token && cfg?.secret); + } catch { + return false; + } +} + +function buildAuthLoginArgv(profile: string, configPath?: string): string[] { + const cliPath = process.argv[1] ?? ''; + return [ + cliPath, + ...(profile !== 'default' ? ['--profile', profile] : []), + ...(configPath ? ['--config', configPath] : []), + 'auth', 'login', + ]; +} + +// ── Step definitions ────────────────────────────────────────────────────────── + +interface StepOutcome { + step: string; + status: 'ok' | 'skipped' | 'failed' | 'warn'; + message?: string; +} + +interface StepDef { + name: string; + description: string; + skippable: boolean; +} + +const SETUP_STEPS: readonly StepDef[] = [ + { name: 'check-claude-cli', description: 'Verify claude CLI on PATH', skippable: false }, + { name: 'check-network', description: 'Probe npm registry', skippable: true }, + { name: 'install-switchbot-cli', description: 'Install @switchbot/openapi-cli if missing or outdated', skippable: true }, + { name: 'register-mcp', description: 'Write switchbot MCP entry to ~/.claude/settings.json', skippable: false }, + { name: 'auth', description: 'Verify credentials; spawn auth login if missing', skippable: true }, + { name: 'doctor-verify', description: 'Run base doctor checks and report health', skippable: false }, +]; + +const DEPRECATED_SKIP_NAMES = new Set(); + +function validateSkip(skip: Set): { ok: true } | { ok: false; offending: string } { + const skippable = new Set(SETUP_STEPS.filter((s) => s.skippable).map((s) => s.name)); + for (const name of skip) { + if (DEPRECATED_SKIP_NAMES.has(name)) continue; + if (!skippable.has(name)) return { ok: false, offending: name }; + } + return { ok: true }; +} + +// ── Step implementations ────────────────────────────────────────────────────── + +function setupStepCheckClaudeCli(): StepOutcome { + const c = checkClaudeCodeCli(); + 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-claude-cli', status: 'failed', message: msg }; + } + const detail = c.detail as { version?: string }; + return { step: 'check-claude-cli', status: 'ok', message: `claude ${detail.version ?? ''}`.trim() }; +} + +function setupStepCheckNetwork(): StepOutcome { + const r = spawnSync('npm', ['ping'], { + encoding: 'utf-8', + shell: process.platform === 'win32', + timeout: 5_000, + }); + if ((r.status ?? 1) === 0) { + return { step: 'check-network', status: 'ok', message: 'npm registry reachable' }; + } + return { + step: 'check-network', + status: 'warn', + message: 'npm registry unreachable — install-switchbot-cli will be skipped', + }; +} + +function setupStepInstallSwitchbotCli(): StepOutcome { + const list = spawnSync('npm', ['list', '-g', '--json', '--depth=0', SWITCHBOT_CLI_PACKAGE], { + encoding: 'utf-8', + shell: process.platform === 'win32', + timeout: 15_000, + }); + let installed: string | null = null; + try { + const parsed = JSON.parse(list.stdout ?? '{}') as { dependencies?: Record }; + installed = parsed.dependencies?.[SWITCHBOT_CLI_PACKAGE]?.version ?? null; + } catch { /* not installed */ } + + if (installed !== null) { + return { step: 'install-switchbot-cli', status: 'ok', message: `already installed (${installed})` }; + } + + const inst = spawnSync('npm', ['install', '-g', `${SWITCHBOT_CLI_PACKAGE}@latest`], { + encoding: 'utf-8', + shell: process.platform === 'win32', + timeout: 120_000, + }); + if ((inst.status ?? 1) !== 0) { + return { + step: 'install-switchbot-cli', + status: 'failed', + message: `npm install -g failed (exit ${inst.status ?? 1}): ${inst.stderr ?? ''}`, + }; + } + return { step: 'install-switchbot-cli', status: 'ok', message: `installed ${SWITCHBOT_CLI_PACKAGE}` }; +} + +function setupStepRegisterMcp(): StepOutcome { + const r = registerMcp(); + if (!r.ok) { + return { step: 'register-mcp', status: 'failed', message: r.error }; + } + return { + step: 'register-mcp', + status: 'ok', + message: r.alreadyRegistered ? 'already registered' : 'registered switchbot MCP server', + }; +} + +async function setupStepAuth(ctx: { profile: string; configPath?: string; nonInteractive: boolean }): 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 CLAUDE_CODE_BASE_SECTIONS = ['node', 'path', 'credentials', 'mcp'] as const; + const checks: Check[] = (await runDoctorChecks(CLAUDE_CODE_BASE_SECTIONS)) ?? []; + const mcpCheck = checkMcpRegistered(); + const all = [...checks, mcpCheck]; + const summary = { + ok: all.filter((c) => c.status === 'ok').length, + warn: all.filter((c) => c.status === 'warn').length, + fail: all.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`, + }; +} + +export async function isClaudeCodeAlreadyConfigured(): Promise { + if (checkClaudeCodeCli().status !== 'ok') return false; + if (checkMcpRegistered().status !== 'ok') return false; + if (!await credentialsPresent()) return false; + return true; +} + +// ── Pipeline runner ─────────────────────────────────────────────────────────── + +async function runSetup( + skip: Set, + ctx: { profile: string; configPath?: string; nonInteractive: boolean }, +): Promise<{ outcomes: StepOutcome[]; anyFailed: boolean; preflightFailed: boolean }> { + const outcomes: StepOutcome[] = []; + let preflightFailed = false; + let networkOffline = false; + + for (const step of SETUP_STEPS) { + if (step.name === 'install-switchbot-cli' && networkOffline && !skip.has(step.name)) { + outcomes.push({ step: step.name, status: 'skipped', message: 'skipped: npm registry unreachable' }); + continue; + } + if (skip.has(step.name)) { + outcomes.push({ step: step.name, status: 'skipped' }); + continue; + } + let outcome: StepOutcome; + if (step.name === 'check-claude-cli') outcome = setupStepCheckClaudeCli(); + else if (step.name === 'check-network') outcome = setupStepCheckNetwork(); + else if (step.name === 'install-switchbot-cli') outcome = setupStepInstallSwitchbotCli(); + else if (step.name === 'register-mcp') outcome = setupStepRegisterMcp(); + else if (step.name === 'auth') outcome = await setupStepAuth(ctx); + else outcome = await setupStepDoctorVerify(); + + outcomes.push(outcome); + if (step.name === 'check-claude-cli' && outcome.status === 'failed') { + preflightFailed = true; + break; + } + if (step.name === 'check-network' && outcome.status === 'warn') { + networkOffline = true; + } + } + return { outcomes, anyFailed: outcomes.some((o) => o.status === 'failed'), preflightFailed }; +} + +// ── Command registration ────────────────────────────────────────────────────── + +export function registerClaudeCodeCommand(program: Command): void { + const claudeCode = program + .command('claude-code') + .description('Claude Code integration commands'); + + claudeCode + .command('setup') + .description('Bootstrap the Claude Code integration: install CLI if missing, register MCP server, auth, verify') + .option('--skip ', 'Comma-separated step names to skip (skippable: "install-switchbot-cli", "auth")') + .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(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.log(chalk.bold('switchbot claude-code setup — dry run')); + console.log(''); + for (const s of SETUP_STEPS) { + const tag = skip.has(s.name) ? chalk.dim(' · (skip)') : ' •'; + console.log(`${tag} ${s.name.padEnd(24)} ${s.description}`); + } + console.log(''); + console.log(chalk.dim('No changes made. Re-run without --dry-run to apply.')); + } + process.exit(0); + return; + } + + if (skip.size === 0 && await isClaudeCodeAlreadyConfigured()) { + if (isJsonMode()) { + printJson({ ok: true, alreadyConfigured: true, outcomes: [] }); + } else { + console.log(chalk.green('Already configured, nothing to do.')); + console.log(chalk.dim('Run: switchbot doctor — to verify health')); + } + process.exit(0); + return; + } + + if (!isJsonMode()) { + console.log(chalk.bold('Setting up Claude Code integration...')); + console.log(''); + } + + const ctx = { profile, configPath, nonInteractive: Boolean(opts.yes) }; + const { outcomes, anyFailed, preflightFailed } = await runSetup(skip, ctx); + + if (isJsonMode()) { + printJson({ ok: !anyFailed, hasWarnings: outcomes.some((o) => o.status === 'warn'), preflightFailed, outcomes }); + } else { + for (const o of outcomes) { + const icon = + o.status === 'ok' ? chalk.green('✓') : + o.status === 'skipped' ? chalk.dim('·') : + o.status === 'warn' ? chalk.yellow('⚠') : + chalk.red('✗'); + console.log(`${icon} ${o.step.padEnd(24)} ${o.message ?? ''}`); + } + console.log(''); + if (!anyFailed) { + console.log(chalk.green('Setup complete.')); + console.log(chalk.dim('Restart Claude Code to load the SwitchBot skill and MCP tools.')); + console.log(chalk.dim('After restart, ask: "List my SwitchBot devices."')); + } else if (preflightFailed) { + console.log(chalk.red('Preflight failed — install Claude Code first (https://claude.ai/claude-code), then re-run.')); + } else { + console.log(chalk.yellow('Setup finished with failures.')); + console.log(chalk.dim('Run: switchbot claude-code setup — to retry')); + } + } + + if (preflightFailed) process.exit(2); + if (anyFailed) process.exit(1); + process.exit(0); + }); +} diff --git a/src/program-builder.ts b/src/program-builder.ts index c8102569..fe3a73f8 100644 --- a/src/program-builder.ts +++ b/src/program-builder.ts @@ -31,6 +31,7 @@ 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'; +import { registerClaudeCodeCommand } from './commands/claude-code.js'; const require = createRequire(import.meta.url); @@ -38,7 +39,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', 'codex', + 'health', 'upgrade-check', 'daemon', 'reset', 'codex', 'claude-code', ] as const; const cacheModeArg = (value: string): string => { @@ -123,6 +124,7 @@ export function buildProgram(): Command { registerUpgradeCheckCommand(program); registerDaemonCommand(program); registerCodexCommand(program); + registerClaudeCodeCommand(program); return program; } From 9a6e98ff903b501b68deeb0a6f2db55f16b4be02 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Fri, 29 May 2026 12:13:42 +0800 Subject: [PATCH 03/11] fix(marketplace): correct .claude-plugin/marketplace.json to point at claude-code-plugin Route B (Codex git sparse clone) reads packages/codex-plugin via --sparse and never touches .claude-plugin/marketplace.json at the repo root. That file is the Claude Code Plugin Marketplace entry point and must point to the Claude Code plugin source, not the Codex plugin source. Changes: - .claude-plugin/marketplace.json: source -> ./packages/claude-code-plugin/plugins/switchbot, add owner field, drop non-standard policy block - scripts/smoke-codex-git-sparse.mjs: extend sparse-checkout to include packages/claude-code-plugin, assert root marketplace points to Claude Code path, verify plugin.json present; Codex Route B assertion unchanged - packages/claude-code-plugin/plugins/switchbot/.claude-plugin/plugin.json: add repository/license/keywords fields Co-Authored-By: Claude Sonnet 4.6 --- .claude-plugin/marketplace.json | 11 ++++++----- .../plugins/switchbot/.claude-plugin/plugin.json | 5 ++++- scripts/smoke-codex-git-sparse.mjs | 15 ++++++++++----- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 105dd31f..2150d318 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,14 +1,15 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "switchbot", + "owner": { + "name": "OpenWonderLabs", + "email": "developer@wondertechlabs.com" + }, "plugins": [ { "name": "switchbot", - "source": "./packages/codex-plugin/plugins/switchbot", - "policy": { - "installation": "AVAILABLE", - "authentication": "ON_INSTALL" - }, + "source": "./packages/claude-code-plugin/plugins/switchbot", + "description": "Control SwitchBot smart-home devices from Claude Code via MCP.", "category": "Productivity" } ] diff --git a/packages/claude-code-plugin/plugins/switchbot/.claude-plugin/plugin.json b/packages/claude-code-plugin/plugins/switchbot/.claude-plugin/plugin.json index dc96f09b..423e1a22 100644 --- a/packages/claude-code-plugin/plugins/switchbot/.claude-plugin/plugin.json +++ b/packages/claude-code-plugin/plugins/switchbot/.claude-plugin/plugin.json @@ -5,5 +5,8 @@ "author": { "name": "OpenWonderLabs" }, - "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli" + "repository": "https://github.com/OpenWonderLabs/switchbot-openapi-cli", + "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli", + "license": "MIT", + "keywords": ["switchbot", "smart-home", "iot", "mcp"] } diff --git a/scripts/smoke-codex-git-sparse.mjs b/scripts/smoke-codex-git-sparse.mjs index aecc8fd3..d3b754b5 100644 --- a/scripts/smoke-codex-git-sparse.mjs +++ b/scripts/smoke-codex-git-sparse.mjs @@ -41,27 +41,32 @@ try { runGit(['clone', '--no-checkout', '--branch', ref, repoRoot, stagingDir], { cwd: workDir }); } runGit(['-C', stagingDir, 'sparse-checkout', 'init', '--cone'], { cwd: workDir }); - runGit(['-C', stagingDir, 'sparse-checkout', 'set', '.claude-plugin', 'packages/codex-plugin'], { cwd: workDir }); + runGit(['-C', stagingDir, 'sparse-checkout', 'set', '.claude-plugin', 'packages/codex-plugin', 'packages/claude-code-plugin'], { cwd: workDir }); runGit(['-C', stagingDir, 'checkout', ref], { cwd: workDir }); + // .claude-plugin/marketplace.json — Claude Code plugin marketplace entry point const rootMarketplacePath = path.join(stagingDir, '.claude-plugin', 'marketplace.json'); + // packages/codex-plugin/.agents/plugins/marketplace.json — Codex Route B entry point const packageMarketplacePath = path.join(stagingDir, 'packages', 'codex-plugin', '.agents', 'plugins', 'marketplace.json'); const pluginMcpPath = path.join(stagingDir, 'packages', 'codex-plugin', 'plugins', 'switchbot', '.mcp.json'); + // packages/claude-code-plugin/plugins/switchbot — Claude Code plugin source + const claudeCodePluginJsonPath = path.join(stagingDir, 'packages', 'claude-code-plugin', 'plugins', 'switchbot', '.claude-plugin', 'plugin.json'); - for (const requiredPath of [rootMarketplacePath, packageMarketplacePath, pluginMcpPath]) { + for (const requiredPath of [rootMarketplacePath, packageMarketplacePath, pluginMcpPath, claudeCodePluginJsonPath]) { if (!existsSync(requiredPath)) { throw new Error(`sparse checkout missing ${path.relative(stagingDir, requiredPath)}`); } } + // Root marketplace must point to the Claude Code plugin directory const rootMarketplace = readJson(rootMarketplacePath); if (rootMarketplace?.name !== 'switchbot') { throw new Error(`root marketplace name must be switchbot, got ${rootMarketplace?.name ?? ''}`); } const rootPlugin = rootMarketplace?.plugins?.find((plugin) => plugin?.name === 'switchbot'); - if (rootPlugin?.source !== './packages/codex-plugin/plugins/switchbot') { + if (rootPlugin?.source !== './packages/claude-code-plugin/plugins/switchbot') { throw new Error( - `root marketplace switchbot source must be ./packages/codex-plugin/plugins/switchbot, got ${rootPlugin?.source ?? ''}`, + `root marketplace switchbot source must be ./packages/claude-code-plugin/plugins/switchbot, got ${rootPlugin?.source ?? ''}`, ); } @@ -74,7 +79,7 @@ try { throw new Error(`package marketplace switchbot source must be ./plugins/switchbot, got ${packagePlugin?.source ?? ''}`); } - console.log(`codex git sparse smoke ok: ref ${ref} exposes root and package marketplace manifests with switchbot sources`); + console.log(`codex git sparse smoke ok: ref ${ref} exposes Claude Code and Codex marketplace manifests with correct sources`); } finally { try { rmSync(workDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); From 409b07ab216aae92bf84400f4918f30bb84c0170 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Fri, 29 May 2026 12:18:30 +0800 Subject: [PATCH 04/11] polish(claude-code-plugin): mcp.json wrapper, displayName, README marketplace path - .mcp.json: wrap in mcpServers for consistency with official docs and codex-plugin - plugin.json: add displayName 'SwitchBot' (Claude Code v2.1.143+ UI label) - README: expose /plugin marketplace add install path, clarify which marketplace.json serves which system Co-Authored-By: Claude Sonnet 4.6 --- README.md | 9 ++++++++- .../plugins/switchbot/.claude-plugin/plugin.json | 1 + packages/claude-code-plugin/plugins/switchbot/.mcp.json | 8 +++++--- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4f36749c..c57268d4 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,14 @@ switchbot claude-code setup The optional skill package [`@switchbot/claude-code-plugin`](https://www.npmjs.com/package/@switchbot/claude-code-plugin) bundles the SKILL.md context document. Install it only if your environment does not already load the skill automatically. -**Note:** The root `marketplace.json` file in this repo is for Codex CLI Route B (git sparse clone) and points to the Codex plugin at `packages/codex-plugin/plugins/switchbot`. Claude Code users register via `switchbot claude-code setup` and do not use this file. +**Or install via Claude Code Plugin Marketplace:** + +``` +/plugin marketplace add OpenWonderLabs/switchbot-openapi-cli +/plugin install switchbot@switchbot +``` + +**Note:** The root `marketplace.json` in this repo is for Codex CLI Route B (git sparse clone) and points to `packages/codex-plugin/plugins/switchbot`. The `.claude-plugin/marketplace.json` is for Claude Code Plugin Marketplace and points to `packages/claude-code-plugin/plugins/switchbot`. --- diff --git a/packages/claude-code-plugin/plugins/switchbot/.claude-plugin/plugin.json b/packages/claude-code-plugin/plugins/switchbot/.claude-plugin/plugin.json index 423e1a22..4dadaa23 100644 --- a/packages/claude-code-plugin/plugins/switchbot/.claude-plugin/plugin.json +++ b/packages/claude-code-plugin/plugins/switchbot/.claude-plugin/plugin.json @@ -1,5 +1,6 @@ { "name": "switchbot", + "displayName": "SwitchBot", "version": "0.1.0", "description": "Control SwitchBot smart-home devices from Claude Code via MCP.", "author": { diff --git a/packages/claude-code-plugin/plugins/switchbot/.mcp.json b/packages/claude-code-plugin/plugins/switchbot/.mcp.json index 1737be66..37025324 100644 --- a/packages/claude-code-plugin/plugins/switchbot/.mcp.json +++ b/packages/claude-code-plugin/plugins/switchbot/.mcp.json @@ -1,6 +1,8 @@ { - "switchbot": { - "command": "switchbot", - "args": ["mcp", "serve", "--tools", "all"] + "mcpServers": { + "switchbot": { + "command": "switchbot", + "args": ["mcp", "serve", "--tools", "all"] + } } } From 085492ab6ce2c1dee9c55bd97c9e6de0e93c871d Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Fri, 29 May 2026 12:24:20 +0800 Subject: [PATCH 05/11] fix(codex-plugin): hooks use switchbot-codex-auth, sync marketplace metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. hooks.json: replace fragile relative path ../../../bin/auth.js with the globally-installed switchbot-codex-auth binary — survives any plugin installation layout where bin/ is not adjacent to the plugin dir 2. marketplace.json (all three Codex copies): add interface.displayName, policy.installation/authentication, category so Codex UI shows proper metadata regardless of which installation path the user took Also add missing $schema to packages/codex-plugin/marketplace.json Co-Authored-By: Claude Sonnet 4.6 --- marketplace.json | 10 +++++++++- .../codex-plugin/.agents/plugins/marketplace.json | 10 +++++++++- packages/codex-plugin/marketplace.json | 11 ++++++++++- .../plugins/switchbot/.codex-plugin/hooks.json | 4 ++-- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/marketplace.json b/marketplace.json index c31215e8..d7e91b21 100644 --- a/marketplace.json +++ b/marketplace.json @@ -1,9 +1,17 @@ { "name": "switchbot", + "interface": { + "displayName": "SwitchBot" + }, "plugins": [ { "name": "switchbot", - "source": "./packages/codex-plugin/plugins/switchbot" + "source": "./packages/codex-plugin/plugins/switchbot", + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" } ] } diff --git a/packages/codex-plugin/.agents/plugins/marketplace.json b/packages/codex-plugin/.agents/plugins/marketplace.json index 72c38b30..cc053e7e 100644 --- a/packages/codex-plugin/.agents/plugins/marketplace.json +++ b/packages/codex-plugin/.agents/plugins/marketplace.json @@ -1,10 +1,18 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "switchbot", + "interface": { + "displayName": "SwitchBot" + }, "plugins": [ { "name": "switchbot", - "source": "./plugins/switchbot" + "source": "./plugins/switchbot", + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" } ] } diff --git a/packages/codex-plugin/marketplace.json b/packages/codex-plugin/marketplace.json index 841c2dbb..cc053e7e 100644 --- a/packages/codex-plugin/marketplace.json +++ b/packages/codex-plugin/marketplace.json @@ -1,9 +1,18 @@ { + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "switchbot", + "interface": { + "displayName": "SwitchBot" + }, "plugins": [ { "name": "switchbot", - "source": "./plugins/switchbot" + "source": "./plugins/switchbot", + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" } ] } diff --git a/packages/codex-plugin/plugins/switchbot/.codex-plugin/hooks.json b/packages/codex-plugin/plugins/switchbot/.codex-plugin/hooks.json index 68276b35..7d527852 100644 --- a/packages/codex-plugin/plugins/switchbot/.codex-plugin/hooks.json +++ b/packages/codex-plugin/plugins/switchbot/.codex-plugin/hooks.json @@ -1,6 +1,6 @@ { "onInstall": { - "command": "node", - "args": ["../../../bin/auth.js", "--hook"] + "command": "switchbot-codex-auth", + "args": ["--hook"] } } From 2be9ce6e72eb948d75a968bf21dc4cb3ef6a9094 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Fri, 29 May 2026 13:58:54 +0800 Subject: [PATCH 06/11] fix: category casing, codex setup help text, codex hooks test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - claude-code-plugin marketplace.json: 'productivity' -> 'Productivity' (consistent with all other marketplace files) - codex setup: document --dry-run and --json as global flags in --help so users don't have to discover them from the README - codex-plugin/tests/hooks.test.js: assert onInstall.command is the global binary 'switchbot-codex-auth' and that it is declared in package.json#bin — prevents silent rename breaking the hook Co-Authored-By: Claude Sonnet 4.6 --- .../.claude-plugin/marketplace.json | 2 +- packages/codex-plugin/tests/hooks.test.js | 32 +++++++++++++++++++ src/commands/codex.ts | 4 +++ 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 packages/codex-plugin/tests/hooks.test.js diff --git a/packages/claude-code-plugin/.claude-plugin/marketplace.json b/packages/claude-code-plugin/.claude-plugin/marketplace.json index 61c3709d..ff6d3578 100644 --- a/packages/claude-code-plugin/.claude-plugin/marketplace.json +++ b/packages/claude-code-plugin/.claude-plugin/marketplace.json @@ -15,7 +15,7 @@ "name": "OpenWonderLabs" }, "source": "./plugins/switchbot", - "category": "productivity", + "category": "Productivity", "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli" } ] diff --git a/packages/codex-plugin/tests/hooks.test.js b/packages/codex-plugin/tests/hooks.test.js new file mode 100644 index 00000000..cf4caec7 --- /dev/null +++ b/packages/codex-plugin/tests/hooks.test.js @@ -0,0 +1,32 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const pluginRoot = resolve(__dirname, '../plugins/switchbot'); +const pkgJson = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf8')); + +describe('hooks.json', () => { + const hooksPath = resolve(pluginRoot, '.codex-plugin/hooks.json'); + + it('exists on disk', () => { + assert.ok(existsSync(hooksPath), `hooks.json missing at ${hooksPath}`); + }); + + it('onInstall.command is switchbot-codex-auth (not a relative path)', () => { + const hooks = JSON.parse(readFileSync(hooksPath, 'utf8')); + const cmd = hooks?.onInstall?.command; + assert.equal(cmd, 'switchbot-codex-auth', + `onInstall.command must be the global binary "switchbot-codex-auth", got "${cmd}"`); + }); + + it('switchbot-codex-auth is declared in package.json#bin', () => { + const bin = pkgJson?.bin ?? {}; + assert.ok( + Object.prototype.hasOwnProperty.call(bin, 'switchbot-codex-auth'), + 'switchbot-codex-auth must be declared in package.json#bin so the hook command is on PATH after npm install -g' + ); + }); +}); diff --git a/src/commands/codex.ts b/src/commands/codex.ts index d1d99522..ec9b6f97 100644 --- a/src/commands/codex.ts +++ b/src/commands/codex.ts @@ -647,6 +647,10 @@ function registerCodexSetupSubcommand(codex: Command): void { .option('--yes', 'Non-interactive mode: do not spawn auth login, fail fast if credentials missing') .option('--upgrade', 'Upgrade @switchbot/openapi-cli to the latest published version if already installed') .addHelpText('after', ` +Global flags that also apply to this command: + --dry-run Print step list without executing any changes + --json Emit machine-readable JSON output + Environment variables: CODEX_GIT_MARKETPLACE_REF Git ref used when registering via git marketplace (default: main) CODEX_MARKETPLACE_ADD_TIMEOUT Timeout in ms for "codex plugin marketplace add" (default: 60000) From 24e99c0379d397a53aae05a87b8a24968235843e Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Fri, 29 May 2026 14:04:06 +0800 Subject: [PATCH 07/11] fix(claude-code): document --dry-run/--json in setup --help Mirror the 'Global flags that also apply to this command' section already added to codex setup in 2be9ce6. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/claude-code.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/commands/claude-code.ts b/src/commands/claude-code.ts index 43e1256f..b236c02f 100644 --- a/src/commands/claude-code.ts +++ b/src/commands/claude-code.ts @@ -235,6 +235,11 @@ export function registerClaudeCodeCommand(program: Command): void { .description('Bootstrap the Claude Code integration: install CLI if missing, register MCP server, auth, verify') .option('--skip ', 'Comma-separated step names to skip (skippable: "install-switchbot-cli", "auth")') .option('--yes', 'Non-interactive mode: do not spawn auth login, fail fast if credentials missing') + .addHelpText('after', ` +Global flags that also apply to this command: + --dry-run Print step list without executing any changes + --json Emit machine-readable JSON output +`) .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(skip); From 373f718615ee0745996c29948a98e0de9f37c5b8 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Fri, 29 May 2026 14:11:11 +0800 Subject: [PATCH 08/11] =?UTF-8?q?release:=20v3.7.7=20=E2=80=94=20claude-co?= =?UTF-8?q?de=20setup,=20marketplace=20fixes,=20auth=20cache=20clear?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps: @switchbot/openapi-cli 3.7.6 -> 3.7.7 @switchbot/codex-plugin 0.1.4 -> 0.1.5 @switchbot/claude-code-plugin 0.1.1 -> 0.1.2 CONTRIBUTING.md: document the intentional dual hook strategy (claude-code uses relative path, codex uses global binary — each justified by its own installation layout constraints). Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 12 ++++++++++++ CONTRIBUTING.md | 11 +++++++++++ package-lock.json | 8 ++++---- package.json | 2 +- packages/claude-code-plugin/package.json | 2 +- packages/codex-plugin/package.json | 2 +- 6 files changed, 30 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de168064..3e90dcb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [3.7.7] + +### Added + +- **`switchbot claude-code setup` command** — 6-step pipeline that bootstraps Claude Code integration end-to-end: verify `claude` CLI on PATH, optionally install `@switchbot/openapi-cli`, register the MCP server via `claude mcp add --scope user`, authenticate, and run a health check. Supports `--yes` (non-interactive), `--skip`, `--dry-run`, `--json`. Paste `npx @switchbot/openapi-cli claude-code setup` into Claude Code chat to set up without opening a terminal. +- **Claude Code Plugin Marketplace support** — `.claude-plugin/marketplace.json` now correctly points to `packages/claude-code-plugin/plugins/switchbot` so `/plugin marketplace add OpenWonderLabs/switchbot-openapi-cli` followed by `/plugin install switchbot@switchbot` works end-to-end. `smoke-codex-git-sparse.mjs` extended to validate both the Claude Code and Codex marketplace entry points. + +### Fixed + +- **`switchbot auth login` clears device/status cache** — switching accounts no longer returns stale data from the previous account's cache. `clearCache()` and `clearStatusCache()` are called immediately after new credentials are saved. +- **`codex setup --help` / `claude-code setup --help`** — both commands now list `--dry-run` and `--json` under a "Global flags that also apply to this command" section so users do not have to discover them from the README. + ## [3.7.5] ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f91784f1..a91df2f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,3 +28,14 @@ Example body: The CHANGELOG itself follows Keep a Changelog + SemVer, with a **Changed (BREAKING)** section whenever a release introduces a breaking change (even in a patch version). + +## Plugin hook strategies + +The two plugin packages use different `onInstall` hook strategies — this is intentional: + +| Package | Hook command | Reason | +|---------|-------------|--------| +| `packages/claude-code-plugin` | `node ../bin/auth.js` (relative path) | Claude Code installs the full npm package; `bin/` is always adjacent. | +| `packages/codex-plugin` | `switchbot-codex-auth` (global binary) | Codex may install only the `plugins/switchbot/` sub-directory, placing it in `~/.codex/plugins/switchbot/`. A relative path to `bin/auth.js` would escape the plugin directory and fail. Using the globally-installed binary works regardless of install layout. | + +When changing either hook, preserve this distinction. Do not "unify" them without first verifying that both installation layouts still resolve the hook correctly. diff --git a/package-lock.json b/package-lock.json index 9f267142..3aa2f621 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@switchbot/openapi-cli", - "version": "3.7.6", + "version": "3.7.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@switchbot/openapi-cli", - "version": "3.7.6", + "version": "3.7.7", "license": "MIT", "workspaces": [ "packages/*" @@ -6612,7 +6612,7 @@ }, "packages/claude-code-plugin": { "name": "@switchbot/claude-code-plugin", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "bin": { "switchbot-claude-auth": "bin/auth.js" @@ -6631,7 +6631,7 @@ }, "packages/codex-plugin": { "name": "@switchbot/codex-plugin", - "version": "0.1.4", + "version": "0.1.5", "license": "MIT", "bin": { "switchbot-codex-auth": "bin/auth.js", diff --git a/package.json b/package.json index d791aa2f..d8421379 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "3.7.6", + "version": "3.7.7", "description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.", "keywords": [ "switchbot", diff --git a/packages/claude-code-plugin/package.json b/packages/claude-code-plugin/package.json index e76982cc..5b2e6cab 100644 --- a/packages/claude-code-plugin/package.json +++ b/packages/claude-code-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/claude-code-plugin", - "version": "0.1.1", + "version": "0.1.2", "type": "module", "description": "SwitchBot Claude Code plugin — wires Claude Code to the SwitchBot CLI MCP server (24 tools, zero Node.js dependencies)", "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli/tree/main/packages/claude-code-plugin", diff --git a/packages/codex-plugin/package.json b/packages/codex-plugin/package.json index 392fbecf..36dbac61 100644 --- a/packages/codex-plugin/package.json +++ b/packages/codex-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/codex-plugin", - "version": "0.1.4", + "version": "0.1.5", "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", From 26f13f49aa88b98639c1b0fe29b0fab15362f4d2 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Fri, 29 May 2026 14:13:50 +0800 Subject: [PATCH 09/11] docs(readme): fix install completeness and symmetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Codex 'Or install directly': add switchbot codex doctor verify step - Codex: add 'Or install via Codex Plugin Marketplace' path (with prerequisite note), symmetric with the Claude Code marketplace block - OpenClaw: add npm install -g @switchbot/openapi-cli as required first step — switchbot-openclaw setup checks for >=3.7.1 but the skill package itself does not pull the CLI as a hard dep - Claude Code marketplace block: add note that plugin marketplace support must be enabled, symmetric with Codex block Co-Authored-By: Claude Sonnet 4.6 --- README.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c57268d4..6fb0a041 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,15 @@ Then restart Codex and confirm it's working. npm install -g @switchbot/codex-plugin switchbot-codex-install # registers the plugin with Codex switchbot auth login # browser OAuth — saves to OS keychain +switchbot codex doctor # verify +``` + +**Or install via Codex Plugin Marketplace** (requires Codex CLI with marketplace support): + +```bash +codex plugin marketplace add OpenWonderLabs/switchbot-openapi-cli +codex plugin add switchbot@switchbot +switchbot auth login ``` --- @@ -107,9 +116,10 @@ switchbot auth login # browser OAuth — saves to OS keychain The OpenClaw skill is published to npm as [`@switchbot/openclaw-skill`](https://www.npmjs.com/package/@switchbot/openclaw-skill). ```bash -openclaw plugins install @switchbot/openclaw-skill # via OpenClaw plugin manager (recommended) +npm install -g @switchbot/openapi-cli # required CLI +openclaw plugins install @switchbot/openclaw-skill # via OpenClaw plugin manager (recommended) # or -npm install -g @switchbot/openclaw-skill # via npm +npm install -g @switchbot/openclaw-skill # via npm switchbot-openclaw setup # verify CLI install and credentials switchbot auth login # browser OAuth — saves to OS keychain @@ -147,7 +157,7 @@ switchbot claude-code setup The optional skill package [`@switchbot/claude-code-plugin`](https://www.npmjs.com/package/@switchbot/claude-code-plugin) bundles the SKILL.md context document. Install it only if your environment does not already load the skill automatically. -**Or install via Claude Code Plugin Marketplace:** +**Or install via Claude Code Plugin Marketplace** (requires Claude Code with plugin marketplace support enabled): ``` /plugin marketplace add OpenWonderLabs/switchbot-openapi-cli From 1b31b7738322c9ca83c70f917267ef702c1c777b Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Fri, 29 May 2026 14:18:21 +0800 Subject: [PATCH 10/11] docs(readme): replace deprecated switchbot-codex-install with codex setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit switchbot-codex-install is marked deprecated in bin/install.js (prints a WARNING on every run) and flagged as 'Legacy helper binary' in the plugin README. The root README still recommended it in the direct-install block — replaced with 'switchbot codex setup' which is the intended successor and already the recommended path. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6fb0a041..f41a19af 100644 --- a/README.md +++ b/README.md @@ -95,10 +95,8 @@ Then restart Codex and confirm it's working. **Or install directly:** ```bash -npm install -g @switchbot/codex-plugin -switchbot-codex-install # registers the plugin with Codex -switchbot auth login # browser OAuth — saves to OS keychain -switchbot codex doctor # verify +npm install -g @switchbot/openapi-cli @switchbot/codex-plugin +switchbot codex setup # one-shot bootstrap: register, auth, verify ``` **Or install via Codex Plugin Marketplace** (requires Codex CLI with marketplace support): From f14f5d24349237cc294423633a00aa9ce81191c0 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Fri, 29 May 2026 14:25:46 +0800 Subject: [PATCH 11/11] fix(capabilities): register claude-code setup in COMMAND_META Exhaustive coverage guard in capabilities-meta.test.ts requires every CLI leaf command to have an entry. claude-code setup was missing. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/capabilities.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts index 24558d7d..8fdd597c 100644 --- a/src/commands/capabilities.ts +++ b/src/commands/capabilities.ts @@ -218,6 +218,7 @@ export const COMMAND_META: Record = { 'codex doctor': READ_LOCAL, 'codex repair': ACTION_LOCAL, 'codex setup': ACTION_LOCAL, + 'claude-code setup': ACTION_LOCAL, 'uninstall': ACTION_LOCAL, 'upgrade-check': READ_REMOTE, 'webhook setup': ACTION_REMOTE,