Cursor CLI (#457)

This commit is contained in:
Solomon
2025-08-13 17:07:54 +01:00
committed by GitHub
parent faa177fe60
commit bbe2e61df1
6 changed files with 924 additions and 30 deletions

View File

@@ -35,3 +35,4 @@ strum = "0.27.2"
bon = "3.6" bon = "3.6"
fork_stream = "0.1.0" fork_stream = "0.1.0"
os_pipe = "1.2" os_pipe = "1.2"
strip-ansi-escapes = "0.2.1"

View File

@@ -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 { pub fn codex() -> Self {
Self { Self {
label: "codex".to_string(), label: "codex".to_string(),
@@ -199,6 +212,7 @@ impl AgentProfiles {
AgentProfile::codex(), AgentProfile::codex(),
AgentProfile::opencode(), AgentProfile::opencode(),
AgentProfile::qwen_code(), AgentProfile::qwen_code(),
AgentProfile::cursor(),
], ],
} }
} }
@@ -300,5 +314,10 @@ mod tests {
let opencode_command = get_profile_command("opencode"); let opencode_command = get_profile_command("opencode");
assert!(opencode_command.contains("npx -y opencode-ai@latest run")); assert!(opencode_command.contains("npx -y opencode-ai@latest run"));
assert!(opencode_command.contains("--print-logs")); 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"));
} }
} }

View 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(), &current_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"),
}
}
}

View File

@@ -12,12 +12,16 @@ use utils::msg_store::MsgStore;
use crate::{ use crate::{
command::AgentProfiles, 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 amp;
pub mod claude; pub mod claude;
pub mod codex; pub mod codex;
pub mod cursor;
pub mod gemini; pub mod gemini;
pub mod opencode; pub mod opencode;
@@ -59,6 +63,7 @@ pub enum CodingAgent {
Amp, Amp,
Gemini, Gemini,
Codex, Codex,
Cursor,
// ClaudeCodeRouter, // ClaudeCodeRouter,
Opencode, Opencode,
// Aider, // Aider,
@@ -77,6 +82,7 @@ impl CodingAgent {
"amp" => Ok(CodingAgent::Amp(Amp::new())), "amp" => Ok(CodingAgent::Amp(Amp::new())),
"gemini" => Ok(CodingAgent::Gemini(Gemini::new())), "gemini" => Ok(CodingAgent::Gemini(Gemini::new())),
"codex" => Ok(CodingAgent::Codex(Codex::new())), "codex" => Ok(CodingAgent::Codex(Codex::new())),
"cursor" => Ok(CodingAgent::Cursor(Cursor::new())),
"opencode" => Ok(CodingAgent::Opencode(Opencode::new())), "opencode" => Ok(CodingAgent::Opencode(Opencode::new())),
_ => { _ => {
// Try to load from AgentProfiles // Try to load from AgentProfiles
@@ -100,6 +106,9 @@ impl CodingAgent {
BaseCodingAgent::Opencode => Ok(CodingAgent::Opencode( BaseCodingAgent::Opencode => Ok(CodingAgent::Opencode(
Opencode::with_command_builder(agent_profile.command.clone()), Opencode::with_command_builder(agent_profile.command.clone()),
)), )),
BaseCodingAgent::Cursor => Ok(CodingAgent::Cursor(
Cursor::with_command_builder(agent_profile.command.clone()),
)),
} }
} else { } else {
Err(ExecutorError::UnknownExecutorType(format!( Err(ExecutorError::UnknownExecutorType(format!(
@@ -124,6 +133,9 @@ impl BaseCodingAgent {
Self::Gemini => Some(vec!["mcpServers"]), Self::Gemini => Some(vec!["mcpServers"]),
//ExecutorConfig::Aider => None, // Aider doesn't support MCP. https://github.com/Aider-AI/aider/issues/3314 //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 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::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::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::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")),
} }
} }
} }

View File

@@ -49,7 +49,8 @@ const getEntryIcon = (entryType: NormalizedEntryType) => {
(tool_name.toLowerCase() === 'todowrite' || (tool_name.toLowerCase() === 'todowrite' ||
tool_name.toLowerCase() === 'todoread' || tool_name.toLowerCase() === 'todoread' ||
tool_name.toLowerCase() === 'todo_write' || 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" />; 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() === 'todowrite' ||
entryType.tool_name.toLowerCase() === 'todoread' || entryType.tool_name.toLowerCase() === 'todoread' ||
entryType.tool_name.toLowerCase() === 'todo_write' || 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`; 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 // Render markdown for assistant messages, plan presentations, and tool outputs that contain backticks
return ( return (
entryType.type === 'assistant_message' || entryType.type === 'assistant_message' ||
(entryType.type === 'tool_use' && entryType.type === 'system_message' ||
entryType.action_type.action === 'plan_presentation') || entryType.type === 'user_message' ||
(entryType.type === 'tool_use' && entryType.type === 'thinking' ||
entryType.tool_name && entryType.type === 'tool_use'
(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_')))
); );
}; };

View File

@@ -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 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 = { export type CommandBuilder = {
/** /**