diff --git a/bt-daemon/src/command_output.rs b/bt-daemon/src/command_output.rs new file mode 100644 index 0000000..53998c5 --- /dev/null +++ b/bt-daemon/src/command_output.rs @@ -0,0 +1,205 @@ +//! Stable, host-independent output contracts for user-facing trace commands. +//! +//! Embedders such as `bt` own their global `--json` flag, but should delegate +//! the output shape to this crate so every front-end reports daemon commands +//! consistently and JSON mode never falls back to human prose. + +use crate::wire::StatusResult; +use serde::Serialize; +use std::path::PathBuf; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputFormat { + Human, + Json, +} + +impl From for OutputFormat { + fn from(json: bool) -> Self { + if json { + Self::Json + } else { + Self::Human + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct StatusCommandOutput { + pub running: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub daemon_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub uptime_ms: Option, + pub sessions: Vec, +} + +impl From> for StatusCommandOutput { + fn from(status: Option) -> Self { + match status { + Some(status) => Self { + running: true, + daemon_version: Some(status.daemon_version), + uptime_ms: Some(status.uptime_ms), + sessions: status.sessions, + }, + None => Self { + running: false, + daemon_version: None, + uptime_ms: None, + sessions: Vec::new(), + }, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct SetupCommandOutput { + pub source: String, + pub display_name: String, + pub settings_path: PathBuf, + pub restart_required: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct StopCommandOutput { + pub running: bool, + pub stopped: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "command", rename_all = "snake_case")] +pub enum TraceCommandOutput { + Status(StatusCommandOutput), + Setup(SetupCommandOutput), + Stop(StopCommandOutput), +} + +impl TraceCommandOutput { + pub fn status(status: Option) -> Self { + Self::Status(status.into()) + } + + pub fn setup( + source: impl Into, + display_name: impl Into, + settings_path: impl Into, + ) -> Self { + Self::Setup(SetupCommandOutput { + source: source.into(), + display_name: display_name.into(), + settings_path: settings_path.into(), + restart_required: true, + }) + } + + pub fn stop(running: bool, stopped: bool) -> Self { + Self::Stop(StopCommandOutput { running, stopped }) + } + + pub fn render(&self, format: OutputFormat) -> anyhow::Result { + match format { + OutputFormat::Json => Ok(serde_json::to_string(self)?), + OutputFormat::Human => self.render_human(), + } + } + + fn render_human(&self) -> anyhow::Result { + match self { + Self::Status(status) if !status.running => Ok("bt-daemon is not running".into()), + Self::Status(status) => Ok(serde_json::to_string_pretty(&StatusResult { + daemon_version: status.daemon_version.clone().unwrap_or_default(), + uptime_ms: status.uptime_ms.unwrap_or_default(), + sessions: status.sessions.clone(), + })?), + Self::Setup(setup) => Ok(format!( + "The Braintrust tracing plugin is installed for {} and configured in {}.\nRestart the coding agent to load the tracing plugin.", + setup.display_name, + setup.settings_path.display() + )), + Self::Stop(stop) if stop.stopped => Ok("Tracing daemon stopped.".into()), + Self::Stop(_) => Ok("No tracing daemon is running.".into()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn absent_status_is_machine_readable_in_json_mode() { + let output = TraceCommandOutput::status(None); + let rendered = output.render(OutputFormat::Json).unwrap(); + let value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + assert_eq!(value["command"], "status"); + assert_eq!(value["running"], false); + assert_eq!(value["sessions"], serde_json::json!([])); + assert!(value.get("daemon_version").is_none()); + assert!(!rendered.contains("not running")); + } + + #[test] + fn setup_json_contains_stable_selection_fields_without_prose() { + let output = TraceCommandOutput::setup( + "opencode", + "OpenCode", + PathBuf::from("/tmp/opencode/braintrust.json"), + ); + let rendered = output.render(OutputFormat::Json).unwrap(); + let value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + assert_eq!(value["command"], "setup"); + assert_eq!(value["source"], "opencode"); + assert_eq!(value["restart_required"], true); + assert!(!rendered.contains("installed for")); + } + + #[test] + fn stop_json_reports_idempotent_and_successful_shutdowns() { + let absent: serde_json::Value = serde_json::from_str( + &TraceCommandOutput::stop(false, false) + .render(OutputFormat::Json) + .unwrap(), + ) + .unwrap(); + assert_eq!( + absent, + serde_json::json!({ + "command": "stop", + "running": false, + "stopped": false + }) + ); + + let stopped: serde_json::Value = serde_json::from_str( + &TraceCommandOutput::stop(true, true) + .render(OutputFormat::Json) + .unwrap(), + ) + .unwrap(); + assert_eq!( + stopped, + serde_json::json!({ + "command": "stop", + "running": true, + "stopped": true + }) + ); + } + + #[test] + fn human_output_preserves_existing_messages() { + assert_eq!( + TraceCommandOutput::status(None) + .render(OutputFormat::Human) + .unwrap(), + "bt-daemon is not running" + ); + assert_eq!( + TraceCommandOutput::stop(false, false) + .render(OutputFormat::Human) + .unwrap(), + "No tracing daemon is running." + ); + } +} diff --git a/bt-daemon/src/lib.rs b/bt-daemon/src/lib.rs index 627f79e..ac53f71 100644 --- a/bt-daemon/src/lib.rs +++ b/bt-daemon/src/lib.rs @@ -13,6 +13,7 @@ pub mod paths; mod client; +mod command_output; mod dispatch; mod ids; mod journal; @@ -25,6 +26,9 @@ mod transport; pub mod wire; pub use client::HostInfo; +pub use command_output::{ + OutputFormat, SetupCommandOutput, StatusCommandOutput, StopCommandOutput, TraceCommandOutput, +}; pub use server::{AuthLease, AuthProvider, AuthResolveReason, ServeOptions}; pub use sink::{BraintrustSinkConfig, BraintrustSinkFactory, DebugSinkFactory, Sink, SinkFactory}; pub use translate::{ diff --git a/bt-daemon/src/main.rs b/bt-daemon/src/main.rs index 2fb7f6e..87b01aa 100644 --- a/bt-daemon/src/main.rs +++ b/bt-daemon/src/main.rs @@ -9,7 +9,8 @@ use bt_daemon::wire::{AuthSelection, BackendAuth, SessionRoute, TraceDestination use bt_daemon::{ braintrust_serve_options, paths, run_hook, run_import, run_serve, run_status, run_traced, AuthLease, AuthProvider, AuthResolveReason, BraintrustSinkConfig, DebugSinkFactory, HookArgs, - HostInfo, ImportArgs, Registry, RunArgs, RunHookCommand, ServeArgs, ServeOptions, StatusArgs, + HostInfo, ImportArgs, OutputFormat, Registry, RunArgs, RunHookCommand, ServeArgs, ServeOptions, + StatusArgs, TraceCommandOutput, }; use clap::{Args, Parser, Subcommand}; use std::ffi::OsString; @@ -72,6 +73,9 @@ const VERSION: &str = env!("CARGO_PKG_VERSION"); about = "Braintrust coding-agent tracing daemon (standalone test binary)" )] struct Cli { + /// Output user-facing command results as JSON. + #[arg(long, global = true)] + json: bool, #[command(subcommand)] command: Command, } @@ -212,11 +216,9 @@ async fn main() { std::process::exit(0); } Command::Status(args) => match run_status(args).await { - Ok(Some(status)) => { - println!("{}", serde_json::to_string_pretty(&status).unwrap()); - } - Ok(None) => { - println!("bt-daemon is not running"); + Ok(status) => { + let output = TraceCommandOutput::status(status); + println!("{}", output.render(OutputFormat::from(cli.json)).unwrap()); } Err(e) => { eprintln!("bt-daemon status: {e}"); diff --git a/bt-daemon/tests/command_output.rs b/bt-daemon/tests/command_output.rs new file mode 100644 index 0000000..9bf47ee --- /dev/null +++ b/bt-daemon/tests/command_output.rs @@ -0,0 +1,31 @@ +#![cfg(feature = "cli")] + +use std::process::Command; + +#[test] +fn standalone_status_json_is_valid_when_daemon_is_absent() { + #[cfg(unix)] + let temp = tempfile::tempdir().unwrap(); + #[cfg(unix)] + let socket = temp.path().join("missing.sock"); + #[cfg(windows)] + let socket = std::path::PathBuf::from(format!( + r"\\.\pipe\missing-bt-daemon-{}", + uuid::Uuid::new_v4() + )); + + let output = Command::new(env!("CARGO_BIN_EXE_bt-daemon")) + .args(["status", "--json", "--socket"]) + .arg(socket) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(value["command"], "status"); + assert_eq!(value["running"], false); + assert_eq!(value["sessions"], serde_json::json!([])); +}