mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
feat: add human session attach and list output
This commit is contained in:
+1
-1
@@ -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:
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user