aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src/integrations.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/dinggerd/src/integrations.rs')
-rw-r--r--crates/dinggerd/src/integrations.rs166
1 files changed, 165 insertions, 1 deletions
diff --git a/crates/dinggerd/src/integrations.rs b/crates/dinggerd/src/integrations.rs
index d7cbd34..b3daf1d 100644
--- a/crates/dinggerd/src/integrations.rs
+++ b/crates/dinggerd/src/integrations.rs
@@ -2,9 +2,11 @@
//! state (herdr's integration model), so detection does not rely on screen
//! scraping for the agents that support it.
-use std::path::PathBuf;
+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).
@@ -63,6 +65,7 @@ pub fn install(agent: &str) -> Result<(), String> {
.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)
@@ -82,3 +85,164 @@ fn install_pi() -> Result<(), String> {
);
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);
+ }
+}