feat: add human session attach and list output

This commit is contained in:
Ogulcan Celik
2026-04-29 21:58:22 +03:00
parent 5baf231e07
commit ded6d7bfa6
7 changed files with 215 additions and 52 deletions
+1 -1
View File
@@ -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:
+2 -2
View File
@@ -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
```
+1 -1
View File
@@ -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
+108 -36
View File
@@ -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");
}
+1
View File
@@ -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");
+72 -7
View File
@@ -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);
@@ -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();
+30 -5
View File
@@ -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())