1use std::io::Read;
19use std::path::{Path, PathBuf};
20use std::time::Duration;
21
22use anyhow::{bail, Context, Result};
23use chrono::Utc;
24use clap::{Parser, Subcommand};
25use serde::Deserialize;
26use serde_json::{json, Value};
27
28use crate::cli::format::TableOrJson;
29use crate::daemon::client::DaemonClient;
30use crate::daemon::protocol::{DaemonEnvelope, DaemonReply};
31use crate::daemon::server;
32use crate::sessions::{NotificationKind, ObserveRequest, SessionEvent};
33
34const SERVICE: &str = "sessions";
36
37const HOOK_TIMEOUT: Duration = Duration::from_secs(2);
40
41#[derive(Parser)]
44pub struct SessionsCommand {
45 #[command(subcommand)]
47 pub command: SessionsSubcommands,
48}
49
50#[derive(Subcommand)]
52pub enum SessionsSubcommands {
53 List(ListCommand),
55 Hook(HookCommand),
58 InstallHooks(InstallHooksCommand),
61 UninstallHooks(UninstallHooksCommand),
63}
64
65impl SessionsCommand {
66 pub async fn execute(self) -> Result<()> {
68 match self.command {
69 SessionsSubcommands::List(cmd) => cmd.execute().await,
70 SessionsSubcommands::Hook(cmd) => cmd.execute().await,
71 SessionsSubcommands::InstallHooks(cmd) => cmd.execute(),
72 SessionsSubcommands::UninstallHooks(cmd) => cmd.execute(),
73 }
74 }
75}
76
77#[derive(Parser)]
81pub struct ListCommand {
82 #[arg(long, value_name = "PATH")]
84 pub socket: Option<PathBuf>,
85 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
87 pub output: TableOrJson,
88}
89
90impl ListCommand {
91 pub async fn execute(self) -> Result<()> {
93 let socket = server::resolve_socket(self.socket)?;
94 let result = call(&socket, "list", Value::Null).await?;
95 match self.output {
96 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
97 TableOrJson::Table => println!("{}", render_sessions(&result)),
98 }
99 Ok(())
100 }
101}
102
103#[derive(Parser)]
108pub struct HookCommand {
109 #[arg(long, value_name = "PATH")]
111 pub socket: Option<PathBuf>,
112}
113
114impl HookCommand {
115 pub async fn execute(self) -> Result<()> {
119 let mut input = String::new();
120 if std::io::stdin().read_to_string(&mut input).is_err() {
121 return Ok(());
122 }
123 self.report(&input).await;
124 Ok(())
125 }
126
127 async fn report(&self, input: &str) {
130 let Ok(hook) = serde_json::from_str::<HookPayload>(input) else {
131 return;
132 };
133 let Some((op, payload)) = hook.to_op() else {
134 return;
135 };
136 let Ok(socket) = server::resolve_socket(self.socket.clone()) else {
137 return;
138 };
139 let env = DaemonEnvelope::service(SERVICE, op, payload);
142 let _ = tokio::time::timeout(HOOK_TIMEOUT, DaemonClient::new(&socket).request(env)).await;
143 }
144}
145
146#[derive(Debug, Clone, Default, Deserialize)]
150struct HookPayload {
151 #[serde(default)]
152 session_id: Option<String>,
153 #[serde(default)]
154 transcript_path: Option<PathBuf>,
155 #[serde(default)]
156 cwd: Option<PathBuf>,
157 #[serde(default)]
158 hook_event_name: Option<String>,
159 #[serde(default)]
162 message: Option<String>,
163 #[serde(default)]
165 model: Option<String>,
166}
167
168impl HookPayload {
169 fn to_op(&self) -> Option<(&'static str, Value)> {
172 let session_id = self.session_id.clone().filter(|s| !s.trim().is_empty())?;
173 let event_name = self.hook_event_name.as_deref()?;
174 if event_name == "SessionEnd" {
175 let mut payload = json!({ "session_id": session_id });
176 if let Some(reason) = &self.message {
177 payload["reason"] = Value::String(reason.clone());
178 }
179 return Some(("end", payload));
180 }
181 let event = session_event_for(event_name, self.message.as_deref())?;
182 let request = ObserveRequest {
183 session_id,
184 cwd: self.cwd.clone(),
185 transcript_path: self.transcript_path.clone(),
186 event,
187 repo: None,
188 model: self.model.clone(),
189 };
190 Some(("observe", serde_json::to_value(request).ok()?))
191 }
192}
193
194fn session_event_for(event_name: &str, message: Option<&str>) -> Option<SessionEvent> {
198 Some(match event_name {
199 "SessionStart" => SessionEvent::SessionStart,
200 "UserPromptSubmit" => SessionEvent::UserPromptSubmit,
201 "PreToolUse" => SessionEvent::PreToolUse,
202 "PostToolUse" => SessionEvent::PostToolUse,
203 "Stop" => SessionEvent::Stop,
204 "Notification" => SessionEvent::Notification(classify_notification(message)),
205 _ => return None,
206 })
207}
208
209fn classify_notification(message: Option<&str>) -> NotificationKind {
214 let Some(message) = message else {
215 return NotificationKind::Other;
216 };
217 let lower = message.to_lowercase();
218 if lower.contains("permission") || lower.contains("approve") || lower.contains("allow") {
219 NotificationKind::PermissionPrompt
220 } else if lower.contains("waiting for your input")
221 || lower.contains("idle")
222 || lower.contains("needs your input")
223 {
224 NotificationKind::IdlePrompt
225 } else {
226 NotificationKind::Other
227 }
228}
229
230#[derive(Parser)]
234pub struct InstallHooksCommand {
235 #[arg(long, value_name = "PATH")]
238 pub settings: Option<PathBuf>,
239}
240
241impl InstallHooksCommand {
242 pub fn execute(self) -> Result<()> {
245 let path = settings_path(self.settings)?;
246 let mut settings = read_settings(&path)?;
247 let command = hook_command();
248 let added = merge_hooks(&mut settings, &command);
249 write_settings(&path, &settings)?;
250 if added == 0 {
251 println!(
252 "sessions hooks already installed in {} (no change)",
253 path.display()
254 );
255 } else {
256 println!(
257 "installed {added} sessions hook event(s) into {}\ncommand: {command}",
258 path.display()
259 );
260 }
261 Ok(())
262 }
263}
264
265#[derive(Parser)]
267pub struct UninstallHooksCommand {
268 #[arg(long, value_name = "PATH")]
271 pub settings: Option<PathBuf>,
272}
273
274impl UninstallHooksCommand {
275 pub fn execute(self) -> Result<()> {
278 let path = settings_path(self.settings)?;
279 if !path.exists() {
280 println!("no settings file at {} (nothing to remove)", path.display());
281 return Ok(());
282 }
283 let mut settings = read_settings(&path)?;
284 let removed = remove_hooks(&mut settings, &hook_command());
285 write_settings(&path, &settings)?;
286 println!(
287 "removed {removed} sessions hook entry(ies) from {}",
288 path.display()
289 );
290 Ok(())
291 }
292}
293
294const HOOK_EVENTS: &[(&str, bool)] = &[
299 ("SessionStart", false),
300 ("UserPromptSubmit", false),
301 ("PreToolUse", true),
302 ("PostToolUse", true),
303 ("Notification", false),
304 ("Stop", false),
305 ("SessionEnd", false),
306];
307
308fn hook_command() -> String {
313 match std::env::current_exe() {
314 Ok(exe) => format!("{} sessions hook", exe.display()),
315 Err(_) => "omni-dev sessions hook".to_string(),
316 }
317}
318
319fn settings_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
322 if let Some(path) = explicit {
323 return Ok(path);
324 }
325 if let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR") {
326 return Ok(PathBuf::from(dir).join("settings.json"));
327 }
328 let home = dirs::home_dir().context("could not resolve the home directory")?;
329 Ok(home.join(".claude").join("settings.json"))
330}
331
332fn read_settings(path: &Path) -> Result<Value> {
336 if !path.exists() {
337 return Ok(json!({}));
338 }
339 let text = std::fs::read_to_string(path)
340 .with_context(|| format!("failed to read {}", path.display()))?;
341 if text.trim().is_empty() {
342 return Ok(json!({}));
343 }
344 let value: Value = serde_json::from_str(&text).with_context(|| {
345 format!(
346 "{} is not valid JSON; refusing to overwrite it",
347 path.display()
348 )
349 })?;
350 if !value.is_object() {
351 bail!(
352 "{} is not a JSON object; refusing to overwrite it",
353 path.display()
354 );
355 }
356 Ok(value)
357}
358
359fn write_settings(path: &Path, settings: &Value) -> Result<()> {
362 if let Some(parent) = path.parent() {
363 std::fs::create_dir_all(parent)
364 .with_context(|| format!("failed to create {}", parent.display()))?;
365 }
366 let mut text = serde_json::to_string_pretty(settings)?;
367 text.push('\n');
368 std::fs::write(path, text).with_context(|| format!("failed to write {}", path.display()))?;
369 Ok(())
370}
371
372fn merge_hooks(settings: &mut Value, command: &str) -> usize {
378 let Some(root) = settings.as_object_mut() else {
381 return 0;
382 };
383 let hooks = root
384 .entry("hooks")
385 .or_insert_with(|| json!({}))
386 .as_object_mut();
387 let Some(hooks) = hooks else {
388 return 0;
391 };
392 let mut added = 0;
393 for (event, needs_matcher) in HOOK_EVENTS {
394 let groups = hooks
395 .entry((*event).to_string())
396 .or_insert_with(|| json!([]));
397 let Some(groups) = groups.as_array_mut() else {
398 continue;
399 };
400 if groups.iter().any(|g| group_has_command(g, command)) {
401 continue; }
403 groups.push(hook_group(command, *needs_matcher));
404 added += 1;
405 }
406 added
407}
408
409fn remove_hooks(settings: &mut Value, command: &str) -> usize {
413 let Some(root) = settings.as_object_mut() else {
414 return 0;
415 };
416 let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) else {
417 return 0;
418 };
419 let mut removed = 0;
420 let mut empty_events = Vec::new();
421 for (event, groups) in hooks.iter_mut() {
422 let Some(groups) = groups.as_array_mut() else {
423 continue;
424 };
425 for group in groups.iter_mut() {
426 if let Some(inner) = group.get_mut("hooks").and_then(Value::as_array_mut) {
427 let before = inner.len();
428 inner.retain(|h| !hook_has_command(h, command));
429 removed += before - inner.len();
430 }
431 }
432 groups.retain(|g| {
435 g.get("hooks")
436 .and_then(Value::as_array)
437 .map_or(true, |h| !h.is_empty())
438 });
439 if groups.is_empty() {
440 empty_events.push(event.clone());
441 }
442 }
443 for event in empty_events {
444 hooks.remove(&event);
445 }
446 removed
447}
448
449fn hook_group(command: &str, needs_matcher: bool) -> Value {
453 let mut group = json!({
454 "hooks": [{ "type": "command", "command": command }],
455 });
456 if needs_matcher {
457 group["matcher"] = Value::String("*".to_string());
458 }
459 group
460}
461
462fn group_has_command(group: &Value, command: &str) -> bool {
464 group
465 .get("hooks")
466 .and_then(Value::as_array)
467 .is_some_and(|hooks| hooks.iter().any(|h| hook_has_command(h, command)))
468}
469
470fn hook_has_command(hook: &Value, command: &str) -> bool {
472 hook.get("command").and_then(Value::as_str) == Some(command)
473}
474
475async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
480 let reply = DaemonClient::new(socket)
481 .request(DaemonEnvelope::service(SERVICE, op, payload))
482 .await?;
483 reply_payload(reply)
484}
485
486fn reply_payload(reply: DaemonReply) -> Result<Value> {
489 if reply.ok {
490 Ok(reply.payload)
491 } else {
492 bail!(
493 "daemon returned an error: {}",
494 reply.error.as_deref().unwrap_or("unknown error")
495 )
496 }
497}
498
499fn render_sessions(result: &Value) -> String {
503 let sessions = result
504 .get("sessions")
505 .and_then(Value::as_array)
506 .map(Vec::as_slice)
507 .unwrap_or_default();
508 if sessions.is_empty() {
509 return "No active Claude Code sessions.".to_string();
510 }
511 let mut out = format!(
513 "{:<13} {:<8} {:<20} {:>5} {}",
514 "STATE", "SOURCE", "REPO", "AGE", "CWD"
515 );
516 for session in sessions {
517 let state = state_display(session.get("state").and_then(Value::as_str).unwrap_or("-"));
518 let source = source_label(session);
519 let repo = sanitize(session.get("repo").and_then(Value::as_str).unwrap_or("-"));
520 let cwd = sanitize(session.get("cwd").and_then(Value::as_str).unwrap_or("-"));
521 let age = age_secs(session.get("last_seen").and_then(Value::as_str));
522 out.push_str(&format!(
523 "\n{state:<13} {source:<8} {repo:<20} {age:>4}s {cwd}"
524 ));
525 }
526 out
527}
528
529fn state_display(state: &str) -> String {
533 match state {
534 "waiting_for_permission" => "waiting-perm".to_string(),
535 "waiting_for_input" => "waiting-input".to_string(),
536 other => sanitize(other),
537 }
538}
539
540fn source_label(session: &Value) -> &'static str {
543 match session.pointer("/source/kind").and_then(Value::as_str) {
544 Some("vs_code") => "vscode",
545 _ => "terminal",
546 }
547}
548
549fn sanitize(s: &str) -> String {
553 s.chars().filter(|c| !c.is_control()).collect()
554}
555
556fn age_secs(ts: Option<&str>) -> i64 {
558 ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
559 .map_or(0, |t| {
560 (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
561 })
562}
563
564#[cfg(test)]
565#[allow(clippy::unwrap_used, clippy::expect_used)]
566mod tests {
567 use super::*;
568
569 #[derive(Parser)]
571 struct Wrapper {
572 #[command(subcommand)]
573 cmd: SessionsSubcommands,
574 }
575
576 fn parse(args: &[&str]) -> SessionsSubcommands {
577 let mut full = vec!["omni-dev"];
578 full.extend_from_slice(args);
579 Wrapper::try_parse_from(full).unwrap().cmd
580 }
581
582 #[test]
583 fn subcommands_parse() {
584 assert!(matches!(parse(&["list"]), SessionsSubcommands::List(_)));
585 assert!(matches!(parse(&["hook"]), SessionsSubcommands::Hook(_)));
586 assert!(matches!(
587 parse(&["install-hooks"]),
588 SessionsSubcommands::InstallHooks(_)
589 ));
590 assert!(matches!(
591 parse(&["uninstall-hooks"]),
592 SessionsSubcommands::UninstallHooks(_)
593 ));
594 }
595
596 #[test]
597 fn list_parses_flags() {
598 let cmd =
599 ListCommand::try_parse_from(["list", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
600 assert_eq!(cmd.output, TableOrJson::Json);
601 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
602 }
603
604 fn hook_op(json_str: &str) -> Option<(&'static str, Value)> {
607 serde_json::from_str::<HookPayload>(json_str)
608 .unwrap()
609 .to_op()
610 }
611
612 #[test]
613 fn hook_maps_lifecycle_events_to_observe() {
614 let (op, payload) = hook_op(
615 r#"{"session_id":"s1","cwd":"/p","transcript_path":"/t.jsonl","hook_event_name":"PreToolUse"}"#,
616 )
617 .unwrap();
618 assert_eq!(op, "observe");
619 assert_eq!(payload["session_id"], "s1");
620 assert_eq!(payload["cwd"], "/p");
621 assert_eq!(payload["event"], "pre_tool_use");
622 }
623
624 #[test]
625 fn hook_maps_session_start_and_stop() {
626 assert_eq!(
627 hook_op(r#"{"session_id":"s1","hook_event_name":"SessionStart"}"#)
628 .unwrap()
629 .1["event"],
630 "session_start"
631 );
632 assert_eq!(
633 hook_op(r#"{"session_id":"s1","hook_event_name":"Stop"}"#)
634 .unwrap()
635 .1["event"],
636 "stop"
637 );
638 }
639
640 #[test]
641 fn hook_maps_session_end_to_end_op() {
642 let (op, payload) =
643 hook_op(r#"{"session_id":"s1","hook_event_name":"SessionEnd","message":"exit"}"#)
644 .unwrap();
645 assert_eq!(op, "end");
646 assert_eq!(payload["session_id"], "s1");
647 assert_eq!(payload["reason"], "exit");
648 }
649
650 #[test]
651 fn hook_classifies_notifications() {
652 let permission = hook_op(
653 r#"{"session_id":"s1","hook_event_name":"Notification","message":"Claude needs your permission to use Bash"}"#,
654 )
655 .unwrap();
656 assert_eq!(permission.1["event"]["notification"], "permission_prompt");
657
658 let idle = hook_op(
659 r#"{"session_id":"s1","hook_event_name":"Notification","message":"Claude is waiting for your input"}"#,
660 )
661 .unwrap();
662 assert_eq!(idle.1["event"]["notification"], "idle_prompt");
663
664 let other = hook_op(
665 r#"{"session_id":"s1","hook_event_name":"Notification","message":"something else"}"#,
666 )
667 .unwrap();
668 assert_eq!(other.1["event"]["notification"], "other");
669 }
670
671 #[test]
672 fn hook_ignores_unknown_events_and_missing_session_id() {
673 assert!(hook_op(r#"{"hook_event_name":"Stop"}"#).is_none());
675 assert!(hook_op(r#"{"session_id":" ","hook_event_name":"Stop"}"#).is_none());
677 assert!(hook_op(r#"{"session_id":"s1","hook_event_name":"PreCompact"}"#).is_none());
679 assert!(hook_op("{}").is_none());
681 }
682
683 #[test]
684 fn classify_notification_covers_cases() {
685 assert_eq!(
686 classify_notification(Some("Please approve this")),
687 NotificationKind::PermissionPrompt
688 );
689 assert_eq!(
690 classify_notification(Some("Claude is idle")),
691 NotificationKind::IdlePrompt
692 );
693 assert_eq!(classify_notification(None), NotificationKind::Other);
694 }
695
696 #[test]
699 fn merge_hooks_is_idempotent_and_additive() {
700 let mut settings = json!({
702 "hooks": {
703 "PreToolUse": [
704 { "matcher": "Bash", "hooks": [{ "type": "command", "command": "other-tool" }] }
705 ]
706 },
707 "model": "sonnet"
708 });
709 let cmd = "/usr/bin/omni-dev sessions hook";
710 let added = merge_hooks(&mut settings, cmd);
711 assert_eq!(added, HOOK_EVENTS.len());
712
713 for (event, _) in HOOK_EVENTS {
715 let groups = settings["hooks"][event].as_array().unwrap();
716 assert!(
717 groups.iter().any(|g| group_has_command(g, cmd)),
718 "missing under {event}"
719 );
720 }
721 let pre = settings["hooks"]["PreToolUse"].as_array().unwrap();
722 assert!(pre.iter().any(|g| group_has_command(g, "other-tool")));
723 assert_eq!(settings["model"], "sonnet");
724
725 assert_eq!(merge_hooks(&mut settings, cmd), 0);
727 }
728
729 #[test]
730 fn merge_then_remove_round_trips_and_preserves_others() {
731 let mut settings = json!({
732 "hooks": {
733 "PreToolUse": [
734 { "matcher": "Bash", "hooks": [{ "type": "command", "command": "keep-me" }] }
735 ]
736 }
737 });
738 let cmd = "/usr/bin/omni-dev sessions hook";
739 merge_hooks(&mut settings, cmd);
740 let removed = remove_hooks(&mut settings, cmd);
741 assert_eq!(removed, HOOK_EVENTS.len());
742
743 for (event, _) in HOOK_EVENTS {
745 let empty = settings["hooks"]
746 .get(event)
747 .and_then(Value::as_array)
748 .map_or(true, |g| g.iter().all(|g| !group_has_command(g, cmd)));
749 assert!(empty, "our hook survived under {event}");
750 }
751 let pre = settings["hooks"]["PreToolUse"].as_array().unwrap();
753 assert!(pre.iter().any(|g| group_has_command(g, "keep-me")));
754 }
755
756 #[test]
757 fn remove_hooks_prunes_empty_events_entirely() {
758 let mut settings = json!({});
759 let cmd = "cmd sessions hook";
760 merge_hooks(&mut settings, cmd);
761 remove_hooks(&mut settings, cmd);
762 let hooks = settings["hooks"].as_object().unwrap();
764 assert!(hooks.is_empty(), "expected all events pruned: {hooks:?}");
765 }
766
767 #[test]
768 fn install_uninstall_via_files_round_trips() {
769 let tmp = tempfile::tempdir().unwrap();
770 let path = tmp.path().join("settings.json");
771 let mut settings = read_settings(&path).unwrap();
773 merge_hooks(&mut settings, "cmd sessions hook");
774 write_settings(&path, &settings).unwrap();
775 assert!(path.exists());
776
777 let reloaded = read_settings(&path).unwrap();
778 assert!(reloaded["hooks"]["Stop"].is_array());
779
780 let mut settings = read_settings(&path).unwrap();
781 remove_hooks(&mut settings, "cmd sessions hook");
782 write_settings(&path, &settings).unwrap();
783 let reloaded = read_settings(&path).unwrap();
784 assert!(reloaded["hooks"].as_object().unwrap().is_empty());
785 }
786
787 #[test]
788 fn read_settings_rejects_non_json() {
789 let tmp = tempfile::tempdir().unwrap();
790 let path = tmp.path().join("settings.json");
791 std::fs::write(&path, "not json {").unwrap();
792 let err = read_settings(&path).unwrap_err();
793 assert!(err.to_string().contains("not valid JSON"), "{err}");
794 }
795
796 #[test]
797 fn read_settings_handles_empty_and_non_object() {
798 let tmp = tempfile::tempdir().unwrap();
799 let path = tmp.path().join("settings.json");
800 std::fs::write(&path, " \n").unwrap();
802 assert_eq!(read_settings(&path).unwrap(), json!({}));
803 std::fs::write(&path, "[1, 2, 3]").unwrap();
805 let err = read_settings(&path).unwrap_err();
806 assert!(err.to_string().contains("not a JSON object"), "{err}");
807 }
808
809 #[test]
810 fn hook_command_targets_sessions_hook() {
811 assert!(hook_command().ends_with("sessions hook"));
812 }
813
814 #[test]
817 fn render_sessions_handles_empty() {
818 assert_eq!(
819 render_sessions(&json!({ "sessions": [] })),
820 "No active Claude Code sessions."
821 );
822 assert_eq!(
823 render_sessions(&json!({})),
824 "No active Claude Code sessions."
825 );
826 }
827
828 #[test]
829 fn render_sessions_renders_rows_and_source() {
830 let result = json!({ "sessions": [{
831 "session_id": "s1",
832 "state": "working",
833 "source": { "kind": "vs_code", "window_key": "w1" },
834 "repo": "omni-dev",
835 "cwd": "/home/me/omni-dev",
836 "last_seen": "2000-01-01T00:00:00Z",
837 }]});
838 let table = render_sessions(&result);
839 assert!(table.contains("working"), "{table}");
840 assert!(table.contains("vscode"), "{table}");
841 assert!(table.contains("omni-dev"), "{table}");
842 assert_eq!(table.lines().count(), 2, "{table}");
844 }
845
846 #[test]
847 fn render_sessions_strips_control_bytes() {
848 let result = json!({ "sessions": [{
849 "session_id": "s1",
850 "state": "wor\x1b[31mking",
851 "source": { "kind": "terminal" },
852 "repo": "ev\x07il",
853 "cwd": "/tmp/a\rb",
854 "last_seen": "2000-01-01T00:00:00Z",
855 }]});
856 let table = render_sessions(&result);
857 assert!(
858 !table.contains(|c: char| c.is_control() && c != '\n'),
859 "{table:?}"
860 );
861 assert_eq!(table.lines().count(), 2, "{table:?}");
863 }
864
865 #[test]
866 fn source_label_maps_kinds() {
867 assert_eq!(
868 source_label(&json!({ "source": { "kind": "vs_code", "window_key": "w" } })),
869 "vscode"
870 );
871 assert_eq!(
872 source_label(&json!({ "source": { "kind": "terminal" } })),
873 "terminal"
874 );
875 assert_eq!(source_label(&json!({})), "terminal");
876 }
877
878 #[test]
879 fn reply_payload_unwraps_ok_and_maps_errors() {
880 assert_eq!(
881 reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
882 json!({ "a": 1 })
883 );
884 let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
885 assert!(err.to_string().contains("boom"), "{err}");
886 }
887
888 #[test]
891 fn install_then_uninstall_command_execute_round_trips() {
892 let tmp = tempfile::tempdir().unwrap();
893 let path = tmp.path().join("settings.json");
894 InstallHooksCommand {
896 settings: Some(path.clone()),
897 }
898 .execute()
899 .unwrap();
900 assert!(read_settings(&path).unwrap()["hooks"]["Stop"].is_array());
901 InstallHooksCommand {
903 settings: Some(path.clone()),
904 }
905 .execute()
906 .unwrap();
907 UninstallHooksCommand {
909 settings: Some(path.clone()),
910 }
911 .execute()
912 .unwrap();
913 assert!(read_settings(&path).unwrap()["hooks"]
914 .as_object()
915 .unwrap()
916 .is_empty());
917 }
918
919 #[test]
920 fn uninstall_command_on_a_missing_file_is_a_noop() {
921 let tmp = tempfile::tempdir().unwrap();
922 let path = tmp.path().join("does-not-exist.json");
923 UninstallHooksCommand {
925 settings: Some(path.clone()),
926 }
927 .execute()
928 .unwrap();
929 assert!(!path.exists());
930 }
931
932 #[tokio::test]
933 async fn sessions_command_dispatches_to_subcommands() {
934 let tmp = tempfile::tempdir().unwrap();
936 let path = tmp.path().join("settings.json");
937 SessionsCommand {
938 command: SessionsSubcommands::InstallHooks(InstallHooksCommand {
939 settings: Some(path.clone()),
940 }),
941 }
942 .execute()
943 .await
944 .unwrap();
945 SessionsCommand {
946 command: SessionsSubcommands::UninstallHooks(UninstallHooksCommand {
947 settings: Some(path.clone()),
948 }),
949 }
950 .execute()
951 .await
952 .unwrap();
953 }
954
955 #[tokio::test]
956 async fn hook_report_is_silent_when_the_daemon_is_down() {
957 let tmp = tempfile::tempdir_in("/tmp").unwrap();
960 let sock = tmp.path().join("nope.sock");
961 let cmd = HookCommand { socket: Some(sock) };
962 cmd.report(r#"{"session_id":"s1","hook_event_name":"Stop"}"#)
963 .await;
964 cmd.report("not json").await;
966 cmd.report(r#"{"hook_event_name":"Stop"}"#).await; }
968
969 fn fake_daemon(reply: Value) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<()>) {
973 use futures::{SinkExt, StreamExt};
974 use tokio::net::UnixListener;
975 use tokio_util::codec::{Framed, LinesCodec};
976
977 let dir = tempfile::tempdir_in("/tmp").unwrap();
978 let sock = dir.path().join("d.sock");
979 let listener = UnixListener::bind(&sock).unwrap();
980 let server = tokio::spawn(async move {
981 let (stream, _) = listener.accept().await.unwrap();
982 let mut framed = Framed::new(stream, LinesCodec::new());
983 let _req = framed.next().await.unwrap().unwrap();
984 framed
985 .send(serde_json::to_string(&reply).unwrap())
986 .await
987 .unwrap();
988 });
989 (dir, sock, server)
990 }
991
992 #[tokio::test]
993 async fn list_command_execute_renders_from_a_socket() {
994 let payload = json!({
995 "ok": true,
996 "payload": { "sessions": [{
997 "session_id": "s1", "state": "working",
998 "source": { "kind": "terminal" }, "repo": "omni-dev",
999 "cwd": "/home/me/omni-dev", "last_seen": "2000-01-01T00:00:00Z"
1000 }]}
1001 });
1002 let (_dir, sock, server) = fake_daemon(payload.clone());
1005 SessionsCommand {
1006 command: SessionsSubcommands::List(ListCommand {
1007 socket: Some(sock),
1008 output: TableOrJson::Table,
1009 }),
1010 }
1011 .execute()
1012 .await
1013 .unwrap();
1014 server.await.unwrap();
1015
1016 let (_dir, sock, server) = fake_daemon(payload);
1018 ListCommand {
1019 socket: Some(sock),
1020 output: TableOrJson::Json,
1021 }
1022 .execute()
1023 .await
1024 .unwrap();
1025 server.await.unwrap();
1026 }
1027
1028 #[test]
1029 fn merge_and_remove_hooks_tolerate_malformed_shapes() {
1030 let cmd = "cmd sessions hook";
1031 assert_eq!(merge_hooks(&mut json!([]), cmd), 0);
1033 assert_eq!(remove_hooks(&mut json!([]), cmd), 0);
1034 assert_eq!(merge_hooks(&mut json!({ "hooks": 5 }), cmd), 0);
1036 assert_eq!(remove_hooks(&mut json!({}), cmd), 0);
1038 assert_eq!(merge_hooks(&mut json!({ "hooks": { "Stop": 5 } }), cmd), 6);
1040 assert_eq!(remove_hooks(&mut json!({ "hooks": { "Stop": 5 } }), cmd), 0);
1041 }
1042}