diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b1af4a4106..42e714dadd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -349,6 +349,8 @@ jobs:
src/process/ProcessOwnership.part2.test.ts
src/process/ProcessOwnership.part3.test.ts
src/process/ProcessOwnership.validation.test.ts
+ src/application/Update.test.ts
+ tests/process-global/updateMaintenance.test.ts
tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts
- run: npm run verify:package:windows
- uses: actions/upload-artifact@v4
diff --git a/README.md b/README.md
index 13413c78f3..41795389c5 100644
--- a/README.md
+++ b/README.md
@@ -102,7 +102,7 @@ Set up REA with your agent:
npx rea-agents setup
```
-Choose **Agent Integration**, select your agents, then review and approve the plan. Setup configures MCP access and installs REA's guided workflow. It can connect an existing Hopper or Ghidra installation, or offer to install Hopper if you need it.
+Choose which supported agents should use REA, then review the exact paths and changes before approving. Existing REA registrations are selected by default; newly detected agents are available to select, but detection alone does not select them. Setup adds MCP access and REA's guided workflow for selected agents. Hopper is a separate optional choice with its own consent. Setup can also record an existing Ghidra installation.
Setup shows its changes before applying them and backs up existing configuration. See [Installation and setup](docs/installation.md) for requirements and setup options.
@@ -110,7 +110,7 @@ Setup shows its changes before applying them and backs up existing configuration
After setup, restart your agent and [describe the app or feature](#just-ask-your-agent) you want to understand. Hopper can run in demo mode; if it shows a first-run prompt, choose the demo or enter an existing license.
-REA can configure Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, and Windsurf. It detects Devin but does not configure it. Other agents can use the [manual MCP configuration](#manual-mcp-configuration).
+REA supports Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, Devin, OpenCode, Antigravity, GitHub Copilot CLI, and VS Code. Existing REA registrations are selected by default during setup; other detected agents remain unselected until chosen. Other agents can use the [manual MCP configuration](#manual-mcp-configuration).
### From the terminal with npx
@@ -138,7 +138,7 @@ npm install --global rea-agents
rea setup
```
-Update either installation with `rea upgrade`.
+Update either installation with `rea update`.
### Requirements
@@ -411,7 +411,7 @@ See [provider evaluation](docs/provider-evaluation.md) for coverage and remainin
## Using REA with other agents
-Setup detects Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, and Devin. It automatically configures the first six when present; detected Devin installations are reported but left unchanged. Any agent that supports local MCP servers can use REA with the configuration below.
+Setup offers supported agent integrations for selection. Existing REA registrations are selected by default; newly detected agents remain unselected until chosen. Any agent that supports local MCP servers can use the configuration below.
### Manual MCP configuration
@@ -431,11 +431,11 @@ Setup detects Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf,
Persistent registrations should use one exact package version. `rea setup`
-maintains that pin, upgrades the bundled skill at the same time, and gives Codex
-a 30-second startup allowance for a cold package-runner start. An interactive
-`rea upgrade` opens the updated setup plan after installing the new executable;
-structured or non-interactive upgrades tell you to run that sync explicitly.
-Restart clients whose MCP registration changed.
+maintains that pin, updates the bundled skill at the same time, and gives Codex
+a 30-second startup allowance for a cold package-runner start. `rea update`
+installs an exact release and verifies the new executable. It returns an
+unapplied maintenance plan for existing REA integrations, with a scoped setup
+command to review and approve their changes. Restart affected agents afterward.
MCP clients that support prompts can also discover six ordered investigation
workflows through `prompts/list`. Their optional identifier arguments use the
@@ -491,7 +491,7 @@ Or install the `rea` command globally:
```bash
npm install --global rea-agents
rea --help
-rea upgrade
+rea update
rea mcp
```
@@ -645,7 +645,7 @@ No decompiler can guarantee the original source. REA gives an agent pseudocode,
Which agents can use REA?
-Any agent that can run a local MCP server can use the manual configuration. Setup detects Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, and Devin; it automatically configures the first six when present and reports Devin without modifying it.
+Any agent that can run a local MCP server can use the manual configuration. Setup offers the supported integrations listed in [Installation and setup](docs/installation.md#supported-agents); existing REA registrations are selected by default, and other detected agents require selection.
diff --git a/README_ar.md b/README_ar.md
index 2b2bdbddeb..ab2fffbcde 100644
--- a/README_ar.md
+++ b/README_ar.md
@@ -86,13 +86,13 @@
npx rea-agents setup
```
-اختر **Agent Integration**، وحدد الوكلاء الذين تريد إعدادهم، ثم راجع الخطة ووافق عليها. يضبط Setup اتصال MCP ويثبت سير عمل الاستقصاء. يمكنه استخدام تثبيت موجود من Hopper أو Ghidra، أو اقتراح تثبيت Hopper.
+يعرض Setup أولاً قائمة متعددة الاختيار للوكلاء الذين تريد ربطهم. تكون تسجيلات REA الموجودة محددة افتراضياً، ولا يؤدي اكتشاف عميل جديد وحده إلى تحديده؛ ويمكنك اختيار عميل غير مهيأ. راجع المسارات والتغييرات المحددة قبل الموافقة. يثبت Setup سير عمل REA للوكلاء المحددين افتراضياً. Hopper خيار منفصل يتطلب موافقة مستقلة، ويمكن أيضاً حفظ مسارات تثبيت Ghidra الموجودة.
يعرض التغييرات قبل تطبيقها وينشئ نسخة احتياطية من الإعدادات الموجودة. راجع [دليل التثبيت والإعداد](docs/installation.md) للمتطلبات والخيارات الإضافية.
### الاستخدام مع وكيل
-بعد الإعداد، أعد تشغيل الوكيل واشرح التطبيق أو الميزة التي تريد استقصاءها. يستطيع REA إعداد Claude Code وClaude Desktop وCodex وCursor وGemini CLI وWindsurf. يكتشف Devin فقط ولا يضبط إعداداته. يمكن للوكلاء الآخرين استخدام إعداد MCP أدناه.
+بعد الإعداد، أعد تشغيل الوكيل واشرح التطبيق أو الميزة التي تريد استقصاءها. يدعم REA Claude Code وClaude Desktop وCodex وCursor وGemini CLI وWindsurf وDevin وOpenCode وAntigravity وGitHub Copilot CLI وVS Code. تكون تسجيلات REA الموجودة محددة افتراضياً، ويجب اختيار العملاء المكتشفين الآخرين يدوياً. يمكن للوكلاء الآخرين استخدام إعداد MCP أدناه.
يمكن تشغيل Hopper في الوضع التجريبي. إذا ظهرت رسالة عند التشغيل الأول، فاختر التجربة أو أدخل ترخيصًا موجودًا.
@@ -144,7 +144,7 @@ rea setup
### التحديث والإزالة
-- يحدّث `rea upgrade` تثبيت REA الحالي.
+- يحدّث `rea update` تثبيت REA الحالي.
- يزيل `rea uninstall` تسجيلات الوكلاء وملفات سير العمل التي يديرها REA، ويترك Hopper مثبتًا.
- يزيل `rea uninstall --purge-data` أيضًا ذاكرة REA المؤقتة وحالته. استخدمه فقط إذا أردت حذف هذه البيانات.
@@ -219,7 +219,7 @@ rea setup
## استخدام REA مع وكلاء برمجة آخرين
-يستطيع Setup إعداد Claude Code وClaude Desktop وCodex وCursor وGemini CLI وWindsurf. يكتشف Devin فقط ولا يغيّر إعداداته. يمكن لأي وكيل يدعم خوادم MCP المحلية الاتصال باستخدام الإعداد التالي:
+يدعم Setup Claude Code وClaude Desktop وCodex وCursor وGemini CLI وWindsurf وDevin وOpenCode وAntigravity وGitHub Copilot CLI وVS Code. تكون تسجيلات REA الموجودة محددة افتراضياً، ويجب اختيار العملاء المكتشفين الآخرين يدوياً. يمكن لأي وكيل يدعم خوادم MCP المحلية الاتصال باستخدام الإعداد التالي:
diff --git a/README_ja.md b/README_ja.md
index 27538f3787..64fea31cd6 100644
--- a/README_ja.md
+++ b/README_ja.md
@@ -86,13 +86,13 @@ REA をエージェントで使うための設定を行います:
npx rea-agents setup
```
-**Agent Integration** を選び、設定するエージェントを選択して、計画を確認・承認します。Setup は MCP 接続と REA の調査ワークフローを設定します。既存の Hopper または Ghidra に接続でき、Hopper がなければインストールを提案できます。
+Setup は最初に連携するエージェントを複数選択できるようにします。既存の REA 登録は初期選択されますが、検出されただけのクライアントは自動選択されず、未設定のクライアントも選べます。具体的なパスと変更内容を確認してから承認してください。選択したエージェントには通常 REA のワークフローをインストールします。Hopper は別の任意操作で、個別の承認が必要です。既存の Ghidra のパスも登録できます。
変更は事前に表示され、既存の設定はバックアップされます。要件と追加オプションは[インストールガイド](docs/installation.md)を参照してください。
### エージェントで使う
-設定後にエージェントを再起動し、調べたいアプリや機能を説明します。REA は Claude Code、Claude Desktop、Codex、Cursor、Gemini CLI、Windsurf を設定できます。Devin は検出のみです。その他のエージェントは下記の MCP 設定を使えます。
+設定後にエージェントを再起動し、調べたいアプリや機能を説明します。REA は Claude Code、Claude Desktop、Codex、Cursor、Gemini CLI、Windsurf、Devin、OpenCode、Antigravity、GitHub Copilot CLI、VS Code に対応します。既存の REA 登録は初期選択され、それ以外の検出済みクライアントは選択が必要です。その他のエージェントは下記の MCP 設定を使えます。
Hopper はデモモードで使えます。初回起動の画面ではデモを選択するか、既存のライセンスを入力してください。
@@ -144,7 +144,7 @@ Linux の既定ランチャーは `/opt/hopper/bin/Hopper` です。別の場所
### 更新とアンインストール
-- `rea upgrade` は現在の REA インストールを更新します。
+- `rea update` は現在の REA インストールを更新します。
- `rea uninstall` は REA が管理するエージェント登録とワークフローファイルを削除します。Hopper は残ります。
- `rea uninstall --purge-data` は REA のキャッシュと状態も削除します。それらを削除したい場合にだけ使ってください。
@@ -208,7 +208,7 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
## 他のコーディングエージェントで使う
-Setup は Claude Code、Claude Desktop、Codex、Cursor、Gemini CLI、Windsurf を設定できます。Devin は検出結果のみを報告します。ローカル MCP サーバーに対応するエージェントは、次の設定でも接続できます。
+Setup は Claude Code、Claude Desktop、Codex、Cursor、Gemini CLI、Windsurf、Devin、OpenCode、Antigravity、GitHub Copilot CLI、VS Code に対応します。既存の REA 登録は初期選択され、それ以外の検出済みクライアントは選択が必要です。ローカル MCP サーバーに対応するエージェントは、次の設定でも接続できます。
diff --git a/README_ko.md b/README_ko.md
index 353c67b3fe..a70850270e 100644
--- a/README_ko.md
+++ b/README_ko.md
@@ -87,13 +87,13 @@ REA를 에이전트에 연결하세요.
npx rea-agents setup
```
-**Agent Integration**을 선택하고 설정할 에이전트를 고른 뒤 계획을 검토하고 승인하세요. Setup은 MCP 연결과 REA의 조사 워크플로를 설정합니다. 기존 Hopper나 Ghidra에 연결하거나 Hopper 설치를 제안할 수 있습니다.
+Setup은 먼저 연결할 에이전트를 여러 개 선택하도록 안내합니다. 기존 REA 등록은 기본 선택되며, 감지만 된 클라이언트는 자동 선택되지 않습니다. 아직 설정되지 않은 클라이언트도 직접 선택할 수 있습니다. 정확한 경로와 변경 사항을 검토한 뒤 승인하세요. 선택한 에이전트에는 기본적으로 REA 워크플로가 설치됩니다. Hopper는 별도의 선택 사항이며 따로 승인해야 합니다. 기존 Ghidra 경로도 등록할 수 있습니다.
변경 전에 계획을 표시하고 기존 설정을 백업합니다. 요구 사항과 추가 옵션은 [설치 안내](docs/installation.md)를 참고하세요.
### 에이전트에서 사용하기
-설정 후 에이전트를 다시 시작하고 조사할 앱이나 기능을 설명하세요. REA는 Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf를 설정할 수 있습니다. Devin은 감지만 합니다. 다른 에이전트는 아래 MCP 설정을 사용할 수 있습니다.
+설정 후 에이전트를 다시 시작하고 조사할 앱이나 기능을 설명하세요. REA는 Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, Devin, OpenCode, Antigravity, GitHub Copilot CLI, VS Code를 지원합니다. 기존 REA 등록은 기본 선택되고, 그 밖의 감지된 클라이언트는 직접 선택해야 합니다. 다른 에이전트는 아래 MCP 설정을 사용할 수 있습니다.
Hopper는 데모 모드로 사용할 수 있습니다. 첫 실행 안내가 나오면 데모를 선택하거나 기존 라이선스를 입력하세요.
@@ -145,7 +145,7 @@ Linux의 기본 Hopper 실행 파일은 `/opt/hopper/bin/Hopper`입니다. 다
### 업데이트와 제거
-- `rea upgrade`는 현재 REA 설치를 업데이트합니다.
+- `rea update`는 현재 REA 설치를 업데이트합니다.
- `rea uninstall`은 REA가 관리하는 에이전트 등록과 워크플로 파일을 제거합니다. Hopper는 유지합니다.
- `rea uninstall --purge-data`는 REA 캐시와 상태도 삭제합니다. 해당 데이터를 지우려는 경우에만 사용하세요.
@@ -209,7 +209,7 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
## 다른 코딩 에이전트에서 사용하기
-Setup은 Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf를 설정할 수 있습니다. Devin은 감지 결과만 보고합니다. 로컬 MCP 서버를 지원하는 에이전트는 다음 설정으로 연결할 수 있습니다.
+Setup은 Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, Devin, OpenCode, Antigravity, GitHub Copilot CLI, VS Code를 지원합니다. 기존 REA 등록은 기본 선택되고, 그 밖의 감지된 클라이언트는 직접 선택해야 합니다. 로컬 MCP 서버를 지원하는 에이전트는 다음 설정으로 연결할 수 있습니다.
diff --git a/README_zh.md b/README_zh.md
index 457a7a59be..29e85b9b1d 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -86,13 +86,13 @@ REA 让调查始终以二进制证据为依据。它不会声称能恢复原始
npx rea-agents setup
```
-选择 **Agent Integration**,勾选要配置的智能体,然后查看并批准计划。Setup 会配置 MCP 访问并安装 REA 的调查工作流。它可以连接现有的 Hopper 或 Ghidra,也可以提议安装 Hopper。
+Setup 首先让你多选要连接的智能体。已有 REA 注册默认选中;仅被检测到的客户端不会自动选中,也可以手动选择尚未配置的客户端。查看具体路径和变更后再批准。所选智能体默认安装 REA 工作流;Hopper 是单独的可选操作,需要单独批准。Setup 也可以记录现有 Ghidra 的路径。
Setup 会先展示变更并备份已有配置。要求与更多选项见[安装与设置](docs/installation.md)。
### 使用智能体
-设置完成后,重启智能体并描述你想了解的应用或功能。REA 可以配置 Claude Code、Claude Desktop、Codex、Cursor、Gemini CLI 和 Windsurf。Devin 只会被检测,不会被自动配置;其他智能体可以使用下方的 MCP 配置。
+设置完成后,重启智能体并描述你想了解的应用或功能。REA 支持 Claude Code、Claude Desktop、Codex、Cursor、Gemini CLI、Windsurf、Devin、OpenCode、Antigravity、GitHub Copilot CLI 和 VS Code。已有 REA 注册默认选中;其他检测到的客户端需手动选择。其他智能体可以使用下方的 MCP 配置。
Hopper 支持演示模式。如果首次启动时出现提示,选择演示模式或输入已有许可证。
@@ -144,7 +144,7 @@ Linux 上的默认 Hopper 启动器为 `/opt/hopper/bin/Hopper`。其他路径
### 更新与卸载
-- `rea upgrade` 更新当前 REA 安装。
+- `rea update` 更新当前 REA 安装。
- `rea uninstall` 移除 REA 管理的智能体配置项和工作流文件,保留 Hopper。
- `rea uninstall --purge-data` 还会删除 REA 的缓存和状态。仅在需要移除这些数据时使用。
@@ -208,7 +208,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
## 与其他编程智能体一起使用
-Setup 可以配置 Claude Code、Claude Desktop、Codex、Cursor、Gemini CLI 和 Windsurf。Devin 只会被检测,不会被修改。任何支持本地 MCP 服务器的智能体都可以使用以下配置连接 REA。
+Setup 支持 Claude Code、Claude Desktop、Codex、Cursor、Gemini CLI、Windsurf、Devin、OpenCode、Antigravity、GitHub Copilot CLI 和 VS Code。已有 REA 注册默认选中;其他检测到的客户端需手动选择。任何支持本地 MCP 服务器的智能体都可以使用以下配置连接 REA。
diff --git a/docs/installation.md b/docs/installation.md
index ad699578c4..4da38e241e 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -61,21 +61,41 @@ curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh |
Supported options are `--version `, `--dry-run`, `--no-setup`, `--no-prompt`, and `--verbose`. Neither `--no-prompt` nor a non-interactive shell grants permission to install external dependencies.
+## Supported agents
+
+Setup can configure these clients for REA's local MCP server:
+
+| Client | `--client` value |
+| ------------------ | ---------------- |
+| Claude Code | `claude_code` |
+| Claude Desktop | `claude_desktop` |
+| Codex | `codex` |
+| Cursor | `cursor` |
+| Gemini CLI | `gemini_cli` |
+| Windsurf | `windsurf` |
+| Devin | `devin` |
+| OpenCode | `opencode` |
+| Antigravity | `antigravity` |
+| GitHub Copilot CLI | `copilot_cli` |
+| VS Code | `vscode` |
+
## Review setup changes
-`rea setup` detects your agents and analysis tools, then asks what to configure:
+`rea setup` first offers the supported agents in a multi-select. Existing REA
+registrations are selected by default. Newly detected clients remain available
+but unselected: detection gives setup context, not permission to add a new
+registration. Clients without a detected configuration can still be selected.
+Explicit `--client` flags skip this question.
-1. Choose **Agent integration** to install the guided workflow and configure MCP
- access. Select the agents that should receive a registration. Detected agents
- are selected by default; deselect any you do not want to change.
-2. Select **Hopper** if you want setup to connect or install it. Setup can also
- save verified paths for an existing Ghidra installation.
-3. Review the plan and approve it. The approval prompt defaults to **Yes**, but
- you must confirm before REA applies changes.
+Setup adds MCP access for selected clients. It installs REA's bundled workflow
+with those integrations by default; use `--skill=false` to omit it. If no agent
+is selected, setup offers the workflow separately for CLI use, with No as the
+default. Hopper is a separate optional choice: setup shows its proposed
+installation or connection and requires its own explicit approval. It can also
+save verified paths for an existing Ghidra installation.
-Selecting agent integration installs the bundled workflow even if you deselect
-all agents. Choosing no capabilities exits without changes. You can cancel at
-any prompt.
+After selection, review the plan's exact paths and changes and approve before
+REA writes files or installs Hopper. You can cancel at any prompt.
Before applying changes, REA checks your current configuration. The plan lists:
@@ -86,18 +106,19 @@ Before applying changes, REA checks your current configuration. The plan lists:
commands.
Malformed or unsafe existing configuration blocks the whole transaction before
-Hopper installation or any file write. Declining, pressing Ctrl-C, or selecting
-nothing makes no changes. Agent configuration writes preserve unrelated
+Hopper installation or any file write. Declining or pressing Ctrl-C makes no
+changes. Agent configuration writes preserve unrelated
entries, create backups, use atomic replacement, and verify their result.
After setup, REA reports which agents, analysis tools, and workflow files passed
its final checks. Restart any agent named in the completion message, then begin
your investigation. Failed steps and diagnostics remain in terminal history.
-Select exact clients in scripts with repeatable `--client` flags, or retain
-automatic discovery explicitly with `--all-detected`. Use `--skill=false` to
-override the normal bundled-skill installation and `--dry-run` for a read-only
-plan:
+Select exact clients in scripts with repeatable `--client` flags. Each explicit
+client skips interactive selection. Use `--all-detected` only when you intend
+to configure every detected supported client. `--skill=false` omits the
+workflow, and `--dry-run` returns a read-only plan with status `planned` and
+exit code 0:
```bash
rea setup --client codex --client cursor --skill=false --dry-run
@@ -105,13 +126,20 @@ rea setup --client codex --client cursor --skill=false --dry-run
Prompt UI and progress are written to stderr so stdout remains available for
structured results and pipelines. `NO_COLOR=1` disables color. Use
-`--accessible` for sequential, vertically rendered yes/no prompts.
+`--accessible` for sequential, vertically rendered yes/no prompts. Implicit
+interactive setup requires stdin, stdout, and stderr to all be terminals; when
+any stream is redirected, setup stays non-interactive. Declining or cancelling
+returns status `cancelled` with exit code 0.
-For automation, `rea setup --json` reports the plan without applying it.
-Prefer pairing `--yes` with explicit scope such as `--client codex`,
-or `--all-detected`. Legacy unscoped `--yes` remains compatible for
-this release but emits a deprecation warning. Installing missing Hopper
-non-interactively additionally requires `--install-hopper`:
+For automation, `rea setup --json` reports the plan and a compact `.doctor`
+readiness projection without applying it. Use `rea doctor --json` for full
+health diagnostics and canonical tool catalog details. Pair
+`--yes` with explicit scope such as `--client codex`, or use
+`--all-detected` when the broad scope is intended. Without a scope flag,
+`--yes` is limited to existing REA-owned registrations; it does not select all
+detected clients. An unapproved actual apply reports `needs_confirmation` and
+exits 1. Installing missing Hopper non-interactively additionally requires
+`--install-hopper`:
```bash
rea setup --yes --all-detected --install-hopper --json
@@ -119,11 +147,14 @@ rea setup --yes --all-detected --install-hopper --json
Setup pins package-runner MCP registrations to the exact installed REA version,
installs the matching skill and on-demand references in the same plan, and adds
-`startup_timeout_sec = 30` for Codex. Interactive `rea upgrade` installs the new
-executable and then opens the updated `rea setup --all-detected` plan for
-separate approval. Structured or non-TTY upgrades defer that integration sync.
-Use the same setup command to migrate floating, unversioned, or stale
-registrations, then restart changed clients.
+`startup_timeout_sec = 30` for Codex. `rea update` installs the exact resolved
+release into the npm prefix that owns the running package, then checks the new
+executable's version before reporting success. It does not reopen onboarding.
+Release lookup and installation both use npm's configured registry.
+Its maintenance plan selects only existing REA registrations and an already
+installed REA skill. Run the returned scoped setup command to review and approve
+those changes, then restart affected agents. The plan is returned in terminal,
+non-TTY, and JSON modes without applying configuration changes.
## Hopper
@@ -216,10 +247,12 @@ rea doctor --json
rea providers --json
```
-`rea setup` does not mutate Windows client configuration or install Hopper,
-Ghidra, Java, Python, or another package. Manual MCP registration does not
-enable the blocked Ghidra operations. See [Windows Ghidra P0](windows-ghidra-p0.md)
-for diagnostics and the proposed registration format.
+`rea setup` can configure supported Windows agents and install the bundled
+REA skill after approval. Direct registrations use Node to launch REA's entry
+script; package-runner registrations use the pinned `npx` command. Hopper
+installation remains unavailable on Windows. Setup never installs Ghidra,
+Java, or Python, and agent registration does not enable blocked Ghidra
+operations. See [Windows Ghidra P0](windows-ghidra-p0.md) for provider diagnostics.
Doctor validates the platform, architecture, application version,
`support/analyzeHeadless` or `support/analyzeHeadless.bat`, Java
@@ -268,7 +301,7 @@ and complete runtime cleanup.
## Diagnose, update, and remove
-`rea doctor --json` is strictly read-only. `rea upgrade` updates only the npm installation that owns the running CLI. `rea uninstall` removes only REA-owned agent registrations and skill files; `--purge-data` additionally removes REA cache and state paths.
+`rea doctor --json` is strictly read-only. `rea update` updates only the npm installation that owns the running CLI. Source checkouts and package-runner copies must be updated through the mechanism that owns them; a fresh package-runner invocation can use `npx rea-agents@latest`. `rea uninstall` removes only REA-owned agent registrations and skill files; `--purge-data` additionally removes REA cache and state paths.
## MCP Registry
diff --git a/docs/product-catalog.json b/docs/product-catalog.json
index 3dece5572c..a70ec0956f 100644
--- a/docs/product-catalog.json
+++ b/docs/product-catalog.json
@@ -430,8 +430,32 @@
{
"id": "devin",
"display_name": "Devin",
- "format": "unsupported",
- "configuration": "detect-only"
+ "format": "json",
+ "configuration": "managed"
+ },
+ {
+ "id": "opencode",
+ "display_name": "OpenCode",
+ "format": "opencode",
+ "configuration": "managed"
+ },
+ {
+ "id": "antigravity",
+ "display_name": "Antigravity",
+ "format": "json",
+ "configuration": "managed"
+ },
+ {
+ "id": "copilot_cli",
+ "display_name": "GitHub Copilot CLI",
+ "format": "copilot_cli",
+ "configuration": "managed"
+ },
+ {
+ "id": "vscode",
+ "display_name": "VS Code",
+ "format": "vscode",
+ "configuration": "managed"
}
],
"cli": {
@@ -503,7 +527,7 @@
"trace-native-ui-action",
"trace-native-values",
"uninstall",
- "upgrade",
+ "update",
"verify-managed-native-boundaries",
"xrefs"
],
@@ -523,7 +547,7 @@
"digests": {
"tools_sha256": "e14cff8d23b94644dda1c1d0c28dbcf3e8140f05daa06f4bd5f1bde1f1e2ec9b",
"prompts_sha256": "4762db348821fec787a5b8c6423f2c48a71f6f0ec2afee97b16ad69030ec504b",
- "combined_sha256": "93e02e0675739b3049b010eabe96745957e29267759a7028a66057677d1f4d92",
+ "combined_sha256": "37bbb06484eee3010b083c66fdb38e3f90c770d0b9bccb603e25078538635790",
"providers_sha256": "2ae8b85b20be338739e500549d275e78e875aaa10235d2ca283f0ade7787da90"
}
}
diff --git a/docs/verification/managed-conformance-manifest.json b/docs/verification/managed-conformance-manifest.json
index ef6d7faaed..1ad57463bb 100644
--- a/docs/verification/managed-conformance-manifest.json
+++ b/docs/verification/managed-conformance-manifest.json
@@ -1,5 +1,5 @@
{
- "manifest_id": "ecm_77a62ada2f566c17638950d2c7339364f131d0b384264204a7f8d89a9f90068f",
+ "manifest_id": "ecm_333150d508ef63182ea3c8caed5abb0a360497e977d47bf7c7cf8eaa5d29da6d",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
- "sha256": "ec130bedea71c202213535025cf32e9943ef309326ad29a55da511cab5f7ad45"
+ "sha256": "2e36435a6234f09024fb43dd17b8a32c813876e1105dde8a675ae647597ad48d"
}
],
"claims": [
diff --git a/docs/windows-ghidra-p0.md b/docs/windows-ghidra-p0.md
index dff18699f9..25a056ba7e 100644
--- a/docs/windows-ghidra-p0.md
+++ b/docs/windows-ghidra-p0.md
@@ -48,7 +48,9 @@ rea inspect "C:\approved-fixtures\sample.exe" --provider ghidra --format json
`rea doctor` checks the x64 host, exact Ghidra release,
`support\analyzeHeadless.bat`, `java.exe`, `javac.exe`, JDK bitness, and JDK
-major version. `rea setup` remains unavailable on Windows and makes no changes.
+major version. `rea setup` can configure supported agent integrations and the
+bundled REA skill on Windows after approval. It does not install Hopper, Ghidra,
+or Java, and configuring an agent does not enable the blocked Ghidra operations.
For an MCP client, register the resolved Node entry point and preserve the
three environment variables above. A representative configuration is:
diff --git a/package-lock.json b/package-lock.json
index ac25e4971c..57c7ab47f4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -21,12 +21,14 @@
"@xterm/headless": "6.0.0",
"@zip.js/zip.js": "2.8.26",
"canonicalize": "3.0.0",
+ "cross-spawn": "7.0.6",
"diff": "9.0.0",
"graphology": "0.26.0",
"graphology-shortest-path": "2.1.0",
"ignore": "7.0.6",
"incur": "0.4.13",
"isomorphic-git": "1.38.7",
+ "jsonc-parser": "3.3.1",
"pino": "9.14.0",
"playwright-core": "1.58.2",
"plist": "5.0.0",
@@ -42,6 +44,7 @@
},
"devDependencies": {
"@fast-check/vitest": "0.4.1",
+ "@types/cross-spawn": "6.0.6",
"@types/node": "24.10.1",
"@types/semver": "7.7.1",
"@types/write-file-atomic": "4.0.3",
@@ -1894,6 +1897,16 @@
"assertion-error": "^2.0.1"
}
},
+ "node_modules/@types/cross-spawn": {
+ "version": "6.0.6",
+ "resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.6.tgz",
+ "integrity": "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -3425,6 +3438,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/jsonc-parser": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
+ "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
+ "license": "MIT"
+ },
"node_modules/knip": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/knip/-/knip-6.26.0.tgz",
diff --git a/package.json b/package.json
index cdc71595eb..0485ae3232 100644
--- a/package.json
+++ b/package.json
@@ -179,12 +179,14 @@
"@xterm/headless": "6.0.0",
"@zip.js/zip.js": "2.8.26",
"canonicalize": "3.0.0",
+ "cross-spawn": "7.0.6",
"diff": "9.0.0",
"graphology": "0.26.0",
"graphology-shortest-path": "2.1.0",
"ignore": "7.0.6",
"incur": "0.4.13",
"isomorphic-git": "1.38.7",
+ "jsonc-parser": "3.3.1",
"pino": "9.14.0",
"playwright-core": "1.58.2",
"plist": "5.0.0",
@@ -196,6 +198,7 @@
},
"devDependencies": {
"@fast-check/vitest": "0.4.1",
+ "@types/cross-spawn": "6.0.6",
"@types/node": "24.10.1",
"@types/semver": "7.7.1",
"@types/write-file-atomic": "4.0.3",
diff --git a/scripts/verify-package-discovery.mjs b/scripts/verify-package-discovery.mjs
index 87a64a77a8..6308409220 100644
--- a/scripts/verify-package-discovery.mjs
+++ b/scripts/verify-package-discovery.mjs
@@ -2,7 +2,7 @@ import { json, run, runWithStatus } from "./lib/verify-package-core.mjs";
const REQUIRED_HELP_COMMANDS = [
"setup",
- "upgrade",
+ "update",
"inspect-artifact",
"extract-artifact",
"import-reference-source",
@@ -39,6 +39,9 @@ export async function verifyPackageDiscovery({ cli, environment }) {
const doctor = json(doctorExecution.stdout);
const supportedSetupHost =
doctor.checks?.find(({ name }) => name === "host")?.ok === true;
+ const hopperSetupSupported =
+ supportedSetupHost && process.platform !== "win32";
+ const hopperReady = doctor.checks?.find(({ name }) => name === "hopper")?.ok;
const expectedDoctorHealth = doctor.checks?.every(({ ok }) => ok) === true;
const missingHelp = REQUIRED_HELP_COMMANDS.filter(
(command) => !help.includes(command),
@@ -51,10 +54,10 @@ export async function verifyPackageDiscovery({ cli, environment }) {
missingLlms.length !== 0 ||
doctor.healthy !== expectedDoctorHealth ||
doctorExecution.status !== (expectedDoctorHealth ? 0 : 1) ||
- doctor.checks?.find(({ name }) => name === "hopper")?.ok !== true
+ (hopperSetupSupported && hopperReady !== true)
)
throw new Error(
`packaged CLI discovery or doctor failed: ${JSON.stringify({ helpSetup: help.includes("setup"), llmsDecompile: llms.includes("decompile"), doctor })}`,
);
- return { supportedSetupHost };
+ return { supportedSetupHost, hopperSetupSupported };
}
diff --git a/scripts/verify-package-setup.mjs b/scripts/verify-package-setup.mjs
index 68b33443f3..4197786075 100644
--- a/scripts/verify-package-setup.mjs
+++ b/scripts/verify-package-setup.mjs
@@ -18,6 +18,16 @@ import {
run,
runWithStatus,
} from "./lib/verify-package-core.mjs";
+import { parse as parseJsonc } from "jsonc-parser";
+
+const OPENCODE_ORIGINAL = `{
+ // OpenCode preferences must survive setup.
+ "model": "provider/model",
+ "mcp": {
+ // Keep this independently managed server.
+ "other": { "type": "local", "command": ["node", "other.js"] },
+ },
+}\n`;
const verifyMcpAdd = async ({ cli, environment, npxLog }) => {
await run(cli, ["mcp", "add"], environment);
@@ -27,12 +37,48 @@ const verifyMcpAdd = async ({ cli, environment, npxLog }) => {
throw new Error("Incur mcp add did not register the pinned npx command");
};
-const verifyInteractiveSetup = async ({ command, environment, root }) =>
+const snapshotFiles = async (paths) =>
+ Promise.all(
+ paths.map(async (path) => {
+ const exists = await pathExists(path);
+ return {
+ path,
+ exists,
+ content: exists ? await readFile(path, "utf8") : null,
+ };
+ }),
+ );
+
+const assertFilesUnchanged = async (snapshot, label) => {
+ for (const entry of snapshot) {
+ const exists = await pathExists(entry.path);
+ const content = exists ? await readFile(entry.path, "utf8") : null;
+ if (exists !== entry.exists || content !== entry.content)
+ throw new Error(`${label} changed ${entry.path}`);
+ }
+};
+
+const verifyInteractiveSetup = async ({
+ command,
+ environment,
+ root,
+ unchangedPaths,
+}) =>
new Promise((resolvePromise, reject) => {
let output = "";
let cancelled = false;
let settled = false;
- const terminal = spawn(command, ["setup"], {
+ const unchanged = snapshotFiles(unchangedPaths);
+ const wrapper = `
+ const { spawnSync } = require("node:child_process");
+ const child = spawnSync(process.argv[1], ["setup"], { stdio: "inherit", env: process.env, shell: process.platform === "win32" });
+ const terminal = process.platform === "win32"
+ ? spawnSync("cmd.exe", ["/c", "mode", "CON"], { encoding: "utf8", stdio: ["inherit", "pipe", "inherit"] })
+ : spawnSync("stty", ["-a"], { encoding: "utf8", stdio: ["inherit", "pipe", "inherit"] });
+ process.stdout.write("\\n__REA_SETUP_EXIT__" + JSON.stringify({ status: child.status, termios: terminal.stdout, error: child.error?.message }));
+ process.exit(child.status ?? 1);
+ `;
+ const terminal = spawn(process.execPath, ["-e", wrapper, command], {
cwd: root,
env: { ...environment, NO_COLOR: "1", TERM: "xterm-256color" },
name: "xterm-256color",
@@ -55,21 +101,120 @@ const verifyInteractiveSetup = async ({ command, environment, root }) =>
terminal.onData((data) => {
output += data;
- if (!cancelled && output.includes("What should REA set up?")) {
+ if (!cancelled && output.includes("Which agents should use REA?")) {
cancelled = true;
terminal.write("\u0003");
}
});
- terminal.onExit(() => {
- if (!cancelled || !output.includes("No changes were made.")) {
+ terminal.onExit(async ({ exitCode }) => {
+ const marker = output.lastIndexOf("__REA_SETUP_EXIT__");
+ const result = marker < 0 ? undefined : output.slice(marker + 18).trim();
+ let childResult;
+ try {
+ childResult = result === undefined ? undefined : JSON.parse(result);
+ } catch {
+ childResult = undefined;
+ }
+ if (
+ !cancelled ||
+ exitCode !== 0 ||
+ childResult?.status !== 0 ||
+ !output.includes("\u001b[?25h") ||
+ (process.platform !== "win32" &&
+ (!childResult.termios?.includes("icanon") ||
+ !childResult.termios?.includes("echo"))) ||
+ !output.includes("No changes were made.")
+ ) {
finish(
new Error(
- `packaged rea-agents setup did not open and cancel the wizard: ${output}`,
+ `packaged rea-agents setup did not cancel with a restored terminal and exit 0: ${output}`,
),
);
return;
}
- finish();
+ try {
+ await assertFilesUnchanged(await unchanged, "Interactive cancellation");
+ finish();
+ } catch (error) {
+ finish(error);
+ }
+ });
+ });
+
+const verifyRedirectedStdoutSetup = async ({
+ command,
+ environment,
+ root,
+ unchangedPaths,
+}) =>
+ new Promise((resolvePromise, reject) => {
+ let output = "";
+ let settled = false;
+ const unchanged = snapshotFiles(unchangedPaths);
+ const wrapper = `
+ const { spawnSync } = require("node:child_process");
+ const child = spawnSync(process.argv[1], ["setup"], { encoding: "utf8", env: process.env, stdio: ["inherit", "pipe", "inherit"], shell: process.platform === "win32" });
+ process.stdout.write("\\n__REA_REDIRECTED_SETUP__" + JSON.stringify({ status: child.status, stdout: child.stdout, error: child.error?.message, stdinTTY: process.stdin.isTTY, stderrTTY: process.stderr.isTTY, stdoutRedirected: true }));
+ process.exit(child.status ?? 1);
+ `;
+ const terminal = spawn(process.execPath, ["-e", wrapper, command], {
+ cwd: root,
+ env: { ...environment, NO_COLOR: "1", TERM: "xterm-256color" },
+ name: "xterm-256color",
+ cols: 120,
+ rows: 40,
+ });
+ const finish = (error) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timeout);
+ if (error === undefined) resolvePromise();
+ else reject(error);
+ };
+ const timeout = setTimeout(() => {
+ terminal.kill();
+ finish(
+ new Error(
+ `packaged setup started an interactive prompt with redirected stdout: ${output}`,
+ ),
+ );
+ }, 15_000);
+ terminal.onData((data) => {
+ output += data;
+ });
+ terminal.onExit(async ({ exitCode }) => {
+ const marker = output.lastIndexOf("__REA_REDIRECTED_SETUP__");
+ let childResult;
+ try {
+ childResult =
+ marker < 0 ? undefined : JSON.parse(output.slice(marker + 24).trim());
+ } catch {
+ childResult = undefined;
+ }
+ const allOutput = `${output}\n${childResult?.stdout ?? ""}`;
+ if (
+ (exitCode !== 0 && exitCode !== 1) ||
+ childResult?.status !== exitCode ||
+ childResult.stdinTTY !== true ||
+ childResult.stderrTTY !== true ||
+ childResult.stdoutRedirected !== true ||
+ childResult.stdout?.includes("Which agents should use REA?") ||
+ allOutput.includes("Which agents should use REA?") ||
+ /\u001b\[[0-9;?]*[ -/]*[@-~]/u.test(allOutput)
+ ) {
+ finish(
+ new Error(
+ `packaged setup did not remain plain and non-interactive with redirected stdout: ${output}`,
+ ),
+ );
+ return;
+ }
+ try {
+ await assertFilesUnchanged(await unchanged, "Redirected-stdout setup");
+ finish();
+ } catch (error) {
+ finish(error);
+ }
});
});
@@ -79,11 +224,13 @@ const verifySetupPlan = async ({
claudeConfig,
cursorConfig,
codexTarget,
+ opencodeConfig,
+ opencodeOriginal,
supportedSetupHost,
}) => {
const plannedExecution = await runWithStatus(
cli,
- ["setup", "--json"],
+ ["setup", "--all-detected", "--dry-run", "--json"],
environment,
);
const planned = json(plannedExecution.stdout);
@@ -91,15 +238,17 @@ const verifySetupPlan = async ({
const plannedClaudeConfig = await readFile(claudeConfig, "utf8");
const plannedCodexConfig = await readFile(codexTarget, "utf8");
const plannedCursorConfig = await readFile(cursorConfig, "utf8");
+ const plannedOpenCodeConfig = await readFile(opencodeConfig, "utf8");
if (
- planned.status !== "needs_confirmation" ||
- plannedExecution.status !== 1 ||
+ planned.status !== "planned" ||
+ plannedExecution.status !== 0 ||
planned.appliedActions.length !== 0 ||
!planned.plannedActions.some(({ kind }) => kind === "configure_client") ||
!planned.plannedActions.some(({ kind }) => kind === "install_skill") ||
plannedClaudeConfig !== '{"existing":true}\n' ||
plannedCodexConfig !== 'model = "gpt-5"\n' ||
- plannedCursorConfig !== '{"existing":true}\n'
+ plannedCursorConfig !== '{"existing":true}\n' ||
+ plannedOpenCodeConfig !== opencodeOriginal
)
throw new Error(
`packaged setup plan was not read-only: ${JSON.stringify({ status: planned.status, exitCode: plannedExecution.status, plannedKinds: planned.plannedActions.map(({ kind }) => kind), appliedKinds: planned.appliedActions.map(({ kind }) => kind), claudeConfig: plannedClaudeConfig, codexConfig: plannedCodexConfig, cursorConfig: plannedCursorConfig })}`,
@@ -108,11 +257,101 @@ const verifySetupPlan = async ({
return { planned };
};
-const verifySetupApply = async ({ cli, environment, supportedSetupHost }) => {
- const status = process.platform === "linux" ? "needs_human" : "ready";
+const verifyAbsentOpenCodeSetup = async ({ cli, environment, home }) => {
+ const xdgConfigHome = join(home, ".xdg-opencode-absent");
+ const configPath = join(xdgConfigHome, "opencode", "opencode.json");
+ if (await pathExists(configPath))
+ throw new Error("packaged OpenCode first-run fixture was not absent");
+ const execution = await runWithStatus(
+ cli,
+ ["setup", "--yes", "--client", "opencode", "--skill=false", "--json"],
+ { ...environment, XDG_CONFIG_HOME: xdgConfigHome },
+ );
+ const result = json(execution.stdout);
+ const config = JSON.parse(await readFile(configPath, "utf8"));
+ if (
+ execution.status !== 0 ||
+ result.status !== "ready" ||
+ !result.appliedActions.includes("configured_opencode") ||
+ config.mcp?.rea?.type !== "local" ||
+ config.mcp.rea.enabled !== true ||
+ JSON.stringify(config.mcp.rea.command) !== JSON.stringify([cli, "mcp"])
+ )
+ throw new Error(
+ `packaged setup did not configure explicitly selected OpenCode without a config file: ${JSON.stringify(result)}`,
+ );
+ await rm(xdgConfigHome, { recursive: true, force: true });
+};
+
+const verifyAbsentClaudeCodeSetup = async ({ cli, environment, home }) => {
+ const freshHome = join(home, ".claude-code-absent-home");
+ const configPath = join(freshHome, ".claude.json");
+ const markerPath = join(freshHome, ".claude");
+ await rm(freshHome, { recursive: true, force: true });
+ await mkdir(freshHome, { recursive: true });
+ const childEnvironment = {
+ ...environment,
+ HOME: freshHome,
+ USERPROFILE: freshHome,
+ XDG_CONFIG_HOME: join(freshHome, ".config"),
+ };
+ delete childEnvironment.CLAUDE_CONFIG_DIR;
+ if ((await pathExists(configPath)) || (await pathExists(markerPath)))
+ throw new Error("packaged Claude Code first-run fixture was not absent");
+ const args = [
+ "setup",
+ "--yes",
+ "--client",
+ "claude_code",
+ "--skill=false",
+ "--json",
+ ];
+ const firstExecution = await runWithStatus(cli, args, childEnvironment);
+ const first = json(firstExecution.stdout);
+ const config = json(await readFile(configPath, "utf8"));
+ const registration = first.doctor.identity?.registrations?.find(
+ ({ client }) => client === "claude_code",
+ );
+ if (
+ firstExecution.status !== 0 ||
+ first.status !== "ready" ||
+ !first.appliedActions.includes("configured_claude_code") ||
+ config.mcpServers?.rea?.command !== cli ||
+ JSON.stringify(config.mcpServers?.rea?.args) !== JSON.stringify(["mcp"]) ||
+ (await pathExists(markerPath)) ||
+ registration?.state !== "aligned" ||
+ registration.config_path !== configPath
+ )
+ throw new Error(
+ `packaged setup did not configure an explicitly selected Claude Code with no marker directory: ${JSON.stringify(first)}`,
+ );
+ const repeatExecution = await runWithStatus(cli, args, childEnvironment);
+ const repeat = json(repeatExecution.stdout);
+ if (
+ repeatExecution.status !== 0 ||
+ repeat.status !== "ready" ||
+ repeat.appliedActions.length !== 0 ||
+ repeat.plannedActions.length !== 0
+ )
+ throw new Error(
+ `packaged setup was not idempotent for Claude Code without a marker directory: ${JSON.stringify(repeat)}`,
+ );
+ await rm(freshHome, { recursive: true, force: true });
+};
+
+const verifySetupApply = async ({
+ cli,
+ environment,
+ supportedSetupHost,
+ hopperSetupSupported,
+}) => {
+ const status = "ready";
+ const hopperStatus = hopperSetupSupported
+ ? "needs_confirmation"
+ : "needs_human";
const firstExecution = await runWithStatus(
cli,
- ["setup", "--yes", "--json"],
+ ["setup", "--yes", "--all-detected", "--json"],
environment,
);
const first = json(firstExecution.stdout);
@@ -124,14 +363,24 @@ const verifySetupApply = async ({ cli, environment, supportedSetupHost }) => {
const aligned = json(alignedExecution.stdout);
const secondExecution = await runWithStatus(
cli,
- ["setup", "--install-hopper", "--json"],
+ [
+ "setup",
+ "--client",
+ "claude_desktop",
+ "--client",
+ "codex",
+ "--client",
+ "cursor",
+ "--install-hopper",
+ "--json",
+ ],
environment,
);
const second = json(secondExecution.stdout);
if (supportedSetupHost) {
if (
first.status !== status ||
- second.status !== "needs_confirmation" ||
+ second.status !== hopperStatus ||
firstExecution.status !== (status === "ready" ? 0 : 1) ||
secondExecution.status !== 1 ||
aligned.status !== status ||
@@ -170,6 +419,31 @@ const assertClientConfig = async (cli, configPath) => {
throw new Error("packaged client backup failed");
};
+const assertOpenCodeConfig = async ({
+ cli,
+ configPath,
+ expectedRegistration,
+}) => {
+ const contents = await readFile(configPath, "utf8");
+ const errors = [];
+ const parsed = parseJsonc(contents, errors, { allowTrailingComma: true });
+ const config = parsed;
+ const mcp = config?.mcp;
+ if (
+ errors.length !== 0 ||
+ !contents.includes("// OpenCode preferences must survive setup.") ||
+ !contents.includes("// Keep this independently managed server.") ||
+ config?.model !== "provider/model" ||
+ mcp?.other?.command?.[0] !== "node" ||
+ (expectedRegistration &&
+ (mcp?.rea?.type !== "local" ||
+ mcp.rea.enabled !== true ||
+ JSON.stringify(mcp.rea.command) !== JSON.stringify([cli, "mcp"]))) ||
+ (!expectedRegistration && mcp?.rea !== undefined)
+ )
+ throw new Error("packaged OpenCode JSONC registration readback failed");
+};
+
const assertCodexSymlink = async ({
cli,
codexConfig,
@@ -245,6 +519,8 @@ const verifyConfigReadback = async ({
root,
skillPath,
siblingSkillPath,
+ opencodeConfig,
+ opencodeOriginal,
}) => {
await assertClientConfig(cli, claudeConfig);
await assertClientConfig(cli, cursorConfig);
@@ -255,6 +531,18 @@ const verifyConfigReadback = async ({
codexTarget,
});
await assertSkill({ skillPath, siblingSkillPath, root });
+ await assertOpenCodeConfig({
+ cli,
+ configPath: opencodeConfig,
+ expectedRegistration: true,
+ });
+ if (
+ (await readFile(`${opencodeConfig}.rea.backup`, "utf8")) !==
+ opencodeOriginal
+ )
+ throw new Error(
+ "packaged OpenCode backup did not preserve the original JSONC",
+ );
await assertAlignedDoctor(cli, environment);
};
@@ -269,7 +557,7 @@ const verifySetupFailureRecovery = async ({
await writeFile(cursorConfig, '{"existing":true}\n');
const failedExecution = await runWithStatus(
cli,
- ["setup", "--yes", "--json"],
+ ["setup", "--yes", "--all-detected", "--json"],
environment,
);
const failed = json(failedExecution.stdout);
@@ -289,7 +577,7 @@ const verifySetupFailureRecovery = async ({
await writeFile(claudeConfig, "{}\n");
const recoveredExecution = await runWithStatus(
cli,
- ["setup", "--yes", "--json"],
+ ["setup", "--yes", "--all-detected", "--json"],
environment,
);
const recovered = json(recoveredExecution.stdout);
@@ -308,7 +596,7 @@ const verifyDanglingSymlink = async ({ cli, environment, home }) => {
await symlink(join(home, "missing-gemini.json"), geminiConfig);
const danglingExecution = await runWithStatus(
cli,
- ["setup", "--yes", "--json"],
+ ["setup", "--yes", "--client", "gemini_cli", "--json"],
environment,
);
const dangling = json(danglingExecution.stdout);
@@ -335,7 +623,9 @@ const verifyUninstall = async ({
codexConfig,
cursorTarget,
codexTarget,
+ opencodeConfig,
}) => {
+ const openCodeBeforeUninstall = await readFile(opencodeConfig, "utf8");
const uninstallExecution = await runWithStatus(
cli,
["uninstall", "--json"],
@@ -344,6 +634,12 @@ const verifyUninstall = async ({
const uninstall = json(uninstallExecution.stdout);
const cursorAfterUninstall = json(await readFile(cursorTarget, "utf8"));
const codexAfterUninstall = await readFile(codexTarget, "utf8");
+ const openCodeErrors = [];
+ const openCodeAfterUninstall = parseJsonc(
+ await readFile(opencodeConfig, "utf8"),
+ openCodeErrors,
+ { allowTrailingComma: true },
+ );
if (
uninstall.status !== "complete" ||
uninstallExecution.status !== 0 ||
@@ -357,6 +653,18 @@ const verifyUninstall = async ({
cursorAfterUninstall.mcpServers?.rea !== undefined ||
!codexAfterUninstall.includes('model = "gpt-5"') ||
codexAfterUninstall.includes("mcp_servers.rea") ||
+ openCodeErrors.length !== 0 ||
+ openCodeAfterUninstall?.model !== "provider/model" ||
+ openCodeAfterUninstall?.mcp?.rea !== undefined ||
+ openCodeAfterUninstall?.mcp?.other?.command?.[0] !== "node" ||
+ !(await readFile(`${opencodeConfig}.rea.backup`, "utf8")).includes(
+ "OpenCode preferences must survive setup",
+ ) ||
+ !(await readFile(`${opencodeConfig}.rea.backup`, "utf8")).includes(
+ "Keep this independently managed server",
+ ) ||
+ (await readFile(`${opencodeConfig}.rea.backup`, "utf8")) !==
+ openCodeBeforeUninstall ||
!(await readFile(`${cursorConfig}.rea.backup`, "utf8")).includes('"rea"') ||
!(await readFile(`${codexConfig}.rea.backup`, "utf8")).includes(
"[mcp_servers.rea]",
@@ -380,13 +688,9 @@ export async function verifyPackageSetup({
codexTarget,
cursorTarget,
supportedSetupHost,
+ hopperSetupSupported,
root,
}) {
- await verifyInteractiveSetup({
- command: packageRunnerCli,
- environment,
- root,
- });
await verifyMcpAdd({ cli, environment, npxLog });
const skillPath = join(
home,
@@ -397,20 +701,53 @@ export async function verifyPackageSetup({
await mkdir(join(home, ".agents/skills/unrelated"), { recursive: true });
await writeFile(siblingSkillPath, "unrelated skill\n");
}
+ const opencodeConfig = join(
+ environment.XDG_CONFIG_HOME ?? join(home, ".config"),
+ "opencode",
+ "opencode.json",
+ );
+ if (supportedSetupHost) {
+ await mkdir(join(opencodeConfig, ".."), { recursive: true });
+ await writeFile(opencodeConfig, OPENCODE_ORIGINAL);
+ }
+ const unchangedPaths = [
+ claudeConfig,
+ cursorConfig,
+ codexTarget,
+ opencodeConfig,
+ skillPath,
+ ];
+ await verifyInteractiveSetup({
+ command: packageRunnerCli,
+ environment,
+ root,
+ unchangedPaths,
+ });
+ await verifyRedirectedStdoutSetup({
+ command: packageRunnerCli,
+ environment,
+ root,
+ unchangedPaths,
+ });
await verifySetupPlan({
cli,
environment,
claudeConfig,
cursorConfig,
codexTarget,
+ opencodeConfig,
+ opencodeOriginal: OPENCODE_ORIGINAL,
supportedSetupHost,
});
const apply = await verifySetupApply({
cli,
environment,
supportedSetupHost,
+ hopperSetupSupported,
});
if (supportedSetupHost) {
+ await verifyAbsentOpenCodeSetup({ cli, environment, home });
+ await verifyAbsentClaudeCodeSetup({ cli, environment, home });
await verifyConfigReadback({
cli,
environment,
@@ -421,6 +758,8 @@ export async function verifyPackageSetup({
root,
skillPath,
siblingSkillPath,
+ opencodeConfig,
+ opencodeOriginal: OPENCODE_ORIGINAL,
});
await verifySetupFailureRecovery({
cli,
@@ -437,6 +776,7 @@ export async function verifyPackageSetup({
codexConfig,
cursorTarget,
codexTarget,
+ opencodeConfig,
});
} else if (
apply.first.status !== "needs_human" ||
diff --git a/scripts/verify-package-update.mjs b/scripts/verify-package-update.mjs
new file mode 100644
index 0000000000..fc12965464
--- /dev/null
+++ b/scripts/verify-package-update.mjs
@@ -0,0 +1,180 @@
+import { createHash } from "node:crypto";
+import { createServer } from "node:http";
+import { readFile, writeFile, mkdir } from "node:fs/promises";
+import { dirname, join } from "node:path";
+import { pathToFileURL } from "node:url";
+
+import { exec, json } from "./lib/verify-package-core.mjs";
+
+/** Exercise the packaged updater with real npm and a local release registry fixture. */
+export async function verifyPackageUpdate({
+ prefix,
+ packageRoot,
+ tarball,
+ workspace,
+ environment,
+}) {
+ const metadataPath = join(packageRoot, "package.json");
+ const metadata = json(await readFile(metadataPath, "utf8"));
+ const archive = await readFile(tarball);
+ const entry = join(packageRoot, "scripts", "rea.mjs");
+ const home = join(workspace, "update-home");
+ const codexPath = join(home, ".codex", "config.toml");
+ const originalConfig =
+ '[mcp_servers.rea]\ncommand = "npx"\nargs = ["-y", "rea-agents@0.0.0", "mcp"]\n';
+ await mkdir(dirname(codexPath), { recursive: true });
+ await writeFile(codexPath, originalConfig);
+ const server = createServer((request, response) => {
+ void serveRegistry(request, response).catch((error) => {
+ response.writeHead(500);
+ response.end(String(error));
+ });
+ });
+ let registry;
+ async function serveRegistry(request, response) {
+ const pathname = new URL(request.url, registry).pathname;
+ if (pathname === "/rea-agents") {
+ response.setHeader("Content-Type", "application/json");
+ response.end(
+ JSON.stringify({
+ name: metadata.name,
+ "dist-tags": { latest: metadata.version },
+ versions: {
+ [metadata.version]: {
+ ...metadata,
+ dist: {
+ tarball: `${registry}/release.tgz`,
+ integrity: `sha512-${createHash("sha512").update(archive).digest("base64")}`,
+ },
+ },
+ },
+ }),
+ );
+ } else if (pathname === "/release.tgz") {
+ response.end(archive);
+ } else if (request.method === "POST") {
+ response.setHeader("Content-Type", "application/json");
+ response.end("{}");
+ } else {
+ const upstream = await fetch(`https://registry.npmjs.org${request.url}`);
+ response.writeHead(upstream.status, {
+ "Content-Type":
+ upstream.headers.get("content-type") ?? "application/octet-stream",
+ });
+ response.end(Buffer.from(await upstream.arrayBuffer()));
+ }
+ }
+ await new Promise((resolveListen, reject) => {
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", resolveListen);
+ });
+ try {
+ const address = server.address();
+ if (typeof address !== "object" || address === null)
+ throw new Error("Registry fixture did not bind");
+ registry = `http://127.0.0.1:${address.port}`;
+ // Model an older installed release's identity while retaining the new updater
+ // under test. The registry supplies the unmodified packed target artifact.
+ const generatedPath = join(
+ packageRoot,
+ "dist",
+ "generatedPackageMetadata.js",
+ );
+ const generated = await readFile(generatedPath, "utf8");
+ const previousVersion = "0.0.0";
+ const previousGenerated = generated.replace(
+ `version: "${metadata.version}"`,
+ `version: "${previousVersion}"`,
+ );
+ if (previousGenerated === generated)
+ throw new Error("Could not create previous-release identity fixture");
+ await writeFile(generatedPath, previousGenerated);
+ await writeFile(
+ metadataPath,
+ JSON.stringify({ ...metadata, version: previousVersion }),
+ );
+ const runtimeEnvironment = {
+ ...environment,
+ HOME: home,
+ USERPROFILE: home,
+ APPDATA: join(home, "AppData", "Roaming"),
+ CLAUDE_CONFIG_DIR: home,
+ CODEX_HOME: join(home, ".codex"),
+ COPILOT_HOME: join(home, ".copilot"),
+ XDG_CONFIG_HOME: join(home, ".config"),
+ OPENCODE_CONFIG: join(home, ".config", "opencode", "opencode.json"),
+ npm_config_prefix: prefix,
+ npm_config_registry: registry,
+ };
+ const previous = (
+ await exec(process.execPath, [entry, "--version"], {
+ env: runtimeEnvironment,
+ })
+ ).stdout.trim();
+ if (previous !== previousVersion)
+ throw new Error(`Previous-release fixture reported ${previous}`);
+ const harness = join(workspace, "verify-update.mjs");
+ await writeFile(
+ harness,
+ `
+import { runUpdate } from ${JSON.stringify(pathToFileURL(join(packageRoot, "dist/application/Update.js")).href)};
+import { systemUpdateHost } from ${JSON.stringify(pathToFileURL(join(packageRoot, "dist/application/UpdateRuntime.js")).href)};
+const host = systemUpdateHost(${JSON.stringify(packageRoot)}, ${JSON.stringify(home)});
+const result = await runUpdate(${JSON.stringify(previousVersion)}, host, "structured");
+process.stdout.write(JSON.stringify(result));
+if (result.status === "failed") process.exitCode = 1;
+`,
+ );
+ const result = json(
+ (
+ await exec(process.execPath, [harness], {
+ env: runtimeEnvironment,
+ maxBuffer: 16 * 1024 * 1024,
+ })
+ ).stdout,
+ );
+ if (
+ result.status !== "updated" ||
+ result.installedVersion !== metadata.version ||
+ result.command.at(-1) !== `${metadata.name}@${metadata.version}` ||
+ result.maintenance.status !== "planned" ||
+ result.maintenance.scope.clients.join(",") !== "codex" ||
+ result.maintenance.scope.skill !== false
+ )
+ throw new Error(
+ `Packaged updater failed its exact-release contract: ${JSON.stringify(result)}`,
+ );
+ if ((await readFile(codexPath, "utf8")) !== originalConfig)
+ throw new Error(
+ "Update applied integration maintenance without approval",
+ );
+ const current = (
+ await exec(process.execPath, [entry, "--version"], {
+ env: runtimeEnvironment,
+ })
+ ).stdout.trim();
+ if (current !== metadata.version)
+ throw new Error(`Updated executable reported ${current}`);
+ const help = (
+ await exec(process.execPath, [entry, "--help"], {
+ env: runtimeEnvironment,
+ })
+ ).stdout;
+ if (!/^\s{2}update\s/mu.test(help) || /^\s{2}upgrade\s/mu.test(help))
+ throw new Error(
+ "Updated CLI did not expose update as its sole maintenance command",
+ );
+ return {
+ installed_version: result.installedVersion,
+ exact_release: true,
+ maintenance_read_only: true,
+ };
+ } finally {
+ server.closeAllConnections();
+ await new Promise((resolveClose, reject) =>
+ server.close((error) =>
+ error === undefined ? resolveClose() : reject(error),
+ ),
+ );
+ }
+}
diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs
index 32937264ea..64f4ba7ea3 100644
--- a/scripts/verify-package.mjs
+++ b/scripts/verify-package.mjs
@@ -12,6 +12,7 @@ import { verifyPackageDiscovery } from "./verify-package-discovery.mjs";
import { verifyPackageEnvironment } from "./verify-package-environment.mjs";
import { verifyPackageEvidence } from "./verify-package-evidence.mjs";
import { verifyPackageInstall } from "./verify-package-install.mjs";
+import { verifyPackageUpdate } from "./verify-package-update.mjs";
import { verifyPackageMcp } from "./verify-package-mcp.mjs";
import { verifyPackagePack } from "./verify-package-pack.mjs";
import { verifyPackagePlatform } from "./verify-package-platform.mjs";
@@ -32,6 +33,7 @@ const referenceRoot = join(workspace, "reference-source");
let tarball;
let mcpStartup;
let mcpModuleLoading;
+let update;
try {
({ tarball } = await verifyPackagePack({ root, workspace }));
@@ -48,6 +50,18 @@ try {
workspace,
environment: environmentData.environment,
});
+ update = await verifyPackageUpdate({
+ prefix: environmentData.prefix,
+ packageRoot: join(
+ environmentData.prefix,
+ "lib",
+ "node_modules",
+ PRODUCT_IDENTITY.packageName,
+ ),
+ tarball: join(root, tarball),
+ workspace,
+ environment: environmentData.environment,
+ });
mcpStartup = await measureMcpStartup({
command: cli,
args: ["mcp"],
@@ -65,10 +79,11 @@ try {
policy: MCP_STARTUP_POLICY,
packageName: PRODUCT_IDENTITY.packageName,
});
- const { supportedSetupHost } = await verifyPackageDiscovery({
- cli,
- environment: environmentData.environment,
- });
+ const { supportedSetupHost, hopperSetupSupported } =
+ await verifyPackageDiscovery({
+ cli,
+ environment: environmentData.environment,
+ });
await verifyPackageArtifactAndElectron({
cli,
workspace,
@@ -109,6 +124,7 @@ try {
codexTarget: environmentData.codexTarget,
cursorTarget: environmentData.cursorTarget,
supportedSetupHost,
+ hopperSetupSupported,
root,
});
await verifyPackageMcp({
@@ -117,7 +133,7 @@ try {
evidenceRoot,
});
process.stdout.write(
- `${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), cli: true, analysisCli: true, artifactCli: true, managedCli: true, managedReconstructionCli: true, managedNativeVerificationCli: true, managedApplicationGraphCli: true, evidenceCli: true, incurMcpCommand: PRODUCT_IDENTITY.mcpCommand, lifecycleScriptsRequired: false, doctor: "platform-appropriate", setup: supportedSetupHost ? "planned-then-idempotent" : "unsupported-host-rejected", setupPlanReadOnly: supportedSetupHost, existingHopperPreserved: supportedSetupHost, clients: supportedSetupHost ? 3 : 0, backupReadback: supportedSetupHost, failureRecovery: supportedSetupHost, configSymlinkLifecycle: supportedSetupHost, skill: supportedSetupHost, skillReferences: supportedSetupHost, mcpTools: TOOL_CONTRACTS.length, mcpPrompts: prompts.names.length, promptCompletion: true, promptCompletionLifecycle: true, evidenceMcp: true, targetFree: true, targetLifecycle: true, boundedRegexBridge: true, mcpStartup, mcpModuleLoading })}\n`,
+ `${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), cli: true, analysisCli: true, artifactCli: true, managedCli: true, managedReconstructionCli: true, managedNativeVerificationCli: true, managedApplicationGraphCli: true, evidenceCli: true, incurMcpCommand: PRODUCT_IDENTITY.mcpCommand, lifecycleScriptsRequired: false, doctor: "platform-appropriate", setup: supportedSetupHost ? "planned-then-idempotent" : "unsupported-host-rejected", setupPlanReadOnly: supportedSetupHost, existingHopperPreserved: supportedSetupHost, clients: supportedSetupHost ? 4 : 0, backupReadback: supportedSetupHost, failureRecovery: supportedSetupHost, configSymlinkLifecycle: supportedSetupHost, skill: supportedSetupHost, skillReferences: supportedSetupHost, mcpTools: TOOL_CONTRACTS.length, mcpPrompts: prompts.names.length, promptCompletion: true, promptCompletionLifecycle: true, evidenceMcp: true, targetFree: true, targetLifecycle: true, boundedRegexBridge: true, update, mcpStartup, mcpModuleLoading })}\n`,
);
} finally {
if (tarball) await rm(join(root, tarball), { force: true });
diff --git a/scripts/verify-published-package.mjs b/scripts/verify-published-package.mjs
index f47869f501..35c5224efb 100644
--- a/scripts/verify-published-package.mjs
+++ b/scripts/verify-published-package.mjs
@@ -1,10 +1,11 @@
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { execFile } from "node:child_process";
-import { mkdtemp, readFile, rm } from "node:fs/promises";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { promisify } from "node:util";
+import { pathToFileURL } from "node:url";
import { lt, rcompare } from "semver";
@@ -44,7 +45,7 @@ transport.stderr?.on("data", (chunk) => {
});
let publishedToolCount = 0;
-let upgrade;
+let update;
try {
await client.connect(transport);
@@ -71,7 +72,7 @@ try {
});
if (status.isError === true)
throw new Error("Published target-free MCP session check failed");
- upgrade = await verifyPublishedUpgrade({ version, canaryRoot });
+ update = await verifyPublishedUpdate({ version, canaryRoot });
} catch (cause) {
throw new Error(`Published package verification failed: ${stderr}`, {
cause,
@@ -86,17 +87,17 @@ try {
}
process.stdout.write(
- `${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), package: "rea-agents", version, mcpTools: publishedToolCount, canonicalMcpTools: CATALOG_IDENTITY.counts.mcp_tools, upgrade })}\n`,
+ `${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), package: "rea-agents", version, mcpTools: publishedToolCount, canonicalMcpTools: CATALOG_IDENTITY.counts.mcp_tools, update })}\n`,
);
-async function verifyPublishedUpgrade({ version: targetVersion, canaryRoot }) {
+async function verifyPublishedUpdate({ version: targetVersion, canaryRoot }) {
const previousVersion = await findPreviousPublishedVersion(targetVersion);
- const prefix = join(canaryRoot, "upgrade-prefix");
- const home = join(canaryRoot, "upgrade-home");
+ const prefix = join(canaryRoot, "update-prefix");
+ const home = join(canaryRoot, "update-home");
const environment = {
...process.env,
HOME: home,
- npm_config_cache: join(canaryRoot, "upgrade-cache"),
+ npm_config_cache: join(canaryRoot, "update-cache"),
npm_config_prefix: prefix,
PATH: `${join(prefix, "bin")}${delimiter}${process.env.PATH ?? ""}`,
};
@@ -114,33 +115,63 @@ async function verifyPublishedUpgrade({ version: targetVersion, canaryRoot }) {
{ cwd: canaryRoot, env: environment, maxBuffer: 16 * 1024 * 1024 },
);
- const { stdout } = await execFileAsync(
- "npx",
- ["rea-agents", "upgrade", "--json"],
- { cwd: canaryRoot, env: environment, maxBuffer: 16 * 1024 * 1024 },
- );
- const result = JSON.parse(stdout);
- if (
- result.status !== "upgraded" ||
- result.previousVersion !== previousVersion ||
- result.latestVersion !== targetVersion
- )
- throw new Error(
- `npx rea-agents upgrade did not upgrade ${previousVersion} to ${targetVersion}: ${stdout}`,
- );
-
const npmRoot = (
await execFileAsync("npm", ["root", "--global"], {
cwd: canaryRoot,
env: environment,
})
).stdout.trim();
+ // Older releases may predate the update command. Exercise the new published
+ // updater's production workflow against that real previous installation.
+ const updaterPrefix = join(canaryRoot, "updater");
+ await execFileAsync(
+ "npm",
+ [
+ "install",
+ "--ignore-scripts",
+ "--no-save",
+ "--no-package-lock",
+ "--prefix",
+ updaterPrefix,
+ `rea-agents@${targetVersion}`,
+ ],
+ { cwd: canaryRoot, env: environment, maxBuffer: 16 * 1024 * 1024 },
+ );
+ const updaterRoot = join(updaterPrefix, "node_modules", "rea-agents");
+ const harness = join(canaryRoot, "update.mjs");
+ await writeFile(
+ harness,
+ `
+import { runUpdate } from ${JSON.stringify(pathToFileURL(join(updaterRoot, "dist/application/Update.js")).href)};
+import { systemUpdateHost } from ${JSON.stringify(pathToFileURL(join(updaterRoot, "dist/application/UpdateRuntime.js")).href)};
+const result = await runUpdate(${JSON.stringify(previousVersion)}, systemUpdateHost(${JSON.stringify(join(npmRoot, "rea-agents"))}, ${JSON.stringify(home)}), "structured");
+process.stdout.write(JSON.stringify(result));
+if (result.status === "failed") process.exitCode = 1;
+`,
+ );
+ const { stdout } = await execFileAsync(process.execPath, [harness], {
+ cwd: canaryRoot,
+ env: environment,
+ maxBuffer: 16 * 1024 * 1024,
+ });
+ const result = JSON.parse(stdout);
+ if (
+ result.status !== "updated" ||
+ result.previousVersion !== previousVersion ||
+ result.latestVersion !== targetVersion ||
+ result.installedVersion !== targetVersion ||
+ result.command.at(-1) !== `rea-agents@${targetVersion}`
+ )
+ throw new Error(
+ `Published REA updater did not update ${previousVersion} to ${targetVersion}: ${stdout}`,
+ );
+
const installed = JSON.parse(
await readFile(join(npmRoot, "rea-agents", "package.json"), "utf8"),
);
if (installed.version !== targetVersion)
throw new Error(
- `npx rea-agents upgrade installed ${installed.version}, expected ${targetVersion}`,
+ `REA update installed ${installed.version}, expected ${targetVersion}`,
);
const help = (
@@ -149,8 +180,10 @@ async function verifyPublishedUpgrade({ version: targetVersion, canaryRoot }) {
env: environment,
})
).stdout;
- if (!help.includes("setup"))
- throw new Error("the upgraded rea-agents executable did not start");
+ if (!/^\s{2}update\s/mu.test(help) || /^\s{2}upgrade\s/mu.test(help))
+ throw new Error(
+ "the updated executable did not expose only the update command",
+ );
return {
status: result.status,
diff --git a/scripts/verify-windows-package.mjs b/scripts/verify-windows-package.mjs
index 37d8753efb..5414483a39 100644
--- a/scripts/verify-windows-package.mjs
+++ b/scripts/verify-windows-package.mjs
@@ -1,15 +1,25 @@
#!/usr/bin/env node
import { execFile } from "node:child_process";
-import { access, mkdtemp, rm } from "node:fs/promises";
+import {
+ access,
+ mkdir,
+ mkdtemp,
+ readFile,
+ rm,
+ writeFile,
+} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
+import { parse as parseJsonc } from "jsonc-parser";
+import { supportedClients } from "../dist/application/SupportedClients.js";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { verifyCompleteToolCatalog } from "./lib/verify-package-core.mjs";
+import { verifyPackageUpdate } from "./verify-package-update.mjs";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
const exec = promisify(execFile);
@@ -89,6 +99,117 @@ try {
await client.close();
}
+ const home = join(workspace, "home");
+ const setupEnvironment = {
+ ...environment,
+ HOME: home,
+ USERPROFILE: home,
+ APPDATA: join(home, "AppData", "Roaming"),
+ XDG_CONFIG_HOME: join(home, ".config"),
+ OPENCODE_CONFIG: join(home, ".config", "opencode", "opencode.jsonc"),
+ COPILOT_HOME: join(home, ".copilot"),
+ };
+ await mkdir(join(home, ".config", "opencode"), { recursive: true });
+ await writeFile(
+ setupEnvironment.OPENCODE_CONFIG,
+ '{\n // preserve user settings\n "theme": "dark",\n}\n',
+ );
+ const setupArgs = [
+ "setup",
+ "--yes",
+ "--client",
+ "opencode",
+ "--client",
+ "vscode",
+ "--client",
+ "copilot_cli",
+ "--skill=false",
+ "--json",
+ ];
+ const setup = JSON.parse(
+ (
+ await exec(process.execPath, [entry, ...setupArgs], {
+ env: setupEnvironment,
+ windowsHide: true,
+ })
+ ).stdout,
+ );
+ if (setup.status !== "ready" || setup.appliedActions.length !== 3)
+ throw new Error(
+ `Packaged agent-only setup failed: ${JSON.stringify(setup)}`,
+ );
+ const expectedCommand =
+ process.platform === "win32"
+ ? [process.execPath, entry, "mcp"]
+ : [entry, "mcp"];
+ const clients = supportedClients(home, process.platform, setupEnvironment);
+ for (const id of ["opencode", "vscode", "copilot_cli"]) {
+ const clientLocation = clients.find(({ name }) => name === id);
+ if (clientLocation === undefined)
+ throw new Error(`Missing packaged client ${id}`);
+ const text = await readFile(clientLocation.configPath, "utf8");
+ const config = parseJsonc(text);
+ const registration =
+ id === "opencode"
+ ? config.mcp?.rea
+ : id === "vscode"
+ ? config.servers?.rea
+ : config.mcpServers?.rea;
+ const actualCommand =
+ id === "opencode"
+ ? registration?.command
+ : [registration?.command, ...(registration?.args ?? [])];
+ if (JSON.stringify(actualCommand) !== JSON.stringify(expectedCommand))
+ throw new Error(
+ `Packaged ${id} has an unusable launcher: ${JSON.stringify(actualCommand)}`,
+ );
+ if (id === "opencode" && !text.includes("// preserve user settings"))
+ throw new Error("Packaged setup discarded OpenCode JSONC comments");
+ }
+ const repeated = JSON.parse(
+ (
+ await exec(process.execPath, [entry, ...setupArgs], {
+ env: setupEnvironment,
+ windowsHide: true,
+ })
+ ).stdout,
+ );
+ if (
+ repeated.status !== "ready" ||
+ repeated.plannedActions.length !== 0 ||
+ repeated.appliedActions.length !== 0
+ )
+ throw new Error("Packaged agent setup is not idempotent");
+ const uninstalled = JSON.parse(
+ (
+ await exec(process.execPath, [entry, "uninstall", "--json"], {
+ env: setupEnvironment,
+ windowsHide: true,
+ })
+ ).stdout,
+ );
+ for (const id of ["opencode", "vscode", "copilot_cli"])
+ if (
+ uninstalled.items?.find(({ name }) => name === id)?.status !== "removed"
+ )
+ throw new Error(`Packaged uninstall did not remove ${id}`);
+
+ if (process.platform !== "win32")
+ await npm(
+ ["install", "--global", "--ignore-scripts", "--prefix", prefix, tarball],
+ workspace,
+ );
+ const update = await verifyPackageUpdate({
+ prefix,
+ packageRoot:
+ process.platform === "win32"
+ ? join(prefix, "node_modules", "rea-agents")
+ : join(prefix, "lib", "node_modules", "rea-agents"),
+ tarball,
+ workspace,
+ environment,
+ });
+
process.stdout.write(
`${JSON.stringify({
verifier_run: await completeVerifierRun(verifierRun),
@@ -97,6 +218,10 @@ try {
package: packageResult.filename,
tools: toolCount,
ghidra_bridge: "present",
+ agent_setup: ["opencode", "vscode", "copilot_cli"],
+ setup_idempotent: true,
+ uninstall: true,
+ update,
})}\n`,
);
} finally {
diff --git a/skills/reverse-engineer-anything/SKILL.md b/skills/reverse-engineer-anything/SKILL.md
index 92664d44db..ef8f10b8e2 100644
--- a/skills/reverse-engineer-anything/SKILL.md
+++ b/skills/reverse-engineer-anything/SKILL.md
@@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, an
metadata:
version: "23"
tool_count: 116
- catalog_digest: "93e02e0675739b3049b010eabe96745957e29267759a7028a66057677d1f4d92"
+ catalog_digest: "37bbb06484eee3010b083c66fdb38e3f90c770d0b9bccb603e25078538635790"
---
# REA
diff --git a/src/application/ClientConfigurationDocument.ts b/src/application/ClientConfigurationDocument.ts
index 9fce8da574..376cdfd312 100644
--- a/src/application/ClientConfigurationDocument.ts
+++ b/src/application/ClientConfigurationDocument.ts
@@ -1,14 +1,24 @@
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import { isDeepStrictEqual } from "node:util";
+import {
+ applyEdits,
+ modify,
+ parse as parseJsonc,
+ printParseErrorCode,
+ type ParseError,
+} from "jsonc-parser";
import { z } from "zod";
import type { SetupClient } from "./SupportedClients.js";
+export type ClientConfigurationFormat = NonNullable;
+export type ClientServersKey = "mcp_servers" | "mcpServers" | "mcp" | "servers";
+
/** Validated client document and its server table, preserving unrelated settings. */
export interface ClientConfigurationDocument {
readonly document: Record;
readonly servers: Record;
- readonly serversKey: "mcp_servers" | "mcpServers";
+ readonly serversKey: ClientServersKey;
}
const objectSchema = z.record(z.string(), z.unknown());
@@ -34,25 +44,117 @@ const normalizeConfigurationValue = (value: unknown): unknown => {
);
};
-/** Parse the client format and reject malformed root or server-table values. */
+/** Return the top-level object used for MCP server registrations by a client. */
+export const clientConfigurationServersKey = (
+ format: ClientConfigurationFormat,
+): ClientServersKey => {
+ switch (format) {
+ case "toml":
+ return "mcp_servers";
+ case "opencode":
+ return "mcp";
+ case "vscode":
+ return "servers";
+ default:
+ return "mcpServers";
+ }
+};
+
+const parseDocument = (
+ text: string,
+ format: ClientConfigurationFormat,
+): Record => {
+ if (format === "toml") return objectSchema.parse(parseToml(text));
+ const errors: ParseError[] = [];
+ const document = parseJsonc(text, errors, { allowTrailingComma: true });
+ const firstError = errors[0];
+ if (firstError !== undefined)
+ throw new SyntaxError(
+ `Invalid JSON/JSONC at offset ${firstError.offset}: ${printParseErrorCode(firstError.error)}`,
+ );
+ return objectSchema.parse(document);
+};
+
+/** Parse a client document and reject malformed roots or MCP server tables. */
export const parseClientConfiguration = (
text: string,
- format: SetupClient["format"],
+ format: ClientConfigurationFormat | undefined,
): ClientConfigurationDocument => {
- const document = objectSchema.parse(
- format === "toml" ? parseToml(text) : JSON.parse(text),
- );
- const serversKey = format === "toml" ? "mcp_servers" : "mcpServers";
+ if (format === undefined || format === "unsupported")
+ throw new TypeError("client does not have a supported MCP config format");
+ const document = parseDocument(text, format);
+ const serversKey = clientConfigurationServersKey(format);
const value = document[serversKey];
const servers = value === undefined ? {} : objectSchema.parse(value);
return { document, servers, serversKey };
};
-/** Serialize a validated client document using the selected format. */
+/** Serialize a validated client document, retaining JSONC comments. */
export const serializeClientConfiguration = (
document: Record,
- format: SetupClient["format"],
-): string =>
- format === "toml"
- ? stringifyToml(document)
- : `${JSON.stringify(document, null, 2)}\n`;
+ format: ClientConfigurationFormat | undefined,
+ originalText?: string,
+ editedServerName?: string,
+): string => {
+ if (format === undefined || format === "unsupported")
+ throw new TypeError("client does not have a supported MCP config format");
+ if (format === "toml") return stringifyToml(document);
+ if (originalText !== undefined && editedServerName !== undefined) {
+ const serversKey = clientConfigurationServersKey(format);
+ const servers = objectSchema.parse(document[serversKey] ?? {});
+ const editedValue = Object.hasOwn(servers, editedServerName)
+ ? servers[editedServerName]
+ : undefined;
+ const edits = modify(
+ originalText,
+ [serversKey, editedServerName],
+ editedValue,
+ {
+ formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" },
+ },
+ );
+ return applyEdits(originalText, edits);
+ }
+ return `${JSON.stringify(document, null, 2)}\n`;
+};
+
+/** Build the stdio entry shape expected by one client's configuration dialect. */
+export const clientRegistrationEntry = (
+ format: ClientConfigurationFormat,
+ command: readonly string[],
+ environment: Readonly>,
+): Record => {
+ const [executable = "rea", ...args] = command;
+ switch (format) {
+ case "opencode":
+ return {
+ type: "local",
+ command: [...command],
+ enabled: true,
+ ...(Object.keys(environment).length === 0
+ ? {}
+ : { environment: { ...environment } }),
+ };
+ case "vscode":
+ return {
+ type: "stdio",
+ command: executable,
+ args,
+ ...(Object.keys(environment).length === 0 ? {} : { env: environment }),
+ };
+ case "copilot_cli":
+ return {
+ type: "stdio",
+ command: executable,
+ args,
+ tools: ["*"],
+ ...(Object.keys(environment).length === 0 ? {} : { env: environment }),
+ };
+ default:
+ return {
+ command: executable,
+ args,
+ ...(Object.keys(environment).length === 0 ? {} : { env: environment }),
+ };
+ }
+};
diff --git a/src/application/ClientRegistrationIdentity.ts b/src/application/ClientRegistrationIdentity.ts
new file mode 100644
index 0000000000..2bc4bd105f
--- /dev/null
+++ b/src/application/ClientRegistrationIdentity.ts
@@ -0,0 +1,50 @@
+import { basename, isAbsolute, resolve } from "node:path";
+import { valid } from "semver";
+
+import { PRODUCT_IDENTITY } from "../identity.js";
+
+/** Check that one parsed command points to REA's MCP entry point. */
+export const isOwnedClientRegistrationCommand = (
+ command: readonly string[],
+ currentCommandPath: string = resolve(process.argv[1] ?? "unknown"),
+): boolean => {
+ if (
+ command.length === 3 &&
+ command[2] === "mcp" &&
+ resolve(command[0] ?? "") === resolve(process.execPath) &&
+ resolve(command[1] ?? "") === currentCommandPath
+ )
+ return true;
+
+ if (command.length === 2 && command[1] === "mcp") {
+ const executable = command[0] ?? "";
+ if (
+ executable === PRODUCT_IDENTITY.cliBinary ||
+ (isAbsolute(executable) &&
+ basename(executable) === PRODUCT_IDENTITY.cliBinary) ||
+ resolve(executable) === currentCommandPath
+ )
+ return true;
+ }
+
+ if (
+ command.length !== 4 ||
+ command[0] !== "npx" ||
+ command[1] !== "-y" ||
+ command[3] !== "mcp"
+ )
+ return false;
+
+ const packageReference = command[2] ?? "";
+ if (
+ packageReference === PRODUCT_IDENTITY.packageName ||
+ packageReference === PRODUCT_IDENTITY.packageSpecifier ||
+ packageReference === PRODUCT_IDENTITY.registrationPackageSpecifier
+ )
+ return true;
+ const versionPrefix = `${PRODUCT_IDENTITY.packageName}@`;
+ return (
+ packageReference.startsWith(versionPrefix) &&
+ valid(packageReference.slice(versionPrefix.length)) !== null
+ );
+};
diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts
index f3e6288721..1cdb806b16 100644
--- a/src/application/ClientRegistrationStatus.ts
+++ b/src/application/ClientRegistrationStatus.ts
@@ -6,7 +6,9 @@ import { z } from "zod";
import { PRODUCT_IDENTITY } from "../identity.js";
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
+import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import { supportedClients } from "./SupportedClients.js";
+import type { SetupClient } from "./SupportedClients.js";
interface ClientRegistrationStatusBase {
readonly client: string;
@@ -44,6 +46,10 @@ const registrationSchema = z
command: z.string().min(1),
args: z.array(z.string()).default([]),
startup_timeout_sec: z.number().positive().optional(),
+ type: z.string().optional(),
+ tools: z.array(z.string()).optional(),
+ disabled: z.boolean().optional(),
+ enabled: z.boolean().optional(),
})
.passthrough();
@@ -54,7 +60,10 @@ export const readClientRegistrationStatuses = async (
): Promise => {
const statuses: ClientRegistrationStatus[] = [];
for (const client of supportedClients(home)) {
- if (client.format === "unsupported" || !(await exists(client.markerPath)))
+ if (
+ client.format === "unsupported" ||
+ (!(await exists(client.markerPath)) && !(await exists(client.configPath)))
+ )
continue;
try {
const content = await readFile(client.configPath, "utf8");
@@ -66,7 +75,7 @@ export const readClientRegistrationStatuses = async (
);
continue;
}
- const registration = registrationSchema.parse(raw);
+ const registration = parseRegistration(raw, client);
const command: RegistrationCommand = [
registration.command,
...registration.args,
@@ -76,7 +85,7 @@ export const readClientRegistrationStatuses = async (
client.name,
client.configPath,
command,
- registrationAligned(registration, client.name, currentCommandPath)
+ registrationAligned(registration, client, currentCommandPath)
? "aligned"
: "stale",
),
@@ -98,16 +107,34 @@ export const readClientRegistrationStatuses = async (
const registrationAligned = (
registration: z.output,
- client: string,
+ client: SetupClient,
currentCommandPath: string,
): boolean => {
const command = [registration.command, ...registration.args];
+ if (registration.disabled === true || registration.enabled === false)
+ return false;
+ if (!isOwnedClientRegistrationCommand(command, currentCommandPath))
+ return false;
if (
- client === "codex" &&
+ command.length === 3 &&
+ command[2] === "mcp" &&
+ resolve(command[0] ?? "") === resolve(process.execPath) &&
+ resolve(command[1] ?? "") === currentCommandPath
+ )
+ return true;
+ if (
+ client.name === "codex" &&
registration.startup_timeout_sec !==
MCP_STARTUP_POLICY.codexStartupTimeoutSeconds
)
return false;
+ if (client.format === "vscode" && registration.type !== "stdio") return false;
+ if (
+ client.format === "copilot_cli" &&
+ (registration.type !== "stdio" ||
+ JSON.stringify(registration.tools) !== JSON.stringify(["*"]))
+ )
+ return false;
if (
command.length === 4 &&
command[0] === "npx" &&
@@ -123,6 +150,35 @@ const registrationAligned = (
);
};
+const parseRegistration = (
+ value: unknown,
+ client: SetupClient,
+): z.output => {
+ if (client.format === "opencode") {
+ const entry = z
+ .object({
+ type: z.literal("local"),
+ command: z.array(z.string()).min(1),
+ environment: z.record(z.string(), z.string()).optional(),
+ })
+ .passthrough()
+ .parse(value);
+ const [command = "", ...args] = entry.command;
+ return registrationSchema.parse({
+ ...entry,
+ command,
+ args,
+ });
+ }
+ const registration = registrationSchema.parse(value);
+ if (
+ (client.format === "vscode" || client.format === "copilot_cli") &&
+ registration.type !== "stdio"
+ )
+ throw new TypeError("Expected an stdio registration");
+ return registration;
+};
+
const remediation =
"Run rea setup to refresh this registration, then restart the client.";
diff --git a/src/application/Doctor.test.ts b/src/application/Doctor.test.ts
index b5d35ea12d..d32ea8b752 100644
--- a/src/application/Doctor.test.ts
+++ b/src/application/Doctor.test.ts
@@ -264,6 +264,46 @@ describe("doctor scoped readiness", () => {
expect(staleSelected.healthy).toBe(false);
});
+ it("keeps agent readiness independent of Hopper host requirements", async () => {
+ const result = await runDoctor(
+ undefined,
+ host({
+ platform: "linux",
+ linuxDistribution: () =>
+ Promise.resolve({
+ id: "debian",
+ versionId: "13",
+ packageFamily: "deb",
+ supported: false,
+ }),
+ clientRegistrations: () =>
+ Promise.resolve([
+ {
+ client: "codex",
+ config_path: "/home/user/.codex/config.toml",
+ command: ["rea", "mcp"],
+ state: "aligned",
+ remediation: null,
+ },
+ ]),
+ }),
+ { clients: ["codex"] },
+ );
+
+ expect(result).toMatchObject({
+ healthy: true,
+ environment_healthy: false,
+ scope: { mode: "explicit", clients: ["codex"], providers: [] },
+ });
+ expect(result.scope_checks.map(({ name }) => name)).toEqual([
+ "node",
+ "registration:codex",
+ ]);
+ expect(result.informational_checks).toContainEqual(
+ expect.objectContaining({ name: "host", ok: false }),
+ );
+ });
+
it("requires explicitly selected providers instead of any available provider", async () => {
const ghidra = {
id: "ghidra",
diff --git a/src/application/DoctorScope.ts b/src/application/DoctorScope.ts
index 2af795e8cc..c6ead4dfe6 100644
--- a/src/application/DoctorScope.ts
+++ b/src/application/DoctorScope.ts
@@ -7,7 +7,6 @@ import type {
DoctorProviderInspection,
DoctorScope,
} from "./Doctor.js";
-import { doctorHealthy } from "./DoctorDiagnostics.js";
/** Normalized readiness boundary included in every doctor response. */
export interface DoctorScopeReport {
@@ -57,9 +56,7 @@ export const scopeDoctorChecks = (
);
const coreChecks = input.checks.filter(
({ name }) =>
- !isProviderCheck(name, input.providers.providerInspections) &&
- name !== "skill:identity" &&
- !name.startsWith("registration:"),
+ name === "node" || (input.scope.target !== null && name === "target"),
);
const clientChecks = input.scope.clients.map((client) =>
selectedRegistrationCheck(
@@ -70,36 +67,27 @@ export const scopeDoctorChecks = (
const skillChecks = input.scope.skill
? [selectedSkillCheck(input.skillState, input.checks)]
: [];
- const selectedProviderChecks =
- input.scope.providers.length === 0
- ? configuredProviderChecks(providerChecks, input.providers)
- : input.scope.providers.flatMap((providerId) =>
- selectedProviderChecksFor(
- providerId,
- providerChecks,
- input.providers,
- ),
- );
+ const selectedProviderChecks = input.scope.providers.flatMap((providerId) =>
+ selectedProviderChecksFor(providerId, providerChecks, input.providers),
+ );
+ const hopperHostChecks = input.scope.providers.includes("hopper")
+ ? input.checks.filter(({ name }) => name === "host")
+ : [];
const scopeChecks = [
...coreChecks,
+ ...hopperHostChecks,
...selectedProviderChecks,
...clientChecks,
...skillChecks,
];
- const providerHealthy =
- input.scope.providers.length === 0
- ? doctorHealthy(
- [...coreChecks, ...providerChecks, ...clientChecks, ...skillChecks],
- input.providers,
- )
- : selectedProviderChecks.every(({ ok }) => ok);
const selectedNames = new Set(scopeChecks.map(({ name }) => name));
return {
healthy:
coreChecks.every(({ ok }) => ok) &&
+ hopperHostChecks.every(({ ok }) => ok) &&
clientChecks.every(({ ok }) => ok) &&
skillChecks.every(({ ok }) => ok) &&
- providerHealthy,
+ selectedProviderChecks.every(({ ok }) => ok),
scopeChecks,
informationalChecks: input.checks.filter(
({ name }) => !selectedNames.has(name),
@@ -114,19 +102,6 @@ const isProviderCheck = (
name.startsWith("hopper") ||
inspections?.some(({ id }) => doctorProviderCheckName(id, name)) === true;
-const configuredProviderChecks = (
- checks: readonly DoctorCheck[],
- providers: DoctorProviderState,
-): readonly DoctorCheck[] =>
- checks.filter(({ name }) =>
- name.startsWith("hopper")
- ? providers.hopperConfigured
- : providers.providerInspections?.some(
- ({ id, configured }) =>
- configured && doctorProviderCheckName(id, name),
- ) === true,
- );
-
const selectedProviderChecksFor = (
providerId: string,
checks: readonly DoctorCheck[],
diff --git a/src/application/Setup.configuration.test.ts b/src/application/Setup.configuration.test.ts
index 965e253302..dc93a0266a 100644
--- a/src/application/Setup.configuration.test.ts
+++ b/src/application/Setup.configuration.test.ts
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest";
+import { resolve } from "node:path";
import { FakeSetupHost, options } from "./Setup.fixture.js";
-import { runSetup } from "./Setup.js";
+import { runSetup, setupRegistrationCommand } from "./Setup.js";
describe("setup workflow", () => {
it("omits an aligned managed skill from an otherwise empty plan", async () => {
@@ -48,9 +49,14 @@ describe("setup workflow", () => {
host.hopper = "/custom/Hopper";
host.unsupportedHopperVersion = true;
const result = await runSetup(
- { ...options(false), structured: false },
+ {
+ ...options(true),
+ clientIds: [],
+ installSkill: false,
+ proposeHopper: false,
+ readinessScope: { providers: ["hopper"] },
+ },
host,
- () => Promise.resolve(true),
);
expect(result.status).toBe("needs_human");
expect(host.hopperInstalls).toBe(0);
@@ -63,7 +69,10 @@ describe("setup workflow", () => {
const host = new FakeSetupHost();
host.hopperInstallSucceeds = false;
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
- const result = await runSetup(options(true, true), host);
+ const result = await runSetup(
+ { ...options(true, true), clientIds: [] },
+ host,
+ );
expect(result.status).toBe("needs_human");
expect(result.code).toBe("download_failed");
expect(result.remediation).toBe("Download failed.");
@@ -80,7 +89,10 @@ describe("setup workflow", () => {
remediation: "Repair the existing TOML.",
});
- const result = await runSetup(options(true, true), host);
+ const result = await runSetup(
+ { ...options(true, true), clientIds: ["codex"] },
+ host,
+ );
expect(result.status).toBe("needs_human");
expect(result.remediation).toBe("Codex: Repair the existing TOML.");
@@ -92,6 +104,7 @@ describe("setup workflow", () => {
it("blocks malformed configuration before unrelated writes with an existing provider", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
+ host.skill = "unchanged";
host.clients = [
{
name: "claude_desktop",
@@ -105,7 +118,10 @@ describe("setup workflow", () => {
remediation: "Repair the existing JSON.",
});
- const result = await runSetup(options(true), host);
+ const result = await runSetup(
+ { ...options(true), clientIds: ["claude_desktop"] },
+ host,
+ );
expect(result.status).toBe("needs_human");
expect(result.remediation).toBe(
@@ -122,7 +138,10 @@ describe("setup workflow", () => {
];
host.clientInspections.set("codex", { status: "already_current" });
- const result = await runSetup(options(false), host);
+ const result = await runSetup(
+ { ...options(false, true), clientIds: ["codex"] },
+ host,
+ );
expect(
result.plannedActions.map(({ id, operation }) => ({
@@ -141,6 +160,7 @@ describe("setup workflow action selection", () => {
it("executes only actions selected by the interactive adapter", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
+ host.skill = "unchanged";
host.clients = [
{ name: "codex", displayName: "Codex", configPath: "/codex.toml" },
{ name: "cursor", displayName: "Cursor", configPath: "/cursor.json" },
@@ -164,6 +184,83 @@ describe("setup workflow action selection", () => {
expect(host.skillInstalls).toBe(0);
});
+ it("preflights only selected clients and offers supported clients without markers", async () => {
+ const host = new FakeSetupHost();
+ host.hopper = "/Applications/Hopper";
+ host.skill = "unchanged";
+ host.clients = [];
+ host.availableClients = [
+ { name: "codex", displayName: "Codex", configPath: "/codex.toml" },
+ { name: "cursor", displayName: "Cursor", configPath: "/cursor.json" },
+ ];
+ host.clientInspections.set("cursor", {
+ status: "invalid",
+ remediation: "Repair the deselected Cursor configuration.",
+ });
+
+ const result = await runSetup(
+ { ...options(true), clientIds: ["codex"] },
+ host,
+ );
+
+ expect(result.status).toBe("ready");
+ expect(result.plannedActions.map(({ id }) => id)).toEqual([
+ "configure_client:codex",
+ ]);
+ expect(host.configurations).toBe(1);
+ expect(result.clientStates).toMatchObject([
+ { client: { name: "codex" }, detected: false, status: "missing" },
+ { client: { name: "cursor" }, detected: false, status: "missing" },
+ ]);
+ });
+
+ it("does not default to a foreign command registered under the rea key", async () => {
+ const host = new FakeSetupHost();
+ host.hopper = "/Applications/Hopper";
+ host.skill = "unchanged";
+ host.clients = [{ name: "codex", configPath: "/codex.toml" }];
+ host.productRegistrations = [
+ {
+ client: "codex",
+ config_path: "/codex.toml",
+ command: ["unrelated-agent", "serve"],
+ state: "stale",
+ remediation: "Refresh this registration.",
+ },
+ ];
+
+ const result = await runSetup(options(true), host);
+
+ expect(result.status).toBe("ready");
+ expect(result.plannedActions).toEqual([]);
+ expect(host.configurations).toBe(0);
+ expect(result.clientStates).toMatchObject([
+ { client: { name: "codex" }, configured: false },
+ ]);
+ });
+
+ it("uses a Windows-safe executable command for an explicit client", async () => {
+ const host = new FakeSetupHost("win32");
+ host.scopedDoctorHealthy = true;
+ host.skill = "unchanged";
+ host.clients = [{ name: "codex", configPath: "C:\\config\\codex.toml" }];
+
+ const result = await runSetup(
+ { ...options(true), clientIds: ["codex"], installSkill: false },
+ host,
+ );
+
+ expect(result.status).toBe("ready");
+ expect(host.configuredCommands).toEqual([
+ setupRegistrationCommand("win32"),
+ ]);
+ expect(setupRegistrationCommand("win32", false)).toEqual([
+ process.execPath,
+ resolve(process.argv[1] ?? "rea"),
+ "mcp",
+ ]);
+ });
+
it.each([
[
"path",
@@ -186,7 +283,10 @@ describe("setup workflow action selection", () => {
host.hopper = "/Applications/Hopper";
host.clients = [{ name: "internal_client_key", configPath: "/agent.json" }];
host.clientResults.set("internal_client_key", { status: "failed", reason });
- const result = await runSetup(options(true), host);
+ const result = await runSetup(
+ { ...options(true), clientIds: ["internal_client_key"] },
+ host,
+ );
expect(result.status).toBe("needs_human");
expect(result.remediation).toBe(message);
expect(result.remediation).not.toContain("internal_client_key");
diff --git a/src/application/Setup.discovery.test.ts b/src/application/Setup.discovery.test.ts
new file mode 100644
index 0000000000..93fda1beb6
--- /dev/null
+++ b/src/application/Setup.discovery.test.ts
@@ -0,0 +1,26 @@
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import { afterEach, describe, expect, it } from "vitest";
+
+import { detectClients } from "./Setup.js";
+
+describe("setup client discovery", () => {
+ let temporaryHome: string | undefined;
+
+ afterEach(async () => {
+ if (temporaryHome !== undefined)
+ await rm(temporaryHome, { recursive: true, force: true });
+ temporaryHome = undefined;
+ });
+
+ it("detects an existing config even when the marker directory is absent", async () => {
+ temporaryHome = await mkdtemp(join(tmpdir(), "rea-setup-discovery-"));
+ await writeFile(join(temporaryHome, ".claude.json"), "{}\n");
+
+ expect(
+ (await detectClients(temporaryHome)).map(({ name }) => name),
+ ).toContain("claude_code");
+ });
+});
diff --git a/src/application/Setup.fixture.ts b/src/application/Setup.fixture.ts
index da314d0cc9..1e7d182ad1 100644
--- a/src/application/Setup.fixture.ts
+++ b/src/application/Setup.fixture.ts
@@ -9,6 +9,9 @@ import {
} from "./Setup.js";
import type { DoctorCheck, DoctorReport, DoctorScope } from "./Doctor.js";
import type { LinuxDistribution } from "./LinuxHopper.js";
+import { PRODUCT_IDENTITY, SDK_IDENTITY } from "../identity.js";
+import { CATALOG_IDENTITY } from "../catalogIdentity.js";
+import type { ClientRegistrationStatus } from "./ClientRegistrationStatus.js";
/** Recording setup host for service-level planning and recovery tests. */
export class FakeSetupHost implements SetupHost {
@@ -22,6 +25,8 @@ export class FakeSetupHost implements SetupHost {
hopperInstallSucceeds = true;
skill: "installed" | "unchanged" | "failed" = "installed";
clients: readonly SetupClient[] = [];
+ availableClients: readonly SetupClient[] | undefined;
+ productRegistrations: readonly ClientRegistrationStatus[] = [];
clientResults = new Map();
clientInspections = new Map();
hopperInstalls = 0;
@@ -32,6 +37,7 @@ export class FakeSetupHost implements SetupHost {
doctorScopes: Array = [];
checkedHopperPaths: Array = [];
configuredProviderEnvironments: SetupProviderEnvironment[] = [];
+ configuredCommands: Array = [];
doctorHealthy: boolean | undefined;
scopedDoctorHealthy: boolean | undefined;
linuxDemoRuntimeMissing = false;
@@ -75,12 +81,16 @@ export class FakeSetupHost implements SetupHost {
};
detectedClients = (): Promise =>
Promise.resolve(this.clients);
+ supportedClients = (): Promise =>
+ Promise.resolve(this.availableClients ?? this.clients);
configureClient = (
client: SetupClient,
providerEnvironment: SetupProviderEnvironment,
+ command: readonly string[],
): Promise => {
this.configurations += 1;
this.configuredProviderEnvironments.push(providerEnvironment);
+ this.configuredCommands.push(command);
return Promise.resolve(
this.clientResults.get(client.name) ?? { status: "configured" },
);
@@ -138,7 +148,11 @@ export class FakeSetupHost implements SetupHost {
const healthy =
scope === undefined
? environmentHealthy
- : (this.scopedDoctorHealthy ?? environmentHealthy);
+ : (scope.clients?.length ?? 0) === 0 &&
+ (scope.providers?.length ?? 0) === 0 &&
+ scope.skill !== true
+ ? true
+ : (this.scopedDoctorHealthy ?? environmentHealthy);
return Promise.resolve({
healthy,
environment_healthy: environmentHealthy,
@@ -153,6 +167,25 @@ export class FakeSetupHost implements SetupHost {
informational_checks: [],
...(this.hopper === undefined ? {} : { hopperPath: this.hopper }),
checks,
+ identity: {
+ cli_package_version: PRODUCT_IDENTITY.packageVersion,
+ expected_skill_version: PRODUCT_IDENTITY.skillVersion,
+ sdk: SDK_IDENTITY,
+ catalog: CATALOG_IDENTITY,
+ live_server: {
+ state: "unknown",
+ remediation: "Inspect the running server.",
+ },
+ installations: { paths: [], state: "unknown" },
+ skill: {
+ installed_version: null,
+ installed_tool_count: null,
+ installed_catalog_digest: null,
+ state: this.skill === "unchanged" ? "aligned" : "missing",
+ remediation: null,
+ },
+ registrations: this.productRegistrations,
+ },
});
};
}
diff --git a/src/application/Setup.planning.test.ts b/src/application/Setup.planning.test.ts
index 30ad11d736..c830346942 100644
--- a/src/application/Setup.planning.test.ts
+++ b/src/application/Setup.planning.test.ts
@@ -7,10 +7,12 @@ describe("setup workflow", () => {
it("returns a complete plan without mutation", async () => {
const host = new FakeSetupHost();
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
- const result = await runSetup(options(false), host);
+ const result = await runSetup(
+ { ...options(false), clientIds: ["cursor"] },
+ host,
+ );
expect(result.status).toBe("needs_confirmation");
expect(result.plannedActions.map(({ kind }) => kind)).toEqual([
- "install_hopper",
"configure_client",
"install_skill",
]);
@@ -61,7 +63,7 @@ describe("setup workflow", () => {
host,
() => Promise.resolve(false),
);
- expect(result.status).toBe("planned");
+ expect(result.status).toBe("cancelled");
expect(result.appliedActions).toEqual([]);
expect(host.hopperInstalls).toBe(0);
expect(host.doctorCalls).toBe(1);
@@ -77,24 +79,40 @@ describe("setup workflow", () => {
() => Promise.resolve({ approved: false, selectedActionIds: [] }),
);
- expect(result.status).toBe("planned");
+ expect(result.status).toBe("ready");
expect(result.plannedActions).toEqual([]);
expect(result.appliedActions).toEqual([]);
expect(host.configurations).toBe(0);
expect(host.skillInstalls).toBe(0);
expect(host.hopperInstalls).toBe(0);
- expect(host.doctorCalls).toBe(1);
+ expect(host.doctorCalls).toBe(2);
});
it("requires the Hopper flag for unattended setup", async () => {
const host = new FakeSetupHost();
const result = await runSetup(options(true), host);
- expect(result.status).toBe("needs_human");
+ expect(result.status).toBe("ready");
expect(host.hopperInstalls).toBe(0);
- expect(result.appliedActions).toEqual(["installed_skill"]);
- expect(result.remediation).toBe(
- "Hopper is optional for non-Hopper providers. Rerun with --yes --install-hopper for deep native analysis.",
+ expect(result.appliedActions).toEqual([]);
+ });
+
+ it("returns an approved planning outcome for explicit dry runs", async () => {
+ const host = new FakeSetupHost();
+ host.hopper = "/Applications/Hopper";
+ host.skill = "unchanged";
+ host.clients = [{ name: "codex", configPath: "/codex.toml" }];
+
+ const result = await runSetup(
+ { ...options(false), dryRun: true, clientIds: ["codex"] },
+ host,
);
+
+ expect(result.status).toBe("planned");
+ expect(result.plannedActions.map(({ id }) => id)).toEqual([
+ "configure_client:codex",
+ ]);
+ expect(host.hopperInstalls).toBe(0);
+ expect(host.configurations).toBe(0);
});
it("installs Hopper when unattended authorization is explicit", async () => {
@@ -106,16 +124,13 @@ describe("setup workflow", () => {
supported: true,
};
const result = await runSetup(options(true, true), host);
- expect(result.appliedActions).toEqual([
- "installed_hopper",
- "installed_skill",
- ]);
+ expect(result.appliedActions).toEqual(["installed_hopper"]);
expect(host.hopperInstalls).toBe(1);
expect(result.status).toBe("ready");
expect(result.remediation).toBeUndefined();
});
- it("reuses existing Hopper and configures detected clients", async () => {
+ it("reuses existing Hopper without selecting every detected client", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.clients = [
@@ -124,11 +139,7 @@ describe("setup workflow", () => {
];
const result = await runSetup(options(true), host);
expect(result.status).toBe("ready");
- expect(result.appliedActions).toEqual([
- "configured_claude",
- "configured_cursor",
- "installed_skill",
- ]);
+ expect(result.appliedActions).toEqual([]);
expect(host.hopperInstalls).toBe(0);
});
});
@@ -144,6 +155,24 @@ describe("setup workflow lifecycle and provider planning", () => {
configPath: "/codex.toml",
},
];
+ host.productRegistrations = [
+ {
+ client: "codex",
+ config_path: "/codex.toml",
+ command: ["rea", "mcp"],
+ state: "aligned",
+ remediation: null,
+ },
+ ];
+ host.productRegistrations = [
+ {
+ client: "codex",
+ config_path: "/codex.toml",
+ command: ["rea", "mcp"],
+ state: "aligned",
+ remediation: null,
+ },
+ ];
const progress: SetupProgressEvent[] = [];
await runSetup(
{ ...options(true), onProgress: (event) => progress.push(event) },
@@ -183,7 +212,10 @@ describe("setup workflow lifecycle and provider planning", () => {
host.skill = "unchanged";
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
- const plan = await runSetup(options(false), host);
+ const plan = await runSetup(
+ { ...options(false), clientIds: ["cursor"] },
+ host,
+ );
expect(plan.plannedActions.map(({ kind }) => kind)).toEqual([
"configure_client",
]);
@@ -196,7 +228,10 @@ describe("setup workflow lifecycle and provider planning", () => {
expect(host.hopperInstalls).toBe(0);
expect(host.configurations).toBe(0);
- const applied = await runSetup(options(true), host);
+ const applied = await runSetup(
+ { ...options(true), clientIds: ["cursor"] },
+ host,
+ );
expect(applied.status).toBe("ready");
expect(host.hopperInstalls).toBe(0);
expect(host.configuredProviderEnvironments).toContainEqual({
@@ -212,7 +247,10 @@ describe("setup workflow lifecycle and provider planning", () => {
host.clients = [{ name: "codex", configPath: "/codex.toml" }];
host.clientResults.set("codex", { status: "unchanged" });
- const result = await runSetup(options(true, true), host);
+ const result = await runSetup(
+ { ...options(true, true), clientIds: ["codex"] },
+ host,
+ );
expect(result.plannedActions.map(({ kind }) => kind)).toEqual([
"install_hopper",
diff --git a/src/application/Setup.recovery.test.ts b/src/application/Setup.recovery.test.ts
index 1cdd578166..f228582afc 100644
--- a/src/application/Setup.recovery.test.ts
+++ b/src/application/Setup.recovery.test.ts
@@ -16,7 +16,10 @@ describe("setup workflow", () => {
host.clientResults.set("second", { status: "configured" });
host.clientResults.set("third", { status: "failed", reason: "readback" });
- const result = await runSetup(options(true), host);
+ const result = await runSetup(
+ { ...options(true), clientIds: ["first", "second", "third"] },
+ host,
+ );
expect(host.configurations).toBe(3);
expect(result.clients).toEqual({
@@ -33,7 +36,10 @@ describe("setup workflow", () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.skill = "failed";
- const result = await runSetup(options(true), host);
+ const result = await runSetup(
+ { ...options(true), installSkill: true, clientIds: [] },
+ host,
+ );
expect(result.status).toBe("needs_human");
expect(result.remediation).toBe(
"REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.",
@@ -44,7 +50,10 @@ describe("setup workflow", () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.doctorHealthy = false;
- const result = await runSetup(options(true), host);
+ const result = await runSetup(
+ { ...options(true), readinessScope: { providers: ["hopper"] } },
+ host,
+ );
expect(result.remediation).toBe(
"Run rea doctor and apply each reported remediation.",
);
@@ -55,6 +64,7 @@ describe("setup workflow", () => {
host.hopper = "/Applications/Hopper";
host.doctorHealthy = false;
host.scopedDoctorHealthy = true;
+ host.clients = [{ name: "codex", configPath: "/codex.toml" }];
const readinessScope = {
clients: ["codex"],
providers: [] as string[],
@@ -77,12 +87,20 @@ describe("setup workflow", () => {
});
expect(host.doctorScopes).toEqual(expect.arrayContaining([readinessScope]));
});
+});
- it("explains unsupported Node and macOS recovery", async () => {
+describe("setup scoped readiness", () => {
+ it("limits Hopper host checks to workflows that request Hopper", async () => {
const platformHost = new FakeSetupHost("win32");
- expect((await runSetup(options(true), platformHost)).remediation).toBe(
- "REA supports Hopper on macOS and selected 64-bit Linux distributions.",
+ platformHost.hopper = "/Applications/Hopper";
+ platformHost.skill = "unchanged";
+ platformHost.clients = [{ name: "codex", configPath: "/codex.toml" }];
+ const windowsAgent = await runSetup(
+ { ...options(true), clientIds: ["codex"] },
+ platformHost,
);
+ expect(windowsAgent.status).toBe("ready");
+ expect(platformHost.configurations).toBe(1);
const nodeHost = new FakeSetupHost();
nodeHost.nodeVersion = "20.0.0";
@@ -92,11 +110,60 @@ describe("setup workflow", () => {
const macHost = new FakeSetupHost();
macHost.version = "11.7";
- expect((await runSetup(options(true), macHost)).remediation).toBe(
+ expect((await runSetup(options(true), macHost)).status).toBe("ready");
+ expect((await runSetup(options(true, true), macHost)).remediation).toBe(
"Upgrade to macOS 12 or newer.",
);
});
+ it("keeps an unchanged selected client in final readiness checks", async () => {
+ const host = new FakeSetupHost();
+ host.hopper = "/Applications/Hopper";
+ host.skill = "unchanged";
+ host.clients = [{ name: "codex", configPath: "/codex.toml" }];
+ host.productRegistrations = [
+ {
+ client: "codex",
+ config_path: "/codex.toml",
+ command: ["rea", "mcp"],
+ state: "aligned",
+ remediation: null,
+ },
+ ];
+ host.clientInspections.set("codex", { status: "already_current" });
+ host.scopedDoctorHealthy = true;
+
+ const result = await runSetup(options(true), host);
+
+ expect(result.status).toBe("ready");
+ expect(result.plannedActions).toEqual([]);
+ expect(host.doctorScopes.at(-1)).toEqual({
+ clients: ["codex"],
+ providers: [],
+ skill: true,
+ });
+ });
+
+ it("does not advertise an unhealthy Hopper in the selected readiness summary", async () => {
+ const host = new FakeSetupHost("win32");
+ host.hopper = "C:\\Hopper\\Hopper.exe";
+ host.unsupportedHopperVersion = true;
+ host.clients = [{ name: "codex", configPath: "C:\\codex.toml" }];
+ host.scopedDoctorHealthy = true;
+
+ const result = await runSetup(
+ {
+ ...options(true),
+ clientIds: ["codex"],
+ installSkill: false,
+ },
+ host,
+ );
+
+ expect(result.status).toBe("ready");
+ expect(result.doctor.availableProviders).toEqual([]);
+ });
+
it("rejects unsupported hosts before mutation", async () => {
const host = new FakeSetupHost("linux");
host.distribution = {
@@ -127,7 +194,10 @@ describe("setup workflow", () => {
host.skill = "unchanged";
host.clients = [{ name: "codex", configPath: "/codex.toml" }];
- const result = await runSetup(options(true), host);
+ const result = await runSetup(
+ { ...options(true), clientIds: ["codex"] },
+ host,
+ );
expect(result.status).toBe("ready");
expect(host.hopperInstalls).toBe(0);
diff --git a/src/application/Setup.ts b/src/application/Setup.ts
index 8f8f99b4c5..e286cbda17 100644
--- a/src/application/Setup.ts
+++ b/src/application/Setup.ts
@@ -8,6 +8,7 @@ import {
runDoctor,
systemDoctorHost,
type DoctorHost,
+ type DoctorReport,
type DoctorScope,
} from "./Doctor.js";
import {
@@ -23,9 +24,10 @@ import {
canonicalSkillNeedsInstall,
installCanonicalSkill,
} from "./SetupSkill.js";
-import { discoverSetupActions } from "./SetupPlan.js";
+import { discoverSetupState, planSetupActions } from "./SetupPlan.js";
import {
clientConfigurationAligned,
+ configureClientConfiguration,
configureJsonClient,
configureTomlClient,
inspectClientConfiguration,
@@ -44,10 +46,20 @@ export type SetupProviderEnvironment = Readonly>;
export { canonicalSkillNeedsInstall, installCanonicalSkill };
-const registrationCommand = (): readonly string[] =>
- process.env.npm_command === "exec"
+/** Resolve the executable and arguments used in a managed MCP registration. */
+export const setupRegistrationCommand = (
+ platform: NodeJS.Platform,
+ useNpmRunner: boolean = process.env.npm_command === "exec",
+): readonly string[] =>
+ useNpmRunner
? PRODUCT_IDENTITY.mcpCommand.split(" ")
- : [resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary), "mcp"];
+ : platform === "win32"
+ ? [
+ process.execPath,
+ resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary),
+ "mcp",
+ ]
+ : [resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary), "mcp"];
/** Result of one backup/write/readback transaction. */
export type ClientConfigurationResult =
@@ -81,6 +93,7 @@ export interface SetupHost {
installHopper(replaceExisting: boolean): Promise;
providerEnvironment?(): Promise;
detectedClients(): Promise;
+ supportedClients?(): Promise;
configureClient(
client: SetupClient,
providerEnvironment: SetupProviderEnvironment,
@@ -102,15 +115,44 @@ export interface SetupHost {
}
/** Structured setup outcome carrying remediation instead of prompting. */
export interface SetupResult {
- readonly status: "planned" | "needs_confirmation" | "ready" | "needs_human";
+ readonly status:
+ | "planned"
+ | "cancelled"
+ | "needs_confirmation"
+ | "ready"
+ | "needs_human";
readonly plannedActions: readonly SetupAction[];
readonly appliedActions: readonly string[];
readonly clients: Readonly>;
- readonly doctor: Awaited>;
+ readonly doctor: SetupDoctorSummary;
+ /** Supported-client discovery state captured before setup mutations. */
+ readonly clientStates: readonly SetupClientState[];
readonly remediation?: string;
readonly code?: SetupFailureCode;
}
+/** Relevant health and identity evidence for the selected setup scope. */
+export interface SetupDoctorSummary {
+ readonly healthy: boolean;
+ readonly environment_healthy: boolean;
+ readonly scope: DoctorReport["scope"];
+ readonly hopperPath?: string;
+ readonly availableProviders: readonly string[];
+ readonly providerInspections?: DoctorReport["providerInspections"];
+ readonly identity?: Pick<
+ NonNullable,
+ "skill" | "registrations"
+ >;
+}
+
+/** Read-only state for one supported client location. */
+export interface SetupClientState {
+ readonly client: SetupClient;
+ readonly detected: boolean;
+ readonly configured: boolean;
+ readonly status: "configured" | "missing" | "needs_configuration" | "invalid";
+}
+
/** One concrete setup mutation disclosed before approval. */
export interface SetupAction {
readonly id: string;
@@ -135,6 +177,8 @@ export interface SetupOptions {
readonly clientIds?: readonly string[];
readonly installSkill?: boolean;
readonly readinessScope?: DoctorScope;
+ readonly dryRun?: boolean;
+ readonly allDetectedClients?: boolean;
readonly onProgress?: (event: SetupProgressEvent) => void;
}
@@ -150,11 +194,21 @@ export interface SetupProgressEvent {
export interface SetupConfirmationDecision {
readonly approved: boolean;
readonly selectedActionIds: readonly string[];
+ readonly cancelled?: boolean;
}
-/** Adapter-owned interactive confirmation for a fully discovered setup plan. */
+/** Read-only context shown while selecting clients or reviewing exact actions. */
+export interface SetupConfirmationContext {
+ readonly stage: "select" | "confirm";
+ readonly clientStates: readonly SetupClientState[];
+ readonly selectedClientIds: readonly string[];
+ readonly clientSelectionAllowed: boolean;
+}
+
+/** Adapter-owned selection and confirmation for the setup workflow. */
export type SetupConfirmation = (
actions: readonly SetupAction[],
+ context?: SetupConfirmationContext,
) => Promise;
/** Discover, approve, and apply setup actions idempotently. */
@@ -166,44 +220,179 @@ export const runSetup = async (
const appliedActions: string[] = [];
let plannedActions: readonly SetupAction[] = [];
const clients: Record = {};
+ let clientStates: readonly SetupClientState[] = [];
+ let readinessScope = options.readinessScope;
const fail = (remediation: string) =>
setupFailure(
remediation,
- [host, plannedActions, appliedActions, clients],
- options.readinessScope,
+ [host, plannedActions, appliedActions, clients, clientStates],
+ readinessScope,
);
- const unsupported = await hostRemediation(host, options.installHopper);
+ const unsupported = await hostRemediation(host, false);
if (unsupported !== undefined) return fail(unsupported);
let hopperPath = await host.hopperPath();
let providerEnvironment = await initialProviderEnvironment(host, hopperPath);
- const discovery = await discoverSetupActions({
+ const discovery = await discoverSetupState({
host,
providerEnvironment,
- command: registrationCommand(),
forceHopperInstall: options.installHopper,
- proposeHopper: options.proposeHopper ?? true,
- selectedClientIds: options.clientIds,
- installSkillSelection: options.installSkill,
+ proposeHopper:
+ options.proposeHopper ?? (confirm !== undefined && !options.structured),
doctorScope: options.readinessScope,
});
- if (discovery.blocker !== undefined) return fail(discovery.blocker);
- const selection = await resolveSetupSelection(options, discovery, confirm);
- plannedActions = selection.plannedActions;
- if (!selection.approved)
+ clientStates = discovery.clientStates;
+ const clientSelectionAllowed =
+ options.clientIds === undefined &&
+ options.readinessScope?.clients === undefined &&
+ options.allDetectedClients !== true &&
+ options.installHopper !== true &&
+ options.installSkill === undefined;
+ const interactiveSelection =
+ confirm !== undefined && !options.structured && clientSelectionAllowed;
+ let selectedClientIds = options.allDetectedClients
+ ? discovery.detectedClientIds
+ : (options.clientIds ??
+ options.readinessScope?.clients ??
+ discovery.defaultClientIds);
+ let skillSelected =
+ options.installSkill === true ||
+ (options.installSkill !== false && selectedClientIds.length > 0);
+ let installSkill = skillSelected && discovery.skillNeedsInstall;
+ let planDiscovery = discovery;
+ if (interactiveSelection) {
+ const offerSkillAction =
+ options.installSkill !== false && discovery.skillNeedsInstall;
+ const selectionActions = await planSetupActions({
+ discovery,
+ host,
+ providerEnvironment,
+ command: setupRegistrationCommand(host.platform),
+ clientIds: [],
+ installSkill: offerSkillAction,
+ });
+ const decision = await confirm(selectionActions.plannedActions, {
+ stage: "select",
+ clientStates,
+ selectedClientIds: discovery.defaultClientIds,
+ clientSelectionAllowed,
+ });
+ if (isSetupDecisionCancelled(decision))
+ return setupCancelled(
+ selectionActions.plannedActions,
+ clients,
+ clientStates,
+ discovery.initialDoctor,
+ );
+ const selectedActionIds = new Set(
+ typeof decision === "boolean"
+ ? decision
+ ? [
+ ...selectionActions.plannedActions.map(({ id }) => id),
+ ...discovery.defaultClientIds.map(
+ (id) => `configure_client:${id}`,
+ ),
+ ]
+ : []
+ : decision.selectedActionIds,
+ );
+ selectedClientIds = [...selectedActionIds]
+ .filter((id) => id.startsWith("configure_client:"))
+ .map((id) => id.slice("configure_client:".length));
+ const proposedInstallAction = selectionActions.plannedActions.some(
+ ({ id }) => id === "install_hopper",
+ );
+ planDiscovery = {
+ ...discovery,
+ installHopper:
+ options.installHopper ||
+ (proposedInstallAction && selectedActionIds.has("install_hopper")),
+ };
+ skillSelected =
+ options.installSkill === true ||
+ (selectedActionIds.has("install_skill") &&
+ selectedClientIds.length === 0) ||
+ (options.installSkill !== false && selectedClientIds.length > 0);
+ installSkill = skillSelected && discovery.skillNeedsInstall;
+ }
+ const planned = await planSetupActions({
+ discovery: planDiscovery,
+ host,
+ providerEnvironment,
+ command: setupRegistrationCommand(host.platform),
+ clientIds: selectedClientIds,
+ installSkill,
+ });
+ plannedActions = planned.plannedActions;
+ if (planned.blocker !== undefined) return fail(planned.blocker);
+ const hopperBlocker = await hostRemediation(
+ host,
+ planDiscovery.installHopper,
+ );
+ if (hopperBlocker !== undefined) return fail(hopperBlocker);
+ const planSelection = {
+ installHopper: planDiscovery.installHopper,
+ installSkill,
+ skillSelected,
+ selectedClients: planned.selectedClients,
+ clientsToConfigure: planned.clientsToConfigure,
+ };
+ readinessScope = resolvedReadinessScope(
+ options.readinessScope,
+ planSelection,
+ );
+
+ if (options.dryRun === true)
return {
- status: confirm === undefined ? "needs_confirmation" : "planned",
+ status: "planned",
plannedActions,
appliedActions,
clients,
- doctor: discovery.initialDoctor,
- remediation:
- "Review the setup plan, then rerun interactively or with --yes.",
+ doctor: summarizeDoctor(discovery.initialDoctor),
+ clientStates,
};
- let detectedClients: readonly SetupClient[] = selection.detectedClients;
+ let approved = options.approved || plannedActions.length === 0;
+ let interactiveApproval = false;
+ if (!approved && confirm !== undefined && !options.structured) {
+ const decision = await confirm?.(plannedActions, {
+ stage: "confirm",
+ clientStates,
+ selectedClientIds,
+ clientSelectionAllowed: false,
+ });
+ if (decision !== undefined) {
+ if (isSetupDecisionCancelled(decision))
+ return setupCancelled(
+ plannedActions,
+ clients,
+ clientStates,
+ discovery.initialDoctor,
+ );
+ approved = typeof decision === "boolean" ? decision : decision.approved;
+ interactiveApproval = approved;
+ }
+ }
+ if (!approved)
+ return {
+ status: confirm === undefined ? "needs_confirmation" : "cancelled",
+ plannedActions,
+ appliedActions,
+ clients,
+ doctor: summarizeDoctor(discovery.initialDoctor),
+ clientStates,
+ ...(confirm === undefined
+ ? {
+ remediation:
+ "Review the setup plan, then rerun interactively or with --yes.",
+ }
+ : {}),
+ };
+
+ let selectedClients: readonly SetupClient[] =
+ planSelection.clientsToConfigure;
if (
- selection.installHopper &&
- (selection.interactiveApproval || options.installHopper)
+ planSelection.installHopper &&
+ (interactiveApproval || options.installHopper)
) {
const install = await installHopperAction({
host,
@@ -211,18 +400,19 @@ export const runSetup = async (
plannedActions,
appliedActions,
clients,
- detectedClients,
+ selectedClients,
providerEnvironment,
- doctorScope: options.readinessScope,
+ doctorScope: readinessScope,
+ clientStates,
});
if ("failure" in install) return install.failure;
- ({ hopperPath, providerEnvironment, detectedClients } = install);
+ ({ hopperPath, providerEnvironment, selectedClients } = install);
}
const clientFailure = await configureDetectedClients({
host,
- detectedClients,
+ detectedClients: selectedClients,
providerEnvironment,
- command: registrationCommand(),
+ command: setupRegistrationCommand(host.platform),
clients,
appliedActions,
...(options.onProgress === undefined
@@ -231,13 +421,13 @@ export const runSetup = async (
});
if (clientFailure !== undefined) return fail(clientFailure);
if (
- selection.installSkill &&
+ planSelection.installSkill &&
!(await installSkillAction(host, options, appliedActions))
)
return fail(
"REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.",
);
- const doctor = await host.doctor(options.readinessScope);
+ const doctor = summarizeDoctor(await host.doctor(readinessScope));
const remediation = finalSetupRemediation(
host.platform,
appliedActions.includes("installed_hopper"),
@@ -250,59 +440,21 @@ export const runSetup = async (
appliedActions,
clients,
doctor,
+ clientStates,
...(remediation === undefined ? {} : { remediation }),
};
};
-const resolveSetupSelection = async (
- options: SetupOptions,
- discovery: Awaited>,
- confirm: SetupConfirmation | undefined,
-) => {
- let { installHopper, installSkill, detectedClients, plannedActions } =
- discovery;
- let approved = options.approved || plannedActions.length === 0;
- let interactiveApproval = false;
- if (!approved && confirm !== undefined && !options.structured) {
- const decision = await confirm(plannedActions);
- if (typeof decision === "boolean") {
- interactiveApproval = decision;
- approved = decision;
- } else {
- const selected = new Set(decision.selectedActionIds);
- plannedActions = plannedActions.filter(({ id }) => selected.has(id));
- installHopper = plannedActions.some(
- ({ kind }) => kind === "install_hopper",
- );
- installSkill = plannedActions.some(
- ({ kind }) => kind === "install_skill",
- );
- detectedClients = detectedClients.filter((client) =>
- selected.has(`configure_client:${client.name}`),
- );
- interactiveApproval = decision.approved;
- approved = decision.approved;
- }
- }
- return {
- approved,
- interactiveApproval,
- installHopper,
- installSkill,
- detectedClients,
- plannedActions,
- };
-};
-
const installHopperAction = async (input: {
readonly host: SetupHost;
readonly options: SetupOptions;
readonly plannedActions: readonly SetupAction[];
readonly appliedActions: string[];
readonly clients: Readonly>;
- readonly detectedClients: readonly SetupClient[];
+ readonly selectedClients: readonly SetupClient[];
readonly providerEnvironment: SetupProviderEnvironment;
readonly doctorScope: DoctorScope | undefined;
+ readonly clientStates: readonly SetupClientState[];
}) => {
const label = "Hopper deep-analysis provider";
emitProgress(input.options, {
@@ -324,7 +476,8 @@ const installHopperAction = async (input: {
plannedActions: input.plannedActions,
appliedActions: input.appliedActions,
clients: input.clients,
- doctor: await input.host.doctor(input.doctorScope),
+ doctor: summarizeDoctor(await input.host.doctor(input.doctorScope)),
+ clientStates: input.clientStates,
code: installed.code,
remediation: installed.remediation,
},
@@ -344,11 +497,11 @@ const installHopperAction = async (input: {
return {
hopperPath: installed.launcherPath,
providerEnvironment,
- detectedClients: await filterClientsNeedingConfigure(
+ selectedClients: await filterClientsNeedingConfigure(
input.host,
- input.detectedClients,
+ input.selectedClients,
providerEnvironment,
- registrationCommand(),
+ setupRegistrationCommand(input.host.platform),
),
};
};
@@ -385,21 +538,25 @@ const installSkillAction = async (
const setupFailure = async (
remediation: string,
- [host, plannedActions, appliedActions, clients]: readonly [
+ [host, plannedActions, appliedActions, clients, clientStates]: readonly [
SetupHost,
readonly SetupAction[],
readonly string[],
Readonly>,
+ readonly SetupClientState[],
],
scope?: DoctorScope,
-): Promise => ({
- status: "needs_human",
- plannedActions,
- appliedActions,
- clients,
- doctor: await host.doctor(scope),
- remediation,
-});
+): Promise => {
+ return {
+ status: "needs_human",
+ plannedActions,
+ appliedActions,
+ clients,
+ doctor: summarizeDoctor(await host.doctor(scope)),
+ clientStates,
+ remediation,
+ };
+};
const emitProgress = (options: SetupOptions, event: SetupProgressEvent): void =>
options.onProgress?.(event);
@@ -440,14 +597,78 @@ const finalSetupRemediation = (
: "Run rea doctor and apply each reported remediation.";
};
+const isSetupDecisionCancelled = (
+ decision: boolean | SetupConfirmationDecision,
+): boolean =>
+ typeof decision === "boolean" ? !decision : decision.cancelled === true;
+
+const setupCancelled = (
+ plannedActions: readonly SetupAction[],
+ clients: Readonly>,
+ clientStates: readonly SetupClientState[],
+ doctor: DoctorReport,
+): SetupResult => ({
+ status: "cancelled",
+ plannedActions,
+ appliedActions: [],
+ clients,
+ doctor: summarizeDoctor(doctor),
+ clientStates,
+});
+
+const resolvedReadinessScope = (
+ requested: DoctorScope | undefined,
+ selection: {
+ readonly selectedClients: readonly SetupClient[];
+ readonly installHopper: boolean;
+ readonly skillSelected?: boolean;
+ },
+): DoctorScope => ({
+ clients: selection.selectedClients.map(({ name }) => name),
+ providers: selection.installHopper
+ ? ["hopper"]
+ : (requested?.providers ?? []),
+ skill: selection.skillSelected === true || requested?.skill === true,
+});
+
+const summarizeDoctor = (report: DoctorReport): SetupDoctorSummary => ({
+ healthy: report.healthy,
+ environment_healthy: report.environment_healthy,
+ scope: report.scope,
+ availableProviders: [
+ ...(report.hopperPath !== undefined &&
+ report.checks
+ .filter(({ name }) => name.startsWith("hopper"))
+ .every(({ ok }) => ok)
+ ? ["hopper"]
+ : []),
+ ...(report.providerInspections ?? [])
+ .filter(({ available }) => available)
+ .map(({ id }) => id),
+ ],
+ ...(report.hopperPath === undefined ? {} : { hopperPath: report.hopperPath }),
+ ...(report.providerInspections === undefined
+ ? {}
+ : { providerInspections: report.providerInspections }),
+ ...(report.identity === undefined
+ ? {}
+ : {
+ identity: {
+ skill: report.identity.skill,
+ registrations: report.identity.registrations,
+ },
+ }),
+});
+
const hostRemediation = async (
host: SetupHost,
- forceHopperInstall: boolean,
+ installHopper: boolean,
): Promise => {
- if (host.platform !== "darwin" && host.platform !== "linux")
- return "REA supports Hopper on macOS and selected 64-bit Linux distributions.";
if (!supportsNodeVersion(host.nodeVersion))
return "Install Node.js 22.19+ or 24.11+ and rerun setup.";
+ if (!installHopper) return undefined;
+ if (host.platform !== "darwin" && host.platform !== "linux")
+ return "REA supports Hopper on macOS and selected 64-bit Linux distributions.";
if (host.platform === "darwin") {
const version = await host.macosVersion();
return version === undefined || major(version) < 12
@@ -455,13 +676,6 @@ const hostRemediation = async (
: undefined;
}
if ((await host.linuxDistribution())?.supported === true) return undefined;
- const providerEnvironment = await host.providerEnvironment?.();
- if (
- !forceHopperInstall &&
- providerEnvironment !== undefined &&
- Object.keys(providerEnvironment).length > 0
- )
- return undefined;
return "Automated Hopper setup supports Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux; configure an existing supported provider instead.";
};
@@ -493,12 +707,11 @@ export const systemSetupHost = (
return setupInstallFailure(result.reason);
},
detectedClients: () => detectClients(homedir()),
+ supportedClients: () => Promise.resolve(supportedClients(homedir())),
configureClient: (client, providerEnvironment, command) =>
client.format === "unsupported"
? Promise.resolve({ status: "skipped" })
- : client.format === "toml"
- ? configureTomlClient(client, providerEnvironment, command)
- : configureJsonClient(client, providerEnvironment, command),
+ : configureClientConfiguration(client, providerEnvironment, command),
clientNeedsConfigure: (client, providerEnvironment, command) =>
clientConfigurationAligned(client, providerEnvironment, command).then(
(aligned) => !aligned,
@@ -510,14 +723,18 @@ export const systemSetupHost = (
};
};
-/** Detect supported agents from stable per-user installation markers. */
+/** Detect supported agents from their config files or stable installation markers. */
export const detectClients = async (
home: string,
): Promise => {
const detected: SetupClient[] = [];
- for (const candidate of supportedClients(home))
- if (await exists(candidate.markerPath ?? candidate.configPath))
- detected.push(candidate);
+ for (const candidate of supportedClients(home)) {
+ const [hasConfig, hasMarker] = await Promise.all([
+ exists(candidate.configPath),
+ candidate.markerPath === undefined ? false : exists(candidate.markerPath),
+ ]);
+ if (hasConfig || hasMarker) detected.push(candidate);
+ }
return detected;
};
diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts
index bb30d4e430..7608eb257f 100644
--- a/src/application/SetupClientConfiguration.ts
+++ b/src/application/SetupClientConfiguration.ts
@@ -1,4 +1,5 @@
import {
+ clientRegistrationEntry,
clientConfigurationValuesEqual,
parseClientConfiguration,
serializeClientConfiguration,
@@ -42,11 +43,22 @@ export const configureTomlClient = (
): Promise =>
configureClientDocument(client, environment, command, "toml");
+/** Configure one supported client's native stdio MCP registration shape. */
+export const configureClientConfiguration = (
+ client: SetupClient,
+ environment: SetupProviderEnvironment = {},
+ command: readonly string[] = defaultCommand(),
+): Promise => {
+ if (client.format === undefined || client.format === "unsupported")
+ return Promise.resolve({ status: "failed", reason: "readback" });
+ return configureClientDocument(client, environment, command, client.format);
+};
+
const configureClientDocument = async (
client: SetupClient,
environment: SetupProviderEnvironment,
command: readonly string[],
- format: "json" | "toml",
+ format: NonNullable,
): Promise => {
const transactionPath = await resolveClientConfigTransactionPath(
client.configPath,
@@ -69,7 +81,12 @@ const configureClientDocument = async (
return { status: "failed", reason: "readback" };
}
const { document, servers, serversKey } = parsed;
- const desired = clientConfigurationDesired(client, environment, command);
+ const desired = clientConfigurationDesired(
+ client,
+ environment,
+ command,
+ format,
+ );
if (
clientConfigurationValuesEqual(
servers[PRODUCT_IDENTITY.mcpServerKey],
@@ -92,7 +109,12 @@ const configureClientDocument = async (
await mkdir(dirname(client.configPath), { recursive: true });
await writeFileAtomic(
transactionPath,
- serializeClientConfiguration(document, format),
+ serializeClientConfiguration(
+ document,
+ format,
+ original,
+ PRODUCT_IDENTITY.mcpServerKey,
+ ),
{
encoding: "utf8",
mode: 0o600,
@@ -135,6 +157,7 @@ export const clientConfigurationAligned = async (
client,
providerEnvironment,
command,
+ client.format,
);
try {
const original = await readFile(client.configPath, "utf8");
@@ -179,6 +202,7 @@ export const inspectClientConfiguration = async (
client,
providerEnvironment,
command,
+ client.format,
);
try {
const { servers } = parseClientConfiguration(original, client.format);
@@ -232,20 +256,24 @@ const clientConfigurationDesired = (
client: SetupClient,
providerEnvironment: SetupProviderEnvironment,
command: readonly string[],
+ format: NonNullable | undefined,
) => {
const environment = Object.fromEntries(
Object.entries(providerEnvironment).sort(([left], [right]) =>
left.localeCompare(right),
),
);
+ const registration = clientRegistrationEntry(
+ format ?? "json",
+ command.length === 0 ? [PRODUCT_IDENTITY.cliBinary, "mcp"] : command,
+ environment,
+ );
return {
- command: command[0] ?? PRODUCT_IDENTITY.cliBinary,
- args: command.slice(1),
+ ...registration,
...(client.name === "codex"
? {
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
}
: {}),
- ...(Object.keys(environment).length === 0 ? {} : { env: environment }),
};
};
diff --git a/src/application/SetupPlan.ts b/src/application/SetupPlan.ts
index aa5ac31d0b..4a263c3268 100644
--- a/src/application/SetupPlan.ts
+++ b/src/application/SetupPlan.ts
@@ -2,6 +2,7 @@ import { homedir } from "node:os";
import { join } from "node:path";
import { PRODUCT_IDENTITY } from "../identity.js";
+import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import {
linuxHopperInstallDisclosure,
type LinuxPackageFamily,
@@ -10,30 +11,211 @@ import { macHopperInstallDisclosure } from "./MacHopper.js";
import type {
SetupAction,
SetupClient,
+ SetupClientState,
SetupHost,
SetupProviderEnvironment,
} from "./Setup.js";
import type { DoctorScope } from "./Doctor.js";
-/** Preflighted client mutation admitted into the setup plan. */
-type SetupClientPlan = Readonly<{
- client: SetupClient;
- operation: "create" | "update";
- backupPath?: string;
-}>;
-
-/** Inputs used to build the setup mutation plan. */
-interface SetupPlanOptions {
- readonly platform: NodeJS.Platform;
+/** Read-only setup facts shared by client selection and preflight. */
+export interface SetupDiscovery {
+ readonly initialDoctor: Awaited>;
readonly installHopper: boolean;
- readonly installSkill: boolean;
- readonly clients: readonly SetupClientPlan[];
- readonly providerEnvironment: SetupProviderEnvironment;
+ readonly skillNeedsInstall: boolean;
+ readonly clients: readonly SetupClient[];
+ readonly detectedClientIds: readonly string[];
+ readonly defaultClientIds: readonly string[];
+ readonly clientStates: readonly SetupClientState[];
readonly linuxPackageFamily?: LinuxPackageFamily;
}
-/** Build the complete setup mutation plan before approval. */
-const setupPlan = (input: SetupPlanOptions): readonly SetupAction[] => [
+/** Discover supported clients and current registrations without mutation. */
+export const discoverSetupState = async (input: {
+ readonly host: SetupHost;
+ readonly providerEnvironment: SetupProviderEnvironment;
+ readonly forceHopperInstall: boolean;
+ readonly proposeHopper: boolean;
+ readonly doctorScope: DoctorScope | undefined;
+}): Promise => {
+ const initialDoctor = await input.host.doctor(input.doctorScope);
+ const linuxHopperRepairNeeded = initialDoctor.checks.some(
+ ({ name, ok, detail }) =>
+ !ok &&
+ (name === "hopper-demo-runtime" ||
+ (name === "hopper-version" && detail === "/opt/hopper/bin/Hopper")),
+ );
+ const linuxDistribution =
+ input.host.platform === "linux"
+ ? await input.host.linuxDistribution()
+ : undefined;
+ const hopperInstallSupported =
+ (input.host.platform === "darwin" &&
+ majorVersion((await input.host.macosVersion()) ?? "0") >= 12) ||
+ (input.host.platform === "linux" && linuxDistribution?.supported === true);
+ const installHopper =
+ input.forceHopperInstall ||
+ (hopperInstallSupported &&
+ input.proposeHopper &&
+ (Object.keys(input.providerEnvironment).length === 0 ||
+ linuxHopperRepairNeeded));
+ const [clients, detectedClients, skillNeedsInstall] = await Promise.all([
+ input.host.supportedClients?.() ?? input.host.detectedClients(),
+ input.host.detectedClients(),
+ input.host.skillNeedsInstall(),
+ ]);
+ const detectedIds = new Set(detectedClients.map(({ name }) => name));
+ const registrations = initialDoctor.identity?.registrations ?? [];
+ const registrationByClient = new Map(
+ registrations.map((registration) => [registration.client, registration]),
+ );
+ const clientStates = clients.map((client): SetupClientState => {
+ const registration = registrationByClient.get(client.name);
+ const ownedRegistration =
+ (registration?.state === "aligned" || registration?.state === "stale") &&
+ isOwnedClientRegistrationCommand(registration.command);
+ const configured = ownedRegistration;
+ const status: SetupClientState["status"] =
+ registration?.state === "invalid"
+ ? "invalid"
+ : ownedRegistration && registration.state === "aligned"
+ ? "configured"
+ : ownedRegistration && registration.state === "stale"
+ ? "needs_configuration"
+ : detectedIds.has(client.name)
+ ? "needs_configuration"
+ : "missing";
+ return {
+ client,
+ detected: detectedIds.has(client.name),
+ configured,
+ status,
+ };
+ });
+ const defaultClientIds = registrations
+ .filter(
+ (registration) =>
+ (registration.state === "aligned" || registration.state === "stale") &&
+ isOwnedClientRegistrationCommand(registration.command),
+ )
+ .map(({ client }) => client);
+ return {
+ initialDoctor,
+ installHopper,
+ skillNeedsInstall,
+ clients,
+ detectedClientIds: [...detectedIds],
+ defaultClientIds,
+ clientStates,
+ ...(linuxDistribution?.packageFamily === undefined
+ ? {}
+ : { linuxPackageFamily: linuxDistribution.packageFamily }),
+ };
+};
+
+/** Preflight exactly the selected clients and produce their concrete actions. */
+export const planSetupActions = async (input: {
+ readonly discovery: SetupDiscovery;
+ readonly host: SetupHost;
+ readonly providerEnvironment: SetupProviderEnvironment;
+ readonly command: readonly string[];
+ readonly clientIds: readonly string[];
+ readonly installSkill: boolean;
+}): Promise<{
+ readonly plannedActions: readonly SetupAction[];
+ readonly selectedClients: readonly SetupClient[];
+ readonly clientsToConfigure: readonly SetupClient[];
+ readonly blocker?: string;
+}> => {
+ const byId = new Map(
+ input.discovery.clients.map((client) => [client.name, client]),
+ );
+ const unknownClient = input.clientIds.find((id) => !byId.has(id));
+ if (unknownClient !== undefined) {
+ return {
+ plannedActions: [],
+ selectedClients: [],
+ clientsToConfigure: [],
+ blocker: `Unsupported setup client: ${unknownClient}.`,
+ };
+ }
+ const selectedClients = input.clientIds.flatMap((id) => {
+ const client = byId.get(id);
+ return client === undefined ? [] : [client];
+ });
+ const inspections = await Promise.all(
+ selectedClients.map(async (client) => ({
+ client,
+ inspection:
+ (await input.host.inspectClientConfiguration?.(
+ client,
+ input.providerEnvironment,
+ input.command,
+ )) ?? ({ status: "update" } as const),
+ })),
+ );
+ const invalid = inspections.find(
+ ({ inspection }) => inspection.status === "invalid",
+ );
+ if (invalid?.inspection.status === "invalid") {
+ return {
+ plannedActions: [],
+ selectedClients,
+ clientsToConfigure: [],
+ blocker: `${invalid.client.displayName ?? invalid.client.name}: ${invalid.inspection.remediation}`,
+ };
+ }
+ const clientPlans = inspections.flatMap(({ client, inspection }) =>
+ inspection.status === "create" ||
+ inspection.status === "update" ||
+ (input.discovery.installHopper && inspection.status === "already_current")
+ ? [
+ {
+ client,
+ operation:
+ inspection.status === "already_current"
+ ? ("update" as const)
+ : inspection.status,
+ ...(inspection.status !== "already_current" &&
+ inspection.backupPath !== undefined
+ ? { backupPath: inspection.backupPath }
+ : {}),
+ },
+ ]
+ : [],
+ );
+ return {
+ plannedActions: setupPlan({
+ platform: input.host.platform,
+ installHopper: input.discovery.installHopper,
+ installSkill: input.installSkill,
+ clients: clientPlans,
+ providerEnvironment: input.providerEnvironment,
+ ...(input.discovery.linuxPackageFamily === undefined
+ ? {}
+ : { linuxPackageFamily: input.discovery.linuxPackageFamily }),
+ command: input.command,
+ }),
+ selectedClients,
+ clientsToConfigure: selectedClients.filter((client) =>
+ clientPlans.some(({ client: planned }) => planned.name === client.name),
+ ),
+ };
+};
+
+/** Describe every selected setup mutation before user approval. */
+const setupPlan = (input: {
+ readonly platform: NodeJS.Platform;
+ readonly installHopper: boolean;
+ readonly installSkill: boolean;
+ readonly clients: readonly {
+ readonly client: SetupClient;
+ readonly operation: "create" | "update";
+ readonly backupPath?: string;
+ }[];
+ readonly providerEnvironment: SetupProviderEnvironment;
+ readonly linuxPackageFamily?: LinuxPackageFamily;
+ readonly command: readonly string[];
+}): readonly SetupAction[] => [
...(input.installHopper
? [
{
@@ -71,7 +253,9 @@ const setupPlan = (input: SetupPlanOptions): readonly SetupAction[] => [
target: client.configPath,
detail: clientConfigurationDetail(
client.displayName ?? client.name,
+ input.command,
input.providerEnvironment,
+ input.installHopper,
),
external: false,
operation,
@@ -94,141 +278,6 @@ const setupPlan = (input: SetupPlanOptions): readonly SetupAction[] => [
: []),
];
-/** Discover and preflight the complete set of selectable setup actions. */
-export const discoverSetupActions = async (input: {
- readonly host: SetupHost;
- readonly providerEnvironment: SetupProviderEnvironment;
- readonly command: readonly string[];
- readonly forceHopperInstall: boolean;
- readonly proposeHopper: boolean;
- readonly selectedClientIds: readonly string[] | undefined;
- readonly installSkillSelection: boolean | undefined;
- readonly doctorScope: DoctorScope | undefined;
-}) => {
- const { host, providerEnvironment, command } = input;
- const initialDoctor = await host.doctor(input.doctorScope);
- const linuxHopperRepairNeeded = initialDoctor.checks.some(
- ({ name, ok, detail }) =>
- !ok &&
- (name === "hopper-demo-runtime" ||
- (name === "hopper-version" && detail === "/opt/hopper/bin/Hopper")),
- );
- const installHopper =
- input.forceHopperInstall ||
- (input.proposeHopper &&
- (Object.keys(providerEnvironment).length === 0 ||
- linuxHopperRepairNeeded));
- const [allDetectedClients, skillNeedsInstall] = await Promise.all([
- host.detectedClients(),
- host.skillNeedsInstall(),
- ]);
- const selectedClientIdSet =
- input.selectedClientIds === undefined
- ? undefined
- : new Set(input.selectedClientIds);
- const detectedClients = allDetectedClients.filter(
- ({ name }) =>
- selectedClientIdSet === undefined || selectedClientIdSet.has(name),
- );
- const installSkill =
- input.installSkillSelection === false ? false : skillNeedsInstall;
- const clients = installHopper
- ? detectedClients
- : await filterClientsNeedingConfigure({
- host,
- clients: detectedClients,
- providerEnvironment,
- command,
- });
- const inspections = await inspectClients({
- host,
- clients,
- providerEnvironment,
- command,
- });
- const invalid = inspections.find(
- ({ inspection }) => inspection.status === "invalid",
- );
- const blocker =
- invalid?.inspection.status === "invalid"
- ? `${invalid.client.displayName ?? invalid.client.name}: ${invalid.inspection.remediation}`
- : undefined;
- const clientPlans = inspections.flatMap(({ client, inspection }) =>
- inspection.status === "create" ||
- inspection.status === "update" ||
- (installHopper && inspection.status === "already_current")
- ? [
- {
- client,
- operation:
- inspection.status === "already_current"
- ? ("update" as const)
- : inspection.status,
- ...(inspection.status !== "already_current" &&
- inspection.backupPath !== undefined
- ? { backupPath: inspection.backupPath }
- : {}),
- },
- ]
- : [],
- );
- const linuxDistribution =
- host.platform === "linux" ? await host.linuxDistribution() : undefined;
- return {
- initialDoctor,
- installHopper,
- installSkill,
- detectedClients: clientPlans.map(({ client }) => client),
- plannedActions: setupPlan({
- platform: host.platform,
- installHopper,
- installSkill,
- clients: clientPlans,
- providerEnvironment,
- ...(linuxDistribution?.packageFamily === undefined
- ? {}
- : { linuxPackageFamily: linuxDistribution.packageFamily }),
- }),
- blocker,
- };
-};
-
-const inspectClients = async (input: {
- readonly host: SetupHost;
- readonly clients: readonly SetupClient[];
- readonly providerEnvironment: SetupProviderEnvironment;
- readonly command: readonly string[];
-}) =>
- Promise.all(
- input.clients.map(async (client) => ({
- client,
- inspection:
- (await input.host.inspectClientConfiguration?.(
- client,
- input.providerEnvironment,
- input.command,
- )) ?? ({ status: "update" } as const),
- })),
- );
-
-const filterClientsNeedingConfigure = async (input: {
- readonly host: SetupHost;
- readonly clients: readonly SetupClient[];
- readonly providerEnvironment: SetupProviderEnvironment;
- readonly command: readonly string[];
-}): Promise => {
- const needs = await Promise.all(
- input.clients.map((client) =>
- input.host.clientNeedsConfigure(
- client,
- input.providerEnvironment,
- input.command,
- ),
- ),
- );
- return input.clients.filter((_, index) => needs[index]);
-};
-
const linuxDisclosure = (family: LinuxPackageFamily) => {
const disclosure = linuxHopperInstallDisclosure(
family,
@@ -241,14 +290,26 @@ const linuxDisclosure = (family: LinuxPackageFamily) => {
};
};
+const majorVersion = (version: string): number =>
+ Number.parseInt(version.split(".")[0] ?? "0", 10);
+
const clientConfigurationDetail = (
client: string,
+ command: readonly string[],
environment: SetupProviderEnvironment,
+ installHopper: boolean,
): string => {
const entries = Object.entries(environment)
.sort(([left], [right]) => left.localeCompare(right))
.map(([name, value]) => `${name}=${value}`);
- const suffix =
- entries.length === 0 ? "" : ` Environment: ${entries.join(", ")}.`;
- return `Add the REA MCP registration for ${client}; preserve unrelated configuration.${suffix}`;
+ const environmentDetail =
+ entries.length === 0
+ ? " No additional environment variables."
+ : ` Environment: ${entries.join(", ")}.`;
+ const hopperDetail = installHopper
+ ? environment.HOPPER_LAUNCHER_PATH === undefined
+ ? " If this plan installs Hopper, REA will add HOPPER_LAUNCHER_PATH using the launcher path reported by that installation."
+ : " The HOPPER_LAUNCHER_PATH value shown above is the current fallback; if this plan installs Hopper, REA will replace it with the launcher path reported by that installation."
+ : "";
+ return `Add the REA MCP registration for ${client}; preserve unrelated configuration. Command: ${command.map((part) => JSON.stringify(part)).join(" ")}.${environmentDetail}${hopperDetail}`;
};
diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts
index 469679c608..040c05220c 100644
--- a/src/application/SupportedClients.ts
+++ b/src/application/SupportedClients.ts
@@ -1,4 +1,5 @@
import { join } from "node:path";
+import { lstatSync } from "node:fs";
/** One supported client configuration location. */
export interface SetupClient {
@@ -6,35 +7,138 @@ export interface SetupClient {
readonly displayName?: string;
readonly configPath: string;
readonly markerPath?: string;
- readonly format?: "json" | "toml" | "unsupported";
+ readonly format?:
+ | "json"
+ | "toml"
+ | "vscode"
+ | "copilot_cli"
+ | "opencode"
+ | "unsupported";
}
+type ClientPath = readonly string[] | ((paths: ClientPathContext) => string);
+
+interface ClientPathContext {
+ readonly home: string;
+ readonly platform: NodeJS.Platform;
+ readonly env: {
+ readonly APPDATA?: string | undefined;
+ readonly CLAUDE_CONFIG_DIR?: string | undefined;
+ readonly CODEX_HOME?: string | undefined;
+ readonly COPILOT_HOME?: string | undefined;
+ readonly OPENCODE_CONFIG?: string | undefined;
+ readonly XDG_CONFIG_HOME?: string | undefined;
+ };
+}
+
+interface ClientDefinition {
+ readonly name: string;
+ readonly displayName: string;
+ readonly configPath: ClientPath;
+ readonly markerPath: ClientPath;
+ readonly format: NonNullable;
+}
+
+const vscodeUserDirectory = ({
+ home,
+ platform,
+ env,
+}: ClientPathContext): string => {
+ if (platform === "win32")
+ return join(
+ env.APPDATA ?? join(home, "AppData", "Roaming"),
+ "Code",
+ "User",
+ );
+ if (platform === "darwin")
+ return join(home, "Library", "Application Support", "Code", "User");
+ return join(env.XDG_CONFIG_HOME ?? join(home, ".config"), "Code", "User");
+};
+
+const claudeDesktopDirectory = ({
+ home,
+ platform,
+ env,
+}: ClientPathContext): string => {
+ if (platform === "win32")
+ return join(env.APPDATA ?? join(home, "AppData", "Roaming"), "Claude");
+ if (platform === "linux")
+ return join(env.XDG_CONFIG_HOME ?? join(home, ".config"), "Claude");
+ return join(home, "Library", "Application Support", "Claude");
+};
+
+const claudeCodeConfigDirectory = ({ home, env }: ClientPathContext): string =>
+ env.CLAUDE_CONFIG_DIR ?? home;
+
+const claudeCodeMarkerDirectory = ({ home, env }: ClientPathContext): string =>
+ env.CLAUDE_CONFIG_DIR ?? join(home, ".claude");
+
+const codexDirectory = ({ home, env }: ClientPathContext): string =>
+ env.CODEX_HOME ?? join(home, ".codex");
+
+const copilotDirectory = ({ home, env }: ClientPathContext): string =>
+ env.COPILOT_HOME ?? join(home, ".copilot");
+
+const opencodeDirectory = ({ home, env }: ClientPathContext): string =>
+ join(env.XDG_CONFIG_HOME ?? join(home, ".config"), "opencode");
+
+const existingOpenCodeConfigPath = (context: ClientPathContext): string => {
+ if (context.env.OPENCODE_CONFIG !== undefined)
+ return context.env.OPENCODE_CONFIG;
+ const directory = opencodeDirectory(context);
+ const candidates = [
+ "opencode.json",
+ "opencode.jsonc",
+ ".opencode.json",
+ ".opencode.jsonc",
+ ].map((filename) => join(directory, filename));
+ return (
+ candidates.find((path) => {
+ try {
+ lstatSync(path);
+ return true;
+ } catch (cause: unknown) {
+ return !(
+ cause instanceof Error &&
+ "code" in cause &&
+ cause.code === "ENOENT"
+ );
+ }
+ }) ??
+ candidates[0] ??
+ join(directory, "opencode.json")
+ );
+};
+
+const devinDirectory = ({ home, platform, env }: ClientPathContext): string =>
+ platform === "win32"
+ ? join(env.APPDATA ?? join(home, "AppData", "Roaming"), "devin")
+ : join(home, ".config", "devin");
+
/** Stable product metadata used to derive setup discovery and documentation. */
export const SUPPORTED_CLIENT_DEFINITIONS = [
{
name: "claude_code",
displayName: "Claude Code",
- configPath: [".claude.json"],
- markerPath: [".claude"],
+ configPath: (context: ClientPathContext) =>
+ join(claudeCodeConfigDirectory(context), ".claude.json"),
+ markerPath: claudeCodeMarkerDirectory,
format: "json",
},
{
name: "claude_desktop",
displayName: "Claude Desktop",
- configPath: [
- "Library",
- "Application Support",
- "Claude",
- "claude_desktop_config.json",
- ],
- markerPath: ["Library", "Application Support", "Claude"],
+ configPath: (context: ClientPathContext) =>
+ join(claudeDesktopDirectory(context), "claude_desktop_config.json"),
+ markerPath: claudeDesktopDirectory,
format: "json",
},
{
name: "codex",
displayName: "Codex",
- configPath: [".codex", "config.toml"],
- markerPath: [".codex"],
+ configPath: (context: ClientPathContext) =>
+ join(codexDirectory(context), "config.toml"),
+ markerPath: codexDirectory,
format: "toml",
},
{
@@ -61,18 +165,65 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
{
name: "devin",
displayName: "Devin",
- configPath: [".devin"],
- markerPath: [".devin"],
- format: "unsupported",
+ configPath: ({ home, platform, env }: ClientPathContext) =>
+ join(devinDirectory({ home, platform, env }), "mcp_config.json"),
+ markerPath: devinDirectory,
+ format: "json",
},
-] as const;
+ {
+ name: "opencode",
+ displayName: "OpenCode",
+ configPath: existingOpenCodeConfigPath,
+ markerPath: opencodeDirectory,
+ format: "opencode",
+ },
+ {
+ name: "antigravity",
+ displayName: "Antigravity",
+ configPath: [".gemini", "config", "mcp_config.json"],
+ markerPath: [".gemini", "config"],
+ format: "json",
+ },
+ {
+ name: "copilot_cli",
+ displayName: "GitHub Copilot CLI",
+ configPath: (context: ClientPathContext) =>
+ join(copilotDirectory(context), "mcp-config.json"),
+ markerPath: copilotDirectory,
+ format: "copilot_cli",
+ },
+ {
+ name: "vscode",
+ displayName: "VS Code",
+ configPath: (context: ClientPathContext) =>
+ join(vscodeUserDirectory(context), "mcp.json"),
+ markerPath: vscodeUserDirectory,
+ format: "vscode",
+ },
+] as const satisfies readonly ClientDefinition[];
+
+const resolvePath = (path: ClientPath, context: ClientPathContext): string =>
+ typeof path === "function" ? path(context) : join(context.home, ...path);
/** Describe every client location that setup, doctor, or uninstall may inspect. */
-export const supportedClients = (home: string): readonly SetupClient[] =>
- SUPPORTED_CLIENT_DEFINITIONS.map((definition) => ({
+export const supportedClients = (
+ home: string,
+ platform: NodeJS.Platform = process.platform,
+ env: ClientPathContext["env"] = {
+ APPDATA: process.env.APPDATA,
+ CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
+ CODEX_HOME: process.env.CODEX_HOME,
+ COPILOT_HOME: process.env.COPILOT_HOME,
+ OPENCODE_CONFIG: process.env.OPENCODE_CONFIG,
+ XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
+ },
+): readonly SetupClient[] => {
+ const context = { home, platform, env };
+ return SUPPORTED_CLIENT_DEFINITIONS.map((definition) => ({
name: definition.name,
displayName: definition.displayName,
- configPath: join(home, ...definition.configPath),
- markerPath: join(home, ...definition.markerPath),
+ configPath: resolvePath(definition.configPath, context),
+ markerPath: resolvePath(definition.markerPath, context),
format: definition.format,
}));
+};
diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts
index 1543c5e2a2..df2881db93 100644
--- a/src/application/Uninstall.ts
+++ b/src/application/Uninstall.ts
@@ -1,15 +1,17 @@
import {
parseClientConfiguration,
serializeClientConfiguration,
+ type ClientServersKey,
} from "./ClientConfigurationDocument.js";
import { copyFile, lstat, readFile, realpath, rm } from "node:fs/promises";
import { homedir } from "node:os";
-import { basename, isAbsolute, join } from "node:path";
+import { join } from "node:path";
import writeFileAtomic from "write-file-atomic";
import { z } from "zod";
import { PRODUCT_IDENTITY } from "../identity.js";
+import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
import { supportedClients, type SetupClient } from "./SupportedClients.js";
@@ -123,7 +125,7 @@ const removeClient = async (
);
}
let document: Record;
- let key: "mcp_servers" | "mcpServers";
+ let key: ClientServersKey;
let servers: Record;
try {
({
@@ -135,13 +137,13 @@ const removeClient = async (
return item(
client.name,
"failed",
- `Configuration is not valid ${client.format === "toml" ? "TOML" : "JSON"} and was not changed. Repair it, then rerun uninstall.`,
+ `Configuration is not valid ${client.format === "toml" ? "TOML" : client.format === "opencode" ? "JSONC" : "JSON"} and was not changed. Repair it, then rerun uninstall.`,
);
}
const registration = servers[PRODUCT_IDENTITY.mcpServerKey];
if (registration === undefined)
return item(client.name, "skipped", "REA registration is absent.");
- if (!isOwnedRegistration(registration))
+ if (!isOwnedRegistration(registration, client))
return item(
client.name,
"retained",
@@ -163,7 +165,12 @@ const removeClient = async (
try {
await fileSystem.writeText(
transactionPath,
- serializeClientConfiguration(document, client.format),
+ serializeClientConfiguration(
+ document,
+ client.format,
+ original,
+ PRODUCT_IDENTITY.mcpServerKey,
+ ),
);
const readback = parseClientConfiguration(
await fileSystem.readText(transactionPath),
@@ -215,27 +222,20 @@ const resolveUninstallConfigPath = async (
};
/** Ownership rule for persistent MCP registrations created by REA setup. */
-const isOwnedRegistration = (value: unknown): boolean => {
+const isOwnedRegistration = (value: unknown, client: SetupClient): boolean => {
+ if (client.format === "opencode") {
+ const parsed = z
+ .object({ type: z.literal("local"), command: z.array(z.string()).min(1) })
+ .passthrough()
+ .safeParse(value);
+ if (!parsed.success) return false;
+ const command = parsed.data.command;
+ return isOwnedClientRegistrationCommand(command);
+ }
const parsed = registrationSchema.safeParse(value);
if (!parsed.success) return false;
const { command, args } = parsed.data;
- return (
- ((command === "rea" ||
- (isAbsolute(command) && basename(command) === "rea")) &&
- args.length === 1 &&
- args[0] === "mcp") ||
- (command === "npx" &&
- (JSON.stringify(args) ===
- JSON.stringify([
- "-y",
- PRODUCT_IDENTITY.registrationPackageSpecifier,
- "mcp",
- ]) ||
- JSON.stringify(args) ===
- JSON.stringify(["-y", PRODUCT_IDENTITY.packageSpecifier, "mcp"]) ||
- JSON.stringify(args) ===
- JSON.stringify(["-y", PRODUCT_IDENTITY.packageName, "mcp"])))
- );
+ return isOwnedClientRegistrationCommand([command, ...args]);
};
const removeManagedPath = async (
diff --git a/src/application/Update.test.ts b/src/application/Update.test.ts
new file mode 100644
index 0000000000..12b9c0c093
--- /dev/null
+++ b/src/application/Update.test.ts
@@ -0,0 +1,301 @@
+import { describe, expect, it } from "vitest";
+
+import { err, ok, type Result } from "../domain/result.js";
+import {
+ runUpdate,
+ type UpdateHost,
+ type UpdateOutput,
+ type NpmInstallation,
+} from "./Update.js";
+import {
+ detectNpmInstallation,
+ type NpmInstallationHost,
+} from "./UpdateRuntime.js";
+import type { IntegrationMaintenance } from "./UpdateMaintenance.js";
+
+class FakeUpdateHost implements UpdateHost {
+ release: Result = ok("3.3.0");
+ installationResult: NpmInstallation | undefined = {
+ prefix: "/fixture",
+ packageRoot: "/fixture/lib/node_modules/rea-agents",
+ };
+ installResult: Result = ok(undefined);
+ verified: Result = ok("3.3.0");
+ maintenance: IntegrationMaintenance = {
+ status: "current",
+ plannedActions: [],
+ };
+ installedVersions: string[] = [];
+ installationInspected = false;
+ verifiedInstallation: NpmInstallation | undefined;
+ maintenanceInspected = false;
+ output: UpdateOutput | undefined;
+
+ installation = (): Promise => {
+ this.installationInspected = true;
+ return Promise.resolve(this.installationResult);
+ };
+ latestVersion = (): Promise> =>
+ Promise.resolve(this.release);
+ installVersion = (
+ _installation: NpmInstallation,
+ version: string,
+ output: UpdateOutput,
+ ): Promise> => {
+ this.installedVersions.push(version);
+ this.output = output;
+ return Promise.resolve(this.installResult);
+ };
+ installedVersion = (
+ installation: NpmInstallation,
+ ): Promise> => {
+ this.verifiedInstallation = installation;
+ return Promise.resolve(this.verified);
+ };
+ planMaintenance = (): Promise => {
+ this.maintenanceInspected = true;
+ return Promise.resolve(this.maintenance);
+ };
+}
+
+describe("REA update", () => {
+ it("identifies an already-current installation without mutation", async () => {
+ const host = new FakeUpdateHost();
+ expect(await runUpdate("3.3.0", host)).toEqual({
+ status: "current",
+ currentVersion: "3.3.0",
+ latestVersion: "3.3.0",
+ installMethod: "npm",
+ });
+ expect(host.installationInspected).toBe(true);
+ expect(host.installedVersions).toEqual([]);
+ expect(host.maintenanceInspected).toBe(false);
+ });
+
+ it("reports an unknown current installation honestly", async () => {
+ const host = new FakeUpdateHost();
+ host.installationResult = undefined;
+ expect(await runUpdate("3.3.0", host)).toMatchObject({
+ status: "current",
+ installMethod: "unknown",
+ });
+ expect(host.installedVersions).toEqual([]);
+ });
+
+ it("installs the resolved release and verifies the same package root", async () => {
+ const host = new FakeUpdateHost();
+ expect(await runUpdate("3.2.1", host, "structured")).toMatchObject({
+ status: "updated",
+ previousVersion: "3.2.1",
+ latestVersion: "3.3.0",
+ installedVersion: "3.3.0",
+ command: [
+ "npm",
+ "install",
+ "--global",
+ "--ignore-scripts",
+ "--prefix",
+ "/fixture",
+ "rea-agents@3.3.0",
+ ],
+ });
+ expect(host.installedVersions).toEqual(["3.3.0"]);
+ expect(host.verifiedInstallation).toEqual(host.installationResult);
+ expect(host.output).toBe("structured");
+ });
+
+ it("does not report success or plan maintenance when the installed version differs", async () => {
+ const host = new FakeUpdateHost();
+ host.verified = ok("3.4.0");
+ expect(await runUpdate("3.2.1", host)).toMatchObject({
+ status: "failed",
+ reason: "verification",
+ latestVersion: "3.3.0",
+ installedVersion: "3.4.0",
+ });
+ expect(host.maintenanceInspected).toBe(false);
+ });
+
+ it("preserves executable verification diagnostics after npm succeeded", async () => {
+ const host = new FakeUpdateHost();
+ host.verified = err("ENOENT /fixture/scripts/rea.mjs");
+ const result = await runUpdate("3.2.1", host);
+ expect(result).toMatchObject({ status: "failed", reason: "verification" });
+ expect(result).toHaveProperty(
+ "remediation",
+ expect.stringContaining("ENOENT /fixture/scripts/rea.mjs"),
+ );
+ expect(host.maintenanceInspected).toBe(false);
+ });
+
+ it("returns integration maintenance without applying it", async () => {
+ const host = new FakeUpdateHost();
+ host.maintenance = {
+ status: "planned",
+ scope: { clients: ["codex"], skill: false },
+ command: ["rea", "setup", "--client", "codex", "--skill=false"],
+ plannedActions: [
+ {
+ id: "configure_client:codex",
+ kind: "configure_client",
+ label: "Codex",
+ target: "/home/.codex/config.toml",
+ detail: "Refresh REA",
+ external: false,
+ operation: "update",
+ },
+ ],
+ };
+ expect(await runUpdate("3.2.1", host)).toMatchObject({
+ status: "updated",
+ maintenance: host.maintenance,
+ });
+ });
+
+ it("keeps a verified update successful when maintenance planning fails", async () => {
+ const host = new FakeUpdateHost();
+ host.maintenance = {
+ status: "unavailable",
+ remediation: "EACCES /home/.codex/config.toml",
+ };
+ expect(await runUpdate("3.2.1", host)).toMatchObject({
+ status: "updated",
+ installedVersion: "3.3.0",
+ maintenance: host.maintenance,
+ });
+ });
+});
+
+describe("update failures and version boundaries", () => {
+ it("normalizes accepted release metadata before installing and comparing versions", async () => {
+ const host = new FakeUpdateHost();
+ host.release = ok("v3.3.0");
+ expect(await runUpdate("3.2.1", host)).toMatchObject({
+ status: "updated",
+ latestVersion: "3.3.0",
+ installedVersion: "3.3.0",
+ });
+ expect(host.installedVersions).toEqual(["3.3.0"]);
+ });
+
+ it("preserves registry errors without installation", async () => {
+ const host = new FakeUpdateHost();
+ host.release = err("npm registry returned HTTP 503");
+ const result = await runUpdate("3.2.1", host);
+ expect(result).toMatchObject({
+ status: "failed",
+ reason: "version-check",
+ latestVersion: null,
+ });
+ expect(result).toHaveProperty(
+ "remediation",
+ expect.stringContaining("HTTP 503"),
+ );
+ expect(host.installedVersions).toEqual([]);
+ });
+
+ it.each(["latest", "", "3.3", "$(echo bad)"])(
+ "rejects invalid release metadata %s before installation",
+ async (version) => {
+ const host = new FakeUpdateHost();
+ host.release = ok(version);
+ expect(await runUpdate("3.2.1", host)).toMatchObject({
+ status: "failed",
+ reason: "version-check",
+ });
+ expect(host.installedVersions).toEqual([]);
+ },
+ );
+
+ it.each([
+ ["3.4.0", "3.3.0"],
+ ["3.3.0", "3.3.0-rc.1"],
+ ])("does not downgrade %s to %s", async (currentVersion, release) => {
+ const host = new FakeUpdateHost();
+ host.release = ok(release);
+ expect(await runUpdate(currentVersion, host)).toMatchObject({
+ status: "current",
+ });
+ expect(host.installedVersions).toEqual([]);
+ });
+
+ it("updates a prerelease to its final release", async () => {
+ const host = new FakeUpdateHost();
+ expect(await runUpdate("3.3.0-rc.1", host)).toMatchObject({
+ status: "updated",
+ installedVersion: "3.3.0",
+ });
+ });
+
+ it("refuses to create a global installation for an unrelated invocation", async () => {
+ const host = new FakeUpdateHost();
+ host.installationResult = undefined;
+ const result = await runUpdate("3.2.1", host);
+ expect(result).toMatchObject({
+ status: "failed",
+ reason: "unknown-install-method",
+ });
+ expect(result).toHaveProperty(
+ "remediation",
+ expect.not.stringContaining("npm install --global"),
+ );
+ expect(host.installedVersions).toEqual([]);
+ });
+
+ it("reports actual npm failures and skips verification", async () => {
+ const host = new FakeUpdateHost();
+ host.installResult = err("EACCES /fixture/lib/node_modules/rea-agents");
+ const result = await runUpdate("3.2.1", host);
+ expect(result).toMatchObject({ status: "failed", reason: "install" });
+ expect(result).toHaveProperty(
+ "remediation",
+ expect.stringContaining("EACCES /fixture/lib/node_modules/rea-agents"),
+ );
+ expect(host.verifiedInstallation).toBeUndefined();
+ });
+});
+
+describe("npm installation ownership", () => {
+ const host = (root: string, prefix = "/usr/local"): NpmInstallationHost => ({
+ canonicalPath: (path) => Promise.resolve(path),
+ globalRoot: () => Promise.resolve(root),
+ globalPrefix: () => Promise.resolve(prefix),
+ });
+
+ it("identifies the global prefix and package root", async () => {
+ const packageRoot = "/usr/local/lib/node_modules/rea-agents";
+ await expect(
+ detectNpmInstallation(packageRoot, host("/usr/local/lib/node_modules")),
+ ).resolves.toEqual({ prefix: "/usr/local", packageRoot });
+ });
+
+ it("retains the curl installer's custom Unix prefix", async () => {
+ const packageRoot = "/home/user/.local/lib/node_modules/rea-agents";
+ await expect(
+ detectNpmInstallation(packageRoot, host("/usr/local/lib/node_modules")),
+ ).resolves.toEqual({ prefix: "/home/user/.local", packageRoot });
+ });
+
+ it.each([
+ "/work/rea",
+ "/work/node_modules/rea-agents",
+ "/cache/_npx/abc/node_modules/rea-agents",
+ ])("rejects unrelated invocation %s", async (packageRoot) => {
+ await expect(
+ detectNpmInstallation(packageRoot, host("/usr/local/lib/node_modules")),
+ ).resolves.toBeUndefined();
+ });
+
+ it("does not trust an unresolvable installation path", async () => {
+ const failing = {
+ ...host("/usr/local/lib/node_modules"),
+ canonicalPath: (): Promise => Promise.reject(new Error("EACCES")),
+ };
+ await expect(
+ detectNpmInstallation(
+ "/home/.local/lib/node_modules/rea-agents",
+ failing,
+ ),
+ ).resolves.toBeUndefined();
+ });
+});
diff --git a/src/application/Update.ts b/src/application/Update.ts
new file mode 100644
index 0000000000..7ba191029e
--- /dev/null
+++ b/src/application/Update.ts
@@ -0,0 +1,155 @@
+import { gt, valid } from "semver";
+
+import { PRODUCT_IDENTITY } from "../identity.js";
+import type { Result } from "../domain/result.js";
+import type { IntegrationMaintenance } from "./UpdateMaintenance.js";
+
+/** Global npm installation that owns the running package and its entry point. */
+export interface NpmInstallation {
+ readonly prefix: string;
+ readonly packageRoot: string;
+}
+
+/** Output policy for the package manager subprocess. */
+export type UpdateOutput = "human" | "structured";
+
+/** Effects used to update and verify one identified REA installation. */
+export interface UpdateHost {
+ installation(): Promise;
+ latestVersion(
+ installation: NpmInstallation | undefined,
+ ): Promise>;
+ installVersion(
+ installation: NpmInstallation,
+ version: string,
+ output: UpdateOutput,
+ ): Promise>;
+ installedVersion(
+ installation: NpmInstallation,
+ ): Promise>;
+ planMaintenance(
+ installation: NpmInstallation,
+ ): Promise;
+}
+
+/** Verified update outcome, separate from the unapplied integration plan. */
+export type UpdateResult =
+ | {
+ readonly status: "current";
+ readonly currentVersion: string;
+ readonly latestVersion: string;
+ readonly installMethod: "npm" | "unknown";
+ }
+ | {
+ readonly status: "updated";
+ readonly previousVersion: string;
+ readonly latestVersion: string;
+ readonly installedVersion: string;
+ readonly installMethod: "npm";
+ readonly command: readonly string[];
+ readonly maintenance: IntegrationMaintenance;
+ readonly remediation: string;
+ }
+ | {
+ readonly status: "failed";
+ readonly currentVersion: string;
+ readonly latestVersion: string | null;
+ readonly reason:
+ | "unknown-install-method"
+ | "version-check"
+ | "install"
+ | "verification";
+ readonly installedVersion?: string;
+ readonly remediation: string;
+ };
+
+/** Resolve the exact npm command for the identified prefix and release. */
+export const updateInstallCommand = (
+ installation: NpmInstallation,
+ version: string,
+): readonly string[] => [
+ "npm",
+ "install",
+ "--global",
+ "--ignore-scripts",
+ "--prefix",
+ installation.prefix,
+ `${PRODUCT_IDENTITY.packageName}@${version}`,
+];
+
+/** Identify, resolve, install, and verify REA without applying setup changes. */
+export const runUpdate = async (
+ currentVersion: string,
+ host: UpdateHost,
+ output: UpdateOutput = "human",
+): Promise => {
+ const installation = await host.installation();
+ const release = await host.latestVersion(installation);
+ const latestVersion = release.ok ? release.value : null;
+ const targetVersion = release.ok ? valid(release.value) : null;
+ if (!release.ok || valid(currentVersion) === null || targetVersion === null)
+ return {
+ status: "failed",
+ currentVersion,
+ latestVersion,
+ reason: "version-check",
+ remediation: release.ok
+ ? "REA received invalid version metadata. Check the installed version and npm registry, then rerun rea update."
+ : `REA could not resolve the latest release: ${release.error}. Retry rea update when registry access is available.`,
+ };
+ if (!gt(targetVersion, currentVersion))
+ return {
+ status: "current",
+ currentVersion,
+ latestVersion: targetVersion,
+ installMethod: installation === undefined ? "unknown" : "npm",
+ };
+ if (installation === undefined)
+ return {
+ status: "failed",
+ currentVersion,
+ latestVersion: targetVersion,
+ reason: "unknown-install-method",
+ remediation:
+ "Update this installation with the package manager or source checkout that owns it. For a one-off latest invocation, use npx rea-agents@latest.",
+ };
+ const installed = await host.installVersion(
+ installation,
+ targetVersion,
+ output,
+ );
+ if (!installed.ok)
+ return {
+ status: "failed",
+ currentVersion,
+ latestVersion: targetVersion,
+ reason: "install",
+ remediation: `REA could not install ${targetVersion} in ${installation.prefix}: ${installed.error}. Repair this npm installation, then rerun rea update.`,
+ };
+ const verified = await host.installedVersion(installation);
+ if (!verified.ok || verified.value !== targetVersion)
+ return {
+ status: "failed",
+ currentVersion,
+ latestVersion: targetVersion,
+ reason: "verification",
+ ...(verified.ok ? { installedVersion: verified.value } : {}),
+ remediation: `npm completed, but REA could not verify ${targetVersion} in ${installation.packageRoot}: ${verified.ok ? `the executable reported ${verified.value}` : verified.error}. The installation may have changed; repair it before retrying.`,
+ };
+ const maintenance = await host.planMaintenance(installation);
+ return {
+ status: "updated",
+ previousVersion: currentVersion,
+ latestVersion: targetVersion,
+ installedVersion: verified.value,
+ installMethod: "npm",
+ command: updateInstallCommand(installation, targetVersion),
+ maintenance,
+ remediation:
+ maintenance.status === "planned"
+ ? "REA is updated. Run the maintenance command to review and approve changes to existing REA integrations, then restart affected agents."
+ : maintenance.status === "unavailable"
+ ? "REA is updated. Integration maintenance could not be planned; inspect the maintenance diagnostic before changing configuration."
+ : "REA is updated. Restart agents running this installation to load the new version.",
+ };
+};
diff --git a/src/application/UpdateMaintenance.ts b/src/application/UpdateMaintenance.ts
new file mode 100644
index 0000000000..64a6f10dfd
--- /dev/null
+++ b/src/application/UpdateMaintenance.ts
@@ -0,0 +1,148 @@
+import { readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { z } from "zod";
+
+import { PRODUCT_IDENTITY } from "../identity.js";
+import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
+import { readClientRegistrationStatuses } from "./ClientRegistrationStatus.js";
+import { parseClientConfiguration } from "./ClientConfigurationDocument.js";
+import { supportedClients } from "./SupportedClients.js";
+import type { SetupAction } from "./Setup.js";
+import type { Result } from "../domain/result.js";
+
+/** Existing REA integrations selected for maintenance, without discovering new targets. */
+export interface MaintenanceScope {
+ readonly clients: readonly string[];
+ readonly skill: boolean;
+}
+
+/** Unapplied maintenance evidence produced by the updated setup executable. */
+export type IntegrationMaintenance =
+ | { readonly status: "current"; readonly plannedActions: readonly [] }
+ | {
+ readonly status: "planned";
+ readonly scope: MaintenanceScope;
+ readonly command: readonly string[];
+ readonly plannedActions: readonly SetupAction[];
+ }
+ | { readonly status: "unavailable"; readonly remediation: string };
+
+const setupPlanSchema = z.object({
+ status: z.literal("planned"),
+ plannedActions: z.array(
+ z.object({
+ id: z.string(),
+ kind: z.enum(["configure_client", "install_skill"]),
+ label: z.string(),
+ target: z.string(),
+ detail: z.string(),
+ external: z.literal(false),
+ operation: z.enum(["create", "update", "install"]),
+ backupPath: z.string().optional(),
+ commands: z.array(z.string()).optional(),
+ }),
+ ),
+});
+
+/** Discover owned registrations and an existing REA skill without writes. */
+export const existingMaintenanceScope = async (
+ home: string,
+ entryPoint: string,
+): Promise => {
+ const registrations = await readClientRegistrationStatuses(home, entryPoint);
+ const supported = supportedClients(home);
+ const clients: string[] = [];
+ for (const registration of registrations) {
+ if (
+ (registration.state !== "aligned" && registration.state !== "stale") ||
+ !isOwnedClientRegistrationCommand(registration.command, entryPoint)
+ )
+ continue;
+ const client = supported.find(({ name }) => name === registration.client);
+ if (client === undefined) continue;
+ const { servers } = parseClientConfiguration(
+ await readFile(client.configPath, "utf8"),
+ client.format,
+ );
+ const enabled = z
+ .object({
+ enabled: z.boolean().optional(),
+ disabled: z.boolean().optional(),
+ })
+ .parse(servers[PRODUCT_IDENTITY.mcpServerKey]);
+ if (enabled.enabled !== false && enabled.disabled !== true)
+ clients.push(client.name);
+ }
+ let skill = false;
+ try {
+ const content = await readFile(
+ join(home, ".agents", "skills", PRODUCT_IDENTITY.skillName, "SKILL.md"),
+ "utf8",
+ );
+ skill =
+ content.startsWith(`---\nname: ${PRODUCT_IDENTITY.skillName}\n`) ||
+ content.startsWith(`---\r\nname: ${PRODUCT_IDENTITY.skillName}\r\n`);
+ } catch (cause: unknown) {
+ if (!(cause instanceof Error && "code" in cause && cause.code === "ENOENT"))
+ throw cause;
+ }
+ return { clients, skill };
+};
+
+/** Ask the verified new executable to plan only existing REA integrations. */
+export const planIntegrationMaintenance = async (
+ home: string,
+ entryPoint: string,
+ execute: (command: readonly string[]) => Promise>,
+): Promise => {
+ try {
+ const scope = await existingMaintenanceScope(home, entryPoint);
+ if (scope.clients.length === 0 && !scope.skill)
+ return { status: "current", plannedActions: [] };
+ const command = [
+ process.execPath,
+ entryPoint,
+ "setup",
+ ...scope.clients.flatMap((client) => ["--client", client]),
+ `--skill=${String(scope.skill)}`,
+ ];
+ const execution = await execute([...command, "--dry-run", "--json"]);
+ if (!execution.ok)
+ return { status: "unavailable", remediation: execution.error };
+ const plan = setupPlanSchema.safeParse(JSON.parse(execution.value));
+ if (!plan.success)
+ return {
+ status: "unavailable",
+ remediation: `The updated setup executable returned an invalid maintenance plan: ${plan.error.message}`,
+ };
+ if (plan.data.plannedActions.length === 0)
+ return { status: "current", plannedActions: [] };
+ const allowedIds = new Set([
+ ...scope.clients.map((client) => `configure_client:${client}`),
+ ...(scope.skill ? ["install_skill"] : []),
+ ]);
+ if (plan.data.plannedActions.some(({ id }) => !allowedIds.has(id)))
+ return {
+ status: "unavailable",
+ remediation:
+ "The updated setup plan included an integration outside the existing REA maintenance scope.",
+ };
+ return {
+ status: "planned",
+ scope,
+ command,
+ plannedActions: plan.data.plannedActions.map(
+ ({ backupPath, commands, ...action }) => ({
+ ...action,
+ ...(backupPath === undefined ? {} : { backupPath }),
+ ...(commands === undefined ? {} : { commands }),
+ }),
+ ),
+ };
+ } catch (cause: unknown) {
+ return {
+ status: "unavailable",
+ remediation: cause instanceof Error ? cause.message : String(cause),
+ };
+ }
+};
diff --git a/src/application/UpdateRuntime.ts b/src/application/UpdateRuntime.ts
new file mode 100644
index 0000000000..e397c493dc
--- /dev/null
+++ b/src/application/UpdateRuntime.ts
@@ -0,0 +1,168 @@
+import { realpath } from "node:fs/promises";
+import { homedir } from "node:os";
+import { basename, dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import spawn from "cross-spawn";
+import { z } from "zod";
+
+import { PRODUCT_IDENTITY } from "../identity.js";
+import { err, ok, type Result } from "../domain/result.js";
+import {
+ updateInstallCommand,
+ type NpmInstallation,
+ type UpdateHost,
+ type UpdateOutput,
+} from "./Update.js";
+import { planIntegrationMaintenance } from "./UpdateMaintenance.js";
+
+/** npm filesystem queries used to identify the installation being updated. */
+export interface NpmInstallationHost {
+ canonicalPath(path: string): Promise;
+ globalRoot(): Promise;
+ globalPrefix(): Promise;
+}
+
+/** Run an updater subprocess with complete diagnostics and isolated machine output. */
+export const runUpdateCommand = (
+ command: readonly string[],
+ output: UpdateOutput = "structured",
+): Promise> =>
+ new Promise((resolveResult) => {
+ const [executable, ...args] = command;
+ if (executable === undefined) {
+ resolveResult(err("No executable supplied."));
+ return;
+ }
+ const environment = { ...process.env };
+ // A fresh installed entry point must not inherit npx's invocation identity.
+ delete environment.npm_command;
+ const child = spawn(executable, args, {
+ stdio: ["ignore", "pipe", "pipe"],
+ env: environment,
+ windowsHide: true,
+ });
+ let stdout = "";
+ let stderr = "";
+ child.stdout?.setEncoding("utf8");
+ child.stderr?.setEncoding("utf8");
+ child.stdout?.on("data", (chunk: string) => {
+ stdout += chunk;
+ if (output === "human") process.stderr.write(chunk);
+ });
+ child.stderr?.on("data", (chunk: string) => {
+ stderr += chunk;
+ if (output === "human") process.stderr.write(chunk);
+ });
+ child.once("error", (cause) => resolveResult(err(cause.message)));
+ child.once("close", (code, signal) =>
+ resolveResult(
+ code === 0
+ ? ok(stdout.trim())
+ : err(
+ stderr.trim() ||
+ stdout.trim() ||
+ (signal === null
+ ? `Process exited with code ${String(code)}.`
+ : `Process terminated by ${signal}.`),
+ ),
+ ),
+ );
+ });
+
+const requireCommandOutput = async (
+ command: readonly string[],
+): Promise => {
+ const result = await runUpdateCommand(command);
+ if (!result.ok) throw new Error(result.error);
+ return result.value;
+};
+
+/** Resolve the global prefix only when it owns the running package. */
+export const detectNpmInstallation = async (
+ packageRoot: string,
+ host: NpmInstallationHost,
+): Promise => {
+ let canonicalPackageRoot: string;
+ try {
+ canonicalPackageRoot = await host.canonicalPath(packageRoot);
+ } catch {
+ return undefined;
+ }
+ try {
+ const npmRoot = await host.globalRoot();
+ const globalPackageRoot = await host.canonicalPath(
+ resolve(npmRoot, PRODUCT_IDENTITY.packageName),
+ );
+ if (canonicalPackageRoot === globalPackageRoot) {
+ const prefix = await host.globalPrefix();
+ return prefix.length === 0
+ ? undefined
+ : { prefix, packageRoot: canonicalPackageRoot };
+ }
+ } catch {
+ // The curl installer uses a Unix prefix that may differ from npm's current default.
+ }
+ const nodeModules = dirname(canonicalPackageRoot);
+ const library = dirname(nodeModules);
+ if (
+ basename(canonicalPackageRoot) !== PRODUCT_IDENTITY.packageName ||
+ basename(nodeModules) !== "node_modules" ||
+ basename(library) !== "lib"
+ )
+ return undefined;
+ return { prefix: dirname(library), packageRoot: canonicalPackageRoot };
+};
+
+/** Create npm, registry, verification, and read-only maintenance effects. */
+export const systemUpdateHost = (
+ packageRoot = fileURLToPath(new URL("../..", import.meta.url)),
+ home = homedir(),
+): UpdateHost => ({
+ installation: () =>
+ detectNpmInstallation(packageRoot, {
+ canonicalPath: realpath,
+ globalRoot: () => requireCommandOutput(["npm", "root", "--global"]),
+ globalPrefix: () => requireCommandOutput(["npm", "prefix", "--global"]),
+ }),
+ latestVersion: async (installation) => {
+ const response = await runUpdateCommand([
+ "npm",
+ "view",
+ "--global",
+ ...(installation === undefined ? [] : ["--prefix", installation.prefix]),
+ PRODUCT_IDENTITY.packageName,
+ "dist-tags.latest",
+ "--json",
+ "--fetch-retries=0",
+ "--fetch-timeout=10000",
+ ]);
+ if (!response.ok) return response;
+ try {
+ const parsed = z.string().min(1).safeParse(JSON.parse(response.value));
+ return parsed.success
+ ? ok(parsed.data)
+ : err(`Invalid npm release metadata: ${parsed.error.message}`);
+ } catch (cause: unknown) {
+ return err(cause instanceof Error ? cause.message : String(cause));
+ }
+ },
+ installVersion: async (installation, version, output) => {
+ const result = await runUpdateCommand(
+ updateInstallCommand(installation, version),
+ output,
+ );
+ return result.ok ? ok(undefined) : result;
+ },
+ installedVersion: (installation) =>
+ runUpdateCommand([
+ process.execPath,
+ join(installation.packageRoot, "scripts", "rea.mjs"),
+ "--version",
+ ]),
+ planMaintenance: (installation) =>
+ planIntegrationMaintenance(
+ home,
+ join(installation.packageRoot, "scripts", "rea.mjs"),
+ runUpdateCommand,
+ ),
+});
diff --git a/src/application/Upgrade.test.ts b/src/application/Upgrade.test.ts
deleted file mode 100644
index 009fd0db59..0000000000
--- a/src/application/Upgrade.test.ts
+++ /dev/null
@@ -1,201 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import {
- detectNpmInstallation,
- runUpgrade,
- type UpgradeHost,
- type UpgradeOutput,
- type NpmInstallationHost,
-} from "./Upgrade.js";
-
-class FakeUpgradeHost implements UpgradeHost {
- latest: string | undefined = "0.6.0";
- installationResult: { readonly prefix: string } | undefined = {
- prefix: "/fixture",
- };
- installSucceeds = true;
- installs = 0;
- output: UpgradeOutput | undefined;
- syncResult: boolean | undefined = true;
- syncs = 0;
-
- latestVersion = (): Promise =>
- Promise.resolve(this.latest);
- installation = (): Promise<{ readonly prefix: string } | undefined> =>
- Promise.resolve(this.installationResult);
- installLatest = (
- _installation: { readonly prefix: string },
- output: UpgradeOutput,
- ): Promise => {
- this.installs += 1;
- this.output = output;
- return Promise.resolve(this.installSucceeds);
- };
- syncAgentIntegration = (
- _output: UpgradeOutput,
- ): Promise => {
- this.syncs += 1;
- return Promise.resolve(this.syncResult);
- };
-}
-
-describe("CLI upgrade", () => {
- it("reports an already-current release without mutation", async () => {
- const host = new FakeUpgradeHost();
-
- expect(await runUpgrade("0.6.0", host)).toEqual({
- status: "current",
- currentVersion: "0.6.0",
- latestVersion: "0.6.0",
- installMethod: "npm",
- });
- expect(host.installs).toBe(0);
- });
-
- it("upgrades an outdated global npm installation", async () => {
- const host = new FakeUpgradeHost();
-
- expect(await runUpgrade("0.5.0", host)).toEqual({
- status: "upgraded",
- previousVersion: "0.5.0",
- latestVersion: "0.6.0",
- versionCheck: "available",
- installMethod: "npm",
- command: "npm install --global rea-agents@latest",
- integrationSync: "setup_invoked",
- clientRestartRequired: true,
- remediation:
- "The updated setup journey was opened to align registrations and the skill. Restart clients whose approved registration changed.",
- });
- expect(host.installs).toBe(1);
- expect(host.syncs).toBe(1);
- });
-
- it("does not mutate when the registry check is unavailable", async () => {
- const host = new FakeUpgradeHost();
- host.latest = undefined;
-
- expect(await runUpgrade("0.5.0", host)).toMatchObject({
- status: "failed",
- latestVersion: null,
- reason: "version-check",
- });
- expect(host.installs).toBe(0);
- });
-
- it.each([
- ["1.10.0", "1.9.0"],
- ["1.0.0", "1.0.0-rc.1"],
- ])(
- "does not downgrade installed version %s to registry version %s",
- async (currentVersion, latestVersion) => {
- const host = new FakeUpgradeHost();
- host.latest = latestVersion;
-
- expect(await runUpgrade(currentVersion, host)).toEqual({
- status: "current",
- currentVersion,
- latestVersion,
- installMethod: "npm",
- });
- expect(host.installs).toBe(0);
- },
- );
-
- it("upgrades a prerelease when the final release is available", async () => {
- const host = new FakeUpgradeHost();
- host.latest = "1.0.0";
-
- expect(await runUpgrade("1.0.0-rc.1", host)).toMatchObject({
- status: "upgraded",
- previousVersion: "1.0.0-rc.1",
- latestVersion: "1.0.0",
- });
- expect(host.installs).toBe(1);
- });
-
- it("rejects invalid registry version metadata without mutation", async () => {
- const host = new FakeUpgradeHost();
- host.latest = "latest";
-
- expect(await runUpgrade("1.0.0", host)).toMatchObject({
- status: "failed",
- currentVersion: "1.0.0",
- latestVersion: "latest",
- reason: "version-check",
- });
- expect(host.installs).toBe(0);
- });
-
- it("preserves stdout for an explicitly structured CLI response", async () => {
- const host = new FakeUpgradeHost();
- host.syncResult = undefined;
-
- expect(await runUpgrade("0.5.0", host, "structured")).toMatchObject({
- status: "upgraded",
- integrationSync: "deferred",
- remediation:
- "Run rea setup --all-detected to align registrations and the skill, then restart clients that may retain an older MCP server.",
- });
-
- expect(host.output).toBe("structured");
- });
-
- it("refuses to mutate an unrelated installation", async () => {
- const host = new FakeUpgradeHost();
- host.installationResult = undefined;
-
- expect(await runUpgrade("0.5.0", host)).toMatchObject({
- status: "failed",
- reason: "unknown-install-method",
- remediation:
- "Update manually with: npm install --global rea-agents@latest",
- });
- expect(host.installs).toBe(0);
- });
-
- it("reports npm installation failure", async () => {
- const host = new FakeUpgradeHost();
- host.installSucceeds = false;
-
- expect(await runUpgrade("0.5.0", host)).toMatchObject({
- status: "failed",
- reason: "install",
- remediation:
- "REA could not update through npm. Check npm registry access and global install permissions, then run: npm install --global rea-agents@latest",
- });
- expect(host.installs).toBe(1);
- });
-});
-
-describe("npm installation detection", () => {
- const host = (root: string, prefix = "/usr/local"): NpmInstallationHost => ({
- canonicalPath: (path) => Promise.resolve(path),
- globalRoot: () => Promise.resolve(root),
- globalPrefix: () => Promise.resolve(prefix),
- });
-
- it("returns the prefix for the global package that owns the running CLI", async () => {
- await expect(
- detectNpmInstallation(
- "/usr/local/lib/node_modules/rea-agents",
- host("/usr/local/lib/node_modules"),
- ),
- ).resolves.toEqual({ prefix: "/usr/local" });
- });
-
- it("infers the prefix used by the curl installer's custom npm layout", async () => {
- await expect(
- detectNpmInstallation(
- "/home/user/.local/lib/node_modules/rea-agents",
- host("/usr/local/lib/node_modules"),
- ),
- ).resolves.toEqual({ prefix: "/home/user/.local" });
- });
-
- it("rejects a source checkout when a different global copy exists", async () => {
- await expect(
- detectNpmInstallation("/work/rea", host("/usr/local/lib/node_modules")),
- ).resolves.toBeUndefined();
- });
-});
diff --git a/src/application/Upgrade.ts b/src/application/Upgrade.ts
deleted file mode 100644
index 100b3c052d..0000000000
--- a/src/application/Upgrade.ts
+++ /dev/null
@@ -1,240 +0,0 @@
-import { execFile, spawn } from "node:child_process";
-import { realpath } from "node:fs/promises";
-import { basename, dirname, resolve } from "node:path";
-import { fileURLToPath } from "node:url";
-import { promisify } from "node:util";
-
-import { gt, valid } from "semver";
-import { z } from "zod";
-
-import { PRODUCT_IDENTITY } from "../identity.js";
-
-const execFileAsync = promisify(execFile);
-const registryResponseSchema = z.object({ version: z.string().min(1) });
-const UPGRADE_COMMAND = "npm install --global rea-agents@latest";
-const CLI_SCRIPT_PATH = fileURLToPath(
- new URL("../../scripts/rea.mjs", import.meta.url),
-);
-
-/** Location of the global npm installation that owns the running CLI. */
-export interface NpmInstallation {
- readonly prefix: string;
-}
-
-/** npm filesystem queries used to identify the installation being updated. */
-export interface NpmInstallationHost {
- canonicalPath(path: string): Promise;
- globalRoot(): Promise;
- globalPrefix(): Promise;
-}
-
-/** Output channel policy for the package manager subprocess. */
-export type UpgradeOutput = "human" | "structured";
-
-/** Effects required to inspect and update the installed CLI. */
-export interface UpgradeHost {
- latestVersion(): Promise;
- installation(): Promise;
- installLatest(
- installation: NpmInstallation,
- output: UpgradeOutput,
- ): Promise;
- syncAgentIntegration(output: UpgradeOutput): Promise;
-}
-
-/** Caller-visible outcome of checking and updating the REA CLI. */
-export type UpgradeResult =
- | {
- readonly status: "current";
- readonly currentVersion: string;
- readonly latestVersion: string;
- readonly installMethod: "npm";
- }
- | {
- readonly status: "upgraded";
- readonly previousVersion: string;
- readonly latestVersion: string | null;
- readonly versionCheck: "available" | "unavailable";
- readonly installMethod: "npm";
- readonly command: typeof UPGRADE_COMMAND;
- readonly integrationSync: "setup_invoked" | "deferred" | "failed";
- readonly clientRestartRequired: true;
- readonly remediation: string;
- }
- | {
- readonly status: "failed";
- readonly currentVersion: string;
- readonly latestVersion: string | null;
- readonly reason: "unknown-install-method" | "version-check" | "install";
- readonly remediation: string;
- };
-
-/** Check the npm registry and update the same global REA installation. */
-export const runUpgrade = async (
- currentVersion: string,
- host: UpgradeHost = systemUpgradeHost(),
- output: UpgradeOutput = "human",
-): Promise => {
- const latestVersion = await host.latestVersion();
- const currentIsValid = valid(currentVersion) !== null;
- const latestIsValid =
- latestVersion !== undefined && valid(latestVersion) !== null;
- if (!currentIsValid || !latestIsValid)
- return {
- status: "failed",
- currentVersion,
- latestVersion: latestVersion ?? null,
- reason: "version-check",
- remediation:
- "REA could not verify a newer registry release. Check npm registry access and version metadata, then rerun rea upgrade.",
- };
-
- if (!gt(latestVersion, currentVersion))
- return {
- status: "current",
- currentVersion,
- latestVersion,
- installMethod: "npm",
- };
-
- const installation = await host.installation();
- if (installation === undefined)
- return {
- status: "failed",
- currentVersion,
- latestVersion: latestVersion ?? null,
- reason: "unknown-install-method",
- remediation: `Update manually with: ${UPGRADE_COMMAND}`,
- };
-
- if (!(await host.installLatest(installation, output)))
- return {
- status: "failed",
- currentVersion,
- latestVersion: latestVersion ?? null,
- reason: "install",
- remediation: `REA could not update through npm. Check npm registry access and global install permissions, then run: ${UPGRADE_COMMAND}`,
- };
-
- const integrationSyncResult = await host.syncAgentIntegration(output);
- const integrationSync =
- integrationSyncResult === undefined
- ? "deferred"
- : integrationSyncResult
- ? "setup_invoked"
- : "failed";
- return {
- status: "upgraded",
- previousVersion: currentVersion,
- latestVersion,
- versionCheck: "available",
- installMethod: "npm",
- command: UPGRADE_COMMAND,
- integrationSync,
- clientRestartRequired: true,
- remediation:
- integrationSync === "setup_invoked"
- ? "The updated setup journey was opened to align registrations and the skill. Restart clients whose approved registration changed."
- : "Run rea setup --all-detected to align registrations and the skill, then restart clients that may retain an older MCP server.",
- };
-};
-
-/** Create the npm registry and subprocess effects for a production upgrade. */
-export const systemUpgradeHost = (): UpgradeHost => ({
- latestVersion: async () => {
- try {
- const response = await fetch(
- `https://registry.npmjs.org/${PRODUCT_IDENTITY.packageName}/latest`,
- { signal: AbortSignal.timeout(10_000) },
- );
- if (!response.ok) return undefined;
- const parsed = registryResponseSchema.safeParse(await response.json());
- return parsed.success ? parsed.data.version : undefined;
- } catch {
- return undefined;
- }
- },
- installation: () =>
- detectNpmInstallation(
- fileURLToPath(new URL("../..", import.meta.url)),
- systemNpmInstallationHost,
- ),
- installLatest: ({ prefix }, output) =>
- runCommand(
- "npm",
- [
- "install",
- "--global",
- "--prefix",
- prefix,
- `${PRODUCT_IDENTITY.packageName}@latest`,
- ],
- output,
- ),
- syncAgentIntegration: (output) =>
- output === "structured" ||
- process.stdin.isTTY !== true ||
- process.stderr.isTTY !== true
- ? Promise.resolve(undefined)
- : runCommand(
- process.execPath,
- [CLI_SCRIPT_PATH, "setup", "--all-detected"],
- "human",
- ),
-});
-
-const systemNpmInstallationHost: NpmInstallationHost = {
- canonicalPath: realpath,
- globalRoot: async () =>
- (await execFileAsync("npm", ["root", "--global"])).stdout.trim(),
- globalPrefix: async () =>
- (await execFileAsync("npm", ["prefix", "--global"])).stdout.trim(),
-};
-
-/** Identify the npm prefix only when it owns the running package. */
-export const detectNpmInstallation = async (
- packageRoot: string,
- host: NpmInstallationHost,
-): Promise => {
- try {
- const canonicalPackageRoot = await host.canonicalPath(packageRoot);
- const npmRoot = await host.globalRoot();
- const globalPackageRoot = await host.canonicalPath(
- resolve(npmRoot, PRODUCT_IDENTITY.packageName),
- );
- if (canonicalPackageRoot === globalPackageRoot) {
- const prefix = await host.globalPrefix();
- return prefix.length === 0 ? undefined : { prefix };
- }
- return inferUnixGlobalPrefix(canonicalPackageRoot);
- } catch {
- return inferUnixGlobalPrefix(packageRoot);
- }
-};
-
-const inferUnixGlobalPrefix = (
- packageRoot: string,
-): NpmInstallation | undefined => {
- const nodeModules = dirname(packageRoot);
- const library = dirname(nodeModules);
- if (
- basename(packageRoot) !== PRODUCT_IDENTITY.packageName ||
- basename(nodeModules) !== "node_modules" ||
- basename(library) !== "lib"
- )
- return undefined;
- return { prefix: dirname(library) };
-};
-
-const runCommand = (
- command: string,
- arguments_: readonly string[],
- output: UpgradeOutput,
-): Promise =>
- new Promise((resolveResult) => {
- const child = spawn(command, [...arguments_], {
- stdio: output === "human" ? "inherit" : ["inherit", "ignore", "inherit"],
- });
- child.once("error", () => resolveResult(false));
- child.once("exit", (code) => resolveResult(code === 0));
- });
diff --git a/src/cli/setupCommands.ts b/src/cli/setupCommands.ts
index 876d256a44..c6969e2c4b 100644
--- a/src/cli/setupCommands.ts
+++ b/src/cli/setupCommands.ts
@@ -11,7 +11,8 @@ import {
type SetupOptions,
} from "../application/Setup.js";
import { runUninstall } from "../application/Uninstall.js";
-import { runUpgrade, systemUpgradeHost } from "../application/Upgrade.js";
+import { runUpdate } from "../application/Update.js";
+import { systemUpdateHost } from "../application/UpdateRuntime.js";
import {
confirmInteractiveSetup,
renderInteractiveSetupResult,
@@ -33,7 +34,7 @@ const supportedClientSchema = z
"Unsupported agent integration",
);
-/** Register setup, doctor, uninstall, and upgrade CLI commands. */
+/** Register setup, doctor, uninstall, and update CLI commands. */
export const registerSetupCommands = (
cli: CliInstance,
logger: Logger,
@@ -45,7 +46,7 @@ export const registerSetupCommands = (
const registerSetupCommand = (cli: CliInstance, logger: Logger): void => {
cli.command(CLI_COMMANDS.setup, {
- description: "Install requirements and configure agents",
+ description: "Configure agent integrations and optional analysis providers",
outputPolicy: "agent-only",
options: z.object({
yes: z
@@ -132,13 +133,14 @@ const registerMaintenanceCommands = (
run: ({ options }) =>
logCliCommand(logger, "uninstall", () => runUninstall(options.purgeData)),
});
- cli.command(CLI_COMMANDS.upgrade, {
- description: "Upgrade a global npm installation to the latest REA release",
+ cli.command(CLI_COMMANDS.update, {
+ description:
+ "Update this REA installation and verify the installed release",
run: ({ formatExplicit }) =>
- logCliCommand(logger, "upgrade", () =>
- runUpgrade(
+ logCliCommand(logger, "update", () =>
+ runUpdate(
PRODUCT_IDENTITY.packageVersion,
- systemUpgradeHost(),
+ systemUpdateHost(),
formatExplicit ? "structured" : "human",
),
),
@@ -162,15 +164,12 @@ const runSetupCommand = async (input: {
const { options } = input;
const interactive = setupIsInteractive(options, input.formatExplicit);
const hasExplicitScope = setupHasExplicitScope(options);
- if (options.yes && !hasExplicitScope)
- process.stderr.write(
- "! Implicit `rea setup --yes` scope is deprecated; use `--all-detected` to retain it.\n",
- );
const result = await runSetup(
setupRunOptions(input, interactive, hasExplicitScope),
systemSetupHost(createSystemDoctorHost()),
interactive
- ? (actions) => confirmInteractiveSetup(actions, options.accessible)
+ ? (actions, context) =>
+ confirmInteractiveSetup(actions, options.accessible, context)
: undefined,
);
if (interactive) renderInteractiveSetupResult(result);
@@ -193,7 +192,9 @@ const setupRunOptions = (
approved: options.yes && !options.dryRun,
installHopper: options.installHopper,
structured: input.formatExplicit || options.dryRun,
- proposeHopper: !hasExplicitScope || options.installHopper,
+ dryRun: options.dryRun,
+ allDetectedClients: options.allDetected,
+ proposeHopper: interactive || options.installHopper,
...(hasSelectedClients ? { clientIds: options.client } : {}),
...(hasExplicitScope
? { installSkill: options.skill ?? agentIntegrationSelected }
@@ -219,6 +220,7 @@ const setupIsInteractive = (
!options.dryRun &&
!formatExplicit &&
process.stdin.isTTY === true &&
+ process.stdout.isTTY === true &&
process.stderr.isTTY === true;
const setupHasExplicitScope = (options: SetupCommandOptions): boolean =>
diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts
index daa080848d..c461f5e36f 100644
--- a/src/cliCommandNames.ts
+++ b/src/cliCommandNames.ts
@@ -6,7 +6,7 @@ export const CLI_COMMANDS = Object.freeze({
setup: "setup",
doctor: "doctor",
uninstall: "uninstall",
- upgrade: "upgrade",
+ update: "update",
xrefs: "xrefs",
trace: "trace",
traceNativeUiAction: "trace-native-ui-action",
diff --git a/src/cliLogging.test.ts b/src/cliLogging.test.ts
index 28eef61fd0..2d98569605 100644
--- a/src/cliLogging.test.ts
+++ b/src/cliLogging.test.ts
@@ -15,7 +15,6 @@ describe("CLI operation status classification", () => {
["failed status", { status: "failed" }],
["confirmation requirement", { status: "needs_confirmation" }],
["human remediation", { status: "needs_human" }],
- ["unapplied plan", { status: "planned" }],
])("classifies %s as failure", (_label, value) => {
expect(isCliOperationFailure(value)).toBe(true);
});
@@ -31,10 +30,12 @@ describe("CLI operation status classification", () => {
result: projectAnalysisError(new ConfigurationError("source setting")),
},
],
+ ["requested dry run", { status: "planned" }],
+ ["cancelled setup", { status: "cancelled" }],
["ready setup", { status: "ready" }],
["complete uninstall", { status: "complete" }],
["current version", { status: "current" }],
- ["completed upgrade", { status: "upgraded" }],
+ ["completed update", { status: "updated" }],
["healthy diagnostics", { healthy: true, checks: [] }],
["bounded evidence", { evidence: [{ truncated: true }] }],
])("keeps %s successful", (_label, value) => {
diff --git a/src/cliLogging.ts b/src/cliLogging.ts
index c9476a1978..b7d70c2284 100644
--- a/src/cliLogging.ts
+++ b/src/cliLogging.ts
@@ -13,8 +13,7 @@ export const isCliOperationFailure = (value: unknown): boolean => {
return (
value.status === "failed" ||
value.status === "needs_confirmation" ||
- value.status === "needs_human" ||
- value.status === "planned"
+ value.status === "needs_human"
);
};
diff --git a/src/cliSetup.ts b/src/cliSetup.ts
index 75eec19bd5..9bd412ee9f 100644
--- a/src/cliSetup.ts
+++ b/src/cliSetup.ts
@@ -10,6 +10,8 @@ import {
import type {
SetupAction,
SetupConfirmationDecision,
+ SetupConfirmationContext,
+ SetupClientState,
SetupProgressEvent,
SetupResult,
} from "./application/Setup.js";
@@ -28,47 +30,41 @@ const promptStreams = {
withGuide: true,
} as const;
-const agentIntegrationCapabilityId = "agent_integration";
-
-interface SetupCapability {
- readonly id: string;
- readonly label: string;
- readonly hint: string;
-}
-
-/** Run the inline setup picker, exact preflight, and explicit consent. */
+/** Select agent targets, then review the resolved plan before consent. */
export const confirmInteractiveSetup = async (
actions: readonly SetupAction[],
accessible: boolean,
+ context?: SetupConfirmationContext,
): Promise => {
- intro("REA setup", promptStreams);
- renderValueIntroduction();
- renderDetectedSummary(actions);
- renderKeyboardHelp(accessible);
- const selectedActionIds = await selectSetupActions(actions, accessible);
- if (selectedActionIds === undefined) return cancelledDecision();
- if (selectedActionIds.length === 0) {
- cancel("Nothing selected. No changes were made.", promptStreams);
- return { approved: false, selectedActionIds };
+ if (context?.stage === "select" || context === undefined) {
+ intro("REA setup", promptStreams);
+ writeLine(
+ "│ Understand local apps and binaries from your terminal or agent.",
+ );
+ renderDetectedSummary(context?.clientStates ?? []);
+ renderKeyboardHelp(accessible);
}
- const selected = new Set(selectedActionIds);
- const selectedActions = actions.filter(({ id }) => selected.has(id));
- renderPreflight(selectedActions);
+ if (context?.stage === "select") {
+ const selected = await selectSetupActions(actions, accessible, context);
+ return selected === undefined
+ ? cancelledDecision()
+ : { approved: false, selectedActionIds: selected };
+ }
+ if (actions.length === 0) return { approved: true, selectedActionIds: [] };
+ renderPreflight(actions);
const approved = await confirm({
...promptStreams,
message:
- selectedActions.length === 1
+ actions.length === 1
? "Apply this change?"
- : `Apply these ${String(selectedActions.length)} changes?`,
+ : `Apply these ${String(actions.length)} changes?`,
initialValue: true,
active: "Yes, apply",
inactive: "No, cancel",
+ vertical: accessible,
});
- if (isCancel(approved) || !approved) {
- cancel("Setup cancelled. No changes were made.", promptStreams);
- return { approved: false, selectedActionIds };
- }
- return { approved: true, selectedActionIds };
+ if (isCancel(approved) || !approved) return cancelledDecision();
+ return { approved: true, selectedActionIds: actions.map(({ id }) => id) };
};
/** Render stable, append-only progress for real setup operations. */
@@ -101,153 +97,87 @@ export const renderInteractiveSetupResult = (result: SetupResult): void => {
);
return;
}
- if (result.status === "planned") return;
+ if (result.status === "planned" || result.status === "cancelled") return;
writeLine(`! ${result.remediation ?? "Setup needs attention."}`);
- outro("Run `rea doctor` for the remaining checks.", promptStreams);
+ outro(
+ `Run \`${cliInvocation()} doctor\` for the remaining checks.`,
+ promptStreams,
+ );
};
const selectSetupActions = async (
actions: readonly SetupAction[],
accessible: boolean,
+ context: SetupConfirmationContext,
): Promise => {
- const clientActions = actions.filter(
- ({ kind }) => kind === "configure_client",
+ const clientStates = context.clientStates.filter(
+ ({ client }) => client.format !== "unsupported",
);
- const componentActions = actions.filter(
- ({ kind }) => kind !== "configure_client",
+ const initial = new Set(context.selectedClientIds);
+ const selectedClientIds = accessible
+ ? await selectClientsAccessibly(clientStates, initial)
+ : await selectClients(clientStates, initial);
+ if (selectedClientIds === undefined) return undefined;
+ const selectedActionIds = selectedClientIds.map(
+ (id) => `configure_client:${id}`,
);
- const capabilities = setupCapabilities(clientActions, componentActions);
- const preselectedCapabilityIds = defaultCapabilityIds(capabilities);
- const selectedCapabilities = accessible
- ? await selectCapabilitiesAccessibly(capabilities, preselectedCapabilityIds)
- : await selectCapabilities(capabilities, preselectedCapabilityIds);
- if (selectedCapabilities === undefined) return undefined;
- const selectedCapabilityIds = new Set(selectedCapabilities);
- const bundledSkillAction = componentActions.find(
- ({ kind }) => kind === "install_skill",
- );
- const selectedActionIds = componentActions
- .filter(
- ({ id }) =>
- id !== bundledSkillAction?.id && selectedCapabilityIds.has(id),
- )
- .map(({ id }) => id);
- if (!selectedCapabilityIds.has(agentIntegrationCapabilityId))
- return selectedActionIds;
- const bundledSkillId =
- bundledSkillAction === undefined ? undefined : bundledSkillAction.id;
- if (clientActions.length === 0)
- return [
- ...(bundledSkillId === undefined ? [] : [bundledSkillId]),
- ...selectedActionIds,
- ];
- const preselectedClientIds = clientActions.map(({ id }) => id);
- const selectedClients = accessible
- ? await selectClientsAccessibly(clientActions, preselectedClientIds)
- : await selectClients(clientActions, preselectedClientIds);
- if (selectedClients === undefined) return undefined;
- return [
- ...selectedClients,
- ...(bundledSkillId === undefined ? [] : [bundledSkillId]),
- ...selectedActionIds,
- ];
-};
-
-const defaultCapabilityIds = (
- capabilities: readonly SetupCapability[],
-): readonly string[] => {
- const hasAgentIntegration = capabilities.some(
- ({ id }) => id === agentIntegrationCapabilityId,
- );
- if (!hasAgentIntegration) return [];
- return [agentIntegrationCapabilityId];
-};
-
-const setupCapabilities = (
- clientActions: readonly SetupAction[],
- componentActions: readonly SetupAction[],
-): readonly SetupCapability[] => [
- ...(clientActions.length === 0 &&
- !componentActions.some(({ kind }) => kind === "install_skill")
- ? []
- : [
- {
- id: agentIntegrationCapabilityId,
- label: "Agent integration (MCP + guided workflow)",
- hint:
- clientActions.length === 0
- ? "MCP registrations are aligned; installs the matching bundled skill"
- : `${String(clientActions.length)} detected ${clientActions.length === 1 ? "agent" : "agents"}; installs the bundled skill when needed`,
- },
- ]),
- ...componentActions
- .filter(({ kind }) => kind !== "install_skill")
- .map((action) => ({
- id: action.id,
- label: `${action.label} (${actionModality(action)})`,
- hint: `${action.operation} · ${action.target}`,
- })),
-];
-
-const selectCapabilities = async (
- capabilities: readonly SetupCapability[],
- initialValues: readonly string[],
-): Promise => {
- const selection = await multiselect({
- ...promptStreams,
- message: "What should REA set up?",
- options: capabilities.map((capability) => ({
- value: capability.id,
- label: capability.label,
- hint: capability.hint,
- })),
- initialValues: [...initialValues],
- required: false,
- maxItems: Math.max(
- 3,
- Math.min(capabilities.length, (process.stderr.rows ?? 24) - 8),
- ),
- showInstructions: true,
- });
- return isCancel(selection) ? undefined : selection;
-};
-
-const selectCapabilitiesAccessibly = async (
- capabilities: readonly SetupCapability[],
- preselectedIds: readonly string[],
-): Promise => {
- const preselected = new Set(preselectedIds);
- const selected: string[] = [];
- for (const capability of capabilities) {
+ const skill = actions.find(({ kind }) => kind === "install_skill");
+ if (skill !== undefined) {
+ if (selectedClientIds.length > 0) selectedActionIds.push(skill.id);
+ else {
+ const included = await confirm({
+ ...promptStreams,
+ message: "Install the guided REA skill for CLI use?",
+ initialValue: false,
+ vertical: accessible,
+ });
+ if (isCancel(included)) return undefined;
+ if (included) selectedActionIds.push(skill.id);
+ }
+ }
+ const hopper = actions.find(({ kind }) => kind === "install_hopper");
+ if (hopper !== undefined) {
const included = await confirm({
...promptStreams,
- message: `Set up ${capability.label}? ${capability.hint}`,
- initialValue: preselected.has(capability.id),
- vertical: true,
+ message: "Install Hopper for deep binary analysis?",
+ initialValue: false,
+ vertical: accessible,
});
if (isCancel(included)) return undefined;
- if (included) selected.push(capability.id);
+ if (included) selectedActionIds.push(hopper.id);
}
- return selected;
+ return selectedActionIds;
};
+const clientHint = (state: SetupClientState): string =>
+ state.configured
+ ? "REA already configured · selected from existing configuration"
+ : state.status === "invalid"
+ ? "Existing configuration needs repair before setup"
+ : state.detected
+ ? "Detected · choose to configure"
+ : "Not detected · configuration is still available";
+
const selectClients = async (
- actions: readonly SetupAction[],
- initialValues: readonly string[],
+ states: readonly SetupClientState[],
+ initial: ReadonlySet,
): Promise => {
+ if (states.length === 0) return [];
const selection = await multiselect({
...promptStreams,
message: "Which agents should use REA?",
- options: actions.map((action) => ({
- value: action.id,
- label: `${action.label} (detected)`,
- hint: `Configure the REA MCP server for ${action.label}`,
+ options: states.map((state) => ({
+ value: state.client.name,
+ label: clientDisplayNames.get(state.client.name) ?? state.client.name,
+ hint: clientHint(state),
})),
- initialValues: [...initialValues],
+ initialValues: states
+ .filter(({ client }) => initial.has(client.name))
+ .map(({ client }) => client.name),
required: false,
maxItems: Math.max(
3,
- Math.min(actions.length, (process.stderr.rows ?? 24) - 8),
+ Math.min(states.length, (process.stderr.rows ?? 24) - 8),
),
showInstructions: true,
});
@@ -255,47 +185,31 @@ const selectClients = async (
};
const selectClientsAccessibly = async (
- actions: readonly SetupAction[],
- initialValues: readonly string[],
+ states: readonly SetupClientState[],
+ initial: ReadonlySet,
): Promise => {
- const preselected = new Set(initialValues);
const selected: string[] = [];
- for (const action of actions) {
+ for (const state of states) {
const included = await confirm({
...promptStreams,
- message: `Configure ${action.label}? ${action.target}`,
- initialValue: preselected.has(action.id),
+ message: `Configure ${clientDisplayNames.get(state.client.name) ?? state.client.name}? ${clientHint(state)}`,
+ initialValue: initial.has(state.client.name),
vertical: true,
});
if (isCancel(included)) return undefined;
- if (included) selected.push(action.id);
+ if (included) selected.push(state.client.name);
}
return selected;
};
-const renderValueIntroduction = (): void => {
- writeLine("│ Understand local apps and binaries from your coding agent.");
+const renderDetectedSummary = (states: readonly SetupClientState[]): void => {
+ const detected = states.filter(({ detected }) => detected);
writeLine("│");
- writeLine("│ • Trace how a feature works with local evidence.");
writeLine(
- "│ • Use the same analysis capabilities from REA's CLI and supported agents.",
+ detected.length === 0
+ ? "◆ No agents detected. Choose any supported agent below."
+ : `◆ Detected: ${humanList(detected.map(({ client }) => clientDisplayNames.get(client.name) ?? client.name))}`,
);
- writeLine(
- "│ • Keep analyzed targets on this machine instead of uploading them to a hosted service.",
- );
-};
-
-const renderDetectedSummary = (actions: readonly SetupAction[]): void => {
- const clients = actions.filter(({ kind }) => kind === "configure_client");
- writeLine("│");
- if (clients.length === 0) {
- writeLine("◆ No agent integrations need configuration");
- } else {
- writeLine(
- `◆ Found ${String(clients.length)} supported ${clients.length === 1 ? "agent" : "agents"}`,
- );
- writeLine(`│ ${humanList(clients.map(({ label }) => label))}`);
- }
};
const renderKeyboardHelp = (accessible: boolean): void => {
@@ -307,6 +221,9 @@ const renderKeyboardHelp = (accessible: boolean): void => {
);
};
+const cliInvocation = (): string =>
+ process.env.npm_command === "exec" ? "npx rea-agents" : "rea";
+
const renderReadyCapabilities = (
result: SetupResult,
readyClients: readonly string[],
@@ -319,7 +236,9 @@ const renderReadyCapabilities = (
writeLine(`│ Agent access: ${humanList(readyClients)}`);
if (result.doctor.identity?.skill.state === "aligned")
writeLine("│ Guided reverse-engineering workflows: installed");
- if (providers.length > 0) writeLine("│ CLI: rea analyze /path/to/app");
+ writeLine(
+ `│ CLI: ${cliInvocation()} ${providers.length > 0 ? "analyze /path/to/app" : "capabilities"}`,
+ );
const firstClient = readyClients[0];
if (firstClient !== undefined)
writeLine(
@@ -337,25 +256,12 @@ const readyAgentClients = (
return [...new Set([...alignedClients, ...changedClients])];
};
-const readyProviders = (result: SetupResult): readonly string[] => {
- const providers = [
- ...(result.doctor.hopperPath === undefined ? [] : ["Hopper"]),
- ...(result.doctor.providerInspections ?? [])
- .filter(({ available }) => available)
- .map(({ id }) => displayProviderId(id)),
- ];
- return [...new Set(providers)];
-};
+const readyProviders = (result: SetupResult): readonly string[] =>
+ result.doctor.availableProviders.map(displayProviderId);
const displayProviderId = (id: string): string =>
id.length === 0 ? id : `${id[0]?.toUpperCase() ?? ""}${id.slice(1)}`;
-const actionModality = (action: SetupAction): string => {
- if (action.kind === "configure_client") return "MCP";
- if (action.kind === "install_hopper") return "provider";
- return "skill";
-};
-
const humanList = (values: readonly string[]): string => {
if (values.length < 2) return values[0] ?? "";
if (values.length === 2) return `${values[0]} and ${values[1]}`;
@@ -391,6 +297,7 @@ const cancelledDecision = (): SetupConfirmationDecision => {
return {
approved: false,
selectedActionIds: [],
+ cancelled: true,
};
};
diff --git a/tests/boundary/cli/cliSetup.test.ts b/tests/boundary/cli/cliSetup.test.ts
index 85998b0ff9..a4740f4be0 100644
--- a/tests/boundary/cli/cliSetup.test.ts
+++ b/tests/boundary/cli/cliSetup.test.ts
@@ -4,11 +4,17 @@ import { promisify } from "node:util";
import { spawn } from "@lydell/node-pty";
import { describe, expect, it } from "vitest";
+import { z } from "zod";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const execute = promisify(execFile);
const decisionMarker = "REA_SETUP_DECISION:";
+const decisionSchema = z.object({
+ approved: z.boolean(),
+ selectedActionIds: z.array(z.string()),
+ cancelled: z.boolean().optional(),
+});
const actions = [
{
@@ -40,234 +46,222 @@ const actions = [
},
] as const;
+const clientStates = [
+ {
+ client: {
+ name: "codex",
+ format: "toml",
+ configPath: [".codex", "config.toml"],
+ marker: [".codex"],
+ },
+ detected: true,
+ configured: false,
+ status: "needs_configuration",
+ },
+ {
+ client: {
+ name: "opencode",
+ format: "opencode",
+ configPath: [".config", "opencode", "opencode.json"],
+ marker: [".config", "opencode"],
+ },
+ detected: false,
+ configured: false,
+ status: "missing",
+ },
+] as const;
+
+const agentActions = actions.filter(({ kind }) => kind !== "install_hopper");
+
describe("interactive setup journey", () => {
- it("leads with value, a compact detected-target summary, and durable key help", async () => {
- const result = await runJourney([
- step("What should REA set up?", "\u0003"),
- ]);
-
- expect(result.output).toContain(
- "Understand local apps and binaries from your coding agent.",
- );
- expect(result.output).toContain(
- "Trace how a feature works with local evidence.",
- );
- expect(result.output).toContain("Found 1 supported agent");
- expect(result.output).toContain("Codex");
- expect(result.output).toContain("Hopper deep-analysis provider (provider)");
- expect(result.output).toContain(
- "Agent integration (MCP + guided workflow)",
- );
- expect(result.output).toContain(
- "Keys: ↑/↓ navigate · Space toggle · Enter confirm · Ctrl-C cancel",
- );
- expect(result.decision.approved).toBe(false);
- });
-
- it("allows deselecting the pre-selected agent integration to opt out", async () => {
- const result = await runJourney([step("What should REA set up?", " \r")]);
-
- expect(result.output).toContain(
- "Agent integration (MCP + guided workflow)",
- );
- expect(result.output).not.toContain(
- "REA reverse-engineering skill (skill)",
- );
- expect(result.output).toContain("Hopper deep-analysis provider (provider)");
- expect(result.output).not.toContain("Ready to review");
- expect(result.output).toContain("Nothing selected. No changes were made.");
- expect(result.decision).toEqual({
- approved: false,
- selectedActionIds: [],
- });
- });
-
- it("pre-selects agent integration and proceeds to the agent picker on Enter", async () => {
- const result = await runJourney([
- step("What should REA set up?", "\r"),
- step("Which agents should use REA?", "\u0003"),
- ]);
-
- expect(result.output).toContain("Codex (detected)");
- expect(result.output).toContain("Setup cancelled. No changes were made.");
- expect(result.decision.approved).toBe(false);
- });
-
- it("offers one bundled repair when MCP is aligned but the skill is missing", async () => {
+ it("shows detected and absent clients without preselecting detected clients", async () => {
const result = await runJourney(
- [step("What should REA set up?", "\r"), step("Apply this change?", "\r")],
+ [step("Which agents should use REA?", "\u001b[B\r")],
false,
- actions.filter(({ kind }) => kind !== "configure_client"),
+ [actions[0]],
);
-
- expect(result.output).toContain("No agent integrations need configuration");
+ expect(result.output).toContain("Detected: Codex");
expect(result.output).toContain(
- "Agent integration (MCP + guided workflow)",
+ "Not detected · configuration is still available",
);
- expect(result.output).not.toContain(
- "REA reverse-engineering skill (skill)",
- );
- expect(result.output).toContain("INSTALL REA reverse-engineering skill");
- expect(result.output).not.toContain("CREATE Codex");
- expect(result.output).not.toContain(
- "INSTALL Hopper deep-analysis provider",
- );
- expect(result.decision).toEqual({
- approved: true,
- selectedActionIds: ["install_skill"],
- });
+ expect(result.output).toContain("Which agents should use REA?");
+ expect(result.output).not.toContain("What should REA set up?");
+ expect(result.decision.selectedActionIds).toEqual([]);
});
- it("pre-selects all detected agents in the agent picker", async () => {
+ it("selects an absent OpenCode client and bundles its skill", async () => {
const result = await runJourney(
[
- step("What should REA set up?", "\r"),
- step("Which agents should use REA?", "\u0003"),
+ step("Which agents should use REA?", "\u001b[B \r"),
+ step("Apply these 2 changes?", "\r"),
],
false,
[
- actions[0],
{
...actions[0],
- id: "configure_client:cursor",
- label: "Cursor",
- target: "/isolated/.cursor/mcp.json",
+ id: "configure_client:opencode",
+ label: "OpenCode",
+ target: "/isolated/.config/opencode/opencode.json",
},
- ...actions.slice(1),
+ actions[1],
],
);
-
- expect(result.output).toContain("Codex (detected)");
- expect(result.output).toContain("Cursor (detected)");
- expect(result.decision.approved).toBe(false);
- });
-});
-
-describe("interactive setup selection and confirmation", () => {
- it("retains the bundled skill even when all agents are deselected (#399)", async () => {
- const result = await runJourney([
- step("What should REA set up?", "\r"),
- step("Which agents should use REA?", " \r"),
- step("Apply this change?", "\r"),
+ expect(result.output).toContain("CREATE OpenCode");
+ expect(result.decision.selectedActionIds).toEqual([
+ "configure_client:opencode",
+ "install_skill",
]);
-
- expect(result.output).toContain("Ready to review");
- expect(result.output).toContain("INSTALL REA reverse-engineering skill");
- expect(result.output).not.toContain("CREATE Codex");
- expect(result.decision).toEqual({
- approved: true,
- selectedActionIds: ["install_skill"],
- });
+ expect(result.decision.approved).toBe(true);
});
- it("defaults the final confirmation to Yes, apply", async () => {
- const result = await runJourney([
- step("What should REA set up?", "\r"),
- step("Which agents should use REA?", "\r"),
- step("Apply these 2 changes?", "\r"),
- ]);
-
- expect(result.output).toContain("Ready to review");
- expect(result.output).toContain("CREATE Codex");
- expect(result.output).toContain("/isolated/.codex/config.toml");
- expect(result.output).toContain("INSTALL REA reverse-engineering skill");
- expect(result.output).not.toContain(
- "INSTALL Hopper deep-analysis provider",
- );
- expect(result.output).toContain("Yes, apply");
- expect(result.decision).toEqual({
- approved: true,
- selectedActionIds: ["configure_client:codex", "install_skill"],
- });
- });
-
- it("can still be cancelled at the final confirmation", async () => {
- const result = await runJourney([
- step("What should REA set up?", "\r"),
- step("Which agents should use REA?", "\r"),
- step("Apply these 2 changes?", "\u001b[D\r"),
- ]);
-
- expect(result.output).toContain("Ready to review");
- expect(result.output).toContain("Setup cancelled. No changes were made.");
- expect(result.decision).toEqual({
- approved: false,
- selectedActionIds: ["configure_client:codex", "install_skill"],
- });
- });
-
- it("defaults accessible agent integration to Yes and others to No", async () => {
+ it("shows the launcher, backup, and path before final approval", async () => {
const result = await runJourney(
[
- step("Set up Agent integration (MCP + guided workflow)?", "\r"),
- step("Set up Hopper deep-analysis provider (provider)?", "\r"),
+ step("Which agents should use REA?", " \r"),
+ step("Apply these 2 changes?", "\r"),
+ ],
+ false,
+ [
+ {
+ ...actions[0],
+ operation: "update",
+ backupPath: "/isolated/.codex/config.toml.bak",
+ commands: ["npx -y rea-agents@3.2.1 mcp"],
+ },
+ actions[1],
+ ],
+ );
+ expect(result.output).toContain("/isolated/.codex/config.toml.bak");
+ expect(result.output).toContain("command: npx -y rea-agents@3.2.1 mcp");
+ expect(result.output.indexOf("command:")).toBeLessThan(
+ result.output.indexOf("Apply these 2 changes?"),
+ );
+ });
+
+ it("treats final No as cancellation", async () => {
+ const result = await runJourney([
+ step("Which agents should use REA?", " \r"),
+ step("Apply these 2 changes?", "\u001b[D\r"),
+ ]);
+ expect(result.output).toContain("Setup cancelled. No changes were made.");
+ expect(result.decision.cancelled).toBe(true);
+ expect(result.decision.approved).toBe(false);
+ });
+
+ it("restores the terminal after Ctrl-C during selection", async () => {
+ const result = await runJourney([
+ step("Which agents should use REA?", "\u0003"),
+ ]);
+ expect(result.output).toContain("\u001b[?25h");
+ expect(result.decision.cancelled).toBe(true);
+ });
+
+ it("can install the skill for CLI use without an agent registration", async () => {
+ const result = await runJourney([
+ step("Which agents should use REA?", "\r"),
+ step("Install the guided REA skill for CLI use?", "\u001b[D\r"),
+ step("Apply this change?", "\r"),
+ ]);
+ expect(result.decision.selectedActionIds).toEqual(["install_skill"]);
+ expect(result.decision.approved).toBe(true);
+ });
+
+ it("offers Hopper separately and defaults against installing it", async () => {
+ const result = await runJourney(
+ [
+ step("Which agents should use REA?", " \r"),
+ step("Install Hopper for deep binary analysis?", "\r"),
+ step("Apply these 2 changes?", "\r"),
+ ],
+ false,
+ actions,
+ );
+ expect(result.decision.selectedActionIds).toEqual([
+ "configure_client:codex",
+ "install_skill",
+ ]);
+ });
+
+ it("uses sequential prompts without auto-selecting detected agents in accessible mode", async () => {
+ const result = await runJourney(
+ [
+ step("Configure Codex?", "\r"),
+ step("Configure OpenCode?", "\u001b[A\r"),
step("Apply this change?", "\r"),
],
true,
- actions.filter(({ kind }) => kind !== "configure_client"),
+ [{ ...actions[0], id: "configure_client:opencode", label: "OpenCode" }],
);
-
- expect(result.output).toContain("Ready to review");
- expect(result.output).toContain("INSTALL REA reverse-engineering skill");
- expect(result.decision).toEqual({
- approved: true,
- selectedActionIds: ["install_skill"],
- });
+ expect(result.decision.selectedActionIds).toEqual([
+ "configure_client:opencode",
+ ]);
});
- it("allows accessible opt-out of the pre-selected agent integration", async () => {
+ it("preselects only a prior REA registration", async () => {
const result = await runJourney(
[
- step("Set up Agent integration (MCP + guided workflow)?", "\u001b[B\r"),
- step("Set up Hopper deep-analysis provider (provider)?", "\r"),
+ step("Which agents should use REA?", "\r"),
+ step("Apply these 2 changes?", "\r"),
],
- true,
+ false,
+ agentActions,
+ ["codex"],
);
-
- expect(result.output).toContain("Nothing selected. No changes were made.");
- expect(result.decision.selectedActionIds).toEqual([]);
+ expect(result.decision.selectedActionIds).toEqual([
+ "configure_client:codex",
+ "install_skill",
+ ]);
});
});
describe("interactive setup completion", () => {
- it("frames completion around the capabilities that are now ready", async () => {
- const { stderr } = await execute(
- process.execPath,
- [
- "--input-type=module",
- "--eval",
+ it.each([
+ { npmCommand: "", launcher: "rea" },
+ { npmCommand: "exec", launcher: "npx rea-agents" },
+ ])(
+ "prints an available next command for $launcher invocation",
+ async ({ npmCommand, launcher }) => {
+ const { stderr } = await execute(
+ process.execPath,
[
- 'import { renderInteractiveSetupResult } from "./dist/cliSetup.js";',
- "renderInteractiveSetupResult({",
- ' status: "ready",',
- " plannedActions: [],",
- " appliedActions: [],",
- ' clients: { codex: { status: "configured" } },',
- " doctor: {",
- " healthy: true,",
- ' hopperPath: "/Applications/Hopper",',
- " checks: [],",
- ' providerInspections: [{ id: "ghidra", available: true }],',
- ' identity: { skill: { state: "aligned" }, registrations: [{ client: "codex", state: "aligned" }] },',
- " },",
- "});",
- ].join("\n"),
- ],
- {
- cwd: process.cwd(),
- env: { ...process.env, NO_COLOR: "1" },
- },
- );
+ "--input-type=module",
+ "--eval",
+ [
+ 'import { renderInteractiveSetupResult } from "./dist/cliSetup.js";',
+ "renderInteractiveSetupResult({",
+ ' status: "ready",',
+ " plannedActions: [],",
+ " appliedActions: [],",
+ ' clients: { codex: { status: "configured" } },',
+ " doctor: {",
+ " healthy: true,",
+ ' availableProviders: ["hopper", "ghidra"],',
+ ' hopperPath: "/Applications/Hopper",',
+ " checks: [],",
+ ' providerInspections: [{ id: "ghidra", available: true }],',
+ ' identity: { skill: { state: "aligned" }, registrations: [{ client: "codex", state: "aligned" }] },',
+ " },",
+ "});",
+ ].join("\n"),
+ ],
+ {
+ cwd: process.cwd(),
+ env: { ...process.env, npm_command: npmCommand, NO_COLOR: "1" },
+ },
+ );
- expect(stderr).toContain("What you can do now");
- expect(stderr).toContain("Deep analysis: Hopper and Ghidra");
- expect(stderr).toContain("Agent access: Codex");
- expect(stderr).toContain("Guided reverse-engineering workflows: installed");
- expect(stderr).toContain("CLI: rea analyze /path/to/app");
- expect(stderr).toContain(
- 'Try in Codex: "Explain how a feature works in /path/to/app and show the evidence."',
- );
- });
+ expect(stderr).toContain("What you can do now");
+ expect(stderr).toContain("Deep analysis: Hopper and Ghidra");
+ expect(stderr).toContain("Agent access: Codex");
+ expect(stderr).toContain(
+ "Guided reverse-engineering workflows: installed",
+ );
+ expect(stderr).toContain(`CLI: ${launcher} analyze /path/to/app`);
+ expect(stderr).toContain(
+ 'Try in Codex: "Explain how a feature works in /path/to/app and show the evidence."',
+ );
+ },
+ );
});
interface JourneyStep {
@@ -277,10 +271,7 @@ interface JourneyStep {
interface JourneyResult {
readonly output: string;
- readonly decision: {
- readonly approved: boolean;
- readonly selectedActionIds: readonly string[];
- };
+ readonly decision: z.infer;
}
const step = (prompt: string, input: string): JourneyStep => ({
@@ -291,13 +282,19 @@ const step = (prompt: string, input: string): JourneyStep => ({
const runJourney = async (
steps: readonly JourneyStep[],
accessible = false,
- journeyActions: readonly object[] = actions,
+ journeyActions: readonly object[] = agentActions,
+ initialClientIds: readonly string[] = [],
): Promise => {
const isolatedHome = await createTestTempDirectory("rea-cli-setup-test-");
const script = [
'import { confirmInteractiveSetup } from "./dist/cliSetup.js";',
`const actions = ${JSON.stringify(journeyActions)};`,
- `const decision = await confirmInteractiveSetup(actions, ${JSON.stringify(accessible)});`,
+ `const context = ${JSON.stringify({ stage: "select", clientStates, selectedClientIds: initialClientIds, clientSelectionAllowed: true })};`,
+ `let decision = await confirmInteractiveSetup(actions, ${JSON.stringify(accessible)}, context);`,
+ "if (!decision.cancelled && decision.selectedActionIds.length > 0) {",
+ " const selected = new Set(decision.selectedActionIds);",
+ ` decision = await confirmInteractiveSetup(actions.filter(({ id }) => selected.has(id)), ${JSON.stringify(accessible)}, { ...context, stage: "confirm", clientSelectionAllowed: false });`,
+ "}",
`process.stdout.write(${JSON.stringify(`\n${decisionMarker}`)} + JSON.stringify(decision) + "\\n");`,
].join("\n");
@@ -362,7 +359,7 @@ const runJourney = async (
try {
finish(undefined, {
output,
- decision: JSON.parse(serialized) as JourneyResult["decision"],
+ decision: decisionSchema.parse(JSON.parse(serialized)),
});
} catch (cause: unknown) {
finish(cause instanceof Error ? cause : new Error(String(cause)));
diff --git a/tests/boundary/filesystem/clientConfigurationDialects.test.ts b/tests/boundary/filesystem/clientConfigurationDialects.test.ts
new file mode 100644
index 0000000000..d86e1cdf1a
--- /dev/null
+++ b/tests/boundary/filesystem/clientConfigurationDialects.test.ts
@@ -0,0 +1,273 @@
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+import { parse as parseJsonc, type ParseError } from "jsonc-parser";
+
+import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js";
+import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js";
+import { systemUninstallHost } from "../../../src/application/Uninstall.js";
+import { supportedClients } from "../../../src/application/SupportedClients.js";
+import { PRODUCT_IDENTITY } from "../../../src/identity.js";
+import { isOwnedClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js";
+import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
+
+const command = [
+ "npx",
+ "-y",
+ PRODUCT_IDENTITY.registrationPackageSpecifier,
+ "mcp",
+] as const;
+
+const getClients = (home: string) =>
+ supportedClients(home).filter(({ name }) =>
+ ["opencode", "antigravity", "copilot_cli", "vscode", "devin"].includes(
+ name,
+ ),
+ );
+
+describe("additional client configuration dialects", () => {
+ it.each([
+ "opencode",
+ "antigravity",
+ "copilot_cli",
+ "vscode",
+ "devin",
+ ] as const)("registers, reads back, and uninstalls %s", async (name) => {
+ const home = await createTestTempDirectory("rea-client-dialect-");
+ const client = getClients(home).find(
+ (candidate) => candidate.name === name,
+ );
+ expect(client).toBeDefined();
+ if (client === undefined) throw new Error(`missing ${name} client`);
+
+ await mkdir(client.markerPath ?? home, { recursive: true });
+ const configured = await configureClientConfiguration(client, {}, command);
+ expect(configured.status).toBe("configured");
+ expect(await configureClientConfiguration(client, {}, command)).toEqual({
+ status: "unchanged",
+ });
+
+ const text = await readFile(client.configPath, "utf8");
+ const document = parseJsonc(text) as Record;
+ const serversKey =
+ name === "opencode"
+ ? "mcp"
+ : name === "vscode"
+ ? "servers"
+ : "mcpServers";
+ const servers = document[serversKey] as Record<
+ string,
+ Record
+ >;
+ const registration = servers[PRODUCT_IDENTITY.mcpServerKey];
+ expect(registration).toBeDefined();
+ if (name === "opencode")
+ expect(registration).toMatchObject({
+ type: "local",
+ command,
+ enabled: true,
+ });
+ if (name === "vscode")
+ expect(registration).toMatchObject({
+ type: "stdio",
+ command: "npx",
+ args: command.slice(1),
+ });
+ if (name === "copilot_cli")
+ expect(registration).toMatchObject({ type: "stdio", tools: ["*"] });
+
+ const statuses = await readClientRegistrationStatuses(home);
+ expect(statuses).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ client: name, state: "aligned", command }),
+ ]),
+ );
+
+ const removed = await systemUninstallHost(home).removeClient(client);
+ expect(removed.status).toBe("removed");
+ const afterRemoval = parseJsonc(
+ await readFile(client.configPath, "utf8"),
+ ) as Record;
+ expect(
+ (afterRemoval[serversKey] as Record)[
+ PRODUCT_IDENTITY.mcpServerKey
+ ],
+ ).toBeUndefined();
+ });
+
+ it("preserves OpenCode JSONC comments and sibling registrations during update and removal", async () => {
+ const home = await createTestTempDirectory("rea-opencode-jsonc-");
+ const client = getClients(home).find(({ name }) => name === "opencode");
+ expect(client).toBeDefined();
+ if (client === undefined) throw new Error("missing OpenCode client");
+ await mkdir(client.markerPath ?? home, { recursive: true });
+ const original = `{
+ // Client-wide preferences stay intact.
+ "model": "provider/model",
+ "mcp": {
+ // An independently managed server.
+ "other": { "type": "local", "command": ["node", "other.js"] },
+ },
+}\n`;
+ await writeFile(client.configPath, original);
+
+ expect(
+ await configureClientConfiguration(client, {}, command),
+ ).toMatchObject({
+ status: "configured",
+ backupPath: `${client.configPath}.rea.backup`,
+ });
+ const updated = await readFile(client.configPath, "utf8");
+ const errors: ParseError[] = [];
+ const parsed = parseJsonc(updated, errors, {
+ allowTrailingComma: true,
+ }) as Record;
+ expect(errors).toEqual([]);
+ expect(updated).toContain("// Client-wide preferences stay intact.");
+ expect(updated).toContain("// An independently managed server.");
+ expect(parsed.model).toBe("provider/model");
+ expect((parsed.mcp as Record).other).toBeDefined();
+ expect(await readFile(`${client.configPath}.rea.backup`, "utf8")).toBe(
+ original,
+ );
+
+ expect((await systemUninstallHost(home).removeClient(client)).status).toBe(
+ "removed",
+ );
+ const removedText = await readFile(client.configPath, "utf8");
+ const removedErrors: ParseError[] = [];
+ const removed = parseJsonc(removedText, removedErrors, {
+ allowTrailingComma: true,
+ }) as Record;
+ expect(removedErrors).toEqual([]);
+ expect(removedText).toContain("// Client-wide preferences stay intact.");
+ expect(removedText).toContain("// An independently managed server.");
+ expect((removed.mcp as Record).rea).toBeUndefined();
+ expect((removed.mcp as Record).other).toBeDefined();
+ });
+
+ it("rejects malformed OpenCode JSONC without creating a backup or changing bytes", async () => {
+ const home = await createTestTempDirectory("rea-opencode-invalid-");
+ const client = getClients(home).find(({ name }) => name === "opencode");
+ expect(client).toBeDefined();
+ if (client === undefined) throw new Error("missing OpenCode client");
+ await mkdir(client.markerPath ?? home, { recursive: true });
+ const invalid = '{ "mcp": { /* broken */ "rea": [ }';
+ await writeFile(client.configPath, invalid);
+
+ expect(await configureClientConfiguration(client, {}, command)).toEqual({
+ status: "failed",
+ reason: "readback",
+ });
+ expect(await readFile(client.configPath, "utf8")).toBe(invalid);
+ });
+});
+
+describe("platform-aware client config paths", () => {
+ it.each([
+ [
+ "darwin",
+ "/home/a",
+ {},
+ "/home/a/Library/Application Support/Code/User/mcp.json",
+ ],
+ ["linux", "/home/a", {}, "/home/a/.config/Code/User/mcp.json"],
+ [
+ "win32",
+ "C:/Users/a",
+ {},
+ "C:/Users/a/AppData/Roaming/Code/User/mcp.json",
+ ],
+ [
+ "linux",
+ "/home/a",
+ { XDG_CONFIG_HOME: "/config" },
+ "/config/Code/User/mcp.json",
+ ],
+ ] as const)(
+ "resolves VS Code user MCP path for %s",
+ (platform, home, env, expected) => {
+ expect(
+ supportedClients(home, platform, env).find(
+ ({ name }) => name === "vscode",
+ )?.configPath,
+ ).toBe(expected);
+ },
+ );
+
+ it("honors Copilot CLI and Devin platform configuration roots", () => {
+ expect(
+ supportedClients("/home/a", "linux", {
+ COPILOT_HOME: "/custom/copilot",
+ XDG_CONFIG_HOME: "/custom/config",
+ }).find(({ name }) => name === "copilot_cli")?.configPath,
+ ).toBe("/custom/copilot/mcp-config.json");
+ expect(
+ supportedClients("C:/Users/a", "win32", {
+ APPDATA: "D:/Profile/Roaming",
+ }).find(({ name }) => name === "devin")?.configPath,
+ ).toBe("D:/Profile/Roaming/devin/mcp_config.json");
+ });
+
+ it("prefers an existing OpenCode JSONC file and honors explicit harness roots", async () => {
+ const home = await createTestTempDirectory("rea-config-overrides-");
+ const opencodeDirectory = join(home, ".config/opencode");
+ await mkdir(opencodeDirectory, { recursive: true });
+ await writeFile(join(opencodeDirectory, "opencode.jsonc"), "{}\n");
+ const clients = supportedClients(home, "linux", {});
+ expect(clients.find(({ name }) => name === "opencode")?.configPath).toBe(
+ join(opencodeDirectory, "opencode.jsonc"),
+ );
+ expect(
+ supportedClients(home, "linux", {
+ OPENCODE_CONFIG: "/custom/open-code.jsonc",
+ CLAUDE_CONFIG_DIR: "/custom/claude",
+ CODEX_HOME: "/custom/codex",
+ }).filter(({ name }) =>
+ ["opencode", "claude_code", "codex"].includes(name),
+ ),
+ ).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ name: "opencode",
+ configPath: "/custom/open-code.jsonc",
+ }),
+ expect.objectContaining({
+ name: "claude_code",
+ configPath: "/custom/claude/.claude.json",
+ }),
+ expect.objectContaining({
+ name: "codex",
+ configPath: "/custom/codex/config.toml",
+ }),
+ ]),
+ );
+ });
+
+ it("recognizes prior exact REA releases while rejecting foreign package commands", () => {
+ expect(
+ isOwnedClientRegistrationCommand([
+ "npx",
+ "-y",
+ `${PRODUCT_IDENTITY.packageName}@3.2.0`,
+ "mcp",
+ ]),
+ ).toBe(true);
+ expect(
+ isOwnedClientRegistrationCommand(["npx", "-y", "another-package", "mcp"]),
+ ).toBe(false);
+ expect(
+ isOwnedClientRegistrationCommand(
+ [process.execPath, "/packaged/rea.mjs", "mcp"],
+ "/packaged/rea.mjs",
+ ),
+ ).toBe(true);
+ expect(
+ isOwnedClientRegistrationCommand(
+ ["node", "/packaged/other-agent.mjs", "mcp"],
+ "/packaged/rea.mjs",
+ ),
+ ).toBe(false);
+ });
+});
diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts
index 0c32ea4cf9..199c47ec26 100644
--- a/tests/boundary/filesystem/clientLifecycle.test.ts
+++ b/tests/boundary/filesystem/clientLifecycle.test.ts
@@ -41,7 +41,11 @@ describe("client configuration filesystem lifecycle", () => {
".cursor",
".gemini",
".codeium/windsurf",
- ".devin",
+ ".config/devin",
+ ".config/opencode",
+ ".gemini/config",
+ ".copilot",
+ "Library/Application Support/Code/User",
])
await mkdir(join(home, marker), { recursive: true });
const detected = await detectClients(home);
@@ -53,13 +57,15 @@ describe("client configuration filesystem lifecycle", () => {
"gemini_cli",
"windsurf",
"devin",
+ "opencode",
+ "antigravity",
+ "copilot_cli",
+ "vscode",
]);
expect(
detected.find(({ name }) => name === "claude_code")?.configPath,
).toBe(join(home, ".claude.json"));
- expect(detected.find(({ name }) => name === "devin")?.format).toBe(
- "unsupported",
- );
+ expect(detected.find(({ name }) => name === "devin")?.format).toBe("json");
const emptyHome = await createTestTempDirectory("rea-empty-");
roots.push(emptyHome);
expect(await detectClients(emptyHome)).toEqual([]);
diff --git a/tests/boundary/filesystem/clientRegistrationStatus.test.ts b/tests/boundary/filesystem/clientRegistrationStatus.test.ts
index 0e877f381a..cb017bb7c3 100644
--- a/tests/boundary/filesystem/clientRegistrationStatus.test.ts
+++ b/tests/boundary/filesystem/clientRegistrationStatus.test.ts
@@ -9,6 +9,32 @@ import { readClientRegistrationStatuses } from "../../../src/application/ClientR
import { PRODUCT_IDENTITY } from "../../../src/identity.js";
describe("client registration status", () => {
+ it("discovers Claude Code from its config file without a marker directory", async () => {
+ const home = await createTestTempDirectory("rea-claude-config-only-");
+ await writeFile(
+ join(home, ".claude.json"),
+ JSON.stringify({
+ mcpServers: {
+ rea: {
+ command: "npx",
+ args: ["-y", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"],
+ },
+ },
+ }),
+ );
+
+ const statuses = await readClientRegistrationStatuses(home, "/current/rea");
+
+ expect(statuses).toEqual([
+ expect.objectContaining({
+ client: "claude_code",
+ config_path: join(home, ".claude.json"),
+ state: "aligned",
+ remediation: null,
+ }),
+ ]);
+ });
+
it("distinguishes aligned, stale, missing, and invalid registrations", async () => {
const home = await createTestTempDirectory("rea-registrations-");
await Promise.all([
diff --git a/tests/process-global/cliLogging.test.ts b/tests/process-global/cliLogging.test.ts
index 20c2209eb3..7bdb286692 100644
--- a/tests/process-global/cliLogging.test.ts
+++ b/tests/process-global/cliLogging.test.ts
@@ -10,6 +10,24 @@ afterEach(() => {
});
describe("CLI operation status", () => {
+ it("treats a requested setup dry run and cancellation as successful outcomes", async () => {
+ for (const status of ["planned", "cancelled"]) {
+ process.exitCode = undefined;
+ await logCliCommand(silentLogger, "setup", () =>
+ Promise.resolve({ status }),
+ );
+ expect(process.exitCode).toBeUndefined();
+ }
+ });
+
+ it("keeps unapproved setup applications unsuccessful", async () => {
+ process.exitCode = undefined;
+ await logCliCommand(silentLogger, "setup", () =>
+ Promise.resolve({ status: "needs_confirmation" }),
+ );
+ expect(process.exitCode).toBe(1);
+ });
+
it("sets a nonzero process status without replacing structured output", async () => {
const output = {
error: "Analysis failed",
diff --git a/tests/process-global/updateMaintenance.test.ts b/tests/process-global/updateMaintenance.test.ts
new file mode 100644
index 0000000000..a7c66dacd0
--- /dev/null
+++ b/tests/process-global/updateMaintenance.test.ts
@@ -0,0 +1,286 @@
+import { mkdir, readFile, rm, writeFile, access } from "node:fs/promises";
+import { dirname, join, resolve } from "node:path";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { err, ok } from "../../src/domain/result.js";
+import { PRODUCT_IDENTITY } from "../../src/identity.js";
+import { supportedClients } from "../../src/application/SupportedClients.js";
+import {
+ existingMaintenanceScope,
+ planIntegrationMaintenance,
+} from "../../src/application/UpdateMaintenance.js";
+import {
+ runUpdateCommand,
+ systemUpdateHost,
+} from "../../src/application/UpdateRuntime.js";
+import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
+
+const entryPoint = resolve("scripts/rea.mjs");
+let home: string;
+
+beforeEach(async () => {
+ home = await createTestTempDirectory("rea-update-maintenance-");
+ for (const [name, value] of Object.entries({
+ HOME: home,
+ USERPROFILE: home,
+ APPDATA: join(home, "AppData", "Roaming"),
+ CLAUDE_CONFIG_DIR: home,
+ CODEX_HOME: join(home, ".codex"),
+ COPILOT_HOME: join(home, ".copilot"),
+ XDG_CONFIG_HOME: join(home, ".config"),
+ OPENCODE_CONFIG: join(home, ".config", "opencode", "opencode.jsonc"),
+ }))
+ vi.stubEnv(name, value);
+});
+
+afterEach(async () => {
+ vi.unstubAllEnvs();
+ await rm(home, { recursive: true, force: true });
+});
+
+const writeClient = async (id: string, content: string): Promise => {
+ const client = supportedClients(home).find(({ name }) => name === id);
+ if (client === undefined) throw new Error(`Unknown test client ${id}`);
+ await mkdir(dirname(client.configPath), { recursive: true });
+ await writeFile(client.configPath, content);
+ return client.configPath;
+};
+
+const writeSkill = async (content: string): Promise => {
+ const path = join(
+ home,
+ ".agents",
+ "skills",
+ PRODUCT_IDENTITY.skillName,
+ "SKILL.md",
+ );
+ await mkdir(dirname(path), { recursive: true });
+ await writeFile(path, content);
+ return path;
+};
+
+const staleCodex =
+ '[mcp_servers.rea]\ncommand = "npx"\nargs = ["-y", "rea-agents@0.1.0", "mcp"]\n';
+const action = {
+ id: "configure_client:codex",
+ kind: "configure_client",
+ label: "Codex",
+ target: "/test/.codex/config.toml",
+ detail: "Refresh the pinned REA version",
+ external: false,
+ operation: "update",
+ backupPath: "/test/.codex/config.toml.rea.backup",
+ commands: ["rea mcp"],
+};
+
+describe("existing REA integration maintenance", () => {
+ it("selects owned registrations while preserving unconfigured, foreign, and disabled choices", async () => {
+ await writeClient("codex", staleCodex);
+ await writeClient("claude_code", '{"mcpServers":{}}');
+ await writeClient(
+ "cursor",
+ '{"mcpServers":{"rea":{"command":"foreign-server","args":[]}}}',
+ );
+ await writeClient(
+ "opencode",
+ '{"mcp":{"rea":{"type":"local","command":["npx","-y","rea-agents@0.1.0","mcp"],"enabled":false}}}',
+ );
+ await writeClient(
+ "vscode",
+ JSON.stringify({
+ servers: { rea: { type: "stdio", command: entryPoint, args: ["mcp"] } },
+ }),
+ );
+ await expect(existingMaintenanceScope(home, entryPoint)).resolves.toEqual({
+ clients: ["codex", "vscode"],
+ skill: false,
+ });
+ });
+
+ it("does not install a missing skill or overwrite an unrelated skill", async () => {
+ await expect(existingMaintenanceScope(home, entryPoint)).resolves.toEqual({
+ clients: [],
+ skill: false,
+ });
+ await writeSkill("---\nname: another-skill\n---\nUser-authored content\n");
+ await expect(existingMaintenanceScope(home, entryPoint)).resolves.toEqual({
+ clients: [],
+ skill: false,
+ });
+ });
+
+ it("includes an existing REA skill and leaves files untouched", async () => {
+ const path = await writeSkill(
+ `---\nname: ${PRODUCT_IDENTITY.skillName}\nmetadata:\n version: "1"\n---\n# REA\n`,
+ );
+ const original = await readFile(path, "utf8");
+ await expect(existingMaintenanceScope(home, entryPoint)).resolves.toEqual({
+ clients: [],
+ skill: true,
+ });
+ expect(await readFile(path, "utf8")).toBe(original);
+ });
+
+ it("skips setup entirely when there is no existing integration", async () => {
+ let invoked = false;
+ const result = await planIntegrationMaintenance(
+ home,
+ entryPoint,
+ async () => {
+ invoked = true;
+ return err("Unexpected setup");
+ },
+ );
+ expect(result).toEqual({ status: "current", plannedActions: [] });
+ expect(invoked).toBe(false);
+ });
+
+ it("returns exact backup and command evidence with a scoped unapplied setup command", async () => {
+ await writeClient("codex", staleCodex);
+ const recorded: string[][] = [];
+ const result = await planIntegrationMaintenance(
+ home,
+ entryPoint,
+ async (command) => {
+ recorded.push([...command]);
+ return ok(
+ JSON.stringify({ status: "planned", plannedActions: [action] }),
+ );
+ },
+ );
+ expect(recorded).toEqual([
+ [
+ process.execPath,
+ entryPoint,
+ "setup",
+ "--client",
+ "codex",
+ "--skill=false",
+ "--dry-run",
+ "--json",
+ ],
+ ]);
+ expect(result).toEqual({
+ status: "planned",
+ scope: { clients: ["codex"], skill: false },
+ command: recorded[0]?.slice(0, -2),
+ plannedActions: [action],
+ });
+ });
+
+ it.each([
+ "{broken",
+ JSON.stringify({ status: "ready", plannedActions: [action] }),
+ JSON.stringify({
+ status: "planned",
+ plannedActions: [{ ...action, kind: "install_hopper", external: true }],
+ }),
+ JSON.stringify({
+ status: "planned",
+ plannedActions: [{ ...action, id: "configure_client:cursor" }],
+ }),
+ ])("rejects malformed or out-of-scope subprocess plans", async (output) => {
+ await writeClient("codex", staleCodex);
+ expect(
+ await planIntegrationMaintenance(home, entryPoint, async () =>
+ ok(output),
+ ),
+ ).toMatchObject({ status: "unavailable" });
+ });
+
+ it("preserves actual setup failure diagnostics", async () => {
+ await writeClient("codex", staleCodex);
+ expect(
+ await planIntegrationMaintenance(home, entryPoint, async () =>
+ err("EACCES /test/.codex/config.toml"),
+ ),
+ ).toEqual({
+ status: "unavailable",
+ remediation: "EACCES /test/.codex/config.toml",
+ });
+ });
+
+ it("runs real setup as a noninteractive dry run and creates no new integrations or backups", async () => {
+ const configPath = await writeClient("codex", staleCodex);
+ const untouched = await writeClient("claude_code", '{"mcpServers":{}}');
+ vi.stubEnv("npm_command", "exec");
+ const result = await planIntegrationMaintenance(
+ home,
+ entryPoint,
+ runUpdateCommand,
+ );
+ expect(result).toMatchObject({
+ status: "planned",
+ scope: { clients: ["codex"], skill: false },
+ plannedActions: [
+ { id: "configure_client:codex", kind: "configure_client" },
+ ],
+ });
+ expect(await readFile(configPath, "utf8")).toBe(staleCodex);
+ expect(await readFile(untouched, "utf8")).toBe('{"mcpServers":{}}');
+ await expect(access(`${configPath}.rea.backup`)).rejects.toMatchObject({
+ code: "ENOENT",
+ });
+ await expect(
+ access(join(home, ".agents", "skills", PRODUCT_IDENTITY.skillName)),
+ ).rejects.toMatchObject({ code: "ENOENT" });
+ });
+});
+
+describe("update subprocess boundaries", () => {
+ it("exposes update as the sole CLI command", async () => {
+ const help = await runUpdateCommand([
+ process.execPath,
+ entryPoint,
+ "--help",
+ ]);
+ expect(help.ok).toBe(true);
+ if (help.ok) {
+ expect(help.value).toMatch(/^\s{2}update\s/mu);
+ expect(help.value).not.toMatch(/^\s{2}upgrade\s/mu);
+ }
+ const removed = await runUpdateCommand([
+ process.execPath,
+ entryPoint,
+ "upgrade",
+ "--json",
+ ]);
+ expect(removed.ok).toBe(false);
+ });
+ it("captures output through stream closure and keeps arguments literal", async () => {
+ const literal = 'space and $(echo bad); & | "quotes"';
+ const result = await runUpdateCommand([
+ process.execPath,
+ "-e",
+ 'process.stdout.write("x".repeat(1024 * 1024) + process.argv[1])',
+ literal,
+ ]);
+ expect(result).toEqual(ok(`${"x".repeat(1024 * 1024)}${literal}`));
+ });
+
+ it("reports actual stderr and launch failures", async () => {
+ await expect(
+ runUpdateCommand([
+ process.execPath,
+ "-e",
+ 'process.stderr.write("EACCES /test/install"); process.exitCode = 2',
+ ]),
+ ).resolves.toEqual(err("EACCES /test/install"));
+ const missing = await runUpdateCommand([join(home, "missing-executable")]);
+ expect(missing).toMatchObject({ ok: false });
+ if (!missing.ok) expect(missing.error).toContain("ENOENT");
+ });
+
+ it("verifies the requested package entry rather than PATH's rea", async () => {
+ const packageRoot = join(home, "package");
+ const script = join(packageRoot, "scripts", "rea.mjs");
+ await mkdir(dirname(script), { recursive: true });
+ await writeFile(script, 'process.stdout.write("3.3.0\\n")');
+ await expect(
+ systemUpdateHost(packageRoot, home).installedVersion({
+ prefix: home,
+ packageRoot,
+ }),
+ ).resolves.toEqual(ok("3.3.0"));
+ });
+});