use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{bail, Context, Result};
use chrono::Utc;
use clap::{Parser, Subcommand};
use serde::Deserialize;
use serde_json::{json, Value};
use crate::cli::format::TableOrJson;
use crate::daemon::client::DaemonClient;
use crate::daemon::protocol::{DaemonEnvelope, DaemonReply};
use crate::daemon::server;
use crate::sessions::{NotificationKind, ObserveRequest, SessionEvent};
const SERVICE: &str = "sessions";
const HOOK_TIMEOUT: Duration = Duration::from_secs(2);
#[derive(Parser)]
pub struct SessionsCommand {
#[command(subcommand)]
pub command: SessionsSubcommands,
}
#[derive(Subcommand)]
pub enum SessionsSubcommands {
List(ListCommand),
Hook(HookCommand),
InstallHooks(InstallHooksCommand),
UninstallHooks(UninstallHooksCommand),
}
impl SessionsCommand {
pub async fn execute(self) -> Result<()> {
match self.command {
SessionsSubcommands::List(cmd) => cmd.execute().await,
SessionsSubcommands::Hook(cmd) => cmd.execute().await,
SessionsSubcommands::InstallHooks(cmd) => cmd.execute(),
SessionsSubcommands::UninstallHooks(cmd) => cmd.execute(),
}
}
}
#[derive(Parser)]
pub struct ListCommand {
#[arg(long, value_name = "PATH")]
pub socket: Option<PathBuf>,
#[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
pub output: TableOrJson,
}
impl ListCommand {
pub async fn execute(self) -> Result<()> {
let socket = server::resolve_socket(self.socket)?;
let result = call(&socket, "list", Value::Null).await?;
match self.output {
TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
TableOrJson::Table => println!("{}", render_sessions(&result)),
}
Ok(())
}
}
#[derive(Parser)]
pub struct HookCommand {
#[arg(long, value_name = "PATH")]
pub socket: Option<PathBuf>,
}
impl HookCommand {
pub async fn execute(self) -> Result<()> {
let mut input = String::new();
if std::io::stdin().read_to_string(&mut input).is_err() {
return Ok(());
}
self.report(&input).await;
Ok(())
}
async fn report(&self, input: &str) {
let Ok(hook) = serde_json::from_str::<HookPayload>(input) else {
return;
};
let Some((op, payload)) = hook.to_op() else {
return;
};
let Ok(socket) = server::resolve_socket(self.socket.clone()) else {
return;
};
let env = DaemonEnvelope::service(SERVICE, op, payload);
let _ = tokio::time::timeout(HOOK_TIMEOUT, DaemonClient::new(&socket).request(env)).await;
}
}
#[derive(Debug, Clone, Default, Deserialize)]
struct HookPayload {
#[serde(default)]
session_id: Option<String>,
#[serde(default)]
transcript_path: Option<PathBuf>,
#[serde(default)]
cwd: Option<PathBuf>,
#[serde(default)]
hook_event_name: Option<String>,
#[serde(default)]
message: Option<String>,
#[serde(default)]
model: Option<String>,
}
impl HookPayload {
fn to_op(&self) -> Option<(&'static str, Value)> {
let session_id = self.session_id.clone().filter(|s| !s.trim().is_empty())?;
let event_name = self.hook_event_name.as_deref()?;
if event_name == "SessionEnd" {
let mut payload = json!({ "session_id": session_id });
if let Some(reason) = &self.message {
payload["reason"] = Value::String(reason.clone());
}
return Some(("end", payload));
}
let event = session_event_for(event_name, self.message.as_deref())?;
let request = ObserveRequest {
session_id,
cwd: self.cwd.clone(),
transcript_path: self.transcript_path.clone(),
event,
repo: None,
model: self.model.clone(),
};
Some(("observe", serde_json::to_value(request).ok()?))
}
}
fn session_event_for(event_name: &str, message: Option<&str>) -> Option<SessionEvent> {
Some(match event_name {
"SessionStart" => SessionEvent::SessionStart,
"UserPromptSubmit" => SessionEvent::UserPromptSubmit,
"PreToolUse" => SessionEvent::PreToolUse,
"PostToolUse" => SessionEvent::PostToolUse,
"Stop" => SessionEvent::Stop,
"Notification" => SessionEvent::Notification(classify_notification(message)),
_ => return None,
})
}
fn classify_notification(message: Option<&str>) -> NotificationKind {
let Some(message) = message else {
return NotificationKind::Other;
};
let lower = message.to_lowercase();
if lower.contains("permission") || lower.contains("approve") || lower.contains("allow") {
NotificationKind::PermissionPrompt
} else if lower.contains("waiting for your input")
|| lower.contains("idle")
|| lower.contains("needs your input")
{
NotificationKind::IdlePrompt
} else {
NotificationKind::Other
}
}
#[derive(Parser)]
pub struct InstallHooksCommand {
#[arg(long, value_name = "PATH")]
pub settings: Option<PathBuf>,
}
impl InstallHooksCommand {
pub fn execute(self) -> Result<()> {
let path = settings_path(self.settings)?;
let mut settings = read_settings(&path)?;
let command = hook_command();
let added = merge_hooks(&mut settings, &command);
write_settings(&path, &settings)?;
if added == 0 {
println!(
"sessions hooks already installed in {} (no change)",
path.display()
);
} else {
println!(
"installed {added} sessions hook event(s) into {}\ncommand: {command}",
path.display()
);
}
Ok(())
}
}
#[derive(Parser)]
pub struct UninstallHooksCommand {
#[arg(long, value_name = "PATH")]
pub settings: Option<PathBuf>,
}
impl UninstallHooksCommand {
pub fn execute(self) -> Result<()> {
let path = settings_path(self.settings)?;
if !path.exists() {
println!("no settings file at {} (nothing to remove)", path.display());
return Ok(());
}
let mut settings = read_settings(&path)?;
let removed = remove_hooks(&mut settings, &hook_command());
write_settings(&path, &settings)?;
println!(
"removed {removed} sessions hook entry(ies) from {}",
path.display()
);
Ok(())
}
}
const HOOK_EVENTS: &[(&str, bool)] = &[
("SessionStart", false),
("UserPromptSubmit", false),
("PreToolUse", true),
("PostToolUse", true),
("Notification", false),
("Stop", false),
("SessionEnd", false),
];
fn hook_command() -> String {
match std::env::current_exe() {
Ok(exe) => format!("{} sessions hook", exe.display()),
Err(_) => "omni-dev sessions hook".to_string(),
}
}
fn settings_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
if let Some(path) = explicit {
return Ok(path);
}
if let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR") {
return Ok(PathBuf::from(dir).join("settings.json"));
}
let home = dirs::home_dir().context("could not resolve the home directory")?;
Ok(home.join(".claude").join("settings.json"))
}
fn read_settings(path: &Path) -> Result<Value> {
if !path.exists() {
return Ok(json!({}));
}
let text = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
if text.trim().is_empty() {
return Ok(json!({}));
}
let value: Value = serde_json::from_str(&text).with_context(|| {
format!(
"{} is not valid JSON; refusing to overwrite it",
path.display()
)
})?;
if !value.is_object() {
bail!(
"{} is not a JSON object; refusing to overwrite it",
path.display()
);
}
Ok(value)
}
fn write_settings(path: &Path, settings: &Value) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let mut text = serde_json::to_string_pretty(settings)?;
text.push('\n');
std::fs::write(path, text).with_context(|| format!("failed to write {}", path.display()))?;
Ok(())
}
fn merge_hooks(settings: &mut Value, command: &str) -> usize {
let Some(root) = settings.as_object_mut() else {
return 0;
};
let hooks = root
.entry("hooks")
.or_insert_with(|| json!({}))
.as_object_mut();
let Some(hooks) = hooks else {
return 0;
};
let mut added = 0;
for (event, needs_matcher) in HOOK_EVENTS {
let groups = hooks
.entry((*event).to_string())
.or_insert_with(|| json!([]));
let Some(groups) = groups.as_array_mut() else {
continue;
};
if groups.iter().any(|g| group_has_command(g, command)) {
continue; }
groups.push(hook_group(command, *needs_matcher));
added += 1;
}
added
}
fn remove_hooks(settings: &mut Value, command: &str) -> usize {
let Some(root) = settings.as_object_mut() else {
return 0;
};
let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) else {
return 0;
};
let mut removed = 0;
let mut empty_events = Vec::new();
for (event, groups) in hooks.iter_mut() {
let Some(groups) = groups.as_array_mut() else {
continue;
};
for group in groups.iter_mut() {
if let Some(inner) = group.get_mut("hooks").and_then(Value::as_array_mut) {
let before = inner.len();
inner.retain(|h| !hook_has_command(h, command));
removed += before - inner.len();
}
}
groups.retain(|g| {
g.get("hooks")
.and_then(Value::as_array)
.map_or(true, |h| !h.is_empty())
});
if groups.is_empty() {
empty_events.push(event.clone());
}
}
for event in empty_events {
hooks.remove(&event);
}
removed
}
fn hook_group(command: &str, needs_matcher: bool) -> Value {
let mut group = json!({
"hooks": [{ "type": "command", "command": command }],
});
if needs_matcher {
group["matcher"] = Value::String("*".to_string());
}
group
}
fn group_has_command(group: &Value, command: &str) -> bool {
group
.get("hooks")
.and_then(Value::as_array)
.is_some_and(|hooks| hooks.iter().any(|h| hook_has_command(h, command)))
}
fn hook_has_command(hook: &Value, command: &str) -> bool {
hook.get("command").and_then(Value::as_str) == Some(command)
}
async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
let reply = DaemonClient::new(socket)
.request(DaemonEnvelope::service(SERVICE, op, payload))
.await?;
reply_payload(reply)
}
fn reply_payload(reply: DaemonReply) -> Result<Value> {
if reply.ok {
Ok(reply.payload)
} else {
bail!(
"daemon returned an error: {}",
reply.error.as_deref().unwrap_or("unknown error")
)
}
}
fn render_sessions(result: &Value) -> String {
let sessions = result
.get("sessions")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
if sessions.is_empty() {
return "No active Claude Code sessions.".to_string();
}
let mut out = format!(
"{:<13} {:<8} {:<20} {:>5} {}",
"STATE", "SOURCE", "REPO", "AGE", "CWD"
);
for session in sessions {
let state = state_display(session.get("state").and_then(Value::as_str).unwrap_or("-"));
let source = source_label(session);
let repo = sanitize(session.get("repo").and_then(Value::as_str).unwrap_or("-"));
let cwd = sanitize(session.get("cwd").and_then(Value::as_str).unwrap_or("-"));
let age = age_secs(session.get("last_seen").and_then(Value::as_str));
out.push_str(&format!(
"\n{state:<13} {source:<8} {repo:<20} {age:>4}s {cwd}"
));
}
out
}
fn state_display(state: &str) -> String {
match state {
"waiting_for_permission" => "waiting-perm".to_string(),
"waiting_for_input" => "waiting-input".to_string(),
other => sanitize(other),
}
}
fn source_label(session: &Value) -> &'static str {
match session.pointer("/source/kind").and_then(Value::as_str) {
Some("vs_code") => "vscode",
_ => "terminal",
}
}
fn sanitize(s: &str) -> String {
s.chars().filter(|c| !c.is_control()).collect()
}
fn age_secs(ts: Option<&str>) -> i64 {
ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map_or(0, |t| {
(Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[derive(Parser)]
struct Wrapper {
#[command(subcommand)]
cmd: SessionsSubcommands,
}
fn parse(args: &[&str]) -> SessionsSubcommands {
let mut full = vec!["omni-dev"];
full.extend_from_slice(args);
Wrapper::try_parse_from(full).unwrap().cmd
}
#[test]
fn subcommands_parse() {
assert!(matches!(parse(&["list"]), SessionsSubcommands::List(_)));
assert!(matches!(parse(&["hook"]), SessionsSubcommands::Hook(_)));
assert!(matches!(
parse(&["install-hooks"]),
SessionsSubcommands::InstallHooks(_)
));
assert!(matches!(
parse(&["uninstall-hooks"]),
SessionsSubcommands::UninstallHooks(_)
));
}
#[test]
fn list_parses_flags() {
let cmd =
ListCommand::try_parse_from(["list", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
assert_eq!(cmd.output, TableOrJson::Json);
assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
}
fn hook_op(json_str: &str) -> Option<(&'static str, Value)> {
serde_json::from_str::<HookPayload>(json_str)
.unwrap()
.to_op()
}
#[test]
fn hook_maps_lifecycle_events_to_observe() {
let (op, payload) = hook_op(
r#"{"session_id":"s1","cwd":"/p","transcript_path":"/t.jsonl","hook_event_name":"PreToolUse"}"#,
)
.unwrap();
assert_eq!(op, "observe");
assert_eq!(payload["session_id"], "s1");
assert_eq!(payload["cwd"], "/p");
assert_eq!(payload["event"], "pre_tool_use");
}
#[test]
fn hook_maps_session_start_and_stop() {
assert_eq!(
hook_op(r#"{"session_id":"s1","hook_event_name":"SessionStart"}"#)
.unwrap()
.1["event"],
"session_start"
);
assert_eq!(
hook_op(r#"{"session_id":"s1","hook_event_name":"Stop"}"#)
.unwrap()
.1["event"],
"stop"
);
}
#[test]
fn hook_maps_session_end_to_end_op() {
let (op, payload) =
hook_op(r#"{"session_id":"s1","hook_event_name":"SessionEnd","message":"exit"}"#)
.unwrap();
assert_eq!(op, "end");
assert_eq!(payload["session_id"], "s1");
assert_eq!(payload["reason"], "exit");
}
#[test]
fn hook_classifies_notifications() {
let permission = hook_op(
r#"{"session_id":"s1","hook_event_name":"Notification","message":"Claude needs your permission to use Bash"}"#,
)
.unwrap();
assert_eq!(permission.1["event"]["notification"], "permission_prompt");
let idle = hook_op(
r#"{"session_id":"s1","hook_event_name":"Notification","message":"Claude is waiting for your input"}"#,
)
.unwrap();
assert_eq!(idle.1["event"]["notification"], "idle_prompt");
let other = hook_op(
r#"{"session_id":"s1","hook_event_name":"Notification","message":"something else"}"#,
)
.unwrap();
assert_eq!(other.1["event"]["notification"], "other");
}
#[test]
fn hook_ignores_unknown_events_and_missing_session_id() {
assert!(hook_op(r#"{"hook_event_name":"Stop"}"#).is_none());
assert!(hook_op(r#"{"session_id":" ","hook_event_name":"Stop"}"#).is_none());
assert!(hook_op(r#"{"session_id":"s1","hook_event_name":"PreCompact"}"#).is_none());
assert!(hook_op("{}").is_none());
}
#[test]
fn classify_notification_covers_cases() {
assert_eq!(
classify_notification(Some("Please approve this")),
NotificationKind::PermissionPrompt
);
assert_eq!(
classify_notification(Some("Claude is idle")),
NotificationKind::IdlePrompt
);
assert_eq!(classify_notification(None), NotificationKind::Other);
}
#[test]
fn merge_hooks_is_idempotent_and_additive() {
let mut settings = json!({
"hooks": {
"PreToolUse": [
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "other-tool" }] }
]
},
"model": "sonnet"
});
let cmd = "/usr/bin/omni-dev sessions hook";
let added = merge_hooks(&mut settings, cmd);
assert_eq!(added, HOOK_EVENTS.len());
for (event, _) in HOOK_EVENTS {
let groups = settings["hooks"][event].as_array().unwrap();
assert!(
groups.iter().any(|g| group_has_command(g, cmd)),
"missing under {event}"
);
}
let pre = settings["hooks"]["PreToolUse"].as_array().unwrap();
assert!(pre.iter().any(|g| group_has_command(g, "other-tool")));
assert_eq!(settings["model"], "sonnet");
assert_eq!(merge_hooks(&mut settings, cmd), 0);
}
#[test]
fn merge_then_remove_round_trips_and_preserves_others() {
let mut settings = json!({
"hooks": {
"PreToolUse": [
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "keep-me" }] }
]
}
});
let cmd = "/usr/bin/omni-dev sessions hook";
merge_hooks(&mut settings, cmd);
let removed = remove_hooks(&mut settings, cmd);
assert_eq!(removed, HOOK_EVENTS.len());
for (event, _) in HOOK_EVENTS {
let empty = settings["hooks"]
.get(event)
.and_then(Value::as_array)
.map_or(true, |g| g.iter().all(|g| !group_has_command(g, cmd)));
assert!(empty, "our hook survived under {event}");
}
let pre = settings["hooks"]["PreToolUse"].as_array().unwrap();
assert!(pre.iter().any(|g| group_has_command(g, "keep-me")));
}
#[test]
fn remove_hooks_prunes_empty_events_entirely() {
let mut settings = json!({});
let cmd = "cmd sessions hook";
merge_hooks(&mut settings, cmd);
remove_hooks(&mut settings, cmd);
let hooks = settings["hooks"].as_object().unwrap();
assert!(hooks.is_empty(), "expected all events pruned: {hooks:?}");
}
#[test]
fn install_uninstall_via_files_round_trips() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("settings.json");
let mut settings = read_settings(&path).unwrap();
merge_hooks(&mut settings, "cmd sessions hook");
write_settings(&path, &settings).unwrap();
assert!(path.exists());
let reloaded = read_settings(&path).unwrap();
assert!(reloaded["hooks"]["Stop"].is_array());
let mut settings = read_settings(&path).unwrap();
remove_hooks(&mut settings, "cmd sessions hook");
write_settings(&path, &settings).unwrap();
let reloaded = read_settings(&path).unwrap();
assert!(reloaded["hooks"].as_object().unwrap().is_empty());
}
#[test]
fn read_settings_rejects_non_json() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("settings.json");
std::fs::write(&path, "not json {").unwrap();
let err = read_settings(&path).unwrap_err();
assert!(err.to_string().contains("not valid JSON"), "{err}");
}
#[test]
fn read_settings_handles_empty_and_non_object() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("settings.json");
std::fs::write(&path, " \n").unwrap();
assert_eq!(read_settings(&path).unwrap(), json!({}));
std::fs::write(&path, "[1, 2, 3]").unwrap();
let err = read_settings(&path).unwrap_err();
assert!(err.to_string().contains("not a JSON object"), "{err}");
}
#[test]
fn hook_command_targets_sessions_hook() {
assert!(hook_command().ends_with("sessions hook"));
}
#[test]
fn render_sessions_handles_empty() {
assert_eq!(
render_sessions(&json!({ "sessions": [] })),
"No active Claude Code sessions."
);
assert_eq!(
render_sessions(&json!({})),
"No active Claude Code sessions."
);
}
#[test]
fn render_sessions_renders_rows_and_source() {
let result = json!({ "sessions": [{
"session_id": "s1",
"state": "working",
"source": { "kind": "vs_code", "window_key": "w1" },
"repo": "omni-dev",
"cwd": "/home/me/omni-dev",
"last_seen": "2000-01-01T00:00:00Z",
}]});
let table = render_sessions(&result);
assert!(table.contains("working"), "{table}");
assert!(table.contains("vscode"), "{table}");
assert!(table.contains("omni-dev"), "{table}");
assert_eq!(table.lines().count(), 2, "{table}");
}
#[test]
fn render_sessions_strips_control_bytes() {
let result = json!({ "sessions": [{
"session_id": "s1",
"state": "wor\x1b[31mking",
"source": { "kind": "terminal" },
"repo": "ev\x07il",
"cwd": "/tmp/a\rb",
"last_seen": "2000-01-01T00:00:00Z",
}]});
let table = render_sessions(&result);
assert!(
!table.contains(|c: char| c.is_control() && c != '\n'),
"{table:?}"
);
assert_eq!(table.lines().count(), 2, "{table:?}");
}
#[test]
fn source_label_maps_kinds() {
assert_eq!(
source_label(&json!({ "source": { "kind": "vs_code", "window_key": "w" } })),
"vscode"
);
assert_eq!(
source_label(&json!({ "source": { "kind": "terminal" } })),
"terminal"
);
assert_eq!(source_label(&json!({})), "terminal");
}
#[test]
fn reply_payload_unwraps_ok_and_maps_errors() {
assert_eq!(
reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
json!({ "a": 1 })
);
let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
assert!(err.to_string().contains("boom"), "{err}");
}
#[test]
fn install_then_uninstall_command_execute_round_trips() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("settings.json");
InstallHooksCommand {
settings: Some(path.clone()),
}
.execute()
.unwrap();
assert!(read_settings(&path).unwrap()["hooks"]["Stop"].is_array());
InstallHooksCommand {
settings: Some(path.clone()),
}
.execute()
.unwrap();
UninstallHooksCommand {
settings: Some(path.clone()),
}
.execute()
.unwrap();
assert!(read_settings(&path).unwrap()["hooks"]
.as_object()
.unwrap()
.is_empty());
}
#[test]
fn uninstall_command_on_a_missing_file_is_a_noop() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("does-not-exist.json");
UninstallHooksCommand {
settings: Some(path.clone()),
}
.execute()
.unwrap();
assert!(!path.exists());
}
#[tokio::test]
async fn sessions_command_dispatches_to_subcommands() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("settings.json");
SessionsCommand {
command: SessionsSubcommands::InstallHooks(InstallHooksCommand {
settings: Some(path.clone()),
}),
}
.execute()
.await
.unwrap();
SessionsCommand {
command: SessionsSubcommands::UninstallHooks(UninstallHooksCommand {
settings: Some(path.clone()),
}),
}
.execute()
.await
.unwrap();
}
#[tokio::test]
async fn hook_report_is_silent_when_the_daemon_is_down() {
let tmp = tempfile::tempdir_in("/tmp").unwrap();
let sock = tmp.path().join("nope.sock");
let cmd = HookCommand { socket: Some(sock) };
cmd.report(r#"{"session_id":"s1","hook_event_name":"Stop"}"#)
.await;
cmd.report("not json").await;
cmd.report(r#"{"hook_event_name":"Stop"}"#).await; }
fn fake_daemon(reply: Value) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<()>) {
use futures::{SinkExt, StreamExt};
use tokio::net::UnixListener;
use tokio_util::codec::{Framed, LinesCodec};
let dir = tempfile::tempdir_in("/tmp").unwrap();
let sock = dir.path().join("d.sock");
let listener = UnixListener::bind(&sock).unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut framed = Framed::new(stream, LinesCodec::new());
let _req = framed.next().await.unwrap().unwrap();
framed
.send(serde_json::to_string(&reply).unwrap())
.await
.unwrap();
});
(dir, sock, server)
}
#[tokio::test]
async fn list_command_execute_renders_from_a_socket() {
let payload = json!({
"ok": true,
"payload": { "sessions": [{
"session_id": "s1", "state": "working",
"source": { "kind": "terminal" }, "repo": "omni-dev",
"cwd": "/home/me/omni-dev", "last_seen": "2000-01-01T00:00:00Z"
}]}
});
let (_dir, sock, server) = fake_daemon(payload.clone());
SessionsCommand {
command: SessionsSubcommands::List(ListCommand {
socket: Some(sock),
output: TableOrJson::Table,
}),
}
.execute()
.await
.unwrap();
server.await.unwrap();
let (_dir, sock, server) = fake_daemon(payload);
ListCommand {
socket: Some(sock),
output: TableOrJson::Json,
}
.execute()
.await
.unwrap();
server.await.unwrap();
}
#[test]
fn merge_and_remove_hooks_tolerate_malformed_shapes() {
let cmd = "cmd sessions hook";
assert_eq!(merge_hooks(&mut json!([]), cmd), 0);
assert_eq!(remove_hooks(&mut json!([]), cmd), 0);
assert_eq!(merge_hooks(&mut json!({ "hooks": 5 }), cmd), 0);
assert_eq!(remove_hooks(&mut json!({}), cmd), 0);
assert_eq!(merge_hooks(&mut json!({ "hooks": { "Stop": 5 } }), cmd), 6);
assert_eq!(remove_hooks(&mut json!({ "hooks": { "Stop": 5 } }), cmd), 0);
}
}