From 2a0eec0cc8277b5adae40ce70bc46099fa9d76b0 Mon Sep 17 00:00:00 2001 From: arzafran Date: Mon, 20 Jul 2026 17:38:49 -0300 Subject: [PATCH] feat: first-class OpenCode integration via a local plugin (#139) OpenCode has no shell-hook config; its extension surface is a JS plugin. A small local plugin (embedded in the CLI, installed to OPENCODE_CONFIG_DIR/plugins/programa.js, else ~/.config/opencode/plugins/) shells out to programa opencode-hook at five lifecycle points: plugin boot (session-start), chat.message (prompt-submit), session.idle (stop), permission.asked (notification), dispose (session-end). Every call is quiet().nothrow() so a CLI hiccup can never take down the user's OpenCode session. No opencode.json edit, no npm install. - opencode-hook handler mirrors codex-hook: PID keys opencode/opencode., Running/Needs-input statuses, completion + attention notifications, guaranteed session-end cleanup. Mirrors codex-hook's quiet-on-missing-socket guard (claude-hook, verified, has no such guard and exits 1 there). - programa opencode install-integration / uninstall-integration with the house diff+confirm pattern. Uninstall refuses to delete the file if the managed-by marker is missing (a user-customized file is never destroyed). - Install OpenCode Integration... menubar item, en+ja strings. - tests_v2/test_opencode_install_integration.py: 6 tests including the marker-refusal path and modified-file restore. Verified: build green; all 6 CLI tests pass against the built binary. --- CHANGELOG.md | 1 + CLI/CLI+Hooks.swift | 503 ++++++++++++++++++ CLI/programa.swift | 50 ++ Resources/Localizable.xcstrings | 17 + Sources/AppDelegate.swift | 24 + Sources/ProgramaApp.swift | 9 + tests_v2/test_opencode_install_integration.py | 220 ++++++++ 7 files changed, 824 insertions(+) create mode 100644 tests_v2/test_opencode_install_integration.py diff --git a/CHANGELOG.md b/CHANGELOG.md index dd42a283..b650664e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p ### Added - Codex integration now matches Claude Code: "Needs input" notifications, guaranteed cleanup when a session dies without a clean stop, an "Install Codex Integration…" menu item, and `programa codex install-integration` naming (the old install-hooks name still works). Also fixed duplicate desktop notifications for Codex sessions — suppression of raw terminal notifications now applies to any hook-managed agent, not just Claude Code. +- OpenCode integration: session status, "Needs input" and completion notifications now work for OpenCode too, via a small local plugin. "Install OpenCode Integration…" in the File menu (or programa opencode install-integration) shows exactly what will be written and asks first; uninstall is symmetric and never touches a file you've customized. - File menu: "Install Claude Code Integration…" opens a terminal running `programa claude install-integration`, which shows the exact diff it wants to make to your Claude settings and asks before writing. It durably registers Programa's lifecycle hooks so the integration works from any terminal, not just inside Programa; hooks silently no-op elsewhere. Fully reversible with `programa claude uninstall-integration`; your own hooks are never touched. - Closing a terminal is now undoable for 5 seconds: Cmd+Shift+T brings it back with its process still running — whether you closed it or an agent did. The confirmation dialog still appears when a command is actively running. diff --git a/CLI/CLI+Hooks.swift b/CLI/CLI+Hooks.swift index b6bf0899..6650ae27 100644 --- a/CLI/CLI+Hooks.swift +++ b/CLI/CLI+Hooks.swift @@ -2260,6 +2260,471 @@ extension ProgramaCLI { return URL(fileURLWithPath: output).lastPathComponent.lowercased() } + // MARK: - OpenCode hooks + + private func parseOpenCodeHookInput(hookArgs: [String]) -> ClaudeHookParsedInput { + let rawInput = String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) ?? "" + let base = parseClaudeHookInput(rawInput: rawInput) + let cwdArg = optionValue(hookArgs, name: "--cwd")?.trimmingCharacters(in: .whitespacesAndNewlines) + let sessionArg = optionValue(hookArgs, name: "--session")?.trimmingCharacters(in: .whitespacesAndNewlines) + return ClaudeHookParsedInput( + object: base.object, + rawFallback: base.rawFallback, + sessionId: (sessionArg?.isEmpty == false ? sessionArg : nil) ?? base.sessionId, + cwd: (cwdArg?.isEmpty == false ? cwdArg : nil) ?? base.cwd, + transcriptPath: base.transcriptPath + ) + } + + /// OpenCode plugin hook handler. Gracefully no-ops when not running inside programa. + /// + /// Unlike claude-hook/codex-hook, OpenCode has no shell-hook config to gate + /// invocation on $PROGRAMA_SURFACE_ID — the local plugin (embedded as + /// `openCodePluginJS` below) calls `programa opencode-hook --cwd ... --session ...` + /// directly via Bun's `$`, so this function carries its own guard, mirroring + /// codex-hook's belt-and-suspenders pattern (also gated earlier in `run()` and + /// `validateRegisteredArguments` before any socket connection is attempted). + /// + /// The plugin passes `--cwd`/`--session` as CLI args rather than stdin JSON; + /// stdin is still read and tolerated (ignored if empty/absent) for parity with + /// the other hook handlers and in case a richer payload is added later. + /// + /// session.idle and permission.asked can fire close together on OpenCode's event + /// bus; the status/notification writes below are idempotent (same as + /// claude-hook/codex-hook), so repeated firing is harmless. + func runOpenCodeHook( + commandArgs: [String], + client: SocketClient + ) throws { + let env = ProcessInfo.processInfo.environment + + guard env["PROGRAMA_SURFACE_ID"] != nil else { + print("{}") + return + } + + let subcommand = commandArgs.first?.lowercased() ?? "help" + let hookArgs = Array(commandArgs.dropFirst()) + let hookWsFlag = optionValue(hookArgs, name: "--workspace") + let workspaceArg = hookWsFlag ?? env["PROGRAMA_WORKSPACE_ID"] + let surfaceArg = optionValue(hookArgs, name: "--surface") ?? (hookWsFlag == nil ? env["PROGRAMA_SURFACE_ID"] : nil) + let parsedInput = parseOpenCodeHookInput(hookArgs: hookArgs) + let sessionStore = ClaudeHookSessionStore( + processEnv: env.merging( + ["PROGRAMA_CLAUDE_HOOK_STATE_PATH": "~/.programa/opencode-hook-sessions.json"], + uniquingKeysWith: { _, new in new } + ) + ) + + switch subcommand { + case "session-start": + let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( + preferred: nil, + fallback: workspaceArg, + client: client + ) + let surfaceId = try resolvePreferredSurfaceIdForClaudeHook( + preferred: nil, + fallback: surfaceArg, + workspaceId: workspaceId, + client: client + ) + let agentPIDKey = opencodeAgentPIDKey(sessionId: parsedInput.sessionId) + let opencodePid = inferredCodexAgentPID() + if let sessionId = parsedInput.sessionId { + try? sessionStore.upsert( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: parsedInput.cwd, + pid: opencodePid + ) + } + if let opencodePid { + _ = try? client.sendV2(method: "workspace.set_agent_pid", params: [ + "workspace_id": workspaceId, + "key": agentPIDKey, + "pid": opencodePid, + ]) + } + print("{}") + + case "prompt-submit": + let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } + let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( + preferred: mappedSession?.workspaceId, + fallback: workspaceArg, + client: client + ) + let agentPIDKey = opencodeAgentPIDKey(sessionId: parsedInput.sessionId ?? mappedSession?.sessionId) + let opencodePid = mappedSession?.pid ?? inferredCodexAgentPID() + if let sessionId = parsedInput.sessionId, let mappedSession { + try? sessionStore.upsert( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: mappedSession.surfaceId, + cwd: parsedInput.cwd ?? mappedSession.cwd, + pid: opencodePid + ) + } + if let opencodePid { + _ = try? client.sendV2(method: "workspace.set_agent_pid", params: [ + "workspace_id": workspaceId, + "key": agentPIDKey, + "pid": opencodePid, + ]) + } + _ = try? client.sendV2(method: "notification.clear", params: ["workspace_id": workspaceId]) + try setOpenCodeStatus( + client: client, + workspaceId: workspaceId, + value: "Running", + icon: "bolt.fill", + color: "#4C8DFF" + ) + print("{}") + + case "stop": + do { + let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } + let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( + preferred: mappedSession?.workspaceId, + fallback: workspaceArg, + client: client + ) + let surfaceId = try resolvePreferredSurfaceIdForClaudeHook( + preferred: mappedSession?.surfaceId, + fallback: surfaceArg, + workspaceId: workspaceId, + client: client + ) + let agentPIDKey = opencodeAgentPIDKey(sessionId: parsedInput.sessionId ?? mappedSession?.sessionId) + let cwd = parsedInput.cwd ?? mappedSession?.cwd + let opencodePid = mappedSession?.pid ?? inferredCodexAgentPID() + let projectName: String? = { + guard let cwd, !cwd.isEmpty else { return nil } + return URL(fileURLWithPath: NSString(string: cwd).expandingTildeInPath).lastPathComponent + }() + // OpenCode's session.idle event carries no transcript/message payload + // (unlike Claude/Codex Stop hooks), so the completion body stays generic + // unless a future stdin JSON payload supplies one. + let lastMessage = parsedInput.object.flatMap { + firstString(in: $0, keys: ["message", "last_assistant_message", "lastAssistantMessage", "body", "text"]) + } + + if let sessionId = parsedInput.sessionId { + try? sessionStore.upsert( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: cwd, + pid: opencodePid, + lastSubtitle: "Completed", + lastBody: lastMessage.map { truncate($0, maxLength: 200) } + ) + } + if let opencodePid { + _ = try? client.sendV2(method: "workspace.set_agent_pid", params: [ + "workspace_id": workspaceId, + "key": agentPIDKey, + "pid": opencodePid, + ]) + } + + var subtitle = "Completed" + if let projectName, !projectName.isEmpty { + subtitle = "Completed in \(projectName)" + } + let body = sanitizeNotificationField( + lastMessage.map { truncate(normalizedSingleLine($0), maxLength: 200) } + ?? "OpenCode session completed" + ) + _ = try? client.sendV2(method: "notification.create_for_target", params: [ + "workspace_id": workspaceId, + "surface_id": surfaceId, + "title": "OpenCode", + "subtitle": sanitizeNotificationField(subtitle), + "body": body, + ]) + + try? setOpenCodeStatus( + client: client, + workspaceId: workspaceId, + value: "Idle", + icon: "pause.circle.fill", + color: "#8E8E93" + ) + print("{}") + } catch { + if shouldIgnoreClaudeHookTeardownError(error) { + print("{}") + return + } + throw error + } + + case "notification", "notify": + let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } + let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( + preferred: mappedSession?.workspaceId, + fallback: workspaceArg, + client: client + ) + let surfaceId = try resolvePreferredSurfaceIdForClaudeHook( + preferred: mappedSession?.surfaceId, + fallback: surfaceArg, + workspaceId: workspaceId, + client: client + ) + let agentPIDKey = opencodeAgentPIDKey(sessionId: parsedInput.sessionId ?? mappedSession?.sessionId) + let opencodePid = mappedSession?.pid ?? inferredCodexAgentPID() + + // permission.asked carries no message payload from the plugin either; + // tolerate a stdin-supplied one for future-proofing, else use a generic message. + let messageFromInput = parsedInput.object.flatMap { + firstString(in: $0, keys: ["message", "body", "text", "reason", "description"]) + } + let subtitle = "Attention" + let body = messageFromInput.map { normalizedSingleLine($0) } ?? "OpenCode needs your attention" + + if let sessionId = parsedInput.sessionId { + try? sessionStore.upsert( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: parsedInput.cwd, + pid: opencodePid, + lastSubtitle: subtitle, + lastBody: body + ) + } + if let opencodePid { + _ = try? client.sendV2(method: "workspace.set_agent_pid", params: [ + "workspace_id": workspaceId, + "key": agentPIDKey, + "pid": opencodePid, + ]) + } + + _ = try? client.sendV2(method: "notification.create_for_target", params: [ + "workspace_id": workspaceId, + "surface_id": surfaceId, + "title": "OpenCode", + "subtitle": sanitizeNotificationField(subtitle), + "body": sanitizeNotificationField(body), + ]) + _ = try? setOpenCodeStatus( + client: client, + workspaceId: workspaceId, + value: "Needs input", + icon: "bell.fill", + color: "#4C8DFF" + ) + print("{}") + + case "session-end": + do { + let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } + let fallbackWorkspaceId = try? resolvePreferredWorkspaceIdForClaudeHook( + preferred: mappedSession?.workspaceId, + fallback: workspaceArg, + client: client + ) + let fallbackSurfaceId: String? = { + guard let fallbackWorkspaceId else { return nil } + return try? resolvePreferredSurfaceIdForClaudeHook( + preferred: mappedSession?.surfaceId, + fallback: surfaceArg, + workspaceId: fallbackWorkspaceId, + client: client + ) + }() + let consumedSession = try? sessionStore.consume( + sessionId: parsedInput.sessionId, + workspaceId: fallbackWorkspaceId, + surfaceId: fallbackSurfaceId + ) + if let consumedSession { + let workspaceId = consumedSession.workspaceId + let agentPIDKey = opencodeAgentPIDKey(sessionId: parsedInput.sessionId ?? consumedSession.sessionId) + _ = try? clearOpenCodeStatus(client: client, workspaceId: workspaceId) + _ = try? client.sendV2(method: "workspace.clear_agent_pid", params: ["workspace_id": workspaceId, "key": agentPIDKey]) + _ = try? client.sendV2(method: "notification.clear", params: ["workspace_id": workspaceId]) + } + print("{}") + } catch { + if shouldIgnoreClaudeHookTeardownError(error) { + print("{}") + return + } + throw error + } + + case "help", "--help", "-h": + print("programa opencode-hook [--cwd ] [--session ] [--workspace ] [--surface ]") + + default: + throw CLIError(message: "Unknown opencode-hook subcommand: \(subcommand)") + } + } + + private func setOpenCodeStatus( + client: SocketClient, + workspaceId: String, + value: String, + icon: String, + color: String + ) throws { + _ = try client.sendV2(method: "workspace.set_status", params: [ + "workspace_id": workspaceId, + "key": "opencode", + "value": value, + "icon": icon, + "color": color, + ]) + } + + private func clearOpenCodeStatus(client: SocketClient, workspaceId: String) throws { + _ = try client.sendV2(method: "workspace.clear_status", params: ["workspace_id": workspaceId, "key": "opencode"]) + } + + private func opencodeAgentPIDKey(sessionId: String?) -> String { + guard let sessionId = sessionId?.trimmingCharacters(in: .whitespacesAndNewlines), + !sessionId.isEmpty else { + return "opencode" + } + return "opencode.\(sessionId)" + } + + /// The local OpenCode plugin programa installs into + /// ~/.config/opencode/plugins/programa.js (or $OPENCODE_CONFIG_DIR/plugins/programa.js). + /// + /// OpenCode has no shell-hook config (unlike Claude Code/Codex): local plugin + /// files under plugins/ auto-load with no opencode.json edit and no npm install. + /// The exported function runs once at process boot (`{ directory, worktree, $ }`, + /// Bun's `$`) and returns the lifecycle hooks object. `.quiet().nothrow()` on + /// every `$` call is load-bearing — Bun's `$` throws on non-zero exit by default, + /// and a programa CLI hiccup must never crash the user's opencode session. + private static let openCodePluginJS: String = { + """ + // Programa integration for OpenCode. Managed by `programa opencode install-integration`. + export const ProgramaPlugin = async ({ directory, worktree, $ }) => { + const hook = (event, extra = []) => + $`programa opencode-hook ${event} --cwd ${directory} ${extra}`.quiet().nothrow() + await hook("session-start") + return { + "chat.message": async (input) => { + await hook("prompt-submit", ["--session", input?.sessionID ?? ""]) + }, + event: async ({ event }) => { + if (event?.type === "session.idle") await hook("stop", ["--session", event.properties?.sessionID ?? ""]) + if (event?.type === "permission.asked") await hook("notification", ["--session", event.properties?.sessionID ?? ""]) + }, + dispose: async () => { await hook("session-end") }, + } + } + """ + "\n" + }() + + /// Identifier used to detect programa-owned plugin files during uninstall. + private static let openCodePluginMarker = "Managed by `programa opencode install-integration`" + + /// Resolves the target plugins directory, respecting OpenCode's own + /// OPENCODE_CONFIG_DIR override. + private static func openCodePluginsDir() -> String { + if let override = ProcessInfo.processInfo.environment["OPENCODE_CONFIG_DIR"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty { + let expanded = NSString(string: override).expandingTildeInPath + return (expanded as NSString).appendingPathComponent("plugins") + } + return NSString(string: "~/.config/opencode/plugins").expandingTildeInPath + } + + func runOpenCodeInstallIntegration() throws { + let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes") + || ProcessInfo.processInfo.arguments.contains("-y") + let pluginsDir = Self.openCodePluginsDir() + let pluginPath = (pluginsDir as NSString).appendingPathComponent("programa.js") + let fm = FileManager.default + + try fm.createDirectory(atPath: pluginsDir, withIntermediateDirectories: true, attributes: nil) + + let existingContent: String? = fm.fileExists(atPath: pluginPath) + ? (try? String(contentsOfFile: pluginPath, encoding: .utf8)) + : nil + let newContent = Self.openCodePluginJS + + if existingContent == newContent { + print("programa OpenCode integration is already installed. Nothing to change.") + return + } + + print(" \(pluginPath):") + if let existingContent { + printSimpleDiff(old: existingContent, new: newContent) + } else { + print(" (new file)") + let lines = newContent.components(separatedBy: "\n") + for (i, line) in lines.enumerated() where !(i == lines.count - 1 && line.isEmpty) { + let lineLabel = String(format: "%3d", i + 1) + print(" \u{001B}[32m\(lineLabel) +\(line)\u{001B}[0m") + } + } + print("") + + if !skipConfirm { + print("Apply these changes? [Y/n] ", terminator: "") + if let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + !response.isEmpty && response != "y" && response != "yes" { + print("Aborted.") + return + } + } + + try newContent.write(toFile: pluginPath, atomically: true, encoding: .utf8) + + print("") + print("Installed. OpenCode picks up local plugins automatically — no opencode.json edit or npm install needed.") + print("To remove: programa opencode uninstall-integration") + } + + func runOpenCodeUninstallIntegration() throws { + let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes") + || ProcessInfo.processInfo.arguments.contains("-y") + let pluginsDir = Self.openCodePluginsDir() + let pluginPath = (pluginsDir as NSString).appendingPathComponent("programa.js") + let fm = FileManager.default + + guard fm.fileExists(atPath: pluginPath), + let content = try? String(contentsOfFile: pluginPath, encoding: .utf8) else { + print("No OpenCode integration found at \(pluginPath)") + return + } + + guard content.contains(Self.openCodePluginMarker) else { + throw CLIError( + message: "\(pluginPath) does not look like a programa-managed plugin (missing the marker comment). Refusing to delete it — remove it manually if this is intentional." + ) + } + + print(" \(pluginPath):") + printSimpleDiff(old: content, new: "") + print("") + + if !skipConfirm { + print("Apply these changes? [Y/n] ", terminator: "") + if let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + !response.isEmpty && response != "y" && response != "yes" { + print("Aborted.") + return + } + } + + try fm.removeItem(atPath: pluginPath) + print("Removed programa OpenCode integration.") + } + /// Subcommand help text for Hooks commands, split out of the /// central `subcommandUsage` switch (programa.swift) so each domain's /// help text lives next to its command descriptors. Refs #101. @@ -2316,6 +2781,37 @@ extension ProgramaCLI { --workspace Target workspace (default: $PROGRAMA_WORKSPACE_ID) --surface Target surface (default: $PROGRAMA_SURFACE_ID) """ + case "opencode": + return """ + Usage: programa opencode + + Manage Programa's OpenCode plugin integration. + + Subcommands: + install-integration Install programa's plugin into ~/.config/opencode/plugins/programa.js + uninstall-integration Remove programa's plugin (refuses if you've customized the file) + """ + case "opencode-hook": + return """ + Usage: programa opencode-hook [flags] + + Hook for the OpenCode plugin integration. Reads --cwd/--session flags passed + by the plugin (stdin JSON is tolerated but not required). Gracefully no-ops + when not running inside programa. + + Subcommands: + session-start Register an OpenCode session (plugin boot) + prompt-submit Set Running status on user prompt (chat.message) + stop Send completion notification, set Idle (session.idle) + notification Send an attention notification, set Needs input (permission.asked) + session-end Final cleanup when the OpenCode plugin disposes + + Flags: + --cwd Working directory reported by the plugin + --session OpenCode session id, when available + --workspace Target workspace (default: $PROGRAMA_WORKSPACE_ID) + --surface Target surface (default: $PROGRAMA_SURFACE_ID) + """ default: return nil } @@ -2340,6 +2836,13 @@ extension ProgramaCLI { try self.runCodexHook(commandArgs: ctx.commandArgs, client: ctx.client) } ), + CommandDescriptor( + names: ["opencode-hook"], + helpLines: [], + execute: { ctx in + try self.runOpenCodeHook(commandArgs: ctx.commandArgs, client: ctx.client) + } + ), ] } } diff --git a/CLI/programa.swift b/CLI/programa.swift index 8ac98f07..e6702836 100644 --- a/CLI/programa.swift +++ b/CLI/programa.swift @@ -1475,6 +1475,20 @@ struct ProgramaCLI { throw CLIError(message: "Unknown claude subcommand: \(sub)") } + // OpenCode plugin integration management (no socket needed) + if command == "opencode" { + let sub = commandArgs.first?.lowercased() ?? "help" + if sub == "install-integration" { + try runOpenCodeInstallIntegration() + return + } else if sub == "uninstall-integration" { + try runOpenCodeUninstallIntegration() + return + } + print("Usage: programa opencode ") + throw CLIError(message: "Unknown opencode subcommand: \(sub)") + } + // Codex hook handler: gracefully no-op when not inside programa // (before socket connection, so it doesn't fail when no socket exists) if command == "codex-hook" { @@ -1484,6 +1498,15 @@ struct ProgramaCLI { } } + // OpenCode hook handler: gracefully no-op when not inside programa + // (before socket connection, so it doesn't fail when no socket exists) + if command == "opencode-hook" { + guard ProcessInfo.processInfo.environment["PROGRAMA_SURFACE_ID"] != nil else { + print("{}") + return + } + } + guard descriptor.connectionPolicy == .socket else { throw CLIError(message: "Unsupported \(command) subcommand") } @@ -1610,6 +1633,20 @@ struct ProgramaCLI { """, execute: nil ), + CommandDescriptor( + names: ["opencode"], + helpLines: ["opencode "], + connectionPolicy: .local, + detailedUsage: """ + Usage: programa opencode + + Install or remove Programa's OpenCode plugin in + ~/.config/opencode/plugins/programa.js (or $OPENCODE_CONFIG_DIR/plugins/programa.js). + OpenCode auto-loads local plugin files, so no opencode.json edit or + npm install is needed. + """, + execute: nil + ), CommandDescriptor( names: ["ping"], @@ -5277,6 +5314,14 @@ struct ProgramaCLI { throw CLIError(message: "codex-hook: unknown event \(subcommand)") } + case "opencode-hook": + guard ProcessInfo.processInfo.environment["PROGRAMA_SURFACE_ID"] != nil else { return } + let parsed = try parse(values: ["workspace", "surface", "cwd", "session"], maxPositionals: 1) + if let subcommand = parsed.positional.first?.lowercased(), + !["session-start", "prompt-submit", "stop", "notification", "notify", "session-end", "help", "--help", "-h"].contains(subcommand) { + throw CLIError(message: "opencode-hook: unknown event \(subcommand)") + } + case "capture-pane": let parsed = try parse(values: ["workspace", "surface", "lines"], booleans: ["scrollback"]) if let lines = parsed.options["lines"], (Int(lines) ?? 0) <= 0 { @@ -5364,6 +5409,11 @@ struct ProgramaCLI { guard ["install-integration", "uninstall-integration"].contains(parsed.positional[0].lowercased()) else { throw CLIError(message: "claude: expected install-integration or uninstall-integration") } + case "opencode": + let parsed = try parse(booleans: ["yes", "y"], minPositionals: 1, maxPositionals: 1) + guard ["install-integration", "uninstall-integration"].contains(parsed.positional[0].lowercased()) else { + throw CLIError(message: "opencode: expected install-integration or uninstall-integration") + } case "remote-daemon-status": let parsed = try parse(values: ["os", "arch"]) if let os = parsed.options["os"], !["darwin", "linux"].contains(os.lowercased()) { diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 0e3c3330..747a22b6 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -5874,6 +5874,23 @@ } } }, + "menu.file.installOpenCodeIntegration": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Install OpenCode Integration…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "OpenCode 連携をインストール…" + } + } + } + }, "menu.file.reopenClosedBrowserPanel": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 1af44559..f52a6acd 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -4746,6 +4746,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return workspace.id } + /// Opens a new workspace and runs `programa opencode install-integration` in it, + /// mirroring openCodexIntegrationInstaller: no working-directory override, since + /// the installer targets the user's global OpenCode plugin directory, not a project. + @discardableResult + func openOpenCodeIntegrationInstaller(event: NSEvent? = nil, debugSource: String = "unspecified") -> UUID? { + discardOrphanedMainWindowContexts() + guard let context = preferredMainWindowContextForWorkspaceCreation(event: event, debugSource: debugSource) else { + openNewMainWindow(nil) + return nil + } + guard let window = resolvedWindow(for: context) else { + discardOrphanedMainWindowContext(context) + openNewMainWindow(nil) + return nil + } + setActiveMainWindow(window) + + let workspace = context.tabManager.addWorkspace( + initialTerminalInput: "programa opencode install-integration\n", + select: true + ) + return workspace.id + } + private func preferredMainWindowContextForWorkspaceCreation( event: NSEvent? = nil, debugSource: String = "unspecified" diff --git a/Sources/ProgramaApp.swift b/Sources/ProgramaApp.swift index 193aa7c4..8123f76f 100644 --- a/Sources/ProgramaApp.swift +++ b/Sources/ProgramaApp.swift @@ -535,6 +535,15 @@ struct programaApp: App { ) { AppDelegate.shared?.openCodexIntegrationInstaller(debugSource: "menu.installCodexIntegration") } + + Button( + String( + localized: "menu.file.installOpenCodeIntegration", + defaultValue: "Install OpenCode Integration…" + ) + ) { + AppDelegate.shared?.openOpenCodeIntegrationInstaller(debugSource: "menu.installOpenCodeIntegration") + } } // Close tab/workspace diff --git a/tests_v2/test_opencode_install_integration.py b/tests_v2/test_opencode_install_integration.py new file mode 100644 index 00000000..7302f407 --- /dev/null +++ b/tests_v2/test_opencode_install_integration.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""Regression: `programa opencode install-integration` / `uninstall-integration` (#139). + +Runs entirely against the CLI binary with no app/socket involved — the `opencode` +command branch is dispatched before any socket connection is opened. Exercises: +- Fresh install: writes plugins/programa.js containing the opencode-hook marker + and the "Managed by" marker comment, under $OPENCODE_CONFIG_DIR/plugins/. +- Idempotency: a second install run makes no further changes (byte-identical, + "already installed" fast path). +- Modified-file reinstall: a hand-edited file triggers a diff, and --yes + restores the canonical content. +- Uninstall: removes the file. +- Uninstall refuses when the file lacks the marker comment (user replaced the + managed file with their own content) — the file is left intact. +- Declining the confirmation prompt leaves the file untouched. +""" + +import glob +import os +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import List, Optional + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmuxError + + +HOOK_MARKER = "opencode-hook" +MANAGED_MARKER = "Managed by `programa opencode install-integration`" + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/programa-*/Build/Products/Debug/programa") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate programa CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run( + cli: str, + args: List[str], + config_dir: str, + input_text: Optional[str] = None, +) -> subprocess.CompletedProcess: + env = os.environ.copy() + for var in ("PROGRAMA_WORKSPACE_ID", "PROGRAMA_SURFACE_ID", "PROGRAMA_PANEL_ID", "PROGRAMA_TAB_ID"): + env.pop(var, None) + env["OPENCODE_CONFIG_DIR"] = config_dir + return subprocess.run( + [cli] + args, + capture_output=True, + text=True, + check=False, + env=env, + input=input_text, + ) + + +def _merged(proc: subprocess.CompletedProcess) -> str: + return f"{proc.stdout}\n{proc.stderr}".strip() + + +def _plugin_path(config_dir: str) -> Path: + return Path(config_dir) / "plugins" / "programa.js" + + +def test_fresh_install(cli: str) -> None: + with tempfile.TemporaryDirectory() as config_dir: + proc = _run(cli, ["opencode", "install-integration", "--yes"], config_dir) + _must(proc.returncode == 0, f"install-integration should exit 0: {_merged(proc)}") + + plugin_path = _plugin_path(config_dir) + _must(plugin_path.exists(), f"programa.js should be created at {plugin_path}") + + content = plugin_path.read_text(encoding="utf-8") + _must(HOOK_MARKER in content, f"expected '{HOOK_MARKER}' in plugin content, got: {content}") + _must(MANAGED_MARKER in content, f"expected marker comment in plugin content, got: {content}") + + print(" PASS: fresh install writes plugins/programa.js with hook + marker") + + +def test_idempotent_reinstall(cli: str) -> None: + with tempfile.TemporaryDirectory() as config_dir: + proc = _run(cli, ["opencode", "install-integration", "--yes"], config_dir) + _must(proc.returncode == 0, f"first install should exit 0: {_merged(proc)}") + + plugin_path = _plugin_path(config_dir) + before = plugin_path.read_bytes() + + proc2 = _run(cli, ["opencode", "install-integration", "--yes"], config_dir) + _must(proc2.returncode == 0, f"second install should exit 0: {_merged(proc2)}") + _must( + "already installed" in _merged(proc2).lower(), + f"reinstall should hit the already-installed fast path, got: {_merged(proc2)}", + ) + + after = plugin_path.read_bytes() + _must(before == after, "reinstalling should not change programa.js bytes") + + print(" PASS: reinstall is idempotent (byte-identical, fast path message)") + + +def test_modified_file_reinstall_shows_diff_and_restores(cli: str) -> None: + with tempfile.TemporaryDirectory() as config_dir: + proc = _run(cli, ["opencode", "install-integration", "--yes"], config_dir) + _must(proc.returncode == 0, f"first install should exit 0: {_merged(proc)}") + + plugin_path = _plugin_path(config_dir) + canonical = plugin_path.read_text(encoding="utf-8") + + # Hand-edit the managed file (still contains the marker). + modified = canonical + "\n// user tweak\n" + plugin_path.write_text(modified, encoding="utf-8") + + proc2 = _run(cli, ["opencode", "install-integration"], config_dir, input_text="n\n") + _must(proc2.returncode == 0, f"declining reinstall should still exit 0: {_merged(proc2)}") + output = _merged(proc2) + _must("+" in output or "-" in output, f"expected a diff view in output, got: {output}") + _must( + plugin_path.read_text(encoding="utf-8") == modified, + "declining the reinstall prompt should leave the modified file untouched", + ) + + proc3 = _run(cli, ["opencode", "install-integration", "--yes"], config_dir) + _must(proc3.returncode == 0, f"--yes reinstall should exit 0: {_merged(proc3)}") + _must( + plugin_path.read_text(encoding="utf-8") == canonical, + "programa opencode install-integration --yes should restore canonical content", + ) + + print(" PASS: modified-file reinstall shows a diff and --yes restores canonical content") + + +def test_uninstall_deletes_file(cli: str) -> None: + with tempfile.TemporaryDirectory() as config_dir: + proc = _run(cli, ["opencode", "install-integration", "--yes"], config_dir) + _must(proc.returncode == 0, f"install should exit 0: {_merged(proc)}") + + plugin_path = _plugin_path(config_dir) + _must(plugin_path.exists(), "programa.js should exist before uninstall") + + proc2 = _run(cli, ["opencode", "uninstall-integration", "--yes"], config_dir) + _must(proc2.returncode == 0, f"uninstall should exit 0: {_merged(proc2)}") + _must(not plugin_path.exists(), "programa.js should be removed after uninstall") + + print(" PASS: uninstall deletes the managed plugin file") + + +def test_uninstall_refuses_without_marker(cli: str) -> None: + with tempfile.TemporaryDirectory() as config_dir: + plugin_path = _plugin_path(config_dir) + plugin_path.parent.mkdir(parents=True, exist_ok=True) + custom_content = "// my totally custom opencode plugin, not managed by programa\nexport const Custom = async () => ({})\n" + plugin_path.write_text(custom_content, encoding="utf-8") + + proc = _run(cli, ["opencode", "uninstall-integration", "--yes"], config_dir) + _must( + proc.returncode != 0, + f"uninstall should refuse (non-zero exit) when the file lacks the marker, got: {_merged(proc)}", + ) + _must( + plugin_path.exists() and plugin_path.read_text(encoding="utf-8") == custom_content, + "uninstall must leave a non-programa-managed file untouched", + ) + + print(" PASS: uninstall refuses to delete a file missing the marker comment, file intact") + + +def test_decline_confirmation_leaves_file_untouched(cli: str) -> None: + with tempfile.TemporaryDirectory() as config_dir: + plugin_path = _plugin_path(config_dir) + _must(not plugin_path.exists(), "programa.js should not pre-exist for this test") + + proc = _run(cli, ["opencode", "install-integration"], config_dir, input_text="n\n") + + if proc.returncode == 0: + _must( + not plugin_path.exists(), + f"declining confirmation with exit 0 must leave programa.js untouched, but it was created: {_merged(proc)}", + ) + # A non-zero exit code is also an acceptable "clean abort" outcome. + + print(" PASS: declining the confirmation prompt leaves programa.js untouched") + + +def main() -> int: + cli = _find_cli_binary() + print(f"Using CLI: {cli}") + + test_fresh_install(cli) + test_idempotent_reinstall(cli) + test_modified_file_reinstall_shows_diff_and_restores(cli) + test_uninstall_deletes_file(cli) + test_uninstall_refuses_without_marker(cli) + test_decline_confirmation_leaves_file_untouched(cli) + + print("\nPASS: All opencode install-integration tests passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())