From ded6d7bfa6d18fecbaba826837c586a4603cfdb5 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Wed, 29 Apr 2026 21:58:22 +0300 Subject: [PATCH] feat: add human session attach and list output --- CONFIGURATION.md | 2 +- README.md | 4 +- SOCKET_API.md | 2 +- src/cli.rs | 144 ++++++++++++++++++++++++++++++++----------- src/main.rs | 1 + src/session.rs | 79 +++++++++++++++++++++--- tests/cli_wrapper.rs | 35 +++++++++-- 7 files changed, 215 insertions(+), 52 deletions(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 06afdb48..6bf06b27 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -13,7 +13,7 @@ Named sessions share this config file. Sessions are runtime/socket namespaces, n ~/.config/herdr/sessions//session.json ``` -Use `herdr session list`, `herdr session stop `, and `herdr session delete ` to inspect and manage named session namespaces. +Use `herdr session list`, `herdr session attach `, `herdr session stop `, and `herdr session delete ` to inspect and manage named session namespaces. Add `--json` to session commands when scripts need machine-readable output. print the full default config with: diff --git a/README.md b/README.md index 8ecaea54..f1e237bc 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,9 @@ by default herdr launches or attaches to one background session server. `ctrl+b named sessions are runtime/socket namespaces for separate persistent herdr servers. they do not replace workspaces; each named session has its own panes, tabs, workspaces, sockets, and session state while sharing the same global config file. ```bash -herdr --session work -herdr --session side-project herdr session list +herdr session attach work +herdr session attach side-project herdr session stop work herdr session delete side-project ``` diff --git a/SOCKET_API.md b/SOCKET_API.md index ea834e14..3baf31e3 100644 --- a/SOCKET_API.md +++ b/SOCKET_API.md @@ -37,7 +37,7 @@ socket path resolution order: this means `HERDR_SOCKET_PATH` remains an exact low-level socket override, but an explicit cli `--session ` still wins when a command runs inside a pane that inherited `HERDR_SOCKET_PATH`. -session names may contain ASCII letters, numbers, `.`, `_`, and `-`. `default` is reserved for the default session. use `herdr session list`, `herdr session stop `, and `herdr session delete ` to inspect and manage session namespaces. `session delete` refuses running sessions and does not delete the default session. +session names may contain ASCII letters, numbers, `.`, `_`, and `-`. `default` is reserved for the default session. use `herdr session list`, `herdr session attach `, `herdr session stop `, and `herdr session delete ` to inspect and manage session namespaces. session commands print human-readable output by default; pass `--json` for machine-readable output. `session delete` refuses running sessions and does not delete the default session. ## request and response envelopes diff --git a/src/cli.rs b/src/cli.rs index 138815fa..13482e69 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -339,6 +339,7 @@ fn run_session_command(args: &[String]) -> std::io::Result { match subcommand { "list" => session_list(&args[1..]), + "attach" => session_attach_help(&args[1..]), "stop" => session_stop(&args[1..]), "delete" => session_delete(&args[1..]), "help" | "--help" | "-h" => { @@ -373,30 +374,43 @@ fn server_reload_config(args: &[String]) -> std::io::Result { })?) } -fn session_list(args: &[String]) -> std::io::Result { - if !args.is_empty() { - eprintln!("usage: herdr session list"); - return Ok(2); +fn session_attach_help(args: &[String]) -> std::io::Result { + if matches!( + args.first().map(String::as_str), + Some("help" | "--help" | "-h") + ) { + eprintln!("usage: herdr session attach "); + return Ok(0); } + eprintln!("usage: herdr session attach "); + Ok(2) +} + +fn session_list(args: &[String]) -> std::io::Result { + let json = match parse_session_json_only(args, "usage: herdr session list [--json]") { + Ok(json) => json, + Err(code) => return Ok(code), + }; let sessions = crate::session::list_sessions()?; - _print_json(&serde_json::json!({ - "sessions": sessions, - })); + if json { + _print_json(&serde_json::json!({ + "sessions": sessions, + })); + } else { + print_session_table(&sessions); + } Ok(0) } fn session_stop(args: &[String]) -> std::io::Result { - let Some(name) = args.first() else { - eprintln!("usage: herdr session stop "); - return Ok(2); - }; - if args.len() != 1 { - eprintln!("usage: herdr session stop "); - return Ok(2); - } + let (name, json) = + match parse_session_name_and_json(args, "usage: herdr session stop [--json]") { + Ok(parsed) => parsed, + Err(code) => return Ok(code), + }; - let target = match crate::session::parse_target_name(name) { + let target = match crate::session::parse_target_name(&name) { Ok(target) => target, Err(message) => { print_session_error("invalid_session_name", &message); @@ -405,10 +419,14 @@ fn session_stop(args: &[String]) -> std::io::Result { }; match crate::session::stop_session(target.as_deref()) { Ok(session) => { - _print_json(&serde_json::json!({ - "stopped": true, - "session": session, - })); + if json { + _print_json(&serde_json::json!({ + "stopped": true, + "session": session, + })); + } else { + println!("stopped session {}", session.name); + } Ok(0) } Err(message) => { @@ -419,21 +437,22 @@ fn session_stop(args: &[String]) -> std::io::Result { } fn session_delete(args: &[String]) -> std::io::Result { - let Some(name) = args.first() else { - eprintln!("usage: herdr session delete "); - return Ok(2); - }; - if args.len() != 1 { - eprintln!("usage: herdr session delete "); - return Ok(2); - } + let (name, json) = + match parse_session_name_and_json(args, "usage: herdr session delete [--json]") { + Ok(parsed) => parsed, + Err(code) => return Ok(code), + }; - match crate::session::delete_session(name) { + match crate::session::delete_session(&name) { Ok(session) => { - _print_json(&serde_json::json!({ - "deleted": true, - "session": session, - })); + if json { + _print_json(&serde_json::json!({ + "deleted": true, + "session": session, + })); + } else { + println!("deleted session {}", session.name); + } Ok(0) } Err(message) => { @@ -1317,6 +1336,58 @@ fn parse_u64_flag(flag: &str, value: &str) -> std::io::Result { .map_err(|_| std::io::Error::other(format!("invalid value for {flag}: {value}"))) } +fn parse_session_json_only(args: &[String], usage: &str) -> Result { + match args { + [] => Ok(false), + [flag] if flag == "--json" => Ok(true), + _ => { + eprintln!("{usage}"); + Err(2) + } + } +} + +fn parse_session_name_and_json(args: &[String], usage: &str) -> Result<(String, bool), i32> { + let mut name = None; + let mut json = false; + for arg in args { + if arg == "--json" { + json = true; + } else if name.is_none() { + name = Some(arg.clone()); + } else { + eprintln!("{usage}"); + return Err(2); + } + } + + let Some(name) = name else { + eprintln!("{usage}"); + return Err(2); + }; + Ok((name, json)) +} + +fn print_session_table(sessions: &[crate::session::SessionInfo]) { + println!( + "{:<20} {:<8} {:<48} {}", + "name", "status", "directory", "socket" + ); + for session in sessions { + println!( + "{:<20} {:<8} {:<48} {}", + session.name, + if session.running { + "running" + } else { + "stopped" + }, + session.session_dir, + session.socket_path + ); + } +} + fn print_session_error(code: &str, message: &str) { eprintln!( "{}", @@ -1400,9 +1471,10 @@ fn print_integration_help() { fn print_session_help() { eprintln!("herdr session commands:"); - eprintln!(" herdr session list"); - eprintln!(" herdr session stop "); - eprintln!(" herdr session delete "); + eprintln!(" herdr session list [--json]"); + eprintln!(" herdr session attach "); + eprintln!(" herdr session stop [--json]"); + eprintln!(" herdr session delete [--json]"); eprintln!(" use 'default' as to target the default session for stop"); } diff --git a/src/main.rs b/src/main.rs index cf5842c4..d0a535da 100644 --- a/src/main.rs +++ b/src/main.rs @@ -200,6 +200,7 @@ fn main() -> io::Result<()> { println!(); println!("Usage: herdr [options]"); println!(" herdr --session [options]"); + println!(" herdr session attach "); println!(" herdr update"); println!(" herdr server stop"); println!(" herdr server reload-config"); diff --git a/src/session.rs b/src/session.rs index feeb9740..b164fecc 100644 --- a/src/session.rs +++ b/src/session.rs @@ -28,6 +28,25 @@ pub fn configure_from_args(args: &[String]) -> Result, String> { cleaned.push(program.clone()); } + if args.get(1).map(String::as_str) == Some("session") + && args.get(2).map(String::as_str) == Some("attach") + { + if matches!( + args.get(3).map(String::as_str), + Some("help" | "--help" | "-h") + ) { + return Ok(args.to_vec()); + } + let Some(name) = args.get(3) else { + return Err("usage: herdr session attach ".to_string()); + }; + if args.len() != 4 { + return Err("usage: herdr session attach ".to_string()); + } + apply_explicit_name(name)?; + return Ok(cleaned); + } + let mut requested_session = None; let mut index = 1; while index < args.len() { @@ -51,13 +70,7 @@ pub fn configure_from_args(args: &[String]) -> Result, String> { } if let Some(session) = requested_session { - let session = normalize_name(&session)?; - if let Some(session) = session { - std::env::set_var(SESSION_ENV_VAR, session); - } else { - std::env::remove_var(SESSION_ENV_VAR); - } - EXPLICIT_SESSION_REQUESTED.store(true, Ordering::Relaxed); + apply_explicit_name(&session)?; } else if std::env::var_os(crate::api::SOCKET_PATH_ENV_VAR).is_some() { EXPLICIT_SESSION_REQUESTED.store(false, Ordering::Relaxed); } else if let Ok(session) = std::env::var(SESSION_ENV_VAR) { @@ -264,6 +277,17 @@ pub fn validate_name(name: &str) -> Result<(), String> { Ok(()) } +fn apply_explicit_name(name: &str) -> Result<(), String> { + let session = normalize_name(name)?; + if let Some(session) = session { + std::env::set_var(SESSION_ENV_VAR, session); + } else { + std::env::remove_var(SESSION_ENV_VAR); + } + EXPLICIT_SESSION_REQUESTED.store(true, Ordering::Relaxed); + Ok(()) +} + fn normalize_name(name: &str) -> Result, String> { if name == DEFAULT_SESSION_NAME { return Ok(None); @@ -325,6 +349,47 @@ mod tests { clear_explicit_session_for_test(); } + #[test] + fn configure_from_args_rewrites_session_attach_to_default_launch() { + let _guard = env_lock().lock().unwrap(); + std::env::set_var(SESSION_ENV_VAR, "bad/name"); + std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, "/tmp/inherited.sock"); + clear_explicit_session_for_test(); + let args = vec![ + "herdr".to_string(), + "session".to_string(), + "attach".to_string(), + "work".to_string(), + ]; + + let cleaned = configure_from_args(&args).unwrap(); + + assert_eq!(std::env::var(SESSION_ENV_VAR).as_deref(), Ok("work")); + assert!(explicit_session_requested()); + assert_eq!(cleaned, vec!["herdr"]); + std::env::remove_var(SESSION_ENV_VAR); + std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); + clear_explicit_session_for_test(); + } + + #[test] + fn configure_from_args_leaves_session_attach_help_for_cli_dispatch() { + let _guard = env_lock().lock().unwrap(); + std::env::remove_var(SESSION_ENV_VAR); + clear_explicit_session_for_test(); + let args = vec![ + "herdr".to_string(), + "session".to_string(), + "attach".to_string(), + "-h".to_string(), + ]; + + let cleaned = configure_from_args(&args).unwrap(); + + assert_eq!(cleaned, args); + assert!(!explicit_session_requested()); + } + #[test] fn configure_from_args_maps_default_session_name_to_default_path() { let _guard = env_lock().lock().unwrap(); diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index 03bfaad4..e0dbeebb 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -571,6 +571,7 @@ fn help_commands_exit_successfully() { &["pane", "-h"], &["wait", "-h"], &["session", "-h"], + &["session", "attach", "-h"], &["integration", "-h"], ]; @@ -703,7 +704,25 @@ fn named_sessions_use_separate_servers_and_workspace_state() { .collect(); assert_eq!(labels_via_explicit, vec!["beta-ws"]); - let sessions = run_named_cli_json(&config_home, &runtime_dir, &["session", "list"]); + let human_sessions = run_named_cli(&config_home, &runtime_dir, &["session", "list"]); + assert!(human_sessions.status.success()); + let human_sessions = String::from_utf8_lossy(&human_sessions.stdout); + assert!(human_sessions.contains("name"), "stdout: {human_sessions}"); + assert!( + human_sessions.contains("status"), + "stdout: {human_sessions}" + ); + assert!(human_sessions.contains("alpha"), "stdout: {human_sessions}"); + assert!( + human_sessions.contains("running"), + "stdout: {human_sessions}" + ); + assert!( + human_sessions.contains("/sessions/beta"), + "stdout: {human_sessions}" + ); + + let sessions = run_named_cli_json(&config_home, &runtime_dir, &["session", "list", "--json"]); let sessions = sessions["sessions"].as_array().unwrap(); let default_session = sessions .iter() @@ -750,13 +769,19 @@ fn named_sessions_use_separate_servers_and_workspace_state() { String::from_utf8_lossy(&delete_default.stderr) ); - let stopped_alpha = - run_named_cli_json(&config_home, &runtime_dir, &["session", "stop", "alpha"]); + let stopped_alpha = run_named_cli_json( + &config_home, + &runtime_dir, + &["session", "stop", "alpha", "--json"], + ); assert_eq!(stopped_alpha["stopped"], true); assert_eq!(stopped_alpha["session"]["running"], false); - let deleted_alpha = - run_named_cli_json(&config_home, &runtime_dir, &["session", "delete", "alpha"]); + let deleted_alpha = run_named_cli_json( + &config_home, + &runtime_dir, + &["session", "delete", "alpha", "--json"], + ); assert_eq!(deleted_alpha["deleted"], true); assert!(!config_home .join(app_dir_name())