edda-bridge-cursor 0.2.1

Cursor Agent hooks and context injection for Edda
Documentation
use serde::Deserialize;
use std::fs;
use std::io::Write;
use std::path::PathBuf;

#[derive(Debug, Clone, Deserialize, Default)]
pub(crate) struct CursorEnvelope {
    #[serde(default)]
    pub hook_event_name: String,
    #[serde(default)]
    pub session_id: String,
    #[serde(default)]
    pub conversation_id: String,
    #[serde(default)]
    pub generation_id: String,
    #[serde(default)]
    pub cwd: String,
    #[serde(default)]
    pub workspace_roots: Vec<String>,
    #[serde(default)]
    pub cursor_version: String,
    #[serde(default)]
    pub composer_mode: String,
    #[serde(default)]
    pub is_background_agent: bool,
    #[serde(default)]
    pub transcript_path: Option<PathBuf>,
    #[serde(default)]
    pub model: String,
    #[serde(default)]
    pub model_id: String,
    #[serde(default)]
    pub user_email: Option<String>,
    #[serde(default)]
    pub tool_name: String,
    #[serde(default)]
    pub tool_use_id: String,
    #[serde(default)]
    pub tool_input: serde_json::Value,
    #[serde(default)]
    pub tool_output: serde_json::Value,
    #[serde(default)]
    pub duration: Option<u64>,
    #[serde(default)]
    pub subagent_id: String,
    #[serde(default)]
    pub subagent_type: String,
    #[serde(default)]
    pub parent_conversation_id: String,
    #[serde(default)]
    pub task: String,
    #[serde(default)]
    pub git_branch: String,
}

impl CursorEnvelope {
    pub(crate) fn event_name(&self) -> &str {
        match self.hook_event_name.as_str() {
            "SessionStart" => "sessionStart",
            "UserPromptSubmit" => "beforeSubmitPrompt",
            "PreToolUse" => "preToolUse",
            "PostToolUse" => "postToolUse",
            "PreCompact" => "preCompact",
            "SessionEnd" => "sessionEnd",
            "Stop" => "stop",
            "SubagentStart" => "subagentStart",
            "SubagentStop" => "subagentStop",
            event => event,
        }
    }

    pub(crate) fn session_id(&self) -> &str {
        if self.session_id.is_empty() {
            &self.conversation_id
        } else {
            &self.session_id
        }
    }

    pub(crate) fn cwd(&self) -> PathBuf {
        if !self.cwd.is_empty() {
            return cursor_path(&self.cwd);
        }
        self.workspace_roots
            .first()
            .map(|root| cursor_path(root))
            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
    }
}

fn cursor_path(path: &str) -> PathBuf {
    #[cfg(windows)]
    if path.len() >= 4 {
        let bytes = path.as_bytes();
        if bytes[0] == b'/' && bytes[1].is_ascii_alphabetic() && bytes[2] == b':' {
            return PathBuf::from(&path[1..]);
        }
    }
    PathBuf::from(path)
}

pub(crate) fn parse_hook_stdin(stdin: &str) -> anyhow::Result<CursorEnvelope> {
    let envelope: CursorEnvelope = serde_json::from_str(stdin)?;
    if envelope.hook_event_name.is_empty() {
        anyhow::bail!("missing required field: hook_event_name");
    }
    Ok(envelope)
}

pub(crate) fn append_to_session_ledger(envelope: &CursorEnvelope) -> anyhow::Result<()> {
    let session_id = envelope.session_id();
    if session_id.is_empty() {
        return Ok(());
    }

    let cwd = envelope.cwd();
    let project_id = edda_store::project_id(&cwd);
    let ledger_dir = edda_store::project_dir(&project_id).join("ledger");
    fs::create_dir_all(&ledger_dir)?;
    let ledger_path = ledger_dir.join(format!("{session_id}.jsonl"));
    let timestamp =
        time::OffsetDateTime::now_utc().format(&time::format_description::well_known::Rfc3339)?;
    let record = serde_json::json!({
        "ts": timestamp,
        "project_id": project_id,
        "session_id": session_id,
        "conversation_id": envelope.conversation_id,
        "generation_id": envelope.generation_id,
        "hook_event_name": envelope.event_name(),
        "cwd": cwd,
        "cursor_version": envelope.cursor_version,
        "composer_mode": envelope.composer_mode,
        "is_background_agent": envelope.is_background_agent,
        "transcript_path": envelope.transcript_path,
        "user_email": envelope.user_email,
        "model": envelope.model,
        "model_id": envelope.model_id,
        "tool_name": envelope.tool_name,
        "tool_use_id": envelope.tool_use_id,
        "tool_output": envelope.tool_output,
        "duration": envelope.duration,
        "subagent_id": envelope.subagent_id,
        "subagent_type": envelope.subagent_type,
        "parent_conversation_id": envelope.parent_conversation_id,
        "task": envelope.task,
        "git_branch": envelope.git_branch,
        "bridge": "cursor",
    });

    let mut file = fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(ledger_path)?;
    writeln!(file, "{}", serde_json::to_string(&record)?)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_native_session_start_envelope() {
        let stdin = r#"{
            "hook_event_name": "sessionStart",
            "session_id": "cursor-session-1",
            "conversation_id": "conversation-1",
            "workspace_roots": ["C:/work/project"],
            "cursor_version": "3.10.20",
            "composer_mode": "agent",
            "is_background_agent": false,
            "transcript_path": null
        }"#;

        let envelope = parse_hook_stdin(stdin).unwrap();

        assert_eq!(envelope.hook_event_name, "sessionStart");
        assert_eq!(envelope.session_id(), "cursor-session-1");
        assert_eq!(envelope.cwd(), std::path::Path::new("C:/work/project"));
        assert_eq!(envelope.cursor_version, "3.10.20");
        assert_eq!(envelope.composer_mode, "agent");
        assert!(!envelope.is_background_agent);
        assert!(envelope.transcript_path.is_none());
    }

    #[test]
    fn parses_native_pre_tool_use_fields() {
        let stdin = r#"{
            "hook_event_name": "preToolUse",
            "conversation_id": "conversation-2",
            "cwd": "/work/project",
            "tool_name": "Shell",
            "tool_use_id": "tool-1",
            "tool_input": {"command": "cargo test"},
            "tool_output": "not available yet"
        }"#;

        let envelope = parse_hook_stdin(stdin).unwrap();

        assert_eq!(envelope.session_id(), "conversation-2");
        assert_eq!(envelope.tool_name, "Shell");
        assert_eq!(envelope.tool_use_id, "tool-1");
        assert_eq!(envelope.tool_input["command"], "cargo test");
        assert_eq!(envelope.tool_output, "not available yet");
    }

    #[test]
    fn preserves_common_cursor_metadata() {
        let stdin = r#"{
            "hook_event_name": "postToolUse",
            "conversation_id": "conversation-3",
            "generation_id": "generation-3",
            "model": "claude-opus-thinking",
            "model_id": "claude-opus",
            "user_email": "dev@example.com",
            "duration": 5432
        }"#;

        let envelope = parse_hook_stdin(stdin).unwrap();

        assert_eq!(envelope.generation_id, "generation-3");
        assert_eq!(envelope.model, "claude-opus-thinking");
        assert_eq!(envelope.model_id, "claude-opus");
        assert_eq!(envelope.user_email.as_deref(), Some("dev@example.com"));
        assert_eq!(envelope.duration, Some(5432));
    }

    #[test]
    fn normalizes_legacy_pascal_case_event_names() {
        let envelope =
            parse_hook_stdin(r#"{"hook_event_name":"SessionStart","session_id":"legacy-session"}"#)
                .unwrap();

        assert_eq!(envelope.event_name(), "sessionStart");
    }

    #[test]
    fn parses_subagent_lifecycle_fields() {
        let envelope = parse_hook_stdin(
            r#"{
                "hook_event_name":"subagentStart",
                "subagent_id":"subagent-1",
                "subagent_type":"explore",
                "parent_conversation_id":"conversation-parent",
                "task":"Inspect bridge behavior",
                "git_branch":"feature/cursor"
            }"#,
        )
        .unwrap();

        assert_eq!(envelope.subagent_id, "subagent-1");
        assert_eq!(envelope.subagent_type, "explore");
        assert_eq!(envelope.parent_conversation_id, "conversation-parent");
        assert_eq!(envelope.task, "Inspect bridge behavior");
        assert_eq!(envelope.git_branch, "feature/cursor");
    }

    #[cfg(windows)]
    #[test]
    fn normalizes_cursor_windows_workspace_root() {
        let envelope = parse_hook_stdin(
            r#"{"hook_event_name":"sessionStart","workspace_roots":["/C:/work/project"]}"#,
        )
        .unwrap();

        assert_eq!(envelope.cwd(), std::path::Path::new("C:/work/project"));
    }
}