aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src/integrations.rs
blob: b3daf1db91b8d70780c2a979faab13913b406bc3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
//! Agent integrations: hook extensions that let agents report their own
//! state (herdr's integration model), so detection does not rely on screen
//! scraping for the agents that support it.

use std::io::Read;
use std::path::{Path, PathBuf};

use detect_core::Agent;
use serde_json::{json, Value};

/// The embedded pi extension, adapted from herdr's `herdr-agent-state.ts`
/// (Apache-2.0, Copyright herdr contributors).
const PI_EXTENSION: &str = include_str!("../assets/pi/pi-agent-state.ts");

fn pi_extension_dir() -> Result<PathBuf, String> {
    let base = std::env::var_os("PI_CODING_AGENT_DIR")
        .map(PathBuf::from)
        .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".pi").join("agent")))
        .ok_or("cannot determine pi agent dir (no HOME)")?;
    Ok(base.join("extensions"))
}

/// zsh hook: records the command line running in each tmux pane exactly as
/// typed (aliases, pipes, quoting), for restore to retype.
const ZSH_HOOK: &str = r#"# installed by dingger; `dingger install zsh` overwrites this file.
#
# Records the command line running in this tmux pane, as typed, in the pane
# option @dingger_cmd, so `dingger restore` can type it again (without
# running it) after a crash or shutdown.
if [[ -n $TMUX_PANE ]]; then
  _dingger_preexec() { command tmux set-option -p -t "$TMUX_PANE" @dingger_cmd "$1" 2>/dev/null &! }
  _dingger_precmd() { command tmux set-option -p -u -t "$TMUX_PANE" @dingger_cmd 2>/dev/null &! }
  autoload -Uz add-zsh-hook
  add-zsh-hook preexec _dingger_preexec
  add-zsh-hook precmd _dingger_precmd
fi
"#;

fn config_dir() -> Result<PathBuf, String> {
    std::env::var_os("DINGGER_CONFIG_DIR")
        .map(PathBuf::from)
        .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config").join("dingger")))
        .ok_or_else(|| "cannot determine the config dir (no HOME)".to_string())
}

fn install_zsh() -> Result<(), String> {
    let dir = config_dir()?;
    std::fs::create_dir_all(&dir).map_err(|err| format!("mkdir failed: {err}"))?;
    let path = dir.join("dingger.zsh");
    std::fs::write(&path, ZSH_HOOK).map_err(|err| format!("write failed: {err}"))?;
    println!(
        "installed {}\nadd this line to your .zshrc (dingger does not edit it):\n\n  [[ -r {0} ]] && source {0}\n",
        path.display()
    );
    Ok(())
}

/// Install an integration: an agent's state hooks, or `zsh` for retyping
/// command lines on restore.
pub fn install(agent: &str) -> Result<(), String> {
    if agent == "zsh" {
        return install_zsh();
    }
    let parsed = detect_core::parse_agent_label(agent)
        .ok_or_else(|| format!("unknown agent: {agent}"))?;
    match parsed {
        Agent::Pi => install_pi(),
        Agent::Claude => install_claude(),
        other => Err(format!(
            "no hook integration for {} yet — screen detection is used",
            detect_core::agent_label(other)
        )),
    }
}

fn install_pi() -> Result<(), String> {
    let dir = pi_extension_dir()?;
    std::fs::create_dir_all(&dir).map_err(|err| format!("mkdir failed: {err}"))?;
    let path = dir.join("dingger-agent-state.ts");
    std::fs::write(&path, PI_EXTENSION).map_err(|err| format!("write failed: {err}"))?;
    tracing::info!(path = %path.display(), "installed pi extension");
    println!(
        "installed {} — new pi sessions report their state directly;\nrestart running pi agents (or reload them) to pick it up",
        path.display()
    );
    Ok(())
}

/// Claude Code's user settings, where its hooks are configured.
fn claude_settings_path() -> Result<PathBuf, String> {
    std::env::var_os("CLAUDE_CONFIG_DIR")
        .map(PathBuf::from)
        .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude")))
        .map(|dir| dir.join("settings.json"))
        .ok_or_else(|| "cannot determine Claude's config dir (no HOME)".to_string())
}

fn is_dingger_claude_hook(hook: &Value) -> bool {
    hook.get("command")
        .and_then(Value::as_str)
        .is_some_and(|command| command.contains("dingger") && command.ends_with(" hook claude"))
}

/// `settings` with dingger's SessionStart hook running `command`, replacing
/// an earlier one; every other key and hook is kept, in order.
pub fn with_claude_hook(mut settings: Value, command: &str) -> Result<Value, String> {
    let root = settings.as_object_mut().ok_or("settings.json is not a JSON object")?;
    let hooks = root.entry("hooks").or_insert_with(|| json!({}));
    let hooks = hooks.as_object_mut().ok_or("settings.json: \"hooks\" is not an object")?;
    let groups = hooks.entry("SessionStart").or_insert_with(|| json!([]));
    let groups = groups.as_array_mut().ok_or("settings.json: \"hooks.SessionStart\" is not a list")?;
    groups.retain_mut(|group| {
        let Some(list) = group.get_mut("hooks").and_then(Value::as_array_mut) else {
            return true;
        };
        let before = list.len();
        list.retain(|hook| !is_dingger_claude_hook(hook));
        // Drop a group only if it held nothing but dingger's hook.
        !(list.is_empty() && before > 0)
    });
    groups.push(json!({"hooks": [{"type": "command", "command": command, "timeout": 10}]}));
    Ok(settings)
}

/// Add the SessionStart hook to Claude's settings: Claude then reports each
/// session's transcript (`dingger hook claude`), for restore to resume with
/// `claude --resume <id>`. Claude's state still comes from its screen.
fn install_claude() -> Result<(), String> {
    let path = claude_settings_path()?;
    // Write through a symlink to the file it points to.
    let path = std::fs::canonicalize(&path).unwrap_or(path);
    let original = match std::fs::read_to_string(&path) {
        Ok(text) => Some(text),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
        Err(err) => return Err(format!("cannot read {}: {err}", path.display())),
    };
    let settings: Value = match &original {
        Some(text) if !text.trim().is_empty() => serde_json::from_str(text)
            .map_err(|err| format!("{} is not valid JSON ({err}); not changing it", path.display()))?,
        _ => json!({}),
    };
    let exe = std::env::current_exe().map_err(|err| format!("cannot find the dingger binary: {err}"))?;
    let command = format!("{} hook claude", crate::snapshot::shell_quote(&exe.to_string_lossy()));
    let updated = with_claude_hook(settings.clone(), &command)?;
    let mut text = serde_json::to_string_pretty(&updated).map_err(|err| err.to_string())?;
    text.push('\n');
    if original.as_deref() == Some(text.as_str()) {
        println!("already installed in {}", path.display());
        return Ok(());
    }
    if let Some(original) = &original {
        let backup = path.with_extension("json.bak-dingger");
        if !backup.exists() {
            std::fs::write(&backup, original).map_err(|err| format!("backup failed: {err}"))?;
            println!("backed up {} to {}", path.display(), backup.display());
        }
    }
    write_replacing(&path, &text)?;
    tracing::info!(path = %path.display(), "installed claude hook");
    println!(
        "installed in {}: a SessionStart hook running\n  {command}\nnew Claude sessions report their session for restore; running ones report \
         on their next start, /clear, or /resume",
        path.display()
    );
    Ok(())
}

/// Replace `path` with `text` atomically, keeping its permissions.
fn write_replacing(path: &Path, text: &str) -> Result<(), String> {
    if let Some(dir) = path.parent() {
        std::fs::create_dir_all(dir).map_err(|err| format!("mkdir failed: {err}"))?;
    }
    let temporary = path.with_extension(format!("json.dingger-{}", std::process::id()));
    std::fs::write(&temporary, text).map_err(|err| format!("write failed: {err}"))?;
    if let Ok(metadata) = std::fs::metadata(path) {
        let _ = std::fs::set_permissions(&temporary, metadata.permissions());
    }
    std::fs::rename(&temporary, path).map_err(|err| {
        let _ = std::fs::remove_file(&temporary);
        format!("write failed: {err}")
    })
}

/// `dingger hook <agent>`, run by the agent. Never fails it: exits 0 and
/// prints nothing (Claude adds a SessionStart hook's output to the
/// conversation).
pub fn run_hook(agent: &str) {
    if agent != "claude" {
        return;
    }
    let mut input = String::new();
    let _ = std::io::stdin().read_to_string(&mut input);
    let pane = std::env::var("TMUX_PANE").ok();
    if let Some(report) = claude_report(&input, pane.as_deref()) {
        if let Err(err) = crate::hooks::send(&report) {
            tracing::debug!(%err, "claude hook: no report");
        }
    }
}

/// The report for Claude's SessionStart hook input: the pane and the
/// session's transcript, no state. `None` outside tmux, for other events,
/// and for subagents.
pub fn claude_report(input: &str, pane: Option<&str>) -> Option<String> {
    let pane = pane.filter(|pane| pane.starts_with('%'))?;
    let input: Value = serde_json::from_str(input).ok()?;
    if input.get("hook_event_name")?.as_str()? != "SessionStart" || input.get("agent_id").is_some() {
        return None;
    }
    let transcript = input.get("transcript_path")?.as_str().filter(|path| !path.is_empty())?;
    let params = json!({"pane_id": pane, "agent": "claude", "agent_session_path": transcript});
    Some(json!({"method": "pane.report_agent", "params": params}).to_string())
}

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

    #[test]
    fn claude_hook_is_added_beside_other_hooks_and_keys_in_order() {
        let settings: Value = serde_json::from_str(
            r#"{"model":"sonnet","hooks":{"SessionStart":[{"matcher":"^startup$","hooks":[{"type":"command","command":"bash herdr.sh session"}]}],"Stop":[]},"theme":"auto"}"#,
        )
        .unwrap();
        let once = with_claude_hook(settings, "/bin/dingger hook claude").unwrap();
        let twice = with_claude_hook(once.clone(), "/new/dingger hook claude").unwrap();
        assert_eq!(
            twice.to_string(),
            r#"{"model":"sonnet","hooks":{"SessionStart":[{"matcher":"^startup$","hooks":[{"type":"command","command":"bash herdr.sh session"}]},{"hooks":[{"type":"command","command":"/new/dingger hook claude","timeout":10}]}],"Stop":[]},"theme":"auto"}"#,
            "replaced, not added again; nothing else changes"
        );
        assert_eq!(with_claude_hook(json!({}), "d hook claude").unwrap()["hooks"]["SessionStart"][0]["hooks"][0]["command"], "d hook claude");
        assert!(with_claude_hook(json!([]), "d hook claude").is_err());
    }

    #[test]
    fn claude_report_is_the_session_start_transcript() {
        let start = r#"{"session_id":"abc","transcript_path":"/p/abc.jsonl","hook_event_name":"SessionStart","source":"startup"}"#;
        assert_eq!(
            claude_report(start, Some("%7")).as_deref(),
            Some(r#"{"method":"pane.report_agent","params":{"pane_id":"%7","agent":"claude","agent_session_path":"/p/abc.jsonl"}}"#)
        );
        assert_eq!(claude_report(start, None), None, "outside tmux");
        assert_eq!(claude_report(&start.replace("SessionStart", "Stop"), Some("%7")), None);
        assert_eq!(claude_report(&start.replace("\"source\"", "\"agent_id\":\"x\",\"source\""), Some("%7")), None, "a subagent");
        assert_eq!(claude_report("not json", Some("%7")), None);
    }
}