Cursor CLI (#457)
This commit is contained in:
@@ -35,3 +35,4 @@ strum = "0.27.2"
|
||||
bon = "3.6"
|
||||
fork_stream = "0.1.0"
|
||||
os_pipe = "1.2"
|
||||
strip-ansi-escapes = "0.2.1"
|
||||
|
||||
@@ -130,6 +130,19 @@ impl AgentProfile {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cursor() -> Self {
|
||||
Self {
|
||||
label: "cursor".to_string(),
|
||||
agent: BaseCodingAgent::Cursor,
|
||||
command: CommandBuilder::new("cursor-agent").params(vec![
|
||||
"-p",
|
||||
"--output-format=stream-json",
|
||||
"--force",
|
||||
]),
|
||||
mcp_config_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn codex() -> Self {
|
||||
Self {
|
||||
label: "codex".to_string(),
|
||||
@@ -199,6 +212,7 @@ impl AgentProfiles {
|
||||
AgentProfile::codex(),
|
||||
AgentProfile::opencode(),
|
||||
AgentProfile::qwen_code(),
|
||||
AgentProfile::cursor(),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -300,5 +314,10 @@ mod tests {
|
||||
let opencode_command = get_profile_command("opencode");
|
||||
assert!(opencode_command.contains("npx -y opencode-ai@latest run"));
|
||||
assert!(opencode_command.contains("--print-logs"));
|
||||
|
||||
let cursor_command = get_profile_command("cursor");
|
||||
assert!(cursor_command.contains("cursor-agent"));
|
||||
assert!(cursor_command.contains("-p"));
|
||||
assert!(cursor_command.contains("--output-format=stream-json"));
|
||||
}
|
||||
}
|
||||
|
||||
881
crates/executors/src/executors/cursor.rs
Normal file
881
crates/executors/src/executors/cursor.rs
Normal file
@@ -0,0 +1,881 @@
|
||||
use core::str;
|
||||
use std::{path::PathBuf, process::Stdio, sync::Arc, time::Duration};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use command_group::{AsyncCommandGroup, AsyncGroupChild};
|
||||
use futures::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::{io::AsyncWriteExt, process::Command};
|
||||
use ts_rs::TS;
|
||||
use utils::{msg_store::MsgStore, path::make_path_relative, shell::get_shell_command};
|
||||
|
||||
use crate::{
|
||||
command::{AgentProfiles, CommandBuilder},
|
||||
executors::{ExecutorError, StandardCodingAgentExecutor},
|
||||
logs::{
|
||||
ActionType, EditDiff, NormalizedEntry, NormalizedEntryType,
|
||||
plain_text_processor::PlainTextLogProcessor,
|
||||
utils::{ConversationPatch, EntryIndexProvider},
|
||||
},
|
||||
};
|
||||
|
||||
/// Executor for running Cursor CLI and normalizing its JSONL stream
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct Cursor {
|
||||
command_builder: CommandBuilder,
|
||||
}
|
||||
|
||||
impl Default for Cursor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Cursor {
|
||||
/// Create a new Cursor executor with default profile
|
||||
pub fn new() -> Self {
|
||||
let profile = AgentProfiles::get_cached()
|
||||
.get_profile("cursor")
|
||||
.expect("Default cursor profile should exist");
|
||||
|
||||
Self::with_command_builder(profile.command.clone())
|
||||
}
|
||||
|
||||
/// Create a new Cursor executor with custom command builder
|
||||
pub fn with_command_builder(command_builder: CommandBuilder) -> Self {
|
||||
Self { command_builder }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl StandardCodingAgentExecutor for Cursor {
|
||||
async fn spawn(
|
||||
&self,
|
||||
current_dir: &PathBuf,
|
||||
prompt: &str,
|
||||
) -> Result<AsyncGroupChild, ExecutorError> {
|
||||
let (shell_cmd, shell_arg) = get_shell_command();
|
||||
let agent_cmd = self.command_builder.build_initial();
|
||||
|
||||
let mut command = Command::new(shell_cmd);
|
||||
command
|
||||
.kill_on_drop(true)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.current_dir(current_dir)
|
||||
.arg(shell_arg)
|
||||
.arg(&agent_cmd);
|
||||
|
||||
let mut child = command.group_spawn()?;
|
||||
|
||||
if let Some(mut stdin) = child.inner().stdin.take() {
|
||||
stdin.write_all(prompt.as_bytes()).await?;
|
||||
stdin.shutdown().await?;
|
||||
}
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
async fn spawn_follow_up(
|
||||
&self,
|
||||
current_dir: &PathBuf,
|
||||
prompt: &str,
|
||||
session_id: &str,
|
||||
) -> Result<AsyncGroupChild, ExecutorError> {
|
||||
let (shell_cmd, shell_arg) = get_shell_command();
|
||||
let agent_cmd = self
|
||||
.command_builder
|
||||
.build_follow_up(&["--resume".to_string(), session_id.to_string()]);
|
||||
|
||||
let mut command = Command::new(shell_cmd);
|
||||
command
|
||||
.kill_on_drop(true)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.current_dir(current_dir)
|
||||
.arg(shell_arg)
|
||||
.arg(&agent_cmd);
|
||||
|
||||
let mut child = command.group_spawn()?;
|
||||
|
||||
if let Some(mut stdin) = child.inner().stdin.take() {
|
||||
stdin.write_all(prompt.as_bytes()).await?;
|
||||
stdin.shutdown().await?;
|
||||
}
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
fn normalize_logs(&self, msg_store: Arc<MsgStore>, worktree_path: &PathBuf) {
|
||||
let entry_index_provider = EntryIndexProvider::new();
|
||||
|
||||
// Process Cursor stdout JSONL with typed serde models
|
||||
let current_dir = worktree_path.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut lines = msg_store.stdout_lines_stream();
|
||||
|
||||
// Cursor agent doesn't use STDERR. Everything comes through STDOUT, both JSONL and raw error output.
|
||||
let mut error_plaintext_processor = PlainTextLogProcessor::builder()
|
||||
.normalized_entry_producer(Box::new(|content: String| NormalizedEntry {
|
||||
timestamp: None,
|
||||
entry_type: NormalizedEntryType::ErrorMessage,
|
||||
content,
|
||||
metadata: None,
|
||||
}))
|
||||
.time_gap(Duration::from_secs(2)) // Break messages if they are 2 seconds apart
|
||||
.index_provider(EntryIndexProvider::new())
|
||||
.build();
|
||||
|
||||
// Assistant streaming coalescer state
|
||||
let mut model_reported = false;
|
||||
let mut session_id_reported = false;
|
||||
|
||||
let mut current_assistant_message_buffer = String::new();
|
||||
let mut current_assistant_message_index: Option<usize> = None;
|
||||
|
||||
let worktree_str = current_dir.to_string_lossy().to_string();
|
||||
|
||||
while let Some(Ok(line)) = lines.next().await {
|
||||
// Parse line as CursorJson
|
||||
let cursor_json: CursorJson = match serde_json::from_str(&line) {
|
||||
Ok(cursor_json) => cursor_json,
|
||||
Err(_) => {
|
||||
// Not valid JSON, treat as raw error output
|
||||
let line = strip_ansi_escapes::strip_str(line);
|
||||
let line = strip_cursor_ascii_art_banner(line);
|
||||
if line.trim().is_empty() {
|
||||
continue; // Skip empty lines after stripping Noise
|
||||
}
|
||||
|
||||
// Provide a useful sign-in message if needed
|
||||
let line = if line == "Press any key to sign in..." {
|
||||
"Please sign in to Cursor CLI using `cursor-agent login` or set the CURSOR_API_KEY environment variable.".to_string()
|
||||
} else {
|
||||
line
|
||||
};
|
||||
|
||||
for patch in error_plaintext_processor.process(line + "\n") {
|
||||
msg_store.push_patch(patch);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Push session_id if present
|
||||
if !session_id_reported && let Some(session_id) = cursor_json.extract_session_id() {
|
||||
msg_store.push_session_id(session_id);
|
||||
session_id_reported = true;
|
||||
}
|
||||
|
||||
let is_assistant_message = matches!(cursor_json, CursorJson::Assistant { .. });
|
||||
if !is_assistant_message && current_assistant_message_index.is_some() {
|
||||
// flush
|
||||
current_assistant_message_index = None;
|
||||
current_assistant_message_buffer.clear();
|
||||
}
|
||||
|
||||
match &cursor_json {
|
||||
CursorJson::System { model, .. } => {
|
||||
if !model_reported && let Some(model) = model.as_ref() {
|
||||
let entry = NormalizedEntry {
|
||||
timestamp: None,
|
||||
entry_type: NormalizedEntryType::SystemMessage,
|
||||
content: format!("System initialized with model: {model}"),
|
||||
metadata: None,
|
||||
};
|
||||
let id = entry_index_provider.next();
|
||||
msg_store
|
||||
.push_patch(ConversationPatch::add_normalized_entry(id, entry));
|
||||
model_reported = true;
|
||||
}
|
||||
}
|
||||
|
||||
CursorJson::User { message, .. } => {
|
||||
if let Some(text) = message.concat_text() {
|
||||
let entry = NormalizedEntry {
|
||||
timestamp: None,
|
||||
entry_type: NormalizedEntryType::UserMessage,
|
||||
content: text,
|
||||
metadata: None,
|
||||
};
|
||||
let id = entry_index_provider.next();
|
||||
msg_store
|
||||
.push_patch(ConversationPatch::add_normalized_entry(id, entry));
|
||||
}
|
||||
}
|
||||
|
||||
CursorJson::Assistant { message, .. } => {
|
||||
if let Some(chunk) = message.concat_text() {
|
||||
current_assistant_message_buffer.push_str(&chunk);
|
||||
let replace_entry = NormalizedEntry {
|
||||
timestamp: None,
|
||||
entry_type: NormalizedEntryType::AssistantMessage,
|
||||
content: current_assistant_message_buffer.clone(),
|
||||
metadata: None,
|
||||
};
|
||||
if let Some(id) = current_assistant_message_index {
|
||||
msg_store.push_patch(ConversationPatch::replace(id, replace_entry))
|
||||
} else {
|
||||
let id = entry_index_provider.next();
|
||||
current_assistant_message_index = Some(id);
|
||||
msg_store.push_patch(ConversationPatch::add_normalized_entry(
|
||||
id,
|
||||
replace_entry,
|
||||
));
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
CursorJson::ToolCall {
|
||||
subtype, tool_call, ..
|
||||
} => {
|
||||
// Only process "started" subtype (completed contains results we currently ignore)
|
||||
if subtype
|
||||
.as_deref()
|
||||
.map(|s| s.eq_ignore_ascii_case("started"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let tool_name = tool_call.get_name().to_string();
|
||||
let (action_type, content) =
|
||||
tool_call.to_action_and_content(&worktree_str);
|
||||
|
||||
let entry = NormalizedEntry {
|
||||
timestamp: None,
|
||||
entry_type: NormalizedEntryType::ToolUse {
|
||||
tool_name,
|
||||
action_type,
|
||||
},
|
||||
content,
|
||||
metadata: None,
|
||||
};
|
||||
let id = entry_index_provider.next();
|
||||
msg_store
|
||||
.push_patch(ConversationPatch::add_normalized_entry(id, entry));
|
||||
}
|
||||
}
|
||||
|
||||
CursorJson::Result { .. } => {
|
||||
// no-op; metadata-only events not surfaced
|
||||
}
|
||||
|
||||
CursorJson::Unknown => {
|
||||
let entry = NormalizedEntry {
|
||||
timestamp: None,
|
||||
entry_type: NormalizedEntryType::SystemMessage,
|
||||
content: format!("Raw output: `{line}`"),
|
||||
metadata: None,
|
||||
};
|
||||
let id = entry_index_provider.next();
|
||||
msg_store.push_patch(ConversationPatch::add_normalized_entry(id, entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_cursor_ascii_art_banner(line: String) -> String {
|
||||
static BANNER_LINES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
|
||||
let banner_lines = BANNER_LINES.get_or_init(|| {
|
||||
r#" +i":;;
|
||||
[?+<l,",::;;;I
|
||||
{[]_~iI"":::;;;;II
|
||||
)){↗↗↗↗↗↗↗↗↗↗↗↗↗↗↗↗↗↗↗↗↗ll … Cursor Agent
|
||||
11{[#M##M##M#########*ppll
|
||||
11}[]-+############oppqqIl
|
||||
1}[]_+<il;,####bpqqqqwIIII
|
||||
[]?_~<illi_++qqwwwwww;IIII
|
||||
]?-+~>i~{??--wwwwwww;;;III
|
||||
-_+]>{{{}}[[[mmmmmm_<_:;;I
|
||||
r\\|||(()))))mmmm)1)111{?_
|
||||
t/\\\\\|||(|ZZZ||\\\/tf^
|
||||
ttttt/tZZfff^>
|
||||
^^^O>>
|
||||
>>"#
|
||||
.lines()
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
});
|
||||
|
||||
for banner_line in banner_lines {
|
||||
if line.starts_with(banner_line) {
|
||||
return line.replacen(banner_line, "", 1).trim().to_string();
|
||||
}
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Typed Cursor JSON structures
|
||||
=========================== */
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum CursorJson {
|
||||
#[serde(rename = "system")]
|
||||
System {
|
||||
#[serde(default)]
|
||||
subtype: Option<String>,
|
||||
#[serde(default, rename = "apiKeySource")]
|
||||
api_key_source: Option<String>,
|
||||
#[serde(default)]
|
||||
cwd: Option<String>,
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
model: Option<String>,
|
||||
#[serde(default, rename = "permissionMode")]
|
||||
permission_mode: Option<String>,
|
||||
},
|
||||
#[serde(rename = "user")]
|
||||
User {
|
||||
message: CursorMessage,
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
},
|
||||
#[serde(rename = "assistant")]
|
||||
Assistant {
|
||||
message: CursorMessage,
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
},
|
||||
#[serde(rename = "tool_call")]
|
||||
ToolCall {
|
||||
#[serde(default)]
|
||||
subtype: Option<String>, // "started" | "completed"
|
||||
#[serde(default)]
|
||||
call_id: Option<String>,
|
||||
tool_call: CursorToolCall,
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
},
|
||||
#[serde(rename = "result")]
|
||||
Result {
|
||||
#[serde(default)]
|
||||
subtype: Option<String>,
|
||||
#[serde(default)]
|
||||
is_error: Option<bool>,
|
||||
#[serde(default)]
|
||||
duration_ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
result: Option<serde_json::Value>,
|
||||
},
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl CursorJson {
|
||||
pub fn extract_session_id(&self) -> Option<String> {
|
||||
match self {
|
||||
CursorJson::System { session_id, .. } => session_id.clone(),
|
||||
CursorJson::User { session_id, .. } => session_id.clone(),
|
||||
CursorJson::Assistant { session_id, .. } => session_id.clone(),
|
||||
CursorJson::ToolCall { session_id, .. } => session_id.clone(),
|
||||
CursorJson::Result { .. } => None,
|
||||
CursorJson::Unknown => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||
pub struct CursorMessage {
|
||||
pub role: String,
|
||||
pub content: Vec<CursorContentItem>,
|
||||
}
|
||||
|
||||
impl CursorMessage {
|
||||
pub fn concat_text(&self) -> Option<String> {
|
||||
let mut out = String::new();
|
||||
for CursorContentItem::Text { text } in &self.content {
|
||||
out.push_str(text);
|
||||
}
|
||||
if out.is_empty() { None } else { Some(out) }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum CursorContentItem {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Tool call structure
|
||||
=========================== */
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||
pub enum CursorToolCall {
|
||||
#[serde(rename = "shellToolCall")]
|
||||
Shell {
|
||||
args: CursorShellArgs,
|
||||
#[serde(default)]
|
||||
result: Option<serde_json::Value>,
|
||||
},
|
||||
#[serde(rename = "lsToolCall")]
|
||||
LS {
|
||||
args: CursorLsArgs,
|
||||
#[serde(default)]
|
||||
result: Option<serde_json::Value>,
|
||||
},
|
||||
#[serde(rename = "globToolCall")]
|
||||
Glob {
|
||||
args: CursorGlobArgs,
|
||||
#[serde(default)]
|
||||
result: Option<serde_json::Value>,
|
||||
},
|
||||
#[serde(rename = "grepToolCall")]
|
||||
Grep {
|
||||
args: CursorGrepArgs,
|
||||
#[serde(default)]
|
||||
result: Option<serde_json::Value>,
|
||||
},
|
||||
#[serde(rename = "writeToolCall")]
|
||||
Write {
|
||||
args: CursorWriteArgs,
|
||||
#[serde(default)]
|
||||
result: Option<serde_json::Value>,
|
||||
},
|
||||
#[serde(rename = "readToolCall")]
|
||||
Read {
|
||||
args: CursorReadArgs,
|
||||
#[serde(default)]
|
||||
result: Option<serde_json::Value>,
|
||||
},
|
||||
#[serde(rename = "editToolCall")]
|
||||
Edit {
|
||||
args: CursorEditArgs,
|
||||
#[serde(default)]
|
||||
result: Option<serde_json::Value>,
|
||||
},
|
||||
#[serde(rename = "deleteToolCall")]
|
||||
Delete {
|
||||
args: CursorDeleteArgs,
|
||||
#[serde(default)]
|
||||
result: Option<serde_json::Value>,
|
||||
},
|
||||
#[serde(rename = "updateTodosToolCall")]
|
||||
Todo {
|
||||
args: CursorUpdateTodosArgs,
|
||||
#[serde(default)]
|
||||
result: Option<serde_json::Value>,
|
||||
},
|
||||
/// Generic fallback for unknown tools (amp.rs pattern)
|
||||
#[serde(untagged)]
|
||||
Unknown {
|
||||
#[serde(flatten)]
|
||||
data: std::collections::HashMap<String, serde_json::Value>,
|
||||
},
|
||||
}
|
||||
|
||||
impl CursorToolCall {
|
||||
pub fn get_name(&self) -> &str {
|
||||
match self {
|
||||
CursorToolCall::Shell { .. } => "shell",
|
||||
CursorToolCall::LS { .. } => "ls",
|
||||
CursorToolCall::Glob { .. } => "glob",
|
||||
CursorToolCall::Grep { .. } => "grep",
|
||||
CursorToolCall::Write { .. } => "write",
|
||||
CursorToolCall::Read { .. } => "read",
|
||||
CursorToolCall::Edit { .. } => "edit",
|
||||
CursorToolCall::Delete { .. } => "delete",
|
||||
CursorToolCall::Todo { .. } => "todo",
|
||||
CursorToolCall::Unknown { data } => {
|
||||
data.keys().next().map(|s| s.as_str()).unwrap_or("unknown")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_action_and_content(&self, worktree_path: &str) -> (ActionType, String) {
|
||||
match self {
|
||||
CursorToolCall::Read { args, .. } => {
|
||||
let path = make_path_relative(&args.path, worktree_path);
|
||||
(
|
||||
ActionType::FileRead { path: path.clone() },
|
||||
format!("`{path}`"),
|
||||
)
|
||||
}
|
||||
CursorToolCall::Write { args, .. } => {
|
||||
let path = make_path_relative(&args.path, worktree_path);
|
||||
(
|
||||
ActionType::FileEdit {
|
||||
path: path.clone(),
|
||||
diffs: vec![],
|
||||
},
|
||||
format!("`{path}`"),
|
||||
)
|
||||
}
|
||||
CursorToolCall::Edit { args, .. } => {
|
||||
let path = make_path_relative(&args.path, worktree_path);
|
||||
let mut diffs = vec![];
|
||||
|
||||
if let Some(_apply_patch) = &args.apply_patch {
|
||||
// todo: handle v4a
|
||||
}
|
||||
|
||||
if let Some(str_replace) = &args.str_replace {
|
||||
diffs.push(EditDiff::Replace {
|
||||
old: str_replace.old_text.clone(),
|
||||
new: str_replace.new_text.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(multi_str_replace) = &args.multi_str_replace {
|
||||
for edit in multi_str_replace.edits.iter() {
|
||||
diffs.push(EditDiff::Replace {
|
||||
old: edit.old_text.clone(),
|
||||
new: edit.new_text.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
ActionType::FileEdit {
|
||||
path: path.clone(),
|
||||
diffs,
|
||||
},
|
||||
format!("`{path}`"),
|
||||
)
|
||||
}
|
||||
CursorToolCall::Delete { args, .. } => {
|
||||
let path = make_path_relative(&args.path, worktree_path);
|
||||
(
|
||||
ActionType::FileEdit {
|
||||
path: path.clone(),
|
||||
diffs: vec![],
|
||||
},
|
||||
format!("`{path}`"),
|
||||
)
|
||||
}
|
||||
CursorToolCall::Shell { args, .. } => {
|
||||
let cmd = &args.command;
|
||||
(
|
||||
ActionType::CommandRun {
|
||||
command: cmd.clone(),
|
||||
},
|
||||
format!("`{cmd}`"),
|
||||
)
|
||||
}
|
||||
CursorToolCall::Grep { args, .. } => {
|
||||
let pattern = &args.pattern;
|
||||
(
|
||||
ActionType::Search {
|
||||
query: pattern.clone(),
|
||||
},
|
||||
format!("`{pattern}`"),
|
||||
)
|
||||
}
|
||||
CursorToolCall::Glob { args, .. } => {
|
||||
let pattern = args.glob_pattern.clone().unwrap_or_else(|| "*".to_string());
|
||||
if let Some(path) = args.path.as_ref().or(args.target_directory.as_ref()) {
|
||||
let path = make_path_relative(path, worktree_path);
|
||||
(
|
||||
ActionType::Search {
|
||||
query: pattern.clone(),
|
||||
},
|
||||
format!("Find files: `{pattern}` in `{path}`"),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
ActionType::Search {
|
||||
query: pattern.clone(),
|
||||
},
|
||||
format!("Find files: `{pattern}`"),
|
||||
)
|
||||
}
|
||||
}
|
||||
CursorToolCall::LS { args, .. } => {
|
||||
let path = make_path_relative(&args.path, worktree_path);
|
||||
let content = if path.is_empty() {
|
||||
"List directory".to_string()
|
||||
} else {
|
||||
format!("List directory: `{path}`")
|
||||
};
|
||||
(
|
||||
ActionType::Other {
|
||||
description: "List directory".to_string(),
|
||||
},
|
||||
content,
|
||||
)
|
||||
}
|
||||
CursorToolCall::Todo { args, .. } => {
|
||||
let content = if let Some(todos) = args.todos.as_ref() {
|
||||
format!("TODO List:\n{}", summarize_todos_typed(todos))
|
||||
} else {
|
||||
"Managing TODO list".to_string()
|
||||
};
|
||||
(
|
||||
ActionType::Other {
|
||||
description: "Manage TODO list".to_string(),
|
||||
},
|
||||
content,
|
||||
)
|
||||
}
|
||||
CursorToolCall::Unknown { .. } => (
|
||||
ActionType::Other {
|
||||
description: format!("Tool: {}", self.get_name()),
|
||||
},
|
||||
self.get_name().to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||
pub struct CursorShellArgs {
|
||||
pub command: String,
|
||||
#[serde(default, alias = "working_directory", alias = "workingDirectory")]
|
||||
pub working_directory: Option<String>,
|
||||
#[serde(default)]
|
||||
pub timeout: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||
pub struct CursorLsArgs {
|
||||
pub path: String,
|
||||
#[serde(default)]
|
||||
pub ignore: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||
pub struct CursorGlobArgs {
|
||||
#[serde(default, alias = "globPattern", alias = "glob_pattern")]
|
||||
pub glob_pattern: Option<String>,
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
#[serde(default, alias = "target_directory")]
|
||||
pub target_directory: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||
pub struct CursorGrepArgs {
|
||||
pub pattern: String,
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
#[serde(default, alias = "glob")]
|
||||
pub glob_filter: Option<String>,
|
||||
#[serde(default, alias = "outputMode", alias = "output_mode")]
|
||||
pub output_mode: Option<String>,
|
||||
#[serde(default, alias = "-i", alias = "caseInsensitive")]
|
||||
pub case_insensitive: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub multiline: Option<bool>,
|
||||
#[serde(default, alias = "headLimit", alias = "head_limit")]
|
||||
pub head_limit: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub r#type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||
pub struct CursorWriteArgs {
|
||||
pub path: String,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "fileText",
|
||||
alias = "file_text",
|
||||
alias = "contents",
|
||||
alias = "content"
|
||||
)]
|
||||
pub contents: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||
pub struct CursorReadArgs {
|
||||
pub path: String,
|
||||
#[serde(default)]
|
||||
pub offset: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub limit: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||
pub struct CursorEditArgs {
|
||||
pub path: String,
|
||||
#[serde(default, rename = "applyPatch")]
|
||||
pub apply_patch: Option<CursorApplyPatch>,
|
||||
#[serde(default, rename = "strReplace")]
|
||||
pub str_replace: Option<CursorStrReplace>,
|
||||
#[serde(default, rename = "multiStrReplace")]
|
||||
pub multi_str_replace: Option<CursorMultiStrReplace>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||
pub struct CursorApplyPatch {
|
||||
#[serde(rename = "patchContent")]
|
||||
pub patch_content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||
pub struct CursorStrReplace {
|
||||
#[serde(rename = "oldText")]
|
||||
pub old_text: String,
|
||||
#[serde(rename = "newText")]
|
||||
pub new_text: String,
|
||||
#[serde(default, rename = "replaceAll")]
|
||||
pub replace_all: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||
pub struct CursorMultiStrReplace {
|
||||
pub edits: Vec<CursorMultiEditItem>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||
pub struct CursorMultiEditItem {
|
||||
#[serde(rename = "oldText")]
|
||||
pub old_text: String,
|
||||
#[serde(rename = "newText")]
|
||||
pub new_text: String,
|
||||
#[serde(default, rename = "replaceAll")]
|
||||
pub replace_all: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||
pub struct CursorDeleteArgs {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||
pub struct CursorUpdateTodosArgs {
|
||||
#[serde(default)]
|
||||
pub todos: Option<Vec<CursorTodoItem>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||
pub struct CursorTodoItem {
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
pub content: String,
|
||||
pub status: String,
|
||||
#[serde(default, rename = "createdAt")]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default, rename = "updatedAt")]
|
||||
pub updated_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub dependencies: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Helpers
|
||||
=========================== */
|
||||
|
||||
fn summarize_todos_typed(items: &[CursorTodoItem]) -> String {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for todo in items {
|
||||
let content = todo.content.as_str();
|
||||
let status = todo.status.as_str();
|
||||
let emoji = if status.eq_ignore_ascii_case("completed")
|
||||
|| status.contains("COMPLETED")
|
||||
|| status.eq_ignore_ascii_case("done")
|
||||
{
|
||||
"✅"
|
||||
} else if status.eq_ignore_ascii_case("in_progress")
|
||||
|| status.eq_ignore_ascii_case("in-progress")
|
||||
|| status.contains("IN_PROGRESS")
|
||||
{
|
||||
"🔄"
|
||||
} else {
|
||||
"⏳"
|
||||
};
|
||||
out.push(format!("{emoji} {content}"));
|
||||
}
|
||||
if out.is_empty() {
|
||||
"Managing TODO list".to_string()
|
||||
} else {
|
||||
out.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Tests
|
||||
=========================== */
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use utils::msg_store::MsgStore;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cursor_streaming_patch_generation() {
|
||||
// Avoid relying on feature flag in tests; construct with a dummy command
|
||||
let executor = Cursor::with_command_builder(CommandBuilder::new("cursor-agent"));
|
||||
let msg_store = Arc::new(MsgStore::new());
|
||||
let current_dir = std::path::PathBuf::from("/tmp/test-worktree");
|
||||
|
||||
// A minimal synthetic init + assistant micro-chunks (as Cursor would emit)
|
||||
msg_store.push_stdout(format!(
|
||||
"{}\n",
|
||||
r#"{"type":"system","subtype":"init","session_id":"sess-123","model":"OpenAI GPT-5"}"#
|
||||
));
|
||||
msg_store.push_stdout(format!(
|
||||
"{}\n",
|
||||
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello"}]}}"#
|
||||
));
|
||||
msg_store.push_stdout(format!(
|
||||
"{}\n",
|
||||
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":" world"}]}}"#
|
||||
));
|
||||
msg_store.push_finished();
|
||||
|
||||
executor.normalize_logs(msg_store.clone(), ¤t_dir);
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
|
||||
|
||||
// Verify patches were emitted (system init + assistant add/replace)
|
||||
let history = msg_store.get_history();
|
||||
let patch_count = history
|
||||
.iter()
|
||||
.filter(|m| matches!(m, utils::log_msg::LogMsg::JsonPatch(_)))
|
||||
.count();
|
||||
assert!(
|
||||
patch_count >= 2,
|
||||
"Expected at least 2 patches, got {}",
|
||||
patch_count
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_id_extraction_from_system_line() {
|
||||
// Ensure we can parse and find session_id from a system JSON line
|
||||
let system_line = r#"{"type":"system","subtype":"init","session_id":"abc-xyz","model":"Claude 4 Sonnet"}"#;
|
||||
let parsed: CursorJson = serde_json::from_str(system_line).unwrap();
|
||||
assert_eq!(parsed.extract_session_id().as_deref(), Some("abc-xyz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cursor_tool_call_parsing() {
|
||||
// Test known variant (from reference JSONL)
|
||||
let shell_tool_json = r#"{"shellToolCall":{"args":{"command":"wc -l drill.md","workingDirectory":"","timeout":0}}}"#;
|
||||
let parsed: CursorToolCall = serde_json::from_str(shell_tool_json).unwrap();
|
||||
|
||||
match parsed {
|
||||
CursorToolCall::Shell { args, result } => {
|
||||
assert_eq!(args.command, "wc -l drill.md");
|
||||
assert_eq!(args.working_directory, Some("".to_string()));
|
||||
assert_eq!(args.timeout, Some(0));
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
_ => panic!("Expected Shell variant"),
|
||||
}
|
||||
|
||||
// Test unknown variant (captures raw data)
|
||||
let unknown_tool_json =
|
||||
r#"{"unknownTool":{"args":{"someData":"value"},"result":{"status":"success"}}}"#;
|
||||
let parsed: CursorToolCall = serde_json::from_str(unknown_tool_json).unwrap();
|
||||
|
||||
match parsed {
|
||||
CursorToolCall::Unknown { data } => {
|
||||
assert!(data.contains_key("unknownTool"));
|
||||
let unknown_tool = &data["unknownTool"];
|
||||
assert_eq!(unknown_tool["args"]["someData"], "value");
|
||||
assert_eq!(unknown_tool["result"]["status"], "success");
|
||||
}
|
||||
_ => panic!("Expected Unknown variant"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,16 @@ use utils::msg_store::MsgStore;
|
||||
|
||||
use crate::{
|
||||
command::AgentProfiles,
|
||||
executors::{amp::Amp, claude::ClaudeCode, codex::Codex, gemini::Gemini, opencode::Opencode},
|
||||
executors::{
|
||||
amp::Amp, claude::ClaudeCode, codex::Codex, cursor::Cursor, gemini::Gemini,
|
||||
opencode::Opencode,
|
||||
},
|
||||
};
|
||||
|
||||
pub mod amp;
|
||||
pub mod claude;
|
||||
pub mod codex;
|
||||
pub mod cursor;
|
||||
pub mod gemini;
|
||||
pub mod opencode;
|
||||
|
||||
@@ -59,6 +63,7 @@ pub enum CodingAgent {
|
||||
Amp,
|
||||
Gemini,
|
||||
Codex,
|
||||
Cursor,
|
||||
// ClaudeCodeRouter,
|
||||
Opencode,
|
||||
// Aider,
|
||||
@@ -77,6 +82,7 @@ impl CodingAgent {
|
||||
"amp" => Ok(CodingAgent::Amp(Amp::new())),
|
||||
"gemini" => Ok(CodingAgent::Gemini(Gemini::new())),
|
||||
"codex" => Ok(CodingAgent::Codex(Codex::new())),
|
||||
"cursor" => Ok(CodingAgent::Cursor(Cursor::new())),
|
||||
"opencode" => Ok(CodingAgent::Opencode(Opencode::new())),
|
||||
_ => {
|
||||
// Try to load from AgentProfiles
|
||||
@@ -100,6 +106,9 @@ impl CodingAgent {
|
||||
BaseCodingAgent::Opencode => Ok(CodingAgent::Opencode(
|
||||
Opencode::with_command_builder(agent_profile.command.clone()),
|
||||
)),
|
||||
BaseCodingAgent::Cursor => Ok(CodingAgent::Cursor(
|
||||
Cursor::with_command_builder(agent_profile.command.clone()),
|
||||
)),
|
||||
}
|
||||
} else {
|
||||
Err(ExecutorError::UnknownExecutorType(format!(
|
||||
@@ -124,6 +133,9 @@ impl BaseCodingAgent {
|
||||
Self::Gemini => Some(vec!["mcpServers"]),
|
||||
//ExecutorConfig::Aider => None, // Aider doesn't support MCP. https://github.com/Aider-AI/aider/issues/3314
|
||||
Self::Codex => Some(vec!["mcp_servers"]), // Codex uses TOML with mcp_servers
|
||||
// Cursor CLI is supposed to be compatible with MCP server config according to the docs: https://docs.cursor.com/en/cli/using#mcp
|
||||
// But it still doesn't seem to support it properly: https://forum.cursor.com/t/cursor-cli-not-actually-an-mcp-client/127000/5
|
||||
Self::Cursor => Some(vec!["mcpServers"]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +164,7 @@ impl BaseCodingAgent {
|
||||
Self::Codex => dirs::home_dir().map(|home| home.join(".codex").join("config.toml")),
|
||||
Self::Amp => dirs::config_dir().map(|config| config.join("amp").join("settings.json")),
|
||||
Self::Gemini => dirs::home_dir().map(|home| home.join(".gemini").join("settings.json")),
|
||||
Self::Cursor => dirs::home_dir().map(|home| home.join(".cursor").join("mcp.json")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,8 @@ const getEntryIcon = (entryType: NormalizedEntryType) => {
|
||||
(tool_name.toLowerCase() === 'todowrite' ||
|
||||
tool_name.toLowerCase() === 'todoread' ||
|
||||
tool_name.toLowerCase() === 'todo_write' ||
|
||||
tool_name.toLowerCase() === 'todo_read')
|
||||
tool_name.toLowerCase() === 'todo_read' ||
|
||||
tool_name.toLowerCase() === 'todo')
|
||||
) {
|
||||
return <CheckSquare className="h-4 w-4 text-purple-600" />;
|
||||
}
|
||||
@@ -95,7 +96,8 @@ const getContentClassName = (entryType: NormalizedEntryType) => {
|
||||
(entryType.tool_name.toLowerCase() === 'todowrite' ||
|
||||
entryType.tool_name.toLowerCase() === 'todoread' ||
|
||||
entryType.tool_name.toLowerCase() === 'todo_write' ||
|
||||
entryType.tool_name.toLowerCase() === 'todo_read')
|
||||
entryType.tool_name.toLowerCase() === 'todo_read' ||
|
||||
entryType.tool_name.toLowerCase() === 'todo')
|
||||
) {
|
||||
return `${baseClasses} font-mono text-purple-700 dark:text-purple-300 bg-purple-50 dark:bg-purple-950/20 px-2 py-1 rounded`;
|
||||
}
|
||||
@@ -116,32 +118,10 @@ const shouldRenderMarkdown = (entryType: NormalizedEntryType) => {
|
||||
// Render markdown for assistant messages, plan presentations, and tool outputs that contain backticks
|
||||
return (
|
||||
entryType.type === 'assistant_message' ||
|
||||
(entryType.type === 'tool_use' &&
|
||||
entryType.action_type.action === 'plan_presentation') ||
|
||||
(entryType.type === 'tool_use' &&
|
||||
entryType.tool_name &&
|
||||
(entryType.tool_name.toLowerCase() === 'todowrite' ||
|
||||
entryType.tool_name.toLowerCase() === 'todoread' ||
|
||||
entryType.tool_name.toLowerCase() === 'todo_write' ||
|
||||
entryType.tool_name.toLowerCase() === 'todo_read' ||
|
||||
entryType.tool_name.toLowerCase() === 'glob' ||
|
||||
entryType.tool_name.toLowerCase() === 'ls' ||
|
||||
entryType.tool_name.toLowerCase() === 'list_directory' ||
|
||||
entryType.tool_name.toLowerCase() === 'read' ||
|
||||
entryType.tool_name.toLowerCase() === 'read_file' ||
|
||||
entryType.tool_name.toLowerCase() === 'write' ||
|
||||
entryType.tool_name.toLowerCase() === 'create_file' ||
|
||||
entryType.tool_name.toLowerCase() === 'edit' ||
|
||||
entryType.tool_name.toLowerCase() === 'edit_file' ||
|
||||
entryType.tool_name.toLowerCase() === 'multiedit' ||
|
||||
entryType.tool_name.toLowerCase() === 'bash' ||
|
||||
entryType.tool_name.toLowerCase() === 'run_command' ||
|
||||
entryType.tool_name.toLowerCase() === 'grep' ||
|
||||
entryType.tool_name.toLowerCase() === 'search' ||
|
||||
entryType.tool_name.toLowerCase() === 'webfetch' ||
|
||||
entryType.tool_name.toLowerCase() === 'web_fetch' ||
|
||||
entryType.tool_name.toLowerCase() === 'task' ||
|
||||
entryType.tool_name.toLowerCase().startsWith('mcp_')))
|
||||
entryType.type === 'system_message' ||
|
||||
entryType.type === 'user_message' ||
|
||||
entryType.type === 'thinking' ||
|
||||
entryType.type === 'tool_use'
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ export type FileDiffDetails = { fileName: string | null, content: string | null,
|
||||
|
||||
export type RepositoryInfo = { id: bigint, name: string, full_name: string, owner: string, description: string | null, clone_url: string, ssh_url: string, default_branch: string, private: boolean, };
|
||||
|
||||
export enum BaseCodingAgent { CLAUDE_CODE = "CLAUDE_CODE", AMP = "AMP", GEMINI = "GEMINI", CODEX = "CODEX", OPENCODE = "OPENCODE" }
|
||||
export enum BaseCodingAgent { CLAUDE_CODE = "CLAUDE_CODE", AMP = "AMP", GEMINI = "GEMINI", CODEX = "CODEX", CURSOR = "CURSOR", OPENCODE = "OPENCODE" }
|
||||
|
||||
export type CommandBuilder = {
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user