use std::ffi::OsString;
use std::path::{Path, PathBuf};
use crate::{
agent::{CursorTool, PromptPolicy},
permission::PermissionMode,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CursorPermissionMode {
Plan,
Full,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct AllowedTools {
mode: CursorPermissionMode,
tools: Vec<CursorTool>,
}
impl AllowedTools {
pub(crate) fn mode(&self) -> CursorPermissionMode {
self.mode
}
pub(crate) fn tools(&self) -> &[CursorTool] {
&self.tools
}
}
#[derive(Clone, Debug)]
pub(crate) struct CursorSpawnRequest {
pub(crate) model: Option<String>,
pub(crate) allowed: AllowedTools,
pub(crate) cwd: PathBuf,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct CursorSpawnPlan {
pub(crate) args: Vec<String>,
pub(crate) cwd: PathBuf,
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub(crate) enum CursorSpawnError {
#[error(
"cursor agents run only in Plan or Bypass, not {0}: `cursor-agent -p` has no approval protocol and `--exclude-tools` does not fence"
)]
ApprovalUnsupported(PermissionMode),
#[error(
"cursor agents require a nonempty --allowed-tools list: `cursor-agent -p` enables every tool by default"
)]
NoToolsAllowed,
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub(crate) enum CursorPromptError {
#[error(
"cursor: internal error: PromptPolicy::Replace cannot reach spawn (parser rejects it)"
)]
ReplaceUnsupported,
}
pub(crate) fn map_permission_mode(
mode: PermissionMode,
tools: &[CursorTool],
) -> Result<AllowedTools, CursorSpawnError> {
let (mode, tools) = match mode {
PermissionMode::Plan => (
CursorPermissionMode::Plan,
tools
.iter()
.copied()
.filter(|tool| tool.is_read_only())
.collect(),
),
PermissionMode::Bypass => (CursorPermissionMode::Full, tools.to_vec()),
PermissionMode::Auto | PermissionMode::AllowEdits | PermissionMode::Supervised => {
return Err(CursorSpawnError::ApprovalUnsupported(mode));
}
};
if tools.is_empty() {
return Err(CursorSpawnError::NoToolsAllowed);
}
Ok(AllowedTools { mode, tools })
}
const FROZEN_IDENTITY_FLAGS: &[&str] = &["--model"];
pub(crate) fn apply_frozen_identity_args(generated: Vec<String>, frozen: &[String]) -> Vec<String> {
crate::cli_runtime::overlay_identity_flags(generated, frozen, FROZEN_IDENTITY_FLAGS)
}
pub(crate) fn build_spawn_plan(request: &CursorSpawnRequest) -> CursorSpawnPlan {
let mut args = vec![
"-p".into(),
"--output-format".into(),
"stream-json".into(),
"--stream-partial-output".into(),
"--trust".into(),
];
if let Some(model) = &request.model {
args.push("--model".into());
args.push(model.clone());
}
if matches!(request.allowed.mode(), CursorPermissionMode::Plan) {
args.push("--mode".into());
args.push("plan".into());
}
args.push("--allowed-tools".into());
args.push(
request
.allowed
.tools()
.iter()
.map(|tool| tool.as_flag())
.collect::<Vec<_>>()
.join(","),
);
CursorSpawnPlan {
args,
cwd: request.cwd.clone(),
}
}
pub(crate) fn finalize_spawn_args(plan: &CursorSpawnPlan, session_id: &str) -> Vec<OsString> {
let mut args: Vec<OsString> = plan.args.iter().map(OsString::from).collect();
args.push(OsString::from("--new-session-id"));
args.push(OsString::from(session_id));
args
}
pub(crate) fn compose_prompt(
system_prompt: &PromptPolicy,
user_prompt: &str,
) -> Result<String, CursorPromptError> {
match system_prompt {
PromptPolicy::Extend(text) if text.is_empty() => Ok(user_prompt.to_string()),
PromptPolicy::Extend(text) => Ok(format!("{text}\n\n---\n\n{user_prompt}")),
PromptPolicy::Replace(_) => Err(CursorPromptError::ReplaceUnsupported),
}
}
pub(crate) fn log_path(output_file: &Path) -> PathBuf {
output_file.with_file_name(crate::subagent::LOG_FILE_NAME)
}
#[cfg(test)]
#[path = "spawn_tests.rs"]
mod tests;