aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-29 21:58:22 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-29 21:58:22 +0300
commitded6d7bfa6d18fecbaba826837c586a4603cfdb5 (patch)
treed72c10d4c4eea8de59f64df3a87a940ac27481e4
parent5baf231e076ed96bb2aaba245eb53ad0e4501d76 (diff)
feat: add human session attach and list output
-rw-r--r--CONFIGURATION.md2
-rw-r--r--README.md4
-rw-r--r--SOCKET_API.md2
-rw-r--r--src/cli.rs144
-rw-r--r--src/main.rs1
-rw-r--r--src/session.rs79
-rw-r--r--tests/cli_wrapper.rs35
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/<name>/session.json
```
-Use `herdr session list`, `herdr session stop <name>`, and `herdr session delete <name>` to inspect and manage named session namespaces.
+Use `herdr session list`, `herdr session attach <name>`, `herdr session stop <name>`, and `herdr session delete <name>` 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 <name>` 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 <name>`, and `herdr session delete <name>` 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 <name>`, `herdr session stop <name>`, and `herdr session delete <name>` 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<i32> {
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<i32> {
})?)
}
-fn session_list(args: &[String]) -> std::io::Result<i32> {
- if !args.is_empty() {
- eprintln!("usage: herdr session list");
- return Ok(2);
+fn session_attach_help(args: &[String]) -> std::io::Result<i32> {
+ if matches!(
+ args.first().map(String::as_str),
+ Some("help" | "--help" | "-h")
+ ) {
+ eprintln!("usage: herdr session attach <name>");
+ return Ok(0);
}
+ eprintln!("usage: herdr session attach <name>");
+ Ok(2)
+}
+
+fn session_list(args: &[String]) -> std::io::Result<i32> {
+ 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<i32> {
- let Some(name) = args.first() else {
- eprintln!("usage: herdr session stop <name>");
- return Ok(2);
- };
- if args.len() != 1 {
- eprintln!("usage: herdr session stop <name>");
- return Ok(2);
- }
+ let (name, json) =
+ match parse_session_name_and_json(args, "usage: herdr session stop <name> [--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<i32> {
};
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<i32> {
}
fn session_delete(args: &[String]) -> std::io::Result<i32> {
- let Some(name) = args.first() else {
- eprintln!("usage: herdr session delete <name>");
- return Ok(2);
- };
- if args.len() != 1 {
- eprintln!("usage: herdr session delete <name>");
- return Ok(2);
- }
+ let (name, json) =
+ match parse_session_name_and_json(args, "usage: herdr session delete <name> [--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<u64> {
.map_err(|_| std::io::Error::other(format!("invalid value for {flag}: {value}")))
}
+fn parse_session_json_only(args: &[String], usage: &str) -> Result<bool, i32> {
+ 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 <name>");
- eprintln!(" herdr session delete <name>");
+ eprintln!(" herdr session list [--json]");
+ eprintln!(" herdr session attach <name>");
+ eprintln!(" herdr session stop <name> [--json]");
+ eprintln!(" herdr session delete <name> [--json]");
eprintln!(" use 'default' as <name> 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 <name> [options]");
+ println!(" herdr session attach <name>");
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<Vec<String>, 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 <name>".to_string());
+ };
+ if args.len() != 4 {
+ return Err("usage: herdr session attach <name>".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<Vec<String>, 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<Option<String>, String> {
if name == DEFAULT_SESSION_NAME {
return Ok(None);
@@ -326,6 +350,47 @@ mod tests {
}
#[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();
let config_home =
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())