1use std::io::Read;
23use std::path::{Path, PathBuf};
24use std::time::Duration;
25
26use anyhow::{anyhow, bail, Context, Result};
27use chrono::Utc;
28use clap::{Parser, Subcommand};
29use serde::Deserialize;
30use serde_json::{json, Value};
31
32use crate::cli::format::TableOrJson;
33use crate::daemon::client::DaemonClient;
34use crate::daemon::paths;
35use crate::daemon::protocol::{DaemonEnvelope, DaemonReply};
36use crate::daemon::server;
37use crate::sessions::{NotificationKind, ObserveRequest, SessionEvent};
38
39const SERVICE: &str = "sessions";
41
42const HOOK_TIMEOUT: Duration = Duration::from_secs(2);
45
46#[derive(Parser)]
49pub struct SessionsCommand {
50 #[command(subcommand)]
52 pub command: SessionsSubcommands,
53}
54
55#[derive(Subcommand)]
57pub enum SessionsSubcommands {
58 List(ListCommand),
60 Hook(HookCommand),
63 InstallHooks(InstallHooksCommand),
66 UninstallHooks(UninstallHooksCommand),
68 InstallWrapper(InstallWrapperCommand),
71 UninstallWrapper(UninstallWrapperCommand),
73 Window(WindowCommand),
75 WindowUnregister(WindowUnregisterCommand),
77}
78
79impl SessionsCommand {
80 pub async fn execute(self) -> Result<()> {
82 match self.command {
83 SessionsSubcommands::List(cmd) => cmd.execute().await,
84 SessionsSubcommands::Hook(cmd) => cmd.execute().await,
85 SessionsSubcommands::InstallHooks(cmd) => cmd.execute(),
86 SessionsSubcommands::UninstallHooks(cmd) => cmd.execute(),
87 SessionsSubcommands::InstallWrapper(cmd) => cmd.execute(),
88 SessionsSubcommands::UninstallWrapper(cmd) => cmd.execute(),
89 SessionsSubcommands::Window(cmd) => cmd.execute().await,
90 SessionsSubcommands::WindowUnregister(cmd) => cmd.execute().await,
91 }
92 }
93}
94
95#[derive(Parser)]
99pub struct ListCommand {
100 #[arg(long, value_name = "PATH")]
102 pub socket: Option<PathBuf>,
103 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
105 pub output: TableOrJson,
106}
107
108impl ListCommand {
109 pub async fn execute(self) -> Result<()> {
111 let socket = server::resolve_socket(self.socket)?;
112 let result = call(&socket, "list", Value::Null).await?;
113 match self.output {
114 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
115 TableOrJson::Table => println!("{}", render_sessions(&result)),
116 }
117 Ok(())
118 }
119}
120
121#[derive(Parser)]
129pub struct WindowCommand {
130 #[arg(long, value_name = "KEY")]
132 pub key: String,
133 #[arg(long = "folder", value_name = "PATH")]
135 pub folders: Vec<PathBuf>,
136 #[arg(long, value_name = "N", default_value_t = 0)]
138 pub tabs: usize,
139 #[arg(long, value_name = "N", default_value_t = 0)]
141 pub terminals: usize,
142 #[arg(long, value_name = "PATH")]
144 pub socket: Option<PathBuf>,
145}
146
147impl WindowCommand {
148 pub async fn execute(self) -> Result<()> {
150 let socket = server::resolve_socket(self.socket)?;
151 let payload = json!({
152 "key": self.key,
153 "folders": self.folders,
154 "tabs": self.tabs,
155 "terminals": self.terminals,
156 });
157 call(&socket, "window", payload).await?;
158 println!("Reported window {}", self.key);
159 Ok(())
160 }
161}
162
163#[derive(Parser)]
166pub struct WindowUnregisterCommand {
167 #[arg(long, value_name = "KEY")]
169 pub key: String,
170 #[arg(long, value_name = "PATH")]
172 pub socket: Option<PathBuf>,
173}
174
175impl WindowUnregisterCommand {
176 pub async fn execute(self) -> Result<()> {
178 let socket = server::resolve_socket(self.socket)?;
179 let reply = call(&socket, "window-unregister", json!({ "key": self.key })).await?;
180 let removed = reply
181 .get("removed")
182 .and_then(Value::as_bool)
183 .unwrap_or(false);
184 println!("removed: {removed}");
185 Ok(())
186 }
187}
188
189#[derive(Parser)]
194pub struct HookCommand {
195 #[arg(long, value_name = "PATH")]
197 pub socket: Option<PathBuf>,
198}
199
200impl HookCommand {
201 pub async fn execute(self) -> Result<()> {
205 let mut input = String::new();
206 if std::io::stdin().read_to_string(&mut input).is_err() {
207 return Ok(());
208 }
209 self.report(&input).await;
210 Ok(())
211 }
212
213 async fn report(&self, input: &str) {
216 let Ok(hook) = serde_json::from_str::<HookPayload>(input) else {
217 return;
218 };
219 let Some((op, payload)) = hook.to_op() else {
220 return;
221 };
222 let Ok(socket) = server::resolve_socket(self.socket.clone()) else {
223 return;
224 };
225 let env = DaemonEnvelope::service(SERVICE, op, payload);
228 let _ = tokio::time::timeout(HOOK_TIMEOUT, DaemonClient::new(&socket).request(env)).await;
229 }
230}
231
232#[derive(Debug, Clone, Default, Deserialize)]
236struct HookPayload {
237 #[serde(default)]
238 session_id: Option<String>,
239 #[serde(default)]
240 transcript_path: Option<PathBuf>,
241 #[serde(default)]
242 cwd: Option<PathBuf>,
243 #[serde(default)]
244 hook_event_name: Option<String>,
245 #[serde(default)]
248 message: Option<String>,
249 #[serde(default)]
251 model: Option<String>,
252}
253
254impl HookPayload {
255 fn to_op(&self) -> Option<(&'static str, Value)> {
258 let session_id = self.session_id.clone().filter(|s| !s.trim().is_empty())?;
259 let event_name = self.hook_event_name.as_deref()?;
260 if event_name == "SessionEnd" {
261 let mut payload = json!({ "session_id": session_id });
262 if let Some(reason) = &self.message {
263 payload["reason"] = Value::String(reason.clone());
264 }
265 return Some(("end", payload));
266 }
267 let event = session_event_for(event_name, self.message.as_deref())?;
268 let request = ObserveRequest {
269 session_id,
270 cwd: self.cwd.clone(),
271 transcript_path: self.transcript_path.clone(),
272 event,
273 repo: None,
274 model: self.model.clone(),
275 };
276 Some(("observe", serde_json::to_value(request).ok()?))
277 }
278}
279
280fn session_event_for(event_name: &str, message: Option<&str>) -> Option<SessionEvent> {
284 Some(match event_name {
285 "SessionStart" => SessionEvent::SessionStart,
286 "UserPromptSubmit" => SessionEvent::UserPromptSubmit,
287 "PreToolUse" => SessionEvent::PreToolUse,
288 "PostToolUse" => SessionEvent::PostToolUse,
289 "Stop" => SessionEvent::Stop,
290 "Notification" => SessionEvent::Notification(classify_notification(message)),
291 _ => return None,
292 })
293}
294
295fn classify_notification(message: Option<&str>) -> NotificationKind {
300 let Some(message) = message else {
301 return NotificationKind::Other;
302 };
303 let lower = message.to_lowercase();
304 if lower.contains("permission") || lower.contains("approve") || lower.contains("allow") {
305 NotificationKind::PermissionPrompt
306 } else if lower.contains("waiting for your input")
307 || lower.contains("idle")
308 || lower.contains("needs your input")
309 {
310 NotificationKind::IdlePrompt
311 } else {
312 NotificationKind::Other
313 }
314}
315
316#[derive(Parser)]
320pub struct InstallHooksCommand {
321 #[arg(long, value_name = "PATH")]
324 pub settings: Option<PathBuf>,
325}
326
327impl InstallHooksCommand {
328 pub fn execute(self) -> Result<()> {
331 let path = settings_path(self.settings)?;
332 let mut settings = read_settings(&path)?;
333 let command = hook_command();
334 let added = merge_hooks(&mut settings, &command);
335 write_settings(&path, &settings)?;
336 if added == 0 {
337 println!(
338 "sessions hooks already installed in {} (no change)",
339 path.display()
340 );
341 } else {
342 println!(
343 "installed {added} sessions hook event(s) into {}\ncommand: {command}",
344 path.display()
345 );
346 }
347 Ok(())
348 }
349}
350
351#[derive(Parser)]
353pub struct UninstallHooksCommand {
354 #[arg(long, value_name = "PATH")]
357 pub settings: Option<PathBuf>,
358}
359
360impl UninstallHooksCommand {
361 pub fn execute(self) -> Result<()> {
364 let path = settings_path(self.settings)?;
365 if !path.exists() {
366 println!("no settings file at {} (nothing to remove)", path.display());
367 return Ok(());
368 }
369 let mut settings = read_settings(&path)?;
370 let removed = remove_hooks(&mut settings, &hook_command());
371 write_settings(&path, &settings)?;
372 println!(
373 "removed {removed} sessions hook entry(ies) from {}",
374 path.display()
375 );
376 Ok(())
377 }
378}
379
380const WRAPPER_SETTING_KEY: &str = "claudeCode.claudeProcessWrapper";
385
386const SHIM_NAME: &str = "claude-wrap";
388
389#[derive(Parser)]
391pub struct InstallWrapperCommand {
392 #[arg(long, value_name = "PATH")]
395 pub settings: Option<PathBuf>,
396
397 #[arg(long, value_name = "PATH")]
399 pub shim: Option<PathBuf>,
400}
401
402impl InstallWrapperCommand {
403 pub fn execute(self) -> Result<()> {
406 let shim = shim_path(self.shim)?;
407 write_shim(&shim)?;
408 let path = vscode_settings_path(self.settings)?;
409 let target = shim.display().to_string();
410
411 let mut settings =
412 read_settings(&path).map_err(|error| manual_setup_hint(&error, &target))?;
413 let changed = set_wrapper(&mut settings, &target);
414 write_settings(&path, &settings)?;
415
416 println!("wrapper shim: {target}");
417 if changed {
418 println!("set {WRAPPER_SETTING_KEY} in {}", path.display());
419 println!("reload VS Code; Claude tabs opened after that report their exact state");
420 } else {
421 println!(
422 "{WRAPPER_SETTING_KEY} already points there in {} (no change)",
423 path.display()
424 );
425 }
426 Ok(())
427 }
428}
429
430#[derive(Parser)]
432pub struct UninstallWrapperCommand {
433 #[arg(long, value_name = "PATH")]
436 pub settings: Option<PathBuf>,
437
438 #[arg(long, value_name = "PATH")]
441 pub shim: Option<PathBuf>,
442}
443
444impl UninstallWrapperCommand {
445 pub fn execute(self) -> Result<()> {
448 let shim = shim_path(self.shim)?;
449 let target = shim.display().to_string();
450 let path = vscode_settings_path(self.settings)?;
451
452 if path.exists() {
453 let mut settings =
454 read_settings(&path).map_err(|error| manual_setup_hint(&error, &target))?;
455 if clear_wrapper(&mut settings, &target) {
456 write_settings(&path, &settings)?;
457 println!("cleared {WRAPPER_SETTING_KEY} in {}", path.display());
458 } else {
459 println!(
460 "{WRAPPER_SETTING_KEY} in {} does not point at our shim; left alone",
461 path.display()
462 );
463 }
464 } else {
465 println!("no settings file at {} (nothing to clear)", path.display());
466 }
467
468 if shim.exists() {
469 std::fs::remove_file(&shim)
470 .with_context(|| format!("failed to remove {}", shim.display()))?;
471 println!("removed {target}");
472 }
473 Ok(())
474 }
475}
476
477fn shim_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
480 match explicit {
481 Some(path) => Ok(path),
482 None => Ok(paths::runtime_dir()?.join(SHIM_NAME)),
483 }
484}
485
486fn vscode_settings_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
492 if let Some(path) = explicit {
493 return Ok(path);
494 }
495 let base = dirs::config_dir().context("could not determine the user config directory")?;
496 Ok(base.join("Code").join("User").join("settings.json"))
497}
498
499fn shim_contents() -> String {
506 let exe = std::env::current_exe()
507 .map_or_else(|_| "omni-dev".to_string(), |exe| exe.display().to_string());
508 format!("#!/bin/sh\nexec \"{exe}\" claude-wrap -- \"$@\"\n")
509}
510
511fn write_shim(path: &Path) -> Result<()> {
513 use std::os::unix::fs::PermissionsExt;
514
515 if let Some(parent) = path.parent() {
516 paths::ensure_dir_0700(parent)?;
517 }
518 std::fs::write(path, shim_contents())
519 .with_context(|| format!("failed to write {}", path.display()))?;
520 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
521 .with_context(|| format!("failed to make {} executable", path.display()))
522}
523
524fn set_wrapper(settings: &mut Value, shim: &str) -> bool {
526 let Some(root) = settings.as_object_mut() else {
527 return false;
528 };
529 if root.get(WRAPPER_SETTING_KEY).and_then(Value::as_str) == Some(shim) {
530 return false;
531 }
532 root.insert(WRAPPER_SETTING_KEY.to_string(), json!(shim));
533 true
534}
535
536fn clear_wrapper(settings: &mut Value, shim: &str) -> bool {
539 let Some(root) = settings.as_object_mut() else {
540 return false;
541 };
542 if root.get(WRAPPER_SETTING_KEY).and_then(Value::as_str) != Some(shim) {
543 return false;
544 }
545 root.remove(WRAPPER_SETTING_KEY);
546 true
547}
548
549fn manual_setup_hint(error: &anyhow::Error, shim: &str) -> anyhow::Error {
556 anyhow!(
557 "{error:#}\n\n\
558 VS Code settings files may contain comments or trailing commas, which cannot be \
559 rewritten safely. The shim is installed, so add this line by hand instead:\n\n \
560 \"{WRAPPER_SETTING_KEY}\": \"{shim}\""
561 )
562}
563
564const HOOK_EVENTS: &[(&str, bool)] = &[
569 ("SessionStart", false),
570 ("UserPromptSubmit", false),
571 ("PreToolUse", true),
572 ("PostToolUse", true),
573 ("Notification", false),
574 ("Stop", false),
575 ("SessionEnd", false),
576];
577
578fn hook_command() -> String {
583 match std::env::current_exe() {
584 Ok(exe) => format!("{} sessions hook", exe.display()),
585 Err(_) => "omni-dev sessions hook".to_string(),
586 }
587}
588
589fn settings_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
592 if let Some(path) = explicit {
593 return Ok(path);
594 }
595 if let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR") {
596 return Ok(PathBuf::from(dir).join("settings.json"));
597 }
598 let home = dirs::home_dir().context("could not resolve the home directory")?;
599 Ok(home.join(".claude").join("settings.json"))
600}
601
602fn read_settings(path: &Path) -> Result<Value> {
606 if !path.exists() {
607 return Ok(json!({}));
608 }
609 let text = std::fs::read_to_string(path)
610 .with_context(|| format!("failed to read {}", path.display()))?;
611 if text.trim().is_empty() {
612 return Ok(json!({}));
613 }
614 let value: Value = serde_json::from_str(&text).with_context(|| {
615 format!(
616 "{} is not valid JSON; refusing to overwrite it",
617 path.display()
618 )
619 })?;
620 if !value.is_object() {
621 bail!(
622 "{} is not a JSON object; refusing to overwrite it",
623 path.display()
624 );
625 }
626 Ok(value)
627}
628
629fn write_settings(path: &Path, settings: &Value) -> Result<()> {
632 if let Some(parent) = path.parent() {
633 std::fs::create_dir_all(parent)
634 .with_context(|| format!("failed to create {}", parent.display()))?;
635 }
636 let mut text = serde_json::to_string_pretty(settings)?;
637 text.push('\n');
638 std::fs::write(path, text).with_context(|| format!("failed to write {}", path.display()))?;
639 Ok(())
640}
641
642fn merge_hooks(settings: &mut Value, command: &str) -> usize {
648 let Some(root) = settings.as_object_mut() else {
651 return 0;
652 };
653 let hooks = root
654 .entry("hooks")
655 .or_insert_with(|| json!({}))
656 .as_object_mut();
657 let Some(hooks) = hooks else {
658 return 0;
661 };
662 let mut added = 0;
663 for (event, needs_matcher) in HOOK_EVENTS {
664 let groups = hooks
665 .entry((*event).to_string())
666 .or_insert_with(|| json!([]));
667 let Some(groups) = groups.as_array_mut() else {
668 continue;
669 };
670 if groups.iter().any(|g| group_has_command(g, command)) {
671 continue; }
673 groups.push(hook_group(command, *needs_matcher));
674 added += 1;
675 }
676 added
677}
678
679fn remove_hooks(settings: &mut Value, command: &str) -> usize {
683 let Some(root) = settings.as_object_mut() else {
684 return 0;
685 };
686 let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) else {
687 return 0;
688 };
689 let mut removed = 0;
690 let mut empty_events = Vec::new();
691 for (event, groups) in hooks.iter_mut() {
692 let Some(groups) = groups.as_array_mut() else {
693 continue;
694 };
695 for group in groups.iter_mut() {
696 if let Some(inner) = group.get_mut("hooks").and_then(Value::as_array_mut) {
697 let before = inner.len();
698 inner.retain(|h| !hook_has_command(h, command));
699 removed += before - inner.len();
700 }
701 }
702 groups.retain(|g| {
705 g.get("hooks")
706 .and_then(Value::as_array)
707 .map_or(true, |h| !h.is_empty())
708 });
709 if groups.is_empty() {
710 empty_events.push(event.clone());
711 }
712 }
713 for event in empty_events {
714 hooks.remove(&event);
715 }
716 removed
717}
718
719fn hook_group(command: &str, needs_matcher: bool) -> Value {
723 let mut group = json!({
724 "hooks": [{ "type": "command", "command": command }],
725 });
726 if needs_matcher {
727 group["matcher"] = Value::String("*".to_string());
728 }
729 group
730}
731
732fn group_has_command(group: &Value, command: &str) -> bool {
734 group
735 .get("hooks")
736 .and_then(Value::as_array)
737 .is_some_and(|hooks| hooks.iter().any(|h| hook_has_command(h, command)))
738}
739
740fn hook_has_command(hook: &Value, command: &str) -> bool {
742 hook.get("command").and_then(Value::as_str) == Some(command)
743}
744
745async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
750 let reply = DaemonClient::new(socket)
751 .request(DaemonEnvelope::service(SERVICE, op, payload))
752 .await?;
753 reply_payload(reply)
754}
755
756fn reply_payload(reply: DaemonReply) -> Result<Value> {
759 if reply.ok {
760 Ok(reply.payload)
761 } else {
762 bail!(
763 "daemon returned an error: {}",
764 reply.error.as_deref().unwrap_or("unknown error")
765 )
766 }
767}
768
769fn render_sessions(result: &Value) -> String {
773 let sessions = result
774 .get("sessions")
775 .and_then(Value::as_array)
776 .map(Vec::as_slice)
777 .unwrap_or_default();
778 if sessions.is_empty() {
779 return "No active Claude Code sessions.".to_string();
780 }
781 let mut out = format!(
783 "{:<13} {:<8} {:<20} {:>5} {}",
784 "STATE", "SOURCE", "REPO", "AGE", "CWD"
785 );
786 for session in sessions {
787 let state = state_display(session.get("state").and_then(Value::as_str).unwrap_or("-"));
788 let source = source_label(session);
789 let repo = sanitize(session.get("repo").and_then(Value::as_str).unwrap_or("-"));
790 let cwd = sanitize(session.get("cwd").and_then(Value::as_str).unwrap_or("-"));
791 let age = age_secs(session.get("last_seen").and_then(Value::as_str));
792 out.push_str(&format!(
793 "\n{state:<13} {source:<8} {repo:<20} {age:>4}s {cwd}"
794 ));
795 }
796 out
797}
798
799fn state_display(state: &str) -> String {
803 match state {
804 "waiting_for_permission" => "waiting-perm".to_string(),
805 "waiting_for_input" => "waiting-input".to_string(),
806 other => sanitize(other),
807 }
808}
809
810fn source_label(session: &Value) -> &'static str {
813 match session.pointer("/source/kind").and_then(Value::as_str) {
814 Some("vs_code") => "vscode",
815 _ => "terminal",
816 }
817}
818
819fn sanitize(s: &str) -> String {
823 s.chars().filter(|c| !c.is_control()).collect()
824}
825
826fn age_secs(ts: Option<&str>) -> i64 {
828 ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
829 .map_or(0, |t| {
830 (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
831 })
832}
833
834#[cfg(test)]
835#[allow(clippy::unwrap_used, clippy::expect_used)]
836mod tests {
837 use super::*;
838
839 #[derive(Parser)]
841 struct Wrapper {
842 #[command(subcommand)]
843 cmd: SessionsSubcommands,
844 }
845
846 fn parse(args: &[&str]) -> SessionsSubcommands {
847 let mut full = vec!["omni-dev"];
848 full.extend_from_slice(args);
849 Wrapper::try_parse_from(full).unwrap().cmd
850 }
851
852 #[test]
853 fn subcommands_parse() {
854 assert!(matches!(parse(&["list"]), SessionsSubcommands::List(_)));
855 assert!(matches!(parse(&["hook"]), SessionsSubcommands::Hook(_)));
856 assert!(matches!(
857 parse(&["install-hooks"]),
858 SessionsSubcommands::InstallHooks(_)
859 ));
860 assert!(matches!(
861 parse(&["uninstall-hooks"]),
862 SessionsSubcommands::UninstallHooks(_)
863 ));
864 assert!(matches!(
865 parse(&["install-wrapper"]),
866 SessionsSubcommands::InstallWrapper(_)
867 ));
868 assert!(matches!(
869 parse(&["uninstall-wrapper"]),
870 SessionsSubcommands::UninstallWrapper(_)
871 ));
872 }
873
874 #[test]
875 fn list_parses_flags() {
876 let cmd =
877 ListCommand::try_parse_from(["list", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
878 assert_eq!(cmd.output, TableOrJson::Json);
879 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
880 }
881
882 fn hook_op(json_str: &str) -> Option<(&'static str, Value)> {
885 serde_json::from_str::<HookPayload>(json_str)
886 .unwrap()
887 .to_op()
888 }
889
890 #[test]
891 fn hook_maps_lifecycle_events_to_observe() {
892 let (op, payload) = hook_op(
893 r#"{"session_id":"s1","cwd":"/p","transcript_path":"/t.jsonl","hook_event_name":"PreToolUse"}"#,
894 )
895 .unwrap();
896 assert_eq!(op, "observe");
897 assert_eq!(payload["session_id"], "s1");
898 assert_eq!(payload["cwd"], "/p");
899 assert_eq!(payload["event"], "pre_tool_use");
900 }
901
902 #[test]
903 fn hook_maps_session_start_and_stop() {
904 assert_eq!(
905 hook_op(r#"{"session_id":"s1","hook_event_name":"SessionStart"}"#)
906 .unwrap()
907 .1["event"],
908 "session_start"
909 );
910 assert_eq!(
911 hook_op(r#"{"session_id":"s1","hook_event_name":"Stop"}"#)
912 .unwrap()
913 .1["event"],
914 "stop"
915 );
916 }
917
918 #[test]
919 fn hook_maps_session_end_to_end_op() {
920 let (op, payload) =
921 hook_op(r#"{"session_id":"s1","hook_event_name":"SessionEnd","message":"exit"}"#)
922 .unwrap();
923 assert_eq!(op, "end");
924 assert_eq!(payload["session_id"], "s1");
925 assert_eq!(payload["reason"], "exit");
926 }
927
928 #[test]
929 fn hook_classifies_notifications() {
930 let permission = hook_op(
931 r#"{"session_id":"s1","hook_event_name":"Notification","message":"Claude needs your permission to use Bash"}"#,
932 )
933 .unwrap();
934 assert_eq!(permission.1["event"]["notification"], "permission_prompt");
935
936 let idle = hook_op(
937 r#"{"session_id":"s1","hook_event_name":"Notification","message":"Claude is waiting for your input"}"#,
938 )
939 .unwrap();
940 assert_eq!(idle.1["event"]["notification"], "idle_prompt");
941
942 let other = hook_op(
943 r#"{"session_id":"s1","hook_event_name":"Notification","message":"something else"}"#,
944 )
945 .unwrap();
946 assert_eq!(other.1["event"]["notification"], "other");
947 }
948
949 #[test]
950 fn hook_ignores_unknown_events_and_missing_session_id() {
951 assert!(hook_op(r#"{"hook_event_name":"Stop"}"#).is_none());
953 assert!(hook_op(r#"{"session_id":" ","hook_event_name":"Stop"}"#).is_none());
955 assert!(hook_op(r#"{"session_id":"s1","hook_event_name":"PreCompact"}"#).is_none());
957 assert!(hook_op("{}").is_none());
959 }
960
961 #[test]
962 fn classify_notification_covers_cases() {
963 assert_eq!(
964 classify_notification(Some("Please approve this")),
965 NotificationKind::PermissionPrompt
966 );
967 assert_eq!(
968 classify_notification(Some("Claude is idle")),
969 NotificationKind::IdlePrompt
970 );
971 assert_eq!(classify_notification(None), NotificationKind::Other);
972 }
973
974 #[test]
977 fn merge_hooks_is_idempotent_and_additive() {
978 let mut settings = json!({
980 "hooks": {
981 "PreToolUse": [
982 { "matcher": "Bash", "hooks": [{ "type": "command", "command": "other-tool" }] }
983 ]
984 },
985 "model": "sonnet"
986 });
987 let cmd = "/usr/bin/omni-dev sessions hook";
988 let added = merge_hooks(&mut settings, cmd);
989 assert_eq!(added, HOOK_EVENTS.len());
990
991 for (event, _) in HOOK_EVENTS {
993 let groups = settings["hooks"][event].as_array().unwrap();
994 assert!(
995 groups.iter().any(|g| group_has_command(g, cmd)),
996 "missing under {event}"
997 );
998 }
999 let pre = settings["hooks"]["PreToolUse"].as_array().unwrap();
1000 assert!(pre.iter().any(|g| group_has_command(g, "other-tool")));
1001 assert_eq!(settings["model"], "sonnet");
1002
1003 assert_eq!(merge_hooks(&mut settings, cmd), 0);
1005 }
1006
1007 #[test]
1008 fn merge_then_remove_round_trips_and_preserves_others() {
1009 let mut settings = json!({
1010 "hooks": {
1011 "PreToolUse": [
1012 { "matcher": "Bash", "hooks": [{ "type": "command", "command": "keep-me" }] }
1013 ]
1014 }
1015 });
1016 let cmd = "/usr/bin/omni-dev sessions hook";
1017 merge_hooks(&mut settings, cmd);
1018 let removed = remove_hooks(&mut settings, cmd);
1019 assert_eq!(removed, HOOK_EVENTS.len());
1020
1021 for (event, _) in HOOK_EVENTS {
1023 let empty = settings["hooks"]
1024 .get(event)
1025 .and_then(Value::as_array)
1026 .map_or(true, |g| g.iter().all(|g| !group_has_command(g, cmd)));
1027 assert!(empty, "our hook survived under {event}");
1028 }
1029 let pre = settings["hooks"]["PreToolUse"].as_array().unwrap();
1031 assert!(pre.iter().any(|g| group_has_command(g, "keep-me")));
1032 }
1033
1034 #[test]
1035 fn remove_hooks_prunes_empty_events_entirely() {
1036 let mut settings = json!({});
1037 let cmd = "cmd sessions hook";
1038 merge_hooks(&mut settings, cmd);
1039 remove_hooks(&mut settings, cmd);
1040 let hooks = settings["hooks"].as_object().unwrap();
1042 assert!(hooks.is_empty(), "expected all events pruned: {hooks:?}");
1043 }
1044
1045 #[test]
1046 fn install_uninstall_via_files_round_trips() {
1047 let tmp = tempfile::tempdir().unwrap();
1048 let path = tmp.path().join("settings.json");
1049 let mut settings = read_settings(&path).unwrap();
1051 merge_hooks(&mut settings, "cmd sessions hook");
1052 write_settings(&path, &settings).unwrap();
1053 assert!(path.exists());
1054
1055 let reloaded = read_settings(&path).unwrap();
1056 assert!(reloaded["hooks"]["Stop"].is_array());
1057
1058 let mut settings = read_settings(&path).unwrap();
1059 remove_hooks(&mut settings, "cmd sessions hook");
1060 write_settings(&path, &settings).unwrap();
1061 let reloaded = read_settings(&path).unwrap();
1062 assert!(reloaded["hooks"].as_object().unwrap().is_empty());
1063 }
1064
1065 #[test]
1066 fn read_settings_rejects_non_json() {
1067 let tmp = tempfile::tempdir().unwrap();
1068 let path = tmp.path().join("settings.json");
1069 std::fs::write(&path, "not json {").unwrap();
1070 let err = read_settings(&path).unwrap_err();
1071 assert!(err.to_string().contains("not valid JSON"), "{err}");
1072 }
1073
1074 #[test]
1075 fn read_settings_handles_empty_and_non_object() {
1076 let tmp = tempfile::tempdir().unwrap();
1077 let path = tmp.path().join("settings.json");
1078 std::fs::write(&path, " \n").unwrap();
1080 assert_eq!(read_settings(&path).unwrap(), json!({}));
1081 std::fs::write(&path, "[1, 2, 3]").unwrap();
1083 let err = read_settings(&path).unwrap_err();
1084 assert!(err.to_string().contains("not a JSON object"), "{err}");
1085 }
1086
1087 #[test]
1088 fn hook_command_targets_sessions_hook() {
1089 assert!(hook_command().ends_with("sessions hook"));
1090 }
1091
1092 #[test]
1095 fn render_sessions_handles_empty() {
1096 assert_eq!(
1097 render_sessions(&json!({ "sessions": [] })),
1098 "No active Claude Code sessions."
1099 );
1100 assert_eq!(
1101 render_sessions(&json!({})),
1102 "No active Claude Code sessions."
1103 );
1104 }
1105
1106 #[test]
1107 fn render_sessions_renders_rows_and_source() {
1108 let result = json!({ "sessions": [{
1109 "session_id": "s1",
1110 "state": "working",
1111 "source": { "kind": "vs_code", "window_key": "w1" },
1112 "repo": "omni-dev",
1113 "cwd": "/home/me/omni-dev",
1114 "last_seen": "2000-01-01T00:00:00Z",
1115 }]});
1116 let table = render_sessions(&result);
1117 assert!(table.contains("working"), "{table}");
1118 assert!(table.contains("vscode"), "{table}");
1119 assert!(table.contains("omni-dev"), "{table}");
1120 assert_eq!(table.lines().count(), 2, "{table}");
1122 }
1123
1124 #[test]
1125 fn render_sessions_strips_control_bytes() {
1126 let result = json!({ "sessions": [{
1127 "session_id": "s1",
1128 "state": "wor\x1b[31mking",
1129 "source": { "kind": "terminal" },
1130 "repo": "ev\x07il",
1131 "cwd": "/tmp/a\rb",
1132 "last_seen": "2000-01-01T00:00:00Z",
1133 }]});
1134 let table = render_sessions(&result);
1135 assert!(
1136 !table.contains(|c: char| c.is_control() && c != '\n'),
1137 "{table:?}"
1138 );
1139 assert_eq!(table.lines().count(), 2, "{table:?}");
1141 }
1142
1143 #[test]
1144 fn source_label_maps_kinds() {
1145 assert_eq!(
1146 source_label(&json!({ "source": { "kind": "vs_code", "window_key": "w" } })),
1147 "vscode"
1148 );
1149 assert_eq!(
1150 source_label(&json!({ "source": { "kind": "terminal" } })),
1151 "terminal"
1152 );
1153 assert_eq!(source_label(&json!({})), "terminal");
1154 }
1155
1156 #[test]
1157 fn reply_payload_unwraps_ok_and_maps_errors() {
1158 assert_eq!(
1159 reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
1160 json!({ "a": 1 })
1161 );
1162 let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
1163 assert!(err.to_string().contains("boom"), "{err}");
1164 }
1165
1166 #[test]
1169 fn install_then_uninstall_command_execute_round_trips() {
1170 let tmp = tempfile::tempdir().unwrap();
1171 let path = tmp.path().join("settings.json");
1172 InstallHooksCommand {
1174 settings: Some(path.clone()),
1175 }
1176 .execute()
1177 .unwrap();
1178 assert!(read_settings(&path).unwrap()["hooks"]["Stop"].is_array());
1179 InstallHooksCommand {
1181 settings: Some(path.clone()),
1182 }
1183 .execute()
1184 .unwrap();
1185 UninstallHooksCommand {
1187 settings: Some(path.clone()),
1188 }
1189 .execute()
1190 .unwrap();
1191 assert!(read_settings(&path).unwrap()["hooks"]
1192 .as_object()
1193 .unwrap()
1194 .is_empty());
1195 }
1196
1197 fn wrapper_commands(tmp: &Path) -> (InstallWrapperCommand, UninstallWrapperCommand) {
1202 let settings = tmp.join("settings.json");
1203 let shim = tmp.join("bin").join("claude-wrap");
1204 (
1205 InstallWrapperCommand {
1206 settings: Some(settings.clone()),
1207 shim: Some(shim.clone()),
1208 },
1209 UninstallWrapperCommand {
1210 settings: Some(settings),
1211 shim: Some(shim),
1212 },
1213 )
1214 }
1215
1216 #[test]
1217 fn install_wrapper_writes_an_executable_shim_and_sets_the_setting() {
1218 use std::os::unix::fs::PermissionsExt;
1219
1220 let tmp = tempfile::tempdir().unwrap();
1221 let (install, _) = wrapper_commands(tmp.path());
1222 let shim = install.shim.clone().unwrap();
1223 let settings = install.settings.clone().unwrap();
1224 install.execute().unwrap();
1225
1226 let script = std::fs::read_to_string(&shim).unwrap();
1229 assert!(script.starts_with("#!/bin/sh\nexec \"/"), "{script}");
1230 assert!(script.contains(" claude-wrap -- \"$@\""), "{script}");
1231 let mode = std::fs::metadata(&shim).unwrap().permissions().mode();
1232 assert_eq!(mode & 0o777, 0o700);
1233
1234 assert_eq!(
1235 read_settings(&settings).unwrap()[WRAPPER_SETTING_KEY],
1236 json!(shim.display().to_string())
1237 );
1238 }
1239
1240 #[test]
1241 fn install_wrapper_is_idempotent_and_preserves_other_settings() {
1242 let tmp = tempfile::tempdir().unwrap();
1243 let (install, _) = wrapper_commands(tmp.path());
1244 let settings_path = install.settings.clone().unwrap();
1245 write_settings(&settings_path, &json!({ "editor.fontSize": 13 })).unwrap();
1246
1247 install.execute().unwrap();
1248 let (again, _) = wrapper_commands(tmp.path());
1249 again.execute().unwrap();
1250
1251 let settings = read_settings(&settings_path).unwrap();
1252 assert_eq!(settings["editor.fontSize"], json!(13));
1253 assert!(settings[WRAPPER_SETTING_KEY].is_string());
1254 }
1255
1256 #[test]
1257 fn uninstall_wrapper_clears_only_our_own_setting() {
1258 let tmp = tempfile::tempdir().unwrap();
1259 let (install, uninstall) = wrapper_commands(tmp.path());
1260 let settings_path = install.settings.clone().unwrap();
1261 let shim = install.shim.clone().unwrap();
1262 install.execute().unwrap();
1263 uninstall.execute().unwrap();
1264
1265 assert!(read_settings(&settings_path).unwrap()[WRAPPER_SETTING_KEY].is_null());
1266 assert!(!shim.exists());
1267
1268 write_settings(
1270 &settings_path,
1271 &json!({ WRAPPER_SETTING_KEY: "/opt/someone-elses-wrapper" }),
1272 )
1273 .unwrap();
1274 let (_, uninstall) = wrapper_commands(tmp.path());
1275 uninstall.execute().unwrap();
1276 assert_eq!(
1277 read_settings(&settings_path).unwrap()[WRAPPER_SETTING_KEY],
1278 json!("/opt/someone-elses-wrapper")
1279 );
1280 }
1281
1282 #[test]
1283 fn uninstall_wrapper_on_a_missing_settings_file_is_a_noop() {
1284 let tmp = tempfile::tempdir().unwrap();
1285 let (_, uninstall) = wrapper_commands(tmp.path());
1286 let settings = uninstall.settings.clone().unwrap();
1287 uninstall.execute().unwrap();
1288 assert!(!settings.exists());
1289 }
1290
1291 #[test]
1292 fn install_wrapper_on_a_jsonc_settings_file_explains_the_manual_step() {
1293 let tmp = tempfile::tempdir().unwrap();
1294 let (install, _) = wrapper_commands(tmp.path());
1295 let settings = install.settings.clone().unwrap();
1296 let shim = install.shim.clone().unwrap();
1297 std::fs::write(
1299 &settings,
1300 "{\n // a comment\n \"editor.fontSize\": 13\n}\n",
1301 )
1302 .unwrap();
1303
1304 let error = format!("{:#}", install.execute().unwrap_err());
1305 assert!(error.contains("not valid JSON"), "{error}");
1306 assert!(error.contains(WRAPPER_SETTING_KEY), "{error}");
1307 assert!(error.contains(&shim.display().to_string()), "{error}");
1309 assert!(shim.exists());
1310 }
1311
1312 #[test]
1313 fn install_wrapper_fails_when_the_shim_directory_cannot_be_made() {
1314 let tmp = tempfile::tempdir().unwrap();
1315 let blocker = tmp.path().join("not-a-dir");
1318 std::fs::write(&blocker, b"").unwrap();
1319 let error = InstallWrapperCommand {
1320 settings: Some(tmp.path().join("settings.json")),
1321 shim: Some(blocker.join("claude-wrap")),
1322 }
1323 .execute()
1324 .unwrap_err();
1325 assert!(format!("{error:#}").contains("not-a-dir"), "{error:#}");
1326 assert!(!tmp.path().join("settings.json").exists());
1328 }
1329
1330 #[test]
1331 fn set_and_clear_wrapper_tolerate_a_non_object_settings_value() {
1332 let mut scalar = json!("not an object");
1335 assert!(!set_wrapper(&mut scalar, "/shim"));
1336 assert!(!clear_wrapper(&mut scalar, "/shim"));
1337 assert_eq!(scalar, json!("not an object"));
1338 }
1339
1340 #[test]
1341 fn the_wrapper_paths_default_outside_the_claude_config_dir() {
1342 let shim = shim_path(None).unwrap();
1345 assert_eq!(shim.file_name().unwrap(), SHIM_NAME);
1346 assert_eq!(shim.parent().unwrap(), paths::runtime_dir().unwrap());
1347 let settings = vscode_settings_path(None).unwrap();
1348 assert!(
1349 settings.ends_with("Code/User/settings.json"),
1350 "{settings:?}"
1351 );
1352 }
1353
1354 #[test]
1355 fn uninstall_command_on_a_missing_file_is_a_noop() {
1356 let tmp = tempfile::tempdir().unwrap();
1357 let path = tmp.path().join("does-not-exist.json");
1358 UninstallHooksCommand {
1360 settings: Some(path.clone()),
1361 }
1362 .execute()
1363 .unwrap();
1364 assert!(!path.exists());
1365 }
1366
1367 #[tokio::test]
1368 async fn sessions_command_dispatches_to_subcommands() {
1369 let tmp = tempfile::tempdir().unwrap();
1371 let path = tmp.path().join("settings.json");
1372 SessionsCommand {
1373 command: SessionsSubcommands::InstallHooks(InstallHooksCommand {
1374 settings: Some(path.clone()),
1375 }),
1376 }
1377 .execute()
1378 .await
1379 .unwrap();
1380 SessionsCommand {
1381 command: SessionsSubcommands::UninstallHooks(UninstallHooksCommand {
1382 settings: Some(path.clone()),
1383 }),
1384 }
1385 .execute()
1386 .await
1387 .unwrap();
1388 let (install, uninstall) = wrapper_commands(tmp.path());
1389 SessionsCommand {
1390 command: SessionsSubcommands::InstallWrapper(install),
1391 }
1392 .execute()
1393 .await
1394 .unwrap();
1395 SessionsCommand {
1396 command: SessionsSubcommands::UninstallWrapper(uninstall),
1397 }
1398 .execute()
1399 .await
1400 .unwrap();
1401 }
1402
1403 #[tokio::test]
1404 async fn hook_report_is_silent_when_the_daemon_is_down() {
1405 let tmp = tempfile::tempdir_in("/tmp").unwrap();
1408 let sock = tmp.path().join("nope.sock");
1409 let cmd = HookCommand { socket: Some(sock) };
1410 cmd.report(r#"{"session_id":"s1","hook_event_name":"Stop"}"#)
1411 .await;
1412 cmd.report("not json").await;
1414 cmd.report(r#"{"hook_event_name":"Stop"}"#).await; }
1416
1417 fn fake_daemon(reply: Value) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<()>) {
1421 use futures::{SinkExt, StreamExt};
1422 use tokio::net::UnixListener;
1423 use tokio_util::codec::{Framed, LinesCodec};
1424
1425 let dir = tempfile::tempdir_in("/tmp").unwrap();
1426 let sock = dir.path().join("d.sock");
1427 let listener = UnixListener::bind(&sock).unwrap();
1428 let server = tokio::spawn(async move {
1429 let (stream, _) = listener.accept().await.unwrap();
1430 let mut framed = Framed::new(stream, LinesCodec::new());
1431 let _req = framed.next().await.unwrap().unwrap();
1432 framed
1433 .send(serde_json::to_string(&reply).unwrap())
1434 .await
1435 .unwrap();
1436 });
1437 (dir, sock, server)
1438 }
1439
1440 fn fake_daemon_capture(
1443 reply: Value,
1444 ) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<Value>) {
1445 use futures::{SinkExt, StreamExt};
1446 use tokio::net::UnixListener;
1447 use tokio_util::codec::{Framed, LinesCodec};
1448
1449 let dir = tempfile::tempdir_in("/tmp").unwrap();
1450 let sock = dir.path().join("d.sock");
1451 let listener = UnixListener::bind(&sock).unwrap();
1452 let server = tokio::spawn(async move {
1453 let (stream, _) = listener.accept().await.unwrap();
1454 let mut framed = Framed::new(stream, LinesCodec::new());
1455 let req = framed.next().await.unwrap().unwrap();
1456 framed
1457 .send(serde_json::to_string(&reply).unwrap())
1458 .await
1459 .unwrap();
1460 serde_json::from_str::<Value>(&req).unwrap()
1461 });
1462 (dir, sock, server)
1463 }
1464
1465 #[tokio::test]
1466 async fn list_command_execute_renders_from_a_socket() {
1467 let payload = json!({
1468 "ok": true,
1469 "payload": { "sessions": [{
1470 "session_id": "s1", "state": "working",
1471 "source": { "kind": "terminal" }, "repo": "omni-dev",
1472 "cwd": "/home/me/omni-dev", "last_seen": "2000-01-01T00:00:00Z"
1473 }]}
1474 });
1475 let (_dir, sock, server) = fake_daemon(payload.clone());
1478 SessionsCommand {
1479 command: SessionsSubcommands::List(ListCommand {
1480 socket: Some(sock),
1481 output: TableOrJson::Table,
1482 }),
1483 }
1484 .execute()
1485 .await
1486 .unwrap();
1487 server.await.unwrap();
1488
1489 let (_dir, sock, server) = fake_daemon(payload);
1491 ListCommand {
1492 socket: Some(sock),
1493 output: TableOrJson::Json,
1494 }
1495 .execute()
1496 .await
1497 .unwrap();
1498 server.await.unwrap();
1499 }
1500
1501 #[test]
1502 fn merge_and_remove_hooks_tolerate_malformed_shapes() {
1503 let cmd = "cmd sessions hook";
1504 assert_eq!(merge_hooks(&mut json!([]), cmd), 0);
1506 assert_eq!(remove_hooks(&mut json!([]), cmd), 0);
1507 assert_eq!(merge_hooks(&mut json!({ "hooks": 5 }), cmd), 0);
1509 assert_eq!(remove_hooks(&mut json!({}), cmd), 0);
1511 assert_eq!(merge_hooks(&mut json!({ "hooks": { "Stop": 5 } }), cmd), 6);
1513 assert_eq!(remove_hooks(&mut json!({ "hooks": { "Stop": 5 } }), cmd), 0);
1514 }
1515
1516 #[test]
1519 fn window_subcommands_route_and_require_key() {
1520 assert!(matches!(
1521 parse(&["window", "--key", "w1"]),
1522 SessionsSubcommands::Window(_)
1523 ));
1524 assert!(matches!(
1525 parse(&["window-unregister", "--key", "w1"]),
1526 SessionsSubcommands::WindowUnregister(_)
1527 ));
1528 assert!(WindowCommand::try_parse_from(["window"]).is_err());
1530 assert!(WindowUnregisterCommand::try_parse_from(["window-unregister"]).is_err());
1531 }
1532
1533 #[test]
1534 fn window_parses_counts_and_folders() {
1535 let cmd = WindowCommand::try_parse_from([
1536 "window",
1537 "--key",
1538 "w1",
1539 "--folder",
1540 "/a",
1541 "--folder",
1542 "/b",
1543 "--tabs",
1544 "2",
1545 "--terminals",
1546 "3",
1547 ])
1548 .unwrap();
1549 assert_eq!(cmd.key, "w1");
1550 assert_eq!(cmd.folders, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
1551 assert_eq!(cmd.tabs, 2);
1552 assert_eq!(cmd.terminals, 3);
1553 let cmd = WindowCommand::try_parse_from(["window", "--key", "w1"]).unwrap();
1555 assert_eq!(cmd.tabs, 0);
1556 assert_eq!(cmd.terminals, 0);
1557 }
1558
1559 #[tokio::test]
1560 async fn window_and_window_unregister_send_their_ops() {
1561 let (_dir, sock, server) =
1562 fake_daemon_capture(json!({ "ok": true, "payload": { "ok": true } }));
1563 SessionsCommand {
1566 command: SessionsSubcommands::Window(WindowCommand {
1567 key: "w1".to_string(),
1568 folders: vec![PathBuf::from("/a")],
1569 tabs: 1,
1570 terminals: 0,
1571 socket: Some(sock),
1572 }),
1573 }
1574 .execute()
1575 .await
1576 .unwrap();
1577 let req = server.await.unwrap();
1579 assert_eq!(req["op"], "window");
1580 assert_eq!(req["payload"]["key"], json!("w1"));
1581 assert_eq!(req["payload"]["folders"], json!(["/a"]));
1582 assert_eq!(req["payload"]["tabs"], json!(1));
1583 assert_eq!(req["payload"]["terminals"], json!(0));
1584
1585 let (_dir, sock, server) =
1588 fake_daemon_capture(json!({ "ok": true, "payload": { "removed": true } }));
1589 SessionsCommand {
1590 command: SessionsSubcommands::WindowUnregister(WindowUnregisterCommand {
1591 key: "w1".to_string(),
1592 socket: Some(sock),
1593 }),
1594 }
1595 .execute()
1596 .await
1597 .unwrap();
1598 let req = server.await.unwrap();
1599 assert_eq!(req["op"], "window-unregister");
1600 assert_eq!(req["payload"]["key"], json!("w1"));
1601 }
1602}