Skip to main content

omni_dev/cli/
sessions.rs

1//! `omni-dev sessions` — track the Claude Code sessions running across every
2//! terminal and VS Code window, via the daemon's `sessions` service.
3//!
4//! The subcommands split by role:
5//! - `list` is a **read** client (like `omni-dev worktrees list`): it asks the
6//!   daemon's `sessions` service for the live set and renders it.
7//! - `hook` is the **feed sink**: Claude Code runs it per hook event; it reads
8//!   the hook JSON on stdin, maps it to an `observe`/`end` op, and fire-and-forgets
9//!   it to the daemon socket. It must **never** block or fail a Claude turn — a
10//!   missing daemon, a bad payload, or any other error is swallowed and it always
11//!   exits 0.
12//! - `install-hooks` / `uninstall-hooks` idempotently merge (or remove) the hook
13//!   block in `~/.claude/settings.json`, preserving any hooks already there.
14//! - `install-wrapper` / `uninstall-wrapper` are the same idea for Feed 4: they
15//!   write the shim that VS Code's Claude extension launches
16//!   [`omni-dev claude-wrap`](crate::cli::claude_wrap) through, and point the
17//!   extension's `claudeCode.claudeProcessWrapper` setting at it.
18//!
19//! The register/heartbeat feed from the companion VS Code extension talks to the
20//! socket directly (like the worktrees companion), not through this CLI.
21
22use 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
39/// The `sessions` service routing key on the daemon control socket.
40const SERVICE: &str = "sessions";
41
42/// How long the fire-and-forget `hook` sink waits for the daemon before giving
43/// up — short, so a slow or wedged daemon never stalls a Claude turn.
44const HOOK_TIMEOUT: Duration = Duration::from_secs(2);
45
46/// Sessions: see the Claude Code sessions running across every terminal and
47/// VS Code window, kept live by the daemon.
48#[derive(Parser)]
49pub struct SessionsCommand {
50    /// The sessions subcommand to execute.
51    #[command(subcommand)]
52    pub command: SessionsSubcommands,
53}
54
55/// Sessions subcommands.
56#[derive(Subcommand)]
57pub enum SessionsSubcommands {
58    /// List the Claude Code sessions currently running across all windows.
59    List(ListCommand),
60    /// Claude Code hook sink: read a hook event on stdin and report it to the
61    /// daemon (run by Claude Code, not by hand).
62    Hook(HookCommand),
63    /// Install the Claude Code hooks that feed the sessions tracker into
64    /// `~/.claude/settings.json` (idempotent).
65    InstallHooks(InstallHooksCommand),
66    /// Remove the sessions-tracker hooks from `~/.claude/settings.json`.
67    UninstallHooks(UninstallHooksCommand),
68    /// Install the `claude-wrap` shim and point VS Code's Claude extension at it
69    /// (idempotent).
70    InstallWrapper(InstallWrapperCommand),
71    /// Remove the `claude-wrap` shim and VS Code's wrapper setting.
72    UninstallWrapper(UninstallWrapperCommand),
73    /// Report a window's Claude tab/terminal counts (companion feed op).
74    Window(WindowCommand),
75    /// Remove a window's embedding report (companion feed op).
76    WindowUnregister(WindowUnregisterCommand),
77}
78
79impl SessionsCommand {
80    /// Executes the sessions command.
81    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// --- list --------------------------------------------------------------------
96
97/// Lists the live cross-window set of running Claude sessions.
98#[derive(Parser)]
99pub struct ListCommand {
100    /// Control-socket path. Defaults to the per-user runtime location.
101    #[arg(long, value_name = "PATH")]
102    pub socket: Option<PathBuf>,
103    /// Output format.
104    #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
105    pub output: TableOrJson,
106}
107
108impl ListCommand {
109    /// Executes the list command.
110    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// --- window feed -------------------------------------------------------------
122
123/// Reports a window's Claude embedding counts (the companion `window` feed op).
124///
125/// Exposed as a typed command so scripted/headless reporters and integration
126/// tests can drive the sessions registry the way the VS Code companion does.
127/// Mirrors `WindowReport`.
128#[derive(Parser)]
129pub struct WindowCommand {
130    /// Stable per-window identity (the companion generates a per-activate UUID).
131    #[arg(long, value_name = "KEY")]
132    pub key: String,
133    /// A workspace-folder path (repeatable) — used to join sessions by `cwd`.
134    #[arg(long = "folder", value_name = "PATH")]
135    pub folders: Vec<PathBuf>,
136    /// How many Claude editor tabs the window has.
137    #[arg(long, value_name = "N", default_value_t = 0)]
138    pub tabs: usize,
139    /// How many Claude Code integrated terminals the window has.
140    #[arg(long, value_name = "N", default_value_t = 0)]
141    pub terminals: usize,
142    /// Control-socket path. Defaults to the per-user runtime location.
143    #[arg(long, value_name = "PATH")]
144    pub socket: Option<PathBuf>,
145}
146
147impl WindowCommand {
148    /// Executes the window command.
149    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/// Removes a window's embedding report — the companion `window-unregister` feed
164/// op made typed. Prints whether an entry was actually removed.
165#[derive(Parser)]
166pub struct WindowUnregisterCommand {
167    /// The window key to unregister.
168    #[arg(long, value_name = "KEY")]
169    pub key: String,
170    /// Control-socket path. Defaults to the per-user runtime location.
171    #[arg(long, value_name = "PATH")]
172    pub socket: Option<PathBuf>,
173}
174
175impl WindowUnregisterCommand {
176    /// Executes the window-unregister command.
177    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// --- hook --------------------------------------------------------------------
190
191/// The Claude Code hook sink: reads one hook event's JSON on stdin and reports it
192/// to the daemon. Fire-and-forget and infallible-by-design.
193#[derive(Parser)]
194pub struct HookCommand {
195    /// Control-socket path. Defaults to the per-user runtime location.
196    #[arg(long, value_name = "PATH")]
197    pub socket: Option<PathBuf>,
198}
199
200impl HookCommand {
201    /// Executes the hook sink. Always returns `Ok(())` (exit 0): a hook must
202    /// never block or fail a Claude turn, so every error — no daemon, bad JSON,
203    /// an unknown event — is swallowed after a best-effort report.
204    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    /// Parses the hook JSON, maps it to an op, and best-effort sends it. Split
214    /// out so tests can exercise the send path against a fake socket.
215    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        // Bounded, and every failure ignored: the daemon may be down, and that
226        // must be a silent no-op.
227        let env = DaemonEnvelope::service(SERVICE, op, payload);
228        let _ = tokio::time::timeout(HOOK_TIMEOUT, DaemonClient::new(&socket).request(env)).await;
229    }
230}
231
232/// The subset of a Claude Code hook payload the sink reads. Every field is
233/// optional and defaulted, so an unexpected or future payload shape never fails
234/// to parse (the sink then simply produces no op). See the hooks docs.
235#[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    /// Present on `Notification` events — the message classified into a
246    /// [`NotificationKind`].
247    #[serde(default)]
248    message: Option<String>,
249    /// Best-effort model id, when a payload carries one.
250    #[serde(default)]
251    model: Option<String>,
252}
253
254impl HookPayload {
255    /// Maps this hook payload to a `(op, payload)` for the daemon, or `None` when
256    /// it carries no `session_id` or names an event the tracker ignores.
257    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
280/// Maps a Claude Code hook event name to the [`SessionEvent`] it implies, or
281/// `None` for an event the tracker does not act on. `SessionEnd` is handled
282/// separately (it maps to the `end` op, not `observe`).
283fn 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
295/// Classifies a `Notification` message into a [`NotificationKind`]. Best-effort
296/// substring matching — the message text is version-unstable, so an unrecognised
297/// message falls back to [`NotificationKind::Other`] (which carries no state
298/// signal and leaves the session's state unchanged).
299fn 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// --- install-hooks / uninstall-hooks ----------------------------------------
317
318/// Installs the sessions-tracker hooks into `~/.claude/settings.json`.
319#[derive(Parser)]
320pub struct InstallHooksCommand {
321    /// Path to the Claude settings file. Defaults to `~/.claude/settings.json`
322    /// (respecting `$CLAUDE_CONFIG_DIR`).
323    #[arg(long, value_name = "PATH")]
324    pub settings: Option<PathBuf>,
325}
326
327impl InstallHooksCommand {
328    /// Executes the install: merges the hook block idempotently, preserving any
329    /// hooks already present.
330    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/// Removes the sessions-tracker hooks from `~/.claude/settings.json`.
352#[derive(Parser)]
353pub struct UninstallHooksCommand {
354    /// Path to the Claude settings file. Defaults to `~/.claude/settings.json`
355    /// (respecting `$CLAUDE_CONFIG_DIR`).
356    #[arg(long, value_name = "PATH")]
357    pub settings: Option<PathBuf>,
358}
359
360impl UninstallHooksCommand {
361    /// Executes the uninstall: removes any hook entries whose command is ours,
362    /// leaving every other hook untouched.
363    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
380// --- install-wrapper / uninstall-wrapper -------------------------------------
381
382/// The VS Code setting the Claude Code extension reads to decide what to launch
383/// Claude through. Machine-scoped, so it belongs in the *user* settings file.
384const WRAPPER_SETTING_KEY: &str = "claudeCode.claudeProcessWrapper";
385
386/// Filename of the shim written into omni-dev's runtime directory.
387const SHIM_NAME: &str = "claude-wrap";
388
389/// Installs the `claude-wrap` shim and points VS Code's Claude extension at it.
390#[derive(Parser)]
391pub struct InstallWrapperCommand {
392    /// Path to the VS Code user settings file. Defaults to the platform's
393    /// `Code/User/settings.json`.
394    #[arg(long, value_name = "PATH")]
395    pub settings: Option<PathBuf>,
396
397    /// Path to write the shim to. Defaults to `<data-dir>/omni-dev/claude-wrap`.
398    #[arg(long, value_name = "PATH")]
399    pub shim: Option<PathBuf>,
400}
401
402impl InstallWrapperCommand {
403    /// Executes the install: writes the shim, then idempotently sets the
404    /// extension's wrapper setting to point at it.
405    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/// Removes the wrapper setting and the shim it points at.
431#[derive(Parser)]
432pub struct UninstallWrapperCommand {
433    /// Path to the VS Code user settings file. Defaults to the platform's
434    /// `Code/User/settings.json`.
435    #[arg(long, value_name = "PATH")]
436    pub settings: Option<PathBuf>,
437
438    /// Path the shim was written to. Defaults to
439    /// `<data-dir>/omni-dev/claude-wrap`.
440    #[arg(long, value_name = "PATH")]
441    pub shim: Option<PathBuf>,
442}
443
444impl UninstallWrapperCommand {
445    /// Executes the uninstall: clears the setting when (and only when) it still
446    /// points at our shim, then removes the shim file.
447    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
477/// The shim's path: an explicit `--shim`, else `claude-wrap` in omni-dev's
478/// runtime directory (beside the daemon socket).
479fn 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
486/// The VS Code **user** settings path: an explicit `--settings`, else
487/// `<config-dir>/Code/User/settings.json` — `~/Library/Application Support/…` on
488/// macOS and `~/.config/…` on Linux, which is where a machine-scoped setting has
489/// to live. (The runtime directory is `data_dir()`; this one is deliberately
490/// `config_dir()`.)
491fn 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
499/// The shim script's contents.
500///
501/// The extension spawns the configured wrapper directly — no shell, no argument
502/// splitting — so the setting has to name a single executable file. This is that
503/// file: a one-line `exec` into `omni-dev claude-wrap`, using the absolute path
504/// of the running binary so it does not depend on VS Code's `PATH`.
505fn 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
511/// Writes the shim, creating its directory and marking it owner-executable.
512fn 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
524/// Points the wrapper setting at `shim`, returning whether anything changed.
525fn 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
536/// Clears the wrapper setting when it points at `shim`, returning whether it
537/// did. A setting pointing somewhere else is someone else's and is left alone.
538fn 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
549/// Turns an unparseable-settings error into one that tells the user what to do.
550///
551/// VS Code settings files are JSON**C** — comments and trailing commas are
552/// legal there and common in practice, and [`read_settings`] rightly refuses to
553/// rewrite what it cannot parse. The shim is already in place by then, so the
554/// only thing left is one line to paste.
555fn 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
564/// The Claude Code hook events the tracker installs, paired with whether the
565/// event's hook group needs a tool `matcher` (`PreToolUse`/`PostToolUse` match on
566/// tool name; the rest have no matcher). `SessionEnd` is included — it maps to
567/// the `end` op in the sink.
568const 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
578/// The hook command string written into settings.json: the absolute path of the
579/// running binary plus `sessions hook`, so Claude Code invokes *this* omni-dev
580/// regardless of its hook `PATH`. Falls back to the bare `omni-dev sessions hook`
581/// when the executable path cannot be resolved (documented as the portable form).
582fn 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
589/// The Claude settings file path: an explicit `--settings`, else
590/// `$CLAUDE_CONFIG_DIR/settings.json`, else `~/.claude/settings.json`.
591fn 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
602/// Reads and parses `path` into a JSON object, treating a missing file as an
603/// empty object. Errors (rather than clobbering) when the file exists but is not
604/// valid JSON, or is valid JSON that is not an object.
605fn 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
629/// Serializes `settings` back to `path`, pretty-printed with a trailing newline,
630/// creating the parent directory if needed.
631fn 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
642/// Merges the sessions-tracker hook `command` into a settings object under each
643/// event in [`HOOK_EVENTS`], returning how many events were newly added.
644/// Idempotent (an event that already has a group running `command` is skipped)
645/// and additive (it never touches other hooks). Creates `hooks` and any per-event
646/// array as needed.
647fn merge_hooks(settings: &mut Value, command: &str) -> usize {
648    // `read_settings` guarantees an object, but degrade gracefully rather than
649    // panic if a caller passes something else.
650    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        // `hooks` exists but is not an object; leave the file alone rather than
659        // clobber a user's unexpected shape.
660        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; // already installed for this event
672        }
673        groups.push(hook_group(command, *needs_matcher));
674        added += 1;
675    }
676    added
677}
678
679/// Removes every hook entry whose command is `command` from a settings object,
680/// pruning any group and per-event array left empty, and returning how many hook
681/// entries were removed. Leaves all other hooks in place.
682fn 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        // Drop groups whose hook list is now empty, then the event if no groups
703        // remain, so an uninstall leaves no empty scaffolding behind.
704        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
719/// One hook group as written into an event array: `{ "hooks": [{ "type":
720/// "command", "command": … }] }`, with a `"matcher": "*"` when the event matches
721/// on tool name.
722fn 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
732/// Whether a hook `group` already contains a hook running `command`.
733fn 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
740/// Whether a single hook entry runs `command`.
741fn hook_has_command(hook: &Value, command: &str) -> bool {
742    hook.get("command").and_then(Value::as_str) == Some(command)
743}
744
745// --- shared socket + rendering ----------------------------------------------
746
747/// Sends one `sessions` service op over the control socket, returning its
748/// payload or turning an `ok: false` reply into an error.
749async 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
756/// Unwraps a daemon reply into its payload, turning an `ok: false` reply into an
757/// error. Pure (no socket), so both mappings are unit-testable.
758fn 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
769/// Renders a `list` reply as a human-readable table: a header and one row per
770/// live session (state, source, repo, working directory, and age). Returns a
771/// placeholder line when nothing is running.
772fn 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    // CWD is last so a long path never misaligns the columns after it.
782    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
799/// A compact, fixed-width-friendly label for a session state, so the wide
800/// `waiting_for_permission` does not overflow the STATE column. Falls through to
801/// the raw (sanitized) string for any unexpected value.
802fn 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
810/// The short source label for a session: `vscode` when embedded in a VS Code
811/// window, else `terminal`.
812fn 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
819/// Strips control characters from an untrusted registry string so a crafted
820/// payload cannot inject terminal escape sequences into the rendered table (the
821/// worktrees `sanitize` precedent, #1137). The `--json` path stays verbatim.
822fn sanitize(s: &str) -> String {
823    s.chars().filter(|c| !c.is_control()).collect()
824}
825
826/// Seconds elapsed since an RFC 3339 timestamp (0 if absent/unparseable).
827fn 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    /// Mirrors the `omni-dev sessions` argv surface for parse tests.
840    #[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    // --- hook mapping --------------------------------------------------------
883
884    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        // No session_id → no op.
952        assert!(hook_op(r#"{"hook_event_name":"Stop"}"#).is_none());
953        // Blank session_id → no op.
954        assert!(hook_op(r#"{"session_id":"  ","hook_event_name":"Stop"}"#).is_none());
955        // Unknown event → no op.
956        assert!(hook_op(r#"{"session_id":"s1","hook_event_name":"PreCompact"}"#).is_none());
957        // Garbage that still parses as an (empty) payload → no op.
958        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    // --- install / uninstall hooks ------------------------------------------
975
976    #[test]
977    fn merge_hooks_is_idempotent_and_additive() {
978        // A pre-existing unrelated hook must survive the merge.
979        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        // Our command landed under every event, and the unrelated hook stands.
992        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        // A second merge is a no-op.
1004        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        // Every one of our entries is gone...
1022        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        // ...but the unrelated PreToolUse hook remains.
1030        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        // With no other hooks, every event array empties and is pruned.
1041        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        // Install into a missing file, then uninstall.
1050        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        // An empty (or whitespace-only) file reads as an empty object.
1079        std::fs::write(&path, "   \n").unwrap();
1080        assert_eq!(read_settings(&path).unwrap(), json!({}));
1081        // Valid JSON that is not an object is refused rather than clobbered.
1082        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    // --- rendering -----------------------------------------------------------
1093
1094    #[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        // Header plus one data row.
1121        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        // Embedded CR cannot forge a row: header plus one data row.
1140        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    // --- command execute() paths -------------------------------------------
1167
1168    #[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        // Install into a missing file: the hook block lands.
1173        InstallHooksCommand {
1174            settings: Some(path.clone()),
1175        }
1176        .execute()
1177        .unwrap();
1178        assert!(read_settings(&path).unwrap()["hooks"]["Stop"].is_array());
1179        // A second install is the idempotent "no change" branch.
1180        InstallHooksCommand {
1181            settings: Some(path.clone()),
1182        }
1183        .execute()
1184        .unwrap();
1185        // Uninstall removes our block, leaving an empty hooks object.
1186        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    // --- install-wrapper / uninstall-wrapper --------------------------------
1198
1199    /// An install/uninstall pair pointed entirely inside `tmp`, so nothing
1200    /// touches the developer's real data or config directories.
1201    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        // The shim is a one-line exec into `claude-wrap`, owner-executable, and
1227        // names an absolute binary so it does not depend on VS Code's PATH.
1228        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        // A setting someone else owns survives, and so does their file.
1269        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        // Comments are legal in VS Code settings and cannot be rewritten safely.
1298        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        // The shim is written first, so the printed line is ready to paste.
1308        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        // A regular file where the shim's parent directory would have to go, so
1316        // creating it cannot succeed — a mistyped `--shim` in practice.
1317        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        // The settings file is left untouched when the shim cannot be written.
1327        assert!(!tmp.path().join("settings.json").exists());
1328    }
1329
1330    #[test]
1331    fn set_and_clear_wrapper_tolerate_a_non_object_settings_value() {
1332        // `read_settings` guarantees an object, but these degrade rather than
1333        // panic if a caller hands them anything else (the `merge_hooks` rule).
1334        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        // The shim lives beside the daemon socket; the setting lives in VS Code's
1343        // *config* directory, which is a different base on every platform.
1344        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        // The no-file branch: nothing to remove, still Ok, and no file created.
1359        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        // Cover the outer dispatch for the two file-backed arms (no socket/stdin).
1370        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        // A valid hook event but no daemon at the socket: the send fails and is
1406        // swallowed (never panics, never errors).
1407        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        // Unmappable input returns before any socket work.
1413        cmd.report("not json").await;
1414        cmd.report(r#"{"hook_event_name":"Stop"}"#).await; // no session_id → no op
1415    }
1416
1417    /// Spawns a minimal fake daemon on a short-path Unix socket that answers one
1418    /// request with `reply`. Returns the temp dir (kept alive), the socket path,
1419    /// and the server task.
1420    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    /// Like [`fake_daemon`] but **returns the request envelope it received** so a
1441    /// test can assert the exact op/payload wire shape the client sent.
1442    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        // Table output, dispatched through the outer `SessionsCommand` so the
1476        // `List` arm of the dispatch is covered too.
1477        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        // JSON output goes through the other branch of the renderer.
1490        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        // Non-object settings: both are no-ops rather than panics.
1505        assert_eq!(merge_hooks(&mut json!([]), cmd), 0);
1506        assert_eq!(remove_hooks(&mut json!([]), cmd), 0);
1507        // `hooks` present but not an object → merge leaves it alone.
1508        assert_eq!(merge_hooks(&mut json!({ "hooks": 5 }), cmd), 0);
1509        // No `hooks` key → remove has nothing to do.
1510        assert_eq!(remove_hooks(&mut json!({}), cmd), 0);
1511        // A per-event value that is not an array is skipped, not indexed.
1512        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    // --- #1361 typed window feed commands -----------------------------------
1517
1518    #[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        // `--key` is required for both.
1529        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        // Counts default to zero.
1554        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        // Routed through the outer `SessionsCommand::execute` so the `Window`
1564        // dispatch arm is covered too.
1565        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        // The WindowReport wire shape: op + every field the daemon reads.
1578        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        // `window-unregister` replies `{removed}` (not `{ok}`); the client reads it.
1586        // Also routed through the wrapper to cover the `WindowUnregister` arm.
1587        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}