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 Window(WindowCommand),
65 WindowUnregister(WindowUnregisterCommand),
67}
68
69impl SessionsCommand {
70 pub async fn execute(self) -> Result<()> {
72 match self.command {
73 SessionsSubcommands::List(cmd) => cmd.execute().await,
74 SessionsSubcommands::Hook(cmd) => cmd.execute().await,
75 SessionsSubcommands::InstallHooks(cmd) => cmd.execute(),
76 SessionsSubcommands::UninstallHooks(cmd) => cmd.execute(),
77 SessionsSubcommands::Window(cmd) => cmd.execute().await,
78 SessionsSubcommands::WindowUnregister(cmd) => cmd.execute().await,
79 }
80 }
81}
82
83#[derive(Parser)]
87pub struct ListCommand {
88 #[arg(long, value_name = "PATH")]
90 pub socket: Option<PathBuf>,
91 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
93 pub output: TableOrJson,
94}
95
96impl ListCommand {
97 pub async fn execute(self) -> Result<()> {
99 let socket = server::resolve_socket(self.socket)?;
100 let result = call(&socket, "list", Value::Null).await?;
101 match self.output {
102 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
103 TableOrJson::Table => println!("{}", render_sessions(&result)),
104 }
105 Ok(())
106 }
107}
108
109#[derive(Parser)]
117pub struct WindowCommand {
118 #[arg(long, value_name = "KEY")]
120 pub key: String,
121 #[arg(long = "folder", value_name = "PATH")]
123 pub folders: Vec<PathBuf>,
124 #[arg(long, value_name = "N", default_value_t = 0)]
126 pub tabs: usize,
127 #[arg(long, value_name = "N", default_value_t = 0)]
129 pub terminals: usize,
130 #[arg(long, value_name = "PATH")]
132 pub socket: Option<PathBuf>,
133}
134
135impl WindowCommand {
136 pub async fn execute(self) -> Result<()> {
138 let socket = server::resolve_socket(self.socket)?;
139 let payload = json!({
140 "key": self.key,
141 "folders": self.folders,
142 "tabs": self.tabs,
143 "terminals": self.terminals,
144 });
145 call(&socket, "window", payload).await?;
146 println!("Reported window {}", self.key);
147 Ok(())
148 }
149}
150
151#[derive(Parser)]
154pub struct WindowUnregisterCommand {
155 #[arg(long, value_name = "KEY")]
157 pub key: String,
158 #[arg(long, value_name = "PATH")]
160 pub socket: Option<PathBuf>,
161}
162
163impl WindowUnregisterCommand {
164 pub async fn execute(self) -> Result<()> {
166 let socket = server::resolve_socket(self.socket)?;
167 let reply = call(&socket, "window-unregister", json!({ "key": self.key })).await?;
168 let removed = reply
169 .get("removed")
170 .and_then(Value::as_bool)
171 .unwrap_or(false);
172 println!("removed: {removed}");
173 Ok(())
174 }
175}
176
177#[derive(Parser)]
182pub struct HookCommand {
183 #[arg(long, value_name = "PATH")]
185 pub socket: Option<PathBuf>,
186}
187
188impl HookCommand {
189 pub async fn execute(self) -> Result<()> {
193 let mut input = String::new();
194 if std::io::stdin().read_to_string(&mut input).is_err() {
195 return Ok(());
196 }
197 self.report(&input).await;
198 Ok(())
199 }
200
201 async fn report(&self, input: &str) {
204 let Ok(hook) = serde_json::from_str::<HookPayload>(input) else {
205 return;
206 };
207 let Some((op, payload)) = hook.to_op() else {
208 return;
209 };
210 let Ok(socket) = server::resolve_socket(self.socket.clone()) else {
211 return;
212 };
213 let env = DaemonEnvelope::service(SERVICE, op, payload);
216 let _ = tokio::time::timeout(HOOK_TIMEOUT, DaemonClient::new(&socket).request(env)).await;
217 }
218}
219
220#[derive(Debug, Clone, Default, Deserialize)]
224struct HookPayload {
225 #[serde(default)]
226 session_id: Option<String>,
227 #[serde(default)]
228 transcript_path: Option<PathBuf>,
229 #[serde(default)]
230 cwd: Option<PathBuf>,
231 #[serde(default)]
232 hook_event_name: Option<String>,
233 #[serde(default)]
236 message: Option<String>,
237 #[serde(default)]
239 model: Option<String>,
240}
241
242impl HookPayload {
243 fn to_op(&self) -> Option<(&'static str, Value)> {
246 let session_id = self.session_id.clone().filter(|s| !s.trim().is_empty())?;
247 let event_name = self.hook_event_name.as_deref()?;
248 if event_name == "SessionEnd" {
249 let mut payload = json!({ "session_id": session_id });
250 if let Some(reason) = &self.message {
251 payload["reason"] = Value::String(reason.clone());
252 }
253 return Some(("end", payload));
254 }
255 let event = session_event_for(event_name, self.message.as_deref())?;
256 let request = ObserveRequest {
257 session_id,
258 cwd: self.cwd.clone(),
259 transcript_path: self.transcript_path.clone(),
260 event,
261 repo: None,
262 model: self.model.clone(),
263 };
264 Some(("observe", serde_json::to_value(request).ok()?))
265 }
266}
267
268fn session_event_for(event_name: &str, message: Option<&str>) -> Option<SessionEvent> {
272 Some(match event_name {
273 "SessionStart" => SessionEvent::SessionStart,
274 "UserPromptSubmit" => SessionEvent::UserPromptSubmit,
275 "PreToolUse" => SessionEvent::PreToolUse,
276 "PostToolUse" => SessionEvent::PostToolUse,
277 "Stop" => SessionEvent::Stop,
278 "Notification" => SessionEvent::Notification(classify_notification(message)),
279 _ => return None,
280 })
281}
282
283fn classify_notification(message: Option<&str>) -> NotificationKind {
288 let Some(message) = message else {
289 return NotificationKind::Other;
290 };
291 let lower = message.to_lowercase();
292 if lower.contains("permission") || lower.contains("approve") || lower.contains("allow") {
293 NotificationKind::PermissionPrompt
294 } else if lower.contains("waiting for your input")
295 || lower.contains("idle")
296 || lower.contains("needs your input")
297 {
298 NotificationKind::IdlePrompt
299 } else {
300 NotificationKind::Other
301 }
302}
303
304#[derive(Parser)]
308pub struct InstallHooksCommand {
309 #[arg(long, value_name = "PATH")]
312 pub settings: Option<PathBuf>,
313}
314
315impl InstallHooksCommand {
316 pub fn execute(self) -> Result<()> {
319 let path = settings_path(self.settings)?;
320 let mut settings = read_settings(&path)?;
321 let command = hook_command();
322 let added = merge_hooks(&mut settings, &command);
323 write_settings(&path, &settings)?;
324 if added == 0 {
325 println!(
326 "sessions hooks already installed in {} (no change)",
327 path.display()
328 );
329 } else {
330 println!(
331 "installed {added} sessions hook event(s) into {}\ncommand: {command}",
332 path.display()
333 );
334 }
335 Ok(())
336 }
337}
338
339#[derive(Parser)]
341pub struct UninstallHooksCommand {
342 #[arg(long, value_name = "PATH")]
345 pub settings: Option<PathBuf>,
346}
347
348impl UninstallHooksCommand {
349 pub fn execute(self) -> Result<()> {
352 let path = settings_path(self.settings)?;
353 if !path.exists() {
354 println!("no settings file at {} (nothing to remove)", path.display());
355 return Ok(());
356 }
357 let mut settings = read_settings(&path)?;
358 let removed = remove_hooks(&mut settings, &hook_command());
359 write_settings(&path, &settings)?;
360 println!(
361 "removed {removed} sessions hook entry(ies) from {}",
362 path.display()
363 );
364 Ok(())
365 }
366}
367
368const HOOK_EVENTS: &[(&str, bool)] = &[
373 ("SessionStart", false),
374 ("UserPromptSubmit", false),
375 ("PreToolUse", true),
376 ("PostToolUse", true),
377 ("Notification", false),
378 ("Stop", false),
379 ("SessionEnd", false),
380];
381
382fn hook_command() -> String {
387 match std::env::current_exe() {
388 Ok(exe) => format!("{} sessions hook", exe.display()),
389 Err(_) => "omni-dev sessions hook".to_string(),
390 }
391}
392
393fn settings_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
396 if let Some(path) = explicit {
397 return Ok(path);
398 }
399 if let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR") {
400 return Ok(PathBuf::from(dir).join("settings.json"));
401 }
402 let home = dirs::home_dir().context("could not resolve the home directory")?;
403 Ok(home.join(".claude").join("settings.json"))
404}
405
406fn read_settings(path: &Path) -> Result<Value> {
410 if !path.exists() {
411 return Ok(json!({}));
412 }
413 let text = std::fs::read_to_string(path)
414 .with_context(|| format!("failed to read {}", path.display()))?;
415 if text.trim().is_empty() {
416 return Ok(json!({}));
417 }
418 let value: Value = serde_json::from_str(&text).with_context(|| {
419 format!(
420 "{} is not valid JSON; refusing to overwrite it",
421 path.display()
422 )
423 })?;
424 if !value.is_object() {
425 bail!(
426 "{} is not a JSON object; refusing to overwrite it",
427 path.display()
428 );
429 }
430 Ok(value)
431}
432
433fn write_settings(path: &Path, settings: &Value) -> Result<()> {
436 if let Some(parent) = path.parent() {
437 std::fs::create_dir_all(parent)
438 .with_context(|| format!("failed to create {}", parent.display()))?;
439 }
440 let mut text = serde_json::to_string_pretty(settings)?;
441 text.push('\n');
442 std::fs::write(path, text).with_context(|| format!("failed to write {}", path.display()))?;
443 Ok(())
444}
445
446fn merge_hooks(settings: &mut Value, command: &str) -> usize {
452 let Some(root) = settings.as_object_mut() else {
455 return 0;
456 };
457 let hooks = root
458 .entry("hooks")
459 .or_insert_with(|| json!({}))
460 .as_object_mut();
461 let Some(hooks) = hooks else {
462 return 0;
465 };
466 let mut added = 0;
467 for (event, needs_matcher) in HOOK_EVENTS {
468 let groups = hooks
469 .entry((*event).to_string())
470 .or_insert_with(|| json!([]));
471 let Some(groups) = groups.as_array_mut() else {
472 continue;
473 };
474 if groups.iter().any(|g| group_has_command(g, command)) {
475 continue; }
477 groups.push(hook_group(command, *needs_matcher));
478 added += 1;
479 }
480 added
481}
482
483fn remove_hooks(settings: &mut Value, command: &str) -> usize {
487 let Some(root) = settings.as_object_mut() else {
488 return 0;
489 };
490 let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) else {
491 return 0;
492 };
493 let mut removed = 0;
494 let mut empty_events = Vec::new();
495 for (event, groups) in hooks.iter_mut() {
496 let Some(groups) = groups.as_array_mut() else {
497 continue;
498 };
499 for group in groups.iter_mut() {
500 if let Some(inner) = group.get_mut("hooks").and_then(Value::as_array_mut) {
501 let before = inner.len();
502 inner.retain(|h| !hook_has_command(h, command));
503 removed += before - inner.len();
504 }
505 }
506 groups.retain(|g| {
509 g.get("hooks")
510 .and_then(Value::as_array)
511 .map_or(true, |h| !h.is_empty())
512 });
513 if groups.is_empty() {
514 empty_events.push(event.clone());
515 }
516 }
517 for event in empty_events {
518 hooks.remove(&event);
519 }
520 removed
521}
522
523fn hook_group(command: &str, needs_matcher: bool) -> Value {
527 let mut group = json!({
528 "hooks": [{ "type": "command", "command": command }],
529 });
530 if needs_matcher {
531 group["matcher"] = Value::String("*".to_string());
532 }
533 group
534}
535
536fn group_has_command(group: &Value, command: &str) -> bool {
538 group
539 .get("hooks")
540 .and_then(Value::as_array)
541 .is_some_and(|hooks| hooks.iter().any(|h| hook_has_command(h, command)))
542}
543
544fn hook_has_command(hook: &Value, command: &str) -> bool {
546 hook.get("command").and_then(Value::as_str) == Some(command)
547}
548
549async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
554 let reply = DaemonClient::new(socket)
555 .request(DaemonEnvelope::service(SERVICE, op, payload))
556 .await?;
557 reply_payload(reply)
558}
559
560fn reply_payload(reply: DaemonReply) -> Result<Value> {
563 if reply.ok {
564 Ok(reply.payload)
565 } else {
566 bail!(
567 "daemon returned an error: {}",
568 reply.error.as_deref().unwrap_or("unknown error")
569 )
570 }
571}
572
573fn render_sessions(result: &Value) -> String {
577 let sessions = result
578 .get("sessions")
579 .and_then(Value::as_array)
580 .map(Vec::as_slice)
581 .unwrap_or_default();
582 if sessions.is_empty() {
583 return "No active Claude Code sessions.".to_string();
584 }
585 let mut out = format!(
587 "{:<13} {:<8} {:<20} {:>5} {}",
588 "STATE", "SOURCE", "REPO", "AGE", "CWD"
589 );
590 for session in sessions {
591 let state = state_display(session.get("state").and_then(Value::as_str).unwrap_or("-"));
592 let source = source_label(session);
593 let repo = sanitize(session.get("repo").and_then(Value::as_str).unwrap_or("-"));
594 let cwd = sanitize(session.get("cwd").and_then(Value::as_str).unwrap_or("-"));
595 let age = age_secs(session.get("last_seen").and_then(Value::as_str));
596 out.push_str(&format!(
597 "\n{state:<13} {source:<8} {repo:<20} {age:>4}s {cwd}"
598 ));
599 }
600 out
601}
602
603fn state_display(state: &str) -> String {
607 match state {
608 "waiting_for_permission" => "waiting-perm".to_string(),
609 "waiting_for_input" => "waiting-input".to_string(),
610 other => sanitize(other),
611 }
612}
613
614fn source_label(session: &Value) -> &'static str {
617 match session.pointer("/source/kind").and_then(Value::as_str) {
618 Some("vs_code") => "vscode",
619 _ => "terminal",
620 }
621}
622
623fn sanitize(s: &str) -> String {
627 s.chars().filter(|c| !c.is_control()).collect()
628}
629
630fn age_secs(ts: Option<&str>) -> i64 {
632 ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
633 .map_or(0, |t| {
634 (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
635 })
636}
637
638#[cfg(test)]
639#[allow(clippy::unwrap_used, clippy::expect_used)]
640mod tests {
641 use super::*;
642
643 #[derive(Parser)]
645 struct Wrapper {
646 #[command(subcommand)]
647 cmd: SessionsSubcommands,
648 }
649
650 fn parse(args: &[&str]) -> SessionsSubcommands {
651 let mut full = vec!["omni-dev"];
652 full.extend_from_slice(args);
653 Wrapper::try_parse_from(full).unwrap().cmd
654 }
655
656 #[test]
657 fn subcommands_parse() {
658 assert!(matches!(parse(&["list"]), SessionsSubcommands::List(_)));
659 assert!(matches!(parse(&["hook"]), SessionsSubcommands::Hook(_)));
660 assert!(matches!(
661 parse(&["install-hooks"]),
662 SessionsSubcommands::InstallHooks(_)
663 ));
664 assert!(matches!(
665 parse(&["uninstall-hooks"]),
666 SessionsSubcommands::UninstallHooks(_)
667 ));
668 }
669
670 #[test]
671 fn list_parses_flags() {
672 let cmd =
673 ListCommand::try_parse_from(["list", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
674 assert_eq!(cmd.output, TableOrJson::Json);
675 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
676 }
677
678 fn hook_op(json_str: &str) -> Option<(&'static str, Value)> {
681 serde_json::from_str::<HookPayload>(json_str)
682 .unwrap()
683 .to_op()
684 }
685
686 #[test]
687 fn hook_maps_lifecycle_events_to_observe() {
688 let (op, payload) = hook_op(
689 r#"{"session_id":"s1","cwd":"/p","transcript_path":"/t.jsonl","hook_event_name":"PreToolUse"}"#,
690 )
691 .unwrap();
692 assert_eq!(op, "observe");
693 assert_eq!(payload["session_id"], "s1");
694 assert_eq!(payload["cwd"], "/p");
695 assert_eq!(payload["event"], "pre_tool_use");
696 }
697
698 #[test]
699 fn hook_maps_session_start_and_stop() {
700 assert_eq!(
701 hook_op(r#"{"session_id":"s1","hook_event_name":"SessionStart"}"#)
702 .unwrap()
703 .1["event"],
704 "session_start"
705 );
706 assert_eq!(
707 hook_op(r#"{"session_id":"s1","hook_event_name":"Stop"}"#)
708 .unwrap()
709 .1["event"],
710 "stop"
711 );
712 }
713
714 #[test]
715 fn hook_maps_session_end_to_end_op() {
716 let (op, payload) =
717 hook_op(r#"{"session_id":"s1","hook_event_name":"SessionEnd","message":"exit"}"#)
718 .unwrap();
719 assert_eq!(op, "end");
720 assert_eq!(payload["session_id"], "s1");
721 assert_eq!(payload["reason"], "exit");
722 }
723
724 #[test]
725 fn hook_classifies_notifications() {
726 let permission = hook_op(
727 r#"{"session_id":"s1","hook_event_name":"Notification","message":"Claude needs your permission to use Bash"}"#,
728 )
729 .unwrap();
730 assert_eq!(permission.1["event"]["notification"], "permission_prompt");
731
732 let idle = hook_op(
733 r#"{"session_id":"s1","hook_event_name":"Notification","message":"Claude is waiting for your input"}"#,
734 )
735 .unwrap();
736 assert_eq!(idle.1["event"]["notification"], "idle_prompt");
737
738 let other = hook_op(
739 r#"{"session_id":"s1","hook_event_name":"Notification","message":"something else"}"#,
740 )
741 .unwrap();
742 assert_eq!(other.1["event"]["notification"], "other");
743 }
744
745 #[test]
746 fn hook_ignores_unknown_events_and_missing_session_id() {
747 assert!(hook_op(r#"{"hook_event_name":"Stop"}"#).is_none());
749 assert!(hook_op(r#"{"session_id":" ","hook_event_name":"Stop"}"#).is_none());
751 assert!(hook_op(r#"{"session_id":"s1","hook_event_name":"PreCompact"}"#).is_none());
753 assert!(hook_op("{}").is_none());
755 }
756
757 #[test]
758 fn classify_notification_covers_cases() {
759 assert_eq!(
760 classify_notification(Some("Please approve this")),
761 NotificationKind::PermissionPrompt
762 );
763 assert_eq!(
764 classify_notification(Some("Claude is idle")),
765 NotificationKind::IdlePrompt
766 );
767 assert_eq!(classify_notification(None), NotificationKind::Other);
768 }
769
770 #[test]
773 fn merge_hooks_is_idempotent_and_additive() {
774 let mut settings = json!({
776 "hooks": {
777 "PreToolUse": [
778 { "matcher": "Bash", "hooks": [{ "type": "command", "command": "other-tool" }] }
779 ]
780 },
781 "model": "sonnet"
782 });
783 let cmd = "/usr/bin/omni-dev sessions hook";
784 let added = merge_hooks(&mut settings, cmd);
785 assert_eq!(added, HOOK_EVENTS.len());
786
787 for (event, _) in HOOK_EVENTS {
789 let groups = settings["hooks"][event].as_array().unwrap();
790 assert!(
791 groups.iter().any(|g| group_has_command(g, cmd)),
792 "missing under {event}"
793 );
794 }
795 let pre = settings["hooks"]["PreToolUse"].as_array().unwrap();
796 assert!(pre.iter().any(|g| group_has_command(g, "other-tool")));
797 assert_eq!(settings["model"], "sonnet");
798
799 assert_eq!(merge_hooks(&mut settings, cmd), 0);
801 }
802
803 #[test]
804 fn merge_then_remove_round_trips_and_preserves_others() {
805 let mut settings = json!({
806 "hooks": {
807 "PreToolUse": [
808 { "matcher": "Bash", "hooks": [{ "type": "command", "command": "keep-me" }] }
809 ]
810 }
811 });
812 let cmd = "/usr/bin/omni-dev sessions hook";
813 merge_hooks(&mut settings, cmd);
814 let removed = remove_hooks(&mut settings, cmd);
815 assert_eq!(removed, HOOK_EVENTS.len());
816
817 for (event, _) in HOOK_EVENTS {
819 let empty = settings["hooks"]
820 .get(event)
821 .and_then(Value::as_array)
822 .map_or(true, |g| g.iter().all(|g| !group_has_command(g, cmd)));
823 assert!(empty, "our hook survived under {event}");
824 }
825 let pre = settings["hooks"]["PreToolUse"].as_array().unwrap();
827 assert!(pre.iter().any(|g| group_has_command(g, "keep-me")));
828 }
829
830 #[test]
831 fn remove_hooks_prunes_empty_events_entirely() {
832 let mut settings = json!({});
833 let cmd = "cmd sessions hook";
834 merge_hooks(&mut settings, cmd);
835 remove_hooks(&mut settings, cmd);
836 let hooks = settings["hooks"].as_object().unwrap();
838 assert!(hooks.is_empty(), "expected all events pruned: {hooks:?}");
839 }
840
841 #[test]
842 fn install_uninstall_via_files_round_trips() {
843 let tmp = tempfile::tempdir().unwrap();
844 let path = tmp.path().join("settings.json");
845 let mut settings = read_settings(&path).unwrap();
847 merge_hooks(&mut settings, "cmd sessions hook");
848 write_settings(&path, &settings).unwrap();
849 assert!(path.exists());
850
851 let reloaded = read_settings(&path).unwrap();
852 assert!(reloaded["hooks"]["Stop"].is_array());
853
854 let mut settings = read_settings(&path).unwrap();
855 remove_hooks(&mut settings, "cmd sessions hook");
856 write_settings(&path, &settings).unwrap();
857 let reloaded = read_settings(&path).unwrap();
858 assert!(reloaded["hooks"].as_object().unwrap().is_empty());
859 }
860
861 #[test]
862 fn read_settings_rejects_non_json() {
863 let tmp = tempfile::tempdir().unwrap();
864 let path = tmp.path().join("settings.json");
865 std::fs::write(&path, "not json {").unwrap();
866 let err = read_settings(&path).unwrap_err();
867 assert!(err.to_string().contains("not valid JSON"), "{err}");
868 }
869
870 #[test]
871 fn read_settings_handles_empty_and_non_object() {
872 let tmp = tempfile::tempdir().unwrap();
873 let path = tmp.path().join("settings.json");
874 std::fs::write(&path, " \n").unwrap();
876 assert_eq!(read_settings(&path).unwrap(), json!({}));
877 std::fs::write(&path, "[1, 2, 3]").unwrap();
879 let err = read_settings(&path).unwrap_err();
880 assert!(err.to_string().contains("not a JSON object"), "{err}");
881 }
882
883 #[test]
884 fn hook_command_targets_sessions_hook() {
885 assert!(hook_command().ends_with("sessions hook"));
886 }
887
888 #[test]
891 fn render_sessions_handles_empty() {
892 assert_eq!(
893 render_sessions(&json!({ "sessions": [] })),
894 "No active Claude Code sessions."
895 );
896 assert_eq!(
897 render_sessions(&json!({})),
898 "No active Claude Code sessions."
899 );
900 }
901
902 #[test]
903 fn render_sessions_renders_rows_and_source() {
904 let result = json!({ "sessions": [{
905 "session_id": "s1",
906 "state": "working",
907 "source": { "kind": "vs_code", "window_key": "w1" },
908 "repo": "omni-dev",
909 "cwd": "/home/me/omni-dev",
910 "last_seen": "2000-01-01T00:00:00Z",
911 }]});
912 let table = render_sessions(&result);
913 assert!(table.contains("working"), "{table}");
914 assert!(table.contains("vscode"), "{table}");
915 assert!(table.contains("omni-dev"), "{table}");
916 assert_eq!(table.lines().count(), 2, "{table}");
918 }
919
920 #[test]
921 fn render_sessions_strips_control_bytes() {
922 let result = json!({ "sessions": [{
923 "session_id": "s1",
924 "state": "wor\x1b[31mking",
925 "source": { "kind": "terminal" },
926 "repo": "ev\x07il",
927 "cwd": "/tmp/a\rb",
928 "last_seen": "2000-01-01T00:00:00Z",
929 }]});
930 let table = render_sessions(&result);
931 assert!(
932 !table.contains(|c: char| c.is_control() && c != '\n'),
933 "{table:?}"
934 );
935 assert_eq!(table.lines().count(), 2, "{table:?}");
937 }
938
939 #[test]
940 fn source_label_maps_kinds() {
941 assert_eq!(
942 source_label(&json!({ "source": { "kind": "vs_code", "window_key": "w" } })),
943 "vscode"
944 );
945 assert_eq!(
946 source_label(&json!({ "source": { "kind": "terminal" } })),
947 "terminal"
948 );
949 assert_eq!(source_label(&json!({})), "terminal");
950 }
951
952 #[test]
953 fn reply_payload_unwraps_ok_and_maps_errors() {
954 assert_eq!(
955 reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
956 json!({ "a": 1 })
957 );
958 let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
959 assert!(err.to_string().contains("boom"), "{err}");
960 }
961
962 #[test]
965 fn install_then_uninstall_command_execute_round_trips() {
966 let tmp = tempfile::tempdir().unwrap();
967 let path = tmp.path().join("settings.json");
968 InstallHooksCommand {
970 settings: Some(path.clone()),
971 }
972 .execute()
973 .unwrap();
974 assert!(read_settings(&path).unwrap()["hooks"]["Stop"].is_array());
975 InstallHooksCommand {
977 settings: Some(path.clone()),
978 }
979 .execute()
980 .unwrap();
981 UninstallHooksCommand {
983 settings: Some(path.clone()),
984 }
985 .execute()
986 .unwrap();
987 assert!(read_settings(&path).unwrap()["hooks"]
988 .as_object()
989 .unwrap()
990 .is_empty());
991 }
992
993 #[test]
994 fn uninstall_command_on_a_missing_file_is_a_noop() {
995 let tmp = tempfile::tempdir().unwrap();
996 let path = tmp.path().join("does-not-exist.json");
997 UninstallHooksCommand {
999 settings: Some(path.clone()),
1000 }
1001 .execute()
1002 .unwrap();
1003 assert!(!path.exists());
1004 }
1005
1006 #[tokio::test]
1007 async fn sessions_command_dispatches_to_subcommands() {
1008 let tmp = tempfile::tempdir().unwrap();
1010 let path = tmp.path().join("settings.json");
1011 SessionsCommand {
1012 command: SessionsSubcommands::InstallHooks(InstallHooksCommand {
1013 settings: Some(path.clone()),
1014 }),
1015 }
1016 .execute()
1017 .await
1018 .unwrap();
1019 SessionsCommand {
1020 command: SessionsSubcommands::UninstallHooks(UninstallHooksCommand {
1021 settings: Some(path.clone()),
1022 }),
1023 }
1024 .execute()
1025 .await
1026 .unwrap();
1027 }
1028
1029 #[tokio::test]
1030 async fn hook_report_is_silent_when_the_daemon_is_down() {
1031 let tmp = tempfile::tempdir_in("/tmp").unwrap();
1034 let sock = tmp.path().join("nope.sock");
1035 let cmd = HookCommand { socket: Some(sock) };
1036 cmd.report(r#"{"session_id":"s1","hook_event_name":"Stop"}"#)
1037 .await;
1038 cmd.report("not json").await;
1040 cmd.report(r#"{"hook_event_name":"Stop"}"#).await; }
1042
1043 fn fake_daemon(reply: Value) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<()>) {
1047 use futures::{SinkExt, StreamExt};
1048 use tokio::net::UnixListener;
1049 use tokio_util::codec::{Framed, LinesCodec};
1050
1051 let dir = tempfile::tempdir_in("/tmp").unwrap();
1052 let sock = dir.path().join("d.sock");
1053 let listener = UnixListener::bind(&sock).unwrap();
1054 let server = tokio::spawn(async move {
1055 let (stream, _) = listener.accept().await.unwrap();
1056 let mut framed = Framed::new(stream, LinesCodec::new());
1057 let _req = framed.next().await.unwrap().unwrap();
1058 framed
1059 .send(serde_json::to_string(&reply).unwrap())
1060 .await
1061 .unwrap();
1062 });
1063 (dir, sock, server)
1064 }
1065
1066 fn fake_daemon_capture(
1069 reply: Value,
1070 ) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<Value>) {
1071 use futures::{SinkExt, StreamExt};
1072 use tokio::net::UnixListener;
1073 use tokio_util::codec::{Framed, LinesCodec};
1074
1075 let dir = tempfile::tempdir_in("/tmp").unwrap();
1076 let sock = dir.path().join("d.sock");
1077 let listener = UnixListener::bind(&sock).unwrap();
1078 let server = tokio::spawn(async move {
1079 let (stream, _) = listener.accept().await.unwrap();
1080 let mut framed = Framed::new(stream, LinesCodec::new());
1081 let req = framed.next().await.unwrap().unwrap();
1082 framed
1083 .send(serde_json::to_string(&reply).unwrap())
1084 .await
1085 .unwrap();
1086 serde_json::from_str::<Value>(&req).unwrap()
1087 });
1088 (dir, sock, server)
1089 }
1090
1091 #[tokio::test]
1092 async fn list_command_execute_renders_from_a_socket() {
1093 let payload = json!({
1094 "ok": true,
1095 "payload": { "sessions": [{
1096 "session_id": "s1", "state": "working",
1097 "source": { "kind": "terminal" }, "repo": "omni-dev",
1098 "cwd": "/home/me/omni-dev", "last_seen": "2000-01-01T00:00:00Z"
1099 }]}
1100 });
1101 let (_dir, sock, server) = fake_daemon(payload.clone());
1104 SessionsCommand {
1105 command: SessionsSubcommands::List(ListCommand {
1106 socket: Some(sock),
1107 output: TableOrJson::Table,
1108 }),
1109 }
1110 .execute()
1111 .await
1112 .unwrap();
1113 server.await.unwrap();
1114
1115 let (_dir, sock, server) = fake_daemon(payload);
1117 ListCommand {
1118 socket: Some(sock),
1119 output: TableOrJson::Json,
1120 }
1121 .execute()
1122 .await
1123 .unwrap();
1124 server.await.unwrap();
1125 }
1126
1127 #[test]
1128 fn merge_and_remove_hooks_tolerate_malformed_shapes() {
1129 let cmd = "cmd sessions hook";
1130 assert_eq!(merge_hooks(&mut json!([]), cmd), 0);
1132 assert_eq!(remove_hooks(&mut json!([]), cmd), 0);
1133 assert_eq!(merge_hooks(&mut json!({ "hooks": 5 }), cmd), 0);
1135 assert_eq!(remove_hooks(&mut json!({}), cmd), 0);
1137 assert_eq!(merge_hooks(&mut json!({ "hooks": { "Stop": 5 } }), cmd), 6);
1139 assert_eq!(remove_hooks(&mut json!({ "hooks": { "Stop": 5 } }), cmd), 0);
1140 }
1141
1142 #[test]
1145 fn window_subcommands_route_and_require_key() {
1146 assert!(matches!(
1147 parse(&["window", "--key", "w1"]),
1148 SessionsSubcommands::Window(_)
1149 ));
1150 assert!(matches!(
1151 parse(&["window-unregister", "--key", "w1"]),
1152 SessionsSubcommands::WindowUnregister(_)
1153 ));
1154 assert!(WindowCommand::try_parse_from(["window"]).is_err());
1156 assert!(WindowUnregisterCommand::try_parse_from(["window-unregister"]).is_err());
1157 }
1158
1159 #[test]
1160 fn window_parses_counts_and_folders() {
1161 let cmd = WindowCommand::try_parse_from([
1162 "window",
1163 "--key",
1164 "w1",
1165 "--folder",
1166 "/a",
1167 "--folder",
1168 "/b",
1169 "--tabs",
1170 "2",
1171 "--terminals",
1172 "3",
1173 ])
1174 .unwrap();
1175 assert_eq!(cmd.key, "w1");
1176 assert_eq!(cmd.folders, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
1177 assert_eq!(cmd.tabs, 2);
1178 assert_eq!(cmd.terminals, 3);
1179 let cmd = WindowCommand::try_parse_from(["window", "--key", "w1"]).unwrap();
1181 assert_eq!(cmd.tabs, 0);
1182 assert_eq!(cmd.terminals, 0);
1183 }
1184
1185 #[tokio::test]
1186 async fn window_and_window_unregister_send_their_ops() {
1187 let (_dir, sock, server) =
1188 fake_daemon_capture(json!({ "ok": true, "payload": { "ok": true } }));
1189 SessionsCommand {
1192 command: SessionsSubcommands::Window(WindowCommand {
1193 key: "w1".to_string(),
1194 folders: vec![PathBuf::from("/a")],
1195 tabs: 1,
1196 terminals: 0,
1197 socket: Some(sock),
1198 }),
1199 }
1200 .execute()
1201 .await
1202 .unwrap();
1203 let req = server.await.unwrap();
1205 assert_eq!(req["op"], "window");
1206 assert_eq!(req["payload"]["key"], json!("w1"));
1207 assert_eq!(req["payload"]["folders"], json!(["/a"]));
1208 assert_eq!(req["payload"]["tabs"], json!(1));
1209 assert_eq!(req["payload"]["terminals"], json!(0));
1210
1211 let (_dir, sock, server) =
1214 fake_daemon_capture(json!({ "ok": true, "payload": { "removed": true } }));
1215 SessionsCommand {
1216 command: SessionsSubcommands::WindowUnregister(WindowUnregisterCommand {
1217 key: "w1".to_string(),
1218 socket: Some(sock),
1219 }),
1220 }
1221 .execute()
1222 .await
1223 .unwrap();
1224 let req = server.await.unwrap();
1225 assert_eq!(req["op"], "window-unregister");
1226 assert_eq!(req["payload"]["key"], json!("w1"));
1227 }
1228}