aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-04 15:46:31 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-04 16:51:10 +0300
commit5d9212a55acf4db05b8c5cdf3fee3c56ac404cf7 (patch)
tree956276a6b85a52e3a5e0703eb0e5ddf7bc5f9f3c
parent15cab964f3494b77159caaea289e17e1596e0720 (diff)
feat: expose session snapshot through cli
-rw-r--r--docs/next/website/src/content/docs/socket-api.mdx3
-rw-r--r--src/cli/api.rs16
-rw-r--r--src/cli/spec.rs3
-rw-r--r--src/main.rs2
-rw-r--r--tests/cli_wrapper.rs51
5 files changed, 73 insertions, 2 deletions
diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx
index dcf32e0e..64c5ac4e 100644
--- a/docs/next/website/src/content/docs/socket-api.mdx
+++ b/docs/next/website/src/content/docs/socket-api.mdx
@@ -121,6 +121,9 @@ Call `session.snapshot` again after reconnecting or when the local cache may be
stale. Attached worktree provenance is included on workspace records. Full repo
worktree discovery remains `worktree.list`.
+From the CLI, `herdr api snapshot` prints the live `session.snapshot` response
+as JSON for clients and agents that want a simple bootstrap command.
+
Pane control methods use public pane ids such as `w1:p1`. Methods whose
schema makes `pane_id` optional use the server's active focused pane when it is
omitted. `pane.move` always requires the source `pane_id`.
diff --git a/src/cli/api.rs b/src/cli/api.rs
index e931f4f8..418072e2 100644
--- a/src/cli/api.rs
+++ b/src/cli/api.rs
@@ -1,5 +1,7 @@
const API_SCHEMA_JSON: &str = include_str!("../../docs/next/api/herdr-api.schema.json");
+use crate::api::schema::{EmptyParams, Method, Request};
+
pub(super) fn run_api_command(args: &[String]) -> std::io::Result<i32> {
let Some(subcommand) = args.first().map(String::as_str) else {
print_api_help();
@@ -8,6 +10,7 @@ pub(super) fn run_api_command(args: &[String]) -> std::io::Result<i32> {
match subcommand {
"schema" => api_schema(&args[1..]),
+ "snapshot" => api_snapshot(&args[1..]),
"help" | "--help" | "-h" => {
print_api_help();
Ok(0)
@@ -50,6 +53,18 @@ fn api_schema(args: &[String]) -> std::io::Result<i32> {
Ok(0)
}
+fn api_snapshot(args: &[String]) -> std::io::Result<i32> {
+ if !args.is_empty() {
+ eprintln!("usage: herdr api snapshot");
+ return Ok(2);
+ }
+
+ super::print_response(&super::send_request(&Request {
+ id: "cli:api:snapshot".into(),
+ method: Method::SessionSnapshot(EmptyParams::default()),
+ })?)
+}
+
fn write_schema_file(path: &std::path::Path) -> std::io::Result<()> {
std::fs::write(path, API_SCHEMA_JSON)
}
@@ -83,6 +98,7 @@ fn schema_summary_text() -> std::io::Result<String> {
fn print_api_help() {
eprintln!("herdr api commands:");
+ eprintln!(" herdr api snapshot");
eprintln!(" herdr api schema [--json | --output PATH]");
}
diff --git a/src/cli/spec.rs b/src/cli/spec.rs
index 71ecd9cc..088bc15e 100644
--- a/src/cli/spec.rs
+++ b/src/cli/spec.rs
@@ -128,7 +128,8 @@ fn server_command() -> Command {
fn api_command() -> Command {
Command::new("api")
- .about("Inspect socket API metadata")
+ .about("Inspect socket API metadata and live runtime state")
+ .subcommand(Command::new("snapshot").about("Print the live session snapshot"))
.subcommand(
Command::new("schema")
.about("Print or write the bundled API schema")
diff --git a/src/main.rs b/src/main.rs
index 1876c8e5..04ff4894 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -546,7 +546,7 @@ fn main() -> io::Result<()> {
),
(
"herdr api <subcommand>",
- "Inspect the bundled socket API schema",
+ "Inspect socket API metadata and live runtime state",
),
(
"herdr workspace <subcommand>",
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs
index 41e924ee..c1bf27c0 100644
--- a/tests/cli_wrapper.rs
+++ b/tests/cli_wrapper.rs
@@ -1173,6 +1173,57 @@ fn api_schema_json_prints_bundled_schema() {
}
#[test]
+fn api_snapshot_prints_live_session_snapshot() {
+ let base = unique_test_dir();
+ fs::create_dir_all(&base).unwrap();
+ let socket_path = base.join("herdr.sock");
+ let listener = UnixListener::bind(&socket_path).unwrap();
+
+ let server = thread::spawn({
+ let socket_path = socket_path.clone();
+ move || {
+ listener.set_nonblocking(true).unwrap();
+ let deadline = Instant::now() + Duration::from_millis(700);
+ while Instant::now() < deadline {
+ match listener.accept() {
+ Ok((mut stream, _)) => {
+ let mut line = String::new();
+ let mut reader = BufReader::new(stream.try_clone().unwrap());
+ reader.read_line(&mut line).unwrap();
+ let request: serde_json::Value = serde_json::from_str(&line).unwrap();
+ assert_eq!(request["method"], "session.snapshot");
+ assert_eq!(request["id"], "cli:api:snapshot");
+
+ let response = serde_json::json!({
+ "id": "cli:api:snapshot",
+ "result": {
+ "type": "ok",
+ "marker": "snapshot-passthrough"
+ }
+ });
+ writeln!(stream, "{response}").unwrap();
+ stream.flush().unwrap();
+ let _ = fs::remove_file(socket_path);
+ return;
+ }
+ Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
+ thread::sleep(Duration::from_millis(10));
+ }
+ Err(err) => panic!("accept failed: {err}"),
+ }
+ }
+ panic!("CLI did not connect to fake API socket");
+ }
+ });
+
+ let value = run_cli_json(&socket_path, &["api", "snapshot"]);
+
+ assert_eq!(value["result"]["marker"], "snapshot-passthrough");
+ server.join().unwrap();
+ cleanup_test_base(&base);
+}
+
+#[test]
fn api_schema_output_writes_bundled_schema_to_file() {
let base = unique_test_dir();
fs::create_dir_all(&base).unwrap();