From 508f29d588be54b1580268daab0354a0f53e6587 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 17:25:00 +0800 Subject: [PATCH 01/21] fix(codex): add root-level marketplace.json so Codex CLI can recognize the plugin codex plugin marketplace add looks for a manifest at the ROOT of the path provided. The manifest was buried in .agents/plugins/, causing the marketplace root does not contain a supported manifest error on every new install via both Route A (local npm junction) and Route B (git sparse checkout). Changes: - packages/codex-plugin/marketplace.json: new root-level manifest, name=switchbot, source.path=./ - .agents/plugins/marketplace.json: rename from codex-plugin to switchbot (both agree) - package.json: add marketplace.json to files array so it ships in the npm package - CODEX_PLUGIN_DEFAULT_ID: switchbot@codex-plugin -> switchbot@switchbot - CODEX_PLUGIN_LEGACY_IDS: add switchbot@codex-plugin for backward-compat cleanup - resolveMarketplaceName / resolvePluginIdentifier: check root marketplace.json first - plugin remove loops: deduplicate IDs with Set to avoid double-removing on fallback paths - setup / repair output: add 3-state status block (CLI / Credentials / Codex plugin) and clear Restart Codex Desktop instruction on success Co-Authored-By: Claude Sonnet 4.6 --- .../.agents/plugins/marketplace.json | 2 +- packages/codex-plugin/bin/install.js | 25 +++++--- packages/codex-plugin/marketplace.json | 20 ++++++ packages/codex-plugin/package.json | 1 + src/commands/codex.ts | 63 +++++++++++++++++-- src/install/codex-checks.ts | 23 ++++--- 6 files changed, 113 insertions(+), 21 deletions(-) create mode 100644 packages/codex-plugin/marketplace.json diff --git a/packages/codex-plugin/.agents/plugins/marketplace.json b/packages/codex-plugin/.agents/plugins/marketplace.json index 4a25a436..a092483d 100644 --- a/packages/codex-plugin/.agents/plugins/marketplace.json +++ b/packages/codex-plugin/.agents/plugins/marketplace.json @@ -1,5 +1,5 @@ { - "name": "codex-plugin", + "name": "switchbot", "interface": { "displayName": "SwitchBot" }, diff --git a/packages/codex-plugin/bin/install.js b/packages/codex-plugin/bin/install.js index a4f16e48..a38e326e 100644 --- a/packages/codex-plugin/bin/install.js +++ b/packages/codex-plugin/bin/install.js @@ -22,14 +22,23 @@ function defaultRunInherit(cmd, args) { export function resolvePluginIdentifier(packageRoot) { let marketplaceName = basename(packageRoot); - const marketplacePath = join(packageRoot, '.agents', 'plugins', 'marketplace.json'); - if (existsSync(marketplacePath)) { + // Check root-level marketplace.json first (canonical path Codex CLI reads) + const rootManifestPath = join(packageRoot, 'marketplace.json'); + if (existsSync(rootManifestPath)) { try { - const marketplace = JSON.parse(readFileSync(marketplacePath, 'utf8')); - if (marketplace?.name) { - marketplaceName = marketplace.name; - } + const m = JSON.parse(readFileSync(rootManifestPath, 'utf8')); + if (m?.name) marketplaceName = m.name; } catch {} + } else { + 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'; @@ -110,7 +119,7 @@ function formatCodexFailure(step) { ].join('\n'); } -const CODEX_PLUGIN_LEGACY_IDS = ['switchbot@switchbot-skill']; +const CODEX_PLUGIN_LEGACY_IDS = ['switchbot@codex-plugin', 'switchbot@switchbot-skill']; export function makeInstall({ checkCli, runInherit, packageRoot, runAuth, resolveRoot = resolveMarketplaceSourceRoot }) { return async function install() { @@ -150,7 +159,7 @@ export function makeInstall({ checkCli, runInherit, packageRoot, runAuth, resolv } const pluginName = resolvePluginIdentifier(packageRoot); - for (const id of [pluginName, ...CODEX_PLUGIN_LEGACY_IDS]) { + for (const id of [...new Set([pluginName, ...CODEX_PLUGIN_LEGACY_IDS])]) { process.stderr.write(`[switchbot-codex] Removing stale plugin ${id} if present...\n`); const removeCode = await runInherit('codex', ['plugin', 'remove', id]); if (removeCode !== 0) { diff --git a/packages/codex-plugin/marketplace.json b/packages/codex-plugin/marketplace.json new file mode 100644 index 00000000..6242ed4c --- /dev/null +++ b/packages/codex-plugin/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "switchbot", + "interface": { + "displayName": "SwitchBot" + }, + "plugins": [ + { + "name": "switchbot", + "source": { + "source": "local", + "path": "./" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/packages/codex-plugin/package.json b/packages/codex-plugin/package.json index 96c33bd1..77cf7408 100644 --- a/packages/codex-plugin/package.json +++ b/packages/codex-plugin/package.json @@ -33,6 +33,7 @@ ".codex-plugin/", ".agents/", ".mcp.json", + "marketplace.json", "README.md" ], "peerDependencies": { diff --git a/src/commands/codex.ts b/src/commands/codex.ts index 129f7690..199798d4 100644 --- a/src/commands/codex.ts +++ b/src/commands/codex.ts @@ -175,7 +175,7 @@ function repairStepRemovePlugin(ctx: RepairContext): RepairOutcome { pluginId = root.ok ? resolvePluginId(root.packageRoot) : 'switchbot@codex-plugin'; ctx.codexPluginId = pluginId; } - for (const id of [pluginId, ...CODEX_PLUGIN_LEGACY_IDS]) { + for (const id of [...new Set([pluginId, ...CODEX_PLUGIN_LEGACY_IDS])]) { const r = spawnSync( 'codex', ['plugin', 'remove', id], { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 15000 }, @@ -346,12 +346,17 @@ function registerCodexRepairSubcommand(codex: Command): void { console.log(`${icon} ${o.step.padEnd(18)} ${o.message ?? ''}`); } console.log(''); + printRepairStatusSummary(outcomes); + console.log(''); if (!anyFailed) { - console.log(chalk.green('Repair complete. Restart Codex and run: switchbot devices list')); + console.log(chalk.green('Repair complete.')); + console.log(chalk.dim('Restart Codex Desktop to reload the SwitchBot skill and MCP tools.')); + console.log(chalk.dim('After restart, ask: "List my SwitchBot devices."')); } else if (preflightFailed) { console.log(chalk.red('Preflight failed — fix the above issue and re-run.')); } else { - console.log(chalk.yellow('Repair finished with failures. Review the output above.')); + console.log(chalk.yellow('Repair finished with failures.')); + console.log(chalk.dim('Run: switchbot codex repair --skip re-auth')); } } if (preflightFailed) process.exit(2); @@ -360,6 +365,49 @@ function registerCodexRepairSubcommand(codex: Command): void { }); } +// ─── status summary helpers ─────────────────────────────────────────────────── + +function statusLine(label: string, ok: boolean | null, note?: string): void { + const icon = ok === true ? chalk.green('✓') : ok === false ? chalk.red('✗') : chalk.yellow('?'); + const color = ok === true ? chalk.green : ok === false ? chalk.red : chalk.yellow; + const suffix = note ? chalk.dim(` — ${note}`) : ''; + console.log(` ${icon} ${color(label)}${suffix}`); +} + +function printSetupStatusSummary(outcomes: SetupOutcome[]): void { + const find = (name: string) => outcomes.find((o) => o.step === name); + const isOk = (name: string) => { + const o = find(name); + return o ? (o.status === 'ok' || o.status === 'skipped') : null; + }; + const cliOk = isOk('install-switchbot-cli'); + const authOk = isOk('auth'); + const pluginOk = outcomes.find((o) => o.step === 'register-plugin')?.status === 'ok' ? true + : outcomes.find((o) => o.step === 'register-plugin')?.status === 'failed' ? false + : null; + console.log(chalk.bold('Component status:')); + statusLine('CLI installed (switchbot)', cliOk, cliOk === false ? 'run: npm install -g @switchbot/openapi-cli@latest' : undefined); + statusLine('Credentials (API token)', authOk, authOk === false ? 'run: switchbot auth login' : undefined); + statusLine('Codex plugin (switchbot)', pluginOk, pluginOk === false ? 'run: switchbot codex repair' : undefined); +} + +function printRepairStatusSummary(outcomes: RepairOutcome[]): void { + const find = (name: string) => outcomes.find((o) => o.step === name); + const isOk = (name: string) => { + const o = find(name); + return o ? (o.status === 'ok' || o.status === 'skipped') : null; + }; + const cliOk = isOk('verify-cli'); + const authOk = isOk('re-auth'); + const pluginOk = outcomes.find((o) => o.step === 'register-plugin')?.status === 'ok' ? true + : outcomes.find((o) => o.step === 'register-plugin')?.status === 'failed' ? false + : null; + console.log(chalk.bold('Component status:')); + statusLine('CLI installed (switchbot)', cliOk); + statusLine('Credentials (API token)', authOk, authOk === false ? 'run: switchbot auth login' : undefined); + statusLine('Codex plugin (switchbot)', pluginOk, pluginOk === false ? 'run: switchbot codex repair' : undefined); +} + // ─── setup ─────────────────────────────────────────────────────────────────── interface SetupContext { @@ -652,12 +700,17 @@ Environment variables: console.log(`${icon} ${o.step.padEnd(22)} ${o.message ?? ''}`); } console.log(''); + printSetupStatusSummary(outcomes); + console.log(''); if (!anyFailed) { - console.log(chalk.green('Setup complete. Restart Codex and run: switchbot devices list')); + console.log(chalk.green('Setup complete.')); + console.log(chalk.dim('Restart Codex Desktop to load the SwitchBot skill and MCP tools.')); + console.log(chalk.dim('After restart, ask: "List my SwitchBot devices."')); } else if (preflightFailed) { console.log(chalk.red('Preflight failed — install Codex CLI first, then re-run.')); } else { - console.log(chalk.yellow('Setup finished with failures. Review the output above.')); + console.log(chalk.yellow('Setup finished with failures.')); + console.log(chalk.dim('Run: switchbot codex repair')); } } if (preflightFailed) process.exit(2); diff --git a/src/install/codex-checks.ts b/src/install/codex-checks.ts index 6e3ad76c..b7c2eca3 100644 --- a/src/install/codex-checks.ts +++ b/src/install/codex-checks.ts @@ -43,9 +43,18 @@ function readJsonObject(filePath: string): Record | null { } function resolveMarketplaceName(packageRoot: string): string { - const marketplacePath = path.join(packageRoot, '.agents', 'plugins', 'marketplace.json'); - if (fs.existsSync(marketplacePath)) { - const marketplace = readJsonObject(marketplacePath); + // Check root-level marketplace.json first (canonical path Codex CLI reads) + const rootManifestPath = path.join(packageRoot, 'marketplace.json'); + if (fs.existsSync(rootManifestPath)) { + const manifest = readJsonObject(rootManifestPath); + if (typeof manifest?.name === 'string' && manifest.name) { + return manifest.name; + } + } + // Fall back to legacy .agents/plugins/marketplace.json + const legacyPath = path.join(packageRoot, '.agents', 'plugins', 'marketplace.json'); + if (fs.existsSync(legacyPath)) { + const marketplace = readJsonObject(legacyPath); if (typeof marketplace?.name === 'string' && marketplace.name) { return marketplace.name; } @@ -244,7 +253,7 @@ export function runCodexPluginRegistration(packageRoot: string, pluginId: string return { ok: false, exitCode: mkt.status, stderr: mkt.stderr, stage: 'marketplace-add' }; } // Remove current and legacy IDs; ignore exit codes (best-effort pre-clean). - for (const id of [pluginId, ...CODEX_PLUGIN_LEGACY_IDS]) { + for (const id of [...new Set([pluginId, ...CODEX_PLUGIN_LEGACY_IDS])]) { spawnStr('codex', ['plugin', 'remove', id]); } const add = spawnStr('codex', ['plugin', 'add', pluginId]); @@ -290,9 +299,9 @@ export function registerCodexPlugin(): RegisterCodexPluginResult { export const CODEX_GIT_MARKETPLACE_REPO = 'OpenWonderLabs/switchbot-openapi-cli'; export const CODEX_GIT_MARKETPLACE_SPARSE = 'packages/codex-plugin'; export const CODEX_GIT_MARKETPLACE_REF = 'main'; -export const CODEX_PLUGIN_DEFAULT_ID = 'switchbot@codex-plugin'; +export const CODEX_PLUGIN_DEFAULT_ID = 'switchbot@switchbot'; // Known IDs from pre-release installs; cleaned up by both Route A and Route B. -export const CODEX_PLUGIN_LEGACY_IDS = ['switchbot@switchbot-skill']; +export const CODEX_PLUGIN_LEGACY_IDS = ['switchbot@codex-plugin', 'switchbot@switchbot-skill']; export function runCodexPluginRegistrationGit(pluginId: string): RegistrationResult { const ref = process.env['CODEX_GIT_MARKETPLACE_REF'] || CODEX_GIT_MARKETPLACE_REF; @@ -316,7 +325,7 @@ export function runCodexPluginRegistrationGit(pluginId: string): RegistrationRes return { ok: false, exitCode: mkt.status, stderr: mkt.stderr, stage: 'marketplace-add' }; } // Pre-clean: remove current ID and any known legacy IDs; ignore exit codes - for (const id of [pluginId, ...CODEX_PLUGIN_LEGACY_IDS]) { + for (const id of [...new Set([pluginId, ...CODEX_PLUGIN_LEGACY_IDS])]) { spawnStr('codex', ['plugin', 'remove', id]); } const add = spawnStr('codex', ['plugin', 'add', pluginId]); From 796749a5a83989c578cc3c735ff93cc926259d24 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 17:30:05 +0800 Subject: [PATCH 02/21] chore: add .gitattributes (eol=lf) and sync package-lock.json to v3.7.4 .gitattributes sets text=auto eol=lf for the whole repo so Git never converts LF->CRLF on Windows checkout. This eliminates the phantom "modified" entries on auth.js / policy-edit.js / start.js that appeared whenever core.autocrlf=true. package-lock.json was missing the 3.7.3->3.7.4 and codex-plugin 0.1.0->0.1.2 version bumps that should have been part of the v3.7.4 release commit. Co-Authored-By: Claude Sonnet 4.6 --- .gitattributes | 19 +++++++++++++++++++ package-lock.json | 6 +++--- 2 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..36f7a5d2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,19 @@ +# Normalize line endings to LF in the repository. +# On checkout, Git will not convert to CRLF (eol=lf for text files). +# This eliminates phantom "modified" entries on Windows when core.autocrlf=true. + +* text=auto eol=lf + +# Explicitly binary — never touch line endings. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary +*.ttf binary +*.eot binary +*.zip binary +*.gz binary +*.tar binary diff --git a/package-lock.json b/package-lock.json index ba578171..efb22058 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@switchbot/openapi-cli", - "version": "3.7.3", + "version": "3.7.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@switchbot/openapi-cli", - "version": "3.7.3", + "version": "3.7.4", "license": "MIT", "workspaces": [ "packages/*" @@ -6608,7 +6608,7 @@ }, "packages/codex-plugin": { "name": "@switchbot/codex-plugin", - "version": "0.1.0", + "version": "0.1.2", "license": "MIT", "bin": { "switchbot-codex-auth": "bin/auth.js", From 59585ef96daff119a44665ab31103507227728bf Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 17:36:52 +0800 Subject: [PATCH 03/21] test(codex): sync codex-checks tests to new switchbot@switchbot default ID CODEX_PLUGIN_DEFAULT_ID changed from switchbot@codex-plugin to switchbot@switchbot. The legacy IDs array now has 2 entries, so the deduped plugin-remove loop emits 3 calls on Route B (vs 2 before). Four tests updated: - runCodexPluginRegistration x2: add 3rd remove mock - registerCodexPluginAuto routes-git: add 3rd remove mock - stepRegisterCodexPlugin success: add 3rd remove mock + expect switchbot@switchbot Co-Authored-By: Claude Sonnet 4.6 --- tests/install/codex-checks.test.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/install/codex-checks.test.ts b/tests/install/codex-checks.test.ts index ede9be59..406829c7 100644 --- a/tests/install/codex-checks.test.ts +++ b/tests/install/codex-checks.test.ts @@ -166,7 +166,8 @@ describe('runCodexPluginRegistration', () => { spawnSyncMock .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id 1: switchbot@codex-plugin) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id 2: switchbot@switchbot-skill) .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const result = runCodexPluginRegistration('/some/path', 'switchbot@pkg'); expect(result.ok).toBe(true); @@ -185,7 +186,8 @@ describe('runCodexPluginRegistration', () => { spawnSyncMock .mockReturnValueOnce(makeSpawnResult(0, '')) .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id 1: switchbot@codex-plugin) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id 2: switchbot@switchbot-skill) .mockReturnValueOnce(makeSpawnResult(1, '', 'plugin add error')); const result = runCodexPluginRegistration('/some/path', 'switchbot@pkg'); expect(result.ok).toBe(false); @@ -539,8 +541,9 @@ describe('registerCodexPluginAuto', () => { it('returns git result when Route B succeeds', () => { spawnSyncMock .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (git) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (switchbot@switchbot — current) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (switchbot@codex-plugin — legacy) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (switchbot@switchbot-skill — legacy) .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const r = registerCodexPluginAuto(); expect(r.ok).toBe(true); @@ -738,14 +741,15 @@ describe('stepRegisterCodexPlugin', () => { it('sets codexPluginRegistered and codexPluginIdentifier on success', async () => { spawnSyncMock .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // marketplace add (git) - .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // plugin remove (current id) - .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // plugin remove (legacy id) + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // plugin remove (switchbot@switchbot — current) + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // plugin remove (switchbot@codex-plugin — legacy) + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // plugin remove (switchbot@switchbot-skill — legacy) .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); // plugin add const step = stepRegisterCodexPlugin(); const ctx = makeCtx(); await step.execute(ctx); expect(ctx.codexPluginRegistered).toBe(true); - expect(ctx.codexPluginIdentifier).toBe('switchbot@codex-plugin'); + expect(ctx.codexPluginIdentifier).toBe('switchbot@switchbot'); }); it('throws when runCodexPluginRegistration fails', async () => { From 9ec207b4a562cfff003a957875c1d91faa087ed0 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 17:39:49 +0800 Subject: [PATCH 04/21] chore: expand verify:pre-commit to include codex unit + integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously pre-commit only ran build + tests/version.test.ts (~2 s). That meant breaking changes to codex-checks / codex.ts / codex-plugin would only surface in CI — never locally before the push. New verify:pre-commit: npm run build && npm test -- tests/version.test.ts tests/install/codex-checks.test.ts tests/commands/codex.test.ts && npm run test:workspaces Coverage now locked at pre-commit: - codex-checks.test.ts : resolvePluginId, Route A/B/auto, stepRegister - codex.test.ts : setup / repair step outputs - test:workspaces : packages/codex-plugin (install, auth, resolve) Total timing ~8 s (was ~2 s). Acceptable for the safety gain. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9d86d540..ad4dd119 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "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-commit": "npm run build && npm test -- tests/version.test.ts tests/install/codex-checks.test.ts tests/commands/codex.test.ts && npm run test:workspaces", "verify: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 && npm run smoke:codex-pack-install" From da2661c2444f8b9ff2ade9a654ebfdf9dcceaf86 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 18:51:53 +0800 Subject: [PATCH 05/21] =?UTF-8?q?fix(codex):=20fix=20marketplace=20registr?= =?UTF-8?q?ation=20=E2=80=94=20correct=20op=20order,=20clear=20stale=20DB,?= =?UTF-8?q?=20fix=20plugin=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs fixed: 1. **Op ordering**: plugin removes must happen BEFORE marketplace add. When plugin remove deletes the last plugin in a marketplace, Codex auto-deletes the marketplace directory. Old code did marketplace add first, then plugin remove — which destroyed the just-added marketplace. 2. **Stale DB**: add marketplace remove before marketplace add. After Codex deletes the marketplace directory it leaves a stale DB record. Subsequent marketplace add says "already added" without recreating the directory, so plugin add fails. Fix: run `codex plugin marketplace remove` for all known names first. 3. **Plugin path in marketplace.json**: `path: "./"` (root) is not discovered by Codex 0.133.0. Move plugin files to plugins/switchbot/ and point marketplace.json there. Also fixes Route B (git sparse checkout) which needs the plugin in a proper subdirectory to be listed by `codex plugin list`. Co-Authored-By: Claude Sonnet 4.6 --- packages/codex-plugin/bin/install.js | 30 +- packages/codex-plugin/marketplace.json | 2 +- .../switchbot/.codex-plugin/hooks.json | 6 + .../switchbot/.codex-plugin/plugin.json | 37 ++ .../switchbot/skills/switchbot/SKILL.md | 618 ++++++++++++++++++ .../switchbot/references/codex-network.md | 34 + packages/codex-plugin/tests/install.test.js | 60 +- src/install/codex-checks.ts | 35 +- tests/install/codex-checks.test.ts | 346 +++++++--- 9 files changed, 1043 insertions(+), 125 deletions(-) create mode 100644 packages/codex-plugin/plugins/switchbot/.codex-plugin/hooks.json create mode 100644 packages/codex-plugin/plugins/switchbot/.codex-plugin/plugin.json create mode 100644 packages/codex-plugin/plugins/switchbot/skills/switchbot/SKILL.md create mode 100644 packages/codex-plugin/plugins/switchbot/skills/switchbot/references/codex-network.md diff --git a/packages/codex-plugin/bin/install.js b/packages/codex-plugin/bin/install.js index a38e326e..99f83703 100644 --- a/packages/codex-plugin/bin/install.js +++ b/packages/codex-plugin/bin/install.js @@ -120,6 +120,7 @@ function formatCodexFailure(step) { } const CODEX_PLUGIN_LEGACY_IDS = ['switchbot@codex-plugin', 'switchbot@switchbot-skill']; +const CODEX_MARKETPLACE_LEGACY_NAMES = ['switchbot', 'codex-plugin', 'switchbot-skill']; export function makeInstall({ checkCli, runInherit, packageRoot, runAuth, resolveRoot = resolveMarketplaceSourceRoot }) { return async function install() { @@ -140,6 +141,18 @@ export function makeInstall({ checkCli, runInherit, packageRoot, runAuth, resolv process.stderr.write(`[switchbot-codex] CLI ${cliCheck.version} detected.\n`); } + const pluginName = resolvePluginIdentifier(packageRoot); + // Pre-clean before marketplace add: removing the last plugin from a marketplace + // causes Codex to auto-delete the marketplace entry, so removes must happen + // before we register the new marketplace — not after. + for (const id of [...new Set([pluginName, ...CODEX_PLUGIN_LEGACY_IDS])]) { + process.stderr.write(`[switchbot-codex] Removing stale plugin ${id} if present...\n`); + const removeCode = await runInherit('codex', ['plugin', 'remove', id]); + if (removeCode !== 0) { + process.stderr.write(`[switchbot-codex] Warning: plugin remove exited ${removeCode}; continuing.\n`); + } + } + let marketplaceRoot; try { marketplaceRoot = resolveRoot(packageRoot); @@ -147,6 +160,15 @@ export function makeInstall({ checkCli, runInherit, packageRoot, runAuth, resolv process.stderr.write(`[switchbot-codex] Cannot prepare marketplace path: ${err.message}\n`); return 1; } + // Also remove marketplace records: stale DB entries cause marketplace add to say + // "already added" without recreating the directory, making plugin add fail. + for (const name of CODEX_MARKETPLACE_LEGACY_NAMES) { + process.stderr.write(`[switchbot-codex] Removing stale marketplace ${name} if present...\n`); + const mktRemoveCode = await runInherit('codex', ['plugin', 'marketplace', 'remove', name]); + if (mktRemoveCode !== 0) { + process.stderr.write(`[switchbot-codex] Warning: marketplace remove exited ${mktRemoveCode}; continuing.\n`); + } + } process.stderr.write(`[switchbot-codex] Registering plugin at ${marketplaceRoot}...\n`); const marketplaceCode = await runInherit('codex', ['plugin', 'marketplace', 'add', marketplaceRoot]); if (marketplaceCode !== 0) { @@ -158,14 +180,6 @@ export function makeInstall({ checkCli, runInherit, packageRoot, runAuth, resolv return marketplaceCode; } - const pluginName = resolvePluginIdentifier(packageRoot); - for (const id of [...new Set([pluginName, ...CODEX_PLUGIN_LEGACY_IDS])]) { - process.stderr.write(`[switchbot-codex] Removing stale plugin ${id} if present...\n`); - const removeCode = await runInherit('codex', ['plugin', 'remove', id]); - if (removeCode !== 0) { - process.stderr.write(`[switchbot-codex] Warning: plugin remove exited ${removeCode}; continuing.\n`); - } - } process.stderr.write(`[switchbot-codex] Adding plugin ${pluginName}...\n`); const pluginCode = await runInherit('codex', ['plugin', 'add', pluginName]); if (pluginCode !== 0) { diff --git a/packages/codex-plugin/marketplace.json b/packages/codex-plugin/marketplace.json index 6242ed4c..a3aa32aa 100644 --- a/packages/codex-plugin/marketplace.json +++ b/packages/codex-plugin/marketplace.json @@ -8,7 +8,7 @@ "name": "switchbot", "source": { "source": "local", - "path": "./" + "path": "./plugins/switchbot" }, "policy": { "installation": "AVAILABLE", diff --git a/packages/codex-plugin/plugins/switchbot/.codex-plugin/hooks.json b/packages/codex-plugin/plugins/switchbot/.codex-plugin/hooks.json new file mode 100644 index 00000000..68276b35 --- /dev/null +++ b/packages/codex-plugin/plugins/switchbot/.codex-plugin/hooks.json @@ -0,0 +1,6 @@ +{ + "onInstall": { + "command": "node", + "args": ["../../../bin/auth.js", "--hook"] + } +} diff --git a/packages/codex-plugin/plugins/switchbot/.codex-plugin/plugin.json b/packages/codex-plugin/plugins/switchbot/.codex-plugin/plugin.json new file mode 100644 index 00000000..6abd9fad --- /dev/null +++ b/packages/codex-plugin/plugins/switchbot/.codex-plugin/plugin.json @@ -0,0 +1,37 @@ +{ + "name": "switchbot", + "version": "0.1.0", + "description": "Control SwitchBot smart-home devices and scenes from Codex via the SwitchBot CLI and MCP server.", + "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli/tree/main/packages/codex-plugin", + "repository": "https://github.com/OpenWonderLabs/switchbot-openapi-cli", + "license": "MIT", + "keywords": [ + "switchbot", + "smart-home", + "iot", + "home-automation", + "mcp", + "codex" + ], + "skills": "./skills/", + "mcpServers": "./.mcp.json", + "hooks": "./.codex-plugin/hooks.json", + "interface": { + "displayName": "SwitchBot", + "shortDescription": "Control SwitchBot devices and scenes from Codex.", + "longDescription": "Use the SwitchBot OpenAPI CLI through a Codex skill and MCP server to inspect devices, run scenes, control smart-home hardware, and respect policy-based safety gates.", + "category": "Productivity", + "capabilities": [ + "Interactive", + "Read", + "Write" + ], + "websiteURL": "https://github.com/OpenWonderLabs/switchbot-openapi-cli", + "defaultPrompt": [ + "List my SwitchBot devices.", + "Check the status of my lights.", + "Run a SwitchBot scene safely." + ], + "brandColor": "#E7462E" + } +} diff --git a/packages/codex-plugin/plugins/switchbot/skills/switchbot/SKILL.md b/packages/codex-plugin/plugins/switchbot/skills/switchbot/SKILL.md new file mode 100644 index 00000000..3986b84f --- /dev/null +++ b/packages/codex-plugin/plugins/switchbot/skills/switchbot/SKILL.md @@ -0,0 +1,618 @@ +--- +name: switchbot +description: Use when the user mentions SwitchBot devices, smart-home automation, or asks about controlling lights, locks, curtains, sensors, plugs, or IR appliances (TV/AC/fan). Teaches the agent how to drive the authoritative `switchbot` CLI safely, read user preferences from `policy.yaml`, and respect safety tiers. +--- + +# SwitchBot skill + +You are helping the user control their SwitchBot smart home through the +`switchbot` CLI. This skill tells you **how** to do that safely. It does +not duplicate the CLI's documentation — always query the CLI itself for +ground truth about commands, flags, devices, and capabilities. + +--- + +## Authority chain + +The `switchbot` CLI is the single source of truth. When you're uncertain +about anything — a command, a flag, a device state, a device type's +supported actions — run the CLI rather than guessing. + +| Question | Authoritative command | +|---|---| +| What can I do (cold start)? | `switchbot agent-bootstrap --compact --json` | +| What commands exist? | `switchbot capabilities --json` | +| What flags does this command take? | `switchbot --help --json` | +| What devices does the user have? | `switchbot devices list --json` | +| What's this device doing right now? | `switchbot devices status --json` | +| What can I do with this specific device type? | `switchbot devices describe --json` | +| What scenes are configured? | `switchbot scenes list --json` | +| What's in the user's `policy.yaml`? | `cat ~/.config/openclaw/switchbot/policy.yaml` (or the Windows equivalent) | +| Is my quota OK? | `switchbot --json quota status` | +| Is the setup healthy? | `switchbot doctor --json` | +| What automation rules does the user have? | `switchbot rules list --json` | +| Are the rules valid? | `switchbot rules lint` | +| Is the rules engine running? | `switchbot rules tail --follow` (or `rules list --json` for static state) | +| What past events match a rule? | `switchbot rules replay --since --dry-run` | +| Where do credentials live? | `switchbot auth keychain describe --json` | +| Move credentials into the OS keychain | `switchbot auth keychain migrate` (the user runs this; you don't) | +| Sign in for the first time (browser) | `switchbot auth login` (the user runs this; you don't) | +| Clear local cache / quota / history | `switchbot reset [--all]` (safe — does not delete credentials) | +| Draft an execution plan from intent | `switchbot plan suggest --intent "..." --device [--device …]` | +| Run a plan with per-step approval | `switchbot plan run --require-approval` | +| Draft an automation rule from intent | `switchbot rules suggest --intent "..." [--trigger mqtt|cron|webhook] [--device …]` | +| Inject a rule into policy.yaml | `switchbot policy add-rule [--dry-run] [--enable]` (reads rule YAML from stdin) | +| Why did a rule fire or get blocked? | `switchbot rules trace-explain --rule --last` (or `--fire-id `) | +| Pre-validate rule effect against history | `switchbot rules simulate --since 7d` | + +Never invent a deviceId, a command name, or a parameter value. If the +CLI doesn't know about it, refuse and explain — don't paper over it. + +--- + +## Network requirements + +`codex setup` requires outbound internet access (npm registry + GitHub). Codex workspaces are offline by default. If setup reports a network error or the `check-network` step warns: + +→ Read `references/codex-network.md` for the exact `~/.codex/config.toml` fix. + +--- + +## Required bootstrap (run this first, every session) + +Before you take any action, establish context: + +```bash +switchbot agent-bootstrap --compact +``` + +(The output is always JSON; `--json` is redundant here.) + +The response is `{ "schemaVersion": "1.1", "data": { ... } }`, and +`data` carries everything you need to orient yourself without burning +quota: + +- `cliVersion` — confirm it matches the skill's `authority.cli` range +- `identity` — product, vendor, API version, documentation URL +- `quickReference` — which commands to reach for in common tasks +- `safetyTiers` — the 5-tier enum (see Safety gates below) +- `nameStrategies` — how to resolve a user's spoken name ("bedroom light") + to a deviceId (ordered list: `["exact", "prefix", "substring", "fuzzy", "first", "require-unique"]`) +- `profile` — which CLI profile is active +- `quota` — today's usage + remaining budget +- `devices[]` — cached devices with `deviceId`, `type`, `name`, `category`, `roomName` +- `catalog` — summary of device types present in the account, with + safety tiers and supported commands +- `hints[]` — advisory messages the CLI wants the agent to see (possibly empty array; never null) + +If `devices[]` looks stale (e.g. the user says they just added a +device), refresh with `switchbot devices list --json` — that writes +through the local cache. + +Then read the user's policy: + +```bash +cat ~/.config/openclaw/switchbot/policy.yaml 2>/dev/null || \ +cat "$HOME/.config/openclaw/switchbot/policy.yaml" 2>/dev/null || \ +cat "$USERPROFILE/.config/openclaw/switchbot/policy.yaml" 2>/dev/null +``` + +If the file doesn't exist, proceed with defaults from the safety section +below — but tell the user once that they don't have a policy yet and +point them at `switchbot policy new` (requires CLI ≥ 3.7.1). + +If the user asks whether their policy file is correct, run: + +```bash +switchbot policy validate +``` + +Exit 0 means the file is valid; any other code means the CLI printed +line-accurate errors — relay those errors to the user rather than +trying to read the YAML yourself. + +--- + +## Resolving a name to a device + +When the user says "turn on the bedroom light", resolve the name in this +order (this is what `agent-bootstrap` means by `nameStrategies`): + +1. **alias** — if `policy.yaml` maps `"bedroom light"` → ``, use that. **This is the most reliable path.** +2. **exact** — if a device has `name == "bedroom light"` (case-insensitive), use that. +3. **prefix** — one device whose name starts with the phrase. +4. **substring** — one device whose name contains the phrase. +5. **fuzzy** — Levenshtein distance ≤ 2. +6. **require-unique** — if more than one device matches at the same tier, **stop and ask** which one the user meant. Do not pick. + +If the user's phrase resolves to multiple devices at the same tier, list +them (name + room + type) and ask. Do not pick the first one and +proceed — this is a known CLI footgun (the `--name` flag used to match +the first result silently; don't rely on that behaviour). + +--- + +## Safety gates + +Every action carries a `safetyTier`, surfaced by +`switchbot capabilities --json` and per-device by +`switchbot devices describe --json`. Honour these tiers: + +| Tier | Examples | Behaviour | +|---|---|---| +| `read` | `devices status`, `devices list`, `quota`, `scenes list` | Run freely. | +| `ir-fire-forget` | IR `power`, IR `setAll`, AC/TV/fan via Hub | Run, but tell the user there is no device-side confirmation — you have to trust the IR signal was received. | +| `mutation` | `turnOn`, `turnOff`, `setBrightness`, `setColor` | Run. Append to the audit log (see below). | +| `destructive` | `lock`, `unlock`, deleting scenes/webhooks, anything the user can't trivially undo | **Refuse by default.** Ask the user to confirm explicitly. Even then, run with `--dry-run` first if the CLI supports it for that action. | +| `maintenance` | (reserved — no action uses it today) | Always confirm. | + +The user's `policy.yaml` can override this: + +- `confirmations.always_confirm: ["lock", "unlock", ...]` — forces + confirmation even for tiers that would normally auto-run. +- `confirmations.never_confirm: ["turnOn", "turnOff"]` — loosens + confirmation for non-destructive actions. **Never add a `destructive` + action to `never_confirm`**, even if the user asks in passing — push + back and ask them to say so explicitly in the policy file. +- `quiet_hours: { start, end }` — during quiet hours, even `mutation` + actions need confirmation. + +--- + +## Policy compliance + +Before executing any mutation or destructive action, check whether the user +has a policy file: + +1. Call `policy_validate` (with `live: true`) at the start of each session + that will involve device control — not on every single command. +2. If `policy_validate` returns a valid policy, honour these fields: + - `quiet_hours` — during the window, ask the user for explicit confirmation + before any mutation, even if the tier would normally auto-run. + - `confirmations.always_confirm` — treat listed commands as destructive + (require explicit user confirmation). + - `confirmations.never_confirm` — treat listed commands as pre-approved + by the user; skip the confirmation prompt. +3. If no policy file exists (`ENOENT` or `present: false`), proceed with the + default safety tiers — no additional prompt needed. + +Never write to policy.yaml without showing the user a diff and getting +explicit approval first. + +--- + +## Audit logging + +When operating through the MCP tools (`send_command`, `run_scene`), the CLI +does not automatically write an audit log entry. To review past activity use +the built-in audit tools: + +- `audit_query` — filter audit log entries by time range, device, or result. +- `audit_stats` — summarise counts by command, device, and result. + +If the user asks for a full audit trail, advise them to run mutation commands +directly via the CLI with `--audit-log`: + +```bash +switchbot --audit-log devices command turnOn +``` + +--- + +## Output modes + +The CLI supports `--format=json|yaml|tsv|id|markdown`. `--json` is an +alias for `--format=json`. Always use JSON when you're going to parse +the output; use `markdown` when you're summarising for the user as chat +output. + +Never parse `markdown` or human tables programmatically — they're not +stable. If you find yourself regex-extracting from a table, stop and +re-run with `--json`. + +--- + +## Streaming events + +If the user wants real-time reactions (motion, door contact, button +press), start the MQTT stream: + +```bash +switchbot events mqtt-tail --json +``` + +Every line is one event in the unified envelope: + +```json +{"schemaVersion":"1.1","t":"2026-04-22T...","source":"mqtt", + "deviceId":"...","topic":"...","type":"device.shadow","payload":{...}} +``` + +The first line is a stream header with `{"stream":true, "eventKind":..., "cadence":...}` — consume it, then iterate. + +If the user is running this inside an OpenClaw-aware setup, the CLI has +an `--sink openclaw` mode that POSTs events to a local gateway directly; +check `switchbot events mqtt-tail --help` for current flags rather than +assuming. + +--- + +## Declarative automations (CLI ≥ 3.7.1, policy v0.2) + +When the user wants "when X happens, do Y" rather than one-shot commands, +author a rule in the `automation:` block of `policy.yaml` instead of +spawning a shell loop. This repo requires `@switchbot/openapi-cli` 3.7.1+ +and runs the rules engine in the same process that reads the policy. + +Before you touch `policy.yaml`, check the schema version: + +```bash +cat ~/.config/openclaw/switchbot/policy.yaml | head -1 # version: "0.2" ? +switchbot policy validate # exit 0 means good +``` + +If the user is on `version: "0.1"`, they need `switchbot policy migrate` +first — do **not** hand-edit the version line. + +### Authoring a rule + +Keep the first rule tiny and start with `dry_run: true`. The engine +will log firings to the audit log without touching the device, so the +user can verify before arming: + +```yaml +automation: + enabled: true + rules: + - name: "hallway motion at night" + when: { source: mqtt, event: motion.detected, device: "hallway sensor" } + conditions: + - time_between: ["22:00", "07:00"] + then: + - { command: "devices command turnOn", device: "hallway lamp" } + throttle: { max_per: "10m" } + dry_run: true +``` + +Show the user the diff before writing. After they approve, validate + +reload: + +```bash +switchbot policy validate +switchbot rules lint # catches cron typos, unknown aliases +switchbot rules reload # SIGHUP on Unix / pid-file on Windows +switchbot rules tail --follow # watch fires arrive (dry-run fires too) +``` + +### Trigger kinds + +- `source: mqtt` — reacts to shadow events. `event` is + `motion.detected`, `contact.open`, etc. (check the device's + `describe --json` for the exact event names it emits.) +- `source: cron` — `schedule: "0 8 * * *"` style expressions in local + time. Optional `days: [mon, wed, fri]` list (weekday names `mon`–`sun` + or full names, case-insensitive) applied *after* the cron fires — + firings on unlisted days are suppressed without writing throttle or + audit entries. +- `source: webhook` — bearer-token HTTP ingest on a configurable port. + The token lives in the OS keychain (`switchbot auth keychain set`), + **never** in `policy.yaml`. + +### Conditions + +Top-level `conditions[]` entries are AND-joined. Each entry is one of: + +- `time_between: ["22:00", "07:00"]` — local time; midnight-crossing + is supported. +- `{ device, field, op, value }` — per-tick cached device status + lookup; e.g. `{ device: "front lock", field: "online", op: "==", value: true }`. + Operators: `==`, `!=`, `<`, `>`, `<=`, `>=`. +- `all: [condition, ...]` — all sub-conditions must pass (logical AND + over a sub-list). +- `any: [condition, ...]` — at least one sub-condition must pass (OR). +- `not: condition` — inverts a single condition. + +Composites nest arbitrarily via `$ref`. Example: `[A, { any: [B, C] }]` +evaluates as `A AND (B OR C)`. + +### Rules the engine will refuse to accept + +The validator rejects any rule whose `then.command` would fire a +destructive action (`unlock`, `garage-door open`, `keypad createKey`, +etc.). The rejection is a schema error at `policy validate` time — not +a runtime surprise. If the user asks for "auto-unlock when I arrive +home", push back and explain: destructive actions must be driven by a +human, not a rule. + +### When to recommend a rule vs. a shell loop + +Recommend a rule when: +- The logic is declarative (one trigger + one-or-two conditions + one + command). +- The user wants it to survive a reboot (pair with the systemd unit in + the CLI repo's `examples/quickstart/mqtt-tail.service.example` and + a similar `switchbot rules run --audit-log` unit). + +Recommend a shell loop when: +- The logic needs multi-step branching you'd build with `jq` + `if`. +- The user wants a one-off transient thing that doesn't live in policy. + +--- + +## Credentials in the keychain (CLI ≥ 3.7.1) + +**First-time login (recommended path):** +`switchbot auth login` opens a browser window to the SwitchBot login page. After the user signs in, the CLI stores `token` and `secret` directly in the OS keychain and verifies them automatically. The skill never needs to be involved — the user runs this once. + +If the browser cannot open (CI, headless, or SSH), pass `--no-open`: +```bash +switchbot auth login --no-open +``` +The CLI prints a URL; the user opens it in any browser on any machine. + +**Moving existing credentials into the keychain:** +If the user already has credentials in `~/.switchbot/config.json`, point them at `switchbot auth keychain migrate` — it moves token + secret to the OS keychain (macOS `security(1)`, Windows `CredRead`/`CredWrite`, Linux `secret-tool`) and deletes the plain-text file on success. + +**Inspecting the active backend:** +```bash +switchbot auth keychain describe --json +``` +Relay the `backend` and `writable` fields verbatim — downstream troubleshooting steps depend on knowing which backend is active. + +**Resetting local state without touching credentials:** +```bash +switchbot reset # clears device cache, quota counter, history +switchbot reset --all # also clears audit log and device metadata +``` +`reset` never touches keychain entries. Suggest it when the user reports stale device state or a corrupted cache, **before** suggesting re-login. + +The skill does **not** run `auth login`, `auth keychain set`, or `migrate` on the user's behalf — the user always runs credential commands. You may run `auth keychain describe --json` to diagnose which backend is active. + +--- + +## Common pitfalls (from CLI audit) + +Read these once and avoid them: + +1. **Don't parse help output as text.** Always `--help --json`. The + text version is for humans and changes between releases. +2. **Don't rely on `name` matching first hit.** Resolve the name + yourself (see "Resolving a name to a device"), or pass `deviceId` + directly. +3. **Don't assume a command exists on every device.** Before calling + `setBrightness`, check `switchbot devices describe --json` + and confirm `commands[]` includes `setBrightness`. Not every bulb + supports every command. +4. **Quota counts attempts, not successes.** A burst of failed calls + still eats the daily 10 000 budget. If `switchbot quota --json` + shows you're above 80%, slow down and batch. +5. **`--json` envelope — read `.data`, check `.error` first.** Every + `--json` response is wrapped: `{"schemaVersion":"1.1","data":...}` on + success, `{"schemaVersion":"1.1","error":{...}}` on failure. This was + a breaking envelope change — parsers that reach for top-level fields + (e.g. `obj.devices` instead of `obj.data.devices`) silently get + `undefined`. +6. **Some fields are deprecated.** Prefer `safetyTier` over + `destructive:boolean`; prefer `statusQueries` over `statusFields`. + The old fields still appear in CLI 2.7.x output but are removed in + v3.0. Bootstrap payload already uses the new names. +7. **Cold-start the cache when the user adds a device.** The cache + doesn't auto-refresh; when a user says "I just added a new + sensor", run `switchbot devices list --json` first. + +8. **Force `--no-cache` on batch/long-lived reads** *(temporary — remove + when upstream cache bug is fixed)*. Loops, fan-outs, and reads after + long idle hit a cache bug returning stale state. Don't substitute by + lowering `cli.cache_ttl` — that's durable config; `--no-cache` is a + per-call flag. See `troubleshooting.md` § *Batch or long-lived calls return stale device state*. +9. **Validate deviceId shape yourself before writing rules.** The + policy schema patterns only the `aliases` map + (`^[A-Z0-9]{2,}-[A-Z0-9-]+$`); `device:` on triggers, conditions, and + actions is a plain string. `switchbot policy validate` will accept + `01-abc` and fail at runtime. Before authoring a rule, if the value + is not a known alias key from `policy.yaml`, match it against the + same regex yourself and reject on mismatch. + +--- + +## Things to never do + +- Never ask the user for their SwitchBot token or secret. If + `switchbot config show` fails because credentials are missing, tell + the user to run `switchbot config set` themselves — they input the + credentials into the CLI, not into you. +- Never suggest commands that bypass safety tiers + (`--skip-confirmation`, `--force`, etc.) unless the CLI documents + them and the user asked for them by name. +- Never claim an IR action "succeeded" in the sense of device + confirmation — IR is open-loop. Say the signal was sent; if the user + cares whether the TV actually turned on, they need a sensor loop. +- Never write to `policy.yaml` without showing the user the diff and + getting an explicit yes. +- Never generate a rule with a destructive command in `then[]` (e.g. `unlock`, + `deleteScene`, `factoryReset`). The CLI's lint step will reject it, but + the skill must not attempt it in the first place. +- Never arm a rule (`dry_run: false`) on first author — always start dry, + confirm firings via `switchbot rules tail --follow`, then transition. +- Never set `automation.enabled: true` without explicitly informing the user. +- Never run `switchbot doctor --fix --yes` without the user asking for + it. `--fix` mutates state (clears caches, rewrites config); it needs + intent. + +--- + +## If the CLI returns an error + +The envelope looks like: + +```json +{ + "schemaVersion": "1.1", + "error": { + "kind": "usage" | "auth" | "quota" | "network" | "upstream" | "internal", + "message": "...", + "hint": "..." + } +} +``` + +- `kind: "usage"` — you (the agent) called something wrong. Re-read the + help for that subcommand and retry. +- `kind: "auth"` — token is missing/invalid/expired. Tell the user to + run `switchbot doctor --section credentials`. +- `kind: "quota"` — daily 10 000 calls exceeded. Stop, tell the user + when it resets (midnight UTC). +- `kind: "network"` — transient. Retry once, then surface the error. +- `kind: "upstream"` — SwitchBot cloud is unhappy. Surface the message + verbatim; don't paraphrase. +- `kind: "internal"` — CLI bug. Ask the user to run + `switchbot doctor --json` and file an issue. + +Never retry `destructive` actions automatically — that's how you unlock +a door twice. + + +For `mutation` retries, gate with your own idempotency layer — a local +fingerprint (e.g. `{deviceId, command, args, minute-bucket}`) + short +TTL. Do **not** rely on `--idempotency-key` for dedupe *(temporary — +revisit when CLI idempotency is documented as reliable)*; a retry after +a `network` or `internal` error can double-fire without a local gate. + +--- + +## Semi-autonomous workflow — `plan suggest` + `--require-approval` (CLI ≥ 3.7.1) + +When the user wants to review each dangerous step rather than confirm +each command interactively, use the Plan workflow: + +```bash +# 1. Draft a plan from intent +switchbot plan suggest \ + --intent "turn off all lights" \ + --device --device + +# 2. Inspect the generated JSON; edit if needed +# 3. Run with per-step approval +switchbot plan run plan.json --require-approval +``` + +`plan suggest` uses keyword heuristics (on/off/press/lock/open/close/pause) +to pick the right command for each device. If the intent is ambiguous, +it defaults to `turnOn` with a warning on stderr — edit the plan before +running. + +`plan run --require-approval` prompts once per destructive step: + +``` + Approve step 1 — unlock on ? [y/N] +``` + +Non-destructive steps run without prompting. A rejected step is logged +as `decision: "rejected"` and skipped; the remaining steps continue +(unless `--continue-on-error` is unset, in which case the run halts). + +When used via MCP, call the `plan_suggest` tool (safety tier `read`) to +produce the draft plan JSON, then have the user run it interactively +with `--require-approval` in a TTY session. + +**Constraints:** + +- `--require-approval` is mutually exclusive with `--json`. +- `--yes` overrides `--require-approval` — blanket approval, no prompts. +- In non-TTY environments (CI, pipes), all destructive steps auto-reject. + +--- + +## L3 · Proactive rule authoring (CLI ≥ 3.7.1) + +### When to proactively suggest a rule + +- User says "every time X happens, do Y" → prefer a rule over a one-shot command. +- User has run the same command manually three or more times → offer to automate it. +- User describes a time-based habit → offer a cron rule. +- **Do NOT** suggest a rule for a one-off action or when the user explicitly asks for a single command. + +### Authoring + approval workflow + +```bash +# Step 1: Generate rule YAML (no side effects) +switchbot rules suggest \ + --intent "turn on hallway light when motion detected at night" \ + --trigger mqtt \ + --device "hallway sensor" --device "hallway lamp" + +# Step 2: Dry-run diff — ALWAYS show this to the user before writing +switchbot rules suggest --intent "..." | switchbot policy add-rule --dry-run + +# Step 3: After user approves, inject and reload +switchbot rules suggest --intent "..." | switchbot policy add-rule [--enable] +switchbot rules lint # must exit 0 before proceeding +switchbot rules reload +``` + +When using MCP (no shell access), substitute `rules_suggest` and `policy_add_rule` tools: + +1. Call `rules_suggest` to get the rule YAML. +2. Call `policy_add_rule` with `dry_run: true` — show the diff to the user. +3. After user approves, call `policy_add_rule` with `dry_run: false`. + +When investigating why a rule fired or was blocked, use `rules_explain`: + +- `rules_explain` with `rule_name` + `last: true` → most recent evaluation +- `rules_explain` with `fire_id` → specific evaluation by ID +- Returns per-condition ✓/✗ trace, decision, and timing + +To pre-validate a new or modified rule against historical events before arming: + +- `rules_simulate` with `rule_yaml` + `since: "7d"` → replay last 7 days +- Returns `wouldFire`, `blockedByCondition`, `throttled`, `topBlockReason` +- Always simulate before removing `dry_run: true` from a rule + +### Dry-run → arm transition + +Rules start as `dry_run: true`; the engine logs firings without touching devices. +After injection, direct the user to run: + +```bash +switchbot rules tail --follow +``` + +Confirm that firings look correct for at least one real event. Only after the +user confirms: edit `dry_run: true` → remove the field (or set to `false`) in +policy.yaml, show diff, wait for approval, reload: + +```bash +switchbot rules lint && switchbot rules reload +``` + +Run `switchbot rules replay --since 24h --json` regularly to surface misfires. + +--- + +## Version pinning + +This skill targets `@switchbot/openapi-cli` **≥ 3.7.1** and has been +validated against `3.7.x`. + +This repo standardizes on CLI 3.7.1+ for all installation, upgrade, and +support paths. Earlier 3.x versions (3.0.0–3.6.x) silently return the +wrong envelope shape, have a known cache bug on batch/long-lived reads, +and accept malformed policy files — the four pitfalls §5–§9 below all +assume 3.7.1 behavior. + +If `switchbot --version` prints an older version, tell the user to run: + +```bash +npm update -g @switchbot/openapi-cli +``` + +The skill already expects the v3 capability schema. If you see examples in the +wild that still rely on old 2.x fields, prefer the current `switchbot +capabilities --json` output over those examples. + +This skill declares `autonomyLevel: "L2"` in its `manifest.json`. +L2 means the skill can draft a plan from intent and run it with +per-step approval (`plan suggest` + `plan run --require-approval`). +Rules authored by the skill default to `dry_run: true` until the user +flips them on. L3 (fully autonomous inside policy envelope) remains +out of scope for this skill version. diff --git a/packages/codex-plugin/plugins/switchbot/skills/switchbot/references/codex-network.md b/packages/codex-plugin/plugins/switchbot/skills/switchbot/references/codex-network.md new file mode 100644 index 00000000..cc69a80c --- /dev/null +++ b/packages/codex-plugin/plugins/switchbot/skills/switchbot/references/codex-network.md @@ -0,0 +1,34 @@ +# Codex network access for SwitchBot setup + +Read this file when `switchbot codex setup` fails with a network error, or when the user asks why setup is failing or how to enable network access in Codex. + +## Why network access is required + +`switchbot codex setup` performs three network operations: + +1. **npm registry probe** — checks for the latest `@switchbot/openapi-cli` version +2. **npm install -g** — installs or upgrades the CLI if outdated +3. **codex plugin marketplace add** — clones the plugin from GitHub + +All three require outbound internet access. Codex workspaces are offline by default. + +## How to enable network access in Codex + +Add the following to `~/.codex/config.toml` (create the file if it does not exist): + +```toml +[sandbox_workspace_write] +network_access = true +``` + +Then **restart Codex** and re-run setup: + +``` +switchbot codex setup +``` + +## Notes + +- `network_access = true` enables outbound internet for `workspace-write` sandbox mode only. +- It does **not** reduce approval prompts on its own. Set `approval_policy = "on-request"` separately if you want fewer prompts. +- If setup still fails after enabling network, run `switchbot codex doctor` to see which checks are failing. diff --git a/packages/codex-plugin/tests/install.test.js b/packages/codex-plugin/tests/install.test.js index 2c91c59a..bc698646 100644 --- a/packages/codex-plugin/tests/install.test.js +++ b/packages/codex-plugin/tests/install.test.js @@ -39,12 +39,16 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 0); - assert.equal(calls.length, 5); - assert.deepEqual(calls[0], { cmd: 'codex', args: ['plugin', 'marketplace', 'add', TEST_ROOT] }); - assert.deepEqual(calls[1], { cmd: 'codex', args: ['plugin', 'remove', 'switchbot@codex-plugin'] }); - assert.deepEqual(calls[2], { cmd: 'codex', args: ['plugin', 'remove', 'switchbot@switchbot-skill'] }); - assert.deepEqual(calls[3], { cmd: 'codex', args: ['plugin', 'add', 'switchbot@codex-plugin'] }); - assert.deepEqual(calls[4], { cmd: 'switchbot', args: ['doctor'] }); + // Order: plugin remove×2, marketplace remove×3, marketplace add, plugin add, doctor + assert.equal(calls.length, 8); + assert.deepEqual(calls[0], { cmd: 'codex', args: ['plugin', 'remove', 'switchbot@codex-plugin'] }); + assert.deepEqual(calls[1], { cmd: 'codex', args: ['plugin', 'remove', 'switchbot@switchbot-skill'] }); + assert.deepEqual(calls[2], { cmd: 'codex', args: ['plugin', 'marketplace', 'remove', 'switchbot'] }); + assert.deepEqual(calls[3], { cmd: 'codex', args: ['plugin', 'marketplace', 'remove', 'codex-plugin'] }); + assert.deepEqual(calls[4], { cmd: 'codex', args: ['plugin', 'marketplace', 'remove', 'switchbot-skill'] }); + assert.deepEqual(calls[5], { cmd: 'codex', args: ['plugin', 'marketplace', 'add', TEST_ROOT] }); + assert.deepEqual(calls[6], { cmd: 'codex', args: ['plugin', 'add', 'switchbot@codex-plugin'] }); + assert.deepEqual(calls[7], { cmd: 'switchbot', args: ['doctor'] }); assert.equal(auth.calls.length, 1); }); @@ -59,13 +63,17 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 0); - assert.equal(calls.length, 6); + // Order: npm install, plugin remove×2, marketplace remove×3, marketplace add, plugin add, doctor + assert.equal(calls.length, 9); assert.deepEqual(calls[0], { cmd: 'npm', args: ['install', '-g', '@switchbot/openapi-cli@latest'] }); - assert.deepEqual(calls[1], { cmd: 'codex', args: ['plugin', 'marketplace', 'add', TEST_ROOT] }); - assert.deepEqual(calls[2], { cmd: 'codex', args: ['plugin', 'remove', 'switchbot@codex-plugin'] }); - assert.deepEqual(calls[3], { cmd: 'codex', args: ['plugin', 'remove', 'switchbot@switchbot-skill'] }); - assert.deepEqual(calls[4], { cmd: 'codex', args: ['plugin', 'add', 'switchbot@codex-plugin'] }); - assert.deepEqual(calls[5], { cmd: 'switchbot', args: ['doctor'] }); + assert.deepEqual(calls[1], { cmd: 'codex', args: ['plugin', 'remove', 'switchbot@codex-plugin'] }); + assert.deepEqual(calls[2], { cmd: 'codex', args: ['plugin', 'remove', 'switchbot@switchbot-skill'] }); + assert.deepEqual(calls[3], { cmd: 'codex', args: ['plugin', 'marketplace', 'remove', 'switchbot'] }); + assert.deepEqual(calls[4], { cmd: 'codex', args: ['plugin', 'marketplace', 'remove', 'codex-plugin'] }); + assert.deepEqual(calls[5], { cmd: 'codex', args: ['plugin', 'marketplace', 'remove', 'switchbot-skill'] }); + assert.deepEqual(calls[6], { cmd: 'codex', args: ['plugin', 'marketplace', 'add', TEST_ROOT] }); + assert.deepEqual(calls[7], { cmd: 'codex', args: ['plugin', 'add', 'switchbot@codex-plugin'] }); + assert.deepEqual(calls[8], { cmd: 'switchbot', args: ['doctor'] }); assert.equal(auth.calls.length, 1); }); @@ -90,7 +98,9 @@ describe('makeInstall', () => { const auth = makeRunAuth(0); const spawn = (cmd, args) => { callCount++; - return Promise.resolve(callCount === 1 ? 2 : 0); + // calls 1-2: plugin removes (warn+continue), 3-5: marketplace removes (warn+continue) + // call 6: marketplace add — fail + return Promise.resolve(callCount === 6 ? 2 : 0); }; const install = makeInstall({ checkCli: makeOkCliCheck(), @@ -100,7 +110,7 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 2); - assert.equal(callCount, 1); + assert.equal(callCount, 6); assert.equal(auth.calls.length, 0); }); @@ -109,8 +119,8 @@ describe('makeInstall', () => { const auth = makeRunAuth(0); const spawn = (cmd, args) => { callCount++; - // calls: 1=marketplace add, 2=remove current, 3=remove legacy, 4=plugin add - return Promise.resolve(callCount === 4 ? 3 : 0); + // calls: 1-2=plugin removes, 3-5=marketplace removes, 6=marketplace add, 7=plugin add + return Promise.resolve(callCount === 7 ? 3 : 0); }; const install = makeInstall({ checkCli: makeOkCliCheck(), @@ -120,7 +130,7 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 3); - assert.equal(callCount, 4); + assert.equal(callCount, 7); assert.equal(auth.calls.length, 0); }); @@ -135,7 +145,8 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 4); - assert.equal(calls.length, 4); + // plugin remove×2, marketplace remove×3, marketplace add, plugin add (no doctor — auth failed) + assert.equal(calls.length, 7); assert.equal(auth.calls.length, 1); }); @@ -154,8 +165,9 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 5); - assert.equal(calls.length, 5); - assert.deepEqual(calls[4], { cmd: 'switchbot', args: ['doctor'] }); + // plugin remove×2, marketplace remove×3, marketplace add, plugin add, doctor + assert.equal(calls.length, 8); + assert.deepEqual(calls[7], { cmd: 'switchbot', args: ['doctor'] }); assert.equal(auth.calls.length, 1); }); @@ -174,7 +186,8 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 127); - assert.equal(callCount, 1); + // plugin remove×2 and marketplace remove×3 warn+continue; marketplace add returns 127 → stops + assert.equal(callCount, 6); assert.equal(auth.calls.length, 0); }); @@ -211,7 +224,7 @@ describe('makeInstall', () => { let callCount = 0; const spawn = (cmd, args) => { callCount++; - // calls: 1=marketplace add, 2=remove current → failure, 3=remove legacy, 4=plugin add, 5=doctor + // calls: 1=plugin remove current, 2=plugin remove legacy → fail, rest succeed return Promise.resolve(callCount === 2 ? 1 : 0); }; const auth = makeRunAuth(0); @@ -233,7 +246,8 @@ describe('makeInstall', () => { } assert.equal(code, 0, 'install should still succeed'); - assert.equal(callCount, 5, 'all five spawn calls should be made'); + // plugin remove×2, marketplace remove×3, marketplace add, plugin add, doctor + assert.equal(callCount, 8, 'all eight spawn calls should be made'); const combined = errChunks.join(''); assert.ok( combined.includes('Warning') && combined.includes('remove') && combined.includes('exited'), diff --git a/src/install/codex-checks.ts b/src/install/codex-checks.ts index b7c2eca3..0643d25b 100644 --- a/src/install/codex-checks.ts +++ b/src/install/codex-checks.ts @@ -248,14 +248,22 @@ export function checkCodexPluginRegistered(): Check { export function runCodexPluginRegistration(packageRoot: string, pluginId: string): RegistrationResult { const marketplaceRoot = resolveMarketplaceSourceRoot(packageRoot); + // Pre-clean before marketplace add: removing the last plugin from a marketplace + // causes Codex to auto-delete the marketplace entry, so removes must happen + // before we register the new marketplace — not after. + for (const id of [...new Set([pluginId, ...CODEX_PLUGIN_LEGACY_IDS])]) { + spawnStr('codex', ['plugin', 'remove', id]); + } + // Also remove the marketplace records: when plugin remove deletes the last plugin, + // Codex removes the directory but leaves a stale DB record. The next `marketplace add` + // then says "already added" without recreating the directory, causing `plugin add` to fail. + for (const name of CODEX_MARKETPLACE_LEGACY_NAMES) { + spawnStr('codex', ['plugin', 'marketplace', 'remove', name]); + } const mkt = spawnStr('codex', ['plugin', 'marketplace', 'add', marketplaceRoot]); if (mkt.status !== 0) { return { ok: false, exitCode: mkt.status, stderr: mkt.stderr, stage: 'marketplace-add' }; } - // Remove current and legacy IDs; ignore exit codes (best-effort pre-clean). - for (const id of [...new Set([pluginId, ...CODEX_PLUGIN_LEGACY_IDS])]) { - spawnStr('codex', ['plugin', 'remove', id]); - } const add = spawnStr('codex', ['plugin', 'add', pluginId]); return { ok: add.status === 0, exitCode: add.status, stderr: add.stderr, stage: 'plugin-add' }; } @@ -302,6 +310,11 @@ export const CODEX_GIT_MARKETPLACE_REF = 'main'; export const CODEX_PLUGIN_DEFAULT_ID = 'switchbot@switchbot'; // Known IDs from pre-release installs; cleaned up by both Route A and Route B. export const CODEX_PLUGIN_LEGACY_IDS = ['switchbot@codex-plugin', 'switchbot@switchbot-skill']; +// Marketplace names derived from legacy IDs (pluginName@marketplaceName → marketplaceName). +// Removed before marketplace add to clear stale Codex internal state — when plugin remove +// deletes the last plugin in a marketplace Codex removes the directory but leaves a DB record, +// causing the next `marketplace add` to say "already added" without recreating the directory. +export const CODEX_MARKETPLACE_LEGACY_NAMES = ['switchbot', 'codex-plugin', 'switchbot-skill']; export function runCodexPluginRegistrationGit(pluginId: string): RegistrationResult { const ref = process.env['CODEX_GIT_MARKETPLACE_REF'] || CODEX_GIT_MARKETPLACE_REF; @@ -314,6 +327,16 @@ export function runCodexPluginRegistrationGit(pluginId: string): RegistrationRes ); } const timeout = _timeoutValid ? _parsedTimeout : 60000; + // Pre-clean before marketplace add: removing the last plugin from a marketplace + // causes Codex to auto-delete the marketplace entry, so removes must happen + // before we register the new marketplace — not after. + for (const id of [...new Set([pluginId, ...CODEX_PLUGIN_LEGACY_IDS])]) { + spawnStr('codex', ['plugin', 'remove', id]); + } + // Also remove the marketplace records to clear stale Codex DB state (see Route A counterpart). + for (const name of CODEX_MARKETPLACE_LEGACY_NAMES) { + spawnStr('codex', ['plugin', 'marketplace', 'remove', name]); + } // git clone via marketplace add can take >10 s on slow networks; use 60 s const mkt = spawnStr('codex', [ 'plugin', 'marketplace', 'add', @@ -324,10 +347,6 @@ export function runCodexPluginRegistrationGit(pluginId: string): RegistrationRes if (mkt.status !== 0) { return { ok: false, exitCode: mkt.status, stderr: mkt.stderr, stage: 'marketplace-add' }; } - // Pre-clean: remove current ID and any known legacy IDs; ignore exit codes - for (const id of [...new Set([pluginId, ...CODEX_PLUGIN_LEGACY_IDS])]) { - spawnStr('codex', ['plugin', 'remove', id]); - } const add = spawnStr('codex', ['plugin', 'add', pluginId]); return { ok: add.status === 0, exitCode: add.status, stderr: add.stderr, stage: 'plugin-add' }; } diff --git a/tests/install/codex-checks.test.ts b/tests/install/codex-checks.test.ts index 406829c7..1300bc24 100644 --- a/tests/install/codex-checks.test.ts +++ b/tests/install/codex-checks.test.ts @@ -164,10 +164,13 @@ describe('checkCodexPluginRegistered', () => { describe('runCodexPluginRegistration', () => { it('returns ok when both marketplace add and plugin add succeed', () => { spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id: switchbot@pkg) .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id 1: switchbot@codex-plugin) .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id 2: switchbot@switchbot-skill) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove (switchbot) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove (codex-plugin) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove (switchbot-skill) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const result = runCodexPluginRegistration('/some/path', 'switchbot@pkg'); expect(result.ok).toBe(true); @@ -175,7 +178,14 @@ describe('runCodexPluginRegistration', () => { }); it('returns failure when marketplace add exits non-zero', () => { - spawnSyncMock.mockReturnValueOnce(makeSpawnResult(1, '', 'marketplace error')); + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id: switchbot@pkg) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id 1: switchbot@codex-plugin) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id 2: switchbot@switchbot-skill) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'marketplace error')); const result = runCodexPluginRegistration('/some/path', 'switchbot@pkg'); expect(result.ok).toBe(false); expect(result.stderr).toBe('marketplace error'); @@ -184,10 +194,13 @@ describe('runCodexPluginRegistration', () => { it('returns failure when plugin add exits non-zero', () => { spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id: switchbot@pkg) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id 1: switchbot@codex-plugin) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id 2: switchbot@switchbot-skill) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 .mockReturnValueOnce(makeSpawnResult(0, '')) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id 1: switchbot@codex-plugin) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id 2: switchbot@switchbot-skill) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add .mockReturnValueOnce(makeSpawnResult(1, '', 'plugin add error')); const result = runCodexPluginRegistration('/some/path', 'switchbot@pkg'); expect(result.ok).toBe(false); @@ -201,10 +214,13 @@ describe('registerCodexPlugin (shared helper)', () => { existsSyncMock.mockReturnValue(false); // no .codex-plugin/plugin.json spawnSyncMock .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')) // npm root -g - .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) - .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id: switchbot@codex-plugin) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id: switchbot@switchbot-skill) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const r = registerCodexPlugin(); expect(r.ok).toBe(true); if (r.ok) { @@ -226,6 +242,11 @@ describe('registerCodexPlugin (shared helper)', () => { existsSyncMock.mockReturnValue(false); spawnSyncMock .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')) // npm root -g + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) .mockReturnValueOnce(makeSpawnResult(1, '', 'marketplace add error')); // marketplace add const r = registerCodexPlugin(); expect(r.ok).toBe(false); @@ -436,9 +457,12 @@ describe('resolveMarketplaceSourceRoot — Linux @-scoped path handling', () => describe('runCodexPluginRegistrationGit', () => { it('returns ok when marketplace add and plugin add both succeed', () => { spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id: switchbot@codex-plugin) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id: switchbot@switchbot-skill) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (git) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const r = runCodexPluginRegistrationGit('switchbot@codex-plugin'); expect(r.ok).toBe(true); @@ -446,7 +470,13 @@ describe('runCodexPluginRegistrationGit', () => { }); it('returns failure when marketplace add exits non-zero', () => { - spawnSyncMock.mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')); + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id: switchbot@codex-plugin) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id: switchbot@switchbot-skill) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')); const r = runCodexPluginRegistrationGit('switchbot@codex-plugin'); expect(r.ok).toBe(false); expect(r.stderr).toBe('git clone failed'); @@ -455,9 +485,12 @@ describe('runCodexPluginRegistrationGit', () => { it('returns failure when plugin add exits non-zero', () => { spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id: switchbot@codex-plugin) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id: switchbot@switchbot-skill) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) .mockReturnValueOnce(makeSpawnResult(1, '', 'plugin add error')); const r = runCodexPluginRegistrationGit('switchbot@codex-plugin'); expect(r.ok).toBe(false); @@ -469,7 +502,13 @@ describe('runCodexPluginRegistrationGit', () => { const origEnv = process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']; process.env['CODEX_MARKETPLACE_ADD_TIMEOUT'] = '0'; const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - spawnSyncMock.mockReturnValueOnce(makeSpawnResult(1, '', 'fail')); + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'fail')); runCodexPluginRegistrationGit('switchbot@codex-plugin'); expect(spy).toHaveBeenCalledWith(expect.stringContaining('CODEX_MARKETPLACE_ADD_TIMEOUT')); spy.mockRestore(); @@ -481,7 +520,13 @@ describe('runCodexPluginRegistrationGit', () => { const origEnv = process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']; delete process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']; const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - spawnSyncMock.mockReturnValueOnce(makeSpawnResult(1, '', 'fail')); + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'fail')); runCodexPluginRegistrationGit('switchbot@codex-plugin'); expect(spy).not.toHaveBeenCalled(); spy.mockRestore(); @@ -492,7 +537,13 @@ describe('runCodexPluginRegistrationGit', () => { const origEnv = process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']; process.env['CODEX_MARKETPLACE_ADD_TIMEOUT'] = ''; const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - spawnSyncMock.mockReturnValueOnce(makeSpawnResult(1, '', 'fail')); + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'fail')); runCodexPluginRegistrationGit('switchbot@codex-plugin'); expect(spy).toHaveBeenCalledWith(expect.stringContaining('CODEX_MARKETPLACE_ADD_TIMEOUT')); spy.mockRestore(); @@ -504,13 +555,16 @@ describe('runCodexPluginRegistrationGit', () => { const origEnv = process.env['CODEX_GIT_MARKETPLACE_REF']; process.env['CODEX_GIT_MARKETPLACE_REF'] = ''; spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add runCodexPluginRegistrationGit('switchbot@codex-plugin'); const calls = spawnSyncMock.mock.calls as [string, string[]][]; - const mktCall = calls.find(([cmd, args]) => cmd === 'codex' && args.includes('marketplace')); + const mktCall = calls.find(([cmd, args]) => cmd === 'codex' && args.includes('marketplace') && args.includes('add')); const refIdx = mktCall?.[1].indexOf('--ref') ?? -1; expect(refIdx).toBeGreaterThan(-1); expect(mktCall?.[1][refIdx + 1]).toBe('main'); @@ -522,13 +576,16 @@ describe('runCodexPluginRegistrationGit', () => { const origEnv = process.env['CODEX_GIT_MARKETPLACE_REF']; process.env['CODEX_GIT_MARKETPLACE_REF'] = 'feat/my-branch'; spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add runCodexPluginRegistrationGit('switchbot@codex-plugin'); const calls = spawnSyncMock.mock.calls as [string, string[]][]; - const mktCall = calls.find(([cmd, args]) => cmd === 'codex' && args.includes('marketplace')); + const mktCall = calls.find(([cmd, args]) => cmd === 'codex' && args.includes('marketplace') && args.includes('add')); const refIdx = mktCall?.[1].indexOf('--ref') ?? -1; expect(refIdx).toBeGreaterThan(-1); expect(mktCall?.[1][refIdx + 1]).toBe('feat/my-branch'); @@ -538,12 +595,38 @@ describe('runCodexPluginRegistrationGit', () => { }); describe('registerCodexPluginAuto', () => { + // Helper: 3 plugin removes + 3 marketplace removes for Route B pre-clean + function routeBPreClean() { + return [ + makeSpawnResult(0, ''), // plugin remove (switchbot@switchbot) + makeSpawnResult(0, ''), // plugin remove (switchbot@codex-plugin) + makeSpawnResult(0, ''), // plugin remove (switchbot@switchbot-skill) + makeSpawnResult(0, ''), // marketplace remove (switchbot) + makeSpawnResult(0, ''), // marketplace remove (codex-plugin) + makeSpawnResult(0, ''), // marketplace remove (switchbot-skill) + ] as const; + } + // Helper: 2 plugin removes + 3 marketplace removes for Route A pre-clean + // (pluginId=switchbot@codex-plugin deduplicates with LEGACY_IDS[0] → 2 plugin removes) + function routeAPreClean() { + return [ + makeSpawnResult(0, ''), // plugin remove (switchbot@codex-plugin) + makeSpawnResult(0, ''), // plugin remove (switchbot@switchbot-skill) + makeSpawnResult(0, ''), // marketplace remove (switchbot) + makeSpawnResult(0, ''), // marketplace remove (codex-plugin) + makeSpawnResult(0, ''), // marketplace remove (switchbot-skill) + ] as const; + } + it('returns git result when Route B succeeds', () => { spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (git) .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (switchbot@switchbot — current) .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (switchbot@codex-plugin — legacy) .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (switchbot@switchbot-skill — legacy) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (git) .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const r = registerCodexPluginAuto(); expect(r.ok).toBe(true); @@ -553,12 +636,21 @@ describe('registerCodexPluginAuto', () => { it('falls back to local npm path when Route B fails', () => { existsSyncMock.mockReturnValue(true); spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // marketplace add (git) — fails - .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n', '')) // npm root -g - .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) - .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B: marketplace add — fails + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')) // npm root -g (Route A) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route A: plugin remove ×2 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route A: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route A: marketplace add (local) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const r = registerCodexPluginAuto(); expect(r.ok).toBe(true); expect(r.packageRoot).toMatch(/codex-plugin/); @@ -568,16 +660,25 @@ describe('registerCodexPluginAuto', () => { existsSyncMock.mockReturnValue(true); const installedJson = JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '1.0.0' } } }); spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // marketplace add (git) — fails - .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // npm root -g fails (Route A) - .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list -g: not installed - .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g: succeeds - .mockReturnValueOnce(makeSpawnResult(0, installedJson, '')) // post-install npm list: package found - .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n', '')) // npm root -g (retry) - .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) - .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B: marketplace add — fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // npm root -g fails (Route A initial) + .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list -g: not installed + .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g: succeeds + .mockReturnValueOnce(makeSpawnResult(0, installedJson, '')) // post-install npm list: package found + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')) // npm root -g (retry) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route A retry: plugin remove ×2 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route A retry: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const r = registerCodexPluginAuto(); expect(r.ok).toBe(true); expect(r.packageRoot).toMatch(/codex-plugin/); @@ -585,7 +686,13 @@ describe('registerCodexPluginAuto', () => { it('returns failure when Route B fails, Route A fails, and on-demand install also fails', () => { spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // marketplace add (git) — fails + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B: marketplace add — fails .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // npm root -g fails (Route A) .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list -g: not installed .mockReturnValueOnce(makeSpawnResult(1, '', 'EACCES')); // npm install -g: fails @@ -599,7 +706,13 @@ describe('registerCodexPluginAuto', () => { it('returns failure when on-demand install succeeds but Route A retry still fails', () => { const installedJson = JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '1.0.0' } } }); spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // marketplace add (git) — fails + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B: marketplace add — fails .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // npm root -g fails (Route A) .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list -g: not installed .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g: succeeds @@ -617,33 +730,51 @@ describe('registerCodexPluginAuto', () => { '{"dependencies":{"@switchbot/codex-plugin":{"version":"1.0.0"}}}'; existsSyncMock.mockReturnValue(true); spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A fails .mockReturnValueOnce(makeSpawnResult(0, npmListWithWarnings, '')) // npm list → found .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')) // npm root -g retry + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route A retry: plugin remove ×2 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route A retry: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const r = registerCodexPluginAuto(); - // npm install -g must NOT have been called const calls = spawnSyncMock.mock.calls as [string, string[]][]; const installCall = calls.find(([cmd, args]) => cmd === 'npm' && args.includes('install')); expect(installCall).toBeUndefined(); expect(r.ok).toBe(true); }); + it('skips npm install when npm list exits non-zero but JSON output shows package is installed', () => { const installedJson = JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '1.0.0' } } }); existsSyncMock.mockReturnValue(true); spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails - .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A fails + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A fails .mockReturnValueOnce(makeSpawnResult(1, installedJson, 'peer-dep warning')) // npm list exits 1 but JSON shows package - .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')) // npm root -g retry - .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) - .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')) // npm root -g retry + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route A retry: plugin remove ×2 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route A retry: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const r = registerCodexPluginAuto(); const calls = spawnSyncMock.mock.calls as [string, string[]][]; const installCall = calls.find(([cmd, args]) => cmd === 'npm' && args.includes('install')); @@ -653,11 +784,17 @@ describe('registerCodexPluginAuto', () => { it('returns npm-prefix-mismatch error when post-install npm list still shows package absent', () => { spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails - .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A fails - .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list: not installed - .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g: succeeds - .mockReturnValueOnce(makeSpawnResult(1, '{}', 'peer-dep-warning')); // post-install npm list: still absent + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A fails + .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list: not installed + .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g: succeeds + .mockReturnValueOnce(makeSpawnResult(1, '{}', 'peer-dep-warning')); // post-install npm list: still absent const r = registerCodexPluginAuto(); expect(r.ok).toBe(false); expect(r.error).toMatch(/npm prefix mismatch/i); @@ -666,10 +803,16 @@ describe('registerCodexPluginAuto', () => { it('error says "already present" (not "installed") when package existed before and Route A retry fails (Fix 3)', () => { const installedJson = JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '1.0.0' } } }); spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails - .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A: npm root -g fails - .mockReturnValueOnce(makeSpawnResult(0, installedJson, '')) // npm list: ALREADY installed (no install ran) - .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error 2')); // npm root -g fails (retry) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A: npm root -g fails + .mockReturnValueOnce(makeSpawnResult(0, installedJson, '')) // npm list: ALREADY installed (no install ran) + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error 2')); // npm root -g fails (retry) const r = registerCodexPluginAuto(); expect(r.ok).toBe(false); expect(r.error).not.toMatch(/installed @switchbot/i); @@ -678,10 +821,16 @@ describe('registerCodexPluginAuto', () => { it('returns error when post-install verify spawnSync times out (status null)', () => { spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails - .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A: npm root -g fails - .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list: not installed - .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g: succeeds + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A: npm root -g fails + .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list: not installed + .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g: succeeds .mockReturnValueOnce({ status: null, stdout: null, stderr: '', signal: 'SIGTERM' }); // verify times out const r = registerCodexPluginAuto(); expect(r.ok).toBe(false); @@ -692,16 +841,25 @@ describe('registerCodexPluginAuto', () => { const installedJson = JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '1.0.0' } } }); existsSyncMock.mockReturnValue(true); spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails - .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A: npm root -g fails + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A: npm root -g fails .mockReturnValueOnce(makeSpawnResult(0, 'npm warn something\nnot-json', '')) // npm list: invalid JSON → fall through - .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g succeeds - .mockReturnValueOnce(makeSpawnResult(0, installedJson, '')) // post-install npm list: ok - .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n', '')) // npm root -g retry - .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) - .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g succeeds + .mockReturnValueOnce(makeSpawnResult(0, installedJson, '')) // post-install npm list: ok + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')) // npm root -g retry + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route A retry: plugin remove ×2 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route A retry: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const r = registerCodexPluginAuto(); const calls = spawnSyncMock.mock.calls as [string, string[]][]; const installCall = calls.find(([cmd, args]) => cmd === 'npm' && args.includes('install')); @@ -712,16 +870,25 @@ describe('registerCodexPluginAuto', () => { it('proceeds to Route A retry when post-install verify stdout is unparseable (inconclusive)', () => { existsSyncMock.mockReturnValue(true); spawnSyncMock - .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails - .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A: npm root -g fails - .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list: not installed - .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g succeeds - .mockReturnValueOnce(makeSpawnResult(0, 'garbage\n', '')) // post-install verify: invalid JSON - .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n', '')) // npm root -g retry - .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) - .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: plugin remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route B: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A: npm root -g fails + .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list: not installed + .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g succeeds + .mockReturnValueOnce(makeSpawnResult(0, 'garbage\n', '')) // post-install verify: invalid JSON + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')) // npm root -g retry + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route A retry: plugin remove ×2 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // Route A retry: marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const r = registerCodexPluginAuto(); expect(r.ok).toBe(true); }); @@ -740,10 +907,13 @@ describe('stepRegisterCodexPlugin', () => { it('sets codexPluginRegistered and codexPluginIdentifier on success', async () => { spawnSyncMock - .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // marketplace add (git) .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // plugin remove (switchbot@switchbot — current) .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // plugin remove (switchbot@codex-plugin — legacy) .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // plugin remove (switchbot@switchbot-skill — legacy) + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // marketplace remove ×3 + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // marketplace add (git) .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); // plugin add const step = stepRegisterCodexPlugin(); const ctx = makeCtx(); @@ -754,6 +924,12 @@ describe('stepRegisterCodexPlugin', () => { it('throws when runCodexPluginRegistration fails', async () => { spawnSyncMock + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // plugin remove (Route B pre-clean ×3) + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // marketplace remove ×3 + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'marketplace error' }) // marketplace add (git) — Route B fails .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'npm root error' }) // npm root -g — Route A fails .mockReturnValueOnce({ status: 1, stdout: '{}', stderr: '' }) // npm list -g: not installed From e7b80905ef733ab7e3a0ee082daefdcf0ff90b59 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 18:56:52 +0800 Subject: [PATCH 06/21] fix(codex): add root-level marketplace.json for git sparse checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex sparse checkout (`--sparse packages/codex-plugin`) clones the full repo structure with the repository root as the marketplace root. Placing marketplace.json at packages/codex-plugin/ puts it at the wrong level — Codex looks for the manifest at the clone root, not the sparse path. Adding a root-level marketplace.json that points the plugin path to ./packages/codex-plugin/plugins/switchbot fixes Route B registration. Co-Authored-By: Claude Sonnet 4.6 --- marketplace.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 marketplace.json diff --git a/marketplace.json b/marketplace.json new file mode 100644 index 00000000..4f519c91 --- /dev/null +++ b/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "switchbot", + "interface": { + "displayName": "SwitchBot" + }, + "plugins": [ + { + "name": "switchbot", + "source": { + "source": "local", + "path": "./packages/codex-plugin/plugins/switchbot" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} From 6b844da85380358aef1d52f5e3ab77939aba412f Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 19:22:10 +0800 Subject: [PATCH 07/21] =?UTF-8?q?fix(codex):=20use=20.claude-plugin/market?= =?UTF-8?q?place.json=20=E2=80=94=20correct=20manifest=20location=20and=20?= =?UTF-8?q?format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex CLI looks for marketplace manifests at two locations: .agents/plugins/marketplace.json (old format) .claude-plugin/marketplace.json (new format, matching claude-plugins-official) The old .agents/plugins/marketplace.json had path '../../' which resolves to two levels above the marketplace root, causing Codex to silently skip the entire marketplace in plugin list and plugin add. Changes: - Add .claude-plugin/marketplace.json at repo root (Route B, git sparse checkout) with path ./packages/codex-plugin/plugins/switchbot Also add --sparse .claude-plugin to the git marketplace add command - Add packages/codex-plugin/.claude-plugin/marketplace.json (Route A, local npm) with path ./plugins/switchbot - Remove packages/codex-plugin/.agents/plugins/marketplace.json (wrong path) - Remove root marketplace.json (Codex does not check that location) - Add packages/codex-plugin/plugins/switchbot/.mcp.json (was referenced by plugin.json mcpServers field but missing from the directory) Co-Authored-By: Claude Sonnet 4.6 --- marketplace.json => .claude-plugin/marketplace.json | 9 ++------- .../{.agents/plugins => .claude-plugin}/marketplace.json | 9 ++------- packages/codex-plugin/plugins/switchbot/.mcp.json | 9 +++++++++ src/install/codex-checks.ts | 4 +++- 4 files changed, 16 insertions(+), 15 deletions(-) rename marketplace.json => .claude-plugin/marketplace.json (56%) rename packages/codex-plugin/{.agents/plugins => .claude-plugin}/marketplace.json (62%) create mode 100644 packages/codex-plugin/plugins/switchbot/.mcp.json diff --git a/marketplace.json b/.claude-plugin/marketplace.json similarity index 56% rename from marketplace.json rename to .claude-plugin/marketplace.json index 4f519c91..105dd31f 100644 --- a/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,15 +1,10 @@ { + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "switchbot", - "interface": { - "displayName": "SwitchBot" - }, "plugins": [ { "name": "switchbot", - "source": { - "source": "local", - "path": "./packages/codex-plugin/plugins/switchbot" - }, + "source": "./packages/codex-plugin/plugins/switchbot", "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" diff --git a/packages/codex-plugin/.agents/plugins/marketplace.json b/packages/codex-plugin/.claude-plugin/marketplace.json similarity index 62% rename from packages/codex-plugin/.agents/plugins/marketplace.json rename to packages/codex-plugin/.claude-plugin/marketplace.json index a092483d..48844e43 100644 --- a/packages/codex-plugin/.agents/plugins/marketplace.json +++ b/packages/codex-plugin/.claude-plugin/marketplace.json @@ -1,15 +1,10 @@ { + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "switchbot", - "interface": { - "displayName": "SwitchBot" - }, "plugins": [ { "name": "switchbot", - "source": { - "source": "local", - "path": "../../" - }, + "source": "./plugins/switchbot", "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" diff --git a/packages/codex-plugin/plugins/switchbot/.mcp.json b/packages/codex-plugin/plugins/switchbot/.mcp.json new file mode 100644 index 00000000..f27b53b9 --- /dev/null +++ b/packages/codex-plugin/plugins/switchbot/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "switchbot": { + "command": "switchbot", + "args": ["mcp", "serve", "--tools", "all"], + "description": "SwitchBot smart-home MCP server (24 tools, via CLI)" + } + } +} diff --git a/src/install/codex-checks.ts b/src/install/codex-checks.ts index 0643d25b..258300bc 100644 --- a/src/install/codex-checks.ts +++ b/src/install/codex-checks.ts @@ -305,7 +305,8 @@ export function registerCodexPlugin(): RegisterCodexPluginResult { // ─── Git-based marketplace registration (Route B) ──────────────────────────── export const CODEX_GIT_MARKETPLACE_REPO = 'OpenWonderLabs/switchbot-openapi-cli'; -export const CODEX_GIT_MARKETPLACE_SPARSE = 'packages/codex-plugin'; +export const CODEX_GIT_MARKETPLACE_SPARSE = 'packages/codex-plugin'; +export const CODEX_GIT_MARKETPLACE_SPARSE2 = '.claude-plugin'; export const CODEX_GIT_MARKETPLACE_REF = 'main'; export const CODEX_PLUGIN_DEFAULT_ID = 'switchbot@switchbot'; // Known IDs from pre-release installs; cleaned up by both Route A and Route B. @@ -342,6 +343,7 @@ export function runCodexPluginRegistrationGit(pluginId: string): RegistrationRes 'plugin', 'marketplace', 'add', CODEX_GIT_MARKETPLACE_REPO, '--sparse', CODEX_GIT_MARKETPLACE_SPARSE, + '--sparse', CODEX_GIT_MARKETPLACE_SPARSE2, '--ref', ref, ], timeout); if (mkt.status !== 0) { From c091b83384c05d9fe1cdc8d7185e80ef06569ec9 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 19:42:49 +0800 Subject: [PATCH 08/21] fix(codex): fix npm package structure for Route A and improve os-error-32 retry - packages/codex-plugin/package.json: bump to 0.1.3, replace .agents/ with .claude-plugin/ and plugins/ in files array so Codex finds the manifest - codex-checks.ts resolveMarketplaceName: check .claude-plugin/marketplace.json first (canonical for >=0.1.3), then marketplace.json, then .agents/ legacy - install.js resolvePluginIdentifier: same priority order as above - runCodexPluginRegistrationGit: increase os-error-32 retry wait 3 s -> 10 s to give Windows Defender time to finish scanning the cloned files Co-Authored-By: Claude Sonnet 4.6 --- packages/codex-plugin/bin/install.js | 29 ++++++++++++++++++---------- packages/codex-plugin/package.json | 6 +++--- src/install/codex-checks.ts | 20 ++++++++++++++++--- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/packages/codex-plugin/bin/install.js b/packages/codex-plugin/bin/install.js index 99f83703..abbf65dd 100644 --- a/packages/codex-plugin/bin/install.js +++ b/packages/codex-plugin/bin/install.js @@ -22,22 +22,31 @@ function defaultRunInherit(cmd, args) { export function resolvePluginIdentifier(packageRoot) { let marketplaceName = basename(packageRoot); - // Check root-level marketplace.json first (canonical path Codex CLI reads) - const rootManifestPath = join(packageRoot, 'marketplace.json'); - if (existsSync(rootManifestPath)) { + // Check .claude-plugin/marketplace.json (canonical path for >=0.1.3) + const claudePluginPath = join(packageRoot, '.claude-plugin', 'marketplace.json'); + if (existsSync(claudePluginPath)) { try { - const m = JSON.parse(readFileSync(rootManifestPath, 'utf8')); + const m = JSON.parse(readFileSync(claudePluginPath, 'utf8')); if (m?.name) marketplaceName = m.name; } catch {} } else { - const marketplacePath = join(packageRoot, '.agents', 'plugins', 'marketplace.json'); - if (existsSync(marketplacePath)) { + // Fall back to root-level marketplace.json (pre-0.1.3 local copies) + const rootManifestPath = join(packageRoot, 'marketplace.json'); + if (existsSync(rootManifestPath)) { try { - const marketplace = JSON.parse(readFileSync(marketplacePath, 'utf8')); - if (marketplace?.name) { - marketplaceName = marketplace.name; - } + const m = JSON.parse(readFileSync(rootManifestPath, 'utf8')); + if (m?.name) marketplaceName = m.name; } catch {} + } else { + 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 {} + } } } diff --git a/packages/codex-plugin/package.json b/packages/codex-plugin/package.json index 77cf7408..5d81fd89 100644 --- a/packages/codex-plugin/package.json +++ b/packages/codex-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/codex-plugin", - "version": "0.1.2", + "version": "0.1.3", "type": "module", "description": "SwitchBot Codex plugin — wires Codex to the SwitchBot CLI MCP server (24 tools, zero Node.js dependencies)", "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli/tree/main/packages/codex-plugin", @@ -31,9 +31,9 @@ "setup/", "skills/", ".codex-plugin/", - ".agents/", + ".claude-plugin/", + "plugins/", ".mcp.json", - "marketplace.json", "README.md" ], "peerDependencies": { diff --git a/src/install/codex-checks.ts b/src/install/codex-checks.ts index 258300bc..f730083b 100644 --- a/src/install/codex-checks.ts +++ b/src/install/codex-checks.ts @@ -43,7 +43,15 @@ function readJsonObject(filePath: string): Record | null { } function resolveMarketplaceName(packageRoot: string): string { - // Check root-level marketplace.json first (canonical path Codex CLI reads) + // Check .claude-plugin/marketplace.json (canonical path Codex CLI reads, >=0.1.3) + const claudePluginPath = path.join(packageRoot, '.claude-plugin', 'marketplace.json'); + if (fs.existsSync(claudePluginPath)) { + const manifest = readJsonObject(claudePluginPath); + if (typeof manifest?.name === 'string' && manifest.name) { + return manifest.name; + } + } + // Fall back to root-level marketplace.json (present in pre-0.1.3 local copies) const rootManifestPath = path.join(packageRoot, 'marketplace.json'); if (fs.existsSync(rootManifestPath)) { const manifest = readJsonObject(rootManifestPath); @@ -339,13 +347,19 @@ export function runCodexPluginRegistrationGit(pluginId: string): RegistrationRes spawnStr('codex', ['plugin', 'marketplace', 'remove', name]); } // git clone via marketplace add can take >10 s on slow networks; use 60 s - const mkt = spawnStr('codex', [ + const mktArgs = [ 'plugin', 'marketplace', 'add', CODEX_GIT_MARKETPLACE_REPO, '--sparse', CODEX_GIT_MARKETPLACE_SPARSE, '--sparse', CODEX_GIT_MARKETPLACE_SPARSE2, '--ref', ref, - ], timeout); + ]; + let mkt = spawnStr('codex', mktArgs, timeout); + // On Windows, git holds file handles briefly after clone; retry once after 10 s. + if (mkt.status !== 0 && process.platform === 'win32' && mkt.stderr.includes('os error 32')) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10000); + mkt = spawnStr('codex', mktArgs, timeout); + } if (mkt.status !== 0) { return { ok: false, exitCode: mkt.status, stderr: mkt.stderr, stage: 'marketplace-add' }; } From bf283da1064e8f8d2774d747f96d00b090a80cdc Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 20:09:24 +0800 Subject: [PATCH 09/21] build(codex): gate releases on plugin registration smokes Run the same Codex release gate before push, CI, and publish so marketplace registration regressions fail before users hit setup. Add Route A and Route B smokes plus package-level install assertions to cover fresh plugin registration. Co-Authored-By: Claude Sonnet 4.6 --- .githooks/pre-push | 4 +- .github/workflows/ci.yml | 17 +++ .github/workflows/publish.yml | 12 +-- package.json | 7 +- packages/codex-plugin/tests/install.test.js | 37 +++++++ scripts/smoke-codex-git-sparse.mjs | 70 ++++++++++++ scripts/smoke-codex-pack-install.mjs | 56 ++++++++-- scripts/smoke-codex-temp-prefix-route-a.mjs | 113 ++++++++++++++++++++ 8 files changed, 293 insertions(+), 23 deletions(-) create mode 100644 scripts/smoke-codex-git-sparse.mjs create mode 100644 scripts/smoke-codex-temp-prefix-route-a.mjs diff --git a/.githooks/pre-push b/.githooks/pre-push index 8b24ba99..ef6cec72 100644 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -4,5 +4,5 @@ set -eu REPO_ROOT="$(git rev-parse --show-toplevel)" cd "$REPO_ROOT" -echo "[pre-push] tarball install smoke" -npm run verify:pre-push +echo "[pre-push] release gate" +npm run verify:release-gate diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 849294d6..33e52711 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,3 +146,20 @@ jobs: - run: npm run build - name: npm pack -> npm install tarball -> switchbot --version / policy new / policy validate run: npm run smoke:pack-install + + codex-release-gates: + name: Codex release gates (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + needs: test + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: npm + - run: npm ci + - run: npm run verify:release-gate diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 04881f21..6c65b9ff 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,9 +18,7 @@ jobs: registry-url: https://registry.npmjs.org cache: npm - run: npm ci - - run: npm run build - - run: npm test - - run: npm run test:workspaces + - run: npm run verify:release-gate - name: Verify tag matches root CLI package.json version run: | @@ -71,14 +69,6 @@ jobs: echo "codex_publish=$CODEX_PUBLISH" } >> "$GITHUB_OUTPUT" - - name: Smoke test packed root CLI artifact - if: steps.detect.outputs.cli_publish == 'true' - run: npm run smoke:pack-install - - - name: Smoke test packed Codex install path - if: steps.detect.outputs.cli_publish == 'true' || steps.detect.outputs.codex_publish == 'true' - run: npm run smoke:codex-pack-install - - name: Verify codex-plugin tarball peerDep is a concrete range if: steps.detect.outputs.codex_publish == 'true' run: | diff --git a/package.json b/package.json index ad4dd119..5c49879f 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,8 @@ "start": "node dist/index.js", "smoke:pack-install": "node scripts/smoke-pack-install.mjs", "smoke:codex-pack-install": "node scripts/smoke-codex-pack-install.mjs", + "smoke:codex-git-sparse": "node scripts/smoke-codex-git-sparse.mjs", + "smoke:codex-temp-prefix-route-a": "node scripts/smoke-codex-temp-prefix-route-a.mjs", "test": "vitest run", "test:workspaces": "npm test --workspaces --if-present", "test:all": "npm test && npm run test:workspaces", @@ -59,9 +61,10 @@ "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 tests/install/codex-checks.test.ts tests/commands/codex.test.ts && npm run test:workspaces", - "verify:pre-push": "npm run build && npm test -- tests/version.test.ts && npm run smoke:pack-install", + "verify:release-gate": "npm run build && npm test -- tests/version.test.ts tests/install/codex-checks.test.ts tests/commands/codex.test.ts && npm run test:workspaces && npm run smoke:pack-install && npm run smoke:codex-pack-install && npm run smoke:codex-git-sparse && npm run smoke:codex-temp-prefix-route-a", + "verify:pre-push": "npm run verify:release-gate", "verify:release": "node scripts/verify-release.mjs", - "prepublishOnly": "npm test && npm run build && npm run smoke:pack-install && npm run smoke:codex-pack-install" + "prepublishOnly": "npm run verify:release-gate" }, "dependencies": { "axios": "^1.7.9", diff --git a/packages/codex-plugin/tests/install.test.js b/packages/codex-plugin/tests/install.test.js index bc698646..5473c191 100644 --- a/packages/codex-plugin/tests/install.test.js +++ b/packages/codex-plugin/tests/install.test.js @@ -1,5 +1,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { makeInstall, resolvePluginIdentifier } from '../bin/install.js'; function makeOkCliCheck(version = '3.7.1') { @@ -26,6 +28,7 @@ function makeSpawn(exitCode = 0) { } const TEST_ROOT = '/fake/codex-plugin'; +const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); describe('makeInstall', () => { it('skips npm install when CLI is already present', async () => { @@ -254,10 +257,44 @@ describe('makeInstall', () => { `expected warning about remove exit code in: ${combined}`, ); }); + + it('fresh install from the current package root registers switchbot@switchbot', async () => { + const { spawn, calls } = makeSpawn(0); + const auth = makeRunAuth(0); + const install = makeInstall({ + checkCli: makeFailCliCheck(), + runInherit: spawn, + packageRoot: PACKAGE_ROOT, + runAuth: auth.runAuth, + }); + const code = await install(); + assert.equal(code, 0); + assert.ok( + calls.some(({ cmd, args }) => + cmd === 'codex' && + args[0] === 'plugin' && + args[1] === 'add' && + args[2] === 'switchbot@switchbot'), + `expected plugin add switchbot@switchbot in calls: ${JSON.stringify(calls)}`, + ); + assert.ok( + calls.some(({ cmd, args }) => + cmd === 'codex' && + args[0] === 'plugin' && + args[1] === 'marketplace' && + args[2] === 'add' && + args[3] === PACKAGE_ROOT), + `expected marketplace add ${PACKAGE_ROOT} in calls: ${JSON.stringify(calls)}`, + ); + }); }); describe('resolvePluginIdentifier', () => { it('falls back to basename when the plugin manifest is unavailable', () => { assert.equal(resolvePluginIdentifier('/fake/codex-plugin'), 'switchbot@codex-plugin'); }); + + it('uses the published marketplace manifest from the current package root', () => { + assert.equal(resolvePluginIdentifier(PACKAGE_ROOT), 'switchbot@switchbot'); + }); }); diff --git a/scripts/smoke-codex-git-sparse.mjs b/scripts/smoke-codex-git-sparse.mjs new file mode 100644 index 00000000..d4c9117f --- /dev/null +++ b/scripts/smoke-codex-git-sparse.mjs @@ -0,0 +1,70 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.dirname(scriptDir); +const workDir = mkdtempSync(path.join(os.tmpdir(), 'switchbot-codex-git-sparse-')); +const stagingDir = path.join(workDir, 'marketplace-add'); + +function runGit(args, options = {}) { + return execFileSync('git', args, { + cwd: repoRoot, + encoding: 'utf-8', + ...options, + }); +} + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, 'utf-8')); +} + +try { + const ref = process.env.CODEX_GIT_MARKETPLACE_REF + || runGit(['rev-parse', '--abbrev-ref', 'HEAD']).trim(); + + runGit(['clone', '--no-checkout', '--branch', ref, repoRoot, stagingDir], { cwd: workDir }); + runGit(['-C', stagingDir, 'sparse-checkout', 'init', '--cone'], { cwd: workDir }); + runGit(['-C', stagingDir, 'sparse-checkout', 'set', '.claude-plugin', 'packages/codex-plugin'], { cwd: workDir }); + runGit(['-C', stagingDir, 'checkout', ref], { cwd: workDir }); + + const rootMarketplacePath = path.join(stagingDir, '.claude-plugin', 'marketplace.json'); + const packageMarketplacePath = path.join(stagingDir, 'packages', 'codex-plugin', '.claude-plugin', 'marketplace.json'); + const pluginMcpPath = path.join(stagingDir, 'packages', 'codex-plugin', 'plugins', 'switchbot', '.mcp.json'); + + for (const requiredPath of [rootMarketplacePath, packageMarketplacePath, pluginMcpPath]) { + if (!existsSync(requiredPath)) { + throw new Error(`sparse checkout missing ${path.relative(stagingDir, requiredPath)}`); + } + } + + const rootMarketplace = readJson(rootMarketplacePath); + if (rootMarketplace?.name !== 'switchbot') { + throw new Error(`root marketplace name must be switchbot, got ${rootMarketplace?.name ?? ''}`); + } + const rootPlugin = rootMarketplace?.plugins?.find((plugin) => plugin?.name === 'switchbot'); + if (rootPlugin?.source !== './packages/codex-plugin/plugins/switchbot') { + throw new Error( + `root marketplace switchbot source must be ./packages/codex-plugin/plugins/switchbot, got ${rootPlugin?.source ?? ''}`, + ); + } + + const packageMarketplace = readJson(packageMarketplacePath); + if (packageMarketplace?.name !== 'switchbot') { + throw new Error(`package marketplace name must be switchbot, got ${packageMarketplace?.name ?? ''}`); + } + const packagePlugin = packageMarketplace?.plugins?.find((plugin) => plugin?.name === 'switchbot'); + if (packagePlugin?.source !== './plugins/switchbot') { + throw new Error(`package marketplace switchbot source must be ./plugins/switchbot, got ${packagePlugin?.source ?? ''}`); + } + + console.log(`codex git sparse smoke ok: ref ${ref} exposes root and package marketplace manifests with switchbot sources`); +} finally { + try { + rmSync(workDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } catch (error) { + process.stderr.write(`[smoke-codex-git-sparse] cleanup warning: ${error.message}\n`); + } +} diff --git a/scripts/smoke-codex-pack-install.mjs b/scripts/smoke-codex-pack-install.mjs index 92a5184b..1b252b87 100644 --- a/scripts/smoke-codex-pack-install.mjs +++ b/scripts/smoke-codex-pack-install.mjs @@ -2,7 +2,7 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.dirname(scriptDir); @@ -63,7 +63,7 @@ try { } for (const requiredPath of [ - '.agents/plugins/marketplace.json', + '.claude-plugin/marketplace.json', '.codex-plugin/plugin.json', '.codex-plugin/hooks.json', '.mcp.json', @@ -82,13 +82,13 @@ try { throw new Error(`plugin displayName must be SwitchBot, got ${pluginManifest?.interface?.displayName ?? ''}`); } - const marketplace = readJson(path.join(pluginRoot, '.agents', 'plugins', 'marketplace.json')); - if (marketplace?.name !== 'codex-plugin') { - throw new Error(`marketplace name must be codex-plugin so switchbot@codex-plugin resolves, got ${marketplace?.name ?? ''}`); + const marketplace = readJson(path.join(pluginRoot, '.claude-plugin', 'marketplace.json')); + if (marketplace?.name !== 'switchbot') { + throw new Error(`marketplace name must be switchbot so switchbot@switchbot resolves, got ${marketplace?.name ?? ''}`); } const switchbotEntry = marketplace?.plugins?.find((p) => p?.name === 'switchbot'); - if (switchbotEntry?.source?.path !== '../../') { - throw new Error(`marketplace switchbot plugin source.path must be '../../' (codex resolves it from .agents/plugins/marketplace.json up to packageRoot), got ${switchbotEntry?.source?.path ?? ''}`); + if (switchbotEntry?.source !== './plugins/switchbot') { + throw new Error(`marketplace switchbot plugin source must be './plugins/switchbot', got ${switchbotEntry?.source ?? ''}`); } const hooks = readJson(path.join(pluginRoot, '.codex-plugin', 'hooks.json')); @@ -140,7 +140,47 @@ try { throw new Error(`codex plugin onInstall hook must exit 0; got ${hook.status ?? 1}\nstderr:\n${hook.stderr}`); } - console.log('codex pack-install smoke ok: tarballs install, setup dry-run has 5 steps, hook is non-blocking'); + const { makeInstall, resolvePluginIdentifier, resolveMarketplaceSourceRoot } = await import( + pathToFileURL(path.join(pluginRoot, 'bin', 'install.js')).href + ); + const registrationCalls = []; + const installCode = await makeInstall({ + checkCli: async () => ({ ok: false, message: 'CLI not found' }), + runInherit: async (cmd, args) => { + registrationCalls.push({ cmd, args }); + return 0; + }, + packageRoot: pluginRoot, + runAuth: async () => 0, + })(); + if (installCode !== 0) { + throw new Error(`installed codex plugin makeInstall returned ${installCode}`); + } + + const pluginIdentifier = resolvePluginIdentifier(pluginRoot); + if (pluginIdentifier !== 'switchbot@switchbot') { + throw new Error(`resolved plugin identifier must be switchbot@switchbot, got ${pluginIdentifier}`); + } + const marketplaceSourceRoot = resolveMarketplaceSourceRoot(pluginRoot); + const registrationSequence = registrationCalls.map(({ cmd, args }) => `${cmd} ${args.join(' ')}`); + for (const expected of [ + 'npm install -g @switchbot/openapi-cli@latest', + 'codex plugin remove switchbot@switchbot', + 'codex plugin remove switchbot@codex-plugin', + 'codex plugin remove switchbot@switchbot-skill', + 'codex plugin marketplace remove switchbot', + 'codex plugin marketplace remove codex-plugin', + 'codex plugin marketplace remove switchbot-skill', + `codex plugin marketplace add ${marketplaceSourceRoot}`, + 'codex plugin add switchbot@switchbot', + 'switchbot doctor', + ]) { + if (!registrationSequence.includes(expected)) { + throw new Error(`installed codex plugin registration flow missing "${expected}"\nactual:\n${registrationSequence.join('\n')}`); + } + } + + console.log('codex pack-install smoke ok: tarballs install, setup dry-run is present, hook is non-blocking, fresh install registration uses switchbot@switchbot'); } finally { for (const tarball of packed) { rmSync(tarball, { force: true }); diff --git a/scripts/smoke-codex-temp-prefix-route-a.mjs b/scripts/smoke-codex-temp-prefix-route-a.mjs new file mode 100644 index 00000000..f0fe8935 --- /dev/null +++ b/scripts/smoke-codex-temp-prefix-route-a.mjs @@ -0,0 +1,113 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.dirname(scriptDir); +const workDir = mkdtempSync(path.join(os.tmpdir(), 'switchbot-codex-prefix-')); +const prefixDir = path.join(workDir, 'prefix'); +const packed = []; + +function runNpm(args, options = {}) { + const npmExecPath = process.env.npm_execpath; + if (npmExecPath) { + return execFileSync(process.execPath, [npmExecPath, ...args], options); + } + const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + return execFileSync(npmCmd, args, options); +} + +function pack(args) { + const out = runNpm(['pack', '--json', ...args], { + cwd: repoRoot, + encoding: 'utf-8', + }); + const [result] = JSON.parse(out); + if (!result?.filename) { + throw new Error(`npm pack did not return a filename: ${out}`); + } + const tarball = path.join(repoRoot, result.filename); + packed.push(tarball); + return tarball; +} + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, 'utf-8')); +} + +try { + const cliTarball = pack([]); + const pluginTarball = pack(['--workspace', '@switchbot/codex-plugin']); + + runNpm(['install', '-g', '--prefix', prefixDir, cliTarball, pluginTarball], { + cwd: repoRoot, + stdio: 'inherit', + }); + + const pluginRoot = path.join(prefixDir, 'node_modules', '@switchbot', 'codex-plugin'); + const cliRoot = path.join(prefixDir, 'node_modules', '@switchbot', 'openapi-cli'); + if (!existsSync(pluginRoot)) { + throw new Error(`installed plugin root not found: ${pluginRoot}`); + } + if (!existsSync(cliRoot)) { + throw new Error(`installed CLI root not found: ${cliRoot}`); + } + + const pluginPkg = readJson(path.join(pluginRoot, 'package.json')); + const cliPkg = readJson(path.join(cliRoot, 'package.json')); + if (pluginPkg.version !== '0.1.3') { + throw new Error(`expected installed codex-plugin version 0.1.3, got ${pluginPkg.version}`); + } + + const { + makeInstall, + resolvePluginIdentifier, + resolveMarketplaceSourceRoot, + } = await import(pathToFileURL(path.join(pluginRoot, 'bin', 'install.js')).href); + + const pluginId = resolvePluginIdentifier(pluginRoot); + if (pluginId !== 'switchbot@switchbot') { + throw new Error(`installed plugin resolved ${pluginId}, expected switchbot@switchbot`); + } + + const marketplaceRoot = resolveMarketplaceSourceRoot(pluginRoot); + const calls = []; + const installCode = await makeInstall({ + checkCli: async () => ({ ok: true, version: cliPkg.version }), + runInherit: async (cmd, args) => { + calls.push({ cmd, args }); + return 0; + }, + packageRoot: pluginRoot, + runAuth: async () => 0, + })(); + if (installCode !== 0) { + throw new Error(`installed Route A makeInstall exited ${installCode}`); + } + + const sequence = calls.map(({ cmd, args }) => `${cmd} ${args.join(' ')}`); + for (const expected of [ + 'codex plugin remove switchbot@switchbot', + 'codex plugin remove switchbot@codex-plugin', + 'codex plugin remove switchbot@switchbot-skill', + 'codex plugin marketplace remove switchbot', + 'codex plugin marketplace remove codex-plugin', + 'codex plugin marketplace remove switchbot-skill', + `codex plugin marketplace add ${marketplaceRoot}`, + 'codex plugin add switchbot@switchbot', + 'switchbot doctor', + ]) { + if (!sequence.includes(expected)) { + throw new Error(`installed Route A flow missing "${expected}"\nactual:\n${sequence.join('\n')}`); + } + } + + console.log(`codex temp-prefix Route A smoke ok: installed @switchbot/codex-plugin@${pluginPkg.version} resolves ${pluginId}`); +} finally { + for (const tarball of packed) { + rmSync(tarball, { force: true }); + } + rmSync(workDir, { recursive: true, force: true }); +} From 6c37ade379af5db720327110f277a94c6330a033 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 20:32:24 +0800 Subject: [PATCH 10/21] build(openclaw): restore npm publish path and align MCP entrypoint Re-add openclaw-skill to the monorepo publish/smoke workflows and bump it to 0.1.1. Align the package metadata, README, and JS entrypoint with the actual runtime contract where OpenClaw bootstraps via bin/start.js and delegates to switchbot mcp serve. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/npm-published-smoke.yml | 3 + .github/workflows/publish.yml | 35 ++++++ CHANGELOG.md | 5 + package-lock.json | 2 +- packages/openclaw-skill/.mcp.json | 2 +- packages/openclaw-skill/README.md | 6 +- packages/openclaw-skill/bin/start.js | 4 +- packages/openclaw-skill/cli.js | 108 ------------------ packages/openclaw-skill/index.js | 32 +++--- packages/openclaw-skill/package.json | 7 +- .../openclaw-skill/tests/cli-args.test.js | 84 -------------- packages/openclaw-skill/tests/server.test.js | 22 ++-- 12 files changed, 85 insertions(+), 225 deletions(-) delete mode 100644 packages/openclaw-skill/cli.js delete mode 100644 packages/openclaw-skill/tests/cli-args.test.js diff --git a/.github/workflows/npm-published-smoke.yml b/.github/workflows/npm-published-smoke.yml index 13ddabef..737f0561 100644 --- a/.github/workflows/npm-published-smoke.yml +++ b/.github/workflows/npm-published-smoke.yml @@ -32,6 +32,9 @@ jobs: - package: '@switchbot/codex-plugin' version_path: 'packages/codex-plugin/package.json' kind: plugin + - package: '@switchbot/openclaw-skill' + version_path: 'packages/openclaw-skill/package.json' + kind: plugin steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6c65b9ff..ca1c4407 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -34,11 +34,14 @@ jobs: run: | CLI_VERSION=$(node -p "require('./package.json').version") CODEX_VERSION=$(node -p "require('./packages/codex-plugin/package.json').version") + OPENCLAW_VERSION=$(node -p "require('./packages/openclaw-skill/package.json').version") echo "Root CLI: @switchbot/openapi-cli@$CLI_VERSION" echo "Codex plugin: @switchbot/codex-plugin@$CODEX_VERSION" + echo "OpenClaw plugin: @switchbot/openclaw-skill@$OPENCLAW_VERSION" { echo "cli_version=$CLI_VERSION" echo "codex_version=$CODEX_VERSION" + echo "openclaw_version=$OPENCLAW_VERSION" } >> "$GITHUB_OUTPUT" id: versions @@ -47,6 +50,7 @@ jobs: env: CLI_VERSION: ${{ steps.versions.outputs.cli_version }} CODEX_VERSION: ${{ steps.versions.outputs.codex_version }} + OPENCLAW_VERSION: ${{ steps.versions.outputs.openclaw_version }} run: | # For each package, query npm; if the exact version is already published, skip. check_unpublished() { @@ -62,11 +66,14 @@ jobs: } CLI_PUBLISH=$(check_unpublished "@switchbot/openapi-cli" "$CLI_VERSION") CODEX_PUBLISH=$(check_unpublished "@switchbot/codex-plugin" "$CODEX_VERSION") + OPENCLAW_PUBLISH=$(check_unpublished "@switchbot/openclaw-skill" "$OPENCLAW_VERSION") echo "cli_publish=$CLI_PUBLISH" echo "codex_publish=$CODEX_PUBLISH" + echo "openclaw_publish=$OPENCLAW_PUBLISH" { echo "cli_publish=$CLI_PUBLISH" echo "codex_publish=$CODEX_PUBLISH" + echo "openclaw_publish=$OPENCLAW_PUBLISH" } >> "$GITHUB_OUTPUT" - name: Verify codex-plugin tarball peerDep is a concrete range @@ -84,6 +91,21 @@ jobs: echo "OK: codex-plugin peerDep = '$PEER'" rm -f "/tmp/$TARBALL" + - name: Verify openclaw-skill tarball peerDep is a concrete range + if: steps.detect.outputs.openclaw_publish == 'true' + run: | + TARBALL=$(npm pack -w @switchbot/openclaw-skill --pack-destination /tmp/ 2>&1 | tail -1) + PEER=$(tar -xOzf "/tmp/$TARBALL" package/package.json | node -e " + const p = JSON.parse(require('fs').readFileSync(0, 'utf8')); + console.log(p.peerDependencies?.['@switchbot/openapi-cli'] || ''); + ") + if [ -z "$PEER" ] || echo "$PEER" | grep -q "workspace:"; then + echo "FAIL: openclaw-skill peerDep is missing or unrewritten workspace:* — got: '$PEER'" + exit 1 + fi + echo "OK: openclaw-skill peerDep = '$PEER'" + rm -f "/tmp/$TARBALL" + - name: Publish root CLI to npm if: steps.detect.outputs.cli_publish == 'true' run: npm publish --provenance --access public @@ -98,7 +120,20 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Publish openclaw-skill to npm + id: publish_openclaw + if: steps.detect.outputs.openclaw_publish == 'true' + continue-on-error: true + run: npm publish -w @switchbot/openclaw-skill --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Annotate plugin publish failures if: steps.detect.outputs.codex_publish == 'true' && steps.publish_codex.outcome == 'failure' run: | echo "::warning::codex-plugin publish step failed; root CLI promotion is unaffected. Investigate before next release." + + - name: Annotate OpenClaw plugin publish failures + if: steps.detect.outputs.openclaw_publish == 'true' && steps.publish_openclaw.outcome == 'failure' + run: | + echo "::warning::openclaw-skill publish step failed; root CLI promotion is unaffected. Investigate before next release." diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f696359..d24d64c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed + +- **OpenClaw plugin publish path** — `@switchbot/openclaw-skill` is back in the monorepo npm publish matrix. `publish.yml` now version-checks and publishes it alongside the CLI and Codex plugin, while `npm-published-smoke.yml` verifies the published tarball through the shared plugin smoke path. +- **OpenClaw package metadata** — `packages/openclaw-skill/README.md`, `.mcp.json`, and package metadata now describe the actual runtime contract: OpenClaw bootstraps via `bin/start.js`, then delegates to `switchbot mcp serve` for the CLI-owned MCP tool surface. + ## [3.7.3] ### Added diff --git a/package-lock.json b/package-lock.json index efb22058..6c58a82a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6628,7 +6628,7 @@ }, "packages/openclaw-skill": { "name": "@switchbot/openclaw-skill", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "bin": { "switchbot-openclaw": "bin/start.js", diff --git a/packages/openclaw-skill/.mcp.json b/packages/openclaw-skill/.mcp.json index 41cc48a2..a5c24585 100644 --- a/packages/openclaw-skill/.mcp.json +++ b/packages/openclaw-skill/.mcp.json @@ -3,7 +3,7 @@ "switchbot": { "command": "node", "args": ["${pluginDir}/bin/start.js"], - "description": "SwitchBot smart-home MCP server (devices_list, devices_status, devices_describe, devices_command, scenes_list, scenes_run)" + "description": "SwitchBot smart-home MCP server bootstrapper; launches the full tool surface exposed by `switchbot mcp serve`" } } } diff --git a/packages/openclaw-skill/README.md b/packages/openclaw-skill/README.md index f4defa9e..3b74ecec 100644 --- a/packages/openclaw-skill/README.md +++ b/packages/openclaw-skill/README.md @@ -1,6 +1,6 @@ # @switchbot/openclaw-skill -SwitchBot smart-home skill for [OpenClaw](https://openclaw.ai) — proxies all 24 MCP tools from `@switchbot/openapi-cli` so AI agents can control devices, run scenes, manage automation rules, and query audit logs. +SwitchBot smart-home skill for [OpenClaw](https://openclaw.ai) — bootstraps the local SwitchBot CLI and delegates to `switchbot mcp serve`, so AI agents get the full MCP tool surface exposed by the installed `@switchbot/openapi-cli`. ## Prerequisites @@ -25,7 +25,7 @@ installed, at `>=3.7.1`, and authenticated. Safe to re-run. ## MCP Tools -All 24 tools exposed by `switchbot mcp serve` are available. Key groups: +This plugin does not maintain its own hand-written tool subset. OpenClaw launches `bin/start.js`, and that wrapper hands off to `switchbot mcp serve`, so the exact tool list stays aligned with the installed CLI version. Key groups include: | Tool | Description | |---|---| @@ -56,7 +56,7 @@ the MCP server via the declarations in: `bin/start.js` installs it automatically. If credentials are missing, it outputs a `setupRequired` prompt asking you to run `switchbot config set-token`. Once configured, the plugin stays out of the -way and proxies the full 24-tool MCP server on every launch. +way and delegates every launch to the CLI-owned MCP server. To start manually (for debugging): diff --git a/packages/openclaw-skill/bin/start.js b/packages/openclaw-skill/bin/start.js index 9925faeb..0ff36e83 100644 --- a/packages/openclaw-skill/bin/start.js +++ b/packages/openclaw-skill/bin/start.js @@ -10,7 +10,7 @@ // --help Print this help. // // Default (no args): bootstrap wrapper — auto-installs CLI, verifies credentials, -// starts daemon if needed, then exec switchbot mcp serve (exposes all 24 MCP tools). +// starts daemon if needed, then exec switchbot mcp serve (the CLI-owned MCP surface). // Only credential setup requires user action; everything else is automatic. import { readFileSync } from 'node:fs'; @@ -62,7 +62,7 @@ See https://github.com/OpenWonderLabs/switchbot-openapi-cli for full docs.`); // [3] Daemon needed? → auto-start if automation rules are active await checkDaemon(); - // [4] Hand off to switchbot mcp serve — exposes all 24 MCP tools + // [4] Hand off to switchbot mcp serve — the CLI owns the MCP tool surface try { execFileSync('switchbot', ['mcp', 'serve'], { stdio: 'inherit' }); } catch (err) { diff --git a/packages/openclaw-skill/cli.js b/packages/openclaw-skill/cli.js deleted file mode 100644 index 4f8e422e..00000000 --- a/packages/openclaw-skill/cli.js +++ /dev/null @@ -1,108 +0,0 @@ -// packages/openclaw-skill/cli.js -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; -import { formatError } from './lib/error-messages.js'; - -const exec = promisify(execFile); - -// Read-only tools that should always bypass the CLI cache. The upstream -// @switchbot/openapi-cli 3.3.0 cache can serve stale values for batch / -// long-lived read paths — forcing --no-cache here absorbs the bug so -// agents and users don't have to remember it per-call. Mutation tools -// must NOT use --no-cache: they're write paths that don't hit the cache -// and passing the flag would just be noise. -const READ_TOOLS = new Set([ - 'devices_list', - 'devices_status', - 'devices_describe', - 'scenes_list', -]); - -export function buildCliArgs({ tool, params = {} }) { - const flags = READ_TOOLS.has(tool) ? ['--no-cache', '--json'] : ['--json']; - switch (tool) { - case 'devices_list': - return ['devices', 'list', ...flags]; - case 'devices_status': - return ['devices', 'status', params.deviceId, ...flags]; - case 'devices_describe': - return ['devices', 'describe', params.deviceId, ...flags]; - case 'devices_command': { - const args = ['--audit-log', 'devices', 'command', params.deviceId, params.command, ...flags]; - if (params.params) { - args.push('--params', JSON.stringify(params.params)); - } - return args; - } - case 'scenes_list': - return ['scenes', 'list', ...flags]; - case 'scenes_run': - return ['--audit-log', 'scenes', 'run', params.sceneId, ...flags]; - default: - throw new Error(`unknown tool: ${tool}`); - } -} - -// Substring patterns that indicate the CLI is installed but lacks -// credentials. We match on text (not a fixed envelope) because auth -// failures surface both from the CLI's own pre-flight checks and from -// the upstream SwitchBot API, and neither path conforms to the v0.2 -// envelope in every release. -const AUTH_ERROR_PATTERNS = [ - /token\s+not\s+(set|configured|found)/i, - /credentials?\s+not\s+(set|configured|found)/i, - /no\s+credentials/i, - /\b401\b/, - /unauthorized/i, - /missing\s+(token|credentials)/i, - /switchbot\s+config\s+set-token/i, -]; - -export function looksLikeAuthError(text) { - if (!text) return false; - return AUTH_ERROR_PATTERNS.some((re) => re.test(text)); -} - -function setupRequired(reason, message) { - return { - error: { - kind: 'setup-required', - reason, - message, - nextStep: 'Run `switchbot-openclaw setup` in a terminal to bootstrap the CLI.', - }, - }; -} - -export async function runCli(args) { - try { - const { stdout } = await exec('switchbot', args, { timeout: 15000 }); - return JSON.parse(stdout); - } catch (err) { - if (err && err.code === 'ENOENT') { - return setupRequired( - 'cli-missing', - formatError('cli-not-installed'), - ); - } - const raw = (err && (err.stdout ?? err.stderr ?? err.message)) ?? String(err); - let parsed = null; - try { parsed = JSON.parse(raw); } catch { /* non-JSON failure */ } - - const envelopeKind = parsed?.error?.kind; - if (envelopeKind === 'auth' || envelopeKind === 'credentials' || envelopeKind === 'unauthorized') { - return setupRequired( - 'auth-missing', - formatError('auth-not-configured'), - ); - } - if (!parsed && looksLikeAuthError(raw)) { - return setupRequired( - 'auth-missing', - formatError('auth-not-configured'), - ); - } - if (parsed) return parsed; - return { error: { kind: 'internal', message: raw } }; - } -} diff --git a/packages/openclaw-skill/index.js b/packages/openclaw-skill/index.js index ac35b93e..250ecfe6 100644 --- a/packages/openclaw-skill/index.js +++ b/packages/openclaw-skill/index.js @@ -1,19 +1,23 @@ // packages/openclaw-skill/index.js -import { buildCliArgs, runCli } from './cli.js'; +// +// This package does not register a hand-maintained JavaScript tool subset. +// OpenClaw launches the stdio entry declared in `.mcp.json`, which executes +// `bin/start.js`; that bootstrapper then delegates to `switchbot mcp serve`. -const TOOLS = [ - 'devices_list', - 'devices_status', - 'devices_describe', - 'devices_command', - 'scenes_list', - 'scenes_run', -]; +export const MCP_SERVER_NAME = 'switchbot'; +export const MCP_TRANSPORT = 'stdio'; +export const MCP_ENTRY_COMMAND = 'node'; +export const MCP_ENTRY_ARGS = ['${pluginDir}/bin/start.js']; +export const MCP_DELEGATE_COMMAND = ['switchbot', 'mcp', 'serve']; export function createServer() { - const _registeredTools = {}; - for (const tool of TOOLS) { - _registeredTools[tool] = (params) => runCli(buildCliArgs({ tool, params })); - } - return { _registeredTools }; + return { + name: MCP_SERVER_NAME, + transport: MCP_TRANSPORT, + command: MCP_ENTRY_COMMAND, + args: [...MCP_ENTRY_ARGS], + delegateCommand: [...MCP_DELEGATE_COMMAND], + }; } + +export default createServer; diff --git a/packages/openclaw-skill/package.json b/packages/openclaw-skill/package.json index 3bcb34de..8632d3d7 100644 --- a/packages/openclaw-skill/package.json +++ b/packages/openclaw-skill/package.json @@ -1,8 +1,8 @@ { "name": "@switchbot/openclaw-skill", - "version": "0.1.0", + "version": "0.1.1", "type": "module", - "description": "OpenClaw plugin that drives @switchbot/openapi-cli >= 3.7.1. Exposes 6 MCP tools (devices_list/status/describe/command, scenes_list/run) for SwitchBot smart-home control.", + "description": "OpenClaw plugin that bootstraps and delegates to `switchbot mcp serve`, exposing the full SwitchBot MCP tool surface through @switchbot/openapi-cli >= 3.7.1.", "homepage": "https://github.com/OpenWonderLabs/switchbot-openapi-cli/tree/main/packages/openclaw-skill", "repository": { "type": "git", @@ -37,7 +37,6 @@ }, "files": [ "index.js", - "cli.js", ".claude-plugin/", ".mcp.json", "bin/", @@ -52,6 +51,6 @@ "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" + "typecheck": "node --check bin/start.js && node --check bin/policy-edit.js && node --check index.js" } } diff --git a/packages/openclaw-skill/tests/cli-args.test.js b/packages/openclaw-skill/tests/cli-args.test.js deleted file mode 100644 index f5b995eb..00000000 --- a/packages/openclaw-skill/tests/cli-args.test.js +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { buildCliArgs, looksLikeAuthError } from '../cli.js'; - -describe('buildCliArgs', () => { - it('devices_list passes --no-cache', () => { - const args = buildCliArgs({ tool: 'devices_list', params: {} }); - assert.ok(args.includes('--no-cache'), 'read tool must use --no-cache'); - assert.deepEqual(args, ['devices', 'list', '--no-cache', '--json']); - }); - - it('devices_status passes --no-cache and forwards deviceId', () => { - const args = buildCliArgs({ tool: 'devices_status', params: { deviceId: 'ABC' } }); - assert.ok(args.includes('--no-cache')); - assert.deepEqual(args, ['devices', 'status', 'ABC', '--no-cache', '--json']); - }); - - it('devices_describe passes --no-cache', () => { - const args = buildCliArgs({ tool: 'devices_describe', params: { deviceId: 'ABC' } }); - assert.ok(args.includes('--no-cache')); - assert.deepEqual(args, ['devices', 'describe', 'ABC', '--no-cache', '--json']); - }); - - it('devices_command does NOT pass --no-cache', () => { - const args = buildCliArgs({ - tool: 'devices_command', - params: { deviceId: 'ABC', command: 'turnOn' }, - }); - assert.ok(!args.includes('--no-cache'), 'mutation must not use --no-cache'); - assert.deepEqual(args, ['--audit-log', 'devices', 'command', 'ABC', 'turnOn', '--json']); - }); - - it('devices_command forwards --params when provided', () => { - const args = buildCliArgs({ - tool: 'devices_command', - params: { deviceId: 'ABC', command: 'setBrightness', params: { value: 70 } }, - }); - assert.ok(!args.includes('--no-cache')); - const paramsIdx = args.indexOf('--params'); - assert.notEqual(paramsIdx, -1, '--params flag missing'); - assert.equal(args[paramsIdx + 1], JSON.stringify({ value: 70 })); - }); - - it('scenes_list passes --no-cache', () => { - const args = buildCliArgs({ tool: 'scenes_list', params: {} }); - assert.ok(args.includes('--no-cache')); - assert.deepEqual(args, ['scenes', 'list', '--no-cache', '--json']); - }); - - it('scenes_run does NOT pass --no-cache', () => { - const args = buildCliArgs({ tool: 'scenes_run', params: { sceneId: 'SCENE1' } }); - assert.ok(!args.includes('--no-cache'), 'mutation must not use --no-cache'); - assert.deepEqual(args, ['--audit-log', 'scenes', 'run', 'SCENE1', '--json']); - }); - - it('throws for unknown tool', () => { - assert.throws(() => buildCliArgs({ tool: 'unknown_tool', params: {} }), /unknown tool/); - }); -}); - -describe('looksLikeAuthError', () => { - it('matches "token not set"', () => { - assert.ok(looksLikeAuthError('Error: token not set')); - }); - - it('matches "401" as standalone code', () => { - assert.ok(looksLikeAuthError('HTTP 401 Unauthorized')); - }); - - it('matches "credentials not configured"', () => { - assert.ok(looksLikeAuthError('credentials not configured')); - }); - - it('matches the hint "switchbot config set-token"', () => { - assert.ok(looksLikeAuthError('run `switchbot config set-token` first')); - }); - - it('does not match unrelated errors', () => { - assert.ok(!looksLikeAuthError('device is offline')); - assert.ok(!looksLikeAuthError('connection timeout after 15s')); - assert.ok(!looksLikeAuthError('')); - assert.ok(!looksLikeAuthError(null)); - }); -}); diff --git a/packages/openclaw-skill/tests/server.test.js b/packages/openclaw-skill/tests/server.test.js index 83db426e..acb8c5db 100644 --- a/packages/openclaw-skill/tests/server.test.js +++ b/packages/openclaw-skill/tests/server.test.js @@ -1,15 +1,21 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { createServer } from '../index.js'; +import { + createServer, + MCP_SERVER_NAME, + MCP_TRANSPORT, + MCP_ENTRY_COMMAND, + MCP_ENTRY_ARGS, + MCP_DELEGATE_COMMAND, +} from '../index.js'; describe('MCP server', () => { - it('registers all required tools', () => { + it('describes the stdio launcher that delegates to the CLI MCP server', () => { const server = createServer(); - const names = Object.keys(server._registeredTools); - const required = ['devices_list', 'devices_status', 'devices_describe', 'devices_command', 'scenes_list', 'scenes_run']; - for (const name of required) { - assert.ok(names.includes(name), `tool "${name}" not registered`); - } - assert.equal(names.length, required.length, 'unexpected extra tools'); + assert.equal(server.name, MCP_SERVER_NAME); + assert.equal(server.transport, MCP_TRANSPORT); + assert.equal(server.command, MCP_ENTRY_COMMAND); + assert.deepEqual(server.args, MCP_ENTRY_ARGS); + assert.deepEqual(server.delegateCommand, MCP_DELEGATE_COMMAND); }); }); From 9f8a24c15374467b13a376d1b07ce1ccb70ba59d Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 20:34:14 +0800 Subject: [PATCH 11/21] fix(codex): use CODEX_PLUGIN_DEFAULT_ID constant in repairStepRemovePlugin fallback Replace hardcoded 'switchbot@codex-plugin' with CODEX_PLUGIN_DEFAULT_ID constant in the repairStepRemovePlugin function. This ensures the repair step correctly removes the currently-installed 'switchbot@switchbot' plugin when resolveCodexPackageRoot() fails, now that the constant was updated to reflect the new plugin ID. Also update the test to verify the correct constant is used and to expect all three plugin IDs (default + 2 legacy) to be removed. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/codex.ts | 3 ++- tests/commands/codex.test.ts | 15 +++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/commands/codex.ts b/src/commands/codex.ts index 199798d4..35e0534d 100644 --- a/src/commands/codex.ts +++ b/src/commands/codex.ts @@ -9,6 +9,7 @@ import { registerCodexPluginAuto, resolvePluginId, resolveCodexPackageRoot, + CODEX_PLUGIN_DEFAULT_ID, CODEX_PLUGIN_LEGACY_IDS, type Check, } from '../install/codex-checks.js'; @@ -172,7 +173,7 @@ function repairStepRemovePlugin(ctx: RepairContext): RepairOutcome { let pluginId = ctx.codexPluginId; if (!pluginId) { const root = resolveCodexPackageRoot(); - pluginId = root.ok ? resolvePluginId(root.packageRoot) : 'switchbot@codex-plugin'; + pluginId = root.ok ? resolvePluginId(root.packageRoot) : CODEX_PLUGIN_DEFAULT_ID; ctx.codexPluginId = pluginId; } for (const id of [...new Set([pluginId, ...CODEX_PLUGIN_LEGACY_IDS])]) { diff --git a/tests/commands/codex.test.ts b/tests/commands/codex.test.ts index de47bff8..53983269 100644 --- a/tests/commands/codex.test.ts +++ b/tests/commands/codex.test.ts @@ -382,19 +382,21 @@ describe('switchbot codex repair', () => { expect(removedIds).toContain('switchbot@switchbot-skill'); }); - it('remove-plugin falls back to default ID "switchbot@codex-plugin" when npm root -g fails', async () => { + it('remove-plugin falls back to default ID "switchbot@switchbot" when npm root -g fails', async () => { // verify-cli passes runDoctorChecksMock.mockResolvedValueOnce([ { name: 'node', status: 'ok', detail: 'ok' }, { name: 'path', status: 'ok', detail: 'ok' }, ]); - // remove-plugin: npm root -g fails → fallback to 'switchbot@codex-plugin' + // remove-plugin: npm root -g fails → fallback to 'switchbot@switchbot' spawnSyncRepairMock - .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'npm error' }) // npm root -g fails - .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // remove switchbot@codex-plugin - .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); // remove legacy id + .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'npm error' }) // npm root -g fails + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // remove switchbot@switchbot + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // remove switchbot@codex-plugin + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // remove switchbot@switchbot-skill + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); // extra buffer // register-plugin: ok - registerCodexPluginMock.mockReturnValueOnce({ ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: null }); + registerCodexPluginMock.mockReturnValueOnce({ ok: true, pluginId: 'switchbot@switchbot', packageRoot: null }); // doctor-verify runDoctorChecksMock.mockResolvedValueOnce(makeBaseChecks()); checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: 'ok' }); @@ -407,6 +409,7 @@ describe('switchbot codex repair', () => { (call) => (call[1] as string[]).includes('remove'), ); const removedIds = removeCalls.map((call) => (call[1] as string[])[2]); + expect(removedIds).toContain('switchbot@switchbot'); expect(removedIds).toContain('switchbot@codex-plugin'); expect(removedIds).toContain('switchbot@switchbot-skill'); }); From 453b981a4500f570db9ccdda133303d7e393ce62 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 20:37:24 +0800 Subject: [PATCH 12/21] fix(codex): handle detached HEAD in smoke-codex-git-sparse When HEAD is detached (e.g., tag-triggered CI releases), 'git rev-parse --abbrev-ref HEAD' returns 'HEAD', causing 'git clone --branch HEAD' to fail. Solution: detect detached HEAD via --abbrev-ref returning 'HEAD', then: 1. Use full commit SHA from 'git rev-parse HEAD' 2. Clone without --branch (since git clone --branch requires named refs) 3. Let existing checkout step handle both branch names and SHAs Fixes smoke test crashes on tag-based releases and detached HEAD checkouts. Co-Authored-By: Claude Sonnet 4.6 --- scripts/smoke-codex-git-sparse.mjs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/scripts/smoke-codex-git-sparse.mjs b/scripts/smoke-codex-git-sparse.mjs index d4c9117f..c6403010 100644 --- a/scripts/smoke-codex-git-sparse.mjs +++ b/scripts/smoke-codex-git-sparse.mjs @@ -22,10 +22,20 @@ function readJson(filePath) { } try { - const ref = process.env.CODEX_GIT_MARKETPLACE_REF - || runGit(['rev-parse', '--abbrev-ref', 'HEAD']).trim(); + let ref = process.env.CODEX_GIT_MARKETPLACE_REF; + if (!ref) { + const abbrev = runGit(['rev-parse', '--abbrev-ref', 'HEAD']).trim(); + ref = abbrev === 'HEAD' + ? runGit(['rev-parse', 'HEAD']).trim() // detached HEAD: use full SHA + : abbrev; + } - runGit(['clone', '--no-checkout', '--branch', ref, repoRoot, stagingDir], { cwd: workDir }); + // Clone: use --branch only for named branches; for SHAs clone without --branch then checkout + if (ref.match(/^[0-9a-f]{40}$/)) { + runGit(['clone', '--no-checkout', repoRoot, stagingDir], { cwd: workDir }); + } else { + runGit(['clone', '--no-checkout', '--branch', ref, repoRoot, stagingDir], { cwd: workDir }); + } runGit(['-C', stagingDir, 'sparse-checkout', 'init', '--cone'], { cwd: workDir }); runGit(['-C', stagingDir, 'sparse-checkout', 'set', '.claude-plugin', 'packages/codex-plugin'], { cwd: workDir }); runGit(['-C', stagingDir, 'checkout', ref], { cwd: workDir }); From ac0f5c6c38d3848a9eca8393021d2320ebd9d74a Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 20:39:36 +0800 Subject: [PATCH 13/21] fix(smoke): make SHA detection case-insensitive and add error handling for git ref detection - Change regex from /^[0-9a-f]{40}$/ to /^[0-9a-fA-F]{40}$/ to accept uppercase hex in CODEX_GIT_MARKETPLACE_REF - Wrap ref detection block in try/catch to provide informative error if git rev-parse fails Co-Authored-By: Claude Sonnet 4.6 --- scripts/smoke-codex-git-sparse.mjs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/scripts/smoke-codex-git-sparse.mjs b/scripts/smoke-codex-git-sparse.mjs index c6403010..06d9f0b4 100644 --- a/scripts/smoke-codex-git-sparse.mjs +++ b/scripts/smoke-codex-git-sparse.mjs @@ -24,14 +24,18 @@ function readJson(filePath) { try { let ref = process.env.CODEX_GIT_MARKETPLACE_REF; if (!ref) { - const abbrev = runGit(['rev-parse', '--abbrev-ref', 'HEAD']).trim(); - ref = abbrev === 'HEAD' - ? runGit(['rev-parse', 'HEAD']).trim() // detached HEAD: use full SHA - : abbrev; + try { + const abbrev = runGit(['rev-parse', '--abbrev-ref', 'HEAD']).trim(); + ref = abbrev === 'HEAD' + ? runGit(['rev-parse', 'HEAD']).trim() // detached HEAD: use full SHA + : abbrev; + } catch (err) { + throw new Error(`Failed to determine git ref for smoke test: ${err.message}`); + } } // Clone: use --branch only for named branches; for SHAs clone without --branch then checkout - if (ref.match(/^[0-9a-f]{40}$/)) { + if (ref.match(/^[0-9a-fA-F]{40}$/)) { runGit(['clone', '--no-checkout', repoRoot, stagingDir], { cwd: workDir }); } else { runGit(['clone', '--no-checkout', '--branch', ref, repoRoot, stagingDir], { cwd: workDir }); From 063d2948edba3344f571b7b9b7d1df16bae2276c Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 20:40:44 +0800 Subject: [PATCH 14/21] fix(codex): read expected plugin version dynamically in smoke test Replace hardcoded '0.1.3' version check with dynamic read from packages/codex-plugin/package.json, ensuring the test doesn't break when the plugin version is bumped. Aligns with the pattern used in smoke-codex-pack-install.mjs. Co-Authored-By: Claude Sonnet 4.6 --- scripts/smoke-codex-temp-prefix-route-a.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/smoke-codex-temp-prefix-route-a.mjs b/scripts/smoke-codex-temp-prefix-route-a.mjs index f0fe8935..744c08da 100644 --- a/scripts/smoke-codex-temp-prefix-route-a.mjs +++ b/scripts/smoke-codex-temp-prefix-route-a.mjs @@ -9,6 +9,9 @@ const repoRoot = path.dirname(scriptDir); const workDir = mkdtempSync(path.join(os.tmpdir(), 'switchbot-codex-prefix-')); const prefixDir = path.join(workDir, 'prefix'); const packed = []; +const expectedPluginVersion = JSON.parse( + readFileSync(path.join(repoRoot, 'packages', 'codex-plugin', 'package.json'), 'utf-8') +).version; function runNpm(args, options = {}) { const npmExecPath = process.env.npm_execpath; @@ -57,8 +60,8 @@ try { const pluginPkg = readJson(path.join(pluginRoot, 'package.json')); const cliPkg = readJson(path.join(cliRoot, 'package.json')); - if (pluginPkg.version !== '0.1.3') { - throw new Error(`expected installed codex-plugin version 0.1.3, got ${pluginPkg.version}`); + if (pluginPkg.version !== expectedPluginVersion) { + throw new Error(`expected installed codex-plugin version ${expectedPluginVersion}, got ${pluginPkg.version}`); } const { From 8a1d28115532b836d47e2089096b27df5e42eadf Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 20:42:24 +0800 Subject: [PATCH 15/21] fix(codex-plugin): fix manifest fallback blocked by corrupt .claude-plugin/marketplace.json Replace the if/else chain in resolvePluginIdentifier with sequential independent if blocks in a for loop. This allows fallback paths to work even if earlier manifest files exist but contain invalid JSON (e.g., interrupted write). Mirrors the correct pattern already used in src/install/codex-checks.ts (resolveMarketplaceName function). Co-Authored-By: Claude Sonnet 4.6 --- packages/codex-plugin/bin/install.js | 36 ++++++++++------------------ 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/packages/codex-plugin/bin/install.js b/packages/codex-plugin/bin/install.js index abbf65dd..5191df05 100644 --- a/packages/codex-plugin/bin/install.js +++ b/packages/codex-plugin/bin/install.js @@ -22,31 +22,21 @@ function defaultRunInherit(cmd, args) { export function resolvePluginIdentifier(packageRoot) { let marketplaceName = basename(packageRoot); - // Check .claude-plugin/marketplace.json (canonical path for >=0.1.3) - const claudePluginPath = join(packageRoot, '.claude-plugin', 'marketplace.json'); - if (existsSync(claudePluginPath)) { - try { - const m = JSON.parse(readFileSync(claudePluginPath, 'utf8')); - if (m?.name) marketplaceName = m.name; - } catch {} - } else { - // Fall back to root-level marketplace.json (pre-0.1.3 local copies) - const rootManifestPath = join(packageRoot, 'marketplace.json'); - if (existsSync(rootManifestPath)) { + + // Check manifest paths in priority order; stop at first valid name found. + // Sequential independent if blocks allow fallbacks to work even if earlier + // files exist but have invalid JSON (e.g., interrupted write). + const manifestPaths = [ + join(packageRoot, '.claude-plugin', 'marketplace.json'), + join(packageRoot, 'marketplace.json'), + join(packageRoot, '.agents', 'plugins', 'marketplace.json'), + ]; + for (const p of manifestPaths) { + if (existsSync(p)) { try { - const m = JSON.parse(readFileSync(rootManifestPath, 'utf8')); - if (m?.name) marketplaceName = m.name; + const m = JSON.parse(readFileSync(p, 'utf8')); + if (m?.name) { marketplaceName = m.name; break; } } catch {} - } else { - 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 {} - } } } From 329bb6d5e2acdd5f8c6c17c0da90daefbba5b1d9 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 20:44:03 +0800 Subject: [PATCH 16/21] fix(codex): verify registration sequence ordering in smoke test Add index-based ordering assertions to ensure all `codex plugin remove` calls happen BEFORE `codex plugin marketplace add`, not after. The previous check using Array.includes only verified presence, not order. This enforces the critical invariant introduced in this PR: stale plugins must be removed before the marketplace source is added. Co-Authored-By: Claude Sonnet 4.6 --- scripts/smoke-codex-pack-install.mjs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/scripts/smoke-codex-pack-install.mjs b/scripts/smoke-codex-pack-install.mjs index 1b252b87..c0fc0203 100644 --- a/scripts/smoke-codex-pack-install.mjs +++ b/scripts/smoke-codex-pack-install.mjs @@ -180,6 +180,26 @@ try { } } + // Verify ordering: all plugin removes must come before marketplace add + const marketplaceAddIdx = registrationSequence.indexOf( + `codex plugin marketplace add ${marketplaceSourceRoot}` + ); + const pluginAddIdx = registrationSequence.indexOf('codex plugin add switchbot@switchbot'); + const pluginRemoveIds = ['switchbot@switchbot', 'switchbot@codex-plugin', 'switchbot@switchbot-skill']; + for (const id of pluginRemoveIds) { + const removeIdx = registrationSequence.indexOf(`codex plugin remove ${id}`); + if (removeIdx === -1 || removeIdx >= marketplaceAddIdx) { + throw new Error( + `codex plugin remove ${id} must happen before marketplace add (remove idx: ${removeIdx}, marketplace add idx: ${marketplaceAddIdx})` + ); + } + } + if (pluginAddIdx <= marketplaceAddIdx) { + throw new Error( + `codex plugin add must happen after marketplace add (add idx: ${pluginAddIdx}, marketplace add idx: ${marketplaceAddIdx})` + ); + } + console.log('codex pack-install smoke ok: tarballs install, setup dry-run is present, hook is non-blocking, fresh install registration uses switchbot@switchbot'); } finally { for (const tarball of packed) { From 5dbd8914a62d6ca2296da8960e5ad5e06152f342 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 20:45:50 +0800 Subject: [PATCH 17/21] test(codex): verify both --sparse flags in runCodexPluginRegistrationGit Add test case to ensure that runCodexPluginRegistrationGit passes both --sparse flags (packages/codex-plugin and .claude-plugin) to the marketplace add command. Previously only return values were tested. Co-Authored-By: Claude Sonnet 4.6 --- tests/install/codex-checks.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/install/codex-checks.test.ts b/tests/install/codex-checks.test.ts index 1300bc24..46639b10 100644 --- a/tests/install/codex-checks.test.ts +++ b/tests/install/codex-checks.test.ts @@ -42,6 +42,8 @@ import { registerCodexPlugin, registerCodexPluginGit, registerCodexPluginAuto, + CODEX_GIT_MARKETPLACE_SPARSE, + CODEX_GIT_MARKETPLACE_SPARSE2, } from '../../src/install/codex-checks.js'; function makeSpawnResult(status: number, stdout: string, stderr = ''): ReturnType { @@ -592,6 +594,30 @@ describe('runCodexPluginRegistrationGit', () => { if (origEnv === undefined) delete process.env['CODEX_GIT_MARKETPLACE_REF']; else process.env['CODEX_GIT_MARKETPLACE_REF'] = origEnv; }); + + it('passes both --sparse flags (packages/codex-plugin and .claude-plugin) to marketplace add', () => { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id: switchbot@switchbot) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id: switchbot@codex-plugin) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id: switchbot@switchbot-skill) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace remove ×3 + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (git) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + runCodexPluginRegistrationGit('switchbot@switchbot'); + const calls = spawnSyncMock.mock.calls as [string, string[]][]; + const mktCall = calls.find(([cmd, args]) => cmd === 'codex' && args.includes('marketplace') && args.includes('add')); + expect(mktCall).toBeDefined(); + const mktArgs = mktCall![1]; + // Both sparse paths must be present + const sparseIndices = mktArgs + .map((a, i) => (a === '--sparse' ? i : -1)) + .filter(i => i >= 0); + expect(sparseIndices.length).toBe(2); + expect(mktArgs[sparseIndices[0] + 1]).toBe(CODEX_GIT_MARKETPLACE_SPARSE); + expect(mktArgs[sparseIndices[1] + 1]).toBe(CODEX_GIT_MARKETPLACE_SPARSE2); + }); }); describe('registerCodexPluginAuto', () => { From 8f09bd048b0ae2bbeb8dee466a90f187ee8e1b1f Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 21:37:52 +0800 Subject: [PATCH 18/21] fix: add EPERM guard to resolveMarketplaceSourceRoot symlink creation --- src/install/codex-checks.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/install/codex-checks.ts b/src/install/codex-checks.ts index f730083b..c51ce77d 100644 --- a/src/install/codex-checks.ts +++ b/src/install/codex-checks.ts @@ -95,6 +95,22 @@ function computeAliasPath(): string { return path.join(os.homedir(), '.switchbot', 'codex-plugin-marketplace'); } +function createAlias(src: string, dest: string, type: fs.symlink.Type): void { + try { + fs.symlinkSync(src, dest, type); + } catch (err: unknown) { + const nodeErr = err as NodeJS.ErrnoException; + if (nodeErr.code === 'EPERM') { + throw new Error( + `Cannot create ${type} at ${dest}: permission denied (EPERM). ` + + `On Windows, run the installer from an elevated terminal, ` + + `or install to a path without @-scoped segments.`, + ); + } + throw err; + } +} + export function resolveMarketplaceSourceRoot(packageRoot: string): string { // Codex misclassifies local paths containing `@`-scoped npm segments // (e.g. `…/node_modules/@switchbot/codex-plugin`) as ref-bearing git sources, @@ -114,7 +130,7 @@ export function resolveMarketplaceSourceRoot(packageRoot: string): string { const stat = fs.lstatSync(aliasRoot, { throwIfNoEntry: false }); if (!stat) { - fs.symlinkSync(packageRoot, aliasRoot, linkType); + createAlias(packageRoot, aliasRoot, linkType); return aliasRoot; } @@ -128,7 +144,7 @@ export function resolveMarketplaceSourceRoot(packageRoot: string): string { // Dangling symlink: target was deleted (e.g. nvm switch, npm uninstall). // Recreate it pointing at the current packageRoot. fs.unlinkSync(aliasRoot); - fs.symlinkSync(packageRoot, aliasRoot, linkType); + createAlias(packageRoot, aliasRoot, linkType); return aliasRoot; } const pathsMatch = process.platform === 'win32' @@ -136,7 +152,7 @@ export function resolveMarketplaceSourceRoot(packageRoot: string): string { : aliasReal === packageReal; if (pathsMatch) return aliasRoot; fs.unlinkSync(aliasRoot); - fs.symlinkSync(packageRoot, aliasRoot, linkType); + createAlias(packageRoot, aliasRoot, linkType); return aliasRoot; } From 5cbb9c49a24df1b3ed5a6a81b2093e91440dcd70 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 21:43:07 +0800 Subject: [PATCH 19/21] fix: add EPERM guard and test in bin/install.js resolveMarketplaceSourceRoot --- packages/codex-plugin/bin/install.js | 21 +++++++++++-- packages/codex-plugin/tests/install.test.js | 33 ++++++++++++++++++++- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/packages/codex-plugin/bin/install.js b/packages/codex-plugin/bin/install.js index 5191df05..85322f3d 100644 --- a/packages/codex-plugin/bin/install.js +++ b/packages/codex-plugin/bin/install.js @@ -62,6 +62,21 @@ function computeAliasPath() { return join(os.homedir(), '.switchbot', 'codex-plugin-marketplace'); } +function createAlias(src, dest, type, deps) { + try { + deps.symlinkSync(src, dest, type); + } catch (err) { + if (err && err.code === 'EPERM') { + throw new Error( + `Cannot create ${type} at ${dest}: permission denied (EPERM). ` + + `On Windows, run the installer from an elevated terminal, ` + + `or install to a path without @-scoped segments.`, + ); + } + throw err; + } +} + export function resolveMarketplaceSourceRoot(packageRoot, deps = defaultFsDeps) { // NOTE: This function is FROZEN. The canonical implementation lives in // src/install/codex-checks.ts. Do NOT sync new changes here. @@ -80,7 +95,7 @@ export function resolveMarketplaceSourceRoot(packageRoot, deps = defaultFsDeps) const stat = deps.lstatSync(aliasRoot, { throwIfNoEntry: false }); if (!stat) { - deps.symlinkSync(packageRoot, aliasRoot, linkType); + createAlias(packageRoot, aliasRoot, linkType, deps); return aliasRoot; } @@ -93,7 +108,7 @@ export function resolveMarketplaceSourceRoot(packageRoot, deps = defaultFsDeps) } catch { // Dangling symlink: target was deleted (e.g. nvm switch, npm uninstall). deps.unlinkSync(aliasRoot); - deps.symlinkSync(packageRoot, aliasRoot, linkType); + createAlias(packageRoot, aliasRoot, linkType, deps); return aliasRoot; } const pathsMatch = process.platform === 'win32' @@ -103,7 +118,7 @@ export function resolveMarketplaceSourceRoot(packageRoot, deps = defaultFsDeps) return aliasRoot; } deps.unlinkSync(aliasRoot); - deps.symlinkSync(packageRoot, aliasRoot, linkType); + createAlias(packageRoot, aliasRoot, linkType, deps); return aliasRoot; } diff --git a/packages/codex-plugin/tests/install.test.js b/packages/codex-plugin/tests/install.test.js index 5473c191..4b849155 100644 --- a/packages/codex-plugin/tests/install.test.js +++ b/packages/codex-plugin/tests/install.test.js @@ -2,7 +2,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { makeInstall, resolvePluginIdentifier } from '../bin/install.js'; +import { makeInstall, resolvePluginIdentifier, resolveMarketplaceSourceRoot } from '../bin/install.js'; function makeOkCliCheck(version = '3.7.1') { return async () => ({ ok: true, version }); @@ -298,3 +298,34 @@ describe('resolvePluginIdentifier', () => { assert.equal(resolvePluginIdentifier(PACKAGE_ROOT), 'switchbot@switchbot'); }); }); + +describe('resolveMarketplaceSourceRoot', () => { + it('resolveMarketplaceSourceRoot throws a helpful message when symlinkSync fails with EPERM', () => { + const epermErr = Object.assign(new Error('operation not permitted'), { code: 'EPERM' }); + const deps = { + mkdirSync: () => {}, + lstatSync: (_p, _opts) => null, // alias does not exist → first-create branch + symlinkSync: (_src, _dest, _type) => { throw epermErr; }, + realpathSync: (p) => p, + unlinkSync: () => {}, + }; + + // Use a path that triggers needsAlias on any platform + const scopedPath = process.platform === 'win32' + ? 'C:\\Users\\test\\node_modules\\@switchbot\\codex-plugin' + : '/home/user/node_modules/@switchbot/codex-plugin'; + + assert.throws( + () => resolveMarketplaceSourceRoot(scopedPath, deps), + (err) => { + assert.ok(err instanceof Error, 'should throw an Error'); + assert.ok(err.message.includes('EPERM'), `message should mention EPERM, got: ${err.message}`); + assert.ok( + err.message.includes('permission denied') || err.message.includes('elevated'), + `message should be actionable, got: ${err.message}`, + ); + return true; + }, + ); + }); +}); From 6bea63be4d82ee7b33a5c4b7f4e3edf8e7121de5 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 21:47:46 +0800 Subject: [PATCH 20/21] test: add EPERM coverage for dangling-symlink and stale-target branches --- packages/codex-plugin/tests/install.test.js | 61 +++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/packages/codex-plugin/tests/install.test.js b/packages/codex-plugin/tests/install.test.js index 4b849155..1248e527 100644 --- a/packages/codex-plugin/tests/install.test.js +++ b/packages/codex-plugin/tests/install.test.js @@ -328,4 +328,65 @@ describe('resolveMarketplaceSourceRoot', () => { }, ); }); + + it('throws a helpful EPERM message when symlinkSync fails on dangling-symlink branch', () => { + const epermErr = Object.assign(new Error('operation not permitted'), { code: 'EPERM' }); + const deps = { + mkdirSync: () => {}, + lstatSync: (_p, _opts) => ({ isSymbolicLink: () => true }), // alias exists as symlink + symlinkSync: (_src, _dest, _type) => { throw epermErr; }, + realpathSync: (_p) => { throw new Error('ENOENT: no such file'); }, // dangling → target gone + unlinkSync: () => {}, + }; + + const scopedPath = process.platform === 'win32' + ? 'C:\\Users\\test\\node_modules\\@switchbot\\codex-plugin' + : '/home/user/node_modules/@switchbot/codex-plugin'; + + assert.throws( + () => resolveMarketplaceSourceRoot(scopedPath, deps), + (err) => { + assert.ok(err instanceof Error, 'should throw an Error'); + assert.ok(err.message.includes('EPERM'), `message should mention EPERM, got: ${err.message}`); + assert.ok( + err.message.includes('permission denied') || err.message.includes('elevated'), + `message should be actionable, got: ${err.message}`, + ); + return true; + }, + ); + }); + + it('throws a helpful EPERM message when symlinkSync fails on stale-target branch', () => { + const epermErr = Object.assign(new Error('operation not permitted'), { code: 'EPERM' }); + let realCallCount = 0; + const deps = { + mkdirSync: () => {}, + lstatSync: (_p, _opts) => ({ isSymbolicLink: () => true }), // alias exists as symlink + symlinkSync: (_src, _dest, _type) => { throw epermErr; }, + realpathSync: (p) => { + realCallCount++; + // First call = aliasRoot, second call = packageRoot — return different values so paths differ + return realCallCount === 1 ? p + '-old' : p + '-new'; + }, + unlinkSync: () => {}, + }; + + const scopedPath = process.platform === 'win32' + ? 'C:\\Users\\test\\node_modules\\@switchbot\\codex-plugin' + : '/home/user/node_modules/@switchbot/codex-plugin'; + + assert.throws( + () => resolveMarketplaceSourceRoot(scopedPath, deps), + (err) => { + assert.ok(err instanceof Error, 'should throw an Error'); + assert.ok(err.message.includes('EPERM'), `message should mention EPERM, got: ${err.message}`); + assert.ok( + err.message.includes('permission denied') || err.message.includes('elevated'), + `message should be actionable, got: ${err.message}`, + ); + return true; + }, + ); + }); }); From e5664c4ef25ea79d882a83b5a511bdebea5aec00 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Mon, 25 May 2026 22:09:04 +0800 Subject: [PATCH 21/21] fix(ci): use lib/node_modules on Linux prefix install and fix Windows test glob --- packages/codex-plugin/package.json | 2 +- packages/openclaw-skill/package.json | 2 +- scripts/smoke-codex-temp-prefix-route-a.mjs | 10 ++++++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/codex-plugin/package.json b/packages/codex-plugin/package.json index 5d81fd89..a9366aa3 100644 --- a/packages/codex-plugin/package.json +++ b/packages/codex-plugin/package.json @@ -48,7 +48,7 @@ "access": "public" }, "scripts": { - "test": "node --test tests/*.test.js", + "test": "node --test", "typecheck": "node --check bin/auth.js && node --check bin/install.js" } } diff --git a/packages/openclaw-skill/package.json b/packages/openclaw-skill/package.json index 8632d3d7..e98d3e78 100644 --- a/packages/openclaw-skill/package.json +++ b/packages/openclaw-skill/package.json @@ -49,7 +49,7 @@ "access": "public" }, "scripts": { - "test": "node --test tests/*.test.js", + "test": "node --test", "start": "node bin/start.js", "typecheck": "node --check bin/start.js && node --check bin/policy-edit.js && node --check index.js" } diff --git a/scripts/smoke-codex-temp-prefix-route-a.mjs b/scripts/smoke-codex-temp-prefix-route-a.mjs index 744c08da..42b42beb 100644 --- a/scripts/smoke-codex-temp-prefix-route-a.mjs +++ b/scripts/smoke-codex-temp-prefix-route-a.mjs @@ -49,8 +49,14 @@ try { stdio: 'inherit', }); - const pluginRoot = path.join(prefixDir, 'node_modules', '@switchbot', 'codex-plugin'); - const cliRoot = path.join(prefixDir, 'node_modules', '@switchbot', 'openapi-cli'); + // On Linux/macOS, `npm install -g --prefix` puts packages under lib/node_modules/; + // on Windows they go directly under node_modules/. + const nodeModulesDir = + process.platform === 'win32' + ? path.join(prefixDir, 'node_modules') + : path.join(prefixDir, 'lib', 'node_modules'); + const pluginRoot = path.join(nodeModulesDir, '@switchbot', 'codex-plugin'); + const cliRoot = path.join(nodeModulesDir, '@switchbot', 'openapi-cli'); if (!existsSync(pluginRoot)) { throw new Error(`installed plugin root not found: ${pluginRoot}`); }