Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
1f37569
feat(codex): Route B — register plugin via git marketplace URL
May 23, 2026
2a10b1e
fix(codex): extend @-scoped path alias to Linux/macOS, fix WSL auth c…
May 23, 2026
1e19940
fix(codex): address code review — regex scope, null packageRoot, WSL …
May 23, 2026
a3d70ce
fix(codex): fall back to local npm path when GitHub is unreachable (r…
May 23, 2026
6da0538
fix(auth,codex): close OAuth server on error, preserve git error cont…
May 23, 2026
cf8d0d4
fix(codex): simplify plugin setup guidance
May 23, 2026
5826944
feat(codex/setup): install @switchbot/codex-plugin on demand instead …
May 23, 2026
c145cbc
docs(readme): document self-hosted git marketplace for Codex plugin
May 23, 2026
33129d0
docs(readme): add codex command section, trim verbose descriptions
May 23, 2026
b9dd337
docs(readme): rewrite as concise cheat-sheet (254 lines)
May 23, 2026
a11a246
docs(readme): restore 'who is this for' entry-point summary
May 23, 2026
ddef72a
docs(help): update rules description and add missing trace-explain/si…
May 24, 2026
5acd9b2
fix(codex): address code review findings from PR #54
May 24, 2026
ead9f54
fix(codex): allow Route B installs past preflight; restore README dev…
May 24, 2026
2d357bb
fix(codex): address code review findings — timeouts, regex, legacy ID…
May 24, 2026
89cac08
fix(codex): tighten validateSkip, add timeout env var, use canonical …
May 24, 2026
c647172
fix(codex): update --skip help text and harden timeout env var parsing
May 24, 2026
8079280
fix(codex): warn to stderr when CODEX_MARKETPLACE_ADD_TIMEOUT is set …
May 24, 2026
f2345d6
fix(codex-plugin): catch resolveMarketplaceSourceRoot errors and emit…
May 24, 2026
ba10ce1
fix(codex): warn when deprecated --skip name is used (no-op instead o…
May 24, 2026
3e0f06e
fix(codex): strip npm warning prefix before JSON parse in installCode…
May 24, 2026
92ccc6c
fix(codex-plugin): log warning when codex plugin remove exits non-zero
May 24, 2026
371c078
fix(codex): use line-based JSON detection and fix timeout warning mes…
May 24, 2026
2059aab
fix(codex): reconstruct full JSON from first JSON line in installCode…
May 24, 2026
27c8968
chore: bump version to 3.7.3
May 24, 2026
19d8200
fix(codex): remove install-codex-plugin from smoke step list; clean l…
May 24, 2026
9851fc9
fix(codex): address code-review findings — legacy IDs, npm list exit …
May 24, 2026
10515c2
fix(codex-checks): return error when post-install verify spawnSync ti…
May 24, 2026
e26ddea
fix(codex): guard dangling symlinks, fix empty REF fallback, export l…
May 24, 2026
6a100f6
chore(codex-plugin): freeze resolveMarketplaceSourceRoot in install.js
May 24, 2026
34783d2
test(codex): fill coverage gaps identified by post-fix audit
May 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
908 changes: 130 additions & 778 deletions README.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@switchbot/openapi-cli",
"version": "3.7.2",
"version": "3.7.3",
"description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.",
"keywords": [
"switchbot",
Expand Down
11 changes: 4 additions & 7 deletions packages/codex-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Codex plugin for SwitchBot smart-home control through the authoritative
- A Codex skill at `skills/switchbot/SKILL.md`
- An MCP server definition that runs `switchbot mcp serve --tools all`
- A best-effort `onInstall` hook that runs non-interactive setup when the CLI is present
- A bootstrap binary: `switchbot-codex-install`
- Legacy helper binaries: `switchbot-codex-auth` and `switchbot-codex-install`

## Requirements

Expand Down Expand Up @@ -115,16 +115,14 @@ Expected result:

## Uninstall

Remove the plugin entry you installed. Common Codex plugin IDs are:
Remove the plugin entry:

```bash
codex plugin remove switchbot@switchbot-skill
codex plugin remove switchbot@codex-plugin
```

Repo-marketplace installs usually use `switchbot@switchbot-skill`. Package-local
marketplace installs use `switchbot@codex-plugin` (matches the package directory
name).
Older prerelease installs may have used `switchbot@switchbot-skill`; removing
that id is harmless if Codex reports it is not installed.

If you installed the npm package globally and also want to remove the helper
commands:
Expand All @@ -138,7 +136,6 @@ npm uninstall -g @switchbot/codex-plugin
To remove the plugin, local policy files, and stored login state:

```bash
codex plugin remove switchbot@switchbot-skill
codex plugin remove switchbot@codex-plugin
switchbot auth logout
```
Expand Down
58 changes: 47 additions & 11 deletions packages/codex-plugin/bin/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,41 +55,64 @@ function computeAliasPath() {
}

export function resolveMarketplaceSourceRoot(packageRoot, deps = defaultFsDeps) {
if (process.platform !== 'win32' || !/^[A-Za-z]:[\\/].*[\\/]@[^\\/]+[\\/]/.test(packageRoot)) {
// NOTE: This function is FROZEN. The canonical implementation lives in
// src/install/codex-checks.ts. Do NOT sync new changes here.
// The switchbot-codex-install binary is deprecated; use: switchbot codex setup
const needsAlias = process.platform === 'win32'
? /^[A-Za-z]:[\\/].*[\\/]@[^\\/]+[\\/]/.test(packageRoot)
: /\/@[^/]+\//.test(packageRoot);

if (!needsAlias) {
return packageRoot;
}

const aliasRoot = computeAliasPath();
deps.mkdirSync(dirname(aliasRoot), { recursive: true });
const linkType = process.platform === 'win32' ? 'junction' : 'dir';

const stat = deps.lstatSync(aliasRoot, { throwIfNoEntry: false });
if (!stat) {
deps.symlinkSync(packageRoot, aliasRoot, 'junction');
deps.symlinkSync(packageRoot, aliasRoot, linkType);
return aliasRoot;
}

if (stat.isSymbolicLink()) {
const aliasReal = deps.realpathSync(aliasRoot);
const packageReal = deps.realpathSync(packageRoot);
if (aliasReal.toLowerCase() === packageReal.toLowerCase()) {
let aliasReal;
let packageReal;
try {
aliasReal = deps.realpathSync(aliasRoot);
packageReal = deps.realpathSync(packageRoot);
} catch {
// Dangling symlink: target was deleted (e.g. nvm switch, npm uninstall).
deps.unlinkSync(aliasRoot);
deps.symlinkSync(packageRoot, aliasRoot, linkType);
return aliasRoot;
}
const pathsMatch = process.platform === 'win32'
? aliasReal.toLowerCase() === packageReal.toLowerCase()
: aliasReal === packageReal;
if (pathsMatch) {
return aliasRoot;
}
deps.unlinkSync(aliasRoot);
deps.symlinkSync(packageRoot, aliasRoot, 'junction');
deps.symlinkSync(packageRoot, aliasRoot, linkType);
return aliasRoot;
}

throw new Error(`alias path ${aliasRoot} exists and is not a junction; remove it manually and retry`);
const expected = process.platform === 'win32' ? 'junction' : 'symlink';
throw new Error(`alias path ${aliasRoot} exists and is not a ${expected}; remove it manually and retry`);
}

function formatCodexFailure(step) {
return [
`[switchbot-codex] Codex CLI not found while running ${step}.`,
'[switchbot-codex] Install or open Codex first, then re-run switchbot-codex-install.',
'[switchbot-codex] Install or open Codex first, then run: npx @switchbot/openapi-cli codex setup',
].join('\n');
}

export function makeInstall({ checkCli, runInherit, packageRoot, runAuth }) {
const CODEX_PLUGIN_LEGACY_IDS = ['switchbot@switchbot-skill'];

export function makeInstall({ checkCli, runInherit, packageRoot, runAuth, resolveRoot = resolveMarketplaceSourceRoot }) {
return async function install() {
process.stderr.write(
'[switchbot-codex] WARNING: switchbot-codex-install is deprecated.\n' +
Expand All @@ -108,7 +131,13 @@ export function makeInstall({ checkCli, runInherit, packageRoot, runAuth }) {
process.stderr.write(`[switchbot-codex] CLI ${cliCheck.version} detected.\n`);
}

const marketplaceRoot = resolveMarketplaceSourceRoot(packageRoot);
let marketplaceRoot;
try {
marketplaceRoot = resolveRoot(packageRoot);
} catch (err) {
process.stderr.write(`[switchbot-codex] Cannot prepare marketplace path: ${err.message}\n`);
return 1;
}
process.stderr.write(`[switchbot-codex] Registering plugin at ${marketplaceRoot}...\n`);
const marketplaceCode = await runInherit('codex', ['plugin', 'marketplace', 'add', marketplaceRoot]);
if (marketplaceCode !== 0) {
Expand All @@ -121,6 +150,13 @@ export function makeInstall({ checkCli, runInherit, packageRoot, runAuth }) {
}

const pluginName = resolvePluginIdentifier(packageRoot);
for (const id of [pluginName, ...CODEX_PLUGIN_LEGACY_IDS]) {
process.stderr.write(`[switchbot-codex] Removing stale plugin ${id} if present...\n`);
const removeCode = await runInherit('codex', ['plugin', 'remove', id]);
if (removeCode !== 0) {
process.stderr.write(`[switchbot-codex] Warning: plugin remove exited ${removeCode}; continuing.\n`);
}
}
process.stderr.write(`[switchbot-codex] Adding plugin ${pluginName}...\n`);
const pluginCode = await runInherit('codex', ['plugin', 'add', pluginName]);
if (pluginCode !== 0) {
Expand All @@ -130,7 +166,7 @@ export function makeInstall({ checkCli, runInherit, packageRoot, runAuth }) {
}
process.stderr.write(
'[switchbot-codex] "codex plugin add" failed — your Codex version may not support it.\n' +
'[switchbot-codex] Fallback: follow the legacy install steps in CODEX_INSTALL.md.\n'
'[switchbot-codex] Fallback: run npx @switchbot/openapi-cli codex setup after updating Codex.\n'
);
return pluginCode;
}
Expand Down
90 changes: 79 additions & 11 deletions packages/codex-plugin/tests/install.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,12 @@ describe('makeInstall', () => {
});
const code = await install();
assert.equal(code, 0);
assert.equal(calls.length, 3);
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', 'add', 'switchbot@codex-plugin'] });
assert.deepEqual(calls[2], { 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', 'add', 'switchbot@codex-plugin'] });
assert.deepEqual(calls[4], { cmd: 'switchbot', args: ['doctor'] });
assert.equal(auth.calls.length, 1);
});

Expand All @@ -57,11 +59,13 @@ describe('makeInstall', () => {
});
const code = await install();
assert.equal(code, 0);
assert.equal(calls.length, 4);
assert.equal(calls.length, 6);
assert.deepEqual(calls[0], { cmd: 'npm', args: ['install', '-g', '@switchbot/openapi-cli@latest'] });
assert.deepEqual(calls[1], { cmd: 'codex', args: ['plugin', 'marketplace', 'add', TEST_ROOT] });
assert.deepEqual(calls[2], { cmd: 'codex', args: ['plugin', 'add', 'switchbot@codex-plugin'] });
assert.deepEqual(calls[3], { cmd: 'switchbot', args: ['doctor'] });
assert.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.equal(auth.calls.length, 1);
});

Expand Down Expand Up @@ -105,7 +109,8 @@ describe('makeInstall', () => {
const auth = makeRunAuth(0);
const spawn = (cmd, args) => {
callCount++;
return Promise.resolve(callCount === 2 ? 3 : 0);
// calls: 1=marketplace add, 2=remove current, 3=remove legacy, 4=plugin add
return Promise.resolve(callCount === 4 ? 3 : 0);
};
const install = makeInstall({
checkCli: makeOkCliCheck(),
Expand All @@ -115,7 +120,7 @@ describe('makeInstall', () => {
});
const code = await install();
assert.equal(code, 3);
assert.equal(callCount, 2);
assert.equal(callCount, 4);
assert.equal(auth.calls.length, 0);
});

Expand All @@ -130,7 +135,7 @@ describe('makeInstall', () => {
});
const code = await install();
assert.equal(code, 4);
assert.equal(calls.length, 2);
assert.equal(calls.length, 4);
assert.equal(auth.calls.length, 1);
});

Expand All @@ -149,8 +154,8 @@ describe('makeInstall', () => {
});
const code = await install();
assert.equal(code, 5);
assert.equal(calls.length, 3);
assert.deepEqual(calls[2], { cmd: 'switchbot', args: ['doctor'] });
assert.equal(calls.length, 5);
assert.deepEqual(calls[4], { cmd: 'switchbot', args: ['doctor'] });
assert.equal(auth.calls.length, 1);
});

Expand All @@ -172,6 +177,69 @@ describe('makeInstall', () => {
assert.equal(callCount, 1);
assert.equal(auth.calls.length, 0);
});

it('returns 1 with a prefixed message when resolveMarketplaceSourceRoot throws', async () => {
const auth = makeRunAuth(0);
const { spawn } = makeSpawn(0);
const errChunks = [];
const origWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = (chunk, ...rest) => { errChunks.push(String(chunk)); return true; };

let code;
try {
const install = makeInstall({
checkCli: makeOkCliCheck(),
runInherit: spawn,
packageRoot: TEST_ROOT,
runAuth: auth.runAuth,
resolveRoot: () => {
throw new Error('alias path /home/user/.switchbot/codex-plugin-marketplace exists and is not a symlink/junction; remove it manually and retry');
},
});
code = await install();
} finally {
process.stderr.write = origWrite;
}

assert.equal(code, 1);
const combined = errChunks.join('');
assert.ok(combined.includes('[switchbot-codex]'), `expected [switchbot-codex] prefix in: ${combined}`);
assert.ok(combined.includes('codex-plugin-marketplace'), `expected alias path in: ${combined}`);
});

it('logs a warning and continues when plugin remove exits non-zero', async () => {
let callCount = 0;
const spawn = (cmd, args) => {
callCount++;
// calls: 1=marketplace add, 2=remove current → failure, 3=remove legacy, 4=plugin add, 5=doctor
return Promise.resolve(callCount === 2 ? 1 : 0);
};
const auth = makeRunAuth(0);
const errChunks = [];
const origWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = (chunk, ...rest) => { errChunks.push(String(chunk)); return true; };

let code;
try {
const install = makeInstall({
checkCli: makeOkCliCheck(),
runInherit: spawn,
packageRoot: TEST_ROOT,
runAuth: auth.runAuth,
});
code = await install();
} finally {
process.stderr.write = origWrite;
}

assert.equal(code, 0, 'install should still succeed');
assert.equal(callCount, 5, 'all five spawn calls should be made');
const combined = errChunks.join('');
assert.ok(
combined.includes('Warning') && combined.includes('remove') && combined.includes('exited'),
`expected warning about remove exit code in: ${combined}`,
);
});
});

describe('resolvePluginIdentifier', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,44 @@ describe('resolveMarketplaceSourceRoot', () => {
});
assert.throws(() => resolveMarketplaceSourceRoot(SCOPED_ROOT, deps), /exists and is not a junction/);
});

it('aliases Linux npm @scope package paths', () => {
const savedPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
try {
const target = '/home/me/.npm-global/lib/node_modules/@switchbot/codex-plugin';
const created = [];
const deps = makeDeps({
lstatSync: () => null,
mkdirSync: (p) => created.push(['mkdir', p]),
symlinkSync: (from, to, type) => created.push(['symlink', from, to, type]),
});
const resolved = resolveMarketplaceSourceRoot(target, deps);
assert.match(resolved, /codex-plugin-marketplace$/);
assert.equal(created[1][1], target);
assert.equal(created[1][3], 'dir');
} finally {
Object.defineProperty(process, 'platform', { value: savedPlatform, configurable: true });
}
});

it('aliases Linux custom-prefix path with no node_modules segment', () => {
const savedPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
try {
const target = '/home/me/.local/lib/@switchbot/codex-plugin';
const created = [];
const deps = makeDeps({
lstatSync: () => null,
mkdirSync: (p) => created.push(['mkdir', p]),
symlinkSync: (from, to, type) => created.push(['symlink', from, to, type]),
});
const resolved = resolveMarketplaceSourceRoot(target, deps);
assert.match(resolved, /codex-plugin-marketplace$/);
assert.equal(created[1][1], target);
assert.equal(created[1][3], 'dir');
} finally {
Object.defineProperty(process, 'platform', { value: savedPlatform, configurable: true });
}
});
});
3 changes: 1 addition & 2 deletions scripts/smoke-codex-pack-install.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,6 @@ try {
for (const expected of [
'check-codex-cli',
'install-switchbot-cli',
'install-codex-plugin',
'register-plugin',
'auth',
'doctor-verify',
Expand All @@ -141,7 +140,7 @@ 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 includes plugin install, hook is non-blocking');
console.log('codex pack-install smoke ok: tarballs install, setup dry-run has 5 steps, hook is non-blocking');
} finally {
for (const tarball of packed) {
rmSync(tarball, { force: true });
Expand Down
Loading