Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -34,6 +35,7 @@ let package = Package(
"CrowPersistence",
"CrowClaude",
"CrowCodex",
"CrowCursor",
"CrowIPC",
"CrowTelemetry",
],
Expand Down
3 changes: 3 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/Agent/AgentKind.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
17 changes: 17 additions & 0 deletions Packages/CrowCursor/Package.swift
Original file line number Diff line number Diff line change
@@ -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"]),
]
)
101 changes: 101 additions & 0 deletions Packages/CrowCursor/Sources/CrowCursor/CursorAgent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import Foundation
import CrowCore

/// `CodingAgent` conformer for the Cursor CLI (`agent` binary). Mirrors the
/// 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"
/// 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 (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"
}

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
)
}
}
111 changes: 111 additions & 0 deletions Packages/CrowCursor/Sources/CrowCursor/CursorHookConfigWriter.swift
Original file line number Diff line number Diff line change
@@ -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 `<project>/.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 <Name>` 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<String> = ["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
/// `<worktree>/.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
/// `<crow> hook-event --agent cursor --event <PascalName>` 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 `<cursorHome>/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))
}
}
78 changes: 78 additions & 0 deletions Packages/CrowCursor/Sources/CrowCursor/CursorLauncher.swift
Original file line number Diff line number Diff line change
@@ -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)'"
}
}
Loading
Loading