Skip to content

feat(devices): cache deviceId→type locally and pre-validate commands …#1

Merged
chenliuyun merged 1 commit into
mainfrom
feat/device-cache-command-validation
Apr 17, 2026
Merged

feat(devices): cache deviceId→type locally and pre-validate commands …#1
chenliuyun merged 1 commit into
mainfrom
feat/device-cache-command-validation

Conversation

@chenliuyun

Copy link
Copy Markdown
Collaborator

…against catalog

  • Add src/devices/cache.ts: persists deviceId → {type, name, category} at ~/.switchbot/devices.json (0o600, atomic, sibling to --config override)
  • devices list / describe now refresh the cache
  • devices command pre-validates against the catalog before hitting the API:
    • unsupported command → exit 2 with supported-command list
    • no-param command given a parameter → exit 2 with corrected invocation
    • --type customize bypasses validation (arbitrary IR button names)
    • unknown deviceId / stale cache gracefully fall through
  • Validation is placed outside the action's try/catch so its exit(2) is not re-handled as exit(1) by handleError
  • Add 19 tests (11 cache unit tests + 8 command validation tests)

♻️ Current situation

Describe the current situation. Explain current problems, if there are any. Be as descriptive as possible (e.g., including examples or code snippets).

💡 Proposed solution

Describe the proposed solution and changes. How does it affect the project? How does it affect the internal structure (e.g., refactorings)?

⚙️ Release Notes

Provide a summary of the changes or features from a user's point of view. If there are breaking changes, provide migration guides using code examples of the affected features.

➕ Additional Information

If applicable, provide additional context in this section.

Testing

Which tests were added? Which existing tests were adapted/changed? Which situations are covered, and what edge cases are missing?

Reviewer Nudging

Where should the reviewer start? what is a good entry point?

…against catalog

- Add src/devices/cache.ts: persists deviceId → {type, name, category} at
  ~/.switchbot/devices.json (0o600, atomic, sibling to --config override)
- devices list / describe now refresh the cache
- devices command pre-validates against the catalog before hitting the API:
  * unsupported command → exit 2 with supported-command list
  * no-param command given a parameter → exit 2 with corrected invocation
  * --type customize bypasses validation (arbitrary IR button names)
  * unknown deviceId / stale cache gracefully fall through
- Validation is placed outside the action's try/catch so its exit(2) is
  not re-handled as exit(1) by handleError
- Add 19 tests (11 cache unit tests + 8 command validation tests)
@chenliuyun chenliuyun merged commit 515bd20 into main Apr 17, 2026
3 checks passed
@chenliuyun chenliuyun deleted the feat/device-cache-command-validation branch April 18, 2026 18:29
chenliuyun pushed a commit that referenced this pull request Apr 19, 2026
Covers all seven findings from the post-implementation review plus
the /iot/credential integration fixes found during smoke testing:

1. events stream filter mismatch — `--filter deviceId=` and
   `--filter type=` silently matched nothing because the matcher read
   ctx.deviceMac/ctx.deviceType from the shadow payload, but those
   fields live on webhook bodies. Added matchShadowEventFilter that
   reads top-level deviceId/deviceType from the parsed shadow event.

2. ErrorSubKind extensions — introduced MqttError with subKind values
   mqtt-tls-failed, mqtt-connect-timeout, mqtt-disconnected (wired into
   ErrorSubKind union, buildErrorPayload, handleError). Credential
   fetch now maps 401/429 to ApiError(auth-failed/quota-exceeded) and
   network timeouts to MqttError(mqtt-connect-timeout). Classify
   initial connect failures via classifyMqttConnectError so cert
   errors surface as mqtt-tls-failed.

3. Runtime reconnect loop — previously MqttTlsClient only retried the
   initial connect; mid-session disconnects silently stopped streaming
   with reconnectAttempts and checkConnectionStability sitting as dead
   code. Now attaches a close handler post-connect that drives
   connectWithRetry on drop, exhausting 5 attempts before emitting an
   mqtt-disconnected MqttError to the registered runtime-error
   handler. Sleep between attempts is now abortable, so end() cancels
   a pending backoff immediately instead of waiting out the delay.

4. Credential cache path — replaced the literal '~' fallback with
   os.homedir(). Also clean up the .tmp file if the atomic
   rename fails so we do not leave orphan writes behind.

5. events stream smoke tests — added tests/commands/events-stream.test.ts
   covering extractShadowEvent parsing and the end-to-end filter path
   that broke before finding #1. Added tests/mqtt/errors.test.ts for
   MqttError classification and buildErrorPayload integration.
   Extended credential tests for 401/429/null-body handling.

6. Quota SIGINT/SIGTERM — quota.ts previously registered global
   signal handlers that called process.exit(130/143), short-circuiting
   command-layer cleanup (watch / events stream finally blocks). The
   handlers now only flush the counter; they fall back to the
   conventional exit code only when quota is the sole listener, so
   short one-shot commands keep their old behavior while long-running
   commands retain control of their own exit path.

7. Status cache cross-process staleness — status.json was read into
   a process-local hot cache with no invalidation, so a long-running
   MCP server could not see writes from a concurrent one-shot CLI.
   loadStatusCache now stats mtime before every read and reloads when
   the file has changed on disk; saveStatusCache/clearStatusCache/
   resetStatusCache update the tracked mtime accordingly. Same-process
   reads remain zero-IO when mtime is unchanged.

8. /iot/credential integration — the endpoint rejected POST {} with
   statusCode 190 "param is invalid". The signing convention differs
   from the public OpenAPI: nonce is the literal string "OpenClaw"
   (not a UUID), the HMAC signature is NOT uppercased, the `t` header
   is numeric, and the body requires a 12-char random `instanceId`.
   Response shape is also nested under body.channels.mqtt with
   topics: {status: string} (wrap to single-element array for our
   subscribe path). Error messages surface at the outer `message`
   field, not body.message. Split buildCredentialHeaders from the
   OpenAPI buildAuthHeaders to keep both conventions clean.

9. TLS material encoding — caBase64/certBase64/keyBase64 are a
   misnomer: the /iot/credential response carries literal PEM text
   in those fields. Decoding them as base64 corrupted the material
   ("PEM routines::no start line"). Pass the strings through to mqtt
   as-is. Also align connect options with OpenClaw's reference
   implementation (keepalive: 60, reschedulePings: true) and dispose
   the prior client before reconnecting so stale listeners from a
   dead TCP socket do not leak.

Tests: 697/697 passing (+24 new).
chenliuyun pushed a commit that referenced this pull request Apr 20, 2026
…tch (bug #1)

Under require-unique, the exact-name short-circuit at line 73 returned
immediately on the first exact hit, bypassing the ambiguity check entirely.
This meant a query like '空调' on an account with both '空调' (exact) and
'卧室空调' (substring) silently resolved to the exact device instead of
raising ambiguous_name_match (exit 2).

Fix: gate the short-circuit by strategy. For require-unique, the exact hit is
pushed as a score-0 candidate and the full candidate list is assembled before
the ambiguity check runs. Fuzzy/first/exact/prefix/substring behaviour is
unchanged.

Tests added (4 new):
- exact + substring under require-unique → ambiguous (reporter scenario)
- two exact-same-name devices under require-unique → ambiguous
- single exact match, no other matches under require-unique → resolves OK
- fuzzy still short-circuits on exact match (regression pin)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
chenliuyun pushed a commit that referenced this pull request Apr 20, 2026
Document every fix landed in this branch beyond the history-aggregate
feature: bugs #1, #4, #5, #6, #8, #9, #10, #11, #12, #13, #14, #15,
#16, #17, #18 from the OpenClaw v2.4.0 smoke-test report. Call out
the deferred items (#2, #7) explicitly so readers don't assume they
were overlooked.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
chenliuyun pushed a commit that referenced this pull request Apr 20, 2026
…tch (bug #1)

Under require-unique, the exact-name short-circuit at line 73 returned
immediately on the first exact hit, bypassing the ambiguity check entirely.
This meant a query like '空调' on an account with both '空调' (exact) and
'卧室空调' (substring) silently resolved to the exact device instead of
raising ambiguous_name_match (exit 2).

Fix: gate the short-circuit by strategy. For require-unique, the exact hit is
pushed as a score-0 candidate and the full candidate list is assembled before
the ambiguity check runs. Fuzzy/first/exact/prefix/substring behaviour is
unchanged.

Tests added (4 new):
- exact + substring under require-unique → ambiguous (reporter scenario)
- two exact-same-name devices under require-unique → ambiguous
- single exact match, no other matches under require-unique → resolves OK
- fuzzy still short-circuits on exact match (regression pin)
chenliuyun pushed a commit that referenced this pull request Apr 20, 2026
Document every fix landed in this branch beyond the history-aggregate
feature: bugs #1, #4, #5, #6, #8, #9, #10, #11, #12, #13, #14, #15,
#16, #17, #18 from the OpenClaw v2.4.0 smoke-test report. Call out
the deferred items (#2, #7) explicitly so readers don't assume they
were overlooked.
chenliuyun added a commit that referenced this pull request May 7, 2026
…M condition + rules simulate) (#41)

* feat: Track κ — AI decision loop (PRs 1–4)

PR 1 — Decision Trace
- src/rules/trace.ts: deepSortedJson, ruleVersion (sha256/8-char), TraceBuilder,
  shouldWriteTrace (full/sampled/off), filterTraceRecords
- engine.ts: fireId before eval, TraceBuilder threaded, emitTrace closure
- matcher.ts: trace? param, pushConditionTrace after eval, isLlmCondition guard
- audit.ts: rule-evaluate/llm-condition/llm-budget-exceeded kinds; writeEvaluateTrace
- schema v0.2.json: automation.audit block (evaluate_trace, evaluate_retention_days)
- tests/rules/trace.test.ts: 30 tests

PR 2 — rules explain
- src/rules/explain.ts: loadTraceRecords, loadRelatedAudit, formatExplainText/Json
- rules.ts: trace-explain subcommand (avoids collision with static explain)
- mcp.ts: rules_explain tool
- tests/rules/explain.test.ts: 19 tests

PR 3 — LLM condition
- src/rules/types.ts: LlmCondition interface + isLlmCondition guard; AutomationLlmBudgetConfig
- llm/provider.ts: decide() + DecideResult/DecideOptions on LLMProvider interface
- llm/providers/anthropic.ts: decide() via tool-use API
- llm/providers/openai.ts: decide() via function-calling API
- src/rules/llm-condition.ts: LlmConditionEvaluator (cache, budget, on_error)
  Cache key: sha256(JSON.stringify([ruleVersion, prompt, deepSortedJson(ctx)])) — fixes spec gap #5
- matcher.ts: llm condition branch with provider injection
- schema: llm condition oneOf branch + automation.llm_budget
- engine.ts: 4 lint rules (condition-llm-no-provider, no-cache-ttl-high-freq,
  budget-zero, on-error-pass)
- tests/rules/llm-condition.test.ts: 22 tests

PR 4 — rules simulate
- src/rules/simulate.ts: simulateRule(), as-of state fetcher, against-file replay,
  ThrottleGate simulation, LLM skip marker
- rules.ts: simulate subcommand
- mcp.ts: rules_simulate tool
- capabilities.ts: trace-explain + simulate entries
- tests/rules/simulate.test.ts: 18 tests

Spec gaps fixed:
  #1 canonicalize undefined → deepSortedJson() recursive key-sort
  #5 cache key raw concat → JSON.stringify([...]) structured array

* fix: guard Array.isArray before iterating rules/then in checkNotifyConnectivity

---------

Co-authored-by: chenliuyun <chenliuyun@onero.com>
chenliuyun added a commit that referenced this pull request May 7, 2026
* feat: Track κ — AI decision loop (PRs 1–4)

PR 1 — Decision Trace
- src/rules/trace.ts: deepSortedJson, ruleVersion (sha256/8-char), TraceBuilder,
  shouldWriteTrace (full/sampled/off), filterTraceRecords
- engine.ts: fireId before eval, TraceBuilder threaded, emitTrace closure
- matcher.ts: trace? param, pushConditionTrace after eval, isLlmCondition guard
- audit.ts: rule-evaluate/llm-condition/llm-budget-exceeded kinds; writeEvaluateTrace
- schema v0.2.json: automation.audit block (evaluate_trace, evaluate_retention_days)
- tests/rules/trace.test.ts: 30 tests

PR 2 — rules explain
- src/rules/explain.ts: loadTraceRecords, loadRelatedAudit, formatExplainText/Json
- rules.ts: trace-explain subcommand (avoids collision with static explain)
- mcp.ts: rules_explain tool
- tests/rules/explain.test.ts: 19 tests

PR 3 — LLM condition
- src/rules/types.ts: LlmCondition interface + isLlmCondition guard; AutomationLlmBudgetConfig
- llm/provider.ts: decide() + DecideResult/DecideOptions on LLMProvider interface
- llm/providers/anthropic.ts: decide() via tool-use API
- llm/providers/openai.ts: decide() via function-calling API
- src/rules/llm-condition.ts: LlmConditionEvaluator (cache, budget, on_error)
  Cache key: sha256(JSON.stringify([ruleVersion, prompt, deepSortedJson(ctx)])) — fixes spec gap #5
- matcher.ts: llm condition branch with provider injection
- schema: llm condition oneOf branch + automation.llm_budget
- engine.ts: 4 lint rules (condition-llm-no-provider, no-cache-ttl-high-freq,
  budget-zero, on-error-pass)
- tests/rules/llm-condition.test.ts: 22 tests

PR 4 — rules simulate
- src/rules/simulate.ts: simulateRule(), as-of state fetcher, against-file replay,
  ThrottleGate simulation, LLM skip marker
- rules.ts: simulate subcommand
- mcp.ts: rules_simulate tool
- capabilities.ts: trace-explain + simulate entries
- tests/rules/simulate.test.ts: 18 tests

Spec gaps fixed:
  #1 canonicalize undefined → deepSortedJson() recursive key-sort
  #5 cache key raw concat → JSON.stringify([...]) structured array

* fix: guard Array.isArray before iterating rules/then in checkNotifyConnectivity

* 3.4.0: AI decision loop — trace, LLM condition, trace-explain, simulate

- automation.audit.evaluate_trace records per-evaluation decisions
- llm: condition type gates rules on LLM yes/no judgement (cache, budget, on_error)
- rules trace-explain: inspect why a rule fired or was blocked
- rules simulate: replay historical events against a rule offline
- MCP: rules_explain + rules_simulate tools
- 2204 tests (+245)

---------

Co-authored-by: chenliuyun <chenliuyun@onero.com>
chenliuyun pushed a commit that referenced this pull request May 23, 2026
Plain copy from sibling repo (per PR #1 deviation rationale — captures
in-flight uncommitted state that subtree split would miss).

Package identity:
- name: @switchbot/openclaw-skill (was @cly-org/switchbot-openclaw-skill)
- version: 0.1.0 (reset for first publish under new scope)
- repo URLs point at OpenWonderLabs/switchbot-openapi-cli
- author block stripped (top-level package.json has author)
- peerDep "@switchbot/openapi-cli": ">=3.7.1" (literal range, not workspace:*)
- files[] adds lib/ (matches PR #1 fix — runtime imports of error-messages.js)

Skipped @switchbot/agent-shared package entirely. The only candidate for
sharing is lib/error-messages.js (~30 lines of static error strings) and
codex's catalog is a strict superset of openclaw's. Bundling or publishing
a third package adds machinery without paying off; revisit if either
catalog grows materially. Documented in spec decision log.

Cleanup:
- codex-plugin: strip personal author/repo metadata, align with openclaw
- README: add OpenClaw integration section, update Quick start callout
- spec: log PR #2 deviations (plain copy, agent-shared skip)

Tests: codex-plugin 31/31, openclaw-skill 29/29, root vitest 2715/2715.
chenliuyun added a commit that referenced this pull request May 23, 2026
* feat(install): add Codex CLI and plugin preflight checks for --agent codex

* feat(install): add codex-checks module with CLI/npm/registration checks

* feat(install): add resolvePluginId shared utility to codex-checks

* feat(doctor): export Check, CHECK_REGISTRY, and runDoctorChecks helper

* feat(install): extend AgentName with codex, add stepRegisterCodexPlugin

* feat(codex): add switchbot codex doctor subcommand

* feat(install): add codex agent path routing to stepRegisterCodexPlugin

* feat(codex): add switchbot codex repair subcommand

* feat(codex): register codex command in program-builder and add COMMAND_META entries

* refactor(codex): extract credentialsPresent helper, share resolveCodexPackageRoot

* feat(codex): hint repair command when doctor reports warn or fail

* docs(spec): codex install paths design — setup subcommand + AGENTS.md + README bootstrap + hook hardening

* docs(spec): fix 4 consistency issues in codex-commands design

* docs(spec): fix 5 remaining consistency issues + add 3 test cases in codex-commands design

* docs(codex): align spec drift on doctor-verify, auth argv, register helper

- doctor-verify in both specs now spells out the same 7-check set
  (4 base + 3 codex), so codex setup and codex repair share semantics.
- auth/re-auth argv documents --profile and --config forwarding via the
  buildAuthLoginArgv helper, matching codex repair re-auth.
- onInstall hook path pinned to packages/codex-plugin/bin/auth.js per
  current .codex-plugin/hooks.json (no more "(or install.js)" wording).
- stepRegisterCodexPlugin / repairStepRegisterPlugin / setup
  register-plugin all reference the shared registerCodexPlugin() helper;
  inline npm root -g and pluginId concat are forbidden.

* fix(codex): tighten install warnings and extract registerCodexPlugin helper

- preflight: --agent codex now fails (was warn) when the npm package is
  missing — this is a register-only command, so a missing package is a
  hard stop, not a soft hint.
- codex-checks: warning text for missing/unregistered plugin now spells
  out the full repair recipe (npm install -g @cly-org/switchbot-codex-plugin
  && switchbot install --agent codex) instead of just the latter half.
- codex-checks: add registerCodexPlugin() helper that wraps
  resolveCodexPackageRoot + resolvePluginId + runCodexPluginRegistration
  with normalized error strings, so the three call sites
  (install --agent codex, codex repair, codex setup) share one path.
- default-steps.stepRegisterCodexPlugin: replace the inlined npm root -g
  + path.join + runCodexPluginRegistration with a single
  registerCodexPlugin() call.
- doctor: export formatDoctorChecks for reuse by codex doctor / repair /
  setup output formatting.

* feat(codex): add codex setup subcommand and route repair through shared helper

- New `switchbot codex setup` subcommand runs five steps:
  check-codex-cli, install-switchbot-cli, register-plugin, auth,
  doctor-verify (4 base + 3 codex = 7 checks). Only install-switchbot-cli
  and auth are skippable; --skip on other steps exits 2.
- codex repair re-auth and codex setup auth share buildAuthLoginArgv,
  which forwards the active --profile and --config to the spawned
  `auth login`. Spawn now uses process.execPath + cliPath
  (process.argv[1]) instead of resolving "switchbot" through PATH.
- repairStepRegisterPlugin replaced with a single registerCodexPlugin()
  call so install --agent codex and codex repair share one
  registration path. The local printDoctorChecks copy is dropped in
  favour of formatDoctorChecks from doctor.ts.
- capabilities: COMMAND_META gains an entry for `codex setup`.

* docs(readme): add Codex integration quick start section

- New ## Codex integration section after Quick start documents the
  full surface: prerequisites, npx-based one-command bootstrap (which
  also explains why the install-switchbot-cli step exists — it pins
  the global install after npx), manual install path, doctor/repair
  flow, --dry-run / --json / --yes / --skip flag matrix, and
  --profile / --config inheritance.
- Quick start gains a one-line callout redirecting Codex users away
  from the --agent claude-code default example.
- Table of contents links the new section.

* docs(spec): monorepo migration design — absorb codex-plugin and openclaw-skill

* docs(spec): fix 3 design issues in monorepo migration — workspace:*, publish gate, grep scope

* feat(monorepo): consolidate codex-plugin via npm workspaces

Enable `"workspaces": ["packages/*"]` and import the Codex plugin from
the sibling `openclaw-switchbot-skill` repo into `packages/codex-plugin/`,
renamed from `@cly-org/switchbot-codex-plugin` to `@switchbot/codex-plugin`
(version reset to 0.1.0; old scope was never published — `npm view` 404).

CLI updates:
- src/install/codex-checks.ts: resolveCodexPackageRoot path now joins
  `@switchbot/codex-plugin`; doctor warning recipes reference the new name.
- src/install/preflight.ts and default-steps.ts: same rename.
- Test mocks updated; default plugin id derived from new dirname is now
  `switchbot@codex-plugin`.

Build / CI:
- Add aggregate scripts: `test:workspaces`, `test:all`, `typecheck:workspaces`,
  `typecheck:all`. Existing root `test`/`typecheck` keep root-only scope
  (preserves pre-commit/pre-push timings).
- ci.yml `test` job now runs `npm test` plus `npm run test:workspaces`.

Plugin import notes:
- Plain copy used instead of `git subtree add`. Sibling working tree had
  uncommitted in-flight test files that subtree split would have skipped.
  History remains in the sibling repo for archival lookup.
- Added `lib/` to `package.json#files`; sibling omitted it but
  `lib/error-messages.js` is a runtime import of `bin/auth.js`.
- Dropped 3 sibling-repo orchestration tests (codex-mcp-config,
  codex-setup, install-scripts) that imported `../../../scripts/*` —
  no analog in this monorepo; superseded by `switchbot codex setup`.

Spec decision log updated with the three execution-time deviations.
Hard checks pass: tarball has concrete peerDep `">=3.7.1"` (not
`workspace:*`), 11 files including `lib/error-messages.js`; scoped grep
for `@cly-org` in src/packages/.github/tests/scripts/README.md returns
zero hits.

* feat(monorepo): import openclaw-skill as workspace package

Plain copy from sibling repo (per PR #1 deviation rationale — captures
in-flight uncommitted state that subtree split would miss).

Package identity:
- name: @switchbot/openclaw-skill (was @cly-org/switchbot-openclaw-skill)
- version: 0.1.0 (reset for first publish under new scope)
- repo URLs point at OpenWonderLabs/switchbot-openapi-cli
- author block stripped (top-level package.json has author)
- peerDep "@switchbot/openapi-cli": ">=3.7.1" (literal range, not workspace:*)
- files[] adds lib/ (matches PR #1 fix — runtime imports of error-messages.js)

Skipped @switchbot/agent-shared package entirely. The only candidate for
sharing is lib/error-messages.js (~30 lines of static error strings) and
codex's catalog is a strict superset of openclaw's. Bundling or publishing
a third package adds machinery without paying off; revisit if either
catalog grows materially. Documented in spec decision log.

Cleanup:
- codex-plugin: strip personal author/repo metadata, align with openclaw
- README: add OpenClaw integration section, update Quick start callout
- spec: log PR #2 deviations (plain copy, agent-shared skip)

Tests: codex-plugin 31/31, openclaw-skill 29/29, root vitest 2715/2715.

* ci(publish): per-package matrix and detect-versions gate for monorepo

Restructure release pipeline now that @switchbot/codex-plugin and
@switchbot/openclaw-skill ship from this repo as workspace packages.

publish.yml: add detect-versions step that queries npm for each package,
publish only unpublished versions, gate plugin tarballs on concrete
peerDeps, set continue-on-error on plugin steps so plugin failures
surface as annotations without blocking CLI promotion.

npm-published-smoke.yml: per-package matrix (cli vs plugin kinds),
skip-if-not-republished logic, plugin tarball-shape checks (peerDep
literal, executable bin entries) without live smoke.

ci.yml: drop policy-schema-sync — the skill consumer is now in this
monorepo so the cross-repo sync gate is obsolete.

CHANGELOG: Unreleased entry documenting monorepo absorption, Codex
command group, plugin renames, and workflow restructure. No version
chosen yet — release decision is separate.

* ci(publish): defer @switchbot/openclaw-skill npm publish

Keep packages/openclaw-skill/ in the monorepo (workspace tests still
cover it) but drop it from publish.yml and npm-published-smoke.yml
matrix. README OpenClaw integration section and CHANGELOG mentions
removed so users do not search for an unpublished npm package.

Re-enabling later only requires re-adding the publish + smoke entries.

* feat(codex): 6-step setup adds install-codex-plugin; onInstall is best-effort

- codex setup: add install-codex-plugin step between install-switchbot-cli
  and register-plugin so npx @switchbot/openapi-cli codex setup bootstraps
  end-to-end on a brand-new machine. Both install steps remain --skip-able.
- codex-plugin onInstall hook: always exits 0. When CLI is present it runs
  switchbot codex setup --yes; when absent it prints a hint and lets the
  Codex plugin install succeed (so a missing CLI never rolls it back).
- README + plugin README: 6-step list, single recommended npx path.
- Specs: align design docs with @SwitchBot scope and 6-step flow.

Tests: +1 root (codex setup install-codex-plugin coverage), +3 workspace
(makeRunOnInstall: missing CLI / setup fail / setup ok). 2716 root +
34 codex-plugin pass.

* fix(codex): junction workaround for @-path; ship corrected marketplace.json

- runCodexPluginRegistration now bridges packageRoot via a junction
  (Windows) or symlink (POSIX) in os.tmpdir() before invoking
  `codex plugin marketplace add`. codex CLI 0.133.0 misparses local
  paths containing `@` (e.g. <npm-root>/@switchbot/codex-plugin) as
  `owner/repo@ref` and rejects them with `--ref is only supported for
  git marketplace sources`. Falls back to the original path if the
  link cannot be created (test mocks, restricted filesystems).
- Track packages/codex-plugin/.agents/plugins/marketplace.json in git
  and add `.agents/` to the package `files` array so it ships in the
  npm tarball. The local source `path` is `../../` so codex resolves
  the plugin manifest at packageRoot/.codex-plugin/plugin.json (was
  `./`, which pointed at the wrong directory).

* test(codex): add pack-install smoke for codex plugin tarball

Pack the CLI and @switchbot/codex-plugin workspaces, install both
into a scratch dir, and assert tarball shape before any release:

- Plugin peerDependency is concrete (not workspace:*).
- Required files ship: .codex-plugin/plugin.json + hooks.json,
  .agents/plugins/marketplace.json, .mcp.json, skills/, bin/.
- marketplace.json name is "codex-plugin"; the switchbot plugin
  source.path is "../../" — codex resolves the path relative to
  .agents/plugins/marketplace.json itself, so it must climb two
  levels back to packageRoot/.codex-plugin/plugin.json.
- onInstall hook runs bin/auth.js --hook and exits 0.
- `switchbot codex setup --dry-run --json` lists the 6 setup steps.

Wired into prepublishOnly so a wrong path, missing files entry, or
broken hook is caught before the release tag.

* fix(codex): stable Windows junction alias; add stage field to RegistrationResult

resolveMarketplaceSourceRoot bridges scoped-npm packageRoots through
~/switchbot before calling `codex plugin marketplace add`. codex 0.133.0
mis-parses paths containing `@` (e.g. <root>\node_modules\@SwitchBot\
codex-plugin) as `owner/repo@ref` and rejects them. A previous attempt
aliased through os.tmpdir/<pid> and deleted the link in a finally —
codex persists the marketplace path, so the alias must outlive the
install. Reuse ~/switchbot when it already points at the same
packageRoot; only create the junction once.

runCodexPluginRegistration now sets stage: 'marketplace-add' |
'plugin-add' so registerCodexPlugin can name the failing codex
subcommand in its error (marketplace-add exit 1: ...). resolvePluginId
splits into resolvePluginName + resolveMarketplaceName so the
marketplace segment of the plugin id reads from .agents/plugins/
marketplace.json instead of basename(packageRoot).

publish.yml gains a smoke:codex-pack-install step (runs when the CLI
or codex-plugin version bumps). Tests cover the stage field, the
not-installed warn path, the marketplace.json name source, and the
Windows alias creation.

* fix(codex): relocate Windows alias to LOCALAPPDATA and repair stale junctions

resolveMarketplaceSourceRoot moves the @-path workaround junction from
~/switchbot to %LOCALAPPDATA%\switchbot\codex-plugin-marketplace
(fallback ~/.switchbot/codex-plugin-marketplace when LOCALAPPDATA is
unset). The previous location collided with user-owned directories
named "switchbot" and silently fell back to the broken @ path on any
mismatch. The new path is app-owned, so we treat divergent junctions
as repairable (unlink + recreate) and throw when a non-junction sits
at the alias path instead of returning a path Codex cannot parse. fs
errors propagate to the caller.

Tests cover four states: missing alias, healthy junction, stale
junction pointing elsewhere, and real-directory collision.

* fix(codex-plugin): mirror alias-relocation logic in install.js

Mirrors the resolveMarketplaceSourceRoot rewrite from the CLI side:
junction lives at %LOCALAPPDATA%\switchbot\codex-plugin-marketplace
(fallback ~/.switchbot/...), divergent junctions are repaired in place,
and a real directory at the alias path throws instead of silently
returning the broken @ path. Adds a deps parameter for fs injection
so node:test can cover all four states without touching the real
filesystem.

* docs(changelog): describe final alias path and repair behavior

* test(openclaw-skill): use 127.0.0.1 in policy-editor fetch URLs

Server listens on 127.0.0.1; on Node 18 (Linux), undici resolves
'localhost' to ::1 first and fails without IPv4 fallback, so CI
test (18.x) reports 'TypeError: fetch failed'. Match the bind
address explicitly to make the tests deterministic across Node
versions.

* fix: publish openclaw-skill and fix Windows .cmd shim invocations

- publish.yml: add openclaw-skill version detection, unpublished check,
  and publish step (continue-on-error, provenance) mirroring codex-plugin
- preflight.ts: add shell: process.platform === 'win32' to the npm
  spawnSync call so .cmd shims are found on Windows (aligns with the
  existing pattern in codex-checks.ts)
- check-cli.js: add shell: SHELL to all execFile/execFileSync calls so
  switchbot.cmd and npm.cmd shims are launched correctly on Windows

* fix(auth): release callback port immediately when open() throws

Expose close() on CallbackHandle so callers can tear down the server on
demand. In browserLogin, wrap open() in try/catch and call close() on
failure — previously the port stayed occupied for the full 120 s timeout
if open() failed (e.g. headless / no-browser environments), blocking
immediate retries with EADDRINUSE.

* fix(codex): pre-remove plugin before add to avoid Windows backup ACCESS_DENIED

codex plugin-add backs up any existing installation before replacing it.
On Windows, if the previously registered plugin path is a junction with
restricted permissions, the backup hits os error 5 (ACCESS_DENIED).
Running plugin-remove first forces a fresh install, bypassing the backup.

* test(codex-checks): add plugin-remove mock slot in registration tests

runCodexPluginRegistration now calls plugin-remove before plugin-add;
update the four affected spawnSync mock sequences to match.

---------

Co-authored-by: chenliuyun <chenliuyun@onero.com>
chenliuyun added a commit that referenced this pull request May 23, 2026
#53)

* feat(install): add Codex CLI and plugin preflight checks for --agent codex

* feat(install): add codex-checks module with CLI/npm/registration checks

* feat(install): add resolvePluginId shared utility to codex-checks

* feat(doctor): export Check, CHECK_REGISTRY, and runDoctorChecks helper

* feat(install): extend AgentName with codex, add stepRegisterCodexPlugin

* feat(codex): add switchbot codex doctor subcommand

* feat(install): add codex agent path routing to stepRegisterCodexPlugin

* feat(codex): add switchbot codex repair subcommand

* feat(codex): register codex command in program-builder and add COMMAND_META entries

* refactor(codex): extract credentialsPresent helper, share resolveCodexPackageRoot

* feat(codex): hint repair command when doctor reports warn or fail

* docs(spec): codex install paths design — setup subcommand + AGENTS.md + README bootstrap + hook hardening

* docs(spec): fix 4 consistency issues in codex-commands design

* docs(spec): fix 5 remaining consistency issues + add 3 test cases in codex-commands design

* docs(codex): align spec drift on doctor-verify, auth argv, register helper

- doctor-verify in both specs now spells out the same 7-check set
  (4 base + 3 codex), so codex setup and codex repair share semantics.
- auth/re-auth argv documents --profile and --config forwarding via the
  buildAuthLoginArgv helper, matching codex repair re-auth.
- onInstall hook path pinned to packages/codex-plugin/bin/auth.js per
  current .codex-plugin/hooks.json (no more "(or install.js)" wording).
- stepRegisterCodexPlugin / repairStepRegisterPlugin / setup
  register-plugin all reference the shared registerCodexPlugin() helper;
  inline npm root -g and pluginId concat are forbidden.

* fix(codex): tighten install warnings and extract registerCodexPlugin helper

- preflight: --agent codex now fails (was warn) when the npm package is
  missing — this is a register-only command, so a missing package is a
  hard stop, not a soft hint.
- codex-checks: warning text for missing/unregistered plugin now spells
  out the full repair recipe (npm install -g @cly-org/switchbot-codex-plugin
  && switchbot install --agent codex) instead of just the latter half.
- codex-checks: add registerCodexPlugin() helper that wraps
  resolveCodexPackageRoot + resolvePluginId + runCodexPluginRegistration
  with normalized error strings, so the three call sites
  (install --agent codex, codex repair, codex setup) share one path.
- default-steps.stepRegisterCodexPlugin: replace the inlined npm root -g
  + path.join + runCodexPluginRegistration with a single
  registerCodexPlugin() call.
- doctor: export formatDoctorChecks for reuse by codex doctor / repair /
  setup output formatting.

* feat(codex): add codex setup subcommand and route repair through shared helper

- New `switchbot codex setup` subcommand runs five steps:
  check-codex-cli, install-switchbot-cli, register-plugin, auth,
  doctor-verify (4 base + 3 codex = 7 checks). Only install-switchbot-cli
  and auth are skippable; --skip on other steps exits 2.
- codex repair re-auth and codex setup auth share buildAuthLoginArgv,
  which forwards the active --profile and --config to the spawned
  `auth login`. Spawn now uses process.execPath + cliPath
  (process.argv[1]) instead of resolving "switchbot" through PATH.
- repairStepRegisterPlugin replaced with a single registerCodexPlugin()
  call so install --agent codex and codex repair share one
  registration path. The local printDoctorChecks copy is dropped in
  favour of formatDoctorChecks from doctor.ts.
- capabilities: COMMAND_META gains an entry for `codex setup`.

* docs(readme): add Codex integration quick start section

- New ## Codex integration section after Quick start documents the
  full surface: prerequisites, npx-based one-command bootstrap (which
  also explains why the install-switchbot-cli step exists — it pins
  the global install after npx), manual install path, doctor/repair
  flow, --dry-run / --json / --yes / --skip flag matrix, and
  --profile / --config inheritance.
- Quick start gains a one-line callout redirecting Codex users away
  from the --agent claude-code default example.
- Table of contents links the new section.

* docs(spec): monorepo migration design — absorb codex-plugin and openclaw-skill

* docs(spec): fix 3 design issues in monorepo migration — workspace:*, publish gate, grep scope

* feat(monorepo): consolidate codex-plugin via npm workspaces

Enable `"workspaces": ["packages/*"]` and import the Codex plugin from
the sibling `openclaw-switchbot-skill` repo into `packages/codex-plugin/`,
renamed from `@cly-org/switchbot-codex-plugin` to `@switchbot/codex-plugin`
(version reset to 0.1.0; old scope was never published — `npm view` 404).

CLI updates:
- src/install/codex-checks.ts: resolveCodexPackageRoot path now joins
  `@switchbot/codex-plugin`; doctor warning recipes reference the new name.
- src/install/preflight.ts and default-steps.ts: same rename.
- Test mocks updated; default plugin id derived from new dirname is now
  `switchbot@codex-plugin`.

Build / CI:
- Add aggregate scripts: `test:workspaces`, `test:all`, `typecheck:workspaces`,
  `typecheck:all`. Existing root `test`/`typecheck` keep root-only scope
  (preserves pre-commit/pre-push timings).
- ci.yml `test` job now runs `npm test` plus `npm run test:workspaces`.

Plugin import notes:
- Plain copy used instead of `git subtree add`. Sibling working tree had
  uncommitted in-flight test files that subtree split would have skipped.
  History remains in the sibling repo for archival lookup.
- Added `lib/` to `package.json#files`; sibling omitted it but
  `lib/error-messages.js` is a runtime import of `bin/auth.js`.
- Dropped 3 sibling-repo orchestration tests (codex-mcp-config,
  codex-setup, install-scripts) that imported `../../../scripts/*` —
  no analog in this monorepo; superseded by `switchbot codex setup`.

Spec decision log updated with the three execution-time deviations.
Hard checks pass: tarball has concrete peerDep `">=3.7.1"` (not
`workspace:*`), 11 files including `lib/error-messages.js`; scoped grep
for `@cly-org` in src/packages/.github/tests/scripts/README.md returns
zero hits.

* feat(monorepo): import openclaw-skill as workspace package

Plain copy from sibling repo (per PR #1 deviation rationale — captures
in-flight uncommitted state that subtree split would miss).

Package identity:
- name: @switchbot/openclaw-skill (was @cly-org/switchbot-openclaw-skill)
- version: 0.1.0 (reset for first publish under new scope)
- repo URLs point at OpenWonderLabs/switchbot-openapi-cli
- author block stripped (top-level package.json has author)
- peerDep "@switchbot/openapi-cli": ">=3.7.1" (literal range, not workspace:*)
- files[] adds lib/ (matches PR #1 fix — runtime imports of error-messages.js)

Skipped @switchbot/agent-shared package entirely. The only candidate for
sharing is lib/error-messages.js (~30 lines of static error strings) and
codex's catalog is a strict superset of openclaw's. Bundling or publishing
a third package adds machinery without paying off; revisit if either
catalog grows materially. Documented in spec decision log.

Cleanup:
- codex-plugin: strip personal author/repo metadata, align with openclaw
- README: add OpenClaw integration section, update Quick start callout
- spec: log PR #2 deviations (plain copy, agent-shared skip)

Tests: codex-plugin 31/31, openclaw-skill 29/29, root vitest 2715/2715.

* ci(publish): per-package matrix and detect-versions gate for monorepo

Restructure release pipeline now that @switchbot/codex-plugin and
@switchbot/openclaw-skill ship from this repo as workspace packages.

publish.yml: add detect-versions step that queries npm for each package,
publish only unpublished versions, gate plugin tarballs on concrete
peerDeps, set continue-on-error on plugin steps so plugin failures
surface as annotations without blocking CLI promotion.

npm-published-smoke.yml: per-package matrix (cli vs plugin kinds),
skip-if-not-republished logic, plugin tarball-shape checks (peerDep
literal, executable bin entries) without live smoke.

ci.yml: drop policy-schema-sync — the skill consumer is now in this
monorepo so the cross-repo sync gate is obsolete.

CHANGELOG: Unreleased entry documenting monorepo absorption, Codex
command group, plugin renames, and workflow restructure. No version
chosen yet — release decision is separate.

* ci(publish): defer @switchbot/openclaw-skill npm publish

Keep packages/openclaw-skill/ in the monorepo (workspace tests still
cover it) but drop it from publish.yml and npm-published-smoke.yml
matrix. README OpenClaw integration section and CHANGELOG mentions
removed so users do not search for an unpublished npm package.

Re-enabling later only requires re-adding the publish + smoke entries.

* feat(codex): 6-step setup adds install-codex-plugin; onInstall is best-effort

- codex setup: add install-codex-plugin step between install-switchbot-cli
  and register-plugin so npx @switchbot/openapi-cli codex setup bootstraps
  end-to-end on a brand-new machine. Both install steps remain --skip-able.
- codex-plugin onInstall hook: always exits 0. When CLI is present it runs
  switchbot codex setup --yes; when absent it prints a hint and lets the
  Codex plugin install succeed (so a missing CLI never rolls it back).
- README + plugin README: 6-step list, single recommended npx path.
- Specs: align design docs with @SwitchBot scope and 6-step flow.

Tests: +1 root (codex setup install-codex-plugin coverage), +3 workspace
(makeRunOnInstall: missing CLI / setup fail / setup ok). 2716 root +
34 codex-plugin pass.

* fix(codex): junction workaround for @-path; ship corrected marketplace.json

- runCodexPluginRegistration now bridges packageRoot via a junction
  (Windows) or symlink (POSIX) in os.tmpdir() before invoking
  `codex plugin marketplace add`. codex CLI 0.133.0 misparses local
  paths containing `@` (e.g. <npm-root>/@switchbot/codex-plugin) as
  `owner/repo@ref` and rejects them with `--ref is only supported for
  git marketplace sources`. Falls back to the original path if the
  link cannot be created (test mocks, restricted filesystems).
- Track packages/codex-plugin/.agents/plugins/marketplace.json in git
  and add `.agents/` to the package `files` array so it ships in the
  npm tarball. The local source `path` is `../../` so codex resolves
  the plugin manifest at packageRoot/.codex-plugin/plugin.json (was
  `./`, which pointed at the wrong directory).

* test(codex): add pack-install smoke for codex plugin tarball

Pack the CLI and @switchbot/codex-plugin workspaces, install both
into a scratch dir, and assert tarball shape before any release:

- Plugin peerDependency is concrete (not workspace:*).
- Required files ship: .codex-plugin/plugin.json + hooks.json,
  .agents/plugins/marketplace.json, .mcp.json, skills/, bin/.
- marketplace.json name is "codex-plugin"; the switchbot plugin
  source.path is "../../" — codex resolves the path relative to
  .agents/plugins/marketplace.json itself, so it must climb two
  levels back to packageRoot/.codex-plugin/plugin.json.
- onInstall hook runs bin/auth.js --hook and exits 0.
- `switchbot codex setup --dry-run --json` lists the 6 setup steps.

Wired into prepublishOnly so a wrong path, missing files entry, or
broken hook is caught before the release tag.

* fix(codex): stable Windows junction alias; add stage field to RegistrationResult

resolveMarketplaceSourceRoot bridges scoped-npm packageRoots through
~/switchbot before calling `codex plugin marketplace add`. codex 0.133.0
mis-parses paths containing `@` (e.g. <root>\node_modules\@SwitchBot\
codex-plugin) as `owner/repo@ref` and rejects them. A previous attempt
aliased through os.tmpdir/<pid> and deleted the link in a finally —
codex persists the marketplace path, so the alias must outlive the
install. Reuse ~/switchbot when it already points at the same
packageRoot; only create the junction once.

runCodexPluginRegistration now sets stage: 'marketplace-add' |
'plugin-add' so registerCodexPlugin can name the failing codex
subcommand in its error (marketplace-add exit 1: ...). resolvePluginId
splits into resolvePluginName + resolveMarketplaceName so the
marketplace segment of the plugin id reads from .agents/plugins/
marketplace.json instead of basename(packageRoot).

publish.yml gains a smoke:codex-pack-install step (runs when the CLI
or codex-plugin version bumps). Tests cover the stage field, the
not-installed warn path, the marketplace.json name source, and the
Windows alias creation.

* fix(codex): relocate Windows alias to LOCALAPPDATA and repair stale junctions

resolveMarketplaceSourceRoot moves the @-path workaround junction from
~/switchbot to %LOCALAPPDATA%\switchbot\codex-plugin-marketplace
(fallback ~/.switchbot/codex-plugin-marketplace when LOCALAPPDATA is
unset). The previous location collided with user-owned directories
named "switchbot" and silently fell back to the broken @ path on any
mismatch. The new path is app-owned, so we treat divergent junctions
as repairable (unlink + recreate) and throw when a non-junction sits
at the alias path instead of returning a path Codex cannot parse. fs
errors propagate to the caller.

Tests cover four states: missing alias, healthy junction, stale
junction pointing elsewhere, and real-directory collision.

* fix(codex-plugin): mirror alias-relocation logic in install.js

Mirrors the resolveMarketplaceSourceRoot rewrite from the CLI side:
junction lives at %LOCALAPPDATA%\switchbot\codex-plugin-marketplace
(fallback ~/.switchbot/...), divergent junctions are repaired in place,
and a real directory at the alias path throws instead of silently
returning the broken @ path. Adds a deps parameter for fs injection
so node:test can cover all four states without touching the real
filesystem.

* docs(changelog): describe final alias path and repair behavior

* test(openclaw-skill): use 127.0.0.1 in policy-editor fetch URLs

Server listens on 127.0.0.1; on Node 18 (Linux), undici resolves
'localhost' to ::1 first and fails without IPv4 fallback, so CI
test (18.x) reports 'TypeError: fetch failed'. Match the bind
address explicitly to make the tests deterministic across Node
versions.

* fix: publish openclaw-skill and fix Windows .cmd shim invocations

- publish.yml: add openclaw-skill version detection, unpublished check,
  and publish step (continue-on-error, provenance) mirroring codex-plugin
- preflight.ts: add shell: process.platform === 'win32' to the npm
  spawnSync call so .cmd shims are found on Windows (aligns with the
  existing pattern in codex-checks.ts)
- check-cli.js: add shell: SHELL to all execFile/execFileSync calls so
  switchbot.cmd and npm.cmd shims are launched correctly on Windows

* fix(auth): release callback port immediately when open() throws

Expose close() on CallbackHandle so callers can tear down the server on
demand. In browserLogin, wrap open() in try/catch and call close() on
failure — previously the port stayed occupied for the full 120 s timeout
if open() failed (e.g. headless / no-browser environments), blocking
immediate retries with EADDRINUSE.

* fix(codex): pre-remove plugin before add to avoid Windows backup ACCESS_DENIED

codex plugin-add backs up any existing installation before replacing it.
On Windows, if the previously registered plugin path is a junction with
restricted permissions, the backup hits os error 5 (ACCESS_DENIED).
Running plugin-remove first forces a fresh install, bypassing the backup.

* test(codex-checks): add plugin-remove mock slot in registration tests

runCodexPluginRegistration now calls plugin-remove before plugin-add;
update the four affected spawnSync mock sequences to match.

* docs(readme): simplify Codex install section for non-technical users

Remove internal step names, flag details, and profile-scope docs from the
Codex integration section. The section now leads with a single npx command,
a plain-English description of what it does, and a brief troubleshooting
block. Advanced options are deferred to --help.

chore(publish): remove openclaw-skill from npm publish workflow

The openclaw-skill package is distributed via the companion OpenClaw repo and
does not need its own npm publish step in this workflow.

* docs(readme): split Codex section into paste-to-chat and developer blocks

---------

Co-authored-by: chenliuyun <chenliuyun@onero.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant