Files
galaxy/specs/APP-3690/TECH.md
T

250 lines
15 KiB
Markdown

# Tech Spec: OpenCode Plugin Install & Update Flow
See `specs/APP-3690/PRODUCT.md` for the product spec.
## 1. Problem
`plugin_manager_for(CLIAgent::OpenCode)` returns `None` (`plugin_manager/mod.rs:90`), so OpenCode sessions never show the install/update chip. We need an `OpenCodePluginManager` that implements the `CliAgentPluginManager` trait and wires into the existing footer/modal infrastructure.
Unlike Claude Code, OpenCode has no CLI for plugin management, so this implementation is manual-only (instructions modal, no auto-install). This requires a small trait refactor to make auto-operations optional and to generalize the per-agent minimum version.
## 2. Relevant Code
- `plugin_manager/mod.rs` — trait definition, `plugin_manager_for()` factory, `PluginInstructions` types
- `plugin_manager/claude.rs` — reference implementation; owns `compare_versions` and `MINIMUM_PLUGIN_VERSION`
- `agent_input_footer/mod.rs (677-744)``plugin_chip_kind()` determines which chip to show
- `agent_input_footer/mod.rs (748-758)``should_use_manual_mode()` determines auto vs modal
- `agent_input_footer/mod.rs (760-849)``handle_plugin_operation()` shared async handler (unused by OpenCode)
- `agent_input_footer/mod.rs (978-999)` — render logic matching `(chip_kind, manual)` to buttons
- `workspace/view/plugin_install_modal.rs` — generic instructions modal (already agent-agnostic, takes `&'static PluginInstructions`)
- `cli_agent_sessions/mod.rs (86-104)``CLIAgentSession` struct with `plugin_version`, `listener`
- `terminal/view.rs:10732-10771``register_cli_agent_listener()` uses `MINIMUM_PLUGIN_VERSION` from `claude.rs`
- `settings/ai.rs:1093-1105``plugin_chip_dismissed_for_version` setting
## 3. Current State
The plugin manager system works well for Claude Code:
- The trait has six required methods, all implemented by `ClaudeCodePluginManager`.
- The footer routes between auto-install and manual-modal based on `should_use_manual_mode()`.
- The modal is already generic (takes `&'static PluginInstructions`, not a `CLIAgent`).
Three things need generalizing:
1. **`compare_versions` and `MINIMUM_PLUGIN_VERSION`** live in `claude.rs` but are imported by `agent_input_footer/mod.rs:65` and `terminal/view.rs:134`. With two agents needing different minimum versions, these should come from the trait.
2. **All trait methods are required.** OpenCode doesn't need `install()`, `update()`, `is_installed()`, or `needs_update()`. These should have sensible defaults.
3. **Install chip flicker.** Claude avoids flicker by checking `is_installed()` on the filesystem. OpenCode has no filesystem checks, so the install chip would flash on session start before the plugin connects. A debounce is needed.
## 4. Proposed Changes
### 4a. Trait Refactor (`plugin_manager/mod.rs`)
**Move `compare_versions` from `claude.rs` to `mod.rs`.** It's a generic semver utility, not Claude-specific. Same signature, just a new home.
**Add two new required methods to the trait:**
```rust
fn minimum_plugin_version(&self) -> &'static str;
fn can_auto_install(&self) -> bool;
```
`minimum_plugin_version()` replaces the hardcoded `MINIMUM_PLUGIN_VERSION` constant at all call sites. Each agent returns its own constant.
`can_auto_install()` tells the footer whether this agent can auto-install/update. Claude returns `true`, OpenCode returns `false`.
**Add default implementations for the four auto-operation methods:**
```rust
fn is_installed(&self) -> bool { false }
fn needs_update(&self) -> bool { false }
async fn install(&self) -> Result<(), PluginInstallError> {
Err(PluginInstallError {
message: "Auto-install not supported for this agent".to_owned(),
log: String::new(),
})
}
async fn update(&self) -> Result<(), PluginInstallError> {
Err(PluginInstallError {
message: "Auto-update not supported for this agent".to_owned(),
log: String::new(),
})
}
```
Claude overrides all four. OpenCode uses the defaults. The defaults return `Err` — the caller (`handle_plugin_operation`) already logs errors from failed operations. These should never be reached since `should_use_manual_mode()` returns `true` for agents where `can_auto_install()` is `false`.
### 4b. OpenCode Plugin Manager (`plugin_manager/opencode.rs`)
New file. Minimal implementation — just the two required methods plus instructions:
- `can_auto_install()``false`
- `minimum_plugin_version()``"0.1.0"` (keep in sync with `opencode-warp` npm package version)
- `install_instructions()` → static `PluginInstructions` with title "Install Warp Plugin for OpenCode", steps to add `"opencode-warp"` to the `plugin` array in `opencode.json` + restart
- `update_instructions()` → static `PluginInstructions` with title "Update Warp Plugin for OpenCode", steps to `rm -rf ~/.cache/opencode/node_modules/opencode-warp` + restart
All auto-operation methods (`is_installed`, `needs_update`, `install`, `update`) use the trait defaults.
### 4c. Factory Registration (`plugin_manager/mod.rs`)
```rust
CLIAgent::OpenCode => Some(Box::new(opencode::OpenCodePluginManager)),
```
### 4d. Footer: Generalize `MINIMUM_PLUGIN_VERSION` (`agent_input_footer/mod.rs`)
Replace the import of `claude::{compare_versions, MINIMUM_PLUGIN_VERSION}` with:
- `use super::compare_versions` (from `mod.rs`)
- All references to `MINIMUM_PLUGIN_VERSION` become `manager.minimum_plugin_version()`
This affects three places in `plugin_chip_kind()`:
- `agent_input_footer/mod.rs:704` — version comparison for connected plugin
- `agent_input_footer/mod.rs:712` — dismissed version comparison
- `agent_input_footer/mod.rs:732` — dismissed version comparison (filesystem fallback path)
And one place in the dismiss handler:
- `agent_input_footer/mod.rs:1583` — storing the dismissed version
For the dismiss handler, the manager needs to be resolved to get its `minimum_plugin_version()`. Since `plugin_chip_kind()` already resolves the manager, and the dismiss handler knows whether it's dismissing an update chip, this is straightforward — resolve the manager from the session's agent.
### 4e. Footer: `should_use_manual_mode()` (`agent_input_footer/mod.rs:748`)
Add a check for `can_auto_install()` before the existing conditions:
```rust
fn should_use_manual_mode(&self, app: &AppContext) -> bool {
let sessions_model = CLIAgentSessionsModel::as_ref(app);
let session = match sessions_model.session(self.terminal_view_id) {
Some(s) => s,
None => return false,
};
if let Some(manager) = plugin_manager_for(session.agent) {
if !manager.can_auto_install() {
return true;
}
}
if session.is_remote() {
return true;
}
sessions_model.has_plugin_auto_failed(session.agent, &session.remote_host)
}
```
For OpenCode, this always returns `true`, so the footer always opens the modal instead of attempting auto-install. The `handle_plugin_operation` / `handle_install_plugin` / `handle_update_plugin` code paths are never reached.
### 4e-ii. Footer: Chip Button Labels
The install chip buttons are created at construction time with hardcoded labels:
- `install_plugin_button`: "Enable Claude Code notifications" — Claude-specific
- `plugin_instructions_button`: "Notifications setup instructions"
- `update_plugin_button`: "Update Warp plugin"
- `update_instructions_button`: "Plugin update instructions"
The auto-install button label hardcodes "Claude Code". Make it dynamic using the agent's `display_name()`: format as `"Enable {name} notifications"` where `name` comes from `session.agent.display_name()` (e.g., "Enable Claude Code notifications", "Enable OpenCode notifications"). Since the button is created once at construction, update the label via `set_label` in the render path (or when the session changes), the same way the compose button label is already updated dynamically (`agent_input_footer/mod.rs:374`).
### 4f. `terminal/view.rs`: Generalize `register_cli_agent_listener`
`terminal/view.rs:10753` hardcodes `MINIMUM_PLUGIN_VERSION` from `claude.rs`. Replace with:
```rust
let plugin_version = plugin_manager_for(agent)
.map(|m| m.minimum_plugin_version().to_owned());
```
This returns the correct minimum version for whichever agent is active, and `None` if the agent has no plugin manager (which preserves the existing wasm fallback behavior).
### 4g. Install Chip Debounce (`agent_input_footer/mod.rs`)
**Problem:** For agents without filesystem checks (OpenCode), `plugin_chip_kind()` would show the install chip immediately on session start, before the plugin has time to connect and send `SessionStart`.
**Solution:** Add `plugin_chip_ready: bool` to `AgentInputFooter`. It starts `false` and is set to `true` after a debounce timer fires. When a `Started` event fires for a non-auto-install agent, spawn a one-shot timer via `ctx.spawn(Timer::after(PLUGIN_CHIP_DEBOUNCE), ...)`. When the timer fires, set `plugin_chip_ready = true` and call `ctx.notify()` to trigger a re-render. In the timer callback, check whether a listener has connected in the meantime — if so, skip setting the flag.
In `plugin_chip_kind()`, the "no listener" branch checks:
```rust
if !manager.can_auto_install() && !self.plugin_chip_ready {
return None;
}
```
Reset `plugin_chip_ready = false` when a session ends or when a listener connects.
For Claude (which has `can_auto_install() == true`), this check is skipped — Claude uses `is_installed()` instead.
`PLUGIN_CHIP_DEBOUNCE` is a constant in `agent_input_footer/mod.rs` (`Duration::from_secs(3)`).
### 4h. Claude Module Cleanup (`plugin_manager/claude.rs`)
- Remove `compare_versions` (moved to `mod.rs`)
- Add `minimum_plugin_version()` returning `MINIMUM_PLUGIN_VERSION`
- Add `can_auto_install()` returning `true`
- Keep `MINIMUM_PLUGIN_VERSION` as a module-level constant (still useful for the `needs_update()` and `update()` implementations within this module)
## 5. End-to-End Flow
### Install
1. User starts OpenCode in Warp. Command detection creates a `CLIAgentSession`. The footer's `Started` subscription fires, spawning a debounce timer (`plugin_chip_ready` starts `false`).
2. Footer renders. `plugin_chip_kind()` finds `plugin_manager_for(OpenCode)` = `Some`. No listener. `can_auto_install()` is `false`. `plugin_chip_ready` is `false` → returns `None`. No chip.
3. If plugin is installed: `SessionStart` arrives within ~1s, listener is created, `plugin_version` is set, `plugin_chip_ready` reset to `false`. Footer re-renders. Chip never appears.
4. If plugin is not installed: timer fires after 3s, sets `plugin_chip_ready = true`, calls `ctx.notify()`. `plugin_chip_kind()` returns `Install`. `should_use_manual_mode()` returns `true`. Footer shows the instructions chip.
5. User clicks → modal opens with install steps (add to `opencode.json`, restart).
6. User follows steps, restarts OpenCode. Plugin connects, sends `SessionStart`. Chip disappears.
### Update
1. We bump `MINIMUM_PLUGIN_VERSION` in `opencode.rs` to `"0.2.0"`.
2. Plugin connects with `plugin_version: "0.1.0"`.
3. `plugin_chip_kind()`: listener present, `compare_versions("0.1.0", "0.2.0")` is `Less``Update`.
4. `should_use_manual_mode()` returns `true`. Footer shows update instructions chip.
5. User clicks → modal shows cache-clear + restart steps.
6. User follows steps. On restart, Bun re-resolves `opencode-warp` from npm (cache was cleared), installs latest. Plugin connects with `"0.2.0"`. Chip disappears.
## 6. Risks and Mitigations
**Risk: `PluginInstructionStep.command` contains JSON, not a shell command.**
**Risk: `PluginInstructionStep.command` contains JSON, not a shell command.** The install modal's copy button copies the `command` field to clipboard. For OpenCode install, this will be a JSON snippet. **Mitigation:** Already fine — the modal copies any string. A JSON snippet is useful to copy even if it's not a terminal command.
**Risk: Stale `MINIMUM_PLUGIN_VERSION`.** The minimum version is compiled into the Warp binary. **Mitigation:** Same as Claude — by design. We only prompt updates when Warp needs new plugin behavior.
## 7. Testing and Validation
### Unit tests (`plugin_manager/opencode_tests.rs`)
- `opencode_manager_can_auto_install_is_false`
- `opencode_manager_returns_install_instructions` — non-empty steps
- `opencode_manager_returns_update_instructions` — non-empty steps
- `opencode_manager_minimum_version` — returns expected value
### Unit tests (`plugin_manager/mod_tests.rs`)
- Update `returns_none_for_unsupported_agents` — remove `CLIAgent::OpenCode` from assertion
- Add `returns_manager_for_opencode`
- Move `compare_versions` tests here from `claude_tests.rs`
### Unit tests (`plugin_manager/claude_tests.rs`)
- Remove `compare_versions` tests (moved)
- Add `claude_manager_can_auto_install_is_true`
- Add `claude_manager_minimum_version`
### Manual testing
- Start an OpenCode session → install chip appears after ~3s debounce (not immediately)
- If plugin is installed: chip never appears (plugin connects before debounce)
- Click install chip → modal opens with correct OpenCode-specific instructions
- Follow install steps, restart → chip disappears
- Bump minimum version locally → update chip appears
- Click update chip → modal shows cache-clear instructions
- Dismiss install chip → stays hidden
- Dismiss update chip → stays hidden; bump minimum → reappears
- Claude flow unchanged — verify no regressions
## 8. Follow-Ups
- **Auto-install:** If OpenCode adds a plugin management CLI, implement `install()` and `update()` on `OpenCodePluginManager` and flip `can_auto_install()` to `true`.
- **Publish `opencode-warp` to npm:** Must happen before this feature ships.
## 9. Files Changed
- **New:** `plugin_manager/opencode.rs``OpenCodePluginManager`, `MINIMUM_PLUGIN_VERSION`, install/update instructions
- **New:** `plugin_manager/opencode_tests.rs`
- **Modified:** `plugin_manager/mod.rs``compare_versions` moved here, `minimum_plugin_version()` + `can_auto_install()` added to trait, default impls for `is_installed`/`needs_update`/`install`/`update`, factory wires OpenCode
- **Modified:** `plugin_manager/claude.rs` — remove `compare_versions` (moved), add `minimum_plugin_version()` + `can_auto_install()` overrides
- **Modified:** `plugin_manager/claude_tests.rs` — remove `compare_versions` tests (moved), add new trait method tests
- **Modified:** `plugin_manager/mod_tests.rs` — add OpenCode factory test, receive `compare_versions` tests
- **Modified:** `agent_input_footer/mod.rs` — add `plugin_chip_ready: bool` + debounce timer, update imports (`compare_versions` from `mod.rs`), `plugin_chip_kind()` uses `manager.minimum_plugin_version()` + `plugin_chip_ready` guard, `should_use_manual_mode()` checks `can_auto_install()`, dismiss handler resolves minimum version from manager, rename auto-install chip label to be agent-generic
- **Modified:** `terminal/view.rs``register_cli_agent_listener()` uses `plugin_manager_for(agent)?.minimum_plugin_version()` instead of Claude's constant