aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.toml9
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--docs/next/website/src/content/docs/cli-reference.mdx12
-rw-r--r--docs/next/website/src/content/docs/socket-api.mdx16
-rw-r--r--src/cli.rs2
-rw-r--r--src/cli/api.rs102
-rw-r--r--src/main.rs5
-rw-r--r--tests/cli_wrapper.rs87
8 files changed, 233 insertions, 1 deletions
diff --git a/Cargo.toml b/Cargo.toml
index 1e7f3112..d30b1162 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,7 +9,14 @@ repository = "https://github.com/ogulcancelik/herdr"
homepage = "https://herdr.dev"
keywords = ["terminal", "tui", "ai", "agents", "multiplexer"]
categories = ["command-line-utilities"]
-include = ["src/**/*", "assets/sounds/*", "README.md", "LICENSE", "Cargo.toml"]
+include = [
+ "src/**/*",
+ "assets/sounds/*",
+ "docs/next/api/herdr-api.schema.json",
+ "README.md",
+ "LICENSE",
+ "Cargo.toml",
+]
[dependencies]
base64 = "0.22.1"
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index b05a2618..4a1470f0 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -4,6 +4,7 @@
### Added
- Added `session.snapshot` to bootstrap client runtime state in one socket API response before subscribing to events.
+- Added `herdr api schema` to inspect the bundled socket API schema, with `--json` for the full JSON Schema document and `--output PATH` for file output.
### Changed
- Bumped the client/server protocol version to 15 for socket API placement mutation event and response compatibility.
diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx
index d841e564..361d09ac 100644
--- a/docs/next/website/src/content/docs/cli-reference.mdx
+++ b/docs/next/website/src/content/docs/cli-reference.mdx
@@ -33,6 +33,18 @@ herdr status server
herdr status client
```
+API schema commands:
+
+```bash
+herdr api schema
+herdr api schema --json
+herdr api schema --output herdr-api.schema.json
+```
+
+`herdr api schema` prints a short summary of the socket protocol schema bundled
+with the installed binary. Use `--json` for the full JSON Schema document, or
+`--output PATH` to write that document to a file.
+
## Server
```bash
diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx
index 08cd2107..f84bd5e6 100644
--- a/docs/next/website/src/content/docs/socket-api.mdx
+++ b/docs/next/website/src/content/docs/socket-api.mdx
@@ -17,6 +17,22 @@ Most automation should start with the CLI wrappers. Use the raw socket API only
The layers share the same control surface.
+## Schema
+
+The installed CLI can print the socket protocol schema bundled with that Herdr
+binary:
+
+```bash
+herdr api schema
+herdr api schema --json
+herdr api schema --output herdr-api.schema.json
+```
+
+Plain `herdr api schema` prints a short summary. `--json` prints the full JSON
+Schema document for tools, and `--output PATH` writes that document to a file.
+The schema covers raw requests, success responses, error responses, emitted
+events, and subscription events.
+
## What you can control
The socket API can:
diff --git a/src/cli.rs b/src/cli.rs
index 3facba8b..0e0daf54 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -9,6 +9,7 @@ use crate::api::schema::{
};
mod agent;
+mod api;
mod integration;
mod notification;
mod pane;
@@ -49,6 +50,7 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> {
};
exit_code
}
+ "api" => api::run_api_command(&args[2..])?,
"status" => status::run_status_command(&args[2..])?,
"config" => run_config_command(&args[2..])?,
"channel" => run_channel_command(&args[2..])?,
diff --git a/src/cli/api.rs b/src/cli/api.rs
new file mode 100644
index 00000000..e931f4f8
--- /dev/null
+++ b/src/cli/api.rs
@@ -0,0 +1,102 @@
+const API_SCHEMA_JSON: &str = include_str!("../../docs/next/api/herdr-api.schema.json");
+
+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();
+ return Ok(2);
+ };
+
+ match subcommand {
+ "schema" => api_schema(&args[1..]),
+ "help" | "--help" | "-h" => {
+ print_api_help();
+ Ok(0)
+ }
+ _ => {
+ print_api_help();
+ Ok(2)
+ }
+ }
+}
+
+fn api_schema(args: &[String]) -> std::io::Result<i32> {
+ match args {
+ [] => {
+ print!("{}", schema_summary_text()?);
+ }
+ [flag] if flag == "--json" => {
+ print!("{API_SCHEMA_JSON}");
+ }
+ [flag, path] if flag == "--output" => {
+ write_schema_file(std::path::Path::new(path))?;
+ println!("wrote API schema to {path}");
+ }
+ [flag] if flag == "--output" => {
+ eprintln!("missing value for --output");
+ return Ok(2);
+ }
+ [flag] if matches!(flag.as_str(), "help" | "--help" | "-h") => {
+ print_api_schema_help();
+ }
+ [other] if other.starts_with('-') => {
+ eprintln!("unknown option: {other}");
+ return Ok(2);
+ }
+ _ => {
+ print_api_schema_help();
+ return Ok(2);
+ }
+ }
+ Ok(0)
+}
+
+fn write_schema_file(path: &std::path::Path) -> std::io::Result<()> {
+ std::fs::write(path, API_SCHEMA_JSON)
+}
+
+fn schema_summary_text() -> std::io::Result<String> {
+ let value: serde_json::Value = serde_json::from_str(API_SCHEMA_JSON)?;
+ let protocol = value
+ .get("protocol")
+ .and_then(serde_json::Value::as_u64)
+ .ok_or_else(|| std::io::Error::other("API schema is missing protocol"))?;
+ let schema_version = value
+ .get("schema_version")
+ .and_then(serde_json::Value::as_u64)
+ .ok_or_else(|| std::io::Error::other("API schema is missing schema_version"))?;
+ let mut schemas: Vec<&str> = value
+ .get("schemas")
+ .and_then(serde_json::Value::as_object)
+ .ok_or_else(|| std::io::Error::other("API schema is missing schemas"))?
+ .keys()
+ .map(String::as_str)
+ .collect();
+ schemas.sort();
+
+ Ok(format!(
+ "Herdr API schema\nprotocol: {}\nschema_version: {}\nschemas: {}\n\nUse `herdr api schema --json` to print the full schema.\nUse `herdr api schema --output PATH` to write it to a file.\n",
+ protocol,
+ schema_version,
+ schemas.join(", ")
+ ))
+}
+
+fn print_api_help() {
+ eprintln!("herdr api commands:");
+ eprintln!(" herdr api schema [--json | --output PATH]");
+}
+
+fn print_api_schema_help() {
+ eprintln!("usage: herdr api schema [--json | --output PATH]");
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn schema_summary_text_stays_human_sized() {
+ let text = super::schema_summary_text().unwrap();
+ assert!(text.contains("Herdr API schema"));
+ assert!(text.contains("Use `herdr api schema --json`"));
+ assert!(text.len() < 400);
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 597b332e..ea46585f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -486,6 +486,7 @@ fn main() -> io::Result<()> {
println!(" herdr channel set <stable|preview>");
println!(" herdr server stop");
println!(" herdr server reload-config");
+ println!(" herdr api <subcommand> ...");
println!(" herdr config <subcommand> ...");
println!(" herdr channel <subcommand> ...");
println!(" herdr workspace <subcommand> ...");
@@ -527,6 +528,10 @@ fn main() -> io::Result<()> {
"Manage the stable or preview update channel",
),
(
+ "herdr api <subcommand>",
+ "Inspect the bundled socket API schema",
+ ),
+ (
"herdr workspace <subcommand>",
"Workspace helpers over the socket API",
),
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs
index 542d9768..ea402515 100644
--- a/tests/cli_wrapper.rs
+++ b/tests/cli_wrapper.rs
@@ -1029,6 +1029,8 @@ fn help_commands_exit_successfully() {
let help_cases: &[&[&str]] = &[
&["-h"],
&["--help"],
+ &["api", "-h"],
+ &["api", "schema", "-h"],
&["status", "-h"],
&["server", "-h"],
&["workspace", "-h"],
@@ -1073,6 +1075,91 @@ fn root_help_hides_explicit_client_command() {
}
#[test]
+fn root_help_advertises_api_schema_command_group() {
+ let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .arg("--help")
+ .output()
+ .unwrap();
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ assert!(
+ stdout.contains("herdr api <subcommand>"),
+ "root help should advertise the api command group: {stdout}"
+ );
+}
+
+#[test]
+fn api_schema_default_output_is_a_short_summary() {
+ let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .args(["api", "schema"])
+ .output()
+ .unwrap();
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ assert!(stdout.contains("Herdr API schema"), "stdout: {stdout}");
+ assert!(
+ stdout.contains("Use `herdr api schema --json`"),
+ "stdout: {stdout}"
+ );
+ assert!(
+ stdout.len() < 400,
+ "summary should stay small enough for terminal output: {stdout}"
+ );
+}
+
+#[test]
+fn api_schema_json_prints_bundled_schema() {
+ let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .args(["api", "schema", "--json"])
+ .output()
+ .unwrap();
+
+ assert!(output.status.success());
+ let schema: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
+ assert!(schema
+ .get("protocol")
+ .and_then(serde_json::Value::as_u64)
+ .is_some_and(|protocol| protocol > 0));
+ assert_eq!(
+ schema
+ .get("schemas")
+ .and_then(serde_json::Value::as_object)
+ .map(serde_json::Map::len),
+ Some(5)
+ );
+}
+
+#[test]
+fn api_schema_output_writes_bundled_schema_to_file() {
+ let base = unique_test_dir();
+ fs::create_dir_all(&base).unwrap();
+ let schema_path = base.join("herdr-api.schema.json");
+
+ let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .args(["api", "schema", "--output"])
+ .arg(&schema_path)
+ .output()
+ .unwrap();
+
+ assert!(output.status.success());
+ assert!(
+ String::from_utf8_lossy(&output.stdout).contains("wrote API schema"),
+ "stdout: {}",
+ String::from_utf8_lossy(&output.stdout)
+ );
+ let schema: serde_json::Value =
+ serde_json::from_slice(&fs::read(&schema_path).unwrap()).unwrap();
+ assert!(schema
+ .get("protocol")
+ .and_then(serde_json::Value::as_u64)
+ .is_some_and(|protocol| protocol > 0));
+
+ cleanup_test_base(&base);
+}
+
+#[test]
fn explicit_client_command_respects_nested_guard() {
let base = unique_test_dir();
fs::create_dir_all(&base).unwrap();