feat(ai): add Cursor CLI provider
This commit is contained in:
1 parent
25fd5677c7
commit
36e5fa4f4c
36 files changed
+1173
-36
No files matched your search
@@ -183,6 +183,14 @@ pub async fn run_agent_loop(
|
||||
);
|
||||
return crate::ai_opencode_cli::run_opencode_agent(config, &prompt, options, cancelled, on_event).await;
|
||||
}
|
||||
if matches!(config.provider, AiProvider::CursorCli) {
|
||||
let prompt = crate::ai_cursor_cli::build_cursor_prompt(
|
||||
system_prompt,
|
||||
messages,
|
||||
agent_ctx.sql_permissions.allow_writes,
|
||||
);
|
||||
return crate::ai_cursor_cli::run_cursor_agent(config, &prompt, options, cancelled, on_event).await;
|
||||
}
|
||||
let prompt =
|
||||
crate::ai_codex_cli::build_codex_prompt(system_prompt, messages, agent_ctx.sql_permissions.allow_writes);
|
||||
return crate::ai_codex_cli::run_codex_agent(config, &prompt, options, cancelled, on_event).await;
|
||||
|
||||
@@ -81,6 +81,8 @@ pub enum AiProvider {
|
||||
PiAgentCli,
|
||||
#[serde(rename = "opencode-cli")]
|
||||
OpenCodeCli,
|
||||
#[serde(rename = "cursor-cli")]
|
||||
CursorCli,
|
||||
Custom,
|
||||
}
|
||||
|
||||
@@ -99,6 +101,7 @@ impl AiProvider {
|
||||
AiProvider::ClaudeCodeCli => "claude-code-cli",
|
||||
AiProvider::PiAgentCli => "pi-agent-cli",
|
||||
AiProvider::OpenCodeCli => "opencode-cli",
|
||||
AiProvider::CursorCli => "cursor-cli",
|
||||
AiProvider::CodexCli => "codex-cli",
|
||||
AiProvider::Custom => "custom",
|
||||
}
|
||||
@@ -380,6 +383,10 @@ pub struct AiConfig {
|
||||
pub opencode_cli_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub opencode_cli_env: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub cursor_cli_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cursor_cli_env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
fn default_enable_thinking() -> bool {
|
||||
@@ -387,12 +394,16 @@ fn default_enable_thinking() -> bool {
|
||||
}
|
||||
|
||||
/// Whether the provider is a CLI-based provider that goes through its own
|
||||
/// executable (claude-code, codex, opencode, pi) rather than through `with_retry` /
|
||||
/// executable (claude-code, codex, cursor, opencode, pi) rather than through `with_retry` /
|
||||
/// `with_stream_retry`.
|
||||
pub fn is_cli_provider(provider: &AiProvider) -> bool {
|
||||
matches!(
|
||||
provider,
|
||||
AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli
|
||||
AiProvider::CodexCli
|
||||
| AiProvider::ClaudeCodeCli
|
||||
| AiProvider::PiAgentCli
|
||||
| AiProvider::OpenCodeCli
|
||||
| AiProvider::CursorCli
|
||||
)
|
||||
}
|
||||
|
||||
@@ -623,6 +634,7 @@ pub fn resolve_endpoint(config: &AiConfig) -> String {
|
||||
| AiProvider::ClaudeCodeCli
|
||||
| AiProvider::PiAgentCli
|
||||
| AiProvider::OpenCodeCli
|
||||
| AiProvider::CursorCli
|
||||
| AiProvider::Gemini => unreachable!(),
|
||||
}
|
||||
}
|
||||
@@ -1471,6 +1483,7 @@ pub async fn list_models_core(config: &AiConfig) -> Result<Vec<AiModelInfo>, Str
|
||||
AiProvider::ClaudeCodeCli => crate::ai_claude_code_cli::list_claude_code_models(config).await?,
|
||||
AiProvider::PiAgentCli => crate::ai_pi_agent_cli::list_pi_agent_models(config).await?,
|
||||
AiProvider::OpenCodeCli => crate::ai_opencode_cli::list_opencode_models(config).await?,
|
||||
AiProvider::CursorCli => crate::ai_cursor_cli::list_cursor_models(config).await?,
|
||||
_ => {
|
||||
validate_model_list_config(config)?;
|
||||
let client = build_ai_http_client(config, 30)?;
|
||||
@@ -1493,7 +1506,11 @@ pub async fn list_models_core(config: &AiConfig) -> Result<Vec<AiModelInfo>, Str
|
||||
list_openai_compatible_models(&client, config).await?
|
||||
}
|
||||
}
|
||||
AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli => {
|
||||
AiProvider::CodexCli
|
||||
| AiProvider::ClaudeCodeCli
|
||||
| AiProvider::PiAgentCli
|
||||
| AiProvider::OpenCodeCli
|
||||
| AiProvider::CursorCli => {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
@@ -1518,6 +1535,10 @@ pub async fn resolve_model_effort_core(config: &AiConfig, model_id: &str) -> Res
|
||||
return crate::ai_opencode_cli::resolve_opencode_model_effort(config, model_id).await;
|
||||
}
|
||||
|
||||
if matches!(config.provider, AiProvider::CursorCli) {
|
||||
return Ok(AiEffortCapability::Unsupported);
|
||||
}
|
||||
|
||||
if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) {
|
||||
let models = list_models_core(config).await?;
|
||||
return Ok(models
|
||||
@@ -2049,6 +2070,9 @@ pub async fn test_connection_core(config: &AiConfig) -> Result<AiTestConnectionR
|
||||
if matches!(config.provider, AiProvider::OpenCodeCli) {
|
||||
return crate::ai_opencode_cli::test_opencode_connection(config).await;
|
||||
}
|
||||
if matches!(config.provider, AiProvider::CursorCli) {
|
||||
return crate::ai_cursor_cli::test_cursor_connection(config).await;
|
||||
}
|
||||
let mut resolved_config = config.clone();
|
||||
if resolved_config.model.trim().is_empty() {
|
||||
let model = list_models_core(&resolved_config)
|
||||
@@ -2421,7 +2445,11 @@ pub async fn complete(request: &AiCompletionRequest) -> Result<String, String> {
|
||||
|
||||
match request.config.provider {
|
||||
AiProvider::Gemini => call_gemini(&client, request).await,
|
||||
AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli => {
|
||||
AiProvider::CodexCli
|
||||
| AiProvider::ClaudeCodeCli
|
||||
| AiProvider::PiAgentCli
|
||||
| AiProvider::OpenCodeCli
|
||||
| AiProvider::CursorCli => {
|
||||
unreachable!()
|
||||
}
|
||||
AiProvider::Openai
|
||||
@@ -2475,7 +2503,11 @@ pub async fn stream(
|
||||
|
||||
match request.config.provider {
|
||||
AiProvider::Gemini => stream_gemini(&client, session_id, request, cancelled, &on_chunk).await,
|
||||
AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli => {
|
||||
AiProvider::CodexCli
|
||||
| AiProvider::ClaudeCodeCli
|
||||
| AiProvider::PiAgentCli
|
||||
| AiProvider::OpenCodeCli
|
||||
| AiProvider::CursorCli => {
|
||||
unreachable!()
|
||||
}
|
||||
AiProvider::Openai
|
||||
@@ -4115,6 +4147,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
},
|
||||
system_prompt: "Be concise.".to_string(),
|
||||
messages: vec![AiMessage {
|
||||
@@ -4698,6 +4732,8 @@ mod tests {
|
||||
assert!(config.pi_agent_cli_env.is_empty());
|
||||
assert!(config.opencode_cli_path.is_none());
|
||||
assert!(config.opencode_cli_env.is_empty());
|
||||
assert!(config.cursor_cli_path.is_none());
|
||||
assert!(config.cursor_cli_env.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4725,6 +4761,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
let err = build_ai_http_client(&config, 1).unwrap_err();
|
||||
@@ -4757,6 +4795,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
build_ai_http_client(&config, 1).unwrap();
|
||||
@@ -4787,6 +4827,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
build_ai_http_client(&config, 1).unwrap();
|
||||
@@ -4817,6 +4859,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
@@ -4851,6 +4895,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
assert_eq!(resolve_endpoint(&ollama), "http://localhost:11434/v1/chat/completions");
|
||||
@@ -4882,6 +4928,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
for provider in
|
||||
@@ -4932,6 +4980,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
};
|
||||
assert_eq!(resolve_model_list_endpoint(&openai).unwrap(), "https://api.openai.com/v1/models");
|
||||
|
||||
@@ -4958,6 +5008,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
};
|
||||
assert_eq!(resolve_model_list_endpoint(&claude).unwrap(), "https://api.anthropic.com/v1/models");
|
||||
}
|
||||
@@ -4987,6 +5039,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
assert!(uses_anthropic_messages_api(&config));
|
||||
@@ -5068,6 +5122,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
assert!(!uses_anthropic_messages_api(&config));
|
||||
@@ -5109,6 +5165,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
};
|
||||
assert_eq!(resolve_endpoint(&config), "https://api.example.com/v1/chat/completions");
|
||||
assert_eq!(resolve_model_list_endpoint(&config).unwrap(), "https://api.example.com/v1/models");
|
||||
@@ -5174,6 +5232,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
assert_eq!(resolve_endpoint(&config), "https://api.openai.com/v1/responses");
|
||||
@@ -5211,6 +5271,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
let api_key_headers = claude_headers(&config).unwrap();
|
||||
@@ -5328,6 +5390,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
};
|
||||
assert_eq!(resolve_ollama_show_endpoint(&config).unwrap(), "http://localhost:11434/api/show");
|
||||
|
||||
@@ -5362,6 +5426,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
assert_eq!(ollama_selected_model_tool_support(&config).await.unwrap(), Some(true));
|
||||
@@ -5445,6 +5511,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
};
|
||||
let models = vec![
|
||||
AiModelInfo::new("qwen3:0.6b", None),
|
||||
@@ -5708,6 +5776,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
@@ -5921,6 +5991,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
};
|
||||
let mut body = serde_json::json!({
|
||||
"model": &config.model,
|
||||
@@ -5960,6 +6032,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
};
|
||||
let mut body = serde_json::json!({ "model": &config.model });
|
||||
|
||||
@@ -6005,6 +6079,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
};
|
||||
let mut body = serde_json::json!({ "model": &config.model });
|
||||
|
||||
@@ -6044,6 +6120,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
};
|
||||
let mut body = serde_json::json!({ "model": &config.model });
|
||||
|
||||
@@ -6079,6 +6157,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
};
|
||||
let mut body = serde_json::json!({ "model": &config.model });
|
||||
|
||||
@@ -6145,6 +6225,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
};
|
||||
let request = AiCompletionRequest {
|
||||
config: config.clone(),
|
||||
@@ -6205,6 +6287,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
};
|
||||
let mut body = serde_json::json!({
|
||||
"model": &config.model,
|
||||
@@ -6731,9 +6815,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn merge_global_max_retries_skips_cli_providers() {
|
||||
for provider in
|
||||
[AiProvider::CodexCli, AiProvider::ClaudeCodeCli, AiProvider::PiAgentCli, AiProvider::OpenCodeCli]
|
||||
{
|
||||
for provider in [
|
||||
AiProvider::CodexCli,
|
||||
AiProvider::ClaudeCodeCli,
|
||||
AiProvider::PiAgentCli,
|
||||
AiProvider::OpenCodeCli,
|
||||
AiProvider::CursorCli,
|
||||
] {
|
||||
let mut config = test_config(provider.clone());
|
||||
config.max_retries = None;
|
||||
merge_global_max_retries(&mut config, 0);
|
||||
|
||||
@@ -583,6 +583,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ pub enum CliAgentJsonlDialect {
|
||||
CodexExec,
|
||||
ClaudeCodePrint,
|
||||
OpenCodeRun,
|
||||
CursorPrint,
|
||||
}
|
||||
|
||||
pub struct CliAgentProcessSpec {
|
||||
@@ -236,6 +237,7 @@ fn parse_cli_jsonl_line(line: &str, dialect: CliAgentJsonlDialect) -> ParsedCliA
|
||||
CliAgentJsonlDialect::CodexExec => parse_codex_jsonl_line(line),
|
||||
CliAgentJsonlDialect::ClaudeCodePrint => parse_claude_code_jsonl_line(line),
|
||||
CliAgentJsonlDialect::OpenCodeRun => parse_open_code_jsonl_line(line),
|
||||
CliAgentJsonlDialect::CursorPrint => parse_cursor_jsonl_line(line),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -625,6 +627,171 @@ fn open_code_error_message(value: &Value) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn parse_cursor_jsonl_line(line: &str) -> ParsedCliAgentEvent {
|
||||
let Ok(value) = serde_json::from_str::<Value>(line) else {
|
||||
return ParsedCliAgentEvent::default();
|
||||
};
|
||||
|
||||
match value.get("type").and_then(Value::as_str).unwrap_or_default() {
|
||||
"assistant" => parse_cursor_assistant(&value),
|
||||
"thinking" => parse_cursor_thinking(&value),
|
||||
"tool_call" => parse_cursor_tool_call(&value),
|
||||
"result" => parse_cursor_result(&value),
|
||||
"error" => {
|
||||
let message = cursor_error_message(&value);
|
||||
ParsedCliAgentEvent {
|
||||
error: Some(message.clone()),
|
||||
events: vec![AgentEvent::Error { message }],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
_ => ParsedCliAgentEvent::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_cursor_assistant(value: &Value) -> ParsedCliAgentEvent {
|
||||
// Cursor emits timestamped partial assistant messages followed by one
|
||||
// un-timestamped buffered message. Only partials are deltas; consuming the
|
||||
// buffered copy would duplicate the entire response.
|
||||
if value.get("timestamp_ms").or_else(|| value.get("timestampMs")).is_none() {
|
||||
return ParsedCliAgentEvent::default();
|
||||
}
|
||||
let Some(content) = value.pointer("/message/content").or_else(|| value.get("content")) else {
|
||||
return ParsedCliAgentEvent::default();
|
||||
};
|
||||
let mut text = String::new();
|
||||
for block in claude_content_blocks(content) {
|
||||
if block.get("type").and_then(Value::as_str) == Some("text") {
|
||||
if let Some(delta) = block.get("text").and_then(Value::as_str).filter(|delta| !delta.is_empty()) {
|
||||
text.push_str(delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
if text.is_empty() {
|
||||
return ParsedCliAgentEvent::default();
|
||||
}
|
||||
ParsedCliAgentEvent {
|
||||
events: vec![AgentEvent::TextDelta { delta: text.clone() }],
|
||||
final_text: Some(text),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_cursor_thinking(value: &Value) -> ParsedCliAgentEvent {
|
||||
if value.get("subtype").and_then(Value::as_str) != Some("delta") {
|
||||
return ParsedCliAgentEvent::default();
|
||||
}
|
||||
let Some(text) = value.get("text").and_then(Value::as_str).filter(|text| !text.is_empty()) else {
|
||||
return ParsedCliAgentEvent::default();
|
||||
};
|
||||
ParsedCliAgentEvent { events: vec![AgentEvent::ReasoningDelta { delta: text.to_string() }], ..Default::default() }
|
||||
}
|
||||
|
||||
fn parse_cursor_tool_call(value: &Value) -> ParsedCliAgentEvent {
|
||||
let subtype = value.get("subtype").and_then(Value::as_str).unwrap_or_default();
|
||||
let call = value.get("tool_call").or_else(|| value.get("toolCall")).unwrap_or(&Value::Null);
|
||||
let payload = call.as_object().and_then(|object| object.values().next()).unwrap_or(call);
|
||||
let call_kind = call.as_object().and_then(|object| object.keys().next()).map(String::as_str);
|
||||
let tool_call_id = value
|
||||
.get("call_id")
|
||||
.or_else(|| value.get("callId"))
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| payload.get("call_id").and_then(Value::as_str))
|
||||
.or_else(|| payload.get("callId").and_then(Value::as_str))
|
||||
.unwrap_or("cursor-tool-call")
|
||||
.to_string();
|
||||
let tool_name = payload
|
||||
.get("tool_name")
|
||||
.or_else(|| payload.get("toolName"))
|
||||
.or_else(|| payload.get("name"))
|
||||
.or_else(|| payload.get("tool"))
|
||||
.or_else(|| payload.pointer("/args/toolName"))
|
||||
.or_else(|| payload.pointer("/args/tool_name"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string)
|
||||
.or_else(|| call_kind.map(cursor_tool_name_from_kind))
|
||||
.unwrap_or_else(|| "cursor_tool".to_string());
|
||||
let args = payload
|
||||
.get("args")
|
||||
.or_else(|| payload.get("arguments"))
|
||||
.or_else(|| payload.get("input"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Value::Object(Default::default()));
|
||||
|
||||
match subtype {
|
||||
"started" => ParsedCliAgentEvent {
|
||||
events: vec![AgentEvent::ToolCallStart { tool_call_id, tool_name, args }],
|
||||
..Default::default()
|
||||
},
|
||||
"completed" => {
|
||||
let result = payload
|
||||
.get("result")
|
||||
.or_else(|| payload.get("output"))
|
||||
.or_else(|| payload.get("error"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
let is_error = payload
|
||||
.get("is_error")
|
||||
.or_else(|| payload.get("isError"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or_else(|| payload.get("error").is_some_and(|error| !error.is_null()));
|
||||
ParsedCliAgentEvent {
|
||||
events: vec![AgentEvent::ToolCallEnd { tool_call_id, tool_name, result, is_error }],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
_ => ParsedCliAgentEvent::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_tool_name_from_kind(kind: &str) -> String {
|
||||
let stem = kind.strip_suffix("ToolCall").unwrap_or(kind);
|
||||
let mut name = String::new();
|
||||
for (index, ch) in stem.chars().enumerate() {
|
||||
if ch.is_ascii_uppercase() && index > 0 {
|
||||
name.push('_');
|
||||
}
|
||||
name.push(ch.to_ascii_lowercase());
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
fn parse_cursor_result(value: &Value) -> ParsedCliAgentEvent {
|
||||
let subtype = value.get("subtype").and_then(Value::as_str).unwrap_or("success");
|
||||
let is_error = value.get("is_error").or_else(|| value.get("isError")).and_then(Value::as_bool).unwrap_or(false);
|
||||
if subtype != "success" || is_error {
|
||||
let message = cursor_error_message(value);
|
||||
return ParsedCliAgentEvent {
|
||||
error: Some(message.clone()),
|
||||
events: vec![AgentEvent::Error { message }],
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
let usage = value.get("usage");
|
||||
let input_tokens = usage
|
||||
.and_then(|usage| usage.get("inputTokens").or_else(|| usage.get("input_tokens")))
|
||||
.and_then(Value::as_u64)
|
||||
.filter(|value| *value > 0)
|
||||
.map(|value| value as u32);
|
||||
let output_tokens = usage
|
||||
.and_then(|usage| usage.get("outputTokens").or_else(|| usage.get("output_tokens")))
|
||||
.and_then(Value::as_u64)
|
||||
.filter(|value| *value > 0)
|
||||
.map(|value| value as u32);
|
||||
ParsedCliAgentEvent { events: vec![AgentEvent::AgentEnd { input_tokens, output_tokens }], ..Default::default() }
|
||||
}
|
||||
|
||||
fn cursor_error_message(value: &Value) -> String {
|
||||
value
|
||||
.get("error")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| value.get("message").and_then(Value::as_str))
|
||||
.or_else(|| value.get("result").and_then(Value::as_str))
|
||||
.or_else(|| value.pointer("/error/message").and_then(Value::as_str))
|
||||
.unwrap_or("Cursor CLI failed")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub async fn run_cli_jsonl_agent(
|
||||
spec: CliAgentProcessSpec,
|
||||
cancelled: &Notify,
|
||||
@@ -876,4 +1043,47 @@ mod tests {
|
||||
assert!(!process_is_alive(pid.trim()));
|
||||
let _ = std::fs::remove_file(pid_file);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn jsonl_cancellation_kills_and_waits_for_child() {
|
||||
let pid_file = std::env::temp_dir().join(format!(
|
||||
"dbx-cli-agent-cancel-{}-{}",
|
||||
std::process::id(),
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
|
||||
));
|
||||
let script = format!("echo $$ > {}; exec sleep 30", pid_file.display());
|
||||
|
||||
let spec = CliAgentProcessSpec {
|
||||
command: CliAgentCommandSpec { program: "sh".to_string(), args: vec!["-c".to_string(), script] },
|
||||
env: Vec::new(),
|
||||
env_remove: Vec::new(),
|
||||
current_dir: None,
|
||||
stdin: None,
|
||||
dialect: CliAgentJsonlDialect::CodexExec,
|
||||
classify_spawn_error,
|
||||
classify_run_error,
|
||||
};
|
||||
let cancelled = Arc::new(Notify::new());
|
||||
let runner_cancelled = Arc::clone(&cancelled);
|
||||
let runner = tokio::spawn(async move { run_cli_jsonl_agent(spec, runner_cancelled.as_ref(), |_| {}).await });
|
||||
|
||||
timeout(Duration::from_secs(3), async {
|
||||
while !pid_file.exists() {
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("child should start before cancellation");
|
||||
cancelled.notify_waiters();
|
||||
|
||||
let result = timeout(Duration::from_secs(3), runner)
|
||||
.await
|
||||
.expect("runner should return after cancellation")
|
||||
.expect("runner task should not panic");
|
||||
assert_eq!(result.unwrap_err(), "Agent loop cancelled");
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
let pid = std::fs::read_to_string(&pid_file).expect("child pid should be captured");
|
||||
assert!(!process_is_alive(pid.trim()));
|
||||
let _ = std::fs::remove_file(pid_file);
|
||||
}
|
||||
}
|
||||
@@ -1040,6 +1040,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
use crate::agent_events::AgentEvent;
|
||||
use crate::ai::{AiConfig, AiEffortCapability, AiModelInfo, AiTestConnectionResult};
|
||||
use crate::ai_cli_agent::{
|
||||
build_cli_agent_prompt, cli_command, dbx_mcp_enabled_tools, dbx_mcp_scope_env, parse_cli_jsonl_event,
|
||||
run_cli_jsonl_agent, CliAgentCommandSpec, CliAgentJsonlDialect, CliAgentProcessSpec, CliAgentRunOptions,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
const CURSOR_COMMAND_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const CURSOR_CONTROL_ENV: &[&str] = &["CURSOR_CONFIG_DIR", "CURSOR_DATA_DIR"];
|
||||
|
||||
pub type CursorRunOptions = CliAgentRunOptions;
|
||||
pub type CursorCommandSpec = CliAgentCommandSpec;
|
||||
|
||||
struct CursorIsolatedRuntime {
|
||||
root: PathBuf,
|
||||
workspace: PathBuf,
|
||||
config: PathBuf,
|
||||
data: PathBuf,
|
||||
}
|
||||
|
||||
impl CursorIsolatedRuntime {
|
||||
fn create(options: Option<&CursorRunOptions>) -> Result<Self, String> {
|
||||
let root = env::temp_dir().join(format!("dbx-cursor-{}", uuid::Uuid::new_v4()));
|
||||
let workspace = root.join("workspace");
|
||||
let cursor_dir = workspace.join(".cursor");
|
||||
let config = root.join("config");
|
||||
let data = root.join("data");
|
||||
for path in [&workspace, &cursor_dir, &config, &data] {
|
||||
std::fs::create_dir_all(path)
|
||||
.map_err(|error| format!("[cursorRunFailed] Failed to create isolated Cursor directory: {error}"))?;
|
||||
}
|
||||
|
||||
if let Some(options) = options {
|
||||
Self::write_mcp_config(&cursor_dir, options)?;
|
||||
Self::write_permission_config(&cursor_dir, options.agent_mode)?;
|
||||
}
|
||||
|
||||
Ok(Self { root, workspace, config, data })
|
||||
}
|
||||
|
||||
fn write_mcp_config(cursor_dir: &Path, options: &CursorRunOptions) -> Result<(), String> {
|
||||
let command = options
|
||||
.mcp_server_command
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| CursorCommandSpec { program: "dbx-mcp-server".to_string(), args: Vec::new() });
|
||||
let env = dbx_mcp_scope_env(options).into_iter().collect::<BTreeMap<_, _>>();
|
||||
let config = json!({
|
||||
"mcpServers": {
|
||||
"dbx": {
|
||||
"command": command.program,
|
||||
"args": command.args,
|
||||
"env": env
|
||||
}
|
||||
}
|
||||
});
|
||||
write_json_file(&cursor_dir.join("mcp.json"), &config, "Cursor MCP")
|
||||
}
|
||||
|
||||
fn write_permission_config(cursor_dir: &Path, agent_mode: bool) -> Result<(), String> {
|
||||
let allow =
|
||||
dbx_mcp_enabled_tools(agent_mode).into_iter().map(|tool| format!("Mcp(dbx:{tool})")).collect::<Vec<_>>();
|
||||
let config = json!({
|
||||
"permissions": {
|
||||
"allow": allow,
|
||||
"deny": ["Shell(*)", "Read(*)", "Write(*)", "WebFetch(*)"]
|
||||
}
|
||||
});
|
||||
write_json_file(&cursor_dir.join("cli.json"), &config, "Cursor permission")
|
||||
}
|
||||
|
||||
fn process_env(&self, config: &AiConfig) -> Result<Vec<(String, String)>, String> {
|
||||
let mut values = BTreeMap::from_iter(cursor_cli_env(config)?);
|
||||
values.insert("CURSOR_CONFIG_DIR".to_string(), self.config.to_string_lossy().to_string());
|
||||
values.insert("CURSOR_DATA_DIR".to_string(), self.data.to_string_lossy().to_string());
|
||||
Ok(values.into_iter().collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CursorIsolatedRuntime {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.root);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_json_file(path: &Path, value: &Value, label: &str) -> Result<(), String> {
|
||||
let content = serde_json::to_vec_pretty(value)
|
||||
.map_err(|error| format!("[cursorRunFailed] Failed to serialize {label} configuration: {error}"))?;
|
||||
std::fs::write(path, content)
|
||||
.map_err(|error| format!("[cursorRunFailed] Failed to write {label} configuration: {error}"))
|
||||
}
|
||||
|
||||
fn cursor_program(config: &AiConfig) -> String {
|
||||
config.cursor_cli_path.as_deref().map(str::trim).filter(|value| !value.is_empty()).unwrap_or("agent").to_string()
|
||||
}
|
||||
|
||||
fn resolve_cursor_command(config: &AiConfig) -> Result<CursorCommandSpec, String> {
|
||||
let program = cursor_program(config);
|
||||
if starts_with_env_assignment(&program) {
|
||||
return Err("[cursorCliPathInvalid] Cursor CLI path should contain only the executable path. Add environment variables in the Cursor CLI environment variables section.".to_string());
|
||||
}
|
||||
|
||||
let program = if is_path_like_program(&program) { crate::path_utils::expand_tilde(&program) } else { program };
|
||||
let path = Path::new(&program);
|
||||
if path.is_dir() {
|
||||
return ["agent", "cursor-agent"]
|
||||
.into_iter()
|
||||
.find_map(|name| launchable_program_in_dir(path, name))
|
||||
.map(|program| CursorCommandSpec { program, args: Vec::new() })
|
||||
.ok_or_else(|| {
|
||||
"[cursorCliPathInvalid] Cursor CLI path should point to the agent executable or a directory containing agent."
|
||||
.to_string()
|
||||
});
|
||||
}
|
||||
if is_path_like_program(&program) && !path.is_file() {
|
||||
return Err("[cursorCliPathInvalid] Cursor CLI executable does not exist.".to_string());
|
||||
}
|
||||
Ok(CursorCommandSpec { program, args: Vec::new() })
|
||||
}
|
||||
|
||||
fn launchable_program_in_dir(dir: &Path, program: &str) -> Option<String> {
|
||||
program_path_candidates(dir, program)
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.is_file())
|
||||
.map(|path| path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn program_path_candidates(dir: &Path, program: &str) -> Vec<PathBuf> {
|
||||
vec![dir.join(program)]
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn program_path_candidates(dir: &Path, program: &str) -> Vec<PathBuf> {
|
||||
[".exe", ".cmd", ".bat", ""].iter().map(|extension| dir.join(format!("{program}{extension}"))).collect()
|
||||
}
|
||||
|
||||
fn is_path_like_program(program: &str) -> bool {
|
||||
program.contains('/') || program.contains('\\') || program.starts_with('~')
|
||||
}
|
||||
|
||||
fn starts_with_env_assignment(program: &str) -> bool {
|
||||
let Some(first_token) = program.split_whitespace().next() else {
|
||||
return false;
|
||||
};
|
||||
let Some((key, _)) = first_token.split_once('=') else {
|
||||
return false;
|
||||
};
|
||||
is_env_var_name(key)
|
||||
}
|
||||
|
||||
fn is_env_var_name(name: &str) -> bool {
|
||||
let mut chars = name.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return false;
|
||||
};
|
||||
(first == '_' || first.is_ascii_alphabetic()) && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
fn is_reserved_env_name(name: &str) -> bool {
|
||||
let upper = name.to_ascii_uppercase();
|
||||
upper.starts_with("DBX_MCP_") || CURSOR_CONTROL_ENV.iter().any(|reserved| upper == *reserved)
|
||||
}
|
||||
|
||||
pub fn cursor_cli_env(config: &AiConfig) -> Result<Vec<(String, String)>, String> {
|
||||
let mut values = BTreeMap::new();
|
||||
for (key, value) in &config.cursor_cli_env {
|
||||
let key = key.trim();
|
||||
if key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !is_env_var_name(key) {
|
||||
return Err(format!(
|
||||
"[cursorEnvInvalid] Invalid Cursor CLI environment variable name `{key}`. Use names like HTTPS_PROXY."
|
||||
));
|
||||
}
|
||||
if is_reserved_env_name(key) {
|
||||
return Err(format!(
|
||||
"[cursorEnvReserved] `{key}` is managed by DBX for the isolated Cursor session and cannot be set here."
|
||||
));
|
||||
}
|
||||
values.insert(key.to_string(), value.clone());
|
||||
}
|
||||
Ok(values.into_iter().collect())
|
||||
}
|
||||
|
||||
fn cursor_selection_args(config: &AiConfig) -> Vec<String> {
|
||||
let model = config.model.trim();
|
||||
if model.is_empty() || model.eq_ignore_ascii_case("default") {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec!["--model".to_string(), model.to_string()]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_cursor_command(config: &AiConfig, workspace: &Path) -> CursorCommandSpec {
|
||||
let mut command = CursorCommandSpec { program: cursor_program(config), args: Vec::new() };
|
||||
command.args.extend([
|
||||
"-p".to_string(),
|
||||
"--output-format".to_string(),
|
||||
"stream-json".to_string(),
|
||||
"--stream-partial-output".to_string(),
|
||||
"--approve-mcps".to_string(),
|
||||
"--trust".to_string(),
|
||||
"--workspace".to_string(),
|
||||
workspace.to_string_lossy().to_string(),
|
||||
]);
|
||||
command.args.extend(cursor_selection_args(config));
|
||||
command
|
||||
}
|
||||
|
||||
fn resolved_run_command(config: &AiConfig, workspace: &Path) -> Result<CursorCommandSpec, String> {
|
||||
let resolved = resolve_cursor_command(config)?;
|
||||
let mut command = build_cursor_command(config, workspace);
|
||||
command.program = resolved.program;
|
||||
command.args.splice(0..0, resolved.args);
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
pub fn build_cursor_prompt(system_prompt: &str, messages: &[crate::ai::AiMessage], allow_write_sql: bool) -> String {
|
||||
build_cli_agent_prompt("Cursor", system_prompt, messages, allow_write_sql)
|
||||
}
|
||||
|
||||
pub async fn list_cursor_models(config: &AiConfig) -> Result<Vec<AiModelInfo>, String> {
|
||||
let runtime = CursorIsolatedRuntime::create(None)?;
|
||||
let command = resolve_cursor_command(config)?;
|
||||
let mut process = cli_command(&command.program);
|
||||
process.args(&command.args).arg("--list-models");
|
||||
for key in CURSOR_CONTROL_ENV {
|
||||
process.env_remove(key);
|
||||
}
|
||||
process.envs(runtime.process_env(config)?).current_dir(&runtime.workspace).kill_on_drop(true);
|
||||
let output = tokio::time::timeout(CURSOR_COMMAND_TIMEOUT, process.output())
|
||||
.await
|
||||
.map_err(|_| "[cursorTimeout] Cursor model discovery timed out".to_string())?
|
||||
.map_err(|error| classify_cursor_spawn_error(&error.to_string()))?;
|
||||
if !output.status.success() {
|
||||
return Err(classify_cursor_run_error(&combined_output(&output.stderr, &output.stdout)));
|
||||
}
|
||||
parse_cursor_models(&String::from_utf8_lossy(&output.stdout)).ok_or_else(|| {
|
||||
"[cursorProtocolError] Cursor returned no models. Check the Cursor CLI version and authentication state."
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_cursor_models(stdout: &str) -> Option<Vec<AiModelInfo>> {
|
||||
let mut models = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
for line in stdout.lines().map(str::trim).filter(|line| !line.is_empty()) {
|
||||
if line.eq_ignore_ascii_case("available models") {
|
||||
continue;
|
||||
}
|
||||
let Some((reported_id, label)) = line.split_once(" - ") else {
|
||||
continue;
|
||||
};
|
||||
let reported_id = reported_id.trim();
|
||||
if reported_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let id = if reported_id.eq_ignore_ascii_case("auto") { "default" } else { reported_id };
|
||||
if !seen.insert(id.to_string()) {
|
||||
continue;
|
||||
}
|
||||
let label = label.trim().trim_end_matches(" (current)").trim();
|
||||
let display_name = (!label.is_empty()).then(|| label.to_string());
|
||||
let mut info = AiModelInfo::new(id, display_name);
|
||||
info.effort_capability = Some(AiEffortCapability::Unsupported);
|
||||
models.push(info);
|
||||
}
|
||||
(!models.is_empty()).then_some(models)
|
||||
}
|
||||
|
||||
pub async fn test_cursor_connection(config: &AiConfig) -> Result<AiTestConnectionResult, String> {
|
||||
let start = Instant::now();
|
||||
let runtime = CursorIsolatedRuntime::create(None)?;
|
||||
let command = resolve_cursor_command(config)?;
|
||||
let mut process = cli_command(&command.program);
|
||||
process.args(&command.args).args(["status", "--format", "json"]);
|
||||
for key in CURSOR_CONTROL_ENV {
|
||||
process.env_remove(key);
|
||||
}
|
||||
process.envs(runtime.process_env(config)?).current_dir(&runtime.workspace).kill_on_drop(true);
|
||||
let output = tokio::time::timeout(CURSOR_COMMAND_TIMEOUT, process.output())
|
||||
.await
|
||||
.map_err(|_| "[cursorTimeout] Cursor authentication check timed out".to_string())?
|
||||
.map_err(|error| classify_cursor_spawn_error(&error.to_string()))?;
|
||||
if !output.status.success() {
|
||||
return Err(classify_cursor_run_error(&combined_output(&output.stderr, &output.stdout)));
|
||||
}
|
||||
let status: Value = serde_json::from_slice(&output.stdout)
|
||||
.map_err(|error| format!("[cursorProtocolError] Cursor returned invalid status JSON: {error}"))?;
|
||||
let authenticated = status
|
||||
.get("isAuthenticated")
|
||||
.or_else(|| status.get("is_authenticated"))
|
||||
.and_then(Value::as_bool)
|
||||
.or_else(|| {
|
||||
status.get("status").and_then(Value::as_str).map(|status| status.eq_ignore_ascii_case("authenticated"))
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !authenticated {
|
||||
return Err(
|
||||
"[cursorNotAuthenticated] Cursor CLI is not authenticated. Run `agent login`, then retry.".to_string()
|
||||
);
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
Ok(AiTestConnectionResult {
|
||||
success: true,
|
||||
message: format!("OK - {}ms", elapsed.as_millis()),
|
||||
latency_ms: Some(elapsed.as_millis() as u64),
|
||||
model_used: config.model.trim().to_string(),
|
||||
error_category: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn combined_output(stderr: &[u8], stdout: &[u8]) -> String {
|
||||
let stderr = String::from_utf8_lossy(stderr);
|
||||
let stdout = String::from_utf8_lossy(stdout);
|
||||
[stderr.trim(), stdout.trim()].into_iter().filter(|part| !part.is_empty()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
fn classify_cursor_spawn_error(message: &str) -> String {
|
||||
let lower = message.to_ascii_lowercase();
|
||||
if lower.contains("no such file") || lower.contains("not found") || lower.contains("cannot find") {
|
||||
format!("[cursorNotInstalled] {message}")
|
||||
} else {
|
||||
format!("[cursorRunFailed] {message}")
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_cursor_run_error(message: &str) -> String {
|
||||
if message.starts_with("[cursor") || message.starts_with("[dbxMcpMissing]") {
|
||||
return message.to_string();
|
||||
}
|
||||
let lower = message.to_ascii_lowercase();
|
||||
if lower.contains("not authenticated")
|
||||
|| lower.contains("authentication required")
|
||||
|| lower.contains("unauthorized")
|
||||
|| lower.contains("please login")
|
||||
|| lower.contains("please sign in")
|
||||
{
|
||||
format!("[cursorNotAuthenticated] {message}")
|
||||
} else if lower.contains("dbx-mcp-server") || lower.contains("enoent") {
|
||||
format!("[dbxMcpMissing] {message}")
|
||||
} else if lower.contains("mcp") && (lower.contains("dbx") || lower.contains("server")) {
|
||||
format!("[cursorMcpStartupFailed] {message}")
|
||||
} else if lower.contains("json") || lower.contains("protocol") {
|
||||
format!("[cursorProtocolError] {message}")
|
||||
} else {
|
||||
format!("[cursorRunFailed] {message}")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_cursor_jsonl_event(line: &str) -> Option<Vec<AgentEvent>> {
|
||||
parse_cli_jsonl_event(line, CliAgentJsonlDialect::CursorPrint)
|
||||
}
|
||||
|
||||
pub async fn run_cursor_agent(
|
||||
config: &AiConfig,
|
||||
prompt: &str,
|
||||
options: CursorRunOptions,
|
||||
cancelled: &Notify,
|
||||
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
|
||||
) -> Result<String, String> {
|
||||
let runtime = CursorIsolatedRuntime::create(Some(&options))?;
|
||||
let result = run_cli_jsonl_agent(
|
||||
CliAgentProcessSpec {
|
||||
command: resolved_run_command(config, &runtime.workspace)?,
|
||||
env: runtime.process_env(config)?,
|
||||
env_remove: CURSOR_CONTROL_ENV.iter().map(|value| (*value).to_string()).collect(),
|
||||
current_dir: Some(runtime.workspace.clone()),
|
||||
stdin: Some(prompt.to_string()),
|
||||
dialect: CliAgentJsonlDialect::CursorPrint,
|
||||
classify_spawn_error: classify_cursor_spawn_error,
|
||||
classify_run_error: classify_cursor_run_error,
|
||||
},
|
||||
cancelled,
|
||||
on_event,
|
||||
)
|
||||
.await?;
|
||||
if result.trim().is_empty() {
|
||||
Err("[cursorProtocolError] Cursor completed without a text response".to_string())
|
||||
} else {
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ai::{AiApiStyle, AiAuthMethod, AiProvider, AiReasoningLevel};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn config(model: &str) -> AiConfig {
|
||||
AiConfig {
|
||||
provider: AiProvider::CursorCli,
|
||||
api_key: String::new(),
|
||||
auth_method: AiAuthMethod::Bearer,
|
||||
endpoint: String::new(),
|
||||
model: model.to_string(),
|
||||
models: Vec::new(),
|
||||
api_style: AiApiStyle::Completions,
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
enable_thinking: true,
|
||||
reasoning_level: AiReasoningLevel::Default,
|
||||
runtime_effort: None,
|
||||
context_window: None,
|
||||
max_retries: None,
|
||||
codex_cli_path: None,
|
||||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_command_uses_stream_json_and_stdin_mode() {
|
||||
let workspace = Path::new("/tmp/dbx-cursor-workspace");
|
||||
let command = build_cursor_command(&config("default"), workspace);
|
||||
assert_eq!(command.program, "agent");
|
||||
assert_eq!(
|
||||
command.args,
|
||||
[
|
||||
"-p",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--stream-partial-output",
|
||||
"--approve-mcps",
|
||||
"--trust",
|
||||
"--workspace",
|
||||
"/tmp/dbx-cursor-workspace"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_model_is_forwarded() {
|
||||
let command = build_cursor_command(&config("composer-2.5"), Path::new("/tmp/workspace"));
|
||||
assert!(command.args.ends_with(&["--model".to_string(), "composer-2.5".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_map_auto_to_default_and_strip_current_marker() {
|
||||
let models =
|
||||
parse_cursor_models("Available models\n\nauto - Auto (default)\ncomposer-2.5 - Composer 2.5 (current)\n")
|
||||
.unwrap();
|
||||
assert_eq!(models.iter().map(|model| model.id.as_str()).collect::<Vec<_>>(), ["default", "composer-2.5"]);
|
||||
assert_eq!(models[1].display_name.as_deref(), Some("Composer 2.5"));
|
||||
assert!(models.iter().all(|model| model.effort_capability == Some(AiEffortCapability::Unsupported)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_env_rejects_runtime_and_mcp_overrides() {
|
||||
let mut invalid = config("default");
|
||||
invalid.cursor_cli_env.insert("CURSOR_DATA_DIR".to_string(), "/tmp/shared".to_string());
|
||||
assert!(cursor_cli_env(&invalid).unwrap_err().starts_with("[cursorEnvReserved]"));
|
||||
|
||||
invalid.cursor_cli_env.clear();
|
||||
invalid.cursor_cli_env.insert("DBX_MCP_ALLOW_WRITES".to_string(), "1".to_string());
|
||||
assert!(cursor_cli_env(&invalid).unwrap_err().starts_with("[cursorEnvReserved]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_stream_parser_ignores_buffered_assistant_copy() {
|
||||
let partial = parse_cursor_jsonl_event(
|
||||
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"hello"}]},"timestamp_ms":1}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(&partial[0], AgentEvent::TextDelta { delta } if delta == "hello"));
|
||||
assert!(parse_cursor_jsonl_event(
|
||||
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"hello"}]}}"#
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_stream_parser_maps_usage_and_tool_events() {
|
||||
let start = parse_cursor_jsonl_event(
|
||||
r#"{"type":"tool_call","subtype":"started","call_id":"call-1","tool_call":{"mcpToolCall":{"args":{"toolName":"dbx_list_tables","args":{"database":"main"}}}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&start[0], AgentEvent::ToolCallStart { tool_call_id, tool_name, .. } if tool_call_id == "call-1" && tool_name == "dbx_list_tables")
|
||||
);
|
||||
|
||||
let end = parse_cursor_jsonl_event(
|
||||
r#"{"type":"result","subtype":"success","is_error":false,"usage":{"inputTokens":10,"outputTokens":4}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(&end[0], AgentEvent::AgentEnd { input_tokens: Some(10), output_tokens: Some(4) }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolated_runtime_writes_scoped_mcp_and_permissions() {
|
||||
let options = CursorRunOptions {
|
||||
connection_id: "sqlite-1".to_string(),
|
||||
connection_name: "SQLite".to_string(),
|
||||
database: "main".to_string(),
|
||||
schema: None,
|
||||
agent_mode: false,
|
||||
allow_writes: false,
|
||||
allow_dangerous: false,
|
||||
confirmed_write_sql: None,
|
||||
mcp_server_command: Some(CursorCommandSpec {
|
||||
program: "node".to_string(),
|
||||
args: vec!["server.js".to_string()],
|
||||
}),
|
||||
};
|
||||
let runtime = CursorIsolatedRuntime::create(Some(&options)).unwrap();
|
||||
let mcp: Value =
|
||||
serde_json::from_slice(&std::fs::read(runtime.workspace.join(".cursor/mcp.json")).unwrap()).unwrap();
|
||||
assert_eq!(mcp.pointer("/mcpServers/dbx/command").and_then(Value::as_str), Some("node"));
|
||||
assert_eq!(
|
||||
mcp.pointer("/mcpServers/dbx/env/DBX_MCP_SCOPE_CONNECTION_ID").and_then(Value::as_str),
|
||||
Some("sqlite-1")
|
||||
);
|
||||
let permissions: Value =
|
||||
serde_json::from_slice(&std::fs::read(runtime.workspace.join(".cursor/cli.json")).unwrap()).unwrap();
|
||||
let allow = permissions.pointer("/permissions/allow").and_then(Value::as_array).unwrap();
|
||||
assert!(allow.iter().any(|value| value == "Mcp(dbx:dbx_list_connections)"));
|
||||
assert!(!allow.iter().any(|value| value == "Mcp(dbx:dbx_execute_query)"));
|
||||
}
|
||||
|
||||
fn live_config() -> AiConfig {
|
||||
let mut config = config("default");
|
||||
config.cursor_cli_path = std::env::var("DBX_LIVE_CURSOR_PATH").ok();
|
||||
config
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires an installed and authenticated Cursor CLI"]
|
||||
async fn live_model_discovery_and_connection_test() {
|
||||
let config = live_config();
|
||||
let models = list_cursor_models(&config).await.unwrap();
|
||||
assert!(models.iter().any(|model| model.id == "default"));
|
||||
let result = test_cursor_connection(&config).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.latency_ms.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Cursor, a running DBX MCP bridge, and DBX_LIVE_MCP_CONNECTION_NAME/DATABASE"]
|
||||
async fn live_scoped_dbx_mcp_agent_run() {
|
||||
let config = live_config();
|
||||
let connection_name = std::env::var("DBX_LIVE_MCP_CONNECTION_NAME")
|
||||
.expect("set DBX_LIVE_MCP_CONNECTION_NAME to a saved DBX connection");
|
||||
let database = std::env::var("DBX_LIVE_MCP_DATABASE").expect("set DBX_LIVE_MCP_DATABASE to a visible database");
|
||||
let mcp_command = std::env::var("DBX_LIVE_MCP_COMMAND").unwrap_or_else(|_| "dbx-mcp-server".to_string());
|
||||
let options = CursorRunOptions {
|
||||
connection_id: String::new(),
|
||||
connection_name: connection_name.clone(),
|
||||
database,
|
||||
schema: None,
|
||||
agent_mode: false,
|
||||
allow_writes: false,
|
||||
allow_dangerous: false,
|
||||
confirmed_write_sql: None,
|
||||
mcp_server_command: Some(CursorCommandSpec { program: mcp_command, args: Vec::new() }),
|
||||
};
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let captured = Arc::clone(&events);
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(90),
|
||||
run_cursor_agent(
|
||||
&config,
|
||||
"You must use the DBX MCP list-connections tool. Reply with the visible connection name only.",
|
||||
options,
|
||||
&Notify::new(),
|
||||
move |event| captured.lock().unwrap().push(event),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("Cursor MCP smoke test timed out")
|
||||
.unwrap();
|
||||
|
||||
assert!(result.contains(&connection_name));
|
||||
let events = events.lock().unwrap();
|
||||
assert!(
|
||||
events.iter().any(|event| {
|
||||
matches!(event, AgentEvent::ToolCallStart { tool_name, .. } if tool_name.contains("dbx_list_connections"))
|
||||
}),
|
||||
"Cursor events did not expose the expected DBX tool name: {events:#?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,8 @@ pub fn static_effort_capability(config: &AiConfig, model_id: &str) -> Option<AiE
|
||||
| AiProvider::CodexCli
|
||||
| AiProvider::ClaudeCodeCli
|
||||
| AiProvider::PiAgentCli
|
||||
| AiProvider::OpenCodeCli => None,
|
||||
| AiProvider::OpenCodeCli
|
||||
| AiProvider::CursorCli => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +193,7 @@ pub fn registry_source_url(provider: &AiProvider) -> Option<&'static str> {
|
||||
| AiProvider::ClaudeCodeCli
|
||||
| AiProvider::PiAgentCli
|
||||
| AiProvider::OpenCodeCli
|
||||
| AiProvider::CursorCli
|
||||
| AiProvider::Custom => None,
|
||||
}
|
||||
}
|
||||
@@ -211,6 +213,7 @@ pub fn validate_runtime_effort(config: &AiConfig) -> Result<(), String> {
|
||||
| AiProvider::ClaudeCodeCli
|
||||
| AiProvider::PiAgentCli
|
||||
| AiProvider::OpenCodeCli
|
||||
| AiProvider::CursorCli
|
||||
) {
|
||||
return match selection {
|
||||
AiEffortSelection::Enum(value) if !value.trim().is_empty() => Ok(()),
|
||||
@@ -263,7 +266,11 @@ pub fn apply_runtime_effort(body: &mut Value, config: &AiConfig) {
|
||||
apply_openai_effort(object, &config.api_style, selection);
|
||||
}
|
||||
}
|
||||
AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli => {}
|
||||
AiProvider::CodexCli
|
||||
| AiProvider::ClaudeCodeCli
|
||||
| AiProvider::PiAgentCli
|
||||
| AiProvider::OpenCodeCli
|
||||
| AiProvider::CursorCli => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,6 +426,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ pub(crate) fn model_is_assistant_compatible(provider: &AiProvider, model_id: &st
|
||||
| AiProvider::ClaudeCodeCli
|
||||
| AiProvider::PiAgentCli
|
||||
| AiProvider::OpenCodeCli
|
||||
| AiProvider::CursorCli
|
||||
| AiProvider::MiniMax
|
||||
| AiProvider::Custom => true,
|
||||
}
|
||||
|
||||
@@ -466,6 +466,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -757,6 +757,8 @@ mod tests {
|
||||
pi_agent_cli_env: HashMap::new(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: HashMap::new(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1418,6 +1418,8 @@ mod tests {
|
||||
pi_agent_cli_env: Default::default(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: Default::default(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: Default::default(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -2245,23 +2247,33 @@ mod tests {
|
||||
cfg.config.model = "openai/gpt-5.4-mini".to_string();
|
||||
cfg.config.opencode_cli_path = Some("/opt/homebrew/bin/opencode".to_string());
|
||||
cfg.config.opencode_cli_env.insert("HTTPS_PROXY".to_string(), "http://127.0.0.1:7890".to_string());
|
||||
let mut cursor_cfg = make_test_config("cursor-synced", false);
|
||||
cursor_cfg.config.provider = crate::ai::AiProvider::CursorCli;
|
||||
cursor_cfg.config.model = "composer-2.5".to_string();
|
||||
cursor_cfg.config.cursor_cli_path = Some("~/.local/bin/agent".to_string());
|
||||
cursor_cfg.config.cursor_cli_env.insert("NO_PROXY".to_string(), "localhost".to_string());
|
||||
let payload = SensitiveSyncPayload {
|
||||
connection_secrets: vec![],
|
||||
ai_configs: Some(vec![cfg]),
|
||||
ai_configs: Some(vec![cfg, cursor_cfg]),
|
||||
ai_config: None,
|
||||
tunnel_profiles: None,
|
||||
};
|
||||
apply_sensitive_payload(&storage, &payload).await.unwrap();
|
||||
let loaded = storage.load_ai_configs().await.unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].name, "synced");
|
||||
assert!(matches!(loaded[0].config.provider, crate::ai::AiProvider::OpenCodeCli));
|
||||
assert_eq!(loaded[0].config.model, "openai/gpt-5.4-mini");
|
||||
assert_eq!(loaded[0].config.opencode_cli_path.as_deref(), Some("/opt/homebrew/bin/opencode"));
|
||||
assert_eq!(loaded.len(), 2);
|
||||
let opencode = loaded.iter().find(|item| item.name == "synced").unwrap();
|
||||
assert!(matches!(opencode.config.provider, crate::ai::AiProvider::OpenCodeCli));
|
||||
assert_eq!(opencode.config.model, "openai/gpt-5.4-mini");
|
||||
assert_eq!(opencode.config.opencode_cli_path.as_deref(), Some("/opt/homebrew/bin/opencode"));
|
||||
assert_eq!(
|
||||
loaded[0].config.opencode_cli_env.get("HTTPS_PROXY").map(String::as_str),
|
||||
opencode.config.opencode_cli_env.get("HTTPS_PROXY").map(String::as_str),
|
||||
Some("http://127.0.0.1:7890")
|
||||
);
|
||||
let cursor = loaded.iter().find(|item| item.name == "cursor-synced").unwrap();
|
||||
assert!(matches!(cursor.config.provider, crate::ai::AiProvider::CursorCli));
|
||||
assert_eq!(cursor.config.model, "composer-2.5");
|
||||
assert_eq!(cursor.config.cursor_cli_path.as_deref(), Some("~/.local/bin/agent"));
|
||||
assert_eq!(cursor.config.cursor_cli_env.get("NO_PROXY").map(String::as_str), Some("localhost"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod ai;
|
||||
pub mod ai_claude_code_cli;
|
||||
pub mod ai_cli_agent;
|
||||
pub mod ai_codex_cli;
|
||||
pub mod ai_cursor_cli;
|
||||
pub mod ai_effort;
|
||||
mod ai_model_filter;
|
||||
pub mod ai_opencode_cli;
|
||||
|
||||
@@ -5469,6 +5469,8 @@ mod tests {
|
||||
pi_agent_cli_env: std::collections::HashMap::new(),
|
||||
opencode_cli_path: None,
|
||||
opencode_cli_env: std::collections::HashMap::new(),
|
||||
cursor_cli_path: None,
|
||||
cursor_cli_env: std::collections::HashMap::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -5533,6 +5535,33 @@ mod tests {
|
||||
std::fs::remove_file(&db).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cursor_cli_ai_config_roundtrip() {
|
||||
let db = temp_db_path("cursor-cli-ai-roundtrip");
|
||||
let storage = Storage::open(&db).await.unwrap();
|
||||
|
||||
let mut cfg = make_ai_config("cursor-cli", true);
|
||||
cfg.config.provider = AiProvider::CursorCli;
|
||||
cfg.config.api_key.clear();
|
||||
cfg.config.endpoint.clear();
|
||||
cfg.config.model = "composer-2.5".to_string();
|
||||
cfg.config.cursor_cli_path = Some("~/.local/bin/agent".to_string());
|
||||
cfg.config.cursor_cli_env.insert("HTTPS_PROXY".to_string(), "http://127.0.0.1:7890".to_string());
|
||||
storage.save_ai_config_item(&cfg).await.unwrap();
|
||||
|
||||
let loaded = storage.load_ai_configs().await.unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert!(matches!(loaded[0].config.provider, AiProvider::CursorCli));
|
||||
assert_eq!(loaded[0].config.model, "composer-2.5");
|
||||
assert_eq!(loaded[0].config.cursor_cli_path.as_deref(), Some("~/.local/bin/agent"));
|
||||
assert_eq!(
|
||||
loaded[0].config.cursor_cli_env.get("HTTPS_PROXY").map(String::as_str),
|
||||
Some("http://127.0.0.1:7890")
|
||||
);
|
||||
|
||||
std::fs::remove_file(&db).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anthropic_compatible_ai_config_roundtrip() {
|
||||
let db = temp_db_path("anthropic-compatible-ai-roundtrip");
|
||||
|
||||
Reference in new issue
Block a user