From e5f365e814e93ef26cea3faa0853adbb891ea366 Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Sun, 15 Feb 2026 23:43:01 +1100 Subject: [PATCH 1/5] chore: untrack docs/plans from git history Plans are internal development documents and should not be committed to the repository. Already covered by .gitignore. --- .../2026-01-23-cursor-acp-plugin-design.md | 145 -- ...-01-23-cursor-acp-plugin-implementation.md | 1715 ---------------- ...026-01-23-hybrid-acp-implementation-ans.md | 1745 ----------------- ...-01-23-hybrid-acp-implementation-design.md | 662 ------- .../plans/2026-01-28-opencode-cursor-fixes.md | 1062 ---------- ...6-01-29-competitive-improvements-design.md | 546 ------ ...1-29-implement-competitive-improvements.md | 1410 ------------- docs/plans/2026-02-02-auth-hook-design.md | 130 -- docs/plans/2026-02-07-streaming-fix-plan.md | 268 --- .../2026-02-07-windows-support-design.md | 196 -- ...9-critical-fixes-proxy-security-routing.md | 594 ------ 11 files changed, 8473 deletions(-) delete mode 100644 docs/plans/2026-01-23-cursor-acp-plugin-design.md delete mode 100644 docs/plans/2026-01-23-cursor-acp-plugin-implementation.md delete mode 100644 docs/plans/2026-01-23-hybrid-acp-implementation-ans.md delete mode 100644 docs/plans/2026-01-23-hybrid-acp-implementation-design.md delete mode 100644 docs/plans/2026-01-28-opencode-cursor-fixes.md delete mode 100644 docs/plans/2026-01-29-competitive-improvements-design.md delete mode 100644 docs/plans/2026-01-29-implement-competitive-improvements.md delete mode 100644 docs/plans/2026-02-02-auth-hook-design.md delete mode 100644 docs/plans/2026-02-07-streaming-fix-plan.md delete mode 100644 docs/plans/2026-02-07-windows-support-design.md delete mode 100644 docs/plans/2026-02-09-critical-fixes-proxy-security-routing.md diff --git a/docs/plans/2026-01-23-cursor-acp-plugin-design.md b/docs/plans/2026-01-23-cursor-acp-plugin-design.md deleted file mode 100644 index 21a43f1..0000000 --- a/docs/plans/2026-01-23-cursor-acp-plugin-design.md +++ /dev/null @@ -1,145 +0,0 @@ -# OpenCode-Cursor Plugin Design - -## Overview - -Two deliverables: -1. **Plugin fixes** - Fix bugs in existing `src/index.ts` -2. **Go TUI Installer** - Bubbletea installer with beams ASCII animation (ported from jellywatch) - -## Plugin Fixes (10 total) - -### Bug 1: Streaming logic (`src/index.ts:132-144`) -Final chunk with `finish_reason: "stop"` sent inside loop - move outside. - -### Bug 2: Message formatting (`src/index.ts:58-62`) -Naive `role: content` concatenation - use proper conversation format with delimiters. - -### Bug 3: Error handling -Mixed patterns - unify with consistent `throw new Error()` and context. - -### Bug 4: Tool execution stub (`src/index.ts:200-210`) -Echoes input args - remove or implement proper pass-through. - -### Bug 5: Double command name (`src/index.ts:65-76`) -`spawn("cursor-agent", ["cursor-agent", ...])` duplicates command - remove from args. - -### Bug 6: Chunk boundary JSON parsing -Splits on `\n` without buffering - add line buffer for partial chunks. - -### Bug 7: No timeout -Hangs forever if cursor-agent blocks - add configurable timeout. - -### Bug 8: Orphaned child process -No cleanup on cancel - add signal handlers and `child.kill()`. - -### Bug 9: Unused `chat.message` hook -Just passes through - remove entirely. - -### Bug 10: Export shape -Verify OpenCode expects named export `cursorACP` vs default export. - -## Installer Architecture - -### Directory Structure -``` -cmd/installer/ -├── main.go # Bubbletea setup, model, Init/Update -├── view.go # Render functions for each screen -├── animations.go # BeamsTextEffect (ported from jellywatch) -├── tasks.go # Install task execution -├── theme.go # Colors, styles, ASCII header -``` - -### Source: Jellywatch Installer -Port directly from `/home/nomadx/Documents/jellywatch/cmd/installer/`: -- `animations.go` - BeamsTextEffect + TypewriterTicker (use as-is, ~650 lines) -- `theme.go` - Monochrome color scheme (adapt header only) -- `main.go` - Bubbletea model pattern (simplify screens) -- `view.go` - Render pattern (simplify to 3 screens) - -### ASCII Header -From `/home/nomadx/bit/CURSOR.txt`: -``` -▄███████▄ ████████▄ █████████ ███▄ ██ ▄██████▄ ██ ██ ████████▄ ▄███████ ▄███████▄ ████████▄ -██ ██ ██ ██ ██ ██▀██▄ ██ ██▀ ▀▀ ██ ██ ██ ██ ██ ██ ██ ██ ██ -██ ██ ████████▀ ███████ ██ ██▄ ██ ████████ ██ ██ ██ ████████▀ ▀███████▄ ██ ██ ████████▀ -██ ██ ██ ██ ██ ▀█▄██ ██▄ ▄▄ ██ ██ ██ ▀██▄ ██ ██ ██ ██ ▀██▄ -▀███████▀ ██ █████████ ██ ▀███ ▀██████▀ ▀███████▀ ██ ▀███ ███████▀ ▀███████▀ ██ ▀███ -``` - -### Screens (3 total) - -| Screen | Purpose | -|--------|---------| -| `welcome` | Animated header, "Install" option, Enter to start | -| `installing` | Task list with spinner/checkmarks | -| `complete` | Success + usage instructions, or error details | - -### Pre-install Checks - -| Check | Action if fails | -|-------|-----------------| -| `bun` available | Error: "Install bun: curl -fsSL https://bun.sh/install \| bash" | -| `cursor-agent` installed | Error: "Install cursor-agent: curl -fsS https://cursor.com/install \| bash" | -| `cursor-agent` logged in | Warning: "Run `cursor-agent login` after install" (non-blocking) | -| OpenCode config dir exists | Create `~/.config/opencode/` if missing | -| `opencode.json` exists | Create minimal valid JSON if missing | -| Existing `cursor-acp` provider | Prompt: "Already configured. Reinstall?" | - -### Install Tasks (4 total) - -1. **Check prerequisites** - Verify cursor-agent, bun available -2. **Build plugin** - `bun install && bun run build` -3. **Create symlink** - `~/.config/opencode/plugin/cursor-acp.js` → `dist/index.js` -4. **Update opencode.json** - Append cursor-acp provider, validate JSON - -### Post-install Validations - -| Validation | How | -|------------|-----| -| Build succeeded | Check `dist/index.js` exists and non-empty | -| Symlink valid | `os.Stat` resolves symlink target | -| JSON syntax valid | `json.Unmarshal` modified config | -| Provider registered | Parse JSON, verify `cursor-acp` in `provider` | -| Plugin loadable | `node -e "require('./dist/index.js')"` | -| cursor-agent responds | `cursor-agent --version` exit code 0 | - -## Entry Point - -**`install.sh`** (project root): -```bash -#!/bin/bash -set -e -echo "Building installer..." -cd "$(dirname "$0")" -go build -o /tmp/opencode-cursor-installer ./cmd/installer -/tmp/opencode-cursor-installer "$@" -``` - -## Key Paths - -| Path | Purpose | -|------|---------| -| Project root | `/home/nomadx/opencode-cursor` | -| Built plugin | `dist/index.js` | -| Symlink target | `~/.config/opencode/plugin/cursor-acp.js` | -| OpenCode config | `~/.config/opencode/opencode.json` | - -## Dependencies - -```go -require ( - github.com/charmbracelet/bubbletea - github.com/charmbracelet/lipgloss - github.com/charmbracelet/bubbles/spinner -) -``` - -## Estimates - -| Component | Files | Lines | -|-----------|-------|-------| -| Plugin fixes | `src/index.ts` | ~200 | -| Installer | `cmd/installer/*.go` | ~1100 | -| Entry script | `install.sh` | ~15 | -| **Total** | 7 files | ~1300 lines | diff --git a/docs/plans/2026-01-23-cursor-acp-plugin-implementation.md b/docs/plans/2026-01-23-cursor-acp-plugin-implementation.md deleted file mode 100644 index 6daebd0..0000000 --- a/docs/plans/2026-01-23-cursor-acp-plugin-implementation.md +++ /dev/null @@ -1,1715 +0,0 @@ -# [OpenCode-Cursor Plugin] Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Fix bugs in the existing TypeScript plugin and create a Go TUI installer with beams ASCII animation (ported from jellywatch). - -**Architecture:** Two components - (1) TypeScript plugin fixes for streaming, message formatting, and error handling, (2) Go Bubbletea installer with 3 screens (welcome, installing, complete), beams animation, and install tasks for building, symlinking, and config updates. - -**Tech Stack:** TypeScript/Bun (plugin), Go/Bubbletea/Lipgloss (installer) - ---- - -## Task 1: Fix Double Command Name Bug - -**Files:** -- Modify: `src/index.ts:65-76` - -**Step 1: Read current spawn code** - -Current broken code: -```typescript -const args = [ - "cursor-agent", // BUG: command name in args - "--print", - "--output-format", - stream ? "json-stream" : "json", - "--model", - model, - "--workspace", - process.cwd() -]; - -const child = spawn("cursor-agent", args, { // AND in spawn -``` - -**Step 2: Fix by removing command from args** - -```typescript -const args = [ - "--print", - "--output-format", - stream ? "json-stream" : "json", - "--model", - model, - "--workspace", - process.cwd() -]; - -const child = spawn("cursor-agent", args, { -``` - -**Step 3: Commit** - -```bash -git add src/index.ts -git commit -m "remove duplicate cursor-agent from spawn args" -``` - ---- - -## Task 2: Fix Streaming Final Chunk Bug - -**Files:** -- Modify: `src/index.ts:92-145` - -**Step 1: Identify the bug** - -Current code sends final chunk INSIDE the loop (line 132-144). It should be AFTER the loop completes. - -**Step 2: Restructure streaming logic** - -Replace lines 92-149 with: - -```typescript -// Handle streaming responses -if (stream) { - const encoder = new TextEncoder(); - const id = `cursor-${Date.now()}`; - const created = Math.floor(Date.now() / 1000); - let buffer = ""; - - for await (const chunk of child.stdout) { - const text = new TextDecoder().decode(chunk); - buffer += text; - - // Process complete lines only - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; // Keep incomplete line in buffer - - for (const line of lines) { - if (!line.trim() || !line.startsWith("data: ")) continue; - - try { - const data = JSON.parse(line.slice(6)); - const delta = data.choices?.[0]?.delta?.content; - if (delta) { - await output.write({ - id, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: { content: delta }, - finish_reason: null - }] - }); - } - } catch { - // Ignore parse errors for malformed chunks - } - } - } - - // Send final chunk AFTER loop completes - await output.write({ - id, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: {}, - finish_reason: "stop" - }] - }); -} -``` - -**Step 3: Commit** - -```bash -git add src/index.ts -git commit -m "move final stream chunk outside loop, add line buffer" -``` - ---- - -## Task 3: Fix Message Formatting - -**Files:** -- Modify: `src/index.ts:58-62` - -**Step 1: Replace naive concatenation** - -Current: -```typescript -const prompt = messages - .map(m => `${m.role}: ${m.content}`) - .join("\n\n"); -``` - -**Step 2: Use proper conversation format** - -```typescript -// Format messages with clear delimiters for cursor-agent -const prompt = messages - .map(m => { - const role = m.role === "assistant" ? "assistant" : m.role === "system" ? "system" : "user"; - return `<|${role}|>\n${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}\n<|end|>`; - }) - .join("\n"); -``` - -**Step 3: Commit** - -```bash -git add src/index.ts -git commit -m "use delimited message format for cursor-agent" -``` - ---- - -## Task 4: Fix Error Handling - -**Files:** -- Modify: `src/index.ts:147-165` - -**Step 1: Move stderr listener before stream processing** - -**Step 2: Replace error handling section** - -Add before the streaming logic (around line 80): - -```typescript -let stderr = ""; -child.stderr.on("data", (data) => { - stderr += data.toString(); -}); - -child.on("error", (err) => { - throw new Error(`Failed to spawn cursor-agent: ${err.message}`); -}); -``` - -**Step 3: Update exit handling** - -Replace lines 159-165 with: - -```typescript -const exitCode = await new Promise((resolve, reject) => { - child.on("close", resolve); - child.on("error", reject); -}); - -if (exitCode !== 0) { - throw new Error(`cursor-agent exited with code ${exitCode}${stderr ? `: ${stderr}` : ""}`); -} -``` - -**Step 4: Commit** - -```bash -git add src/index.ts -git commit -m "fix: unify error handling, attach stderr listener early" -``` - ---- - -## Task 5: Add Timeout and Cleanup - -**Files:** -- Modify: `src/index.ts` - -**Step 1: Add timeout constant at top of file** - -```typescript -const CURSOR_AGENT_TIMEOUT = 5 * 60 * 1000; // 5 minutes -``` - -**Step 2: Wrap spawn in timeout and cleanup logic** - -Add after spawn: - -```typescript -// Timeout handling -const timeoutId = setTimeout(() => { - child.kill("SIGTERM"); -}, CURSOR_AGENT_TIMEOUT); - -// Cleanup on process exit -const cleanup = () => { - clearTimeout(timeoutId); - if (!child.killed) { - child.kill("SIGTERM"); - } -}; -process.on("SIGINT", cleanup); -process.on("SIGTERM", cleanup); -``` - -**Step 3: Clear timeout after completion** - -Add before return: - -```typescript -clearTimeout(timeoutId); -process.removeListener("SIGINT", cleanup); -process.removeListener("SIGTERM", cleanup); -``` - -**Step 4: Commit** - -```bash -git add src/index.ts -git commit -m "fix: add 5min timeout and cleanup for orphaned processes" -``` - ---- - -## Task 6: Remove Unused Hooks - -**Files:** -- Modify: `src/index.ts:195-210` - -**Step 1: Remove chat.message hook** - -Delete lines 195-198: -```typescript -async "chat.message"(input, output) { - await output.write(input); -}, -``` - -**Step 2: Remove tool.execute hook** - -Delete lines 200-210 (the entire tool.execute block) - cursor-agent handles tools internally. - -**Step 3: Commit** - -```bash -git add src/index.ts -git commit -m "fix: remove unused chat.message and tool.execute hooks" -``` - ---- - -## Task 7: Initialize Go Module for Installer - -**Files:** -- Create: `cmd/installer/` -- Create: `go.mod` -- Create: `go.sum` - -**Step 1: Create directory structure** - -```bash -mkdir -p cmd/installer -``` - -**Step 2: Initialize Go module** - -```bash -cd /home/nomadx/opencode-cursor -go mod init github.com/nomadcxx/opencode-cursor -``` - -**Step 3: Add dependencies** - -```bash -go get github.com/charmbracelet/bubbletea -go get github.com/charmbracelet/lipgloss -go get github.com/charmbracelet/bubbles/spinner -``` - -**Step 4: Commit** - -```bash -git add go.mod go.sum cmd/ -git commit -m "feat: initialize Go module for installer" -``` - ---- - -## Task 8: Create Theme (Port from Jellywatch) - -**Files:** -- Create: `cmd/installer/theme.go` - -**Step 1: Create theme.go** - -```go -// cmd/installer/theme.go -package main - -import "github.com/charmbracelet/lipgloss" - -// Theme colors - Monochrome (same as jellywatch) -var ( - BgBase = lipgloss.Color("#1a1a1a") - BgElevated = lipgloss.Color("#2a2a2a") - Primary = lipgloss.Color("#ffffff") - Secondary = lipgloss.Color("#cccccc") - Accent = lipgloss.Color("#ffffff") - FgPrimary = lipgloss.Color("#ffffff") - FgSecondary = lipgloss.Color("#cccccc") - FgMuted = lipgloss.Color("#666666") - ErrorColor = lipgloss.Color("#ff6b6b") - WarningColor = lipgloss.Color("#888888") - SuccessColor = lipgloss.Color("#ffffff") -) - -// Styles -var ( - checkMark = lipgloss.NewStyle().Foreground(SuccessColor).SetString("[OK]") - failMark = lipgloss.NewStyle().Foreground(ErrorColor).SetString("[FAIL]") - skipMark = lipgloss.NewStyle().Foreground(WarningColor).SetString("[SKIP]") - headerStyle = lipgloss.NewStyle().Foreground(Primary).Bold(true) -) - -// ASCII header from /home/nomadx/bit/CURSOR.txt -const asciiHeader = `▄███████▄ ████████▄ █████████ ███▄ ██ ▄██████▄ ██ ██ ████████▄ ▄███████ ▄███████▄ ████████▄ -██ ██ ██ ██ ██ ██▀██▄ ██ ██▀ ▀▀ ██ ██ ██ ██ ██ ██ ██ ██ ██ -██ ██ ████████▀ ███████ ██ ██▄ ██ ████████ ██ ██ ██ ████████▀ ▀███████▄ ██ ██ ████████▀ -██ ██ ██ ██ ██ ▀█▄██ ██▄ ▄▄ ██ ██ ██ ▀██▄ ██ ██ ██ ██ ▀██▄ -▀███████▀ ██ █████████ ██ ▀███ ▀██████▀ ▀███████▀ ██ ▀███ ███████▀ ▀███████▀ ██ ▀███` -``` - -**Step 2: Commit** - -```bash -git add cmd/installer/theme.go -git commit -m "feat: add installer theme with OPEN-CURSOR ASCII header" -``` - ---- - -## Task 9: Port Beams Animation from Jellywatch - -**Files:** -- Create: `cmd/installer/animations.go` -- Source: `/home/nomadx/Documents/jellywatch/cmd/installer/animations.go` - -**Step 1: Copy animations.go from jellywatch** - -```bash -cp /home/nomadx/Documents/jellywatch/cmd/installer/animations.go /home/nomadx/opencode-cursor/cmd/installer/animations.go -``` - -**Step 2: Remove jellywatch-specific roasts from TypewriterTicker** - -Edit `animations.go` to replace `jellyWatchRoasts` reference with installer messages: - -```go -// Replace the NewTypewriterTicker function's roasts initialization: -func NewTypewriterTicker() *TypewriterTicker { - roasts := []string{ - "Installing OpenCode-Cursor plugin...", - "Bypassing E2BIG errors since 2026", - "stdin/stdout > CLI args", - "Cursor Agent integration made simple", - } - rand.Shuffle(len(roasts), func(i, j int) { - roasts[i], roasts[j] = roasts[j], roasts[i] - }) - // ... rest of function unchanged -``` - -**Step 3: Commit** - -```bash -git add cmd/installer/animations.go -git commit -m "feat: port beams animation from jellywatch" -``` - ---- - -## Task 10: Create Types - -**Files:** -- Create: `cmd/installer/types.go` - -**Step 1: Create simplified types.go** - -```go -// cmd/installer/types.go -package main - -import ( - "context" - "os" - "time" - - "github.com/charmbracelet/bubbles/spinner" -) - -// Installation steps -type installStep int - -const ( - stepWelcome installStep = iota - stepInstalling - stepComplete -) - -// Task status -type taskStatus int - -const ( - statusPending taskStatus = iota - statusRunning - statusComplete - statusFailed - statusSkipped -) - -// Installation task -type installTask struct { - name string - description string - execute func(*model) error - optional bool - status taskStatus - errorDetails *errorInfo -} - -type errorInfo struct { - message string - command string - logFile string -} - -// Pre-install check result -type checkResult struct { - name string - passed bool - message string - warning bool // true = non-blocking warning, false = blocking error -} - -// Main model -type model struct { - step installStep - tasks []installTask - currentTaskIndex int - width int - height int - spinner spinner.Model - errors []string - warnings []string - selectedOption int - debugMode bool - logFile *os.File - - // Animations - beams *BeamsTextEffect - ticker *TypewriterTicker - - // Pre-install checks - checks []checkResult - checksComplete bool - - // Installation paths - projectDir string - pluginDir string - configPath string - existingSetup bool - - // Context for cancellation - ctx context.Context - cancel context.CancelFunc -} - -// Messages -type taskCompleteMsg struct { - index int - success bool - err string -} - -type checksCompleteMsg struct { - checks []checkResult -} - -type tickMsg time.Time - -// globalProgram for sending messages from goroutines -var globalProgram *tea.Program -``` - -Note: Add `tea "github.com/charmbracelet/bubbletea"` to imports. - -**Step 2: Commit** - -```bash -git add cmd/installer/types.go -git commit -m "feat: add installer types" -``` - ---- - -## Task 11: Create Utils - -**Files:** -- Create: `cmd/installer/utils.go` - -**Step 1: Create utils.go** - -```go -// cmd/installer/utils.go -package main - -import ( - "encoding/json" - "fmt" - "os" - "os/exec" - "os/user" - "path/filepath" - "strings" - "time" -) - -// getConfigDir returns ~/.config for the actual user -func getConfigDir() (string, error) { - if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" && sudoUser != "root" { - u, err := user.Lookup(sudoUser) - if err == nil { - return filepath.Join(u.HomeDir, ".config"), nil - } - } - homeDir, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(homeDir, ".config"), nil -} - -// getActualUser returns the actual username (not root when using sudo) -func getActualUser() string { - if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" && sudoUser != "root" { - return sudoUser - } - if u, err := user.Current(); err == nil { - return u.Username - } - return "unknown" -} - -// detectExistingSetup checks if cursor-acp is already configured -func detectExistingSetup() (bool, string) { - configDir, err := getConfigDir() - if err != nil { - return false, "" - } - - configPath := filepath.Join(configDir, "opencode", "opencode.json") - data, err := os.ReadFile(configPath) - if err != nil { - return false, configPath - } - - var config map[string]interface{} - if err := json.Unmarshal(data, &config); err != nil { - return false, configPath - } - - if providers, ok := config["provider"].(map[string]interface{}); ok { - if _, exists := providers["cursor-acp"]; exists { - return true, configPath - } - } - - return false, configPath -} - -// commandExists checks if a command is available -func commandExists(cmd string) bool { - _, err := exec.LookPath(cmd) - return err == nil -} - -// runCommand executes a command and logs output -func runCommand(name string, cmd *exec.Cmd, logFile *os.File) error { - if logFile != nil { - logFile.WriteString(fmt.Sprintf("[%s] Running: %s\n", - time.Now().Format("15:04:05"), cmd.String())) - } - - output, err := cmd.CombinedOutput() - - if logFile != nil { - if len(output) > 0 { - logFile.Write(output) - logFile.WriteString("\n") - } - if err != nil { - logFile.WriteString(fmt.Sprintf("[%s] Error: %v\n\n", - time.Now().Format("15:04:05"), err)) - } else { - logFile.WriteString(fmt.Sprintf("[%s] Success\n\n", - time.Now().Format("15:04:05"))) - } - logFile.Sync() - } - - return err -} - -// validateJSON checks if a file contains valid JSON -func validateJSON(path string) error { - data, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("failed to read file: %w", err) - } - - var js interface{} - if err := json.Unmarshal(data, &js); err != nil { - return fmt.Errorf("invalid JSON: %w", err) - } - - return nil -} - -// cursorAgentLoggedIn checks if cursor-agent is logged in -func cursorAgentLoggedIn() bool { - cmd := exec.Command("cursor-agent", "whoami") - output, err := cmd.Output() - if err != nil { - return false - } - return !strings.Contains(string(output), "Not logged in") -} - -// getProjectDir returns the directory containing this installer -func getProjectDir() string { - exe, err := os.Executable() - if err != nil { - return "/home/nomadx/opencode-cursor" - } - // Follow symlink if needed - real, err := filepath.EvalSymlinks(exe) - if err != nil { - return filepath.Dir(exe) - } - // Go up from cmd/installer to project root - return filepath.Dir(filepath.Dir(filepath.Dir(real))) -} -``` - -**Step 2: Commit** - -```bash -git add cmd/installer/utils.go -git commit -m "feat: add installer utilities" -``` - ---- - -## Task 12: Create Tasks - -**Files:** -- Create: `cmd/installer/tasks.go` - -**Step 1: Create tasks.go** - -```go -// cmd/installer/tasks.go -package main - -import ( - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - - tea "github.com/charmbracelet/bubbletea" -) - -func (m model) startInstallation() (tea.Model, tea.Cmd) { - m.step = stepInstalling - - m.tasks = []installTask{ - {name: "Check prerequisites", description: "Verifying bun and cursor-agent", execute: checkPrerequisites, status: statusPending}, - {name: "Build plugin", description: "Running bun install && bun run build", execute: buildPlugin, status: statusPending}, - {name: "Create symlink", description: "Linking to OpenCode plugin directory", execute: createSymlink, status: statusPending}, - {name: "Update config", description: "Adding cursor-acp provider to opencode.json", execute: updateConfig, status: statusPending}, - {name: "Validate config", description: "Checking JSON syntax", execute: validateConfig, status: statusPending}, - {name: "Verify plugin", description: "Testing plugin loads correctly", execute: verifyPlugin, optional: true, status: statusPending}, - } - - m.currentTaskIndex = 0 - m.tasks[0].status = statusRunning - return m, tea.Batch(m.spinner.Tick, executeTaskCmd(0, &m)) -} - -func executeTaskCmd(index int, m *model) tea.Cmd { - return func() tea.Msg { - if index >= len(m.tasks) { - return taskCompleteMsg{index: index, success: true} - } - - task := &m.tasks[index] - err := task.execute(m) - - if err != nil { - return taskCompleteMsg{ - index: index, - success: false, - err: err.Error(), - } - } - - return taskCompleteMsg{index: index, success: true} - } -} - -func checkPrerequisites(m *model) error { - if !commandExists("bun") { - return fmt.Errorf("bun not found - install with: curl -fsSL https://bun.sh/install | bash") - } - if !commandExists("cursor-agent") { - return fmt.Errorf("cursor-agent not found - install with: curl -fsS https://cursor.com/install | bash") - } - return nil -} - -func buildPlugin(m *model) error { - // Run bun install - installCmd := exec.Command("bun", "install") - installCmd.Dir = m.projectDir - if err := runCommand("bun install", installCmd, m.logFile); err != nil { - return fmt.Errorf("bun install failed") - } - - // Run bun run build - buildCmd := exec.Command("bun", "run", "build") - buildCmd.Dir = m.projectDir - if err := runCommand("bun run build", buildCmd, m.logFile); err != nil { - return fmt.Errorf("bun run build failed") - } - - // Verify dist/index.js exists - distPath := filepath.Join(m.projectDir, "dist", "index.js") - info, err := os.Stat(distPath) - if err != nil || info.Size() == 0 { - return fmt.Errorf("dist/index.js not found or empty after build") - } - - return nil -} - -func createSymlink(m *model) error { - // Ensure plugin directory exists - if err := os.MkdirAll(m.pluginDir, 0755); err != nil { - return fmt.Errorf("failed to create plugin directory: %w", err) - } - - symlinkPath := filepath.Join(m.pluginDir, "cursor-acp.js") - targetPath := filepath.Join(m.projectDir, "dist", "index.js") - - // Remove existing symlink if present - os.Remove(symlinkPath) - - // Create symlink - if err := os.Symlink(targetPath, symlinkPath); err != nil { - return fmt.Errorf("failed to create symlink: %w", err) - } - - // Verify symlink resolves - if _, err := os.Stat(symlinkPath); err != nil { - return fmt.Errorf("symlink verification failed: %w", err) - } - - return nil -} - -func updateConfig(m *model) error { - // Read existing config or create new - var config map[string]interface{} - - data, err := os.ReadFile(m.configPath) - if err != nil { - if !os.IsNotExist(err) { - return fmt.Errorf("failed to read config: %w", err) - } - // Create new config - config = make(map[string]interface{}) - } else { - if err := json.Unmarshal(data, &config); err != nil { - return fmt.Errorf("failed to parse config: %w", err) - } - } - - // Ensure provider section exists - providers, ok := config["provider"].(map[string]interface{}) - if !ok { - providers = make(map[string]interface{}) - config["provider"] = providers - } - - // Add cursor-acp provider - providers["cursor-acp"] = map[string]interface{}{ - "npm": "@ai-sdk/openai-compatible", - "name": "Cursor Agent (ACP stdin)", - "options": map[string]interface{}{ - "baseURL": "http://127.0.0.1:32123/v1", - }, - } - - // Write config back - output, err := json.MarshalIndent(config, "", " ") - if err != nil { - return fmt.Errorf("failed to serialize config: %w", err) - } - - // Ensure config directory exists - if err := os.MkdirAll(filepath.Dir(m.configPath), 0755); err != nil { - return fmt.Errorf("failed to create config directory: %w", err) - } - - if err := os.WriteFile(m.configPath, output, 0644); err != nil { - return fmt.Errorf("failed to write config: %w", err) - } - - return nil -} - -func validateConfig(m *model) error { - if err := validateJSON(m.configPath); err != nil { - return fmt.Errorf("config validation failed: %w", err) - } - - // Verify cursor-acp provider exists in config - data, _ := os.ReadFile(m.configPath) - var config map[string]interface{} - json.Unmarshal(data, &config) - - providers, ok := config["provider"].(map[string]interface{}) - if !ok { - return fmt.Errorf("provider section missing from config") - } - - if _, exists := providers["cursor-acp"]; !exists { - return fmt.Errorf("cursor-acp provider not found in config") - } - - return nil -} - -func verifyPlugin(m *model) error { - // Try to load plugin with node to catch syntax/import errors - pluginPath := filepath.Join(m.projectDir, "dist", "index.js") - cmd := exec.Command("node", "-e", fmt.Sprintf(`require("%s")`, pluginPath)) - if err := cmd.Run(); err != nil { - return fmt.Errorf("plugin failed to load: %w", err) - } - - // Check cursor-agent responds - cmd = exec.Command("cursor-agent", "--version") - if err := cmd.Run(); err != nil { - return fmt.Errorf("cursor-agent not responding") - } - - return nil -} - -func (m model) handleTaskComplete(msg taskCompleteMsg) (tea.Model, tea.Cmd) { - if msg.index >= len(m.tasks) { - m.step = stepComplete - return m, nil - } - - task := &m.tasks[msg.index] - - if msg.success { - task.status = statusComplete - } else { - task.status = statusFailed - task.errorDetails = &errorInfo{ - message: msg.err, - logFile: m.logFile.Name(), - } - // If not optional, stop installation - if !task.optional { - m.errors = append(m.errors, msg.err) - m.step = stepComplete - return m, nil - } - } - - // Move to next task - m.currentTaskIndex++ - if m.currentTaskIndex >= len(m.tasks) { - m.step = stepComplete - return m, nil - } - - m.tasks[m.currentTaskIndex].status = statusRunning - return m, executeTaskCmd(m.currentTaskIndex, &m) -} -``` - -**Step 2: Commit** - -```bash -git add cmd/installer/tasks.go -git commit -m "feat: add installation tasks" -``` - ---- - -## Task 13: Create Main - -**Files:** -- Create: `cmd/installer/main.go` - -**Step 1: Create main.go** - -```go -// cmd/installer/main.go -package main - -import ( - "context" - "fmt" - "os" - "path/filepath" - "time" - - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -func newModel(debugMode bool, logFile *os.File) model { - s := spinner.New() - s.Style = lipgloss.NewStyle().Foreground(Secondary) - s.Spinner = spinner.Dot - - ctx, cancel := context.WithCancel(context.Background()) - - // Detect paths - configDir, _ := getConfigDir() - projectDir := getProjectDir() - existingSetup, configPath := detectExistingSetup() - - m := model{ - step: stepWelcome, - tasks: []installTask{}, - spinner: s, - errors: []string{}, - warnings: []string{}, - debugMode: debugMode, - logFile: logFile, - ctx: ctx, - cancel: cancel, - projectDir: projectDir, - pluginDir: filepath.Join(configDir, "opencode", "plugin"), - configPath: configPath, - existingSetup: existingSetup, - - // Animations (initialized on first resize) - beams: nil, - ticker: NewTypewriterTicker(), - } - - // Run pre-install checks - m.checks = runPreInstallChecks() - - return m -} - -func runPreInstallChecks() []checkResult { - var checks []checkResult - - // Check bun - if commandExists("bun") { - checks = append(checks, checkResult{name: "bun", passed: true, message: "installed"}) - } else { - checks = append(checks, checkResult{name: "bun", passed: false, message: "not found - install with: curl -fsSL https://bun.sh/install | bash"}) - } - - // Check cursor-agent - if commandExists("cursor-agent") { - checks = append(checks, checkResult{name: "cursor-agent", passed: true, message: "installed"}) - // Check if logged in - if cursorAgentLoggedIn() { - checks = append(checks, checkResult{name: "cursor-agent login", passed: true, message: "logged in"}) - } else { - checks = append(checks, checkResult{name: "cursor-agent login", passed: false, message: "not logged in - run: cursor-agent login", warning: true}) - } - } else { - checks = append(checks, checkResult{name: "cursor-agent", passed: false, message: "not found - install with: curl -fsS https://cursor.com/install | bash"}) - } - - // Check OpenCode config directory - configDir, err := getConfigDir() - if err == nil { - opencodeDir := filepath.Join(configDir, "opencode") - if _, err := os.Stat(opencodeDir); err == nil { - checks = append(checks, checkResult{name: "OpenCode config", passed: true, message: opencodeDir}) - } else { - checks = append(checks, checkResult{name: "OpenCode config", passed: true, message: "will create: " + opencodeDir, warning: true}) - } - } - - return checks -} - -func (m model) Init() tea.Cmd { - return tea.Batch( - m.spinner.Tick, - tickCmd(), - ) -} - -func tickCmd() tea.Cmd { - return tea.Tick(time.Millisecond*50, func(t time.Time) tea.Msg { - return tickMsg(t) - }) -} - -func main() { - debugMode := false - for _, arg := range os.Args[1:] { - if arg == "--debug" || arg == "-d" { - debugMode = true - break - } - } - - logFile, err := os.CreateTemp("", "opencode-cursor-installer-*.log") - if err != nil { - logFile = nil - } - if logFile != nil { - defer logFile.Close() - logFile.WriteString(fmt.Sprintf("=== OpenCode-Cursor Installer Log ===\n")) - logFile.WriteString(fmt.Sprintf("Started: %s\n", time.Now().Format("2006-01-02 15:04:05"))) - logFile.WriteString(fmt.Sprintf("Debug Mode: %v\n\n", debugMode)) - } - - m := newModel(debugMode, logFile) - p := tea.NewProgram(m, tea.WithAltScreen()) - globalProgram = p - - if _, err := p.Run(); err != nil { - fmt.Printf("Error: %v\n", err) - os.Exit(1) - } -} -``` - -**Step 2: Commit** - -```bash -git add cmd/installer/main.go -git commit -m "feat: add installer main entry point" -``` - ---- - -## Task 14: Create Update Handler - -**Files:** -- Create: `cmd/installer/update.go` - -**Step 1: Create update.go** - -```go -// cmd/installer/update.go -package main - -import ( - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" -) - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - headerHeight := 7 - if m.beams == nil { - m.beams = NewBeamsTextEffect(msg.Width, headerHeight, asciiHeader) - } else { - m.beams.Resize(msg.Width, headerHeight) - } - return m, nil - - case tickMsg: - if m.beams != nil { - m.beams.Update() - } - if m.ticker != nil { - m.ticker.Update() - } - return m, tickCmd() - - case tea.KeyMsg: - return m.handleKeyPress(msg) - - case spinner.TickMsg: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - - case taskCompleteMsg: - return m.handleTaskComplete(msg) - } - - return m, nil -} - -func (m model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - key := msg.String() - - switch key { - case "ctrl+c": - if m.step != stepInstalling { - if m.cancel != nil { - m.cancel() - } - return m, tea.Quit - } - return m, nil - - case "q": - if m.step == stepComplete || m.step == stepWelcome { - return m, tea.Quit - } - } - - switch m.step { - case stepWelcome: - return m.handleWelcomeKeys(key) - case stepComplete: - return m.handleCompleteKeys(key) - } - - return m, nil -} - -func (m model) handleWelcomeKeys(key string) (tea.Model, tea.Cmd) { - switch key { - case "enter": - // Check for blocking errors - for _, check := range m.checks { - if !check.passed && !check.warning { - return m, nil // Don't proceed with blocking errors - } - } - return m.startInstallation() - } - return m, nil -} - -func (m model) handleCompleteKeys(key string) (tea.Model, tea.Cmd) { - if key == "enter" || key == "q" { - return m, tea.Quit - } - return m, nil -} -``` - -**Step 2: Commit** - -```bash -git add cmd/installer/update.go -git commit -m "feat: add installer update handler" -``` - ---- - -## Task 15: Create View - -**Files:** -- Create: `cmd/installer/view.go` - -**Step 1: Create view.go** - -```go -// cmd/installer/view.go -package main - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" -) - -func (m model) View() string { - if m.width == 0 { - return "Loading..." - } - - if m.width < 80 || m.height < 24 { - return lipgloss.NewStyle(). - Foreground(ErrorColor). - Background(BgBase). - Bold(true). - Width(m.width). - Height(m.height). - Render(fmt.Sprintf( - "Terminal too small!\n\nMinimum: 80x24\nCurrent: %dx%d\n\nPlease resize.", - m.width, m.height, - )) - } - - var content strings.Builder - - // Render animated ASCII header - if m.beams != nil { - beamsOutput := m.beams.Render() - content.WriteString(beamsOutput) - content.WriteString("\n") - } else { - headerLines := strings.Split(asciiHeader, "\n") - for _, line := range headerLines { - centered := lipgloss.NewStyle(). - Width(m.width). - Align(lipgloss.Center). - Foreground(Primary). - Background(BgBase). - Bold(true). - Render(line) - content.WriteString(centered) - content.WriteString("\n") - } - } - content.WriteString("\n") - - // Render ticker - if m.ticker != nil { - tickerText := m.ticker.Render(m.width - 4) - tickerStyled := lipgloss.NewStyle(). - Foreground(FgMuted). - Background(BgBase). - Italic(true). - Width(m.width). - Align(lipgloss.Center). - Render(tickerText) - content.WriteString(tickerStyled) - content.WriteString("\n\n") - } - - // Main content based on step - var mainContent string - switch m.step { - case stepWelcome: - mainContent = m.renderWelcome() - case stepInstalling: - mainContent = m.renderInstalling() - case stepComplete: - mainContent = m.renderComplete() - } - - mainStyle := lipgloss.NewStyle(). - Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderForeground(Secondary). - Foreground(FgPrimary). - Background(BgBase). - Width(m.width - 4) - content.WriteString(mainStyle.Render(mainContent)) - content.WriteString("\n") - - // Help text - helpText := m.getHelpText() - if helpText != "" { - helpStyle := lipgloss.NewStyle(). - Foreground(FgMuted). - Background(BgBase). - Italic(true). - Width(m.width). - Align(lipgloss.Center) - content.WriteString("\n" + helpStyle.Render(helpText)) - } - - // Full screen background - bgStyle := lipgloss.NewStyle(). - Background(BgBase). - Foreground(FgPrimary). - Width(m.width). - Height(m.height). - Align(lipgloss.Center, lipgloss.Top) - - return bgStyle.Render(content.String()) -} - -func (m model) getHelpText() string { - switch m.step { - case stepWelcome: - return "Enter: Install • q: Quit" - case stepInstalling: - return "Please wait..." - case stepComplete: - return "Enter: Exit" - } - return "" -} - -func (m model) renderWelcome() string { - var b strings.Builder - - b.WriteString(lipgloss.NewStyle().Bold(true).Foreground(Primary).Render("OpenCode-Cursor Plugin Installer")) - b.WriteString("\n\n") - - b.WriteString("Pre-install checks:\n\n") - - for _, check := range m.checks { - var status string - if check.passed { - status = checkMark.String() - } else if check.warning { - status = skipMark.String() - } else { - status = failMark.String() - } - b.WriteString(fmt.Sprintf(" %s %s: %s\n", status, check.name, check.message)) - } - - b.WriteString("\n") - - if m.existingSetup { - b.WriteString(lipgloss.NewStyle().Foreground(WarningColor).Render("⚠ cursor-acp already configured - will reinstall")) - b.WriteString("\n\n") - } - - // Check if we can proceed - canProceed := true - for _, check := range m.checks { - if !check.passed && !check.warning { - canProceed = false - break - } - } - - if canProceed { - b.WriteString(lipgloss.NewStyle().Bold(true).Foreground(Primary).Render("Press Enter to install")) - } else { - b.WriteString(lipgloss.NewStyle().Foreground(ErrorColor).Render("Fix errors above before installing")) - } - - return b.String() -} - -func (m model) renderInstalling() string { - var b strings.Builder - - for _, task := range m.tasks { - var line string - switch task.status { - case statusPending: - line = lipgloss.NewStyle().Foreground(FgMuted).Render(" " + task.name) - case statusRunning: - line = m.spinner.View() + " " + lipgloss.NewStyle().Foreground(Secondary).Render(task.description) - case statusComplete: - line = checkMark.String() + " " + task.name - case statusFailed: - line = failMark.String() + " " + task.name - case statusSkipped: - line = skipMark.String() + " " + task.name - } - b.WriteString(line + "\n") - - if task.status == statusFailed && task.errorDetails != nil { - err := task.errorDetails - b.WriteString(lipgloss.NewStyle().Foreground(ErrorColor).Render( - fmt.Sprintf(" └─ Error: %s\n", err.message))) - if err.logFile != "" { - b.WriteString(lipgloss.NewStyle().Foreground(FgMuted).Render( - fmt.Sprintf(" └─ See logs: %s\n", err.logFile))) - } - } - } - - return b.String() -} - -func (m model) renderComplete() string { - hasCriticalFailure := false - for _, task := range m.tasks { - if task.status == statusFailed && !task.optional { - hasCriticalFailure = true - break - } - } - - if hasCriticalFailure { - return lipgloss.NewStyle().Foreground(ErrorColor).Render( - "Installation failed.\nCheck errors above.\n\nPress Enter to exit") - } - - var b strings.Builder - b.WriteString(lipgloss.NewStyle().Foreground(SuccessColor).Bold(true).Render("✓ Installation Complete")) - b.WriteString("\n\n") - - b.WriteString("The cursor-acp provider is now available in OpenCode.\n\n") - - b.WriteString(lipgloss.NewStyle().Bold(true).Foreground(Primary).Render("Quick Start")) - b.WriteString("\n") - - cmdStyle := lipgloss.NewStyle().Foreground(Secondary) - descStyle := lipgloss.NewStyle().Foreground(FgMuted) - - b.WriteString(fmt.Sprintf(" %s %s\n", cmdStyle.Render("opencode"), descStyle.Render("Start OpenCode"))) - b.WriteString(fmt.Sprintf(" %s %s\n\n", cmdStyle.Render("cursor-acp/auto"), descStyle.Render("Use as model name"))) - - if !cursorAgentLoggedIn() { - b.WriteString(lipgloss.NewStyle().Foreground(WarningColor).Render("⚠ Remember to run: cursor-agent login")) - b.WriteString("\n\n") - } - - pathStyle := lipgloss.NewStyle().Foreground(FgMuted).Italic(true) - b.WriteString(fmt.Sprintf("Plugin: %s\n", pathStyle.Render(m.pluginDir+"/cursor-acp.js"))) - b.WriteString(fmt.Sprintf("Config: %s\n", pathStyle.Render(m.configPath))) - - b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(FgMuted).Render("Press Enter to exit")) - - return b.String() -} -``` - -**Step 2: Commit** - -```bash -git add cmd/installer/view.go -git commit -m "feat: add installer view rendering" -``` - ---- - -## Task 16: Create install.sh Entry Point - -**Files:** -- Create: `install.sh` - -**Step 1: Create install.sh** - -```bash -#!/bin/bash -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -INSTALLER_BIN="/tmp/opencode-cursor-installer-$$" - -echo "OpenCode-Cursor Installer" -echo "=========================" -echo "" - -# Check for Go -if ! command -v go &> /dev/null; then - echo "Error: Go is not installed" - echo "Please install Go 1.21 or later from https://golang.org/dl/" - exit 1 -fi - -# Build installer -echo "Building installer..." -cd "$SCRIPT_DIR" -go build -o "$INSTALLER_BIN" ./cmd/installer - -# Run installer -echo "" -"$INSTALLER_BIN" "$@" -EXIT_CODE=$? - -# Cleanup -rm -f "$INSTALLER_BIN" - -exit $EXIT_CODE -``` - -**Step 2: Make executable** - -```bash -chmod +x install.sh -``` - -**Step 3: Commit** - -```bash -git add install.sh -git commit -m "feat: add install.sh entry point" -``` - ---- - -## Task 17: Update README - -**Files:** -- Modify: `README.md` - -**Step 1: Update README with new installation method** - -```markdown -# opencode-cursor - -A lightweight OpenCode plugin for Cursor Agent integration via stdin (fixes E2BIG errors). - -## Background - -[PR #5095](https://github.com/sst/opencode/pull/5095) by [@rinardmclern](https://github.com/rinardmclern) proposed native ACP support for OpenCode. The OpenCode maintainers decided not to merge it, so this plugin provides an alternative solution as a standalone tool. - -## Problem Solved - -`opencode-cursor-auth` passes prompts as CLI arguments → causes `E2BIG: argument list too long` errors. - -This plugin uses stdin/stdout to bypass argument length limits. - -## Installation - -### Quick Install (Recommended) - -```bash -git clone https://github.com/nomadcxx/opencode-cursor.git -cd opencode-cursor -./install.sh -``` - -The installer will: -- Check prerequisites (bun, cursor-agent) -- Build the TypeScript plugin -- Create symlink to OpenCode plugin directory -- Update opencode.json with cursor-acp provider -- Validate the configuration - -### Manual Installation - -```bash -# Install dependencies and build -bun install -bun run build - -# Create plugin directory -mkdir -p ~/.config/opencode/plugin - -# Symlink plugin -ln -s $(pwd)/dist/index.js ~/.config/opencode/plugin/cursor-acp.js - -# Add to ~/.config/opencode/opencode.json: -# { -# "provider": { -# "cursor-acp": { -# "npm": "@ai-sdk/openai-compatible", -# "name": "Cursor Agent (ACP stdin)", -# "options": { -# "baseURL": "http://127.0.0.1:32123/v1" -# } -# } -# } -# } -``` - -## Usage - -OpenCode will automatically use this provider when configured. Select `cursor-acp/auto` as your model. - -## Features - -- ✅ Passes prompts via stdin (fixes E2BIG) -- ✅ Full streaming support with proper buffering -- ✅ Tool calling support -- ✅ Minimal complexity (~200 lines) -- ✅ TUI installer with animated ASCII art -- ✅ Pre/post install validation - -## Prerequisites - -- [Bun](https://bun.sh/) - JavaScript runtime -- [cursor-agent](https://cursor.com/) - Cursor CLI tool -- [Go 1.21+](https://golang.org/) - For building installer - -## Development - -```bash -# Install dependencies -bun install - -# Build plugin -bun run build - -# Watch mode -bun run dev - -# Run installer in debug mode -./install.sh --debug -``` - -## License - -ISC -``` - -**Step 2: Commit** - -```bash -git add README.md -git commit -m "docs: update README with installer instructions" -``` - ---- - -## Task 18: Build and Test - -**Step 1: Build TypeScript plugin** - -```bash -cd /home/nomadx/opencode-cursor -bun install -bun run build -``` - -**Step 2: Build Go installer** - -```bash -go build ./cmd/installer -``` - -**Step 3: Run installer in debug mode** - -```bash -./install.sh --debug -``` - -**Step 4: Verify installation** - -```bash -ls -la ~/.config/opencode/plugin/cursor-acp.js -cat ~/.config/opencode/opencode.json | jq '.provider["cursor-acp"]' -``` - -**Step 5: Final commit** - -```bash -git add -A -git commit -m "feat: complete opencode-cursor plugin with TUI installer" -``` - ---- - -## Remember - -- Exact file paths: `/home/nomadx/opencode-cursor` -- Port animations.go from `/home/nomadx/Documents/jellywatch/cmd/installer/` -- Keep theme identical to jellywatch (monochrome) -- DRY, YAGNI, TDD, frequent commits - ---- - -## Execution Handoff - -Plan complete and saved to `docs/plans/2026-01-23-cursor-acp-plugin-implementation.md`. Two execution options: - -**1. Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration - -**2. Parallel Session (separate)** - Open new session in worktree with executing-plans, batch execution with checkpoints - -Which approach? diff --git a/docs/plans/2026-01-23-hybrid-acp-implementation-ans.md b/docs/plans/2026-01-23-hybrid-acp-implementation-ans.md deleted file mode 100644 index 37fc540..0000000 --- a/docs/plans/2026-01-23-hybrid-acp-implementation-ans.md +++ /dev/null @@ -1,1745 +0,0 @@ -# [Hybrid ACP Implementation] Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Implement full Agent Client Protocol (ACP) compliance with class-based architecture, robust infrastructure (session persistence, retry logic, enhanced tool metadata), and native cursor-agent feature integration. - -**Architecture:** Hybrid approach combining @agentclientprotocol/sdk for ACP protocol correctness with custom extensions for Cursor-specific features (usage tracking, model discovery, health checks). Class-based modular design with separate concerns: SessionManager, RetryEngine, ToolMapper, MetricsTracker, CursorNativeWrapper. - -**Tech Stack:** TypeScript, @agentclientprotocol/sdk, Node 18+ crypto API, Bun runtime support. - ---- - -## File Structure Overview - -**New directories and files to create:** -``` -src/ -├── acp/ # New ACP implementation -│ ├── agent.ts # CursorAcpHybridAgent (main entry) -│ ├── sessions.ts # SessionManager, SessionStorage -│ ├── retry.ts # RetryEngine -│ ├── tools.ts # ToolMapper -│ ├── cursor.ts # CursorNativeWrapper -│ ├── metrics.ts # MetricsTracker -│ ├── logger.ts # createLogger utility -│ └── types.ts # Shared types -├── types.ts # Project-wide types -└── index.ts # Entry point (backward compatible wrapper) - -tests/ -├── unit/ -│ ├── sessions.test.ts -│ ├── retry.test.ts -│ ├── tools.test.ts -│ └── metrics.test.ts -└── integration/ - └── agent.test.ts -``` - ---- - -### Task 1: Project Setup - -**Files:** -- Create: `src/acp/types.ts` -- Create: `src/acp/logger.ts` -- Create: `src/acp/sessions.ts` -- Create: `src/types.ts` - -**Step 1: Add @agentclientprotocol/sdk dependency** - -```bash -cd /home/nomadx/opencode-cursor -bun add @agentclientprotocol/sdk -``` - -Expected: package.json updated with dependency - -**Step 2: Write ACP shared types** - -Create file: `src/acp/types.ts` - -```typescript -import type { Agent, InitializeRequest, InitializeResponse, NewSessionRequest, NewSessionResponse, PromptRequest, PromptResponse, CancelNotification, SetSessionModeRequest, SetSessionModeResponse } from "@agentclientprotocol/sdk"; - -export interface SessionState { - id: string; - cwd?: string; - modeId: "default" | "plan"; - cancelled: boolean; - resumeId?: string; - createdAt: number; - lastActivity: number; -} - -export interface RetryContext { - operation: "prompt" | "tool" | "auth"; - sessionId?: string; -} - -export interface AcpToolUpdate { - sessionId: string; - toolCallId: string; - title?: string; - kind?: "read" | "edit" | "search" | "execute" | "other"; - status: "pending" | "in_progress" | "completed" | "failed"; - rawInput?: string; - rawOutput?: string; - locations?: Array<{ path: string; line?: number }>; - content?: Array<{ type: "content" | "diff"; content?: any }>; - startTime?: number; - endTime?: number; - durationMs?: number; -} - -export interface CursorUsageStats { - totalPrompts: number; - totalTokens: number; - totalDuration: number; - modelBreakdown: Record; -} - -export interface CursorAgentStatus { - healthy: boolean; - version?: string; - logged_in: boolean; -} - -export interface CursorModel { - id: string; - name: string; - description?: string; -} - -export interface PromptMetrics { - sessionId: string; - model: string; - promptTokens: number; - toolCalls: number; - duration: number; - timestamp: number; -} - -export interface AggregateMetrics { - totalPrompts: number; - totalToolCalls: number; - totalDuration: number; - avgDuration: number; -} -``` - -**Step 3: Write logger utility** - -Create file: `src/acp/logger.ts` - -```typescript -export function createLogger(prefix: string) { - return { - debug: (msg: string, meta?: unknown) => - console.error(`[${prefix}:debug] ${msg}`, meta), - info: (msg: string, meta?: unknown) => - console.error(`[${prefix}:info] ${msg}`, meta), - warn: (msg: string, meta?: unknown) => - console.error(`[${prefix}:warn] ${msg}`, meta), - error: (msg: string, err?: unknown) => - console.error(`[${prefix}:error] ${msg}`, err) - }; -} -``` - -**Step 4: Write project-wide types** - -Create file: `src/types.ts` - -```typescript -export interface PluginConfig { - maxRetries?: number; - backoffBaseMs?: number; - backoffMaxMs?: number; - timeoutMs?: number; - persistSessions?: boolean; - sessionRetentionDays?: number; -} -``` - -**Step 5: Install dependencies and test build** - -```bash -bun install -bun run build -``` - -Expected: Build succeeds with new files - -**Step 6: Commit** - -```bash -git add package.json src/acp/types.ts src/acp/logger.ts src/acp/sessions.ts src/types.ts -git commit -m "feat: add project setup and core types" -``` - ---- - -### Task 2: SessionManager Implementation - -**Files:** -- Create: `src/acp/sessions.ts` (partial, from Task 1) -- Modify: `src/acp/sessions.ts` - -**Step 1: Write failing test for SessionManager** - -Create file: `tests/unit/sessions.test.ts` - -```typescript -import { describe, it, expect, beforeEach } from "bun:test"; - -describe("SessionManager", () => { - it("should create session with unique ID", async () => { - // Will fail until we implement SessionManager - expect(true).toBe(true); - }); - - it("should persist session to disk", async () => { - // Will fail until we implement persistence - expect(true).toBe(true); - }); - - it("should update session state", async () => { - // Will fail until we implement update - expect(true).toBe(true); - }); -}); -``` - -**Step 2: Run test to verify it fails** - -```bash -bun test tests/unit/sessions.test.ts -``` - -Expected: FAIL with "SessionManager not defined" - -**Step 3: Implement SessionStorage (file-based persistence)** - -Update file: `src/acp/sessions.ts` - -```typescript -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import * as os from "node:os"; - -interface PersistedSession { - id: string; - cwd?: string; - modeId: "default" | "plan"; - resumeId?: string; - createdAt: number; - lastActivity: number; -} - -class SessionStorage { - private storageDir: string; - - constructor() { - const homeDir = os.homedir(); - this.storageDir = path.join(homeDir, ".opencode", "sessions"); - fs.mkdir(this.storageDir, { recursive: true }).catch(() => {}); - } - - async save(session: PersistedSession): Promise { - const filePath = path.join(this.storageDir, `${session.id}.json`); - await fs.writeFile(filePath, JSON.stringify(session, null, 2)); - } - - async load(sessionId: string): Promise { - const filePath = path.join(this.storageDir, `${sessionId}.json`); - try { - const content = await fs.readFile(filePath, "utf-8"); - return JSON.parse(content); - } catch { - return null; - } - } - - async delete(sessionId: string): Promise { - const filePath = path.join(this.storageDir, `${sessionId}.json`); - await fs.unlink(filePath).catch(() => {}); - } - - async loadAll(): Promise { - const files = await fs.readdir(this.storageDir); - const sessions: PersistedSession[] = []; - - for (const file of files) { - if (!file.endsWith(".json")) continue; - const filePath = path.join(this.storageDir, file); - try { - const content = await fs.readFile(filePath, "utf-8"); - sessions.push(JSON.parse(content)); - } catch { - // Skip corrupted files - } - } - - return sessions; - } - - async cleanupStale(retentionDays: number): Promise { - const cutoff = Date.now() - (retentionDays * 24 * 60 * 60 * 1000); - const sessions = await this.loadAll(); - - for (const session of sessions) { - if (session.lastActivity < cutoff) { - await this.delete(session.id); - } - } - } -} - -export { SessionStorage }; -``` - -**Step 4: Run test to verify persistence works** - -```bash -bun test tests/unit/sessions.test.ts::SessionStorage -``` - -Expected: Tests for SessionStorage pass - -**Step 5: Implement SessionManager class** - -Update file: `src/acp/sessions.ts` - -```typescript -import type { SessionState } from "./types.js"; -import { SessionStorage } from "./sessions.js"; -import { crypto } from "node:crypto"; - -export class SessionManager { - private sessions: Map; - private storage: SessionStorage; - - constructor() { - this.sessions = new Map(); - this.storage = new SessionStorage(); - } - - async initialize(): Promise { - const persisted = await this.storage.loadAll(); - for (const session of persisted) { - this.sessions.set(session.id, { - ...session, - cancelled: false - }); - } - } - - async createSession(params: { cwd?: string; modeId?: "default" | "plan" }): Promise { - const id = crypto.randomUUID(); - const state: SessionState = { - id, - cwd: params.cwd, - modeId: params.modeId || "default", - resumeId: undefined, - cancelled: false, - createdAt: Date.now(), - lastActivity: Date.now() - }; - - this.sessions.set(id, state); - await this.storage.save(state); - return state; - } - - async getSession(id: string): Promise { - return this.sessions.get(id) || null; - } - - async updateSession(id: string, updates: Partial): Promise { - const session = this.sessions.get(id); - if (!session) return; - - const updated = { ...session, ...updates, lastActivity: Date.now() }; - this.sessions.set(id, updated); - await this.storage.save(updated as any); - } - - async deleteSession(id: string): Promise { - this.sessions.delete(id); - await this.storage.delete(id); - } - - async cleanupStale(retentionDays: number = 7): Promise { - await this.storage.cleanupStale(retentionDays); - for (const [id, session] of this.sessions.entries()) { - const cutoff = Date.now() - (retentionDays * 24 * 60 * 60 * 1000); - if (session.lastActivity < cutoff) { - this.sessions.delete(id); - } - } - } - - canResume(sessionId: string): boolean { - const session = this.sessions.get(sessionId); - return !!(session?.resumeId); - } - - setResumeId(sessionId: string, resumeId: string): void { - const session = this.sessions.get(sessionId); - if (session) { - session.resumeId = resumeId; - } - } - - markCancelled(sessionId: string): void { - const session = this.sessions.get(sessionId); - if (session) { - session.cancelled = true; - } - } - - isCancelled(sessionId: string): boolean { - const session = this.sessions.get(sessionId); - return session?.cancelled || false; - } -} - -export { SessionManager }; -``` - -**Step 6: Run tests and verify they pass** - -```bash -bun test tests/unit/sessions.test.ts -``` - -Expected: All tests pass - -**Step 7: Commit** - -```bash -git add src/acp/sessions.ts tests/unit/sessions.test.ts -git commit -m "feat: implement SessionManager with persistence" -``` - ---- - -### Task 3: RetryEngine Implementation - -**Files:** -- Create: `src/acp/retry.ts` -- Create: `tests/unit/retry.test.ts` - -**Step 1: Write failing tests for RetryEngine** - -Create file: `tests/unit/retry.test.ts` - -```typescript -import { describe, it, expect } from "bun:test"; - -describe("RetryEngine", () => { - it("should retry on recoverable errors", async () => { - expect(true).toBe(true); - }); - - it("should not retry on fatal errors", async () => { - expect(true).toBe(true); - }); - - it("should calculate exponential backoff", async () => { - expect(true).toBe(true); - }); -}); -``` - -**Step 2: Run test to verify it fails** - -```bash -bun test tests/unit/retry.test.ts -``` - -Expected: FAIL with "RetryEngine not defined" - -**Step 3: Implement RetryEngine class** - -Create file: `src/acp/retry.ts` - -```typescript -import type { RetryContext } from "./types.js"; -import { createLogger } from "./logger.js"; - -const log = createLogger("RetryEngine"); - -export interface RetryConfig { - maxRetries?: number; - baseDelayMs?: number; - maxDelayMs?: number; -} - -export class RetryEngine { - private maxRetries: number; - private baseDelayMs: number; - private maxDelayMs: number; - - constructor(config: RetryConfig = {}) { - this.maxRetries = config.maxRetries || 3; - this.baseDelayMs = config.baseDelayMs || 1000; - this.maxDelayMs = config.maxDelayMs || 30000; - } - - async executeWithRetry( - operation: () => Promise, - context: RetryContext - ): Promise { - let attempt = 0; - let lastError: Error | null = null; - - while (attempt < this.maxRetries) { - try { - const result = await operation(); - log.info(`Success on attempt ${attempt + 1}`, { context }); - return result; - } catch (error) { - lastError = error as Error; - - if (!this.isRecoverable(error as Error, context)) { - log.error(`Fatal error, not retrying`, { error, context }); - throw error; - } - - attempt++; - const delay = this.calculateBackoff(attempt); - log.warn(`Attempt ${attempt} failed, retrying in ${delay}ms`, { error }); - await this.sleep(delay); - } - } - - throw new Error(`Max retries (${this.maxRetries}) exceeded`, { cause: lastError }); - } - - private isRecoverable(error: Error, context: RetryContext): boolean { - const msg = error.message || ""; - - // Recoverable: timeout, network, rate limit - if (msg.includes("timeout")) return true; - if (msg.includes("ECONNREFUSED")) return true; - if (msg.includes("ETIMEDOUT")) return true; - if (msg.includes("429")) return true; - if (msg.includes("rate limit")) return true; - - // Fatal: auth error, invalid config - if (msg.includes("Not logged in")) return false; - if (msg.includes("Not authenticated")) return false; - if (msg.includes("invalid model")) return false; - if (msg.includes("Invalid configuration")) return false; - - return false; - } - - private calculateBackoff(attempt: number): number { - const delay = this.baseDelayMs * Math.pow(2, attempt - 1); - return Math.min(delay, this.maxDelayMs); - } - - private sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); - } -} -``` - -**Step 4: Run tests and verify they pass** - -```bash -bun test tests/unit/retry.test.ts -``` - -Expected: All tests pass - -**Step 5: Commit** - -```bash -git add src/acp/retry.ts tests/unit/retry.test.ts -git commit -m "feat: implement RetryEngine with exponential backoff" -``` - ---- - -### Task 4: ToolMapper Implementation - -**Files:** -- Create: `src/acp/tools.ts` -- Create: `tests/unit/tools.test.ts` - -**Step 1: Write failing tests for ToolMapper** - -Create file: `tests/unit/tools.test.ts` - -```typescript -import { describe, it, expect } from "bun:test"; - -describe("ToolMapper", () => { - it("should map tool_call events to ACP format", async () => { - expect(true).toBe(true); - }); - - it("should extract locations from tool args", async () => { - expect(true).toBe(true); - }); - - it("should generate diffs for write operations", async () => { - expect(true).toBe(true); - }); -}); -``` - -**Step 2: Run test to verify it fails** - -```bash -bun test tests/unit/tools.test.ts -``` - -Expected: FAIL with "ToolMapper not defined" - -**Step 3: Implement ToolMapper class** - -Create file: `src/acp/tools.ts` - -```typescript -import type { AcpToolUpdate } from "./types.js"; -import { createLogger } from "./logger.js"; - -const log = createLogger("ToolMapper"); - -interface CursorToolCallEvent { - type: "tool_call"; - call_id?: string; - tool_call_id?: string; - subtype: "started" | "completed"; - tool_call?: Record; -} - -interface CursorAgentEvent { - type: string; - subtype?: string; - call_id?: string; - tool_call_id?: string; - tool_call?: Record; -} - -export class ToolMapper { - async mapCursorEventToAcp( - evt: CursorAgentEvent, - sessionId: string - ): Promise { - switch (evt.type) { - case "tool_call": - return this.handleToolCall(evt as CursorToolCallEvent, sessionId); - default: - return []; - } - } - - private async handleToolCall( - evt: CursorToolCallEvent, - sessionId: string - ): Promise { - const updates: AcpToolUpdate[] = []; - const callId = evt.call_id || evt.tool_call_id || ""; - const toolKind = this.getToolKind(evt.tool_call); - const args = toolKind ? evt.tool_call?.[toolKind]?.args || {} : {}; - - if (evt.subtype === "started") { - const update: AcpToolUpdate = { - sessionId, - toolCallId: callId, - title: this.buildToolTitle(toolKind || "other", args), - kind: this.inferToolType(toolKind || "other"), - status: "pending", - locations: this.extractLocations(args), - rawInput: JSON.stringify(args), - startTime: Date.now() - }; - - updates.push(update); - - updates.push({ - sessionId, - toolCallId: callId, - status: "in_progress" - }); - } else if (evt.subtype === "completed") { - const result = toolKind ? evt.tool_call?.[toolKind]?.result : undefined; - const update = await this.buildCompletionUpdate(callId, toolKind || "other", args, result); - updates.push(update); - } - - return updates; - } - - private getToolKind(toolCall: Record | undefined): string | undefined { - if (!toolCall) return undefined; - return Object.keys(toolCall)[0]; - } - - private buildToolTitle(kind: string, args: any): string { - switch (kind) { - case "readToolCall": - return args?.path ? `Read ${args.path}` : "Read"; - case "writeToolCall": - return args?.path ? `Write ${args.path}` : "Write"; - case "grepToolCall": - if (args?.pattern && args?.path) return `Search ${args.path} for ${args.pattern}`; - if (args?.pattern) return `Search for ${args.pattern}`; - return "Search"; - case "globToolCall": - return args?.pattern ? `Glob ${args.pattern}` : "Glob"; - case "bashToolCall": - case "shellToolCall": - const cmd = args?.command ?? args?.cmd ?? (Array.isArray(args?.commands) ? args.commands.join(" && ") : undefined); - return cmd ? `\`${cmd}\`` : "Terminal"; - default: - return kind; - } - } - - private inferToolType(kind: string): "read" | "edit" | "search" | "execute" | "other" { - switch (kind) { - case "readToolCall": - return "read"; - case "writeToolCall": - return "edit"; - case "grepToolCall": - case "globToolCall": - return "search"; - case "bashToolCall": - case "shellToolCall": - return "execute"; - default: - return "other"; - } - } - - private extractLocations(args: any): Array<{ path: string; line?: number }> | undefined { - const locs: Array<{ path: string; line?: number }> = []; - - if (typeof args?.path === "string") { - locs.push({ path: String(args.path), line: typeof args.line === "number" ? args.line : undefined }); - } - - if (Array.isArray(args?.paths)) { - for (const p of args.paths) { - if (typeof p === "string") locs.push({ path: p }); - else if (p && typeof p.path === "string") { - locs.push({ path: p.path, line: typeof p.line === "number" ? p.line : undefined }); - } - } - } - - return locs.length > 0 ? locs : undefined; - } - - private async buildCompletionUpdate( - callId: string, - toolKind: string, - args: any, - result: any - ): Promise { - const update: AcpToolUpdate = { - sessionId: "", - toolCallId: callId, - status: result?.error ? "failed" : "completed", - rawOutput: result ? JSON.stringify(result) : "", - endTime: Date.now() - }; - - const locations = this.extractResultLocations(result); - if (locations) update.locations = locations; - - if (toolKind === "writeToolCall") { - const contentText = result?.newText ?? args?.fileText ?? ""; - update.content = [{ - type: "diff", - path: args.path || "", - oldText: result?.oldText || null, - newText: contentText - }]; - } else if (toolKind === "bashToolCall" || toolKind === "shellToolCall") { - const output = result?.output ?? ""; - const exitCode = typeof result?.exitCode === "number" ? result.exitCode : undefined; - const text = exitCode !== undefined - ? `Exit code: ${exitCode}\n${output || "(no output)"}` - : output || "(no output)"; - update.content = [{ - type: "content", - content: { type: "text", text: "```\n" + text + "\n```" } - }]; - } - - return update; - } - - private extractResultLocations(result: any): Array<{ path: string; line?: number }> | undefined { - if (!result) return undefined; - - const locs: Array<{ path: string; line?: number }> = []; - - if (Array.isArray(result?.matches)) { - for (const m of result.matches) { - if (typeof m === "string") locs.push({ path: m }); - else if (m && typeof m.path === "string") { - locs.push({ path: m.path, line: typeof m.line === "number" ? m.line : undefined }); - } - } - } - - if (Array.isArray(result?.files)) { - for (const f of result.files) { - if (typeof f === "string") locs.push({ path: f }); - else if (f && typeof f.path === "string") { - locs.push({ path: f.path, line: typeof f.line === "number" ? f.line : undefined }); - } - } - } - - if (typeof result?.path === "string") { - locs.push({ path: result.path, line: typeof result.line === "number" ? result.line : undefined }); - } - - return locs.length > 0 ? locs : undefined; - } -} - -export { ToolMapper }; -``` - -**Step 4: Run tests and verify they pass** - -```bash -bun test tests/unit/tools.test.ts -``` - -Expected: All tests pass - -**Step 5: Commit** - -```bash -git add src/acp/tools.ts tests/unit/tools.test.ts -git commit -m "feat: implement ToolMapper with enhanced metadata" -``` - ---- - -### Task 5: MetricsTracker Implementation - -**Files:** -- Create: `src/acp/metrics.ts` -- Create: `tests/unit/metrics.test.ts` - -**Step 1: Write failing tests for MetricsTracker** - -Create file: `tests/unit/metrics.test.ts` - -```typescript -import { describe, it, expect, beforeEach } from "bun:test"; - -describe("MetricsTracker", () => { - beforeEach(() => { - // Reset before each test - }); - - it("should record prompt metrics", async () => { - expect(true).toBe(true); - }); - - it("should record tool calls", async () => { - expect(true).toBe(true); - }); - - it("should calculate aggregate metrics", async () => { - expect(true).toBe(true); - }); -}); -``` - -**Step 2: Run test to verify it fails** - -```bash -bun test tests/unit/metrics.test.ts -``` - -Expected: FAIL with "MetricsTracker not defined" - -**Step 3: Implement MetricsTracker class** - -Create file: `src/acp/metrics.ts` - -```typescript -import type { PromptMetrics, AggregateMetrics } from "./types.js"; -import { createLogger } from "./logger.js"; - -const log = createLogger("MetricsTracker"); - -export class MetricsTracker { - private metrics: Map; - - constructor() { - this.metrics = new Map(); - } - - recordPrompt(sessionId: string, model: string, promptTokens: number = 0): void { - const metrics: PromptMetrics = { - sessionId, - model, - promptTokens, - toolCalls: 0, - duration: 0, - timestamp: Date.now() - }; - - this.metrics.set(sessionId, metrics); - log.debug(`Recorded prompt for session ${sessionId}`, { model, tokens: promptTokens }); - } - - recordToolCall(sessionId: string, toolName: string, durationMs: number): void { - const metrics = this.metrics.get(sessionId); - if (metrics) { - metrics.toolCalls++; - metrics.duration += durationMs; - log.debug(`Recorded tool call for session ${sessionId}`, { tool: toolName, duration: durationMs }); - } - } - - getSessionMetrics(sessionId: string): PromptMetrics | undefined { - return this.metrics.get(sessionId); - } - - getAggregateMetrics(hours: number = 24): AggregateMetrics { - const cutoff = Date.now() - (hours * 60 * 60 * 1000); - const relevant = Array.from(this.metrics.values()) - .filter(m => m.timestamp >= cutoff); - - const totalPrompts = relevant.length; - const totalToolCalls = relevant.reduce((sum, m) => sum + m.toolCalls, 0); - const totalDuration = relevant.reduce((sum, m) => sum + m.duration, 0); - const avgDuration = totalPrompts > 0 ? totalDuration / totalPrompts : 0; - - return { - totalPrompts, - totalToolCalls, - totalDuration, - avgDuration - }; - } - - clearMetrics(sessionId: string): void { - this.metrics.delete(sessionId); - } - - clearAll(): void { - this.metrics.clear(); - } -} -``` - -**Step 4: Run tests and verify they pass** - -```bash -bun test tests/unit/metrics.test.ts -``` - -Expected: All tests pass - -**Step 5: Commit** - -```bash -git add src/acp/metrics.ts tests/unit/metrics.test.ts -git commit -m "feat: implement MetricsTracker for usage analytics" -``` - ---- - -### Task 6: CursorNativeWrapper Implementation - -**Files:** -- Create: `src/acp/cursor.ts` - -**Step 1: Write CursorNativeWrapper class** - -Create file: `src/acp/cursor.ts` - -```typescript -import { spawn, type ChildProcess } from "child_process"; -import * as readline from "node:readline"; -import type { CursorUsageStats, CursorAgentStatus, CursorModel } from "./types.js"; -import { createLogger } from "./logger.js"; - -const log = createLogger("CursorNative"); - -export class CursorNativeWrapper { - private agentPath: string; - - constructor() { - this.agentPath = process.env.CURSOR_AGENT_EXECUTABLE || "cursor-agent"; - } - - async execCommand(args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> { - const child = spawn(this.agentPath, args, { stdio: ["pipe", "pipe", "pipe"] }); - - if (!child.stdout || !child.stderr) { - throw new Error("Failed to spawn cursor-agent"); - } - - let stdout = ""; - let stderr = ""; - - child.stdout.on("data", (data) => stdout += data); - child.stderr.on("data", (data) => stderr += data); - - const exitCode = await new Promise((resolve) => { - child.on("close", resolve); - }); - - return { exitCode, stdout, stderr }; - } - - async getUsage(): Promise { - log.info("Querying cursor-agent usage"); - - try { - const result = await this.execCommand(["--usage"]); - return this.parseUsageOutput(result.stdout); - } catch (error) { - log.warn("Failed to query usage, returning empty stats", { error }); - return { - totalPrompts: 0, - totalTokens: 0, - totalDuration: 0, - modelBreakdown: {} - }; - } - } - - async getStatus(): Promise { - log.info("Checking cursor-agent status"); - - try { - const result = await this.execCommand(["--version"]); - const version = this.extractVersion(result.stdout); - - const whoami = await this.execCommand(["whoami"]); - const loggedIn = !whoami.stdout.includes("Not logged in"); - - return { - healthy: result.exitCode === 0, - version, - logged_in: loggedIn - }; - } catch (error) { - log.warn("Failed to check cursor-agent status", { error }); - return { - healthy: false, - logged_in: false - }; - } - } - - async listModels(): Promise { - log.info("Listing available cursor-agent models"); - - try { - const result = await this.execCommand(["--list-models"]); - return this.parseModelList(result.stdout); - } catch (error) { - log.warn("Failed to list models, returning defaults", { error }); - return [ - { id: "auto", name: "Default", description: "Cursor's default model" } - ]; - } - } - - private parseUsageOutput(stdout: string): CursorUsageStats { - // Parse JSON output from cursor-agent --usage - try { - const data = JSON.parse(stdout); - return { - totalPrompts: data.total_prompts || 0, - totalTokens: data.total_tokens || 0, - totalDuration: data.total_duration || 0, - modelBreakdown: data.model_breakdown || {} - }; - } catch { - // If not JSON, return empty stats - return { - totalPrompts: 0, - totalTokens: 0, - totalDuration: 0, - modelBreakdown: {} - }; - } - } - - private extractVersion(stdout: string): string | undefined { - const match = stdout.match(/cursor-agent version (\d+\.\d+\.\d+)/i); - return match ? match[1] : undefined; - } - - private parseModelList(stdout: string): CursorModel[] { - try { - const data = JSON.parse(stdout); - if (Array.isArray(data.models)) { - return data.models.map((m: any) => ({ - id: m.id || m.name, - name: m.name, - description: m.description - })); - } - return []; - } catch { - // If not JSON, return empty list - return []; - } - } -} -``` - -**Step 2: Commit** - -```bash -git add src/acp/cursor.ts -git commit -m "feat: implement CursorNativeWrapper for native features" -``` - ---- - -### Task 7: Main ACP Agent Implementation - -**Files:** -- Create: `src/acp/agent.ts` -- Modify: `src/index.ts` (wrapper for backward compatibility) - -**Step 1: Write failing integration test** - -Create file: `tests/integration/agent.test.ts` - -```typescript -import { describe, it, expect, beforeEach, afterEach } from "bun:test"; - -describe("CursorAcpHybridAgent Integration", () => { - it("should initialize with ACP capabilities", async () => { - expect(true).toBe(true); - }); - - it("should create session and return session ID", async () => { - expect(true).toBe(true); - }); - - it("should handle prompt with streaming", async () => { - expect(true).toBe(true); - }); -}); -``` - -**Step 2: Run test to verify it fails** - -```bash -bun test tests/integration/agent.test.ts -``` - -Expected: FAIL with "CursorAcpHybridAgent not defined" - -**Step 3: Implement CursorAcpHybridAgent main class** - -Create file: `src/acp/agent.ts` - -```typescript -import type { - Agent, - InitializeRequest, - InitializeResponse, - NewSessionRequest, - NewSessionResponse, - PromptRequest, - PromptResponse, - CancelNotification, - SetSessionModeRequest, - SetSessionModeResponse, - AvailableCommand -} from "@agentclientprotocol/sdk"; - -import { AgentSideConnection } from "@agentclientprotocol/sdk"; -import type { SessionState } from "./types.js"; -import { SessionManager } from "./sessions.js"; -import { RetryEngine, type RetryContext } from "./retry.js"; -import { ToolMapper } from "./tools.js"; -import { MetricsTracker } from "./metrics.js"; -import { CursorNativeWrapper } from "./cursor.js"; -import { createLogger } from "./logger.js"; -import { spawn } from "child_process"; -import * as readline from "node:readline"; - -const log = createLogger("CursorAcpAgent"); - -export class CursorAcpHybridAgent implements Agent { - private client: AgentSideConnection; - private sessions: SessionManager; - private retry: RetryEngine; - private tools: ToolMapper; - private metrics: MetricsTracker; - private cursor: CursorNativeWrapper; - - constructor(client: AgentSideConnection) { - this.client = client; - this.sessions = new SessionManager(); - this.retry = new RetryEngine({ maxRetries: 3, baseDelayMs: 1000, maxDelayMs: 30000 }); - this.tools = new ToolMapper(); - this.metrics = new MetricsTracker(); - this.cursor = new CursorNativeWrapper(); - - log.info("Agent initialized"); - } - - async initialize(req: InitializeRequest): Promise { - log.info("Initializing agent", { clientCapabilities: req.clientCapabilities }); - - await this.sessions.initialize(); - - return { - protocolVersion: 1, - agentCapabilities: { - promptCapabilities: { image: false, embeddedContext: true }, - }, - authMethods: [ - { - id: "cursor-login", - name: "Log in with Cursor Agent", - description: "Run `cursor-agent login` in your terminal", - }, - ], - }; - } - - async newSession(params: NewSessionRequest): Promise { - log.info("Creating new session", { cwd: params.cwd }); - - const session = await this.sessions.createSession({ - cwd: params.cwd, - modeId: "default" - }); - - const models = { - availableModels: [{ modelId: "default", name: "Default", description: "Cursor default" }], - currentModelId: "default" - }; - - const availableCommands: AvailableCommand[] = []; - - setTimeout(() => { - this.client.sessionUpdate({ - sessionId: session.id, - update: { sessionUpdate: "available_commands_update", availableCommands } - }); - }, 0); - - const modes = [ - { id: "default", name: "Always Ask", description: "Normal behavior" }, - { id: "plan", name: "Plan Mode", description: "Analyze only; avoid edits and commands" } - ]; - - return { sessionId: session.id, models, modes: { currentModeId: "default", availableModes: modes } }; - } - - async prompt(params: PromptRequest): Promise { - log.info("Handling prompt", { sessionId: params.sessionId }); - - const session = await this.sessions.getSession(params.sessionId); - if (!session) { - throw new Error(`Session not found: ${params.sessionId}`); - } - - session.cancelled = false; - const modeId = session.modeId; - - const planPrefix = modeId === "plan" - ? "[PLAN MODE] Do not edit files or run commands. Analyze only.\n\n" - : ""; - - const initialPrompt = planPrefix + this.concatPromptChunks(params.prompt); - - const args = [ - "--print", - "--output-format", - "stream-json", - "--stream-partial-output", - "--model", - "auto", - "--workspace", - session.cwd || process.cwd() - ]; - - if (session.resumeId) { - args.push("--resume", session.resumeId); - } - - if (initialPrompt.length > 0) { - args.push(initialPrompt); - } - - const stopReason = await this.retry.executeWithRetry( - () => this.executePromptWithCursor(args, params.sessionId, session), - { operation: "prompt", sessionId: params.sessionId } - ); - - if (session.resumeId && !session.cancelled) { - await this.sessions.updateSession(session.id, { lastActivity: Date.now() }); - } - - return { stopReason }; - } - - private async executePromptWithCursor( - args: string[], - sessionId: string, - session: SessionState - ): Promise { - const agentPath = process.env.CURSOR_AGENT_EXECUTABLE || "cursor-agent"; - - const child = spawn(agentPath, args, { - cwd: session.cwd || process.cwd(), - stdio: ["ignore", "pipe", "pipe"] - }); - - if (!child.stdout) { - throw new Error("Failed to spawn cursor-agent"); - } - - let stopReason: PromptResponse["stopReason"] | undefined; - const rl = readline.createInterface({ input: child.stdout }); - - rl.on("line", async (line) => { - if (session.cancelled) return; - - try { - const evt = JSON.parse(line); - for (const update of await this.tools.mapCursorEventToAcp(evt, sessionId)) { - this.client.sessionUpdate({ - sessionId, - update - }); - } - - if (evt.session_id && !session.resumeId) { - await this.sessions.setResumeId(sessionId, evt.session_id); - } - - if (evt.type === "result") { - if (evt.subtype === "success") stopReason = "end_turn"; - else if (evt.subtype === "cancelled") stopReason = "cancelled"; - else if (evt.subtype === "error" || evt.subtype === "failure" || evt.subtype === "refused") stopReason = "refusal"; - } - } catch (e) { - log.debug("Ignoring non-JSON line"); - } - }); - - const done = new Promise((resolve) => { - let exited = false; - let exitCode: number | null = null; - - const finalize = () => { - if (session.cancelled) return resolve("cancelled"); - if (stopReason) return resolve(stopReason); - resolve(exitCode === 0 ? "end_turn" : "refusal"); - }; - - child.on("exit", (code) => { - exited = true; - exitCode = code ?? null; - finalize(); - }); - - setTimeout(() => { - if (!exited) return; - finalize(); - }, 300); - }); - - rl.on("close", () => { - setTimeout(finalize, 100); - }); - - return done; - } - - async cancel(params: CancelNotification): Promise { - log.info("Cancelling prompt", { sessionId: params.sessionId }); - - const session = await this.sessions.getSession(params.sessionId); - if (!session) { - throw new Error(`Session not found: ${params.sessionId}`); - } - - this.sessions.markCancelled(params.sessionId); - const runningChild = session.running; - - if (runningChild && !runningChild.killed) { - try { - runningChild.kill("SIGTERM"); - setTimeout(() => runningChild.kill("SIGKILL"), 1000); - log.info("Sent SIGTERM, will SIGKILL in 1s if needed"); - } catch (error) { - log.error("Failed to kill cursor-agent", { error }); - } - } - } - - async setSessionMode(params: SetSessionModeRequest): Promise { - log.info("Setting session mode", { sessionId: params.sessionId, modeId: params.modeId }); - - const session = await this.sessions.getSession(params.sessionId); - if (!session) { - throw new Error(`Session not found: ${params.sessionId}`); - } - - await this.sessions.updateSession(params.sessionId, { modeId: params.modeId }); - - this.client.sessionUpdate({ - sessionId: params.sessionId, - update: { sessionUpdate: "current_mode_update", currentModeId: params.modeId } - }); - - return {}; - } - - private concatPromptChunks(prompt: PromptRequest["prompt"]): string { - const parts: string[] = []; - for (const chunk of prompt) { - if (chunk.type === "text") parts.push(chunk.text); - else if (chunk.type === "resource" && "text" in chunk.resource) parts.push(chunk.resource.text as string); - else if (chunk.type === "resource_link") parts.push(chunk.uri); - } - return parts.join("\n\n"); - } -} -``` - -**Step 4: Create backward-compatible entry point** - -Modify file: `src/index.ts` - -```typescript -import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"; -import { CursorAcpHybridAgent } from "./acp/agent.js"; -import { CursorNativeWrapper } from "./acp/cursor.js"; - -export function runAcp() { - const input = Writable.toWeb(process.stdout); - const output = Readable.toWeb(process.stdin); - const stream = ndJsonStream(input, output); - new AgentSideConnection((client: any) => new CursorAcpHybridAgent(client), stream); - - process.stdin.resume(); -} - -export { CursorAcpHybridAgent, CursorNativeWrapper }; -``` - -**Step 5: Run integration tests and verify they pass** - -```bash -bun test tests/integration/agent.test.ts -``` - -Expected: All tests pass - -**Step 6: Commit** - -```bash -git add src/acp/agent.ts src/index.ts tests/integration/agent.test.ts -git commit -m "feat: implement CursorAcpHybridAgent with ACP SDK" -``` - ---- - -### Task 8: Testing & Documentation - -**Files:** -- Modify: `README.md` -- Create: `docs/ACP_MIGRATION.md` -- Modify: `package.json` (scripts) - -**Step 1: Update package.json scripts** - -Modify file: `package.json` - -```json -{ - "name": "opencode-cursor", - "scripts": { - "build": "bun build ./src/index.ts --outdir ./dist --target node", - "dev": "bun build ./src/index.ts --outdir ./dist --target node --watch", - "test": "bun test", - "test:unit": "bun test tests/unit", - "test:integration": "bun test tests/integration" - } -} -``` - -**Step 2: Run all tests** - -```bash -bun test -``` - -Expected: All tests pass (unit + integration) - -**Step 3: Write ACP migration guide** - -Create file: `docs/ACP_MIGRATION.md` - -```markdown -# ACP Implementation Migration Guide - -## What Changed - -The plugin now uses the **Agent Client Protocol (ACP)** via @agentclientprotocol/sdk. This provides: - -- Full ACP compliance (works in Zed, JetBrains, neovim) -- Session persistence (survive crashes) -- Retry logic with exponential backoff -- Enhanced tool metadata (durations, diffs, locations) -- Cursor-native features (usage, status, models) - -## Backward Compatibility - -The old OpenCode-specific format is still available via `src/index.ts` entry point. ACP mode is the new default. - -## Migration for Users - -**No action required!** The plugin will automatically use ACP mode. - -If you encounter issues, you can verify ACP is working by checking logs for `[CursorAcpAgent:*]` prefix. - -## Configuration - -Optional environment variables: - -- `CURSOR_AGENT_EXECUTABLE` - Path to cursor-agent binary (default: "cursor-agent") -- `CURSOR_ACP_MAX_RETRIES` - Max retry attempts (default: 3) -- `CURSOR_ACP_BACKOFF_BASE_MS` - Base backoff delay (default: 1000) -- `CURSOR_ACP_SESSION_RETENTION_DAYS` - Session retention (default: 7) -``` - -**Step 4: Update README** - -Modify file: `README.md` - -Add new section after "Features": - -```markdown -## ACP Protocol - -This plugin implements the **Agent Client Protocol (ACP)** for universal compatibility. It works with: - -- ✅ OpenCode -- ✅ Zed -- ✅ JetBrains -- ✅ neovim (via avante.nvim plugin) -- ✅ AionUi -- ✅ marimo notebook - -### ACP Features - -- Full session management with persistence -- Mode switching (default, plan) -- Enhanced tool call metadata (durations, diffs, locations) -- Proper cancellation semantics -- Auth method negotiation - -### Session Persistence - -Sessions are automatically persisted to `~/.opencode/sessions/` and restored on plugin restart. This means: - -- Survive crashes -- Resume interrupted conversations -- Track session history - -### Retry Logic - -Recoverable errors (timeout, network, rate limit) are automatically retried with exponential backoff: -- Attempt 1: 1s delay -- Attempt 2: 2s delay -- Attempt 3: 4s delay - -Fatal errors (auth, invalid config) fail immediately with clear messages. -``` - -**Step 5: Run manual tests** - -```bash -# Build plugin -bun run build - -# Test with OpenCode (manual verification) -# - Start OpenCode -# - Select cursor-acp/auto model -# - Send a test prompt -# - Verify streaming works -# - Cancel a prompt (Ctrl+C) -# - Restart OpenCode and verify session persists -``` - -Expected: Manual tests pass successfully - -**Step 6: Commit** - -```bash -git add README.md docs/ACP_MIGRATION.md package.json -git commit -m "docs: add ACP migration guide and update README" -``` - ---- - -### Task 9: Final Polish & Release - -**Files:** -- Modify: `tsconfig.json` -- Modify: `.gitignore` - -**Step 1: Update TypeScript config** - -Modify file: `tsconfig.json` - -```json -{ - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "allowJs": true, - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true - }, - "include": [ - "src/**/*" - ], - "exclude": [ - "node_modules", - "tests" - ] -} -``` - -**Step 2: Add dist/ to .gitignore** - -Modify file: `.gitignore` - -```gitignore -node_modules -dist -*.log -.DS_Store -bun.lock -installer -``` - -**Step 3: Final test suite run** - -```bash -bun test -``` - -Expected: All tests pass - -**Step 4: Build verification** - -```bash -bun run build -ls -lh dist/ -``` - -Expected: `dist/index.js` and `dist/acp/*.js` files exist - -**Step 5: Final commit** - -```bash -git add tsconfig.json .gitignore -git commit -m "chore: finalize build configuration" -``` - -**Step 6: Create release notes** - -Create file: `docs/RELEASE_NOTES.md` - -```markdown -# Release Notes - -## v2.0.0 - ACP Implementation - -### New Features - -- ✅ Full Agent Client Protocol (ACP) compliance -- ✅ Class-based architecture (modular, testable) -- ✅ Session persistence (survive crashes) -- ✅ Retry logic with exponential backoff -- ✅ Enhanced tool metadata (durations, diffs, locations) -- ✅ Cursor-native features (usage, status, models) -- ✅ Structured logging for debugging -- ✅ Usage metrics tracking - -### Breaking Changes - -- None (backward compatible with v1.x via src/index.ts wrapper) - -### Migration - -- No action required (automatic) -- See `docs/ACP_MIGRATION.md` for details - -### Dependencies - -- Added: `@agentclientprotocol/sdk` -- Removed: None - -### Known Issues - -- None - -### Testing - -- Unit tests: 100% coverage -- Integration tests: All passing -- Manual testing: OpenCode, Zed verified -``` - -**Step 7: Tag release** - -```bash -git tag v2.0.0 -``` - ---- - -## Summary - -This plan implements a full ACP-compliant plugin with: - -1. **Modular architecture** - 7 core classes, each with single responsibility -2. **Robust infrastructure** - Session persistence, retry logic, structured logging -3. **Enhanced tool metadata** - Durations, diffs, locations for all tool calls -4. **Cursor-native features** - Usage, status, model discovery -5. **Comprehensive testing** - Unit + integration tests for all components -6. **Full documentation** - Migration guide, release notes - -Total estimated effort: **9 tasks**, 2-3 hours per task = **18-27 hours** - -**Next Step:** Choose execution approach: -1. Subagent-Driven (this session) - I dispatch fresh subagent per task, review between tasks, fast iteration -2. Parallel Session (separate) - Open new session in worktree with executing-plans skill, batch execution with checkpoints - -Which approach? diff --git a/docs/plans/2026-01-23-hybrid-acp-implementation-design.md b/docs/plans/2026-01-23-hybrid-acp-implementation-design.md deleted file mode 100644 index 62f5c38..0000000 --- a/docs/plans/2026-01-23-hybrid-acp-implementation-design.md +++ /dev/null @@ -1,662 +0,0 @@ -# Hybrid ACP Implementation Design - -**Date**: 2026-01-23 -**Status**: Brainstorming Complete - Ready for Implementation Planning -**Approach**: Hybrid SDK + Custom Extensions - ---- - -## Overview - -Implement full Agent Client Protocol (ACP) compliance with a hybrid architecture that combines: -- **SDK-based ACP core** for protocol correctness and compatibility -- **Cursor native extensions** for usage, status, and model discovery -- **Robust infrastructure** for session persistence, retry logic, and structured logging - -This approach improves on roshan-c/cursor-acp by adding session persistence, retry logic, enhanced tool metadata, and native cursor-agent feature support. - ---- - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ OpenCode Plugin │ -│ ┌────────────────────────────────────────────────────────┐ │ -│ │ CursorAcpHybridAgent (implements Agent) │ │ -│ │ │ │ -│ │ ┌──────────────────────────────────────────────┐ │ │ -│ │ │ ACP Core (@agentclientprotocol/sdk) │ │ │ -│ │ │ - initialize() │ │ │ -│ │ │ - newSession() │ │ │ -│ │ │ - prompt() │ │ │ -│ │ │ - cancel() │ │ │ -│ │ │ - setSessionMode() │ │ │ -│ │ └──────────────────────────────────────────────┘ │ │ -│ │ │ │ │ -│ │ ┌──────────────────────────────────────────────┐ │ │ -│ │ │ Cursor Native Extensions │ │ │ -│ │ │ - getUsage() │ │ │ -│ │ │ - getStatus() │ │ │ -│ │ │ - listModels() │ │ │ -│ │ │ - getSessionInfo() │ │ │ -│ │ └──────────────────────────────────────────────┘ │ │ -│ │ │ │ │ -│ │ ┌──────────────────────────────────────────────┐ │ │ -│ │ │ Robust Infrastructure │ │ │ -│ │ │ - SessionManager (persistent) │ │ │ -│ │ │ - RetryEngine (exponential backoff) │ │ │ -│ │ │ - ToolMapper (enhanced metadata) │ │ │ -│ │ │ - Logger (structured) │ │ │ -│ │ │ - MetricsTracker (usage) │ │ │ -│ │ └──────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ └────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ - ┌──────────────────────────┐ - │ cursor-agent │ - │ (subprocess) │ - └──────────────────────────┘ -``` - ---- - -## Key Design Principles - -1. **SDK for protocol correctness** - Don't reimplement ACP framing -2. **Native extensions for Cursor features** - Usage, status, models that cursor-agent supports -3. **Infrastructure for robustness** - Session persistence, retry logic, structured logging -4. **Class-based modularity** - Separate concerns, testable, maintainable -5. **Flawless with fallbacks** - Every operation has retry/recovery paths - ---- - -## Core Components - -### 1. CursorAcpHybridAgent (Main Entry Point) - -```typescript -class CursorAcpHybridAgent implements Agent { - // ACP SDK connection - private client: AgentSideConnection; - - // Infrastructure modules - private sessions: SessionManager; - private retry: RetryEngine; - private tools: ToolMapper; - private metrics: MetricsTracker; - private logger: Logger; - - // Cursor native extensions - private cursor: CursorNativeWrapper; - - constructor(client: AgentSideConnection) { - this.client = client; - this.sessions = new SessionManager(); - this.retry = new RetryEngine({ maxRetries: 3, backoffBase: 1000 }); - this.tools = new ToolMapper(); - this.metrics = new MetricsTracker(); - this.logger = createLogger("CursorAcpAgent"); - this.cursor = new CursorNativeWrapper(); - } - - // ACP-required methods (delegated to SDK) - async initialize(req: InitializeRequest): Promise - async newSession(params: NewSessionRequest): Promise - async prompt(params: PromptRequest): Promise - async cancel(params: CancelNotification): Promise - async setSessionMode(params: SetSessionModeRequest): Promise - - // Cursor-specific extensions (beyond ACP) - async getUsage(): Promise - async getStatus(): Promise -} -``` - -**Responsibilities:** -- Implements ACP Agent interface (protocol compliance) -- Orchestrates all infrastructure modules -- Provides Cursor-specific extensions -- Handles high-level error recovery - -### 2. SessionManager (Persistent Session Tracking) - -```typescript -class SessionManager { - private sessions: Map; - private storage: SessionStorage; // File-based persistence - - async createSession(params: NewSessionParams): Promise { - const id = crypto.randomUUID(); - const state: SessionState = { - id, - cwd: params.cwd, - mode: params.modeId || "default", - resumeId: undefined, - createdAt: Date.now(), - lastActivity: Date.now() - }; - - this.sessions.set(id, state); - await this.storage.save(id, state); - return state; - } - - async getSession(id: string): Promise - async updateSession(id: string, updates: Partial): Promise - async deleteSession(id: string): Promise - async loadPersistedSessions(): Promise - - // Resume capability - canResume(sessionId: string): boolean { - const session = this.sessions.get(sessionId); - return !!(session?.resumeId); - } -} -``` - -**Key Features:** -- Session persistence to disk (survive crashes) -- Resume ID tracking from cursor-agent -- In-memory cache for fast access -- Automatic cleanup of stale sessions - -### 3. RetryEngine (Exponential Backoff with Classification) - -```typescript -class RetryEngine { - private maxRetries: number; - private baseDelayMs: number; - private maxDelayMs: number; - - async executeWithRetry( - operation: () => Promise, - context: RetryContext - ): Promise { - let attempt = 0; - let lastError: Error | null = null; - - while (attempt < this.maxRetries) { - try { - const result = await operation(); - this.logger.info(`Success on attempt ${attempt + 1}`, { context }); - return result; - } catch (error) { - lastError = error; - - if (!this.isRecoverable(error, context)) { - this.logger.error(`Fatal error, not retrying`, { error, context }); - throw error; // Don't retry fatal errors - } - - attempt++; - const delay = this.calculateBackoff(attempt); - this.logger.warn(`Attempt ${attempt} failed, retrying in ${delay}ms`, { error }); - await this.sleep(delay); - } - } - - throw new Error(`Max retries (${this.maxRetries}) exceeded`, { cause: lastError }); - } - - private isRecoverable(error: Error, context: RetryContext): boolean { - // Recoverable: timeout, network, rate limit - if (error.message.includes("timeout")) return true; - if (error.message.includes("ECONNREFUSED")) return true; - if (error.message.includes("429")) return true; // Rate limit - - // Fatal: auth error, invalid config - if (error.message.includes("Not logged in")) return false; - if (error.message.includes("invalid model")) return false; - - return false; - } - - private calculateBackoff(attempt: number): number { - const delay = this.baseDelayMs * Math.pow(2, attempt - 1); - return Math.min(delay, this.maxDelayMs); - } -} -``` - -**Improvements over current:** -- Distinguishes recoverable vs fatal errors -- Exponential backoff (1s, 2s, 4s, 8s) -- Per-operation context (prompt, tool, auth) -- Logging at each retry attempt - -### 4. ToolMapper (Enhanced Metadata for Tools) - -```typescript -class ToolMapper { - async mapCursorEventToAcp( - evt: CursorAgentEvent, - sessionId: string - ): Promise { - switch (evt.type) { - case "tool_call": - return this.handleToolCall(evt, sessionId); - default: - return []; - } - } - - private async handleToolCall( - evt: CursorToolCallEvent, - sessionId: string - ): Promise { - const updates: AcpToolUpdate[] = []; - const callId = evt.call_id; - const toolKind = this.getToolKind(evt.tool_call); - const args = evt.tool_call[toolKind]?.args || {}; - - // Tool start (rich metadata) - updates.push({ - sessionId, - toolCallId: callId, - title: this.buildToolTitle(toolKind, args), - kind: this.inferToolType(toolKind), - status: "pending", - locations: this.extractLocations(args), - rawInput: JSON.stringify(args), - startTime: Date.now() - }); - - // Update to in_progress - updates.push({ - sessionId, - toolCallId: callId, - status: "in_progress" - }); - - // Tool completion (with diffs) - if (evt.subtype === "completed") { - const result = evt.tool_call[toolKind]?.result; - const update = await this.buildCompletionUpdate(callId, toolKind, args, result); - updates.push(update); - } - - return updates; - } - - private async buildCompletionUpdate( - callId: string, - toolKind: string, - args: any, - result: any - ): Promise { - const update: AcpToolUpdate = { - sessionId: "", // Filled later - toolCallId: callId, - status: result?.error ? "failed" : "completed", - rawOutput: JSON.stringify(result), - endTime: Date.now(), - durationMs: result?.endTime - result?.startTime - }; - - // Add locations from result - const locations = this.extractResultLocations(result); - if (locations) update.locations = locations; - - // Add content based on tool type - if (toolKind === "writeToolCall") { - update.content = [{ - type: "diff", - path: args.path, - oldText: result.oldText || null, - newText: result.newText || args.fileText - }]; - } else if (toolKind === "bashToolCall" || toolKind === "shellToolCall") { - const output = result.output || ""; - const exitCode = result.exitCode; - const text = exitCode !== undefined - ? `Exit code: ${exitCode}\n${output || "(no output)"}` - : output || "(no output)"; - update.content = [{ - type: "content", - content: { type: "text", text: "```\n" + text + "\n```" } - }]; - } - - return update; - } -} -``` - -**Enhancements over roshan-c:** -- Duration tracking (start/end times) -- Diff rendering for write operations -- Better bash output formatting -- Location extraction from both args and results -- Comprehensive tool coverage - -### 5. CursorNativeWrapper (Native Cursor-Agent Features) - -```typescript -class CursorNativeWrapper { - private agentPath: string; - - constructor() { - this.agentPath = process.env.CURSOR_AGENT_EXECUTABLE || "cursor-agent"; - } - - async getUsage(): Promise { - // Check cursor-agent's native usage tracking if available - const result = await this.execCommand([ "--usage" ]); - return this.parseUsageOutput(result.stdout); - } - - async getStatus(): Promise { - // Check if cursor-agent is healthy - const result = await this.execCommand([ "--status" ]); - return { - healthy: result.exitCode === 0, - version: this.extractVersion(result.stdout), - logged_in: result.stdout.includes("logged in") - }; - } - - async listModels(): Promise { - // Query cursor-agent's available models (dynamic discovery) - const result = await this.execCommand([ "--list-models" ]); - return this.parseModelList(result.stdout); - } - - private async execCommand(args: string[]): Promise<{ exitCode: number, stdout: string, stderr: string }> { - const child = spawn(this.agentPath, args, { stdio: ["pipe", "pipe", "pipe"] }); - // ... implementation - } -} -``` - -**Features:** -- Native usage queries (tokens, costs, rate limits) -- Health checks and version info -- Dynamic model discovery -- Leverages cursor-agent's built-in capabilities - -### 6. MetricsTracker (Usage Analytics) - -```typescript -class MetricsTracker { - private metrics: Map; - - recordPrompt(sessionId: string, model: string, promptTokens: number) { - const metrics: PromptMetrics = { - sessionId, - model, - promptTokens, - toolCalls: 0, - duration: 0, - timestamp: Date.now() - }; - this.metrics.set(sessionId, metrics); - } - - recordToolCall(sessionId: string, toolName: string, durationMs: number) { - const metrics = this.metrics.get(sessionId); - if (metrics) { - metrics.toolCalls++; - metrics.duration += durationMs; - } - } - - getSessionMetrics(sessionId: string): PromptMetrics | undefined { - return this.metrics.get(sessionId); - } - - getAggregateMetrics(lastHours: number = 24): AggregateMetrics { - // Calculate usage over time window - const cutoff = Date.now() - (lastHours * 60 * 60 * 1000); - const relevant = Array.from(this.metrics.values()).filter(m => m.timestamp >= cutoff); - - return { - totalPrompts: relevant.length, - totalToolCalls: relevant.reduce((sum, m) => sum + m.toolCalls, 0), - totalDuration: relevant.reduce((sum, m) => sum + m.duration, 0), - avgDuration: relevant.length > 0 - ? relevant.reduce((sum, m) => sum + m.duration, 0) / relevant.length - : 0 - }; - } -} -``` - -### 7. Logger (Structured Logging) - -```typescript -function createLogger(prefix: string) { - return { - debug: (msg: string, meta?: any) => - console.error(`[${prefix}] ${msg}`, meta), - info: (msg: string, meta?: any) => - console.error(`[${prefix}] ${msg}`, meta), - warn: (msg: string, meta?: any) => - console.error(`[${prefix}] ${msg}`, meta), - error: (msg: string, err?: any) => - console.error(`[${prefix}] ${msg}`, err) - }; -} -``` - ---- - -## Data Flow - -### Prompt Request Flow - -``` -1. OpenCode calls Agent.newSession() - ├─> SessionManager.createSession() - │ └─> Persist to disk - └─> Return session ID to OpenCode - -2. OpenCode calls Agent.prompt() - ├─> RetryEngine.executeWithRetry(() => prompt()) - │ ├─> Attempt 1: Spawn cursor-agent - │ │ ├─> Send prompt via stdin - │ │ ├─> Parse NDJSON from stdout - │ │ ├─> ToolMapper.mapCursorEventToAcp() - │ │ └─> client.sessionUpdate() for each event - │ ├─> If success: return - │ └─> If error: Check recoverable - │ ├─> Recoverable: Retry with exponential backoff - │ └─> Fatal: Throw to OpenCode - └─> Send final stopReason -``` - -### Cancellation Flow - -``` -1. OpenCode calls Agent.cancel() - ├─> SessionManager.getSession(sessionId) - ├─> Update session.cancelled = true - └─> Kill cursor-agent process (SIGTERM + 300ms SIGKILL) - -2. cursor-agent exits - ├─> Detect exit in prompt handler - ├─> Check session.cancelled flag - ├─> Wait 300ms for all updates to flush - └─> Return { stopReason: "cancelled" } -``` - ---- - -## Error Handling Strategy - -### Error Classification - -| Error Type | Recoverable | Action | Retry Policy | -|-------------|--------------|---------|--------------| -| **Timeout** | ✅ | Retry with backoff | Max 3 attempts, 1s→2s→4s | -| **Network (ECONNREFUSED)** | ✅ | Retry with backoff | Max 3 attempts | -| **Rate Limit (429)** | ✅ | Retry with backoff | Max 3 attempts | -| **Not logged in** | ❌ | Don't retry | Return auth error | -| **Invalid model** | ❌ | Don't retry | Return config error | -| **Process crash (exit ≠ 0)** | ❌ | Don't retry | Return fatal error | - -### Fallback Hierarchy - -``` -Primary: Try with current cursor-agent session - └─> If fail with recoverable error: - Secondary: Retry with exponential backoff (3x) - └─> If still fail: - Tertiary: Try with new session (no resume) - └─> If still fail: - Fatal: Return error with context and suggestions -``` - ---- - -## Testing Strategy - -### Unit Tests -- SessionManager: create, retrieve, update, delete, persistence -- RetryEngine: backoff calculation, error classification, max retries -- ToolMapper: event parsing, location extraction, diff generation -- MetricsTracker: record, aggregate, time window filtering - -### Integration Tests -- Full prompt flow (spawn, stream, parse, return) -- Cancellation flow (SIGTERM, flush, stopReason) -- Session persistence (write to disk, read back, resume) -- Retry flow (trigger recoverable error, verify backoff timing) - -### Manual Tests -- Install in fresh OpenCode instance -- Run multi-turn conversation -- Trigger timeout (cancel long prompt) -- Crash recovery (kill plugin, restart, verify session resume) -- Verify in Zed/JetBrains (ACP compatibility) - ---- - -## Implementation Phases - -### Phase 1: Infrastructure Setup (Day 1) -- [ ] Create directory structure (src/, src/modules/, tests/) -- [ ] Set up TypeScript config -- [ ] Add @agentclientprotocol/sdk dependency -- [ ] Implement Logger module -- [ ] Implement SessionStorage (file-based) - -### Phase 2: Core Components (Days 2-3) -- [ ] Implement SessionManager -- [ ] Implement RetryEngine -- [ ] Implement MetricsTracker -- [ ] Write unit tests for each - -### Phase 3: ACP Integration (Days 4-5) -- [ ] Implement CursorAcpHybridAgent skeleton -- [ ] Wire up ACP SDK connection -- [ ] Implement initialize(), newSession(), prompt() stubs -- [ ] Test basic ACP flow with mock cursor-agent - -### Phase 4: Cursor Native (Day 6) -- [ ] Implement CursorNativeWrapper -- [ ] Add getUsage(), getStatus(), listModels() -- [ ] Integrate with main agent - -### Phase 5: Tool Mapping (Days 7-8) -- [ ] Implement ToolMapper -- [ ] Add enhanced metadata (duration, locations, diffs) -- [ ] Test with real cursor-agent tool events - -### Phase 6: Full Integration (Days 9-10) -- [ ] Connect all components in CursorAcpHybridAgent -- [ ] Implement full prompt flow with streaming -- [ ] Add cancellation handling -- [ ] Add session persistence on each prompt - -### Phase 7: Testing & Polish (Days 11-12) -- [ ] Write comprehensive unit tests -- [ ] Write integration tests -- [ ] Manual testing in OpenCode -- [ ] Manual testing in Zed/JetBrains -- [ ] Performance profiling -- [ ] Documentation updates - ---- - -## Success Criteria - -- ✅ All ACP protocol methods implemented -- ✅ Session persistence works across plugin restarts -- ✅ Retry logic handles recoverable errors correctly -- ✅ Tool calls include enhanced metadata (duration, diffs, locations) -- ✅ Cursor native features (usage, status) accessible -- ✅ Structured logging enabled for debugging -- ✅ Tests pass (unit + integration) -- ✅ Works in OpenCode, Zed, JetBrains (ACP compatibility) -- ✅ Fallback behavior tested and documented - ---- - -## Risks & Mitigations - -| Risk | Impact | Mitigation | -|-------|----------|-------------| -| **ACP SDK changes** | Breaking protocol changes | Pin SDK version, monitor releases | -| **cursor-agent API changes** | Feature breakage | Feature detection, graceful degradation | -| **Session persistence corruption** | Lost sessions | JSON validation, backup strategy | -| **Performance overhead** | Slower than current plugin | Profile, optimize hot paths | -| **Complexity increase** | Harder to maintain | Modular design, clear separation of concerns | -| **Testing matrix expansion** | More clients to test | Prioritize OpenCode, add others gradually | - ---- - -## Open Questions - -1. **Metrics storage** - Where to persist metrics? (File system vs SQLite vs in-memory only) -2. **Session cleanup** - How long to keep stale sessions? (24h? 7 days?) -3. **Feature flags** - Should we have flags to disable features? (e.g., NO_PERSISTENCE) -4. **Backward compatibility** - Should we maintain old OpenCode custom format as fallback? -5. **Error reporting** - How detailed should errors be? (Stack traces? User-friendly messages?) - ---- - -## Dependencies - -- `@agentclientprotocol/sdk` - ACP protocol implementation -- `crypto` - UUID generation (Node 18+ native, polyfill otherwise) -- TypeScript - Type safety -- Bun or Node.js - Runtime - ---- - -## File Structure - -``` -src/ -├── index.ts # Entry point (for backward compatibility) -├── acp/ -│ ├── agent.ts # CursorAcpHybridAgent -│ ├── sessions.ts # SessionManager, SessionStorage -│ ├── retry.ts # RetryEngine -│ ├── tools.ts # ToolMapper -│ ├── cursor.ts # CursorNativeWrapper -│ ├── metrics.ts # MetricsTracker -│ └── logger.ts # createLogger utility -├── types.ts # Shared types -└── config.ts # Configuration constants - -tests/ -├── unit/ -│ ├── sessions.test.ts -│ ├── retry.test.ts -│ ├── tools.test.ts -│ └── metrics.test.ts -└── integration/ - └── agent.test.ts - -docs/ -└── plans/ - └── 2026-01-23-hybrid-acp-implementation-design.md -``` - ---- - -**Next Step**: Use writing-plans skill to create detailed implementation plan from this design. diff --git a/docs/plans/2026-01-28-opencode-cursor-fixes.md b/docs/plans/2026-01-28-opencode-cursor-fixes.md deleted file mode 100644 index 74fffab..0000000 --- a/docs/plans/2026-01-28-opencode-cursor-fixes.md +++ /dev/null @@ -1,1062 +0,0 @@ -# OpenCode Cursor Plugin Fixes Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use @superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Fix critical issues in the opencode-cursor plugin identified in the audit: missing modules, broken error handling, test failures, and OpenCode integration problems. - -**Architecture:** Implement missing ACP (Agent Capability Protocol) modules (sessions, tools, metrics), fix logger and error handling in SimpleCursorClient, consolidate duplicate provider implementations, and fix package configuration. - -**Tech Stack:** TypeScript, Bun (testing & building), AI SDK (`ai` package), OpenCode plugin system, Node.js child_process - ---- - -## Task 1: Fix Package.json Configuration - -**Files:** -- Modify: `package.json` - -**Step 1: Remove self-dependency and fix dependencies** - -Remove the circular dependency `"opencode-cursor": "^0.0.1"` from dependencies. -Remove unused `@ai-sdk/openai-compatible` from devDependencies. -Add `@opencode-ai/sdk` as a peerDependency. - -```json -{ - "name": "opencode-cursor", - "version": "2.0.0", - "description": "OpenCode plugin for Cursor Agent via stdin (fixes E2BIG errors)", - "type": "module", - "main": "dist/index.js", - "scripts": { - "build": "bun build ./src/index.ts --outdir ./dist --target node", - "dev": "bun build ./src/index.ts --outdir ./dist --target node --watch", - "test": "bun test", - "test:unit": "bun test tests/unit", - "test:integration": "bun test tests/integration", - "prepublishOnly": "bun run build" - }, - "exports": { - ".": "./dist/index.js" - }, - "files": [ - "dist" - ], - "devDependencies": { - "@opencode-ai/sdk": "latest", - "@types/node": "^22.0.0", - "ai": "^6.0.55", - "typescript": "^5.8.0" - }, - "peerDependencies": { - "@opencode-ai/sdk": "^1.0.0", - "bun-types": "latest" - }, - "license": "ISC" -} -``` - -**Step 2: Verify package.json is valid JSON** - -Run: `cat package.json | jq .` -Expected: Valid JSON output without errors - -**Step 3: Commit** - -```bash -git add package.json -git commit -m "fix: remove circular dependency and clean up package.json" -``` - ---- - -## Task 2: Fix Logger Utility - -**Files:** -- Modify: `src/utils/logger.ts` - -**Step 1: Fix console method selection** - -Replace line 23-24 which always uses `console.error`: - -```typescript -const LOG_LEVELS: Record = { - 'none': 0, - 'error': 1, - 'warn': 2, - 'info': 3, - 'debug': 4 -}; - -const CONSOLE_METHODS: Record = { - 'none': 'info', - 'error': 'error', - 'warn': 'warn', - 'info': 'info', - 'debug': 'debug' -}; - -export function logger(module: string, level: LogLevel = 'info'): Logger { - const currentLevel = LOG_LEVELS[level]; - - const log = (prefix: string, message: string, meta?: unknown) => { - const formatted = JSON.stringify({ module, message, ...(meta ? { meta } : {}) }); - const consoleMethod = CONSOLE_METHODS[prefix as LogLevel] || 'log'; - console[consoleMethod](`[cursor:${module}] ${prefix.toUpperCase()} ${formatted}`); - }; - - return { - debug: (message: string, meta?: unknown) => { - if (currentLevel >= LOG_LEVELS.debug) log('debug', message, meta); - }, - info: (message: string, meta?: unknown) => { - if (currentLevel >= LOG_LEVELS.info) log('info', message, meta); - }, - warn: (message: string, meta?: unknown) => { - if (currentLevel >= LOG_LEVELS.warn) log('warn', message, meta); - }, - error: (message: string, error?: unknown) => { - if (currentLevel >= LOG_LEVELS.error) log('error', message, error); - } - }; -} -``` - -**Step 2: Test the logger** - -Run: `bun -e "import { logger } from './src/utils/logger.js'; const log = logger('test', 'debug'); log.info('test message'); log.error('error message', new Error('test'));"` -Expected: Output showing different log levels with correct console methods - -**Step 3: Commit** - -```bash -git add src/utils/logger.ts -git commit -m "fix: use correct console methods for each log level" -``` - ---- - -## Task 3: Fix Error Handling in SimpleCursorClient - -**Files:** -- Modify: `src/client/simple.ts` - -**Step 1: Add proper error handling to streamText method** - -Replace the empty catch block at line 27: - -```typescript -async *executePromptStream(prompt: string, options: { - cwd?: string; - model?: string; - mode?: 'default' | 'plan' | 'ask'; - resumeId?: string; -} = {}): AsyncGenerator { - const { - cwd = process.cwd(), - model = 'auto', - mode = 'default', - resumeId - } = options; - - const args = [ - '--print', - '--output-format', - 'stream-json', - '--stream-partial-output', - '--model', - model - ]; - - if (mode === 'plan') { - args.push('--plan'); - } else if (mode === 'ask') { - args.push('--mode', 'ask'); - } - - if (resumeId) { - args.push('--resume', resumeId); - } - - this.log.info('Executing prompt stream', { promptLength: prompt.length, mode, model }); - - const child = spawn(this.config.cursorAgentPath, args, { - cwd, - stdio: ['pipe', 'pipe', 'pipe'] - }); - - if (prompt) { - child.stdin.write(prompt); - child.stdin.end(); - } - - const lines: string[] = []; - let buffer = ''; - let processError: Error | null = null; - - child.stdout.on('data', (data) => { - buffer += data.toString(); - const newLines = buffer.split('\n'); - buffer = newLines.pop() || ''; - - for (const line of newLines) { - if (line.trim()) { - lines.push(line.trim()); - } - } - }); - - child.stderr.on('data', (data) => { - const errorMsg = data.toString(); - this.log.error('cursor-agent stderr', { error: errorMsg }); - processError = new Error(errorMsg); - }); - - const streamEnded = new Promise((resolve) => { - child.on('close', (code) => { - if (buffer.trim()) { - lines.push(buffer.trim()); - } - if (code !== 0) { - this.log.error('cursor-agent exited with non-zero code', { code }); - if (!processError) { - processError = new Error(`cursor-agent exited with code ${code}`); - } - } - resolve(code); - }); - - child.on('error', (error) => { - this.log.error('cursor-agent process error', { error: error.message }); - processError = error; - resolve(null); - }); - }); - - // Wait for process to complete before yielding - const exitCode = await streamEnded; - - if (processError) { - throw processError; - } - - for (const line of lines) { - // Validate JSON before yielding - try { - JSON.parse(line); - yield line; - } catch (parseError) { - this.log.warn('Invalid JSON from cursor-agent', { line: line.substring(0, 100) }); - // Skip invalid lines but continue processing - } - } -} -``` - -**Step 2: Add timeout to executePrompt** - -Verify timeout is properly handled in `executePrompt` (lines 164-167): -The timeout code exists but verify it's working: - -```typescript -const timeout = setTimeout(() => { - child.kill('SIGTERM'); - reject(new Error(`Timeout after ${this.config.timeout}ms`)); -}, this.config.timeout); -``` - -**Step 3: Add input validation** - -Add at the beginning of both methods: - -```typescript -if (!prompt || typeof prompt !== 'string') { - throw new Error('Invalid prompt: must be a non-empty string'); -} -``` - -**Step 4: Run tests to verify changes don't break anything** - -Run: `bun test tests/unit 2>&1 | head -50` -Expected: Tests may still fail due to missing modules, but no new failures from our changes - -**Step 5: Commit** - -```bash -git add src/client/simple.ts -git commit -m "fix: add proper error handling and input validation to SimpleCursorClient" -``` - ---- - -## Task 4: Create Missing ACP Sessions Module - -**Files:** -- Create: `src/acp/sessions.ts` -- Modify: `tests/unit/sessions.test.ts` (if needed) - -**Step 1: Create the sessions module** - -```typescript -export interface Session { - id: string; - cwd: string; - modeId?: string; - cancelled?: boolean; - resumeId?: string; - createdAt: number; - updatedAt: number; -} - -export interface SessionCreateOptions { - cwd?: string; - modeId?: string; -} - -export class SessionManager { - private sessions: Map = new Map(); - private storagePath?: string; - - async initialize(): Promise { - // In-memory only for now - this.sessions.clear(); - } - - async createSession(options: SessionCreateOptions): Promise { - const id = `session-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; - const session: Session = { - id, - cwd: options.cwd || process.cwd(), - modeId: options.modeId, - cancelled: false, - createdAt: Date.now(), - updatedAt: Date.now() - }; - - this.sessions.set(id, session); - return session; - } - - async getSession(id: string): Promise { - return this.sessions.get(id) || null; - } - - async updateSession(id: string, updates: Partial): Promise { - const session = this.sessions.get(id); - if (session) { - Object.assign(session, updates, { updatedAt: Date.now() }); - } - } - - async deleteSession(id: string): Promise { - this.sessions.delete(id); - } - - isCancelled(id: string): boolean { - const session = this.sessions.get(id); - return session?.cancelled || false; - } - - markCancelled(id: string): void { - const session = this.sessions.get(id); - if (session) { - session.cancelled = true; - session.updatedAt = Date.now(); - } - } - - canResume(id: string): boolean { - const session = this.sessions.get(id); - return !!session?.resumeId; - } - - setResumeId(id: string, resumeId: string): void { - const session = this.sessions.get(id); - if (session) { - session.resumeId = resumeId; - session.updatedAt = Date.now(); - } - } -} -``` - -**Step 2: Verify the module exports correctly** - -Run: `bun -e "import { SessionManager } from './src/acp/sessions.js'; console.log('SessionManager imported successfully');"` -Expected: "SessionManager imported successfully" - -**Step 3: Run the sessions test** - -Run: `bun test tests/unit/sessions.test.ts 2>&1` -Expected: All tests pass - -**Step 4: Commit** - -```bash -git add src/acp/sessions.ts -git commit -m "feat: implement SessionManager for ACP session management" -``` - ---- - -## Task 5: Create Missing ACP Tools Module - -**Files:** -- Create: `src/acp/tools.ts` -- Test: `tests/unit/tools.test.ts` (already exists) - -**Step 1: Create the tools module** - -```typescript -export interface ToolUpdate { - sessionId: string; - toolCallId: string; - title?: string; - kind?: 'read' | 'write' | 'edit' | 'search' | 'execute' | 'other'; - status?: 'pending' | 'in_progress' | 'completed' | 'failed'; - locations?: Array<{ path: string; line?: number }>; - content?: Array<{ type: string; [key: string]: unknown }>; - rawOutput?: string; - startTime?: number; - endTime?: number; -} - -interface CursorEvent { - type: string; - call_id?: string; - tool_call_id?: string; - subtype?: 'started' | 'completed' | 'failed'; - tool_call?: { - [key: string]: { - args?: Record; - result?: Record; - }; - }; -} - -export class ToolMapper { - async mapCursorEventToAcp(event: CursorEvent, sessionId: string): Promise { - if (event.type !== 'tool_call') { - return []; - } - - const updates: ToolUpdate[] = []; - const toolCallId = event.call_id || event.tool_call_id || 'unknown'; - const subtype = event.subtype || 'started'; - - const toolTypes = this.inferToolTypes(event.tool_call || {}); - - // First update: tool started/pending - updates.push({ - sessionId, - toolCallId, - title: this.buildToolTitle(event.tool_call || {}), - kind: toolTypes.kind, - status: subtype === 'started' ? 'pending' : 'in_progress', - locations: this.extractLocations(event.tool_call || {}), - startTime: subtype === 'started' ? Date.now() : undefined - }); - - // Second update for in_progress - if (subtype === 'started') { - updates.push({ - sessionId, - toolCallId, - status: 'in_progress' - }); - } - - // Completed/failed update - if (subtype === 'completed' || subtype === 'failed') { - const result = this.extractResult(event.tool_call || {}); - updates.push({ - sessionId, - toolCallId, - status: result.error ? 'failed' : 'completed', - content: result.content, - locations: result.locations || this.extractLocations(event.tool_call || {}), - rawOutput: result.rawOutput, - endTime: Date.now() - }); - } - - return updates; - } - - private inferToolTypes(toolCall: Record): { kind: ToolUpdate['kind'] } { - const keys = Object.keys(toolCall); - - for (const key of keys) { - if (key.includes('read')) return { kind: 'read' }; - if (key.includes('write')) return { kind: 'edit' }; - if (key.includes('grep')) return { kind: 'search' }; - if (key.includes('glob')) return { kind: 'search' }; - if (key.includes('bash') || key.includes('shell')) return { kind: 'execute' }; - } - - return { kind: 'other' }; - } - - private buildToolTitle(toolCall: Record): string { - const keys = Object.keys(toolCall); - - for (const key of keys) { - const tool = toolCall[key] as { args?: Record } | undefined; - const args = tool?.args || {}; - - if (key.includes('read') && args.path) return `Read ${args.path}`; - if (key.includes('write') && args.path) return `Write ${args.path}`; - if (key.includes('grep')) { - const pattern = args.pattern || 'pattern'; - const path = args.path; - return path ? `Search ${path} for ${pattern}` : `Search for ${pattern}`; - } - if (key.includes('glob') && args.pattern) return `Glob ${args.pattern}`; - if ((key.includes('bash') || key.includes('shell')) && (args.command || args.cmd)) { - return `\`${args.command || args.cmd}\``; - } - } - - return 'other'; - } - - private extractLocations(toolCall: Record): ToolUpdate['locations'] { - const keys = Object.keys(toolCall); - - for (const key of keys) { - const tool = toolCall[key] as { args?: Record } | undefined; - const args = tool?.args || {}; - - if (args.path) { - if (typeof args.path === 'string') { - return [{ path: args.path, line: args.line as number | undefined }]; - } - if (Array.isArray(args.path)) { - return args.path.map((p: string | { path: string; line?: number }) => - typeof p === 'string' ? { path: p } : { path: p.path, line: p.line } - ); - } - } - - if (args.paths && Array.isArray(args.paths)) { - return args.paths.map((p: string | { path: string; line?: number }) => - typeof p === 'string' ? { path: p } : { path: p.path, line: p.line } - ); - } - } - - return undefined; - } - - private extractResult(toolCall: Record): { - error?: string; - content?: ToolUpdate['content']; - locations?: ToolUpdate['locations']; - rawOutput?: string; - } { - const keys = Object.keys(toolCall); - - for (const key of keys) { - const tool = toolCall[key] as { - result?: Record; - args?: Record; - } | undefined; - const result = tool?.result || {}; - - if (result.error) { - return { error: result.error as string }; - } - - // Extract locations from result - const locations: ToolUpdate['locations'] = []; - if (result.matches && Array.isArray(result.matches)) { - locations.push(...result.matches.map((m: { path: string; line?: number }) => ({ - path: m.path, - line: m.line - }))); - } - if (result.files && Array.isArray(result.files)) { - locations.push(...result.files.map((f: string) => ({ path: f }))); - } - if (result.path) { - locations.push({ path: result.path as string, line: result.line as number | undefined }); - } - - // Extract content - const content: ToolUpdate['content'] = []; - if (result.content || result.newText) { - content.push({ - type: 'content', - content: { text: (result.content || result.newText) as string } - }); - } - if (result.output !== undefined) { - content.push({ - type: 'content', - content: { - text: `Exit code: ${result.exitCode || 0}\n${result.output || '(no output)'}` - } - }); - } - - return { - content: content.length > 0 ? content : undefined, - locations: locations.length > 0 ? locations : undefined, - rawOutput: JSON.stringify(result) - }; - } - - return {}; - } -} -``` - -**Step 2: Verify the module imports correctly** - -Run: `bun -e "import { ToolMapper } from './src/acp/tools.js'; console.log('ToolMapper imported successfully');"` -Expected: "ToolMapper imported successfully" - -**Step 3: Run the tools tests** - -Run: `bun test tests/unit/tools.test.ts 2>&1` -Expected: All tests pass (should see many passing tests) - -**Step 4: Commit** - -```bash -git add src/acp/tools.ts -git commit -m "feat: implement ToolMapper for ACP tool event mapping" -``` - ---- - -## Task 6: Create Missing ACP Metrics Module - -**Files:** -- Create: `src/acp/metrics.ts` -- Test: `tests/unit/metrics.test.ts` (already exists) - -**Step 1: Create the metrics module** - -```typescript -export interface SessionMetrics { - sessionId: string; - model: string; - promptTokens: number; - toolCalls: number; - duration: number; - timestamp: number; -} - -export interface AggregateMetrics { - totalPrompts: number; - totalToolCalls: number; - totalDuration: number; - avgDuration: number; -} - -export class MetricsTracker { - private sessions: Map = new Map(); - - recordPrompt(sessionId: string, model: string, tokens: number): void { - const existing = this.sessions.get(sessionId); - if (existing) { - existing.promptTokens = tokens; - existing.model = model; - } else { - this.sessions.set(sessionId, { - sessionId, - model, - promptTokens: tokens, - toolCalls: 0, - duration: 0, - timestamp: Date.now() - }); - } - } - - recordToolCall(sessionId: string, toolName: string, duration: number): void { - const existing = this.sessions.get(sessionId); - if (existing) { - existing.toolCalls++; - existing.duration += duration; - } - // If no session exists, silently ignore (matches test expectations) - } - - getSessionMetrics(sessionId: string): SessionMetrics | undefined { - return this.sessions.get(sessionId); - } - - getAggregateMetrics(hours: number): AggregateMetrics { - const cutoff = Date.now() - (hours * 60 * 60 * 1000); - let totalPrompts = 0; - let totalToolCalls = 0; - let totalDuration = 0; - - for (const metrics of this.sessions.values()) { - if (metrics.timestamp >= cutoff) { - totalPrompts++; - totalToolCalls += metrics.toolCalls; - totalDuration += metrics.duration; - } - } - - return { - totalPrompts, - totalToolCalls, - totalDuration, - avgDuration: totalPrompts > 0 ? Math.round(totalDuration / totalPrompts) : 0 - }; - } - - clearMetrics(sessionId?: string): void { - if (sessionId) { - this.sessions.delete(sessionId); - } else { - this.sessions.clear(); - } - } - - clearAll(): void { - this.sessions.clear(); - } -} -``` - -**Step 2: Verify the module imports correctly** - -Run: `bun -e "import { MetricsTracker } from './src/acp/metrics.js'; console.log('MetricsTracker imported successfully');"` -Expected: "MetricsTracker imported successfully" - -**Step 3: Run the metrics tests** - -Run: `bun test tests/unit/metrics.test.ts 2>&1` -Expected: All tests pass - -**Step 4: Commit** - -```bash -git add src/acp/metrics.ts -git commit -m "feat: implement MetricsTracker for ACP metrics collection" -``` - ---- - -## Task 7: Consolidate Provider Implementations - -**Files:** -- Delete: `src/minimal-provider.ts` (duplicate) -- Modify: `src/provider.ts` -- Modify: `src/index.ts` (if needed) - -**Step 1: Remove the duplicate minimal-provider.ts** - -Run: `rm src/minimal-provider.ts` - -**Step 2: Ensure provider.ts has correct implementation** - -Verify `src/provider.ts` uses `ai` package (not `@ai-sdk/openai-compatible`): - -```typescript -import { customProvider } from "ai"; -import { SimpleCursorClient } from "./client/simple.js"; - -export const cursorProvider = customProvider({ - id: "cursor-acp", - languageModels: { - "cursor-acp/auto": { - async generateText({ prompt }) { - const result = await new SimpleCursorClient().executePrompt(prompt); - return { - text: result.content || result.error || "No response", - finishReason: result.done ? "stop" : "error" - }; - }, - async *streamText({ prompt }) { - const stream = new SimpleCursorClient().executePromptStream(prompt); - for await (const line of stream) { - try { - const evt = JSON.parse(line); - if (evt.type === "assistant" && evt.message?.content?.[0]?.text) { - yield { - type: "text-delta", - textDelta: evt.message.content[0].text, - finishReason: "stop" - }; - } - } catch { - // Skip invalid JSON lines - } - } - yield { type: "text-delta", finishReason: "stop" }; - } - } - } -}); - -export default cursorProvider; -``` - -**Step 3: Verify index.ts exports correctly** - -Verify `src/index.ts`: -```typescript -import { cursorProvider } from "./provider.js"; - -export { cursorProvider }; -export default cursorProvider; -``` - -**Step 4: Build and verify** - -Run: `bun run build 2>&1` -Expected: Build succeeds without errors - -Run: `node -e "const p = require('./dist/index.js'); console.log('Exports:', Object.keys(p)); console.log('cursorProvider:', typeof p.cursorProvider);"` -Expected: Shows exports including `cursorProvider` as an object - -**Step 5: Commit** - -```bash -git rm src/minimal-provider.ts -git add src/provider.ts src/index.ts -git commit -m "refactor: consolidate provider implementations, remove duplicate" -``` - ---- - -## Task 8: Fix Placeholder Tests - -**Files:** -- Modify: `tests/unit/retry.test.ts` -- Modify: `tests/integration/agent.test.ts` - -**Step 1: Fix retry.test.ts with actual tests** - -```typescript -import { describe, it, expect } from "bun:test"; - -class RetryEngine { - async execute( - fn: () => Promise, - options: { maxRetries?: number; backoffMs?: number; shouldRetry?: (error: Error) => boolean } = {} - ): Promise { - const { maxRetries = 3, backoffMs = 1000, shouldRetry = () => true } = options; - - let lastError: Error; - - for (let attempt = 0; attempt <= maxRetries; attempt++) { - try { - return await fn(); - } catch (error) { - lastError = error as Error; - - if (attempt === maxRetries || !shouldRetry(lastError)) { - throw lastError; - } - - const delay = backoffMs * Math.pow(2, attempt); - await new Promise(resolve => setTimeout(resolve, delay)); - } - } - - throw lastError!; - } - - calculateBackoff(attempt: number, baseMs: number = 1000, maxMs: number = 30000): number { - const delay = baseMs * Math.pow(2, attempt); - return Math.min(delay, maxMs); - } -} - -describe("RetryEngine", () => { - const engine = new RetryEngine(); - - it("should succeed on first attempt", async () => { - const result = await engine.execute(async () => "success"); - expect(result).toBe("success"); - }); - - it("should retry on recoverable errors", async () => { - let attempts = 0; - const result = await engine.execute( - async () => { - attempts++; - if (attempts < 3) { - throw new Error("transient error"); - } - return "success"; - }, - { maxRetries: 3, backoffMs: 10 } - ); - expect(result).toBe("success"); - expect(attempts).toBe(3); - }); - - it("should not retry on fatal errors", async () => { - let attempts = 0; - - await expect( - engine.execute( - async () => { - attempts++; - throw new Error("fatal error"); - }, - { - maxRetries: 3, - backoffMs: 10, - shouldRetry: (error) => !error.message.includes("fatal") - } - ) - ).rejects.toThrow("fatal error"); - - expect(attempts).toBe(1); - }); - - it("should calculate exponential backoff", () => { - expect(engine.calculateBackoff(0, 1000)).toBe(1000); - expect(engine.calculateBackoff(1, 1000)).toBe(2000); - expect(engine.calculateBackoff(2, 1000)).toBe(4000); - expect(engine.calculateBackoff(10, 1000, 30000)).toBe(30000); - }); - - it("should throw after max retries exceeded", async () => { - let attempts = 0; - - await expect( - engine.execute( - async () => { - attempts++; - throw new Error("always fails"); - }, - { maxRetries: 2, backoffMs: 10 } - ) - ).rejects.toThrow("always fails"); - - expect(attempts).toBe(3); // initial + 2 retries - }); -}); -``` - -**Step 2: Fix integration test with actual test** - -```typescript -import { describe, it, expect, beforeAll } from "bun:test"; -import { SimpleCursorClient } from "../../src/client/simple.js"; - -describe("CursorAgent Integration", () => { - let client: SimpleCursorClient; - - beforeAll(() => { - client = new SimpleCursorClient({ - cursorAgentPath: process.env.CURSOR_AGENT_EXECUTABLE || 'cursor-agent' - }); - }); - - it("should initialize client with config", () => { - expect(client).toBeDefined(); - }); - - it("should list available models", async () => { - const models = await client.getAvailableModels(); - expect(models).toBeDefined(); - expect(models.length).toBeGreaterThan(0); - expect(models[0]).toHaveProperty('id'); - expect(models[0]).toHaveProperty('name'); - }); - - it("should validate installation (may fail without cursor-agent)", async () => { - // This test is optional - depends on cursor-agent being installed - const isValid = await client.validateInstallation(); - // Don't assert - just verify method works - expect(typeof isValid).toBe('boolean'); - }); -}); -``` - -**Step 3: Run all tests** - -Run: `bun test 2>&1` -Expected: All tests pass (9 test files, multiple tests each) - -**Step 4: Commit** - -```bash -git add tests/unit/retry.test.ts tests/integration/agent.test.ts -git commit -m "test: replace placeholder tests with actual implementations" -``` - ---- - -## Task 9: Final Verification and Build - -**Files:** -- All files (final check) - -**Step 1: Run full test suite** - -Run: `bun test 2>&1` -Expected: All tests pass with no errors - -**Step 2: Build the package** - -Run: `bun run build 2>&1` -Expected: Build succeeds - -**Step 3: Verify dist output** - -Run: `ls -la dist/` -Expected: Shows `index.js` and source map files - -Run: `node -e "const p = require('./dist/index.js'); console.log('cursorProvider:', Object.keys(p.cursorProvider || {}));"` -Expected: Shows provider has expected properties - -**Step 4: Check for any remaining issues** - -Run: `grep -r "@ai-sdk/openai-compatible" src/ || echo "No incorrect imports found"` -Expected: "No incorrect imports found" - -Run: `grep -r "} catch {}" src/ || echo "No empty catch blocks found"` -Expected: "No empty catch blocks found" (or only the one we intentionally kept) - -**Step 5: Final commit** - -```bash -git add -A -git commit -m "fix: complete opencode-cursor plugin fixes - all tests passing" -``` - ---- - -## Summary - -This plan fixes all critical issues identified in the audit: - -1. **Package.json**: Remove circular dependency, clean up unused deps -2. **Logger**: Fix console method selection -3. **SimpleCursorClient**: Add proper error handling, input validation -4. **ACP Sessions**: Implement missing module -5. **ACP Tools**: Implement missing module -6. **ACP Metrics**: Implement missing module -7. **Provider consolidation**: Remove duplicate code -8. **Tests**: Replace placeholders with actual tests -9. **Final verification**: All tests pass, build succeeds - -Total tasks: 9 -Estimated time: 45-60 minutes - -**Next step:** Execute the plan task-by-task using @superpowers:executing-plans - ---- - -**REQUIRED SUB-SKILL:** Use @superpowers:executing-plans to implement this plan task-by-task in order. - -**For each task:** -1. Mark task as in-progress -2. Execute all steps -3. Verify expected outputs -4. Mark task complete -5. Move to next task - -Do not skip tasks. Do not batch multiple tasks together. diff --git a/docs/plans/2026-01-29-competitive-improvements-design.md b/docs/plans/2026-01-29-competitive-improvements-design.md deleted file mode 100644 index 09a77f8..0000000 --- a/docs/plans/2026-01-29-competitive-improvements-design.md +++ /dev/null @@ -1,546 +0,0 @@ -# Competitive Improvements Design - -> **Goal**: Implement HTTP Proxy Mode, Tool Calling Bridge, and Dynamic Model Discovery to close gaps with competing projects while maintaining our reliability advantages. - -**Date**: 2026-01-29 -**Based on**: Competitive analysis of poso-cursor-auth, cursor-opencode-auth, yet-another-opencode-cursor-auth, and opencode-rules - ---- - -## Executive Summary - -This design addresses three critical gaps identified in our competitive analysis: - -1. **HTTP Proxy Mode** - Provides compatibility with OpenCode's standard provider infrastructure (what poso/Infiland have) -2. **Tool Calling Bridge** - Enables Cursor to use OpenCode's native tools for agent workflows (what poso/Yukaii have) -3. **Dynamic Model Discovery** - Auto-detects available models from cursor-agent (what Yukaii has) - -These improvements maintain our unique strengths (E2BIG protection, real streaming, 55 tests) while adding the compatibility and flexibility users expect. - ---- - -## Phase 1: HTTP Proxy Mode (Foundation) - -### Purpose -Provides an OpenAI-compatible HTTP server as an alternative to direct provider mode. This enables compatibility with OpenCode setups that expect standard provider behavior. - -### Architecture - -``` -┌─────────────────────────────────────────────────────────────┐ -│ OpenCode Agent │ -│ (Sisyphus, etc) │ -└──────────────────────┬──────────────────────────────────────┘ - │ OpenAI-compatible HTTP API - │ POST /v1/chat/completions - │ -┌──────────────────────▼──────────────────────────────────────┐ -│ HTTP Proxy Mode │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ ProxyServer │→ │ OpenAI Req │→ │ CursorClient │ │ -│ │ (Bun.serve) │ │ Parser │ │ (our client) │ │ -│ └──────────────┘ └──────────────┘ └──────┬───────┘ │ -│ ↑ │ │ -│ │ │ spawn │ -│ │ ┌─────▼──────┐ │ -│ │ │ cursor-agent│ │ -│ │ │ (stdin/stdout) │ -│ │ └─────┬──────┘ │ -│ │ │ │ -│ ┌─────┴────────┐ ┌──────────────┐ ┌──────┴──────┐ │ -│ │ OpenAI Resp │← │ Formatter │← │ Parser │ │ -│ │ Generator │ │ │ │ (JSON) │ │ -│ └──────────────┘ └──────────────┘ └─────────────┘ │ -└─────────────────────────────────────────────────────────────┘ -``` - -### Components - -#### 1. ProxyServer -- **File**: `src/proxy/server.ts` -- **Purpose**: HTTP server using `Bun.serve()` -- **Port**: 32124 (default), with fallback to random port -- **Endpoints**: - - `GET /health` - Health check for discovery - - `POST /v1/chat/completions` - Main endpoint - - `GET /v1/models` - Model listing - -#### 2. RequestHandler -- **File**: `src/proxy/handler.ts` -- **Purpose**: Convert OpenAI API requests to cursor-agent calls -- **Key Functions**: - - Parse OpenAI request format (messages, tools, stream) - - Extract model ID and normalize - - Convert messages array to prompt string - - Handle tool definitions if present - -#### 3. ResponseFormatter -- **File**: `src/proxy/formatter.ts` -- **Purpose**: Format cursor-agent output as OpenAI responses -- **Formats**: - - Non-streaming: Full JSON response - - Streaming: Server-Sent Events (SSE) chunks - - Tool calls: OpenAI tool_call format - -#### 4. ModeSelector -- **File**: `src/index.ts` (updated) -- **Purpose**: Choose between direct provider and HTTP proxy -- **Logic**: - - Check environment variable `CURSOR_MODE` - - Default to direct provider (faster) - - HTTP proxy when `CURSOR_MODE=proxy` - -### Error Handling - -1. **Port Conflicts**: Try default port, fallback to random port -2. **Proxy Startup Failure**: Log error, fall back to direct mode -3. **Request Parsing Errors**: Return 400 with OpenAI-compatible error JSON -4. **Cursor-Agent Errors**: Forward stderr as OpenAI error message -5. **Health Check Failures**: Return 503 for load balancer detection - -### Configuration - -```typescript -// src/proxy/types.ts -interface ProxyConfig { - port?: number; // Default: 32124 - host?: string; // Default: '127.0.0.1' - healthCheckPath?: string; // Default: '/health' - fallbackOnError?: boolean; // Default: true - requestTimeout?: number; // Default: 30000ms -} -``` - ---- - -## Phase 2: Tool Calling Bridge (Agent Workflows) - -### Purpose -Enables Cursor to invoke OpenCode's native tools (bash, read, write, etc.) by mapping OpenAI-compatible tool schemas to actual tool executions. - -### Architecture - -``` -User Request with Tools - │ - ▼ -┌──────────────────────────────┐ -│ Tool Schema Injection │ -│ Inject tool definitions │ -│ into cursor-agent prompt │ -└──────────────┬───────────────┘ - │ - ▼ -┌──────────────────────────────┐ -│ Cursor-Agent Processing │ -│ LLM decides to use tool │ -│ Outputs tool call JSON │ -└──────────────┬───────────────┘ - │ - ▼ -┌──────────────────────────────┐ -│ Tool Call Parser │ -│ Parse JSON from output │ -│ Extract tool name + args │ -└──────────────┬───────────────┘ - │ - ▼ -┌──────────────────────────────┐ -│ Tool Executor │ -│ Map to OpenCode tool │ -│ Execute via function call │ -└──────────────┬───────────────┘ - │ - ▼ -┌──────────────────────────────┐ -│ Tool Result Formatter │ -│ Format result as message │ -│ Inject back into context │ -└──────────────┬───────────────┘ - │ - ▼ - Final Response -``` - -### Components - -#### 1. ToolRegistry -- **File**: `src/tools/registry.ts` -- **Purpose**: Discovers and manages available OpenCode tools -- **Tools Supported**: - - `bash` / `shell` - Execute shell commands - - `read` / `file_read` - Read file contents - - `write` / `file_write` - Write file contents - - `edit` / `file_edit` - Edit files - - `grep` / `search` - Search files - - `glob` / `list` - List files - - `ls` - List directory - -#### 2. ToolMapper -- **File**: `src/tools/mapper.ts` -- **Purpose**: Convert OpenAI tool schemas to executable tool calls -- **Schema Format**: -```json -{ - "type": "function", - "function": { - "name": "bash", - "description": "Execute shell command", - "parameters": { - "type": "object", - "properties": { - "command": { "type": "string" }, - "timeout": { "type": "number" } - }, - "required": ["command"] - } - } -} -``` - -#### 3. ToolExecutor -- **File**: `src/tools/executor.ts` -- **Purpose**: Execute OpenCode tools and return results -- **Execution**: - - Import tool functions from OpenCode SDK - - Execute with proper error handling - - Format results for LLM consumption - -#### 4. ConversationManager -- **File**: `src/tools/conversation.ts` -- **Purpose**: Maintain tool call state across requests -- **State Tracking**: - - `tool_call_id` generation and mapping - - Tool call history - - Multi-turn tool workflows - -### Tool Execution Flow - -1. **Request with Tools**: User sends request with `tools` array -2. **Schema Injection**: Inject tool schemas into cursor-agent prompt -3. **LLM Decision**: Cursor-agent decides to use tool, outputs JSON -4. **Parse Tool Call**: Extract `name` and `arguments` from JSON -5. **Execute Tool**: Call matching OpenCode tool function -6. **Format Result**: Convert tool output to user message format -7. **Continue Conversation**: Send result back to cursor-agent -8. **Final Response**: Return completed response to user - -### Tool Schema Injection Prompt - -``` -You have access to the following tools. Use them when needed: - -## Tool: bash -Execute shell commands. -Parameters: -- command (required): The command to run -- timeout (optional): Timeout in milliseconds - -Usage: {"tool": "bash", "arguments": {"command": "ls -la"}} - -## Tool: read -Read file contents. -Parameters: -- path (required): File path to read - -Usage: {"tool": "read", "arguments": {"path": "/path/to/file"}} - -[Additional tools...] - -When you need to use a tool, output ONLY the JSON object. The system will execute it and return the result. -``` - ---- - -## Phase 3: Dynamic Model Discovery (Auto-Configuration) - -### Purpose -Eliminates hardcoded model lists by querying cursor-agent at runtime. This ensures our configuration always matches what cursor-agent supports. - -### Architecture - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Startup / Refresh │ -│ (Every 5 minutes) │ -└──────────────┬──────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ ModelDiscoveryService │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ Query CLI │ │ Parse Output│ │ Cache Models│ │ -│ │ │ │ │ │ (5-min TTL) │ │ -│ └─────────────┘ └─────────────┘ └─────────────┘ │ -└──────────────┬──────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ ConfigUpdater │ -│ Update opencode.json models section │ -│ Preserve user customizations │ -│ Handle model aliases │ -└──────────────┬──────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ RuntimeValidator │ -│ Validate model before each request │ -│ Fallback to auto if invalid │ -│ Update cache on error │ -└─────────────────────────────────────────────────────────────┘ -``` - -### Components - -#### 1. ModelDiscoveryService -- **File**: `src/models/discovery.ts` -- **Purpose**: Query cursor-agent for available models -- **Discovery Methods**: - 1. **Primary**: `cursor-agent models --json` (if available) - 2. **Secondary**: Parse `cursor-agent --help` output - 3. **Tertiary**: Extract from error messages - 4. **Fallback**: Default hardcoded list - -#### 2. ModelCache -- **File**: `src/models/cache.ts` -- **Purpose**: Cache discovered models with TTL -- **Features**: - - 5-minute TTL - - Background refresh (refresh at 4 minutes) - - Immediate invalidation on errors - - Persistent cache option (write to file) - -#### 3. ConfigUpdater -- **File**: `src/models/config.ts` -- **Purpose**: Update OpenCode configuration -- **Behavior**: - - Read existing `~/.config/opencode/opencode.json` - - Merge discovered models - - Preserve user additions - - Handle model aliases (gpt-5 → gpt-5.2) - - Write updated config - -#### 4. ModelValidator -- **File**: `src/models/validator.ts` -- **Purpose**: Validate models at runtime -- **Logic**: - - Check if model exists in cache - - If not, trigger discovery - - If invalid, fallback to 'auto' - - Log validation results - -### Discovery Sources - -#### Source 1: CLI Command (Preferred) -```bash -cursor-agent models --json -# Returns: ["auto", "gpt-5.2", "sonnet-4.5", ...] -``` - -#### Source 2: Help Output Parsing -```bash -cursor-agent --help -# Parse: Available models: auto, gpt-5.2, sonnet-4.5, ... -``` - -#### Source 3: Error Message Extraction -When invalid model requested: -``` -Error: Invalid model. Available models: auto, gpt-5.2, sonnet-4.5, ... -``` - -### Model Aliases - -Maintain aliases for backward compatibility: -- `gpt-5` → `gpt-5.2` -- `claude-sonnet` → `sonnet-4.5` -- `claude-opus` → `opus-4.5` - ---- - -## Integration: Three Phases Working Together - -### Combined Architecture - -``` -┌─────────────────────────────────────────────────────────────┐ -│ OpenCode Configuration │ -│ (Dynamic Models) │ -│ Models discovered at runtime from cursor-agent │ -└──────────────┬──────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ OpenCode Agent │ -│ (User Request) │ -│ Request with tools: [bash, read, write] │ -└──────────────┬──────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ HTTP Proxy Mode │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ Tool Schema Injection │ │ -│ │ Inject tool definitions into prompt │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ Cursor-Agent Processing │ │ -│ │ LLM outputs tool call JSON │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ Tool Executor │ │ -│ │ Execute OpenCode tools │ │ -│ │ Return results │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ Response Formatter │ │ -│ │ Format as OpenAI response │ │ -│ └─────────────────────────────────────────────────────┘ │ -└──────────────┬──────────────────────────────────────────────┘ - │ - ▼ - User -``` - -### Mode Selection Logic - -```typescript -// Mode selection based on configuration -function selectMode(): ProviderMode { - const mode = process.env.CURSOR_MODE || 'direct'; - - switch (mode) { - case 'proxy': - return new HttpProxyMode(); - case 'auto': - // Try direct, fallback to proxy on error - return new AdaptiveMode(); - case 'direct': - default: - return new DirectProviderMode(); - } -} -``` - ---- - -## Testing Strategy - -### Unit Tests - -1. **ProxyServer Tests** - - Port allocation (default + fallback) - - Health check endpoint - - Request parsing - - Error handling - -2. **Tool Calling Tests** - - Tool schema injection - - Tool call parsing - - Tool execution - - Error handling - -3. **Model Discovery Tests** - - CLI parsing - - Cache behavior - - Config updating - - Alias resolution - -### Integration Tests - -1. **End-to-End Proxy Test** - ``` - Start proxy → Send OpenAI request → Verify response - ``` - -2. **Tool Workflow Test** - ``` - Request with tools → Tool execution → Final response - ``` - -3. **Model Discovery Test** - ``` - Trigger discovery → Update config → Verify models - ``` - ---- - -## Success Criteria - -| Phase | Metric | Target | -|-------|--------|--------| -| HTTP Proxy | Compatibility | Works with all OpenCode setups | -| HTTP Proxy | Performance | <100ms overhead vs direct | -| Tool Calling | Tool Support | 6+ OpenCode tools mapped | -| Tool Calling | Success Rate | >95% tool execution success | -| Discovery | Accuracy | 100% model list accuracy | -| Discovery | Refresh | <5s discovery time | - ---- - -## Next Steps - -1. **Validate Design** - Get approval on this document -2. **Create Implementation Plan** - Use superpowers:writing-plans -3. **Set Up Worktree** - Use superpowers:using-git-worktrees -4. **Implement Phase 1** - HTTP Proxy Mode -5. **Implement Phase 2** - Tool Calling Bridge -6. **Implement Phase 3** - Dynamic Model Discovery -7. **Run Full Test Suite** - Verify all 55+ tests pass -8. **Update Documentation** - README, API docs - ---- - -## Appendix: File Structure - -``` -src/ -├── index.ts # Mode selection + exports -├── provider.ts # Direct provider (existing) -├── client/ -│ └── simple.ts # CursorClient (existing) -├── proxy/ -│ ├── server.ts # HTTP proxy server -│ ├── handler.ts # Request handler -│ ├── formatter.ts # Response formatter -│ └── types.ts # Proxy types -├── tools/ -│ ├── registry.ts # Tool registry -│ ├── mapper.ts # Tool schema mapper -│ ├── executor.ts # Tool executor -│ ├── conversation.ts # Conversation manager -│ └── types.ts # Tool types -├── models/ -│ ├── discovery.ts # Model discovery service -│ ├── cache.ts # Model cache -│ ├── config.ts # Config updater -│ ├── validator.ts # Model validator -│ └── types.ts # Model types -tests/ -├── proxy/ -│ ├── server.test.ts -│ ├── handler.test.ts -│ └── formatter.test.ts -├── tools/ -│ ├── registry.test.ts -│ ├── mapper.test.ts -│ └── executor.test.ts -├── models/ -│ ├── discovery.test.ts -│ ├── cache.test.ts -│ └── config.test.ts -└── integration/ - └── proxy-workflow.test.ts -``` - ---- - -*Design validated: 2026-01-29* -*Ready for implementation planning* diff --git a/docs/plans/2026-01-29-implement-competitive-improvements.md b/docs/plans/2026-01-29-implement-competitive-improvements.md deleted file mode 100644 index 7836285..0000000 --- a/docs/plans/2026-01-29-implement-competitive-improvements.md +++ /dev/null @@ -1,1410 +0,0 @@ -# Competitive Improvements Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use @superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Implement HTTP Proxy Mode, Tool Calling Bridge, and Dynamic Model Discovery to close gaps with competing projects. - -**Architecture:** Phase 1 adds HTTP proxy server for compatibility. Phase 2 builds tool calling bridge on top. Phase 3 adds dynamic model discovery. Each phase builds on previous, maintaining backward compatibility. - -**Tech Stack:** TypeScript, Bun (for HTTP server), AI SDK patterns, OpenCode plugin SDK - ---- - -## Phase 1: HTTP Proxy Mode (Foundation) - -### Task 1: Proxy Server Setup - -**Files:** -- Create: `src/proxy/types.ts` -- Create: `src/proxy/server.ts` -- Test: `tests/proxy/server.test.ts` - -**Step 1: Define proxy types** - -Create `src/proxy/types.ts`: -```typescript -export interface ProxyConfig { - port?: number; - host?: string; - healthCheckPath?: string; - requestTimeout?: number; -} - -export interface ProxyServer { - start(): Promise; - stop(): Promise; - getBaseURL(): string; -} -``` - -**Step 2: Write failing test** - -Create `tests/proxy/server.test.ts`: -```typescript -import { describe, it, expect } from "bun:test"; -import { createProxyServer } from "../../src/proxy/server.js"; - -describe("ProxyServer", () => { - it("should start on default port", async () => { - const server = createProxyServer({ port: 32124 }); - const baseURL = await server.start(); - expect(baseURL).toBe("http://127.0.0.1:32124/v1"); - await server.stop(); - }); - - it("should respond to health check", async () => { - const server = createProxyServer({ port: 32125 }); - await server.start(); - const response = await fetch("http://127.0.0.1:32125/health"); - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.ok).toBe(true); - await server.stop(); - }); -}); -``` - -**Step 3: Run test to verify it fails** - -Run: `bun test tests/proxy/server.test.ts` -Expected: FAIL - "createProxyServer not defined" - -**Step 4: Implement proxy server** - -Create `src/proxy/server.ts`: -```typescript -import type { ProxyConfig, ProxyServer } from "./types.js"; - -export function createProxyServer(config: ProxyConfig = {}): ProxyServer { - const port = config.port || 32124; - const host = config.host || "127.0.0.1"; - const healthPath = config.healthCheckPath || "/health"; - - let server: any = null; - let actualPort: number = port; - - return { - async start(): Promise { - const bunAny = globalThis as any; - - server = bunAny.Bun.serve({ - hostname: host, - port, - fetch: async (req: Request) => { - const url = new URL(req.url); - - if (url.pathname === healthPath) { - return new Response(JSON.stringify({ ok: true }), { - status: 200, - headers: { "Content-Type": "application/json" } - }); - } - - return new Response(JSON.stringify({ error: "Not found" }), { - status: 404, - headers: { "Content-Type": "application/json" } - }); - } - }); - - actualPort = server.port; - return `http://${host}:${actualPort}/v1`; - }, - - async stop(): Promise { - if (server) { - server.stop(); - server = null; - } - }, - - getBaseURL(): string { - return `http://${host}:${actualPort}/v1`; - } - }; -} -``` - -**Step 5: Run test to verify it passes** - -Run: `bun test tests/proxy/server.test.ts` -Expected: PASS (both tests) - -**Step 6: Commit** - -```bash -git add src/proxy/types.ts src/proxy/server.ts tests/proxy/server.test.ts -git commit -m "feat: add HTTP proxy server foundation" -``` - ---- - -### Task 2: Request Handler - -**Files:** -- Create: `src/proxy/handler.ts` -- Create: `src/proxy/formatter.ts` -- Test: `tests/proxy/handler.test.ts` - -**Step 1: Write failing test** - -Create `tests/proxy/handler.test.ts`: -```typescript -import { describe, it, expect } from "bun:test"; -import { parseOpenAIRequest } from "../../src/proxy/handler.js"; - -describe("RequestHandler", () => { - it("should parse OpenAI chat completion request", () => { - const body = { - model: "cursor-acp/auto", - messages: [ - { role: "user", content: "Hello" } - ], - stream: false - }; - - const result = parseOpenAIRequest(body); - expect(result.model).toBe("auto"); - expect(result.prompt).toBe("USER: Hello"); - expect(result.stream).toBe(false); - }); - - it("should handle messages array", () => { - const body = { - messages: [ - { role: "system", content: "You are helpful" }, - { role: "user", content: "Hi" } - ] - }; - - const result = parseOpenAIRequest(body); - expect(result.prompt).toBe("SYSTEM: You are helpful\n\nUSER: Hi"); - }); -}); -``` - -**Step 2: Run test to verify it fails** - -Run: `bun test tests/proxy/handler.test.ts` -Expected: FAIL - "parseOpenAIRequest not defined" - -**Step 3: Implement request handler** - -Create `src/proxy/handler.ts`: -```typescript -export interface ParsedRequest { - model: string; - prompt: string; - stream: boolean; - tools?: any[]; -} - -export function parseOpenAIRequest(body: any): ParsedRequest { - const model = body.model?.replace("cursor-acp/", "") || "auto"; - const stream = body.stream === true; - - // Convert messages array to prompt string - let prompt = ""; - if (Array.isArray(body.messages)) { - const lines = body.messages.map((msg: any) => { - const role = msg.role?.toUpperCase() || "USER"; - const content = typeof msg.content === "string" ? msg.content : ""; - return `${role}: ${content}`; - }); - prompt = lines.join("\n\n"); - } - - return { - model, - prompt, - stream, - tools: body.tools - }; -} -``` - -**Step 4: Implement response formatter** - -Create `src/proxy/formatter.ts`: -```typescript -export function createChatCompletionResponse(model: string, content: string) { - return { - id: `cursor-acp-${Date.now()}`, - object: "chat.completion", - created: Math.floor(Date.now() / 1000), - model: `cursor-acp/${model}`, - choices: [ - { - index: 0, - message: { role: "assistant", content }, - finish_reason: "stop" - } - ], - usage: { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0 - } - }; -} - -export function createChatCompletionChunk( - id: string, - created: number, - model: string, - deltaContent: string, - done = false -) { - return { - id, - object: "chat.completion.chunk", - created, - model: `cursor-acp/${model}`, - choices: [ - { - index: 0, - delta: deltaContent ? { content: deltaContent } : {}, - finish_reason: done ? "stop" : null - } - ] - }; -} -``` - -**Step 5: Run test to verify it passes** - -Run: `bun test tests/proxy/handler.test.ts` -Expected: PASS - -**Step 6: Commit** - -```bash -git add src/proxy/handler.ts src/proxy/formatter.ts tests/proxy/handler.test.ts -git commit -m "feat: add request handler and response formatter" -``` - ---- - -### Task 3: Integrate Proxy with Provider - -**Files:** -- Modify: `src/index.ts` -- Modify: `src/provider.ts` -- Test: `tests/proxy/integration.test.ts` - -**Step 1: Update provider to support proxy mode** - -Modify `src/provider.ts` to export proxy-enabled provider: -```typescript -import { createProxyServer } from "./proxy/server.js"; -import { parseOpenAIRequest } from "./proxy/handler.js"; -import { createChatCompletionResponse, createChatCompletionChunk } from "./proxy/formatter.js"; - -export interface ProviderOptions { - mode?: 'direct' | 'proxy'; - proxyConfig?: { port?: number; host?: string }; -} - -export async function createCursorProvider(options: ProviderOptions = {}) { - const mode = options.mode || 'direct'; - - if (mode === 'proxy') { - // Start proxy server - const proxy = createProxyServer(options.proxyConfig); - const baseURL = await proxy.start(); - - return { - id: "cursor-acp", - name: "Cursor ACP Provider (Proxy Mode)", - baseURL, - - languageModel(modelId: string = "cursor-acp/auto") { - const model = modelId.replace("cursor-acp/", "") || "auto"; - - return { - modelId, - provider: "cursor-acp", - - async doGenerate({ prompt, messages }: any) { - // Use HTTP API - const response = await fetch(`${baseURL}/chat/completions`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model: modelId, - messages: messages || [{ role: "user", content: prompt }], - stream: false - }) - }); - - const result = await response.json(); - return { - text: result.choices?.[0]?.message?.content || "", - finishReason: "stop", - usage: result.usage - }; - }, - - async doStream({ prompt, messages }: any) { - const response = await fetch(`${baseURL}/chat/completions`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model: modelId, - messages: messages || [{ role: "user", content: prompt }], - stream: true - }) - }); - - return { - stream: response.body, - rawResponse: { headers: Object.fromEntries(response.headers) } - }; - } - }; - } - }; - } - - // Return direct provider (existing implementation) - return createDirectProvider(); -} -``` - -**Step 2: Update index exports** - -Modify `src/index.ts`: -```typescript -export { CursorPlugin } from "./plugin.js"; -export { createCursorProvider, cursor } from "./provider.js"; -export { createProxyServer } from "./proxy/server.js"; - -// Default export -export { default } from "./provider.js"; - -// Backward compatibility -export { createCursorProvider as cursorProvider }; -``` - -**Step 3: Write integration test** - -Create `tests/proxy/integration.test.ts`: -```typescript -import { describe, it, expect } from "bun:test"; -import { createCursorProvider } from "../../src/provider.js"; - -describe("Proxy Integration", () => { - it("should create provider in proxy mode", async () => { - const provider = await createCursorProvider({ - mode: 'proxy', - proxyConfig: { port: 32126 } - }); - - expect(provider.id).toBe("cursor-acp"); - expect(provider.baseURL).toContain("http://127.0.0.1:32126"); - - // Clean up - const proxy = (provider as any).proxy; - if (proxy?.stop) await proxy.stop(); - }); -}); -``` - -**Step 4: Run tests** - -Run: `bun test tests/proxy/` -Expected: PASS - -**Step 5: Commit** - -```bash -git add src/provider.ts src/index.ts tests/proxy/integration.test.ts -git commit -m "feat: integrate HTTP proxy mode with provider" -``` - ---- - -## Phase 2: Tool Calling Bridge - -### Task 4: Tool Registry - -**Files:** -- Create: `src/tools/types.ts` -- Create: `src/tools/registry.ts` -- Test: `tests/tools/registry.test.ts` - -**Step 1: Define tool types** - -Create `src/tools/types.ts`: -```typescript -export interface ToolDefinition { - type: "function"; - function: { - name: string; - description: string; - parameters: { - type: "object"; - properties: Record; - required?: string[]; - }; - }; -} - -export interface ToolCall { - id: string; - type: "function"; - function: { - name: string; - arguments: string; - }; -} - -export interface ToolResult { - tool_call_id: string; - role: "tool"; - content: string; -} - -export type ToolExecutor = (args: any) => Promise; -``` - -**Step 2: Write failing test** - -Create `tests/tools/registry.test.ts`: -```typescript -import { describe, it, expect } from "bun:test"; -import { ToolRegistry } from "../../src/tools/registry.js"; - -describe("ToolRegistry", () => { - it("should register and retrieve tools", () => { - const registry = new ToolRegistry(); - - registry.register("bash", { - type: "function", - function: { - name: "bash", - description: "Execute shell command", - parameters: { - type: "object", - properties: { - command: { type: "string" } - }, - required: ["command"] - } - } - }, async (args) => `Executed: ${args.command}`); - - const tool = registry.get("bash"); - expect(tool).toBeDefined(); - expect(tool?.definition.function.name).toBe("bash"); - }); - - it("should return all tool definitions", () => { - const registry = new ToolRegistry(); - registry.register("bash", { /* ... */ }, async () => ""); - registry.register("read", { /* ... */ }, async () => ""); - - const definitions = registry.getAllDefinitions(); - expect(definitions).toHaveLength(2); - }); -}); -``` - -**Step 3: Implement tool registry** - -Create `src/tools/registry.ts`: -```typescript -import type { ToolDefinition, ToolExecutor } from "./types.js"; - -interface RegisteredTool { - definition: ToolDefinition; - executor: ToolExecutor; -} - -export class ToolRegistry { - private tools: Map = new Map(); - - register(name: string, definition: ToolDefinition, executor: ToolExecutor): void { - this.tools.set(name, { definition, executor }); - } - - get(name: string): RegisteredTool | undefined { - return this.tools.get(name); - } - - getAllDefinitions(): ToolDefinition[] { - return Array.from(this.tools.values()).map(t => t.definition); - } - - getExecutor(name: string): ToolExecutor | undefined { - return this.tools.get(name)?.executor; - } - - has(name: string): boolean { - return this.tools.has(name); - } -} -``` - -**Step 4: Run test to verify it passes** - -Run: `bun test tests/tools/registry.test.ts` -Expected: PASS - -**Step 5: Commit** - -```bash -git add src/tools/types.ts src/tools/registry.ts tests/tools/registry.test.ts -git commit -m "feat: add tool registry for OpenCode tools" -``` - ---- - -### Task 5: Tool Executor - -**Files:** -- Create: `src/tools/executor.ts` -- Create: `src/tools/mapper.ts` -- Test: `tests/tools/executor.test.ts` - -**Step 1: Write failing test** - -Create `tests/tools/executor.test.ts`: -```typescript -import { describe, it, expect } from "bun:test"; -import { ToolExecutor } from "../../src/tools/executor.js"; -import { ToolRegistry } from "../../src/tools/registry.js"; - -describe("ToolExecutor", () => { - it("should execute registered tool", async () => { - const registry = new ToolRegistry(); - registry.register("echo", { - type: "function", - function: { - name: "echo", - description: "Echo text", - parameters: { - type: "object", - properties: { text: { type: "string" } }, - required: ["text"] - } - } - }, async (args) => args.text); - - const executor = new ToolExecutor(registry); - const result = await executor.execute("echo", { text: "hello" }); - - expect(result).toBe("hello"); - }); - - it("should parse tool call JSON", () => { - const executor = new ToolExecutor(new ToolRegistry()); - const json = '{"tool": "bash", "arguments": {"command": "ls"}}'; - - const result = executor.parseToolCall(json); - expect(result.name).toBe("bash"); - expect(result.arguments).toEqual({ command: "ls" }); - }); -}); -``` - -**Step 2: Implement tool executor** - -Create `src/tools/executor.ts`: -```typescript -import type { ToolRegistry } from "./registry.js"; -import type { ToolCall } from "./types.js"; - -export interface ParsedToolCall { - name: string; - arguments: any; -} - -export class ToolExecutor { - constructor(private registry: ToolRegistry) {} - - async execute(toolName: string, args: any): Promise { - const executor = this.registry.getExecutor(toolName); - if (!executor) { - throw new Error(`Tool not found: ${toolName}`); - } - return await executor(args); - } - - parseToolCall(json: string): ParsedToolCall { - try { - const parsed = JSON.parse(json); - - // Handle different formats - if (parsed.tool && parsed.arguments) { - return { - name: parsed.tool, - arguments: parsed.arguments - }; - } - - if (parsed.name && parsed.arguments) { - return { - name: parsed.name, - arguments: typeof parsed.arguments === "string" - ? JSON.parse(parsed.arguments) - : parsed.arguments - }; - } - - throw new Error("Invalid tool call format"); - } catch (error) { - throw new Error(`Failed to parse tool call: ${error}`); - } - } - - async executeToolCall(toolCall: ToolCall): Promise { - const args = JSON.parse(toolCall.function.arguments); - return await this.execute(toolCall.function.name, args); - } -} -``` - -**Step 3: Implement tool mapper** - -Create `src/tools/mapper.ts`: -```typescript -import type { ToolDefinition } from "./types.js"; - -export function createToolSchemaPrompt(tools: ToolDefinition[]): string { - if (tools.length === 0) return ""; - - const toolDescriptions = tools.map(tool => { - const params = Object.entries(tool.function.parameters.properties) - .map(([name, schema]: [string, any]) => { - const required = tool.function.parameters.required?.includes(name); - return ` - ${name}${required ? " (required)" : ""}: ${schema.type}`; - }) - .join("\n"); - - return `## Tool: ${tool.function.name} -${tool.function.description} -Parameters: -${params || " (none)"} - -Usage: {"tool": "${tool.function.name}", "arguments": {${Object.keys(tool.function.parameters.properties).map(k => `"${k}": "..."`).join(", ")}}}`; - }); - - return `You have access to the following tools. Use them when needed: - -${toolDescriptions.join("\n\n")} - -When you need to use a tool, output ONLY the JSON object. The system will execute it and return the result.`; -} -``` - -**Step 4: Run test to verify it passes** - -Run: `bun test tests/tools/executor.test.ts` -Expected: PASS - -**Step 5: Commit** - -```bash -git add src/tools/executor.ts src/tools/mapper.ts tests/tools/executor.test.ts -git commit -m "feat: add tool executor and schema mapper" -``` - ---- - -### Task 6: Integrate Tool Calling with Proxy - -**Files:** -- Modify: `src/proxy/server.ts` -- Modify: `src/proxy/handler.ts` -- Test: `tests/proxy/tools.test.ts` - -**Step 1: Update proxy server to support tools** - -Modify `src/proxy/server.ts` to accept tool registry: -```typescript -import { ToolRegistry } from "../tools/registry.js"; -import { ToolExecutor } from "../tools/executor.js"; -import { createToolSchemaPrompt } from "../tools/mapper.js"; - -export interface ProxyServerOptions extends ProxyConfig { - toolRegistry?: ToolRegistry; -} - -export function createProxyServer(options: ProxyServerOptions = {}): ProxyServer { - // ... existing code ... - - const toolRegistry = options.toolRegistry || new ToolRegistry(); - const toolExecutor = new ToolExecutor(toolRegistry); - - // Register default OpenCode tools - registerDefaultTools(toolRegistry); - - // In fetch handler, handle tool calling - // ... implement tool call parsing and execution -} - -function registerDefaultTools(registry: ToolRegistry): void { - // Register bash tool - registry.register("bash", { - type: "function", - function: { - name: "bash", - description: "Execute shell command", - parameters: { - type: "object", - properties: { - command: { type: "string", description: "Command to execute" }, - timeout: { type: "number", description: "Timeout in milliseconds" } - }, - required: ["command"] - } - } - }, async (args) => { - // Implementation using OpenCode SDK or direct execution - return `Executed: ${args.command}`; - }); - - // Register read tool - registry.register("read", { - type: "function", - function: { - name: "read", - description: "Read file contents", - parameters: { - type: "object", - properties: { - path: { type: "string", description: "File path to read" } - }, - required: ["path"] - } - } - }, async (args) => { - // Implementation using fs.readFile - const fs = await import("fs"); - return fs.readFileSync(args.path, "utf-8"); - }); - - // Additional tools: write, edit, grep, ls, etc. -} -``` - -**Step 2: Write integration test** - -Create `tests/proxy/tools.test.ts`: -```typescript -import { describe, it, expect } from "bun:test"; -import { createProxyServer } from "../../src/proxy/server.js"; -import { ToolRegistry } from "../../src/tools/registry.js"; - -describe("Proxy Tool Calling", () => { - it("should inject tool schemas into prompt", async () => { - const registry = new ToolRegistry(); - registry.register("test", { - type: "function", - function: { - name: "test", - description: "Test tool", - parameters: { - type: "object", - properties: { input: { type: "string" } }, - required: ["input"] - } - } - }, async (args) => args.input); - - const server = createProxyServer({ - port: 32127, - toolRegistry: registry - }); - - await server.start(); - - // Test that server started with tools - expect(server.getBaseURL()).toContain("32127"); - - await server.stop(); - }); -}); -``` - -**Step 3: Run tests** - -Run: `bun test tests/proxy/` -Expected: PASS - -**Step 4: Commit** - -```bash -git add src/proxy/server.ts src/proxy/handler.ts tests/proxy/tools.test.ts -git commit -m "feat: integrate tool calling with HTTP proxy" -``` - ---- - -## Phase 3: Dynamic Model Discovery - -### Task 7: Model Discovery Service - -**Files:** -- Create: `src/models/types.ts` -- Create: `src/models/discovery.ts` -- Test: `tests/models/discovery.test.ts` - -**Step 1: Define model types** - -Create `src/models/types.ts`: -```typescript -export interface ModelInfo { - id: string; - name: string; - description?: string; - aliases?: string[]; -} - -export interface DiscoveryConfig { - cacheTTL?: number; // milliseconds - fallbackModels?: ModelInfo[]; -} -``` - -**Step 2: Write failing test** - -Create `tests/models/discovery.test.ts`: -```typescript -import { describe, it, expect } from "bun:test"; -import { ModelDiscoveryService } from "../../src/models/discovery.js"; - -describe("ModelDiscoveryService", () => { - it("should discover models from cursor-agent", async () => { - const service = new ModelDiscoveryService(); - const models = await service.discover(); - - // Should return array of models - expect(Array.isArray(models)).toBe(true); - expect(models.length).toBeGreaterThan(0); - - // Each model should have id and name - const firstModel = models[0]; - expect(firstModel.id).toBeDefined(); - expect(firstModel.name).toBeDefined(); - }); - - it("should cache discovered models", async () => { - const service = new ModelDiscoveryService({ cacheTTL: 60000 }); - - // First discovery - const models1 = await service.discover(); - - // Second discovery should return cached - const models2 = await service.discover(); - - expect(models1).toEqual(models2); - }); -}); -``` - -**Step 3: Implement discovery service** - -Create `src/models/discovery.ts`: -```typescript -import type { ModelInfo, DiscoveryConfig } from "./types.js"; - -interface CacheEntry { - models: ModelInfo[]; - timestamp: number; -} - -export class ModelDiscoveryService { - private cache: CacheEntry | null = null; - private cacheTTL: number; - private fallbackModels: ModelInfo[]; - - constructor(config: DiscoveryConfig = {}) { - this.cacheTTL = config.cacheTTL || 5 * 60 * 1000; // 5 minutes - this.fallbackModels = config.fallbackModels || this.getDefaultModels(); - } - - async discover(): Promise { - // Check cache - if (this.cache && Date.now() - this.cache.timestamp < this.cacheTTL) { - return this.cache.models; - } - - try { - const models = await this.queryCursorAgent(); - this.cache = { models, timestamp: Date.now() }; - return models; - } catch (error) { - // Return fallback on error - return this.fallbackModels; - } - } - - private async queryCursorAgent(): Promise { - // Try multiple discovery methods - - // Method 1: CLI command - try { - return await this.queryViaCLI(); - } catch {} - - // Method 2: Parse from help output - try { - return await this.queryViaHelp(); - } catch {} - - // Method 3: Return fallback - return this.fallbackModels; - } - - private async queryViaCLI(): Promise { - const { exec } = await import("child_process"); - const { promisify } = await import("util"); - const execAsync = promisify(exec); - - try { - const { stdout } = await execAsync("cursor-agent models --json", { - timeout: 5000 - }); - - const modelIds = JSON.parse(stdout); - return modelIds.map((id: string) => ({ - id, - name: this.formatModelName(id), - description: `Cursor ${id} model` - })); - } catch { - throw new Error("CLI query failed"); - } - } - - private async queryViaHelp(): Promise { - const { exec } = await import("child_process"); - const { promisify } = await import("util"); - const execAsync = promisify(exec); - - try { - const { stdout } = await execAsync("cursor-agent --help", { - timeout: 5000 - }); - - // Parse models from help text - const match = stdout.match(/Available models:?\s*([\w\-,\s]+)/i); - if (match) { - const modelIds = match[1].split(/,\s*/).map(s => s.trim()); - return modelIds.map((id: string) => ({ - id, - name: this.formatModelName(id), - description: `Cursor ${id} model` - })); - } - - throw new Error("No models found in help"); - } catch { - throw new Error("Help query failed"); - } - } - - private formatModelName(id: string): string { - // Convert kebab-case to Title Case - return id - .split("-") - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); - } - - private getDefaultModels(): ModelInfo[] { - return [ - { id: "auto", name: "Auto", description: "Auto-select best model" }, - { id: "gpt-5.2", name: "GPT-5.2" }, - { id: "sonnet-4.5", name: "Sonnet 4.5" }, - { id: "opus-4.5", name: "Opus 4.5" }, - { id: "gemini-3-pro", name: "Gemini 3 Pro" } - ]; - } - - invalidateCache(): void { - this.cache = null; - } -} -``` - -**Step 4: Run test to verify it passes** - -Run: `bun test tests/models/discovery.test.ts` -Expected: PASS (may use fallback models) - -**Step 5: Commit** - -```bash -git add src/models/types.ts src/models/discovery.ts tests/models/discovery.test.ts -git commit -m "feat: add dynamic model discovery service" -``` - ---- - -### Task 8: Config Updater - -**Files:** -- Create: `src/models/config.ts` -- Test: `tests/models/config.test.ts` - -**Step 1: Write failing test** - -Create `tests/models/config.test.ts`: -```typescript -import { describe, it, expect } from "bun:test"; -import { ConfigUpdater } from "../../src/models/config.js"; -import type { ModelInfo } from "../../src/models/types.js"; - -describe("ConfigUpdater", () => { - it("should format models for opencode config", () => { - const updater = new ConfigUpdater(); - const models: ModelInfo[] = [ - { id: "auto", name: "Auto" }, - { id: "gpt-5.2", name: "GPT-5.2" } - ]; - - const formatted = updater.formatModels(models); - - expect(formatted.auto).toBeDefined(); - expect(formatted.auto.name).toBe("Auto"); - expect(formatted.auto.tools).toBe(true); - expect(formatted.auto.reasoning).toBe(true); - }); - - it("should preserve existing models", () => { - const updater = new ConfigUpdater(); - const existing = { - auto: { name: "Auto", custom: true }, - custom: { name: "Custom", tools: true } - }; - - const newModels: ModelInfo[] = [{ id: "gpt-5.2", name: "GPT-5.2" }]; - const merged = updater.mergeModels(existing, newModels); - - expect(merged.auto.custom).toBe(true); // Preserved - expect(merged.gpt52).toBeDefined(); // Added - expect(merged.custom).toBeDefined(); // Preserved - }); -}); -``` - -**Step 2: Implement config updater** - -Create `src/models/config.ts`: -```typescript -import type { ModelInfo } from "./types.js"; - -interface OpenCodeModelConfig { - name: string; - tools?: boolean; - reasoning?: boolean; - [key: string]: any; -} - -interface OpenCodeProviderConfig { - npm?: string; - name?: string; - options?: Record; - models: Record; -} - -export class ConfigUpdater { - formatModels(models: ModelInfo[]): Record { - const formatted: Record = {}; - - for (const model of models) { - // Normalize ID for JSON key (replace dots/dashes) - const key = model.id.replace(/[.-]/g, ""); - - formatted[key] = { - name: model.name, - tools: true, - reasoning: true, - description: model.description - }; - } - - return formatted; - } - - mergeModels( - existing: Record, - discovered: ModelInfo[] - ): Record { - const formatted = this.formatModels(discovered); - - // Merge, preserving existing custom fields - return { - ...formatted, - ...existing // Existing takes precedence for conflicts - }; - } - - generateProviderConfig( - models: ModelInfo[], - baseURL: string - ): OpenCodeProviderConfig { - return { - npm: "file:///home/nomadx/opencode-cursor", - name: "Cursor Agent Provider", - options: { - baseURL, - apiKey: "cursor-agent" - }, - models: this.formatModels(models) - }; - } -} -``` - -**Step 3: Run test to verify it passes** - -Run: `bun test tests/models/config.test.ts` -Expected: PASS - -**Step 4: Commit** - -```bash -git add src/models/config.ts tests/models/config.test.ts -git commit -m "feat: add config updater for model registration" -``` - ---- - -### Task 9: Integration and CLI - -**Files:** -- Create: `src/cli/discover.ts` -- Modify: `package.json` (add scripts) -- Test: `tests/models/integration.test.ts` - -**Step 1: Create discovery CLI** - -Create `src/cli/discover.ts`: -```typescript -#!/usr/bin/env bun -import { ModelDiscoveryService } from "../models/discovery.js"; -import { ConfigUpdater } from "../models/config.js"; -import { readFileSync, writeFileSync, existsSync } from "fs"; -import { join } from "path"; -import { homedir } from "os"; - -async function main() { - console.log("Discovering Cursor models..."); - - const service = new ModelDiscoveryService(); - const models = await service.discover(); - - console.log(`Found ${models.length} models:`); - for (const model of models) { - console.log(` - ${model.id}: ${model.name}`); - } - - // Update config - const updater = new ConfigUpdater(); - const configPath = join(homedir(), ".config/opencode/opencode.json"); - - if (!existsSync(configPath)) { - console.error(`Config not found: ${configPath}`); - process.exit(1); - } - - const existingConfig = JSON.parse(readFileSync(configPath, "utf-8")); - - // Update cursor-acp provider models - if (existingConfig.provider?.["cursor-acp"]) { - const formatted = updater.formatModels(models); - existingConfig.provider["cursor-acp"].models = { - ...existingConfig.provider["cursor-acp"].models, - ...formatted - }; - - writeFileSync(configPath, JSON.stringify(existingConfig, null, 2)); - console.log(`Updated ${configPath}`); - } else { - console.error("cursor-acp provider not found in config"); - process.exit(1); - } - - console.log("Done!"); -} - -main().catch(console.error); -``` - -**Step 2: Update package.json scripts** - -Modify `package.json`: -```json -{ - "scripts": { - "build": "bun build ./src/index.ts --outdir ./dist --target node", - "dev": "bun build ./src/index.ts --outdir ./dist --target node --watch", - "test": "bun test", - "test:unit": "bun test tests/unit", - "test:integration": "bun test tests/integration", - "discover": "bun run src/cli/discover.ts", - "prepublishOnly": "bun run build" - }, - "bin": { - "cursor-discover": "./dist/cli/discover.js" - } -} -``` - -**Step 3: Write integration test** - -Create `tests/models/integration.test.ts`: -```typescript -import { describe, it, expect } from "bun:test"; -import { ModelDiscoveryService } from "../../src/models/discovery.js"; -import { ConfigUpdater } from "../../src/models/config.js"; - -describe("Model Discovery Integration", () => { - it("should discover and format models", async () => { - const service = new ModelDiscoveryService(); - const updater = new ConfigUpdater(); - - const models = await service.discover(); - const formatted = updater.formatModels(models); - - // Verify format - expect(Object.keys(formatted).length).toBeGreaterThan(0); - - const firstKey = Object.keys(formatted)[0]; - expect(formatted[firstKey].name).toBeDefined(); - expect(formatted[firstKey].tools).toBe(true); - }); -}); -``` - -**Step 4: Run all tests** - -Run: `bun test` -Expected: PASS (all tests) - -**Step 5: Commit** - -```bash -git add src/cli/discover.ts package.json tests/models/integration.test.ts -git commit -m "feat: add model discovery CLI and integration" -``` - ---- - -## Final Tasks - -### Task 10: Update Documentation - -**Files:** -- Modify: `README.md` -- Create: `docs/API.md` - -**Step 1: Update README with new features** - -Modify `README.md` to document: -- HTTP Proxy Mode usage -- Tool Calling configuration -- Model Discovery CLI - -**Step 2: Create API documentation** - -Create `docs/API.md` with full API reference. - -**Step 3: Commit** - -```bash -git add README.md docs/API.md -git commit -m "docs: update documentation for new features" -``` - ---- - -### Task 11: Final Verification - -**Step 1: Run full test suite** - -```bash -./test-all.sh -``` -Expected: All tests pass - -**Step 2: Build and verify** - -```bash -bun run build -node -e "const p = require('./dist/index.js'); console.log('Exports:', Object.keys(p));" -``` -Expected: All exports present - -**Step 3: Commit final changes** - -```bash -git add -A -git commit -m "feat: complete competitive improvements (HTTP proxy, tool calling, model discovery)" -``` - ---- - -## Success Criteria - -| Phase | Metric | Target | -|-------|--------|--------| -| HTTP Proxy | Test Coverage | 80%+ for proxy module | -| HTTP Proxy | Compatibility | Works with all OpenCode setups | -| Tool Calling | Tool Support | 6+ OpenCode tools mapped | -| Tool Calling | Success Rate | >95% tool execution | -| Discovery | Model Accuracy | 100% match to cursor-agent | -| Discovery | Refresh Time | <5s discovery time | -| Overall | Total Tests | 80+ tests | - ---- - -## Files Created/Modified Summary - -### New Files (21) -- `src/proxy/types.ts` -- `src/proxy/server.ts` -- `src/proxy/handler.ts` -- `src/proxy/formatter.ts` -- `src/tools/types.ts` -- `src/tools/registry.ts` -- `src/tools/executor.ts` -- `src/tools/mapper.ts` -- `src/models/types.ts` -- `src/models/discovery.ts` -- `src/models/config.ts` -- `src/cli/discover.ts` -- `tests/proxy/server.test.ts` -- `tests/proxy/handler.test.ts` -- `tests/proxy/integration.test.ts` -- `tests/proxy/tools.test.ts` -- `tests/tools/registry.test.ts` -- `tests/tools/executor.test.ts` -- `tests/models/discovery.test.ts` -- `tests/models/config.test.ts` -- `tests/models/integration.test.ts` -- `docs/API.md` - -### Modified Files (3) -- `src/index.ts` - Add proxy exports -- `src/provider.ts` - Add proxy mode support -- `README.md` - Document new features -- `package.json` - Add discover script - ---- - -## Next Steps - -**Plan complete and saved to `docs/plans/2026-01-29-implement-competitive-improvements.md`.** - -**Two execution options:** - -**1. Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration - -**2. Parallel Session (separate)** - Open new session with @superpowers:executing-plans, batch execution with checkpoints - -**Which approach would you prefer?** diff --git a/docs/plans/2026-02-02-auth-hook-design.md b/docs/plans/2026-02-02-auth-hook-design.md deleted file mode 100644 index 159ed95..0000000 --- a/docs/plans/2026-02-02-auth-hook-design.md +++ /dev/null @@ -1,130 +0,0 @@ -# AuthHook Integration Design - -## Overview -Add OpenCode AuthHook integration to enable `opencode auth login cursor-acp` for seamless authentication via cursor-agent's OAuth flow. - -## Goals -- Allow users to authenticate via `opencode auth login cursor-acp` -- Open browser automatically with cursor-agent's login URL -- Verify successful authentication -- No token parsing or management (cursor-agent handles everything) - -## Architecture - -### Auth Flow -``` -User runs: opencode auth login cursor-acp - │ - ▼ -Plugin AuthHook triggered - │ - ▼ -Spawn: cursor-agent login - │ - ▼ -Capture loginDeepControl URL from stdout - │ - ▼ -Return to OpenCode: { url, instructions, callback } - │ - ▼ -OpenCode opens browser for user - │ - ▼ -User completes auth in browser - │ - ▼ -Callback verifies ~/.cursor/auth.json exists - │ - ▼ -Return success/failed to OpenCode -``` - -### Components - -#### 1. Auth Module (src/auth.ts) -```typescript -export async function startCursorOAuth(): Promise<{ - url: string; - instructions: string; - callback: () => Promise; -}> { - // Spawn cursor-agent login - // Parse loginDeepControl URL from stdout - // Return URL + verification callback -} - -export async function verifyCursorAuth(): Promise { - // Check if ~/.cursor/auth.json exists - // Return true if authenticated -} -``` - -#### 2. AuthHook Integration (src/plugin.ts) -```typescript -auth: { - provider: "cursor-acp", - methods: [{ - type: "oauth", - label: "Cursor", - authorize: async () => { - const { url, instructions, callback } = await startCursorOAuth(); - return { - type: "oauth", - url, - instructions, - callback - }; - } - }] -} -``` - -## Implementation Details - -### URL Extraction -Parse cursor-agent stdout for login URL: -```typescript -const urlMatch = stdout.match(/https:\/\/cursor\.com\/loginDeepControl[^\s]+/); -``` - -### Verification -After callback, check for auth file: -```typescript -const authFile = path.join(homedir(), '.cursor', 'auth.json'); -return fs.existsSync(authFile); -``` - -### Error Handling -- **cursor-agent not found**: Clear error with install instructions -- **Timeout**: "Authentication timed out, please try again" -- **User cancelled**: "Authentication cancelled" -- **No URL captured**: "Failed to get login URL from Cursor CLI" - -## Files to Create/Modify - -### New Files -- `src/auth.ts` - OAuth helper functions (~40 lines) - -### Modified Files -- `src/plugin.ts` - Add `auth` hook to returned Hooks -- `src/index.ts` - Export auth module (if needed) - -## Testing Plan - -1. Run `opencode auth login cursor-acp` -2. Verify browser opens with cursor.com login URL -3. Complete authentication in browser -4. Verify success message in OpenCode -5. Verify `~/.cursor/auth.json` exists -6. Test using cursor-acp model: `opencode run "test" --model cursor-acp/auto` - -## Dependencies -- No new dependencies needed -- Uses existing `child_process` and `fs` modules - -## Notes -- No token parsing required -- No refresh logic needed -- No logout functionality (users can use `cursor-agent logout`) -- Re-authentication handled by re-running `opencode auth login cursor-acp` diff --git a/docs/plans/2026-02-07-streaming-fix-plan.md b/docs/plans/2026-02-07-streaming-fix-plan.md deleted file mode 100644 index 7aefed1..0000000 --- a/docs/plans/2026-02-07-streaming-fix-plan.md +++ /dev/null @@ -1,268 +0,0 @@ -# Fix Streaming + Tool Calls + Thinking (Issue #9) - -**Status**: NOT IMPLEMENTED -**Issue**: https://github.com/Nomadcxx/opencode-cursor/issues/9 -**Goal**: Fix streaming so responses arrive incrementally, with full tool_call and thinking support - -## Architecture - -``` -cursor-agent (stream-json) → JSON lines on stdout → parse events → convert to OpenAI SSE → client -``` - -**Three files to fix**: -- `src/client/simple.ts` — standalone cursor-agent client (direct mode) -- `src/plugin.ts` — Bun/Node.js HTTP proxy (plugin mode, **primary code path**) -- `src/provider.ts` — AI SDK provider wrapping both modes - -**Key decisions**: -- Switch from `--output-format text` to `--output-format stream-json` everywhere -- Add `--stream-partial-output` flag everywhere -- Clean break — no backward compatibility with text mode -- Full tool_call + thinking forwarding - -## cursor-agent stream-json format (reference) - -Each line is a JSON object. Known event types: -```json -{"type": "assistant", "message": {"content": [{"text": "..."}]}} -{"type": "tool_call", "name": "...", "arguments": "...", "id": "..."} -{"type": "thinking", "content": "..."} -``` - -The exact format needs validation by running cursor-agent with `--output-format stream-json` and observing output. Step 1 below handles this. - -## OpenAI SSE format (target output) - -``` -data: {"id":"...","object":"chat.completion.chunk","created":...,"model":"...","choices":[{"index":0,"delta":{"content":"text"},"finish_reason":null}]} - -data: {"id":"...","object":"chat.completion.chunk","created":...,"model":"...","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_...","type":"function","function":{"name":"...","arguments":"..."}}]},"finish_reason":null}]} - -data: [DONE] -``` - ---- - -## Implementation Steps - -### Step 1: Discover cursor-agent stream-json format -**Files**: none (research only) -**Action**: Run `echo "Hello" | cursor-agent --print --output-format stream-json --stream-partial-output --model auto --workspace /tmp` and capture the raw output. Document the exact JSON line format for: assistant text, tool calls (if any), thinking/reasoning, completion signal. Save sample output as `tests/fixtures/stream-json-sample.jsonl` for use in tests. - -**Verify**: File exists with real cursor-agent output lines. - ---- - -### Step 2: Create stream-json parser module -**Files**: `src/streaming/parser.ts` (new) -**Action**: Create a module that parses cursor-agent stream-json lines into typed events. - -```typescript -// Types based on Step 1 findings -export interface CursorTextEvent { type: 'text'; content: string } -export interface CursorToolCallEvent { type: 'tool_call'; id: string; name: string; arguments: string } -export interface CursorThinkingEvent { type: 'thinking'; content: string } -export interface CursorDoneEvent { type: 'done' } -export interface CursorErrorEvent { type: 'error'; message: string } -export type CursorEvent = CursorTextEvent | CursorToolCallEvent | CursorThinkingEvent | CursorDoneEvent | CursorErrorEvent - -export function parseCursorLine(line: string): CursorEvent | null -``` - -**Test first**: `tests/unit/streaming-parser.test.ts` -- Test parsing each event type from Step 1 fixtures -- Test invalid JSON returns null -- Test empty/blank lines return null -- Test unknown event types are handled gracefully - -**Verify**: `npm test -- tests/unit/streaming-parser.test.ts` passes - ---- - -### Step 3: Create OpenAI SSE formatter module -**Files**: `src/streaming/formatter.ts` (new) -**Action**: Create a module that converts `CursorEvent` objects into OpenAI-compatible SSE chunk strings. - -```typescript -export function cursorEventToSSEChunk( - event: CursorEvent, - id: string, - created: number, - model: string -): string | null -// Returns: "data: {...}\n\n" or null if event should be skipped - -export function createDoneSSE(): string -// Returns: "data: [DONE]\n\n" -``` - -Mapping: -- `CursorTextEvent` → `delta: { content: text }` -- `CursorToolCallEvent` → `delta: { tool_calls: [{ index, id, type: "function", function: { name, arguments } }] }` -- `CursorThinkingEvent` → `delta: { content: thinking_text }` (with a metadata field or role annotation — check what OpenCode expects) -- `CursorDoneEvent` → finish_reason: "stop" chunk + `[DONE]` - -**Test first**: `tests/unit/streaming-formatter.test.ts` -- Test each event type produces valid SSE -- Test JSON.parse on the data payload succeeds -- Test [DONE] format is correct -- Test null returned for unknown events - -**Verify**: `npm test -- tests/unit/streaming-formatter.test.ts` passes - ---- - -### Step 4: Create line buffering utility -**Files**: `src/streaming/line-buffer.ts` (new) -**Action**: Create a utility that buffers raw binary chunks into complete lines. cursor-agent outputs one JSON object per line, but chunks from stdout may split mid-line. - -```typescript -export class LineBuffer { - push(chunk: string): string[] // Returns complete lines - flush(): string | null // Returns remaining partial line -} -``` - -**Test first**: `tests/unit/line-buffer.test.ts` -- Test single complete line -- Test line split across two chunks -- Test multiple lines in one chunk -- Test flush returns partial data -- Test empty chunks - -**Verify**: `npm test -- tests/unit/line-buffer.test.ts` passes - ---- - -### Step 5: Fix plugin.ts — Bun streaming path -**Files**: `src/plugin.ts` -**Action**: Modify the Bun handler (lines 169-264) to: - -1. Change cmd args from `--output-format text` to `--output-format stream-json` and add `--stream-partial-output` -2. In the streaming ReadableStream (L227-264): - - Use `LineBuffer` to buffer chunks into complete lines - - Use `parseCursorLine()` on each line - - Use `cursorEventToSSEChunk()` to format SSE output - - Enqueue formatted SSE chunks -3. In the non-streaming path (L192-220): - - Collect all stdout, parse all JSON lines - - Extract final assistant text + any tool calls - - Return proper `chat.completion` response with tool_calls in choices - -**Test**: Run existing `tests/unit/plugin.test.ts` — should still pass (or update mocks for new format) - -**Verify**: `npm test -- tests/unit/plugin.test.ts` passes - ---- - -### Step 6: Fix plugin.ts — Node.js streaming path -**Files**: `src/plugin.ts` -**Action**: Modify the Node.js handler (lines 376-509) with same changes as Step 5: - -1. Change cmd args from `--output-format text` to `--output-format stream-json` and add `--stream-partial-output` -2. In streaming `child.stdout.on('data')` handler (L455-471): - - Use `LineBuffer` to buffer chunks into complete lines - - Parse + format each line as SSE -3. In non-streaming `child.on('close')` handler (L400-443): - - Parse JSON lines from stdout - - Build proper response with tool_calls - -**Test**: Same as Step 5 - -**Verify**: `npm test -- tests/unit/plugin.test.ts` passes - ---- - -### Step 7: Fix simple.ts — streaming client -**Files**: `src/client/simple.ts` -**Action**: Fix `executePromptStream()` (L33-145): - -1. Change `--output-format text` to `--output-format stream-json` (L53-54) -2. **Remove the `await streamEnded` blocking call** (L128-129) — this is THE streaming bug -3. Yield lines incrementally as they arrive from stdout instead of collecting into array first -4. Use `LineBuffer` for proper chunk-to-line buffering - -New flow: -``` -stdout.on('data') → LineBuffer.push() → for each complete line → yield line -``` - -Also fix `executePrompt()` (L147-248): -1. Change `--output-format text` to `--output-format stream-json` -2. Parse JSON lines for all event types, not just `assistant` - -**Test**: Update any tests that mock simple.ts behavior - -**Verify**: `npm test` — all tests pass - ---- - -### Step 8: Fix provider.ts — event type handling -**Files**: `src/provider.ts` -**Action**: In `doStream()` (L177-248): - -1. Handle `tool_call` events from cursor-agent (currently skipped at L225) -2. Handle `thinking` events -3. Emit proper OpenAI chunk types for each event - -In `doGenerate()` (L123-171): -1. Parse all event types from non-streaming response -2. Include tool_calls in response if present - -**Test**: Verify provider returns tool_calls and thinking content - -**Verify**: `npm test` passes - ---- - -### Step 9: Integration test with real cursor-agent -**Files**: `tests/integration/streaming.test.ts` (new) -**Action**: Write an integration test that: -1. Spawns cursor-agent with `--output-format stream-json --stream-partial-output` -2. Sends a simple prompt -3. Verifies lines arrive incrementally (not all at once) -4. Verifies JSON parsing succeeds on each line -5. Verifies at least one `assistant` event is present - -Mark as `.skip` if cursor-agent not available in CI. - -**Verify**: Test passes locally with cursor-agent installed - ---- - -### Step 10: Run full test suite + build -**Action**: -```bash -npm test -npm run build -``` - -Fix any failures. Ensure no regressions. - -**Verify**: Exit code 0 for both commands. - ---- - -## Risks & Open Questions - -1. **cursor-agent stream-json format is undocumented** — Step 1 validates the actual format before any code is written. If the format differs from our assumptions, Steps 2-3 adjust accordingly. -2. **Tool calls may not appear in stream-json** — cursor-agent may not emit tool_call events at all in print mode. Step 1 will reveal this. -3. **Thinking events may have different format** — some models emit thinking as a separate event, others embed it. Step 1 resolves this. -4. **OpenCode's expectations for thinking content** — need to check what delta format OpenCode expects for reasoning/thinking tokens. May need `reasoning_content` field instead of `content`. - -## Files Changed Summary - -| File | Change | -|------|--------| -| `src/streaming/parser.ts` | NEW — cursor-agent JSON line parser | -| `src/streaming/formatter.ts` | NEW — OpenAI SSE chunk formatter | -| `src/streaming/line-buffer.ts` | NEW — stdout line buffering | -| `src/plugin.ts` | Switch to stream-json, parse + format SSE properly | -| `src/client/simple.ts` | Fix await blocking, switch to stream-json | -| `src/provider.ts` | Handle tool_call + thinking events | -| `tests/unit/streaming-parser.test.ts` | NEW | -| `tests/unit/streaming-formatter.test.ts` | NEW | -| `tests/unit/line-buffer.test.ts` | NEW | -| `tests/fixtures/stream-json-sample.jsonl` | NEW — real cursor-agent output | -| `tests/integration/streaming.test.ts` | NEW | diff --git a/docs/plans/2026-02-07-windows-support-design.md b/docs/plans/2026-02-07-windows-support-design.md deleted file mode 100644 index 435033e..0000000 --- a/docs/plans/2026-02-07-windows-support-design.md +++ /dev/null @@ -1,196 +0,0 @@ -# Windows Support Design - -**Status**: ⚠️ NOT IMPLEMENTED — Design document only -**Date**: 2026-02-07 -**Scope**: Experimental Windows support for opencode-cursor - -## Motivation - -opencode-cursor currently supports Linux and macOS. Windows support is not urgent (small user base, no reported demand), but documenting a plan now captures research done while it's fresh and provides a roadmap for contributors who may want to pick this up. - -## Key Insight: OpenCode Already Solves Most Platform Problems - -OpenCode's framework handles the hardest cross-platform concerns at the infrastructure level: - -| Concern | How OpenCode Handles It | -|---------|------------------------| -| **Config/data paths** | Uses `xdg-basedir` → maps to `%LOCALAPPDATA%` on Windows automatically | -| **Plugin discovery** | Uses `path.join` + glob everywhere — no hardcoded separators | -| **Plugin loading** | `config.plugin` array + dynamic imports — platform-agnostic | -| **Skill discovery** | Globs `agent/**/*.md` from config dirs — uses `path.join` | -| **Binary extensions** | Pattern: `process.platform === 'win32' ? '.exe' : ''` | -| **File permissions** | Pattern: `if (platform !== 'win32') chmod(...)` | - -**What this means**: Our plugin doesn't need to solve platform path resolution from scratch. We just need to follow OpenCode's existing patterns and handle our cursor-specific concerns. - -### Reference Patterns from OpenCode - -**Binary extension** (from `lsp/server.ts`): -```typescript -const ext = process.platform === 'win32' ? '.exe' : '' -const bin = path.join(dir, `cursor-agent${ext}`) -``` - -**Conditional chmod** (from `lsp/server.ts`): -```typescript -if (process.platform !== 'win32') { - await $`chmod +x ${bin}`.nothrow() -} -``` - -**Auth plugins** (`opencode-copilot-auth`, `opencode-anthropic-auth`): -- Pure HTTP/OAuth — no filesystem, no process spawning -- Fully platform-agnostic by design -- Shows that OpenCode's plugin API doesn't impose Unix assumptions - -## Current Platform-Specific Surface Area - -Every file that assumes Unix semantics: - -| File | Unix Assumption | Windows Concern | Difficulty | -|------|----------------|-----------------|------------| -| `install.sh` | Bash, `ln -sf`, `chmod +x`, `~/.local/bin` | No bash by default | Medium | -| `sync-models.sh` | Bash script | Same as install.sh | Medium | -| `src/auth.ts` | `~/.cursor/auth.json`, `~/.config/cursor/auth.json` | Windows uses `%APPDATA%\Cursor\` | **Easy** — follow Vercel's pattern | -| `src/cli/discover.ts` | `homedir() + '/.config'` XDG convention | `%LOCALAPPDATA%` | **Easy** — `xdg-basedir` handles it | -| `src/proxy/server.ts` | `ss` / `lsof` for port detection | `netstat` or PowerShell | Easy | -| `src/client/simple.ts` | `SIGTERM` for process termination | No SIGTERM on Windows | Easy — `process.kill(pid)` works | -| `src/models/discovery.ts` | `spawn()` of cursor-agent | `.exe` extension needed | **Easy** — follow OpenCode's pattern | -| `src/plugin.ts` | `spawn()` / `exec()` calls | Shell differences | Easy | -| `src/tools/defaults.ts` | Various exec/spawn | Same | Easy | - -**Assessment**: With OpenCode's patterns as reference, most items are straightforward. The only medium-effort items are the shell scripts (install.sh, sync-models.sh) which need a cross-platform alternative. - -## Remaining Unknowns - -These still require a Windows machine running Cursor to confirm: - -1. **cursor-agent binary path**: Likely `%LOCALAPPDATA%\Programs\cursor\resources\app\cursor-agent.exe` but unconfirmed -2. **cursor-agent Windows behavior**: Same CLI flags? Same stdin/stdout protocol? -3. **Auth file location**: Likely `%APPDATA%\Cursor\auth.json` based on Vercel's patterns - -### How to Resolve - -A Windows contributor needs to run: -```powershell -# Find cursor-agent -Get-ChildItem -Path "$env:LOCALAPPDATA\Programs\cursor" -Recurse -Filter "cursor-agent*" -Get-ChildItem -Path "$env:LOCALAPPDATA\cursor" -Recurse -Filter "cursor-agent*" - -# Find auth file -Get-ChildItem -Path "$env:APPDATA\Cursor" -Recurse -Filter "auth*" -Get-ChildItem -Path "$env:LOCALAPPDATA\Cursor" -Recurse -Filter "auth*" - -# Test cursor-agent -& "path\to\cursor-agent.exe" --help -``` - -## Implementation Plan - -### Phase 1: Create `src/platform.ts` (~1 hour) - -Centralized platform utilities following OpenCode's patterns: - -```typescript -import path from 'path' -import os from 'os' - -export function getCursorConfigDir(): string { - switch (process.platform) { - case 'win32': - return path.join(process.env.APPDATA!, 'Cursor') - case 'darwin': - return path.join(os.homedir(), 'Library', 'Application Support', 'Cursor') - default: - return path.join( - process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), - 'Cursor' - ) - } -} - -export function getCursorDataDir(): string { - switch (process.platform) { - case 'win32': - return path.join(process.env.LOCALAPPDATA!, 'cursor') - case 'darwin': - return path.join(os.homedir(), '.cursor') - default: - return path.join(os.homedir(), '.cursor') - } -} - -export function getBinaryName(name: string): string { - return process.platform === 'win32' ? `${name}.exe` : name -} - -export function getAuthPaths(): string[] { - const config = getCursorConfigDir() - const data = getCursorDataDir() - return [ - path.join(data, 'auth.json'), - path.join(config, 'auth.json'), - ] -} -``` - -### Phase 2: Refactor Existing Code (~2 hours) - -Replace all hardcoded paths with `platform.ts` functions: - -- `auth.ts`: Use `getAuthPaths()` instead of hardcoded Unix paths -- `discover.ts`: Use `getCursorDataDir()` for cursor-agent lookup -- `client/simple.ts`: Use `process.kill(pid)` instead of `SIGTERM` (works cross-platform in Node.js) -- `proxy/server.ts`: Add Windows port detection: - ```typescript - if (process.platform === 'win32') { - // netstat -ano | findstr :PORT - } else { - // existing ss/lsof logic - } - ``` - -### Phase 3: Cross-Platform Installation (~2 hours) - -Replace bash scripts with a Node.js installer: - -- Create `scripts/install.js` that handles all platforms -- npm `bin` field in `package.json` auto-generates `.cmd` shims on Windows (no symlinks needed) -- Keep `install.sh` as thin wrapper calling `node scripts/install.js` for backward compat -- `sync-models.sh` → `scripts/sync-models.js` - -### Phase 4: Testing (~1 hour) - -- Add `process.platform` mocking to existing test suite -- Add Windows-specific path resolution tests -- CI: Add `windows-latest` to GitHub Actions matrix -- Note: Integration tests still require Cursor installed — Windows CI covers unit tests only - -## Rollout Strategy - -Ship as **experimental** behind an environment variable: - -``` -OPENCODE_CURSOR_EXPERIMENTAL_WINDOWS=1 -``` - -- README gets a "Windows (Experimental)" section -- Platform badge added alongside Linux/macOS -- Issue template for Windows bug reports -- Remove experimental flag once 2-3 users confirm it works - -## Effort Estimate - -| Phase | Effort | Blocked On | -|-------|--------|------------| -| Phase 1: `platform.ts` | ~1 hour | Nothing — can implement now | -| Phase 2: Refactor | ~2 hours | Phase 1 | -| Phase 3: Installation | ~2 hours | Phase 1 | -| Phase 4: Testing | ~1 hour | Phases 1-3 | -| **Total** | **~6 hours** | **Windows contributor for validation** | - -> Previous estimate was ~12 hours. Reduced by 50% after discovering OpenCode's existing cross-platform infrastructure. - -## Decision Log - -- **2026-02-07**: Initial design drafted at ~12 hour estimate. Revised after studying OpenCode's cross-platform patterns (xdg-basedir, LSP server binary handling, auth plugin architecture). Most path resolution is handled by the framework — our plugin just needs to follow established patterns. Revised estimate: ~6 hours. diff --git a/docs/plans/2026-02-09-critical-fixes-proxy-security-routing.md b/docs/plans/2026-02-09-critical-fixes-proxy-security-routing.md deleted file mode 100644 index 7bf9c83..0000000 --- a/docs/plans/2026-02-09-critical-fixes-proxy-security-routing.md +++ /dev/null @@ -1,594 +0,0 @@ -# Critical Fixes: Proxy, Security & Tool Routing - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Fix command injection in grep/glob tools, wire up the proxy prompt builder for tool message handling, fix tool routing bugs (SdkExecutor, env var inconsistency, MCP source filter), add CI/CD, and add SdkExecutor tests. - -**Architecture:** Replace shell-interpolated `exec()` with `execFile()` to eliminate injection. Replace inline message-to-prompt conversion in both proxy handlers with the existing `buildPromptFromMessages()` function. Fix executor `canExecute()` to use toolId-based gating. Unify env var parsing to a single constant. - -**Tech Stack:** TypeScript, bun:test, Node.js `child_process.execFile`, GitHub Actions - ---- - -### Task 1: Fix command injection in grep and glob tools - -**Files:** -- Modify: `src/tools/defaults.ts:203-222` (grep handler) -- Modify: `src/tools/defaults.ts:280-298` (glob handler) -- Test: `tests/tools/defaults.test.ts` - -**Step 1: Write failing tests for injection-safe grep and glob** - -In `tests/tools/defaults.test.ts`, add these tests at the end of the `describe` block: - -```typescript - it("should execute grep tool safely with special characters in pattern", async () => { - const registry = new ToolRegistry(); - registerDefaultTools(registry); - const executor = new LocalExecutor(registry); - - const fs = await import("fs"); - const tmpFile = `/tmp/test-grep-${Date.now()}.txt`; - fs.writeFileSync(tmpFile, "hello world\nfoo bar\n", "utf-8"); - - const result = await executeWithChain([executor], "grep", { - pattern: "hello", - path: tmpFile - }); - - expect(result.status).toBe("success"); - expect(result.output).toContain("hello world"); - - fs.unlinkSync(tmpFile); - }); - - it("should execute glob tool safely", async () => { - const registry = new ToolRegistry(); - registerDefaultTools(registry); - const executor = new LocalExecutor(registry); - - const result = await executeWithChain([executor], "glob", { - pattern: "*.ts", - path: "src/tools" - }); - - expect(result.status).toBe("success"); - expect(result.output).toContain(".ts"); - }); -``` - -**Step 2: Run tests to verify they pass (baseline)** - -Run: `bun test tests/tools/defaults.test.ts` -Expected: ALL PASS (tests work with current implementation too) - -**Step 3: Rewrite grep handler to use execFile** - -In `src/tools/defaults.ts`, replace the grep handler body (lines 203-222). Change from `exec()` with string interpolation to `execFile()` with argument array: - -Replace this entire handler callback: -```typescript - }, async (args) => { - const { exec } = await import("child_process"); - const { promisify } = await import("util"); - const execAsync = promisify(exec); - - try { - const pattern = args.pattern as string; - const path = args.path as string; - const include = args.include as string | undefined; - const includeFlag = include ? `--include="${include}"` : ""; - const { stdout } = await execAsync( - `grep -r ${includeFlag} -n "${pattern}" "${path}" 2>/dev/null || true`, - { timeout: 30000 } - ); - - return stdout || "No matches found"; - } catch (error: any) { - throw error; - } - }); -``` - -With: -```typescript - }, async (args) => { - const { execFile } = await import("child_process"); - const { promisify } = await import("util"); - const execFileAsync = promisify(execFile); - - const pattern = args.pattern as string; - const path = args.path as string; - const include = args.include as string | undefined; - - const grepArgs = ["-r", "-n"]; - if (include) { - grepArgs.push(`--include=${include}`); - } - grepArgs.push(pattern, path); - - try { - const { stdout } = await execFileAsync("grep", grepArgs, { timeout: 30000 }); - return stdout || "No matches found"; - } catch (error: any) { - // grep exits with code 1 when no matches found — not an error - if (error.code === 1) { - return "No matches found"; - } - throw error; - } - }); -``` - -**Step 4: Rewrite glob handler to use execFile** - -In `src/tools/defaults.ts`, replace the glob handler body (lines 280-298). Change from `exec()` with string interpolation to `execFile()` with argument array: - -Replace this entire handler callback: -```typescript - }, async (args) => { - const { exec } = await import("child_process"); - const { promisify } = await import("util"); - const execAsync = promisify(exec); - - try { - const pattern = args.pattern as string; - const path = args.path as string | undefined; - const cwd = path || "."; - const { stdout } = await execAsync( - `find "${cwd}" -type f -name "${pattern}" 2>/dev/null | head -50`, - { timeout: 30000 } - ); - - return stdout || "No files found"; - } catch (error: any) { - throw error; - } - }); -``` - -With: -```typescript - }, async (args) => { - const { execFile } = await import("child_process"); - const { promisify } = await import("util"); - const execFileAsync = promisify(execFile); - - const pattern = args.pattern as string; - const path = args.path as string | undefined; - const cwd = path || "."; - - try { - const { stdout } = await execFileAsync( - "find", [cwd, "-type", "f", "-name", pattern], - { timeout: 30000 } - ); - // Limit output to 50 lines (replaces piped `| head -50`) - const lines = (stdout || "").split("\n").filter(Boolean); - return lines.slice(0, 50).join("\n") || "No files found"; - } catch (error: any) { - throw error; - } - }); -``` - -**Step 5: Run tests** - -Run: `bun test tests/tools/defaults.test.ts` -Expected: ALL PASS - -**Step 6: Commit** - -```bash -git add src/tools/defaults.ts tests/tools/defaults.test.ts -git commit -m "fix: eliminate command injection in grep and glob tools using execFile" -``` - ---- - -### Task 2: Wire up prompt builder for proxy handlers - -**Files:** -- Modify: `src/plugin.ts:209-231` (Bun handler message conversion) -- Modify: `src/plugin.ts:491-513` (Node.js handler message conversion) -- Reference: `src/proxy/prompt-builder.ts` (already tested, already handles role:tool and body.tools) -- Test: `tests/unit/proxy/prompt-builder.test.ts` (already exists with 9 tests) - -**Step 1: Add import for buildPromptFromMessages** - -At the top of `src/plugin.ts`, add after the existing imports (around line 14): - -```typescript -import { buildPromptFromMessages } from "./proxy/prompt-builder.js"; -``` - -**Step 2: Replace Bun handler message conversion (lines 209-231)** - -Find the block (inside the Bun `handler` function): -```typescript - // Convert messages to prompt - const lines: string[] = []; - for (const message of messages) { - const role = typeof message.role === "string" ? message.role : "user"; - const content = message.content; - - if (typeof content === "string") { - lines.push(`${role.toUpperCase()}: ${content}`); - } else if (Array.isArray(content)) { - const textParts = content - .map((part: any) => { - if (part && typeof part === "object" && part.type === "text" && typeof part.text === "string") { - return part.text; - } - return ""; - }) - .filter(Boolean); - if (textParts.length) { - lines.push(`${role.toUpperCase()}: ${textParts.join("\n")}`); - } - } - } - const prompt = lines.join("\n\n"); -``` - -Replace with: -```typescript - const prompt = buildPromptFromMessages(messages, tools); -``` - -**Step 3: Replace Node.js handler message conversion (lines 491-513)** - -Find the identical block (inside the Node.js `requestHandler` function): -```typescript - // Convert messages to prompt - const lines: string[] = []; - for (const message of messages) { - const role = typeof message.role === "string" ? message.role : "user"; - const content = message.content; - - if (typeof content === "string") { - lines.push(`${role.toUpperCase()}: ${content}`); - } else if (Array.isArray(content)) { - const textParts = content - .map((part: any) => { - if (part && typeof part === "object" && part.type === "text" && typeof part.text === "string") { - return part.text; - } - return ""; - }) - .filter(Boolean); - if (textParts.length) { - lines.push(`${role.toUpperCase()}: ${textParts.join("\n")}`); - } - } - } - const prompt = lines.join("\n\n"); -``` - -Replace with: -```typescript - const prompt = buildPromptFromMessages(messages, tools); -``` - -Note: The Node.js handler also needs `tools` extracted. Find this line (around line 489): -```typescript - const stream = bodyData?.stream === true; -``` -Add after it: -```typescript - const tools = Array.isArray(bodyData?.tools) ? bodyData.tools : []; -``` - -**Step 4: Run prompt builder tests to confirm they still pass** - -Run: `bun test tests/unit/proxy/prompt-builder.test.ts` -Expected: ALL 9 PASS - -**Step 5: Run full test suite** - -Run: `bun test tests/tools/defaults.test.ts tests/tools/executor-chain.test.ts tests/integration/comprehensive.test.ts tests/unit/plugin.test.ts` -Expected: ALL PASS - -**Step 6: Commit** - -```bash -git add src/plugin.ts -git commit -m "fix: use prompt builder for tool message handling in proxy" -``` - ---- - -### Task 3: Fix SdkExecutor, env var, and MCP source filter - -**Files:** -- Modify: `src/tools/executors/sdk.ts:6-11` -- Modify: `src/plugin.ts:52,841,885-889` -- Create: `tests/tools/sdk-executor.test.ts` - -**Step 1: Write SdkExecutor tests** - -Create `tests/tools/sdk-executor.test.ts`: - -```typescript -import { describe, it, expect } from "bun:test"; -import { SdkExecutor } from "../../src/tools/executors/sdk.js"; - -describe("SdkExecutor", () => { - it("should return false for canExecute when no client", () => { - const exec = new SdkExecutor(null, 5000); - expect(exec.canExecute("any-tool")).toBe(false); - }); - - it("should return false for canExecute when client lacks tool.invoke", () => { - const exec = new SdkExecutor({}, 5000); - expect(exec.canExecute("any-tool")).toBe(false); - }); - - it("should return false for canExecute when toolId not registered", () => { - const client = { tool: { invoke: async () => "ok" } }; - const exec = new SdkExecutor(client, 5000); - // No tool IDs set — should reject - expect(exec.canExecute("unknown-tool")).toBe(false); - }); - - it("should return true for canExecute when toolId is registered", () => { - const client = { tool: { invoke: async () => "ok" } }; - const exec = new SdkExecutor(client, 5000); - exec.setToolIds(["my-tool", "other-tool"]); - expect(exec.canExecute("my-tool")).toBe(true); - expect(exec.canExecute("other-tool")).toBe(true); - expect(exec.canExecute("nope")).toBe(false); - }); - - it("should execute and return string output", async () => { - const client = { tool: { invoke: async (_id: string, _args: any) => "hello world" } }; - const exec = new SdkExecutor(client, 5000); - exec.setToolIds(["test-tool"]); - - const result = await exec.execute("test-tool", {}); - expect(result.status).toBe("success"); - expect(result.output).toBe("hello world"); - }); - - it("should JSON-stringify non-string output", async () => { - const client = { tool: { invoke: async () => ({ key: "value" }) } }; - const exec = new SdkExecutor(client, 5000); - exec.setToolIds(["test-tool"]); - - const result = await exec.execute("test-tool", {}); - expect(result.status).toBe("success"); - expect(result.output).toBe('{"key":"value"}'); - }); - - it("should return error when invoke throws", async () => { - const client = { tool: { invoke: async () => { throw new Error("sdk failure"); } } }; - const exec = new SdkExecutor(client, 5000); - exec.setToolIds(["test-tool"]); - - const result = await exec.execute("test-tool", {}); - expect(result.status).toBe("error"); - expect(result.error).toContain("sdk failure"); - }); - - it("should return error on timeout", async () => { - const client = { - tool: { - invoke: async () => new Promise((resolve) => setTimeout(() => resolve("late"), 10000)) - } - }; - const exec = new SdkExecutor(client, 50); // 50ms timeout - exec.setToolIds(["test-tool"]); - - const result = await exec.execute("test-tool", {}); - expect(result.status).toBe("error"); - expect(result.error).toContain("timeout"); - }); - - it("should return error when canExecute is false", async () => { - const exec = new SdkExecutor(null, 5000); - const result = await exec.execute("any-tool", {}); - expect(result.status).toBe("error"); - expect(result.error).toContain("unavailable"); - }); -}); -``` - -**Step 2: Run tests to verify they fail** - -Run: `bun test tests/tools/sdk-executor.test.ts` -Expected: FAIL — `setToolIds` doesn't exist on SdkExecutor yet, and `canExecute` doesn't accept toolId - -**Step 3: Add `setToolIds` and toolId-aware `canExecute` to SdkExecutor** - -Replace the full content of `src/tools/executors/sdk.ts` with: - -```typescript -import type { IToolExecutor, ExecutionResult } from "../core/types.js"; -import { createLogger } from "../../utils/logger.js"; - -const log = createLogger("tools:executor:sdk"); - -export class SdkExecutor implements IToolExecutor { - private toolIds = new Set(); - - constructor(private client: any, private timeoutMs: number) {} - - setToolIds(ids: Iterable): void { - this.toolIds = new Set(ids); - } - - canExecute(toolId: string): boolean { - return this.toolIds.has(toolId) && Boolean(this.client?.tool?.invoke); - } - - async execute(toolId: string, args: Record): Promise { - if (!this.canExecute(toolId)) return { status: "error", error: "SDK invoke unavailable" }; - try { - const p = this.client.tool.invoke(toolId, args); - const res = await this.runWithTimeout(p); - const out = typeof res === "string" ? res : JSON.stringify(res); - return { status: "success", output: out }; - } catch (err: any) { - log.warn("SDK tool execution failed", { toolId, error: String(err?.message || err) }); - return { status: "error", error: String(err?.message || err) }; - } - } - - private async runWithTimeout(p: Promise): Promise { - if (!this.timeoutMs) return p; - return await Promise.race([ - p, - new Promise((_, reject) => setTimeout(() => reject(new Error("tool execution timeout")), this.timeoutMs)), - ]); - } -} -``` - -**Step 4: Fix env var inconsistency and MCP source filter in plugin.ts** - -In `src/plugin.ts`, make these three changes: - -**Change 1:** Remove the local `forwardToolCalls` variable (line 841). Change: -```typescript - const forwardToolCalls = process.env.CURSOR_ACP_FORWARD_TOOL_CALLS !== "false"; // default ON -``` -To: -```typescript - // forwardToolCalls uses the module-level FORWARD_TOOL_CALLS constant (line 52) -``` - -Then find all 4 occurrences of `toolRouter && forwardToolCalls` (around lines 342, 603, 639, and any other) and change to `toolRouter && FORWARD_TOOL_CALLS`: -```typescript -if (toolRouter && FORWARD_TOOL_CALLS) { -``` - -**Change 2:** Fix the MCP/SDK source filter (lines 885-889). Replace: -```typescript - // Populate MCP executor with discovered SDK tool IDs - if (mcpExec) { - const sdkToolIds = list.filter((t) => t.source === "sdk").map((t) => t.id); - mcpExec.setToolIds(sdkToolIds); - } -``` -With: -```typescript - // Populate executors with their respective tool IDs - if (sdkExec) { - sdkExec.setToolIds(list.filter((t) => t.source === "sdk").map((t) => t.id)); - } - if (mcpExec) { - mcpExec.setToolIds(list.filter((t) => t.source === "mcp").map((t) => t.id)); - } -``` - -**Step 5: Run all tests** - -Run: `bun test tests/tools/sdk-executor.test.ts tests/tools/executor-chain.test.ts tests/tools/defaults.test.ts tests/integration/comprehensive.test.ts` -Expected: ALL PASS - -**Step 6: Commit** - -```bash -git add src/tools/executors/sdk.ts src/plugin.ts tests/tools/sdk-executor.test.ts -git commit -m "fix: SdkExecutor toolId gating, env var consistency, MCP source filter" -``` - ---- - -### Task 4: Add CI/CD pipeline - -**Files:** -- Create: `.github/workflows/ci.yml` - -**Step 1: Create the workflow file** - -Create `.github/workflows/ci.yml`: - -```yaml -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - run: bun install - - - run: bun run build - - - name: Run tests - run: | - bun test \ - tests/tools/defaults.test.ts \ - tests/tools/executor-chain.test.ts \ - tests/tools/sdk-executor.test.ts \ - tests/tools/mcp-executor.test.ts \ - tests/tools/skills.test.ts \ - tests/tools/registry.test.ts \ - tests/integration/comprehensive.test.ts \ - tests/integration/tools-router.integration.test.ts \ - tests/unit/proxy/prompt-builder.test.ts \ - tests/unit/plugin.test.ts \ - tests/unit/plugin-tools-hook.test.ts \ - tests/unit/plugin-config.test.ts \ - tests/unit/auth.test.ts \ - tests/unit/streaming/line-buffer.test.ts \ - tests/unit/streaming/parser.test.ts \ - tests/unit/streaming/types.test.ts \ - tests/unit/streaming/delta-tracker.test.ts \ - tests/competitive/edge.test.ts -``` - -Note: Tests are listed explicitly to avoid picking up `temp_repo/` files. The streaming openai-sse tests are excluded because they have pre-existing failures (fixture mismatch with `timestamp_ms` — separate fix). - -**Step 2: Verify the workflow file is valid YAML** - -Run: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml'))"` -Expected: No output (valid YAML) - -**Step 3: Commit** - -```bash -git add .github/workflows/ci.yml -git commit -m "ci: add GitHub Actions workflow for testing" -``` - ---- - -## Verification - -After all tasks are complete, run the full test suite to verify nothing is broken: - -```bash -bun test \ - tests/tools/defaults.test.ts \ - tests/tools/executor-chain.test.ts \ - tests/tools/sdk-executor.test.ts \ - tests/tools/mcp-executor.test.ts \ - tests/tools/skills.test.ts \ - tests/tools/registry.test.ts \ - tests/integration/comprehensive.test.ts \ - tests/integration/tools-router.integration.test.ts \ - tests/unit/proxy/prompt-builder.test.ts \ - tests/unit/plugin.test.ts \ - tests/unit/plugin-tools-hook.test.ts \ - tests/competitive/edge.test.ts -``` - -Expected: ALL PASS - -Then verify with debug logging: -```bash -CURSOR_ACP_LOG_LEVEL=debug bun test tests/tools/sdk-executor.test.ts 2>&1 | grep "tools:executor:sdk" -``` From ca93ed4c840bb1f64f6eabb0df0fadbc780a2bc2 Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Sun, 15 Feb 2026 23:43:22 +1100 Subject: [PATCH 2/5] chore: gitignore entire docs/plans/ directory Changed pattern from docs/plans/*.md to docs/plans/ to cover all file types in the plans directory. --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 4c4f22e..544d113 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,4 @@ UNINSTALL_STEPS.md # Session logs and plans *-cd-*.txt -docs/plans/*.md +docs/plans/ From ded1912093a79cc4d15c419f868f5652bb1b3dfb Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Mon, 16 Feb 2026 00:07:19 +1100 Subject: [PATCH 3/5] docs: update README with npm-direct installation as primary option --- README.md | 112 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 85 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index a8605b1..087d6f0 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,41 @@ No prompt limits. No broken streams. Full thinking + tool support in Opencode. Y ## Installation -**Option A: One-Line Install** +**Option A — Add to opencode.json (Recommended)** + +The simplest approach—just add the npm package to your OpenCode config: + +```json +{ + "plugin": ["@rama_nigg/open-cursor@latest"], + "provider": { + "cursor-acp": { + "name": "Cursor ACP", + "npm": "@ai-sdk/openai-compatible", + "models": { + "cursor-acp/claude-sonnet": { "name": "Claude Sonnet" }, + "cursor-acp/gpt-4o": { "name": "GPT-4o" }, + "cursor-acp/gemini-2.5-pro": { "name": "Gemini 2.5 Pro" }, + "cursor-acp/cursor-small": { "name": "Cursor Small" } + } + } + } +} +``` + +After authenticating with `cursor-agent login`, run `cursor-agent models` to see the full model list, or use one of the automated installers below to auto-discover models. + +**Prerequisites:** Only OpenCode required. OpenCode's Bun runtime resolves the npm package automatically. + +**Option B — One-line installer (curl)** ```bash curl -fsSL https://raw.githubusercontent.com/Nomadcxx/opencode-cursor/main/install.sh | bash ``` -**Option B: npm Package (Recommended)** +This automated installer detects your environment and chooses the best installation method. + +**Option C — npm global install + CLI** ```bash npm install -g @rama_nigg/open-cursor @@ -29,7 +57,9 @@ npm update -g @rama_nigg/open-cursor open-cursor install ``` -**Option C: TUI Installer** +**Option D — Go TUI installer** + +Interactive visual installer with dual-mode support (quick install or build from source): ```bash git clone https://github.com/Nomadcxx/opencode-cursor.git @@ -37,46 +67,55 @@ cd opencode-cursor go build -o ./installer ./cmd/installer && ./installer ``` -**Option D: Let an LLM do it** +**Option E — LLM instructions** Paste this into any LLM agent (Claude Code, OpenCode, Cursor, etc.): ``` -Install the cursor-acp plugin for OpenCode: - -1. Preferred install (npm): - npm install -g @rama_nigg/open-cursor - open-cursor install - -2. Fallback (build from source): - git clone https://github.com/Nomadcxx/opencode-cursor.git - cd opencode-cursor - bun install && bun run build - mkdir -p ~/.config/opencode/plugin - ln -sf $(pwd)/dist/plugin-entry.js ~/.config/opencode/plugin/cursor-acp.js - ./scripts/sync-models.sh - -3. Verify: - opencode models | grep cursor-acp +Install the cursor-acp plugin for OpenCode using the npm package approach: + +1. Edit your opencode.json config file (usually at ~/.config/opencode/opencode.json) +2. Add "@rama_nigg/open-cursor@latest" to the plugin array +3. Add the cursor-acp provider configuration with models +4. Restart OpenCode + +Example configuration: +{ + "plugin": ["@rama_nigg/open-cursor@latest"], + "provider": { + "cursor-acp": { + "name": "Cursor ACP", + "npm": "@ai-sdk/openai-compatible", + "models": { + "cursor-acp/claude-sonnet": { "name": "Claude Sonnet" } + } + } + } +} + +5. Authenticate: cursor-agent login +6. Verify: opencode models | grep cursor-acp ``` -**Option E: Manual Install** +**Option F — Manual (from source)** + +For developers and contributors who want full control: ```bash +git clone https://github.com/Nomadcxx/opencode-cursor.git +cd opencode-cursor bun install && bun run build mkdir -p ~/.config/opencode/plugin ln -sf $(pwd)/dist/plugin-entry.js ~/.config/opencode/plugin/cursor-acp.js ``` -The installers handle the rest automatically. If you're doing a manual install, you'll need to do the following steps yourself. - -Easiest way is to run the sync script, which populates everything for you: +The automated installers handle configuration automatically. For manual installs, run the sync script to populate models: ```bash ./scripts/sync-models.sh ``` -Or if you'd rather do it by hand, add this to `~/.config/opencode/opencode.json` (then run `./scripts/sync-models.sh` to populate models): +Or configure manually by adding this to `~/.config/opencode/opencode.json` (then run `./scripts/sync-models.sh` to populate models): ```json { @@ -126,6 +165,22 @@ Or if you'd rather do it by hand, add this to `~/.config/opencode/opencode.json` } ``` +### Plugin Configuration Reference + +Depending on your installation method, use the appropriate plugin identifier: + +**npm package (recommended for production):** +```json +"plugin": ["@rama_nigg/open-cursor@latest"] +``` + +**Local build (for development):** +```json +"plugin": ["cursor-acp"] +``` + +Both approaches work—the npm package is resolved automatically by OpenCode's Bun runtime, while the local build requires the symlink setup shown in Option F above. + ## Authentication ### Option 1: Via OpenCode (Recommended) @@ -206,12 +261,15 @@ Detailed architecture: [docs/architecture/runtime-tool-loop.md](docs/architectur ## Prerequisites +**For Option A (npm-direct):** Only [OpenCode](https://opencode.ai/) required. + +**For Options B-F:** - [Bun](https://bun.sh/) - [cursor-agent](https://cursor.com/) - `curl -fsSL https://cursor.com/install | bash` -**Option A (one-line install):** If Go is installed, the script runs the TUI installer; otherwise it performs a shell-only install (Bun + cursor-agent required). For syncing models without the TUI, install [jq](https://jq.org/) or run `./scripts/sync-models.sh` after install. +**Option B (one-line install):** If Go is installed, the script runs the TUI installer; otherwise it performs a shell-only install (OpenCode + cursor-agent required). For syncing models without the TUI, install [jq](https://jq.org/) or run `./scripts/sync-models.sh` after install. -**Option B (TUI installer):** Go 1.21+ required to build the installer. +**Option D (TUI installer):** Go 1.21+ required to build the installer. ## Features From 5e5a932c3889e396a1c5c5d2251f930a4488f17d Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Mon, 16 Feb 2026 00:09:17 +1100 Subject: [PATCH 4/5] feat: update install.sh shell-only path to use npm package directly --- install.sh | 98 ++++++++++++++++++++---------------------------------- 1 file changed, 36 insertions(+), 62 deletions(-) diff --git a/install.sh b/install.sh index 8425821..58104bd 100755 --- a/install.sh +++ b/install.sh @@ -83,118 +83,92 @@ if command -v go &>/dev/null; then ./installer "$@" EXIT_CODE=$? else - echo "Go not found; using shell-only install (Bun and cursor-agent required)." + echo "Go not found; using shell-only install." echo "" - if ! command -v bun &>/dev/null; then - echo "Error: bun is not installed. Install with: curl -fsSL https://bun.sh/install | bash" - exit 1 - fi if ! command -v cursor-agent &>/dev/null; then echo "Error: cursor-agent is not installed. Install with: curl -fsSL https://cursor.com/install | bash" exit 1 fi - mkdir -p "$INSTALL_DIR" - cd "$INSTALL_DIR" - - echo "Downloading opencode-cursor..." - if [ -d ".git" ]; then - git pull origin main - else - git clone --depth 1 https://github.com/Nomadcxx/opencode-cursor.git . - fi - - echo "Building plugin..." - bun install - if ! bun run build; then - echo "Initial build failed. Retrying with forced dependency reinstall..." - bun install --force --no-cache - bun run build - fi - - if [ ! -s "dist/plugin-entry.js" ]; then - echo "Error: dist/plugin-entry.js not found or empty after build" - exit 1 - fi - echo "Installing AI SDK in OpenCode..." mkdir -p "${CONFIG_HOME}/opencode" - (cd "${CONFIG_HOME}/opencode" && bun install "@ai-sdk/openai-compatible") - - echo "Creating plugin symlink..." - mkdir -p "$PLUGIN_DIR" - rm -f "${PLUGIN_DIR}/cursor-acp.js" - ln -sf "$(pwd)/dist/plugin-entry.js" "${PLUGIN_DIR}/cursor-acp.js" + if command -v bun &>/dev/null; then + (cd "${CONFIG_HOME}/opencode" && bun install "@ai-sdk/openai-compatible") + else + echo "Warning: bun not found. The AI SDK (@ai-sdk/openai-compatible) must be installed manually." + echo "Install bun from https://bun.sh and run: cd ${CONFIG_HOME}/opencode && bun install @ai-sdk/openai-compatible" + fi echo "Updating config..." + NPM_PLUGIN="@rama_nigg/open-cursor@latest" if [ -f "$CONFIG_PATH" ]; then CONFIG_BACKUP="${CONFIG_PATH}.bak.$(date +%Y%m%d-%H%M%S)" cp "$CONFIG_PATH" "$CONFIG_BACKUP" echo "Config backup written to $CONFIG_BACKUP" fi - MODELS_JSON="{}" - if command -v jq &>/dev/null; then - RAW=$(cursor-agent models 2>&1 || true) - CLEAN=$(echo "$RAW" | sed 's/\x1b\[[0-9;]*[a-zA-Z]//g') - while IFS= read -r line; do - line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') - if [ -z "$line" ] || echo "$line" | grep -qE '^(Available|Tip:)'; then continue; fi - if echo "$line" | grep -qE '^[a-zA-Z0-9._-]+[[:space:]]+[-–—:][[:space:]]+'; then - id=$(echo "$line" | sed -E 's/^([a-zA-Z0-9._-]+)[[:space:]]+[-–—:][[:space:]]+.*/\1/') - name=$(echo "$line" | sed -E 's/^[a-zA-Z0-9._-]+[[:space:]]+[-–—:][[:space:]]+(.+?)([[:space:]]+\((current|default)\))?[[:space:]]*$/\1/' | sed 's/[[:space:]]*$//') - if [ -n "$id" ] && [ -n "$name" ]; then - MODELS_JSON=$(echo "$MODELS_JSON" | jq --arg id "$id" --arg name "$name" '. + {($id): {name: $name}}') - fi - fi - done <<< "$CLEAN" - fi if [ ! -f "$CONFIG_PATH" ]; then mkdir -p "$(dirname "$CONFIG_PATH")" echo '{"plugin":[],"provider":{}}' > "$CONFIG_PATH" fi + NPM_PLUGIN="@rama_nigg/open-cursor@latest" if command -v jq &>/dev/null; then - UPDATED=$(jq --argjson models "$MODELS_JSON" ' + UPDATED=$(jq --arg npmPlugin "$NPM_PLUGIN" ' .provider["cursor-acp"] = ((.provider["cursor-acp"] // {}) | . + { name: "Cursor", npm: "@ai-sdk/openai-compatible", - options: { baseURL: "http://127.0.0.1:32124/v1" }, - models: $models - }) | .plugin = ((.plugin // []) | if index("cursor-acp") then . else . + ["cursor-acp"] end) + options: { baseURL: "http://127.0.0.1:32124/v1" } + }) | .plugin = ((.plugin // []) | + if index("cursor-acp") then + . + elif map(select(startswith("@rama_nigg/open-cursor"))) | length > 0 then + . + else + . + [$npmPlugin] + end) ' "$CONFIG_PATH") echo "$UPDATED" > "$CONFIG_PATH" else bun -e " const fs=require('fs'); const p=process.argv[1]; + const npmPlugin=process.argv[2]; let c={}; try{c=JSON.parse(fs.readFileSync(p,'utf8'));}catch(_){} c.plugin=c.plugin||[]; - if(!c.plugin.includes('cursor-acp'))c.plugin.push('cursor-acp'); + const hasCursorAcp=c.plugin.includes('cursor-acp'); + const hasNpmPlugin=c.plugin.some(x=>typeof x==='string'&&x.startsWith('@rama_nigg/open-cursor')); + if(!hasCursorAcp&&!hasNpmPlugin)c.plugin.push(npmPlugin); c.provider=c.provider||{}; - c.provider['cursor-acp']={...(c.provider['cursor-acp']||{}),name:'Cursor',npm:'@ai-sdk/openai-compatible',options:{baseURL:'http://127.0.0.1:32124/v1'},models:{}}; + c.provider['cursor-acp']={...(c.provider['cursor-acp']||{}),name:'Cursor',npm:'@ai-sdk/openai-compatible',options:{baseURL:'http://127.0.0.1:32124/v1'}}; fs.writeFileSync(p,JSON.stringify(c,null,2)); - " "$CONFIG_PATH" - echo "Note: jq not found; models not synced. Run ./scripts/sync-models.sh after installing jq." + " "$CONFIG_PATH" "$NPM_PLUGIN" + echo "Note: jq not found; models not synced. Run ./scripts/sync-models.sh or cursor-agent models to populate." fi echo "" echo "Installation complete!" - echo "Plugin: ${PLUGIN_DIR}/cursor-acp.js" - echo "Repository: ${INSTALL_DIR} (uninstall: remove symlink and cursor-acp from opencode.json)" + echo "Plugin: $NPM_PLUGIN added to opencode.json" + echo "To sync models, run: cursor-agent models (then restart OpenCode)" EXIT_CODE=0 fi echo "" if [ $EXIT_CODE -eq 0 ]; then - echo "Repository kept at: ${INSTALL_DIR}" if command -v go &>/dev/null; then + echo "Repository kept at: ${INSTALL_DIR}" echo "Uninstall: cd ${INSTALL_DIR} && ./installer --uninstall" + else + echo "To uninstall, remove the plugin from your opencode.json plugins array." fi else - echo "Installation failed (exit code $EXIT_CODE). Repository kept at: ${INSTALL_DIR}" + if command -v go &>/dev/null; then + echo "Installation failed (exit code $EXIT_CODE). Repository kept at: ${INSTALL_DIR}" + else + echo "Installation failed (exit code $EXIT_CODE)." + fi fi exit $EXIT_CODE From b172a82847764ac4927cf45d1d8411ee403f744d Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Mon, 16 Feb 2026 00:11:17 +1100 Subject: [PATCH 5/5] =?UTF-8?q?chore:=20release=20v2.3.1=20=E2=80=94=20upd?= =?UTF-8?q?ated=20docs=20and=20installers=20for=20npm-direct=20installatio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 235 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 package-lock.json diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f6cbebd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,235 @@ +{ + "name": "@rama_nigg/open-cursor", + "version": "2.3.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@rama_nigg/open-cursor", + "version": "2.3.1", + "license": "ISC", + "dependencies": { + "@opencode-ai/plugin": "1.1.53", + "@opencode-ai/sdk": "1.1.53", + "ai": "^6.0.55", + "strip-ansi": "^7.1.0" + }, + "bin": { + "cursor-discover": "dist/cli/discover.js", + "open-cursor": "dist/cli/opencode-cursor.js" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.8.0" + }, + "peerDependencies": { + "@opencode-ai/sdk": "^1.0.0", + "bun-types": "^1.1.0" + } + }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.46", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.46.tgz", + "integrity": "sha512-zH1UbNRjG5woOXXFOrVCZraqZuFTtmPvLardMGcgLkzpxKV0U3tAGoyWKSZ862H+eBJfI/Hf2yj/zzGJcCkycg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.15", + "@vercel/oidc": "3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.15.tgz", + "integrity": "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.1.53", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.1.53.tgz", + "integrity": "sha512-9ye7Wz2kESgt02AUDaMea4hXxj6XhWwKAG8NwFhrw09Ux54bGaMJFt1eIS8QQGIMaD+Lp11X4QdyEg96etEBJw==", + "license": "MIT", + "dependencies": { + "@opencode-ai/sdk": "1.1.53", + "zod": "4.1.8" + } + }, + "node_modules/@opencode-ai/plugin/node_modules/zod": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", + "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.1.53", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.53.tgz", + "integrity": "sha512-RUIVnPOP1CyyU32FrOOYuE7Ge51lOBuhaFp2NSX98ncApT7ffoNetmwzqrhOiJQgZB1KrbCHLYOCK6AZfacxag==", + "license": "MIT" + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vercel/oidc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", + "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, + "node_modules/ai": { + "version": "6.0.86", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.86.tgz", + "integrity": "sha512-U2W2LBCHA/pr0Ui7vmmsjBiLEzBbZF3yVHNy7Rbzn7IX+SvoQPFM5rN74hhfVzZoE8zBuGD4nLLk+j0elGacvQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.46", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.15", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/bun-types": { + "version": "1.3.9", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.9.tgz", + "integrity": "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json index 41ffdf3..987327f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rama_nigg/open-cursor", - "version": "2.3.0", + "version": "2.3.1", "description": "No prompt limits. No broken streams. Full thinking + tool support. Your Cursor subscription, properly integrated.", "type": "module", "main": "dist/index.js",