aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src/integrations.rs
blob: 5d9cf7af84a8c41e3362709067317bfb0cb082b3 (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
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
//! 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).
/// It carries a placeholder for its version, which install fills in.
const PI_EXTENSION: &str = include_str!("../assets/pi/pi-agent-state.ts");
const VERSION_PLACEHOLDER: &str = "__DINGGER_EXTENSION_VERSION__";

/// The extension's version: a fingerprint of its text (FNV-1a, so it does
/// not change with the Rust release). A running pi reports the version it
/// loaded; one that differs from this runs an old extension.
pub fn pi_extension_version() -> String {
    let hash = PI_EXTENSION.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
        (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3)
    });
    format!("{hash:016x}")
}

/// The extension as installed: stamped with its version.
fn pi_extension_text() -> String {
    PI_EXTENSION.replace(VERSION_PLACEHOLDER, &pi_extension_version())
}

fn pi_extension_path() -> Result<PathBuf, String> {
    Ok(pi_extension_dir()?.join("dingger-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 = pi_extension_path()?;
    std::fs::write(&path, pi_extension_text()).map_err(|err| format!("write failed: {err}"))?;
    tracing::info!(path = %path.display(), "installed pi extension");
    println!(
        "installed {} — new pi sessions report their state directly;\nrunning pi agents pick it up when reloaded (`dingger reload-agents`)",
        path.display()
    );
    Ok(())
}

/// Whether the installed pi extension is this dingger's: only then does a
/// reload bring an agent up to date (otherwise it loads the old one again).
pub fn pi_extension_installed_is_current() -> bool {
    pi_extension_path()
        .and_then(|path| std::fs::read_to_string(path).map_err(|err| err.to_string()))
        .is_ok_and(|installed| installed == pi_extension_text())
}

/// At the daemon's start: if the pi extension is installed and differs
/// from this dingger's, update it, so a dingger update needs no reinstall.
/// Only dingger's own file (it says so on its first line).
pub fn update_pi_extension() {
    let Ok(path) = pi_extension_path() else { return };
    let Ok(installed) = std::fs::read_to_string(&path) else { return };
    let current = pi_extension_text();
    if installed == current || !installed.starts_with("// installed by dingger") {
        return;
    }
    match std::fs::write(&path, current) {
        Ok(()) => tracing::info!(path = %path.display(), version = %pi_extension_version(), "updated pi extension"),
        Err(err) => tracing::warn!(path = %path.display(), %err, "failed to update pi extension"),
    }
}

/// 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 the_installed_extension_carries_its_version_once() {
        let version = pi_extension_version();
        assert_eq!(version.len(), 16);
        assert_eq!(PI_EXTENSION.matches(VERSION_PLACEHOLDER).count(), 1, "the asset has one placeholder");
        let text = pi_extension_text();
        assert!(!text.contains(VERSION_PLACEHOLDER));
        assert!(text.contains(&format!("\"{version}\"")), "stamped as a string literal");
        assert!(text.starts_with("// installed by dingger"), "update_pi_extension recognizes its own file");
    }

    #[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);
    }
}