1use std::{
19 env, fs,
20 io::{Read, Write},
21 os::unix::fs::{OpenOptionsExt, PermissionsExt},
22 path::{Path, PathBuf},
23 time::{SystemTime, UNIX_EPOCH},
24};
25
26use anyhow::{bail, Context, Result};
27use serde_json::{json, Value};
28use sha2::{Digest, Sha256};
29
30use crate::doctor::{Finding, Level};
31
32pub const EVENTS: [&str; 2] = ["SessionStart", "UserPromptSubmit"];
34const MARK: &str = " hook query-first --harness ";
36const TIMEOUT_SECONDS: i64 = 5;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Harness {
40 Claude,
41 Codex,
42}
43
44impl Harness {
45 pub const ALL: [Harness; 2] = [Harness::Claude, Harness::Codex];
46
47 pub fn name(self) -> &'static str {
48 match self {
49 Harness::Claude => "claude",
50 Harness::Codex => "codex",
51 }
52 }
53
54 pub fn parse(name: &str) -> Result<Self> {
55 match name {
56 "claude" => Ok(Harness::Claude),
57 "codex" => Ok(Harness::Codex),
58 other => bail!("unknown harness {other:?}; expected claude or codex"),
59 }
60 }
61}
62
63#[derive(Debug, Clone)]
65pub struct Paths {
66 pub claude_settings: PathBuf,
67 pub codex_config: PathBuf,
68 pub state: PathBuf,
69}
70
71impl Paths {
72 pub fn from_env() -> Result<Self> {
76 let home = env::var_os("HOME")
77 .map(PathBuf::from)
78 .context("HOME is not set")?;
79 let dir = |var: &str, default: PathBuf| {
80 env::var_os(var)
81 .filter(|v| !v.is_empty())
82 .map(PathBuf::from)
83 .unwrap_or(default)
84 };
85 Ok(Self {
86 claude_settings: dir("CLAUDE_CONFIG_DIR", home.join(".claude")).join("settings.json"),
87 codex_config: dir("CODEX_HOME", home.join(".codex")).join("config.toml"),
88 state: dir("XDG_STATE_HOME", home.join(".local/state"))
89 .join("tambo")
90 .join("agent-hooks"),
91 })
92 }
93
94 fn settings(&self, harness: Harness) -> &Path {
95 match harness {
96 Harness::Claude => &self.claude_settings,
97 Harness::Codex => &self.codex_config,
98 }
99 }
100
101 fn before(&self, harness: Harness) -> PathBuf {
102 self.state.join(format!("{}.before", harness.name()))
103 }
104
105 fn absent_marker(&self, harness: Harness) -> PathBuf {
106 self.state.join(format!("{}.before-absent", harness.name()))
107 }
108
109 fn after_digest(&self, harness: Harness) -> PathBuf {
110 self.state.join(format!("{}.after.sha256", harness.name()))
111 }
112
113 fn config(&self) -> PathBuf {
114 self.state.join("config.json")
115 }
116
117 pub fn usage(&self) -> PathBuf {
118 self.state.join("usage.jsonl")
119 }
120}
121
122#[derive(Debug, Clone, Default, PartialEq, Eq)]
124pub struct Target {
125 pub db: Option<PathBuf>,
127}
128
129fn quote(value: &str) -> String {
130 format!("'{}'", value.replace('\'', "'\"'\"'"))
131}
132
133pub fn command(tambo: &Path, harness: Harness, event: &str) -> String {
135 format!(
136 "{}{MARK}{} --event {event}",
137 quote(&tambo.display().to_string()),
138 harness.name()
139 )
140}
141
142fn is_ours(command: &str) -> bool {
143 command.contains(MARK)
144}
145
146fn digest(bytes: &[u8]) -> String {
147 format!("{:x}", Sha256::digest(bytes))
148}
149
150fn read_optional(path: &Path) -> Result<Option<Vec<u8>>> {
151 match fs::read(path) {
152 Ok(bytes) => Ok(Some(bytes)),
153 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
154 Err(e) => Err(e).with_context(|| format!("read {}", path.display())),
155 }
156}
157
158fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
160 let parent = path.parent().context("settings path has no parent")?;
161 fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
162 let mode = fs::metadata(path)
163 .map(|m| m.permissions().mode() & 0o7777)
164 .unwrap_or(0o600);
165 let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
166 tmp.write_all(bytes)?;
167 tmp.as_file().sync_all()?;
168 fs::set_permissions(tmp.path(), fs::Permissions::from_mode(mode))?;
169 tmp.persist(path)
170 .with_context(|| format!("replace {}", path.display()))?;
171 Ok(())
172}
173
174fn private_write(path: &Path, bytes: &[u8]) -> Result<()> {
175 fs::create_dir_all(path.parent().context("state path has no parent")?)?;
176 let mut tmp = tempfile::NamedTempFile::new_in(path.parent().unwrap())?;
177 tmp.write_all(bytes)?;
178 fs::set_permissions(tmp.path(), fs::Permissions::from_mode(0o600))?;
179 tmp.persist(path)?;
180 Ok(())
181}
182
183#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
189#[serde(untagged)]
190enum J {
191 Null,
192 Bool(bool),
193 Number(serde_json::Number),
194 String(String),
195 Array(Vec<J>),
196 Object(indexmap::IndexMap<String, J>),
197}
198
199impl J {
200 fn get(&self, key: &str) -> Option<&J> {
201 match self {
202 J::Object(map) => map.get(key),
203 _ => None,
204 }
205 }
206 fn get_mut(&mut self, key: &str) -> Option<&mut J> {
207 match self {
208 J::Object(map) => map.get_mut(key),
209 _ => None,
210 }
211 }
212 fn array(&self) -> Option<&Vec<J>> {
213 match self {
214 J::Array(items) => Some(items),
215 _ => None,
216 }
217 }
218 fn array_mut(&mut self) -> Option<&mut Vec<J>> {
219 match self {
220 J::Array(items) => Some(items),
221 _ => None,
222 }
223 }
224 fn object_mut(&mut self) -> Option<&mut indexmap::IndexMap<String, J>> {
225 match self {
226 J::Object(map) => Some(map),
227 _ => None,
228 }
229 }
230 fn str(&self) -> Option<&str> {
231 match self {
232 J::String(s) => Some(s),
233 _ => None,
234 }
235 }
236}
237
238fn claude_parse(bytes: Option<&[u8]>) -> Result<J> {
239 let empty = || J::Object(indexmap::IndexMap::new());
240 match bytes {
241 None => Ok(empty()),
242 Some(b) if b.iter().all(u8::is_ascii_whitespace) => Ok(empty()),
243 Some(b) => {
244 let v: J = serde_json::from_slice(b).context(
245 "Claude settings.json is not valid JSON; fix it first, nothing was written",
246 )?;
247 if !matches!(v, J::Object(_)) {
248 bail!("Claude settings.json is not a JSON object; nothing was written");
249 }
250 Ok(v)
251 }
252 }
253}
254
255fn claude_serialize(settings: &J) -> Result<Vec<u8>> {
256 let mut out = serde_json::to_vec_pretty(settings)?;
257 out.push(b'\n');
258 Ok(out)
259}
260
261fn claude_hooks(settings: &J, event: &str) -> Vec<(usize, usize, String)> {
263 let mut found = Vec::new();
264 let groups = settings
265 .get("hooks")
266 .and_then(|h| h.get(event))
267 .and_then(J::array);
268 for (g, group) in groups.into_iter().flatten().enumerate() {
269 for (h, hook) in group
270 .get("hooks")
271 .and_then(J::array)
272 .into_iter()
273 .flatten()
274 .enumerate()
275 {
276 if let Some(cmd) = hook.get("command").and_then(J::str) {
277 found.push((g, h, cmd.to_string()));
278 }
279 }
280 }
281 found
282}
283
284fn claude_commands(settings: &J, event: &str) -> Vec<String> {
285 claude_hooks(settings, event)
286 .into_iter()
287 .map(|(_, _, c)| c)
288 .collect()
289}
290
291fn claude_remove(settings: &mut J, keep_first: bool) {
294 let Some(hooks) = settings.get_mut("hooks").and_then(J::object_mut) else {
295 return;
296 };
297 for event in EVENTS {
298 let Some(groups) = hooks.get_mut(event).and_then(J::array_mut) else {
299 continue;
300 };
301 let mut kept = !keep_first;
302 for group in groups.iter_mut() {
303 if let Some(list) = group.get_mut("hooks").and_then(J::array_mut) {
304 list.retain(|h| {
305 if !h.get("command").and_then(J::str).is_some_and(is_ours) {
306 return true;
307 }
308 if kept {
309 return false;
310 }
311 kept = true;
312 true
313 });
314 }
315 }
316 groups.retain(|g| !g.get("hooks").and_then(J::array).is_some_and(Vec::is_empty));
317 if groups.is_empty() {
318 hooks.shift_remove(event);
319 }
320 }
321 if hooks.is_empty() {
322 settings.object_mut().unwrap().shift_remove("hooks");
323 }
324}
325
326fn claude_render(current: Option<&[u8]>, wanted: &[(String, String)]) -> Result<Option<Vec<u8>>> {
327 let mut settings = claude_parse(current)?;
328 let original = settings.clone();
329 claude_remove(&mut settings, true);
330 for (event, cmd) in wanted {
331 let ours = claude_hooks(&settings, event)
332 .into_iter()
333 .find(|(_, _, c)| is_ours(c));
334 if let Some((g, h, existing)) = ours {
335 if existing != *cmd {
337 let hook = &mut settings
338 .get_mut("hooks")
339 .unwrap()
340 .get_mut(event)
341 .unwrap()
342 .array_mut()
343 .unwrap()[g]
344 .get_mut("hooks")
345 .unwrap()
346 .array_mut()
347 .unwrap()[h];
348 *hook.get_mut("command").unwrap() = J::String(cmd.clone());
349 }
350 continue;
351 }
352 let object = settings.object_mut().unwrap();
353 let hooks = object
354 .entry("hooks".to_string())
355 .or_insert_with(|| J::Object(indexmap::IndexMap::new()))
356 .object_mut()
357 .context("Claude settings.json `hooks` is not an object; nothing was written")?;
358 let group: J = serde_json::from_value(
359 json!({"hooks":[{"type":"command","command":cmd,"timeout":TIMEOUT_SECONDS}]}),
360 )?;
361 hooks
362 .entry(event.clone())
363 .or_insert_with(|| J::Array(Vec::new()))
364 .array_mut()
365 .with_context(|| format!("Claude settings.json hooks.{event} is not a list"))?
366 .push(group);
367 }
368 if settings == original {
369 return Ok(None);
370 }
371 Ok(Some(claude_serialize(&settings)?))
372}
373
374fn claude_strip(current: &[u8]) -> Result<Vec<u8>> {
375 let mut settings = claude_parse(Some(current))?;
376 claude_remove(&mut settings, false);
377 claude_serialize(&settings)
378}
379
380fn codex_parse(bytes: Option<&[u8]>) -> Result<toml_edit::DocumentMut> {
383 let text = match bytes {
384 None => String::new(),
385 Some(b) => String::from_utf8(b.to_vec()).context("Codex config.toml is not UTF-8")?,
386 };
387 text.parse::<toml_edit::DocumentMut>()
388 .context("Codex config.toml is not valid TOML; fix it first, nothing was written")
389}
390
391fn codex_commands(doc: &toml_edit::DocumentMut, event: &str) -> Vec<String> {
392 let Some(groups) = doc
393 .get("hooks")
394 .and_then(|h| h.get(event))
395 .and_then(toml_edit::Item::as_array_of_tables)
396 else {
397 return Vec::new();
398 };
399 groups
400 .iter()
401 .filter_map(|g| g.get("hooks").and_then(toml_edit::Item::as_array_of_tables))
402 .flat_map(|list| list.iter())
403 .filter_map(|h| {
404 h.get("command")
405 .and_then(|c| c.as_str())
406 .map(str::to_string)
407 })
408 .collect()
409}
410
411fn snake(event: &str) -> &'static str {
412 match event {
413 "SessionStart" => "session_start",
414 _ => "user_prompt_submit",
415 }
416}
417
418fn trust_names(config: &Path) -> Vec<String> {
421 let mut names = vec![config.display().to_string()];
422 if let Ok(real) = fs::canonicalize(config) {
423 let real = real.display().to_string();
424 if !names.contains(&real) {
425 names.push(real);
426 }
427 }
428 names
429}
430
431fn codex_positions(doc: &toml_edit::DocumentMut, event: &str) -> Vec<(usize, usize, bool)> {
434 let mut found = Vec::new();
435 let Some(groups) = doc
436 .get("hooks")
437 .and_then(|h| h.get(event))
438 .and_then(toml_edit::Item::as_array_of_tables)
439 else {
440 return found;
441 };
442 for (g, group) in groups.iter().enumerate() {
443 let Some(list) = group
444 .get("hooks")
445 .and_then(toml_edit::Item::as_array_of_tables)
446 else {
447 continue;
448 };
449 for (h, hook) in list.iter().enumerate() {
450 let ours = hook
451 .get("command")
452 .and_then(|c| c.as_str())
453 .is_some_and(is_ours);
454 found.push((g, h, ours));
455 }
456 }
457 found
458}
459
460fn codex_trust_keys(doc: &toml_edit::DocumentMut, config: &Path) -> Vec<String> {
463 let names = trust_names(config);
464 let mut keys = Vec::new();
465 for event in EVENTS {
466 for (g, h, ours) in codex_positions(doc, event) {
467 if ours {
468 for name in &names {
469 keys.push(format!("{name}:{}:{g}:{h}", snake(event)));
470 }
471 }
472 }
473 }
474 keys
475}
476
477fn codex_trusted(doc: &toml_edit::DocumentMut, config: &Path) -> bool {
479 let state = doc.get("hooks").and_then(|h| h.get("state"));
480 let keys = codex_trust_keys(doc, config);
481 EVENTS.iter().all(|e| {
482 keys.iter()
483 .filter(|k| k.contains(&format!(":{}:", snake(e))))
484 .any(|k| state.and_then(|s| s.get(k)).is_some())
485 })
486}
487
488fn codex_remove(doc: &mut toml_edit::DocumentMut, config: &Path, keep_first: bool) {
494 let names = trust_names(config);
495 let mut moves: Vec<(String, Option<String>)> = Vec::new(); for event in EVENTS {
497 let positions = codex_positions(doc, event);
498 let mut kept_first = !keep_first;
499 let keep: Vec<bool> = positions
500 .iter()
501 .map(|&(_, _, ours)| {
502 if !ours {
503 return true;
504 }
505 if kept_first {
506 return false;
507 }
508 kept_first = true;
509 true
510 })
511 .collect();
512 let groups = doc
515 .get("hooks")
516 .and_then(|h| h.get(event))
517 .and_then(toml_edit::Item::as_array_of_tables)
518 .map_or(0, toml_edit::ArrayOfTables::len);
519 let mut new_group = Vec::with_capacity(groups);
520 let mut next = 0;
521 for g in 0..groups {
522 let mine: Vec<bool> = positions
523 .iter()
524 .zip(&keep)
525 .filter(|(p, _)| p.0 == g)
526 .map(|(_, k)| *k)
527 .collect();
528 if !mine.is_empty() && mine.iter().all(|k| !k) {
529 new_group.push(None);
530 } else {
531 new_group.push(Some(next));
532 next += 1;
533 }
534 }
535 let mut next_hook: std::collections::HashMap<usize, usize> = Default::default();
536 for (&(g, h, _), &k) in positions.iter().zip(&keep) {
537 let new = if k {
538 let ng = new_group[g].expect("a group with a kept hook is kept");
539 let slot = next_hook.entry(ng).or_insert(0);
540 let nh = *slot;
541 *slot += 1;
542 Some((ng, nh))
543 } else {
544 None
545 };
546 if new == Some((g, h)) {
547 continue;
548 }
549 for name in &names {
550 let old = format!("{name}:{}:{g}:{h}", snake(event));
551 let target = new.map(|(ng, nh)| format!("{name}:{}:{ng}:{nh}", snake(event)));
552 moves.push((old, target));
553 }
554 }
555 }
556 let Some(hooks) = doc
557 .get_mut("hooks")
558 .and_then(toml_edit::Item::as_table_like_mut)
559 else {
560 return;
561 };
562 if let Some(state) = hooks
563 .get_mut("state")
564 .and_then(toml_edit::Item::as_table_like_mut)
565 {
566 let mut taken = Vec::new();
569 for (old, target) in &moves {
570 if let Some(item) = state.remove(old) {
571 if let Some(target) = target {
572 taken.push((target.clone(), item));
573 }
574 }
575 }
576 for (target, item) in taken {
577 state.insert(&target, item);
578 }
579 if state.is_empty() {
580 hooks.remove("state");
581 }
582 }
583 for event in EVENTS {
584 let Some(groups) = hooks
585 .get_mut(event)
586 .and_then(toml_edit::Item::as_array_of_tables_mut)
587 else {
588 continue;
589 };
590 let mut kept_first = !keep_first;
591 for group in groups.iter_mut() {
592 if let Some(list) = group
593 .get_mut("hooks")
594 .and_then(toml_edit::Item::as_array_of_tables_mut)
595 {
596 list.retain(|h| {
597 if !h
598 .get("command")
599 .and_then(|c| c.as_str())
600 .is_some_and(is_ours)
601 {
602 return true;
603 }
604 if kept_first {
605 return false;
606 }
607 kept_first = true;
608 true
609 });
610 }
611 }
612 groups.retain(|g| {
613 !g.get("hooks")
614 .and_then(toml_edit::Item::as_array_of_tables)
615 .is_some_and(|l| l.is_empty())
616 });
617 if groups.is_empty() {
618 hooks.remove(event);
619 }
620 }
621 if hooks.is_empty() {
622 doc.remove("hooks");
623 }
624}
625
626fn codex_render(
627 current: Option<&[u8]>,
628 wanted: &[(String, String)],
629 config: &Path,
630) -> Result<Option<Vec<u8>>> {
631 let mut doc = codex_parse(current)?;
632 let original = doc.to_string();
633 codex_remove(&mut doc, config, true);
634 for (event, cmd) in wanted {
635 let ours = codex_positions(&doc, event).into_iter().find(|p| p.2);
636 if let Some((g, h, _)) = ours {
637 let hook = &mut doc["hooks"][event.as_str()]
639 .as_array_of_tables_mut()
640 .unwrap()
641 .get_mut(g)
642 .unwrap()["hooks"]
643 .as_array_of_tables_mut()
644 .unwrap()
645 .get_mut(h)
646 .unwrap();
647 if hook.get("command").and_then(|c| c.as_str()) != Some(cmd.as_str()) {
648 hook.insert("command", toml_edit::value(cmd.as_str()));
649 }
650 continue;
651 }
652 if doc.get("hooks").is_none() {
653 let mut table = toml_edit::Table::new();
654 table.set_implicit(true);
655 doc.insert("hooks", toml_edit::Item::Table(table));
656 }
657 let hooks = doc["hooks"]
658 .as_table_mut()
659 .context("Codex config.toml `hooks` is not a table; nothing was written")?;
660 let mut hook = toml_edit::Table::new();
661 hook.insert("type", toml_edit::value("command"));
662 hook.insert("command", toml_edit::value(cmd.as_str()));
663 hook.insert("timeout", toml_edit::value(TIMEOUT_SECONDS));
664 let mut list = toml_edit::ArrayOfTables::new();
665 list.push(hook);
666 let mut group = toml_edit::Table::new();
667 group.insert("hooks", toml_edit::Item::ArrayOfTables(list));
668 hooks
670 .entry(event)
671 .or_insert(toml_edit::Item::ArrayOfTables(
672 toml_edit::ArrayOfTables::new(),
673 ))
674 .as_array_of_tables_mut()
675 .with_context(|| format!("Codex config.toml hooks.{event} is not an array of tables"))?
676 .push(group);
677 }
678 let rendered = doc.to_string();
679 if rendered == original {
680 return Ok(None);
681 }
682 Ok(Some(rendered.into_bytes()))
683}
684
685fn codex_strip(current: &[u8], config: &Path) -> Result<Vec<u8>> {
686 let mut doc = codex_parse(Some(current))?;
687 codex_remove(&mut doc, config, false);
688 Ok(doc.to_string().into_bytes())
689}
690
691fn render(
692 harness: Harness,
693 current: Option<&[u8]>,
694 wanted: &[(String, String)],
695 path: &Path,
696) -> Result<Option<Vec<u8>>> {
697 match harness {
698 Harness::Claude => claude_render(current, wanted),
699 Harness::Codex => codex_render(current, wanted, path),
700 }
701}
702
703fn strip(harness: Harness, current: &[u8], path: &Path) -> Result<Vec<u8>> {
704 match harness {
705 Harness::Claude => claude_strip(current),
706 Harness::Codex => codex_strip(current, path),
707 }
708}
709
710fn configured(harness: Harness, current: Option<&[u8]>) -> Result<Vec<(String, String)>> {
711 let mut found = Vec::new();
712 match harness {
713 Harness::Claude => {
714 let settings = claude_parse(current)?;
715 for e in EVENTS {
716 for c in claude_commands(&settings, e)
717 .into_iter()
718 .filter(|c| is_ours(c))
719 {
720 found.push((e.to_string(), c));
721 }
722 }
723 }
724 Harness::Codex => {
725 let doc = codex_parse(current)?;
726 for e in EVENTS {
727 for c in codex_commands(&doc, e).into_iter().filter(|c| is_ours(c)) {
728 found.push((e.to_string(), c));
729 }
730 }
731 }
732 }
733 Ok(found)
734}
735
736pub fn crew_host_reason(shanty_root: Option<&std::ffi::OsStr>, plan_crew: bool) -> Option<String> {
742 if shanty_root.is_some_and(|r| !r.is_empty()) {
743 return Some("SHANTY_ROOT is set".into());
744 }
745 plan_crew.then(|| "the plan selects a shantytown crew".into())
746}
747
748fn refuse_symlink(path: &Path) -> Result<()> {
751 if fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()) {
752 bail!(
753 "{} is a symlink; tambo will not replace it with a regular file, and nothing was written. \
754 Add the hooks to the file it points to, or replace the link with a regular file and rerun",
755 path.display()
756 );
757 }
758 Ok(())
759}
760
761#[derive(Debug, Clone, PartialEq, Eq)]
765pub enum Outcome {
766 Written,
767 Unchanged,
768 Restored,
769 Removed,
770 NothingToRemove,
771}
772
773impl Outcome {
774 pub fn describe(&self) -> &'static str {
775 match self {
776 Outcome::Written => "query-first hooks installed",
777 Outcome::Unchanged => "query-first hooks already installed; nothing written",
778 Outcome::Restored => "restored the exact settings saved before install",
779 Outcome::Removed => {
780 "removed the tambo hooks; the file changed after install, so other edits were kept"
781 }
782 Outcome::NothingToRemove => "no tambo hooks found; nothing written",
783 }
784 }
785}
786
787pub fn install(paths: &Paths, tambo: &Path, target: &Target) -> Result<Vec<(Harness, Outcome)>> {
789 if !tambo.is_absolute() {
790 bail!(
791 "the hook command needs tambo's absolute path, got {}",
792 tambo.display()
793 );
794 }
795 let mut staged = Vec::new();
797 for harness in Harness::ALL {
798 let path = paths.settings(harness);
799 refuse_symlink(path)?;
800 let current = read_optional(path)?;
801 let wanted: Vec<(String, String)> = EVENTS
802 .iter()
803 .map(|e| (e.to_string(), command(tambo, harness, e)))
804 .collect();
805 let next = render(harness, current.as_deref(), &wanted, path)?;
806 staged.push((harness, current, next));
807 }
808 fs::create_dir_all(&paths.state)?;
809 fs::set_permissions(&paths.state, fs::Permissions::from_mode(0o700))?;
810 let config = json!({"schema":1,"db":target.db.as_ref().map(|d| d.display().to_string())});
811 private_write(&paths.config(), &serde_json::to_vec_pretty(&config)?)?;
812 let mut outcomes = Vec::new();
813 for (harness, current, next) in staged {
814 let Some(next) = next else {
815 outcomes.push((harness, Outcome::Unchanged));
816 continue;
817 };
818 if !paths.before(harness).exists() && !paths.absent_marker(harness).exists() {
821 match ¤t {
822 Some(bytes) => private_write(&paths.before(harness), bytes)?,
823 None => private_write(&paths.absent_marker(harness), b"")?,
824 }
825 }
826 write_atomic(paths.settings(harness), &next)?;
827 private_write(&paths.after_digest(harness), digest(&next).as_bytes())?;
828 outcomes.push((harness, Outcome::Written));
829 }
830 Ok(outcomes)
831}
832
833pub fn uninstall(paths: &Paths) -> Result<Vec<(Harness, Outcome)>> {
835 for harness in Harness::ALL {
836 refuse_symlink(paths.settings(harness))?;
837 }
838 let mut outcomes = Vec::new();
839 for harness in Harness::ALL {
840 let path = paths.settings(harness);
841 let current = read_optional(path)?;
842 let after = read_optional(&paths.after_digest(harness))?
843 .map(|b| String::from_utf8_lossy(&b).trim().to_string());
844 let before = read_optional(&paths.before(harness))?;
845 let was_absent = paths.absent_marker(harness).exists();
846 let untouched = matches!((¤t, &after), (Some(c), Some(a)) if digest(c) == *a);
847 let outcome = match ¤t {
848 Some(_) if untouched && was_absent => {
849 fs::remove_file(path).with_context(|| format!("remove {}", path.display()))?;
850 Outcome::Restored
851 }
852 Some(_) if untouched && before.is_some() => {
853 write_atomic(path, before.as_deref().unwrap())?;
854 Outcome::Restored
855 }
856 Some(bytes) if !configured(harness, Some(bytes))?.is_empty() => {
857 let stripped = strip(harness, bytes, path)?;
861 if before.as_deref() == Some(stripped.as_slice()) {
862 write_atomic(path, &stripped)?;
863 Outcome::Restored
864 } else if was_absent && is_empty_settings(harness, &stripped)? {
865 fs::remove_file(path)?;
866 Outcome::Restored
867 } else {
868 write_atomic(path, &stripped)?;
869 Outcome::Removed
870 }
871 }
872 _ => Outcome::NothingToRemove,
873 };
874 for leftover in [
875 paths.before(harness),
876 paths.absent_marker(harness),
877 paths.after_digest(harness),
878 ] {
879 if leftover.exists() {
880 fs::remove_file(&leftover)?;
881 }
882 }
883 outcomes.push((harness, outcome));
884 }
885 Ok(outcomes)
886}
887
888fn is_empty_settings(harness: Harness, bytes: &[u8]) -> Result<bool> {
889 Ok(match harness {
890 Harness::Claude => matches!(claude_parse(Some(bytes))?, J::Object(m) if m.is_empty()),
891 Harness::Codex => codex_parse(Some(bytes))?.as_table().is_empty(),
892 })
893}
894
895fn program(command: &str) -> Option<PathBuf> {
896 let rest = command.strip_prefix('\'')?;
897 let end = rest.find('\'')?;
898 Some(PathBuf::from(&rest[..end]))
899}
900
901fn executable(path: &Path) -> bool {
902 fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
903}
904
905pub fn verify(paths: &Paths) -> Vec<Finding> {
907 let mut findings = Vec::new();
908 for harness in Harness::ALL {
909 let subject = format!("{} query-first hooks", harness.name());
910 let path = paths.settings(harness);
911 let found = match read_optional(path).and_then(|b| configured(harness, b.as_deref())) {
912 Ok(found) => found,
913 Err(e) => {
914 findings.push(Finding {
915 level: Level::Fail,
916 subject,
917 detail: format!("cannot read {}: {e:#}", path.display()),
918 });
919 continue;
920 }
921 };
922 let missing: Vec<&str> = EVENTS
923 .iter()
924 .copied()
925 .filter(|e| !found.iter().any(|(event, _)| event == e))
926 .collect();
927 let broken: Vec<String> = found
928 .iter()
929 .filter(|(_, c)| !program(c).is_some_and(|p| executable(&p)))
930 .map(|(e, c)| {
931 format!(
932 "{e} runs {}",
933 program(c)
934 .map(|p| p.display().to_string())
935 .unwrap_or_else(|| c.clone())
936 )
937 })
938 .collect();
939 let duplicated = EVENTS
940 .iter()
941 .any(|e| found.iter().filter(|(event, _)| event == e).count() > 1);
942 let (level, detail) = if !broken.is_empty() {
943 (
944 Level::Fail,
945 format!(
946 "broken ({}, not executable); fix: tambo hooks install",
947 broken.join("; ")
948 ),
949 )
950 } else if missing.len() == EVENTS.len() {
951 (
952 Level::Warn,
953 format!(
954 "not installed in {}; fix: tambo hooks install",
955 path.display()
956 ),
957 )
958 } else if !missing.is_empty() || duplicated {
959 (
960 Level::Fail,
961 format!(
962 "incomplete or duplicated in {} (missing: {}); fix: tambo hooks install",
963 path.display(),
964 missing.join(", ")
965 ),
966 )
967 } else if harness == Harness::Codex
968 && !read_optional(path)
969 .ok()
970 .flatten()
971 .and_then(|b| codex_parse(Some(&b)).ok())
972 .is_some_and(|doc| codex_trusted(&doc, path))
973 {
974 (
975 Level::Warn,
976 "installed but not trusted yet, so Codex will not run them; fix: start `codex` and choose \"Trust all and continue\" (or \"Review hooks\" first)".to_string(),
977 )
978 } else {
979 (
980 Level::Ok,
981 format!(
982 "installed in {}{}",
983 path.display(),
984 last_firing(paths, harness)
985 ),
986 )
987 };
988 findings.push(Finding {
989 level,
990 subject,
991 detail,
992 });
993 }
994 findings
995}
996
997fn last_firing(paths: &Paths, harness: Harness) -> String {
998 let Ok(text) = fs::read_to_string(paths.usage()) else {
999 return "; not fired yet".into();
1000 };
1001 let count = text
1002 .lines()
1003 .filter_map(|l| serde_json::from_str::<Value>(l).ok())
1004 .filter(|v| v["harness"] == harness.name())
1005 .count();
1006 if count == 0 {
1007 "; not fired yet".into()
1008 } else {
1009 format!("; fired {count} times")
1010 }
1011}
1012
1013fn load_target(paths: &Paths) -> Target {
1016 let db = fs::read(paths.config())
1017 .ok()
1018 .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
1019 .and_then(|v| v["db"].as_str().map(PathBuf::from));
1020 Target { db }
1021}
1022
1023pub fn directive(
1025 event: &str,
1026 server: Option<&str>,
1027 target: &Target,
1028 crew: bool,
1029) -> (Option<String>, &'static str, Option<bool>) {
1030 if crew {
1031 return (None, "crew-managed", None);
1034 }
1035 let (mode, how, reachable) = match (server, &target.db) {
1036 (Some(url), _) => (
1037 "remote",
1038 format!(
1039 "Use the quipu MCP tool quipu_search {{\"query\":\"<subject>\"}} if it is registered, or:\n curl -s {url}/search -H 'content-type: application/json' -d '{{\"query\":\"<subject>\"}}'"
1040 ),
1041 None,
1042 ),
1043 (None, Some(db)) => (
1044 "local",
1045 format!(
1046 "Run: quipu search \"<subject>\" --db {}",
1047 quote(&db.display().to_string())
1048 ),
1049 Some(db.exists()),
1050 ),
1051 (None, None) => (
1052 "unconfigured",
1053 "No Quipu store is configured for these hooks; run `tambo hooks install` from your tambo work directory.".to_string(),
1054 Some(false),
1055 ),
1056 };
1057 let text = if event == "SessionStart" {
1058 let status = match reachable {
1059 Some(false) if target.db.is_some() => format!(
1060 "\nUNAVAILABLE: the store {} does not exist, so nothing can be looked up. That is not the same as \"nothing is known\".",
1061 target.db.as_ref().unwrap().display()
1062 ),
1063 _ => String::new(),
1064 };
1065 format!(
1066 "QUERY-FIRST (knowledge graph): before you start a task, ask Quipu what is already known about what you are about to touch (earlier incidents, decisions, where facts came from) instead of re-deriving it.\n {how}\nQuery first, grep second.{status}"
1067 )
1068 } else {
1069 format!(
1070 "Query-first reminder: check Quipu for what this prompt touches before acting. {how}"
1071 )
1072 };
1073 (Some(text), mode, reachable)
1074}
1075
1076pub fn run_hook(harness: Harness, event: &str) -> Result<()> {
1079 if !EVENTS.contains(&event) {
1080 bail!("unsupported query-first event {event:?}");
1081 }
1082 let mut input = Vec::new();
1083 let _ = std::io::stdin().take(1 << 20).read_to_end(&mut input);
1084 let session = serde_json::from_slice::<Value>(&input)
1085 .ok()
1086 .and_then(|v| v["session_id"].as_str().map(str::to_string));
1087 let paths = Paths::from_env()?;
1088 let target = load_target(&paths);
1089 let server = env::var("QUIPU_SERVER")
1090 .ok()
1091 .filter(|s| !s.trim().is_empty());
1092 let crew = env::var_os("SHANTY_AGENT").is_some() && env::var_os("SHANTY_ROOT").is_some();
1093 let (text, mode, reachable) = directive(event, server.as_deref(), &target, crew);
1094 if let Some(text) = text {
1095 println!("{text}");
1096 }
1097 let at = SystemTime::now()
1098 .duration_since(UNIX_EPOCH)
1099 .map(|d| d.as_secs_f64())
1100 .unwrap_or(0.0);
1101 let line = json!({"at":at,"harness":harness.name(),"event":event,"mode":mode,
1102 "reachable":reachable,"session":session,"printed":mode != "crew-managed"});
1103 if fs::create_dir_all(&paths.state).is_ok() {
1104 if let Ok(mut f) = fs::OpenOptions::new()
1105 .create(true)
1106 .append(true)
1107 .mode(0o600)
1108 .open(paths.usage())
1109 {
1110 let _ = writeln!(f, "{line}");
1111 }
1112 }
1113 Ok(())
1114}
1115
1116#[cfg(test)]
1117#[path = "harness_hooks_tests.rs"]
1118mod tests;