From b914443d17046b62915b3befb02a384152bd7e96 Mon Sep 17 00:00:00 2001 From: Danny Gershman Date: Wed, 3 Jun 2026 19:11:37 -0400 Subject: [PATCH 1/3] Add Cursor agent under the CodingAgent protocol (#417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands Cursor as a third agent on the existing CodingAgent protocol from PR #197. Mirrors CrowCodex's package shape but the hook layer (writer + signal source) more closely mirrors CrowClaude — Cursor's hook engine is a superset of Claude Code's, not a no-op like Codex's per-session writer. * Add AgentKind.cursor * New package Packages/CrowCursor with CursorAgent, CursorHookConfigWriter, CursorSignalSource, CursorScaffolder, CursorLauncher * CursorHookConfigWriter collapses the camelCase ↔ PascalCase event-name mapping into the writer (Cursor camelCase JSON key → Crow-canonical PascalCase --event arg), letting CursorSignalSource share Claude/Codex's event vocabulary verbatim * CursorAgent.supportsRemoteControl = true (enables RemoteControlBadge and crow send — Cursor's stop.followup_message gives us auto-continue) * CursorScaffolder reuses Resources/AGENTS.md.template; co-existence with CodexScaffolder is idempotent (byte-identical writes, same Known Issues marker preservation) * AppDelegate registers CursorAgent gated on findBinary() and installs ~/.cursor/hooks.json (or $CURSOR_CONFIG_DIR) on launch when present * 25 new tests covering protocol members, autoLaunchCommand for work and review sessions, hook event mapping, idempotency, user-key preservation, scaffolder roundtrip, full StateSignalSource transition table No CLI changes needed — crow hook-event --agent and crow new-session --agent already accept arbitrary agent kinds. CreateSessionView auto-grows from AgentRegistry.shared.allAgents(), so the picker picks up the third entry for free. Closes #417 🐦‍⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: 968C71B6-8487-41C2-A841-E982DC017A5F --- Package.swift | 2 + .../Sources/CrowCore/Agent/AgentKind.swift | 3 + Packages/CrowCursor/Package.swift | 17 ++ .../Sources/CrowCursor/CursorAgent.swift | 97 +++++++++ .../CrowCursor/CursorHookConfigWriter.swift | 111 ++++++++++ .../Sources/CrowCursor/CursorLauncher.swift | 78 +++++++ .../Sources/CrowCursor/CursorScaffolder.swift | 82 ++++++++ .../CrowCursor/CursorSignalSource.swift | 99 +++++++++ .../CrowCursorTests/CursorAgentTests.swift | 66 ++++++ .../CursorHookConfigWriterTests.swift | 138 +++++++++++++ .../CursorScaffolderTests.swift | 52 +++++ .../CursorSignalSourceTests.swift | 194 ++++++++++++++++++ Sources/Crow/App/AppDelegate.swift | 34 ++- 13 files changed, 972 insertions(+), 1 deletion(-) create mode 100644 Packages/CrowCursor/Package.swift create mode 100644 Packages/CrowCursor/Sources/CrowCursor/CursorAgent.swift create mode 100644 Packages/CrowCursor/Sources/CrowCursor/CursorHookConfigWriter.swift create mode 100644 Packages/CrowCursor/Sources/CrowCursor/CursorLauncher.swift create mode 100644 Packages/CrowCursor/Sources/CrowCursor/CursorScaffolder.swift create mode 100644 Packages/CrowCursor/Sources/CrowCursor/CursorSignalSource.swift create mode 100644 Packages/CrowCursor/Tests/CrowCursorTests/CursorAgentTests.swift create mode 100644 Packages/CrowCursor/Tests/CrowCursorTests/CursorHookConfigWriterTests.swift create mode 100644 Packages/CrowCursor/Tests/CrowCursorTests/CursorScaffolderTests.swift create mode 100644 Packages/CrowCursor/Tests/CrowCursorTests/CursorSignalSourceTests.swift diff --git a/Package.swift b/Package.swift index 176505d8..32f1d6e9 100644 --- a/Package.swift +++ b/Package.swift @@ -17,6 +17,7 @@ let package = Package( .package(path: "Packages/CrowPersistence"), .package(path: "Packages/CrowClaude"), .package(path: "Packages/CrowCodex"), + .package(path: "Packages/CrowCursor"), .package(path: "Packages/CrowIPC"), .package(path: "Packages/CrowTelemetry"), .package(path: "Packages/CrowCLI"), @@ -34,6 +35,7 @@ let package = Package( "CrowPersistence", "CrowClaude", "CrowCodex", + "CrowCursor", "CrowIPC", "CrowTelemetry", ], diff --git a/Packages/CrowCore/Sources/CrowCore/Agent/AgentKind.swift b/Packages/CrowCore/Sources/CrowCore/Agent/AgentKind.swift index 4132a696..9421a3fe 100644 --- a/Packages/CrowCore/Sources/CrowCore/Agent/AgentKind.swift +++ b/Packages/CrowCore/Sources/CrowCore/Agent/AgentKind.swift @@ -16,4 +16,7 @@ public struct AgentKind: Hashable, Sendable, Codable, RawRepresentable { /// The OpenAI Codex agent. public static let codex = AgentKind(rawValue: "codex") + + /// The Cursor agent. + public static let cursor = AgentKind(rawValue: "cursor") } diff --git a/Packages/CrowCursor/Package.swift b/Packages/CrowCursor/Package.swift new file mode 100644 index 00000000..bbcfc593 --- /dev/null +++ b/Packages/CrowCursor/Package.swift @@ -0,0 +1,17 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "CrowCursor", + platforms: [.macOS(.v14)], + products: [ + .library(name: "CrowCursor", targets: ["CrowCursor"]), + ], + dependencies: [ + .package(path: "../CrowCore"), + ], + targets: [ + .target(name: "CrowCursor", dependencies: ["CrowCore"]), + .testTarget(name: "CrowCursorTests", dependencies: ["CrowCursor"]), + ] +) diff --git a/Packages/CrowCursor/Sources/CrowCursor/CursorAgent.swift b/Packages/CrowCursor/Sources/CrowCursor/CursorAgent.swift new file mode 100644 index 00000000..2958e343 --- /dev/null +++ b/Packages/CrowCursor/Sources/CrowCursor/CursorAgent.swift @@ -0,0 +1,97 @@ +import Foundation +import CrowCore + +/// `CodingAgent` conformer for the Cursor CLI (`agent` binary). Mirrors the +/// shape of `OpenAICodexAgent` but enables remote control — Cursor's hook +/// engine is a superset of Claude Code's (it supports `stop.followup_message` +/// for auto-continue, accepts the `CLAUDE_PROJECT_DIR` alias, and uses the +/// same exit-code 0/2 protocol). +public struct CursorAgent: CodingAgent { + public let kind: AgentKind = .cursor + public let displayName: String = "Cursor" + /// Visually distinct from Claude's `"sparkles"` and Codex's + /// `"terminal.fill"`. Easy to swap once branding firms up. + public let iconSystemName: String = "cursorarrow.rays" + public let supportsRemoteControl: Bool = true + /// Cursor's CLI binary is named `agent`, not `cursor`. + public let launchCommandToken: String = "agent" + public let hookConfigWriter: any HookConfigWriter + public let stateSignalSource: any StateSignalSource + + private let launcher: CursorLauncher + + /// Standard search paths for the `agent` binary, in priority order. + /// Homebrew-cask installs the Cursor app bundle at the first path on + /// macOS; users who symlink the embedded CLI usually drop it there. + static let cursorBinaryCandidates: [String] = [ + "/opt/homebrew/bin/agent", + "/usr/local/bin/agent", + FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".local/bin/agent").path, + ] + + public init( + hookConfigWriter: any HookConfigWriter = CursorHookConfigWriter(), + stateSignalSource: any StateSignalSource = CursorSignalSource() + ) { + self.hookConfigWriter = hookConfigWriter + self.stateSignalSource = stateSignalSource + self.launcher = CursorLauncher() + } + + public func findBinary() -> String? { + for path in Self.cursorBinaryCandidates { + if FileManager.default.isExecutableFile(atPath: path) { + return path + } + } + return nil + } + + public func autoLaunchCommand( + session: Session, + worktreePath: String, + remoteControlEnabled: Bool, + autoPermissionMode: Bool, + telemetryPort: UInt16? + ) -> String? { + // Review-on-Cursor isn't supported in Phase C — the review skill is + // Claude-only. Returning nil tells `SessionService.launchAgent` to + // log and skip rather than producing a malformed command. + guard session.kind == .work else { return nil } + + // Bare `agent` launch — the user types their prompt into the TUI. + // No env prefix (Cursor reads `CURSOR_API_KEY` from the shell; + // GUI-stored creds are inherited otherwise), no `--continue` + // (MVP doesn't auto-resume), no remote-control flag (Cursor's hook + // engine provides remote control via `stop.followup_message` in the + // global hooks.json, not a per-launch flag). The terminal's cwd is + // already the worktree path. + return "agent\n" + } + + public func generatePrompt( + session: Session, + worktrees: [SessionWorktree], + ticketURL: String?, + provider: Provider? + ) async -> String { + await launcher.generatePrompt( + session: session, + worktrees: worktrees, + ticketURL: ticketURL, + provider: provider + ) + } + + public func launchCommand( + sessionID: UUID, + worktreePath: String, + prompt: String + ) async throws -> String { + try await launcher.launchCommand( + sessionID: sessionID, + worktreePath: worktreePath, + prompt: prompt + ) + } +} diff --git a/Packages/CrowCursor/Sources/CrowCursor/CursorHookConfigWriter.swift b/Packages/CrowCursor/Sources/CrowCursor/CursorHookConfigWriter.swift new file mode 100644 index 00000000..88fb3251 --- /dev/null +++ b/Packages/CrowCursor/Sources/CrowCursor/CursorHookConfigWriter.swift @@ -0,0 +1,111 @@ +import Foundation +import CrowCore + +/// Writes hook configuration that Cursor picks up. Cursor reads hooks from +/// `~/.cursor/hooks.json` (override via `CURSOR_CONFIG_DIR`) regardless of +/// which directory `agent` is invoked from — global, not per-worktree. +/// Per-worktree project hooks at `/.cursor/hooks.json` are +/// supported by Cursor but deferred to a follow-up; MVP is global-only, +/// matching Codex's scope. +/// +/// Because `HookConfigWriter`'s per-session API doesn't fit Cursor's global +/// model, the per-session methods are intentionally no-ops. Real work +/// happens via the static `installGlobalConfig` call invoked once at app +/// launch. +/// +/// Cursor's native event names are camelCase (`preToolUse`, `stop`) but +/// it documents exit-code 0/2 semantics and the `CLAUDE_PROJECT_DIR` alias +/// as "matching Claude Code behavior for compatibility." We collapse the +/// camelCase ↔ PascalCase mapping into this writer: the JSON key uses +/// Cursor's camelCase form, and the `--event ` argument inside the +/// command uses the Crow-canonical PascalCase form. That lets +/// `CursorSignalSource` share Claude/Codex's event vocabulary verbatim. +public struct CursorHookConfigWriter: HookConfigWriter { + + /// Curated event subset matching what `CodexSignalSource` already + /// handles, plus `Notification` (mapped from `afterAgentResponse` as a + /// safety net for headless `agent -p` mode where `stop` may not fire). + /// Keyed by Cursor's camelCase event name; value is the Crow-canonical + /// PascalCase event name written into the `--event` argument. + static let eventMapping: [(cursorKey: String, crowEvent: String)] = [ + ("sessionStart", "SessionStart"), + ("preToolUse", "PreToolUse"), + ("postToolUse", "PostToolUse"), + ("beforeSubmitPrompt", "UserPromptSubmit"), + ("stop", "Stop"), + ("afterAgentResponse", "Notification"), + ] + + /// Post-execution events safe to run async (fire-and-forget). + /// `Stop` stays synchronous because the state-transition timing + /// matters for the UI; `PostToolUse` and `Notification` are + /// observational so async is fine. + private static let asyncCrowEvents: Set = ["PostToolUse", "Notification"] + + public init() {} + + // MARK: - HookConfigWriter Conformance (no-ops) + + /// No-op. Cursor hooks are global, not per-worktree — see + /// `installGlobalConfig`. A future revision may layer in per-project + /// `/.cursor/hooks.json` for finer-grained state, but MVP + /// stays global-only. + public func writeHookConfig(worktreePath: String, sessionID: UUID, crowPath: String) throws {} + + /// No-op. Cursor's global `hooks.json` stays in place when individual + /// sessions are deleted; it serves all sessions. + public func removeHookConfig(worktreePath: String) {} + + // MARK: - Global Configuration + + /// Build the hooks dict in the schema Cursor expects. Each Cursor + /// camelCase event key maps to a command invoking + /// ` hook-event --agent cursor --event ` with no + /// `--session` flag — the crow server resolves the session from `cwd` + /// in the payload. + static func generateHooks(crowPath: String) -> [String: Any] { + var hooks: [String: Any] = [:] + for (cursorKey, crowEvent) in eventMapping { + let command = "\(crowPath) hook-event --agent cursor --event \(crowEvent)" + var entry: [String: Any] = [ + "type": "command", + "command": command, + "timeout": 5, + ] + if asyncCrowEvents.contains(crowEvent) { + entry["async"] = true + } + hooks[cursorKey] = [ + ["hooks": [entry]] as [String: Any] + ] + } + return hooks + } + + /// Install or refresh `/hooks.json` with Crow's hook + /// commands. Idempotent — re-running just rewrites the same content. + /// Preserves any user-authored entries for events Crow doesn't manage. + public static func installGlobalConfig(cursorHome: String, crowPath: String) throws { + try FileManager.default.createDirectory(atPath: cursorHome, withIntermediateDirectories: true) + let hooksPath = (cursorHome as NSString).appendingPathComponent("hooks.json") + + // Read existing hooks.json if present so user-authored entries for + // events outside our `eventMapping` survive. + var existing: [String: Any] = [:] + if let data = FileManager.default.contents(atPath: hooksPath), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + existing = parsed + } + var existingHooks = existing["hooks"] as? [String: Any] ?? [:] + let ours = generateHooks(crowPath: crowPath) + for (eventName, config) in ours { + existingHooks[eventName] = config + } + existing["hooks"] = existingHooks + + let data = try JSONSerialization.data( + withJSONObject: existing, + options: [.prettyPrinted, .sortedKeys]) + try data.write(to: URL(fileURLWithPath: hooksPath)) + } +} diff --git a/Packages/CrowCursor/Sources/CrowCursor/CursorLauncher.swift b/Packages/CrowCursor/Sources/CrowCursor/CursorLauncher.swift new file mode 100644 index 00000000..284d3401 --- /dev/null +++ b/Packages/CrowCursor/Sources/CrowCursor/CursorLauncher.swift @@ -0,0 +1,78 @@ +import Foundation +import CrowCore + +/// Generates initial prompts for Cursor sessions. Mirrors the shape of +/// `CodexLauncher` — plan-first preamble, workspace table, ticket info — +/// without Claude-specific slash commands or `dangerouslyDisableSandbox` +/// directives. +/// +/// Phase-C MVP launches `agent` bare (the user types into the TUI), so +/// this type isn't wired into the auto-launch path yet. A follow-up will +/// use it for a Cursor-flavored `crow-workspace` skill. +public actor CursorLauncher { + public init() {} + + public func generatePrompt( + session: Session, + worktrees: [SessionWorktree], + ticketURL: String?, + provider: Provider? + ) -> String { + var lines: [String] = [] + lines.append("Before editing anything, sketch a brief plan covering:") + lines.append("- The files you'll touch and why") + lines.append("- Any migrations or cascading updates") + lines.append("- How you'll verify the change") + lines.append("Then proceed once the approach is clear.") + lines.append("") + lines.append("# Workspace Context") + lines.append("") + lines.append("| Repository | Path | Branch | Description |") + lines.append("|------------|------|--------|-------------|") + + for wt in worktrees { + lines.append("| \(wt.repoName) | \(wt.worktreePath) | \(wt.branch) | |") + } + + if let url = ticketURL { + lines.append("") + lines.append("## Ticket") + lines.append("") + + switch provider { + case .github: + lines.append("```bash") + lines.append("gh issue view \(url) --comments") + lines.append("```") + case .gitlab: + lines.append("```bash") + lines.append("glab issue view \(url) --comments") + lines.append("```") + case .corveil, nil: + lines.append("URL: \(url)") + } + } + + lines.append("") + lines.append("## Instructions") + lines.append("1. Study the ticket thoroughly") + lines.append("2. Create an implementation plan") + + return lines.joined(separator: "\n") + } + + /// Write `prompt` to a temp file and return the launch command. + public func launchCommand(sessionID: UUID, worktreePath: String, prompt: String) throws -> String { + let tmpDir = FileManager.default.temporaryDirectory + let promptPath = tmpDir.appendingPathComponent("crow-cursor-\(sessionID.uuidString)-prompt.md") + try prompt.write(to: promptPath, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], ofItemAtPath: promptPath.path) + return "cd \(Self.shellEscape(worktreePath)) && agent \"$(cat \(Self.shellEscape(promptPath.path)))\"\n" + } + + private static func shellEscape(_ str: String) -> String { + let escaped = str.replacingOccurrences(of: "'", with: "'\\''") + return "'\(escaped)'" + } +} diff --git a/Packages/CrowCursor/Sources/CrowCursor/CursorScaffolder.swift b/Packages/CrowCursor/Sources/CrowCursor/CursorScaffolder.swift new file mode 100644 index 00000000..d0c93c1b --- /dev/null +++ b/Packages/CrowCursor/Sources/CrowCursor/CursorScaffolder.swift @@ -0,0 +1,82 @@ +import Foundation + +/// Writes Cursor-specific files into `{devRoot}` so the agent has the +/// context it expects. Cursor's docs recommend `AGENTS.md` as the simple +/// alternative to `.cursor/rules/*.mdc` — this is the same file Codex +/// uses, and we reuse the shared `Resources/AGENTS.md.template`. +/// +/// Co-existence with `CodexScaffolder` is safe: both write byte-identical +/// content using the same template, both preserve user edits below the +/// `## Known Issues / Corrections` marker, and both are idempotent. The +/// `.cursorrules` legacy file is intentionally not written — it's silent +/// in current Cursor docs, and `AGENTS.md` covers the same role. +public enum CursorScaffolder { + /// Idempotent. Re-running preserves any user-authored "Known Issues / + /// Corrections" section the same way `Scaffolder` does for `CLAUDE.md` + /// and `CodexScaffolder` does for `AGENTS.md`. + public static func scaffold(devRoot: String) throws { + let agentsPath = (devRoot as NSString).appendingPathComponent("AGENTS.md") + let template = bundledAgentsMD() + + let fm = FileManager.default + let userCorrectionsMarker = "## Known Issues / Corrections" + + if fm.fileExists(atPath: agentsPath), + let existing = try? String(contentsOfFile: agentsPath, encoding: .utf8), + let markerRange = existing.range(of: userCorrectionsMarker) { + // Preserve the user-edited section below the marker. + let userCorrections = String(existing[markerRange.lowerBound...]) + let templateBase: String + if let templateMarker = template.range(of: userCorrectionsMarker) { + templateBase = String(template[.. String { + if let content = loadFromRepo("Resources/AGENTS.md.template") { + return content + } + if let url = Bundle.main.url(forResource: "AGENTS.md", withExtension: "template"), + let content = try? String(contentsOf: url) { + return content + } + return """ + # Crow — Cursor Workspace Context + + You are operating inside a Crow-managed development root. Sessions + live in worktrees under workspace folders here. Use the `crow` CLI + for session, worktree, and metadata operations. + + See `crow --help` for the CLI reference. + + ## Known Issues / Corrections + """ + } + + private static func loadFromRepo(_ relativePath: String) -> String? { + let execURL = URL(fileURLWithPath: ProcessInfo.processInfo.arguments[0]) + var dir = execURL.deletingLastPathComponent() + for _ in 0..<10 { + if FileManager.default.fileExists(atPath: dir.appendingPathComponent("Package.swift").path) { + let filePath = dir.appendingPathComponent(relativePath) + if let content = try? String(contentsOf: filePath) { + return content + } + return nil + } + dir = dir.deletingLastPathComponent() + } + return nil + } +} diff --git a/Packages/CrowCursor/Sources/CrowCursor/CursorSignalSource.swift b/Packages/CrowCursor/Sources/CrowCursor/CursorSignalSource.swift new file mode 100644 index 00000000..e85be3c0 --- /dev/null +++ b/Packages/CrowCursor/Sources/CrowCursor/CursorSignalSource.swift @@ -0,0 +1,99 @@ +import Foundation +import CrowCore + +/// Translates Cursor hook events into `AgentStateTransition` values. +/// +/// Cursor's native event names are camelCase, but `CursorHookConfigWriter` +/// rewrites every event to its Crow-canonical PascalCase name in the +/// `--event` argument so this source can share Claude/Codex's vocabulary +/// verbatim: `SessionStart`, `PreToolUse`, `PostToolUse`, +/// `UserPromptSubmit`, `Stop`, `Notification`. +/// +/// `Notification` here is mapped from Cursor's `afterAgentResponse` — a +/// safety net for headless `agent -p` mode where `stop` may not fire (one +/// of the three things to confirm empirically per the ticket). It clears +/// tool activity and transitions to `.done` only when we haven't already +/// recorded a `Stop`, so the canonical path isn't perturbed. +public struct CursorSignalSource: StateSignalSource { + public init() {} + + public func transition( + for event: AgentHookEvent, + currentActivityState: AgentActivityState, + currentNotificationType: String?, + currentLastTopLevelStopAt: Date? + ) -> AgentStateTransition { + // Same blanket-clear policy as Claude/Codex: every event except + // `PermissionRequest` clears the pending notification. We keep the + // exclusion defensive even though the current writer doesn't + // surface `PermissionRequest` directly — a follow-up that maps + // Cursor's permission flow into this vocabulary will inherit the + // correct precedence. + let blanketClear = event.eventName != "PermissionRequest" + var transition = AgentStateTransition( + notification: blanketClear ? .clear : .leave + ) + + switch event.eventName { + case "SessionStart": + let source = event.source ?? "startup" + transition.newActivityState = source == "resume" ? .done : .idle + transition.lastTopLevelStopAt = .clear + + case "PreToolUse": + let toolName = event.toolName ?? "unknown" + transition.toolActivity = .set(ToolActivity( + toolName: toolName, isActive: true + )) + transition.newActivityState = .working + + case "PostToolUse": + let toolName = event.toolName ?? "unknown" + transition.toolActivity = .set(ToolActivity( + toolName: toolName, isActive: false + )) + + case "UserPromptSubmit": + transition.newActivityState = .working + transition.lastTopLevelStopAt = .clear + + case "Stop": + transition.newActivityState = .done + transition.toolActivity = .clear + transition.lastTopLevelStopAt = .set(Date()) + + case "Notification": + // Safety net for headless mode. Only flip to `.done` if we + // haven't recorded a top-level Stop yet — when Stop has + // already fired, a trailing Notification shouldn't override + // its (more precise) timing or clear its toolActivity again. + transition.toolActivity = .clear + if currentLastTopLevelStopAt == nil { + transition.newActivityState = .done + } + + case "PermissionRequest": + // Cursor's writer doesn't currently map a Cursor event to + // `PermissionRequest`, but the case is kept for cross-agent + // parity and for the follow-up that will route Cursor's + // permission flow through here. + if currentNotificationType != "question" { + transition.notification = .set(HookNotification( + message: "Permission requested", + notificationType: "permission_prompt" + )) + } + transition.newActivityState = .waiting + transition.toolActivity = .clear + + default: + // Unknown events get the blanket notification clear and + // nothing else — Cursor's event vocabulary may grow over + // time without requiring code changes for events that + // don't change state. + break + } + + return transition + } +} diff --git a/Packages/CrowCursor/Tests/CrowCursorTests/CursorAgentTests.swift b/Packages/CrowCursor/Tests/CrowCursorTests/CursorAgentTests.swift new file mode 100644 index 00000000..cd71f3e9 --- /dev/null +++ b/Packages/CrowCursor/Tests/CrowCursorTests/CursorAgentTests.swift @@ -0,0 +1,66 @@ +import Foundation +import Testing +@testable import CrowCursor +@testable import CrowCore + +@Suite("CursorAgent") +struct CursorAgentTests { + private let agent = CursorAgent() + + @Test func protocolMembers() { + #expect(agent.kind == .cursor) + #expect(agent.displayName == "Cursor") + #expect(agent.iconSystemName == "cursorarrow.rays") + #expect(agent.supportsRemoteControl == true) + #expect(agent.launchCommandToken == "agent") + } + + @Test func autoLaunchCommandWorkSession() { + let session = Session(name: "test", agentKind: .cursor) + let cmd = agent.autoLaunchCommand( + session: session, + worktreePath: "/tmp/wt", + remoteControlEnabled: false, + autoPermissionMode: false, + telemetryPort: nil + ) + #expect(cmd == "agent\n") + } + + @Test func autoLaunchCommandIgnoresTelemetryAndRemoteControl() { + // Cursor has no OTEL exporter and provides remote control via the + // global hooks.json (`stop.followup_message`), not a per-launch + // flag — toggling these shouldn't change the launch text. + let session = Session(name: "test", agentKind: .cursor) + let cmd = agent.autoLaunchCommand( + session: session, + worktreePath: "/tmp/wt", + remoteControlEnabled: true, + autoPermissionMode: false, + telemetryPort: 4318 + ) + #expect(cmd == "agent\n") + } + + @Test func autoLaunchCommandReviewSessionUnsupported() { + let session = Session(name: "review", kind: .review, agentKind: .cursor) + let cmd = agent.autoLaunchCommand( + session: session, + worktreePath: "/tmp/wt", + remoteControlEnabled: false, + autoPermissionMode: false, + telemetryPort: nil + ) + #expect(cmd == nil) // Cursor review sessions aren't supported in MVP. + } + + @Test func findBinaryReturnsNilWhenAbsent() { + // We can't easily mock FileManager.isExecutableFile, but we CAN + // verify the search returns nil when the candidate paths don't + // resolve. This relies on the test environment not having an + // `agent` binary at the homedir candidate path — the homebrew + // path may or may not exist depending on the developer machine, + // so we accept either outcome and just verify the result type. + _ = agent.findBinary() // smoke test: must not crash + } +} diff --git a/Packages/CrowCursor/Tests/CrowCursorTests/CursorHookConfigWriterTests.swift b/Packages/CrowCursor/Tests/CrowCursorTests/CursorHookConfigWriterTests.swift new file mode 100644 index 00000000..36eeea2d --- /dev/null +++ b/Packages/CrowCursor/Tests/CrowCursorTests/CursorHookConfigWriterTests.swift @@ -0,0 +1,138 @@ +import Foundation +import Testing +@testable import CrowCursor +@testable import CrowCore + +@Suite("CursorHookConfigWriter") +struct CursorHookConfigWriterTests { + private func makeTempCursorHome() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("cursor-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + @Test func writeHookConfigIsNoOp() throws { + // Per-session writes are no-ops — Cursor hooks are global. + let writer = CursorHookConfigWriter() + let tmp = try makeTempCursorHome() + defer { try? FileManager.default.removeItem(at: tmp) } + try writer.writeHookConfig( + worktreePath: tmp.path, + sessionID: UUID(), + crowPath: "/usr/local/bin/crow" + ) + // No file should have been created in the worktree. + let files = try FileManager.default.contentsOfDirectory(atPath: tmp.path) + #expect(files.isEmpty) + } + + @Test func installGlobalConfigWritesAllEvents() throws { + let cursorHome = try makeTempCursorHome() + defer { try? FileManager.default.removeItem(at: cursorHome) } + try CursorHookConfigWriter.installGlobalConfig( + cursorHome: cursorHome.path, + crowPath: "/opt/homebrew/bin/crow" + ) + + let hooksPath = cursorHome.appendingPathComponent("hooks.json") + let data = try Data(contentsOf: hooksPath) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + let hooks = json["hooks"] as! [String: Any] + + // Cursor keys are camelCase. + #expect(hooks.count == 6) + for event in ["sessionStart", "preToolUse", "postToolUse", "beforeSubmitPrompt", "stop", "afterAgentResponse"] { + #expect(hooks[event] != nil, "missing hook entry for \(event)") + } + + // Spot-check the command shape — the `--event` arg is the + // Crow-canonical PascalCase name, not the Cursor camelCase key. + let entries = hooks["preToolUse"] as! [[String: Any]] + let inner = entries.first!["hooks"] as! [[String: Any]] + let command = inner.first!["command"] as! String + #expect(command == "/opt/homebrew/bin/crow hook-event --agent cursor --event PreToolUse") + } + + @Test func installGlobalConfigMapsAfterAgentResponseToNotification() throws { + let cursorHome = try makeTempCursorHome() + defer { try? FileManager.default.removeItem(at: cursorHome) } + try CursorHookConfigWriter.installGlobalConfig( + cursorHome: cursorHome.path, + crowPath: "/usr/local/bin/crow" + ) + + let hooksPath = cursorHome.appendingPathComponent("hooks.json") + let data = try Data(contentsOf: hooksPath) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + let hooks = json["hooks"] as! [String: Any] + + let entries = hooks["afterAgentResponse"] as! [[String: Any]] + let inner = entries.first!["hooks"] as! [[String: Any]] + let command = inner.first!["command"] as! String + #expect(command.contains("--event Notification")) + } + + @Test func installGlobalConfigPreservesUserEntries() throws { + let cursorHome = try makeTempCursorHome() + defer { try? FileManager.default.removeItem(at: cursorHome) } + + // Pre-seed a user-managed hook for a non-Crow event. + let hooksPath = cursorHome.appendingPathComponent("hooks.json") + let preExisting: [String: Any] = [ + "hooks": [ + "beforeShellExecution": [ + ["hooks": [["type": "command", "command": "/usr/local/bin/my-shell-guard"]]] + ] + ] + ] + let data = try JSONSerialization.data(withJSONObject: preExisting) + try data.write(to: hooksPath) + + try CursorHookConfigWriter.installGlobalConfig( + cursorHome: cursorHome.path, + crowPath: "/usr/local/bin/crow" + ) + + let after = try Data(contentsOf: hooksPath) + let json = try JSONSerialization.jsonObject(with: after) as! [String: Any] + let hooks = json["hooks"] as! [String: Any] + #expect(hooks["beforeShellExecution"] != nil, "user-managed hook entry should be preserved") + #expect(hooks["stop"] != nil, "Crow's stop hook should still be installed") + } + + @Test func installGlobalConfigIsIdempotent() throws { + let cursorHome = try makeTempCursorHome() + defer { try? FileManager.default.removeItem(at: cursorHome) } + try CursorHookConfigWriter.installGlobalConfig(cursorHome: cursorHome.path, crowPath: "/bin/crow") + let first = try Data(contentsOf: cursorHome.appendingPathComponent("hooks.json")) + try CursorHookConfigWriter.installGlobalConfig(cursorHome: cursorHome.path, crowPath: "/bin/crow") + let second = try Data(contentsOf: cursorHome.appendingPathComponent("hooks.json")) + #expect(first == second) + } + + @Test func postToolUseAndNotificationAreAsync() throws { + let cursorHome = try makeTempCursorHome() + defer { try? FileManager.default.removeItem(at: cursorHome) } + try CursorHookConfigWriter.installGlobalConfig( + cursorHome: cursorHome.path, + crowPath: "/bin/crow" + ) + + let data = try Data(contentsOf: cursorHome.appendingPathComponent("hooks.json")) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + let hooks = json["hooks"] as! [String: Any] + + for asyncKey in ["postToolUse", "afterAgentResponse"] { + let entries = hooks[asyncKey] as! [[String: Any]] + let inner = entries.first!["hooks"] as! [[String: Any]] + let asyncFlag = inner.first!["async"] as? Bool + #expect(asyncFlag == true, "\(asyncKey) should be async") + } + + // Spot-check that `stop` stays synchronous — its timing matters. + let stopEntries = hooks["stop"] as! [[String: Any]] + let stopInner = stopEntries.first!["hooks"] as! [[String: Any]] + #expect(stopInner.first!["async"] == nil, "stop should be synchronous") + } +} diff --git a/Packages/CrowCursor/Tests/CrowCursorTests/CursorScaffolderTests.swift b/Packages/CrowCursor/Tests/CrowCursorTests/CursorScaffolderTests.swift new file mode 100644 index 00000000..39da5735 --- /dev/null +++ b/Packages/CrowCursor/Tests/CrowCursorTests/CursorScaffolderTests.swift @@ -0,0 +1,52 @@ +import Foundation +import Testing +@testable import CrowCursor + +@Suite("CursorScaffolder") +struct CursorScaffolderTests { + private func makeTempDevRoot() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("cursor-scaffold-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + @Test func scaffoldWritesAgentsMD() throws { + let devRoot = try makeTempDevRoot() + defer { try? FileManager.default.removeItem(at: devRoot) } + + try CursorScaffolder.scaffold(devRoot: devRoot.path) + + let agents = try String( + contentsOf: devRoot.appendingPathComponent("AGENTS.md"), + encoding: .utf8 + ) + // The fallback bundled template (or the resource) must contain + // some Crow-specific marker. + #expect(agents.contains("Crow")) + #expect(agents.contains("Known Issues / Corrections")) + } + + @Test func scaffoldPreservesUserCorrections() throws { + let devRoot = try makeTempDevRoot() + defer { try? FileManager.default.removeItem(at: devRoot) } + + // First pass: write the template. + try CursorScaffolder.scaffold(devRoot: devRoot.path) + + // Append a user-authored Known Issues entry. + let agentsPath = devRoot.appendingPathComponent("AGENTS.md").path + let template = try String(contentsOfFile: agentsPath, encoding: .utf8) + let edited = template.replacingOccurrences( + of: "## Known Issues / Corrections", + with: "## Known Issues / Corrections\n\n- Use `crow new-session --agent cursor` for Cursor sessions." + ) + try edited.write(toFile: agentsPath, atomically: true, encoding: .utf8) + + // Second pass: should preserve the edits. + try CursorScaffolder.scaffold(devRoot: devRoot.path) + + let after = try String(contentsOfFile: agentsPath, encoding: .utf8) + #expect(after.contains("Use `crow new-session --agent cursor`")) + } +} diff --git a/Packages/CrowCursor/Tests/CrowCursorTests/CursorSignalSourceTests.swift b/Packages/CrowCursor/Tests/CrowCursorTests/CursorSignalSourceTests.swift new file mode 100644 index 00000000..8b48b5ad --- /dev/null +++ b/Packages/CrowCursor/Tests/CrowCursorTests/CursorSignalSourceTests.swift @@ -0,0 +1,194 @@ +import Foundation +import Testing +@testable import CrowCursor +@testable import CrowCore + +@Suite("CursorSignalSource") +struct CursorSignalSourceTests { + private let source = CursorSignalSource() + + private func event( + _ name: String, + toolName: String? = nil, + source: String? = nil + ) -> AgentHookEvent { + AgentHookEvent( + sessionID: UUID(), + eventName: name, + toolName: toolName, + source: source, + summary: name + ) + } + + // MARK: - Canonical event vocabulary (PascalCase via writer rewrite) + + @Test func sessionStartFreshIdle() { + let t = source.transition( + for: event("SessionStart", source: "startup"), + currentActivityState: .done, + currentNotificationType: nil, + currentLastTopLevelStopAt: Date() + ) + #expect(t.newActivityState == .idle) + if case .clear = t.lastTopLevelStopAt {} else { + Issue.record("SessionStart should clear lastTopLevelStopAt") + } + } + + @Test func sessionStartResumeMarksDone() { + let t = source.transition( + for: event("SessionStart", source: "resume"), + currentActivityState: .idle, + currentNotificationType: nil, + currentLastTopLevelStopAt: nil + ) + #expect(t.newActivityState == .done) + } + + @Test func preToolUseSetsWorking() { + let t = source.transition( + for: event("PreToolUse", toolName: "Bash"), + currentActivityState: .idle, + currentNotificationType: nil, + currentLastTopLevelStopAt: nil + ) + #expect(t.newActivityState == .working) + if case .set(let activity) = t.toolActivity { + #expect(activity.toolName == "Bash") + #expect(activity.isActive == true) + } else { + Issue.record("expected tool activity set active") + } + } + + @Test func postToolUseMarksInactive() { + let t = source.transition( + for: event("PostToolUse", toolName: "Bash"), + currentActivityState: .working, + currentNotificationType: nil, + currentLastTopLevelStopAt: nil + ) + #expect(t.newActivityState == nil) + if case .set(let activity) = t.toolActivity { + #expect(activity.isActive == false) + } else { + Issue.record("expected inactive tool activity") + } + } + + @Test func userPromptSubmitClearsLastStopAt() { + let t = source.transition( + for: event("UserPromptSubmit"), + currentActivityState: .done, + currentNotificationType: nil, + currentLastTopLevelStopAt: Date() + ) + #expect(t.newActivityState == .working) + if case .clear = t.lastTopLevelStopAt {} else { + Issue.record("UserPromptSubmit should clear lastTopLevelStopAt") + } + } + + @Test func stopSetsLastStopAt() { + let t = source.transition( + for: event("Stop"), + currentActivityState: .working, + currentNotificationType: nil, + currentLastTopLevelStopAt: nil + ) + #expect(t.newActivityState == .done) + if case .clear = t.toolActivity {} else { + Issue.record("Stop should clear tool activity") + } + if case .set = t.lastTopLevelStopAt {} else { + Issue.record("Stop should set lastTopLevelStopAt") + } + } + + // MARK: - Notification (afterAgentResponse safety net) + + @Test func notificationMarksDoneWhenNoStopYet() { + let t = source.transition( + for: event("Notification"), + currentActivityState: .working, + currentNotificationType: nil, + currentLastTopLevelStopAt: nil + ) + #expect(t.newActivityState == .done) + if case .clear = t.toolActivity {} else { + Issue.record("Notification should clear tool activity") + } + } + + @Test func notificationDoesNotOverrideExistingStop() { + let stopAt = Date() + let t = source.transition( + for: event("Notification"), + currentActivityState: .done, + currentNotificationType: nil, + currentLastTopLevelStopAt: stopAt + ) + // Already stopped — Notification should not flip activity state. + #expect(t.newActivityState == nil) + } + + // MARK: - PermissionRequest (defensive parity) + + @Test func permissionRequestWaits() { + let t = source.transition( + for: event("PermissionRequest"), + currentActivityState: .working, + currentNotificationType: nil, + currentLastTopLevelStopAt: nil + ) + #expect(t.newActivityState == .waiting) + if case .set(let n) = t.notification { + #expect(n.notificationType == "permission_prompt") + } else { + Issue.record("expected permission_prompt notification") + } + if case .clear = t.toolActivity {} else { + Issue.record("expected toolActivity cleared") + } + } + + @Test func permissionRequestPreservesQuestionNotification() { + let t = source.transition( + for: event("PermissionRequest"), + currentActivityState: .waiting, + currentNotificationType: "question", + currentLastTopLevelStopAt: nil + ) + if case .leave = t.notification {} else { + Issue.record("question notification should not be overridden") + } + } + + // MARK: - Blanket clear + + @Test func nonPermissionRequestClearsPendingNotification() { + let t = source.transition( + for: event("PreToolUse", toolName: "Bash"), + currentActivityState: .waiting, + currentNotificationType: "permission_prompt", + currentLastTopLevelStopAt: nil + ) + if case .clear = t.notification {} else { + Issue.record("non-PermissionRequest events should clear pending notification") + } + } + + @Test func unknownEventAppliesBlanketClearOnly() { + let t = source.transition( + for: event("FuturisticUnknownEvent"), + currentActivityState: .working, + currentNotificationType: "permission_prompt", + currentLastTopLevelStopAt: nil + ) + #expect(t.newActivityState == nil) + if case .clear = t.notification {} else { + Issue.record("unknown events should still clear pending notification") + } + } +} diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index 27f312df..b179062e 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -3,6 +3,7 @@ import SwiftUI import CrowClaude import CrowCodex import CrowCore +import CrowCursor import CrowGit import CrowProvider import CrowUI @@ -334,6 +335,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { NSLog("[Crow] OpenAI Codex agent registered") } + // Conditionally register the Cursor agent on the same gate. The + // Cursor CLI installs the binary as `agent` (not `cursor`); when + // it's absent the picker silently stays at the two prior agents. + let cursorAgent = CursorAgent() + if cursorAgent.findBinary() != nil { + AgentRegistry.shared.register(cursorAgent) + NSLog("[Crow] Cursor agent registered") + } + // Initialize libghostty NSLog("[Crow] Initializing Ghostty") GhosttyApp.shared.initialize() @@ -411,6 +421,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + // Cursor-specific dev-root and global config — only when Cursor + // is registered. AGENTS.md is the same file Codex writes; both + // scaffolders are idempotent and preserve the user-edited + // `## Known Issues / Corrections` section, so co-existence is + // safe. hooks.json goes into ~/.cursor (or $CURSOR_CONFIG_DIR). + if AgentRegistry.shared.agent(for: .cursor) != nil { + do { + try CursorScaffolder.scaffold(devRoot: devRoot) + } catch { + NSLog("[Crow] Cursor scaffold failed: %@", error.localizedDescription) + } + if let crowPath = ClaudeHookConfigWriter.findCrowBinary() { + let cursorHome = ProcessInfo.processInfo.environment["CURSOR_CONFIG_DIR"] + ?? NSString(string: "~/.cursor").expandingTildeInPath + do { + try CursorHookConfigWriter.installGlobalConfig(cursorHome: cursorHome, crowPath: crowPath) + } catch { + NSLog("[Crow] Cursor global config install failed: %@", error.localizedDescription) + } + } + } + // Initialize persistence let store = JSONStore() self.store = store @@ -446,7 +478,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Check for runtime dependencies (non-blocking) Task { let missing = await Task.detached { - let tools = ["gh", "git", "claude", "codex", "glab", "code"] + let tools = ["gh", "git", "claude", "codex", "agent", "glab", "code"] return tools.filter { !ShellEnvironment.shared.hasCommand($0) } }.value if !missing.isEmpty { From cca369795217462a7df7ecd0a7a4c6b49f73903e Mon Sep 17 00:00:00 2001 From: Danny Gershman Date: Wed, 3 Jun 2026 19:27:25 -0400 Subject: [PATCH 2/3] =?UTF-8?q?Address=20PR=20#418=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20anchor=20agent-token=20match=20+=20comment=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Yellow: replace bare command.contains(token) in prepareAgentLaunchText with commandLaunchesToken — a shell-word-boundary check anchored at start-of-string or after `;`, `&&`, `||`, `|`. Cursor's `agent` token collides with English prose; this prevents `crow send "refactor the agent registry"` from falsely flipping terminalReadiness = .agentLaunched and contaminating list-terminals readiness reporting. * Green: fix CursorAgent doc comment — remove the aspirational claim that remote control rides on `stop.followup_message` in hooks.json. The hook writer only emits command hooks; actual remote control is `crow send` typing into the TUI (agent-agnostic, in SessionService). * Green: soften the CursorScaffolder co-existence claim — byte-identical only holds when the bundled template is present; the in-source fallback strings differ between Codex and Cursor. Document the fallback ordering (Cursor wins, runs after Codex in AppDelegate) so the behavior is predictable rather than implied. * Green deferred: findBinary path-list vs ShellEnvironment.hasCommand PATH mismatch is a pre-existing accepted pattern (mirrors OpenAICodexAgent exactly per the reviewer's own note); deferred for cross-agent consistency. Adds 13 new tests in Tests/CrowTests/CommandLaunchesTokenTests.swift covering the Cursor footgun (prose like "agent registry", "--agent flag"), all known auto-launch shapes (bare, env-prefixed, cd-prefixed for each of the three agents), and edge cases (empty command, token at end-of-string, semicolon separator). Test run: 165 root + 25 CrowCursor + 27 CrowCodex + 37 CrowClaude + 205 CrowCore — all green. 🐦‍⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: 968C71B6-8487-41C2-A841-E982DC017A5F --- .../Sources/CrowCursor/CursorAgent.swift | 20 +++-- .../Sources/CrowCursor/CursorScaffolder.swift | 10 ++- Sources/Crow/App/AppDelegate.swift | 20 ++++- .../CrowTests/CommandLaunchesTokenTests.swift | 79 +++++++++++++++++++ 4 files changed, 117 insertions(+), 12 deletions(-) create mode 100644 Tests/CrowTests/CommandLaunchesTokenTests.swift diff --git a/Packages/CrowCursor/Sources/CrowCursor/CursorAgent.swift b/Packages/CrowCursor/Sources/CrowCursor/CursorAgent.swift index 2958e343..6857928a 100644 --- a/Packages/CrowCursor/Sources/CrowCursor/CursorAgent.swift +++ b/Packages/CrowCursor/Sources/CrowCursor/CursorAgent.swift @@ -2,10 +2,14 @@ import Foundation import CrowCore /// `CodingAgent` conformer for the Cursor CLI (`agent` binary). Mirrors the -/// shape of `OpenAICodexAgent` but enables remote control — Cursor's hook -/// engine is a superset of Claude Code's (it supports `stop.followup_message` -/// for auto-continue, accepts the `CLAUDE_PROJECT_DIR` alias, and uses the -/// same exit-code 0/2 protocol). +/// shape of `OpenAICodexAgent` but enables remote control — Cursor runs an +/// interactive TUI, so `crow send` (the agent-agnostic stdin-paste path in +/// `SessionService`) is sufficient for remote-driving it; no per-agent +/// hookery needed. Cursor's hook engine itself is a superset of Claude +/// Code's — same exit-code 0/2 protocol, accepts `CLAUDE_PROJECT_DIR` as +/// an alias — which is why the `HookConfigWriter` / `StateSignalSource` +/// pair below works rather than being a no-op like Codex's per-session +/// writer. public struct CursorAgent: CodingAgent { public let kind: AgentKind = .cursor public let displayName: String = "Cursor" @@ -62,10 +66,10 @@ public struct CursorAgent: CodingAgent { // Bare `agent` launch — the user types their prompt into the TUI. // No env prefix (Cursor reads `CURSOR_API_KEY` from the shell; // GUI-stored creds are inherited otherwise), no `--continue` - // (MVP doesn't auto-resume), no remote-control flag (Cursor's hook - // engine provides remote control via `stop.followup_message` in the - // global hooks.json, not a per-launch flag). The terminal's cwd is - // already the worktree path. + // (MVP doesn't auto-resume), no remote-control flag (remote + // control is `crow send` typing into the TUI — agent-agnostic, + // handled by `SessionService.send`, not a per-launch flag). The + // terminal's cwd is already the worktree path. return "agent\n" } diff --git a/Packages/CrowCursor/Sources/CrowCursor/CursorScaffolder.swift b/Packages/CrowCursor/Sources/CrowCursor/CursorScaffolder.swift index d0c93c1b..76e481d7 100644 --- a/Packages/CrowCursor/Sources/CrowCursor/CursorScaffolder.swift +++ b/Packages/CrowCursor/Sources/CrowCursor/CursorScaffolder.swift @@ -5,9 +5,13 @@ import Foundation /// alternative to `.cursor/rules/*.mdc` — this is the same file Codex /// uses, and we reuse the shared `Resources/AGENTS.md.template`. /// -/// Co-existence with `CodexScaffolder` is safe: both write byte-identical -/// content using the same template, both preserve user edits below the -/// `## Known Issues / Corrections` marker, and both are idempotent. The +/// Co-existence with `CodexScaffolder` is safe: when the bundled template +/// is present (the normal path), both writers produce byte-identical +/// content. The two fallback strings still differ slightly — Cursor says +/// "Cursor Workspace Context" — so with both agents registered each +/// launch has Codex write then Cursor overwrite (deterministic because +/// `AppDelegate` runs Cursor's block after Codex's), with the user-edited +/// `## Known Issues / Corrections` section preserved either way. The /// `.cursorrules` legacy file is intentionally not written — it's silent /// in current Cursor docs, and `AGENTS.md` covers the same role. public enum CursorScaffolder { diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index b179062e..5fd1fbe8 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -1837,7 +1837,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { crowPath: String?, telemetryPort: UInt16? ) -> (text: String, didLaunch: Bool) { - guard command.contains(agent.launchCommandToken) else { return (command, false) } + guard commandLaunchesToken(command, token: agent.launchCommandToken) else { return (command, false) } if let worktreePath, let crowPath { do { try agent.hookConfigWriter.writeHookConfig( @@ -1864,6 +1864,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return ("export \(vars) && \(command)", true) } + /// Whether `command` invokes `token` as a shell command rather than an + /// incidental substring. Anchored at start-of-string or after a shell + /// command separator (`;`, `&&`, `||`, `|`, possibly with whitespace) and + /// bounded on the right by whitespace, end-of-string, or a quote. + /// + /// Plain `command.contains(token)` was fine for Claude (`"claude"`) and + /// Codex (`"codex"`), which rarely appear incidentally — but Cursor's + /// token is `"agent"`, a common English word that can show up in any + /// `crow send` text (e.g. *"refactor the agent registry"*). Without + /// anchoring we would flip `terminalReadiness = .agentLaunched` on + /// arbitrary prose and `list-terminals` would report the agent had + /// started when it hadn't. + nonisolated static func commandLaunchesToken(_ command: String, token: String) -> Bool { + let escaped = NSRegularExpression.escapedPattern(for: token) + let pattern = "(?:^|[;&|]\\s*)\(escaped)(?=\\s|$|[\"'])" + return command.range(of: pattern, options: .regularExpression) != nil + } + // MARK: - Claude Binary Resolution /// Replace bare `claude` in a command string with the full path to the real binary, diff --git a/Tests/CrowTests/CommandLaunchesTokenTests.swift b/Tests/CrowTests/CommandLaunchesTokenTests.swift new file mode 100644 index 00000000..abff168b --- /dev/null +++ b/Tests/CrowTests/CommandLaunchesTokenTests.swift @@ -0,0 +1,79 @@ +import Foundation +import Testing +@testable import Crow + +/// Verifies `AppDelegate.commandLaunchesToken` anchors token matches at +/// shell-word boundaries instead of doing a naked substring check. +/// +/// Motivation: Cursor's `launchCommandToken` is `"agent"` — a common +/// English word that can appear inside any `crow send` prose +/// (e.g. *"refactor the agent registry"*). A bare `command.contains(token)` +/// would falsely flip `terminalReadiness = .agentLaunched` on arbitrary +/// text and pollute `list-terminals` readiness reporting. +@Suite("commandLaunchesToken — agent launch detection") +struct CommandLaunchesTokenTests { + + // MARK: Auto-launch shapes — must match + + @Test func bareAgentLaunch() { + #expect(AppDelegate.commandLaunchesToken("agent\n", token: "agent")) + } + + @Test func bareCodexLaunch() { + #expect(AppDelegate.commandLaunchesToken("codex\n", token: "codex")) + } + + @Test func claudeWithArgs() { + #expect(AppDelegate.commandLaunchesToken("claude --rc --name 'foo' \"prompt\"", token: "claude")) + } + + @Test func envPrefixedClaudeLaunch() { + let cmd = "export CLAUDE_CODE_ENABLE_TELEMETRY=1 OTEL_EXPORTER_OTLP_PROTOCOL=http/json && claude --rc" + #expect(AppDelegate.commandLaunchesToken(cmd, token: "claude")) + } + + @Test func cdPrefixedAgentLaunch() { + // Shape emitted by `CursorLauncher.launchCommand`. + let cmd = "cd '/Users/x/wt' && agent \"$(cat /tmp/prompt.md)\"" + #expect(AppDelegate.commandLaunchesToken(cmd, token: "agent")) + } + + @Test func cdPrefixedCodexLaunch() { + let cmd = "cd '/Users/x/wt' && codex \"$(cat /tmp/prompt.md)\"" + #expect(AppDelegate.commandLaunchesToken(cmd, token: "codex")) + } + + // MARK: Incidental prose — must NOT match (the Cursor footgun) + + @Test func proseContainingAgentRejected() { + #expect(!AppDelegate.commandLaunchesToken("refactor the agent registry", token: "agent")) + } + + @Test func proseContainingAgentMidSentenceRejected() { + #expect(!AppDelegate.commandLaunchesToken("Please update the agent kind enum", token: "agent")) + } + + @Test func flagLikeAgentRejected() { + // `--agent` is preceded by `-`, not a shell separator. + #expect(!AppDelegate.commandLaunchesToken("the --agent flag does X", token: "agent")) + } + + @Test func proseContainingClaudeStillRejected() { + // Even rare-as-prose tokens are now properly word-bounded. + #expect(!AppDelegate.commandLaunchesToken("the claudeKind enum", token: "claude")) + } + + // MARK: Edge cases + + @Test func emptyCommandRejected() { + #expect(!AppDelegate.commandLaunchesToken("", token: "agent")) + } + + @Test func tokenAtEndOfString() { + #expect(AppDelegate.commandLaunchesToken("cd /x && agent", token: "agent")) + } + + @Test func semicolonSeparator() { + #expect(AppDelegate.commandLaunchesToken("echo hi; agent\n", token: "agent")) + } +} From 759bb4d210f4595b07e1f7cfb396fc8e47c8d639 Mon Sep 17 00:00:00 2001 From: Danny Gershman Date: Wed, 3 Jun 2026 20:51:13 -0400 Subject: [PATCH 3/3] Fix commandLaunchesToken regression for path-resolved Claude binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cca3697's anchored regex rejected /opt/homebrew/bin/claude because `/` wasn't a recognized left boundary. resolveClaudeInCommand rewrites the bare `claude` token to an absolute path before pasteDeferredLaunch hands the command to prepareAgentLaunchText (the standard #408 path for brand-new managed Claude sessions), so the guard returned (command, false) early and silently skipped both writeHookConfig (per-worktree .claude/settings.local.json) and the OTEL env injection. Hooks didn't route back to the session, sidebar idle/working/done state broke, and telemetry stopped exporting. Adds `/` to the left-boundary alternation: (?:^|[;&|]\s*|/)(?=\s|$|["']) The right boundary stays at \s|$|["'], so incidental path substrings like /tmp/agent_log still reject (token followed by `_`, not a boundary char). Codex and Cursor weren't impacted — their tokens aren't path- resolved, and their writers are no-ops with no OTEL — which is why the existing suite didn't catch it. Adds 5 tests covering: - /opt/homebrew/bin/claude --rc --name 'foo' - /usr/local/bin/claude --rc - /opt/homebrew/bin/agent (Cursor) - env-prefixed path-resolved Claude (the full pasteDeferredLaunch shape) - /tmp/agent_log negative case (confirms `/` doesn't open the door to incidental path substrings) 🐦‍⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: 968C71B6-8487-41C2-A841-E982DC017A5F --- Sources/Crow/App/AppDelegate.swift | 14 +++++--- .../CrowTests/CommandLaunchesTokenTests.swift | 34 +++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index 1d73fe73..18d6032a 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -1873,9 +1873,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } /// Whether `command` invokes `token` as a shell command rather than an - /// incidental substring. Anchored at start-of-string or after a shell - /// command separator (`;`, `&&`, `||`, `|`, possibly with whitespace) and - /// bounded on the right by whitespace, end-of-string, or a quote. + /// incidental substring. Anchored at start-of-string, after a shell + /// command separator (`;`, `&&`, `||`, `|`, possibly with whitespace), + /// or at a path separator (`/`) — the last covers binaries that have + /// already been path-resolved (e.g. `resolveClaudeInCommand` rewrites + /// bare `claude` to `/opt/homebrew/bin/claude` before this guard runs + /// on the deferred-launch path, so without the `/` boundary the guard + /// returns early and we'd silently skip per-worktree hook-config writes + /// and Claude's OTEL env injection). Bounded on the right by whitespace, + /// end-of-string, or a quote. /// /// Plain `command.contains(token)` was fine for Claude (`"claude"`) and /// Codex (`"codex"`), which rarely appear incidentally — but Cursor's @@ -1886,7 +1892,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// started when it hadn't. nonisolated static func commandLaunchesToken(_ command: String, token: String) -> Bool { let escaped = NSRegularExpression.escapedPattern(for: token) - let pattern = "(?:^|[;&|]\\s*)\(escaped)(?=\\s|$|[\"'])" + let pattern = "(?:^|[;&|]\\s*|/)\(escaped)(?=\\s|$|[\"'])" return command.range(of: pattern, options: .regularExpression) != nil } diff --git a/Tests/CrowTests/CommandLaunchesTokenTests.swift b/Tests/CrowTests/CommandLaunchesTokenTests.swift index abff168b..5ece5423 100644 --- a/Tests/CrowTests/CommandLaunchesTokenTests.swift +++ b/Tests/CrowTests/CommandLaunchesTokenTests.swift @@ -43,6 +43,33 @@ struct CommandLaunchesTokenTests { #expect(AppDelegate.commandLaunchesToken(cmd, token: "codex")) } + // MARK: Absolute-path resolved binaries — must match + // `resolveClaudeInCommand` rewrites the bare `claude` token to an + // absolute path before the deferred-launch paste reaches + // `prepareAgentLaunchText`. The left boundary must accept `/` or + // hook-config writes (the per-worktree `.claude/settings.local.json`) + // and Claude's OTEL env injection both silently skip. + + @Test func pathResolvedClaudeBrew() { + #expect(AppDelegate.commandLaunchesToken("/opt/homebrew/bin/claude --rc --name 'foo'", token: "claude")) + } + + @Test func pathResolvedClaudeLocal() { + #expect(AppDelegate.commandLaunchesToken("/usr/local/bin/claude --rc", token: "claude")) + } + + @Test func pathResolvedAgent() { + #expect(AppDelegate.commandLaunchesToken("/opt/homebrew/bin/agent\n", token: "agent")) + } + + @Test func pathResolvedClaudeWithEnvPrefix() { + // The full shape that flows through pasteDeferredLaunch on the + // standard #408 path — env prefix from telemetry merge + path-resolved + // claude. The guard must still admit this so writeHookConfig runs. + let cmd = "export CLAUDE_CODE_ENABLE_TELEMETRY=1 OTEL_EXPORTER_OTLP_PROTOCOL=http/json && /opt/homebrew/bin/claude --rc" + #expect(AppDelegate.commandLaunchesToken(cmd, token: "claude")) + } + // MARK: Incidental prose — must NOT match (the Cursor footgun) @Test func proseContainingAgentRejected() { @@ -63,6 +90,13 @@ struct CommandLaunchesTokenTests { #expect(!AppDelegate.commandLaunchesToken("the claudeKind enum", token: "claude")) } + @Test func pathLikeAgentSubstringRejected() { + // `/tmp/agent_log` has `/` left of `agent` but `_log` on the right, + // so the right-boundary (`\s|$|["']`) keeps it rejected. Confirms + // the `/` boundary doesn't open the door to incidental path substrings. + #expect(!AppDelegate.commandLaunchesToken("tail -f /tmp/agent_log", token: "agent")) + } + // MARK: Edge cases @Test func emptyCommandRejected() {