Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions bt-daemon/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,12 @@ or provide it for one invocation with `bt trace run`. Both accept
`--additional-metadata` or `BRAINTRUST_ADDITIONAL_METADATA`:

```bash
bt trace setup claude --additional-metadata '{"team":"platform"}'
bt trace enable claude --additional-metadata '{"team":"platform"}'
BRAINTRUST_ADDITIONAL_METADATA='{"ci":true,"run_id":"'"$JOB_ID"'"}' \
bt trace run codex -- "summarize this change"
```

`bt trace setup`, `bt trace run`, and `bt trace import` are explicit,
`bt trace enable`, `bt trace run`, and `bt trace import` are explicit,
user-invoked commands, so all three accept routing (profile, organization,
project, destination) and `additional_metadata` from either a flag or the
matching `BRAINTRUST_*` environment variable, with the flag winning if both
Expand All @@ -84,6 +84,10 @@ run`, from that invocation's settings. An explicit `--additional-metadata`
flag or environment variable on `bt trace run` overrides the persisted route
for that invocation only, without mutating the file.

Use `bt trace disable <agent>` to remove the installed tracing plugin and its
Braintrust settings. `bt trace setup <agent>` remains an alias for `bt trace
enable <agent>` for backwards compatibility.

## Build / test

```bash
Expand Down
29 changes: 24 additions & 5 deletions bt-daemon/src/command_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ pub struct StopCommandOutput {
#[serde(tag = "command", rename_all = "snake_case")]
pub enum TraceCommandOutput {
Status(StatusCommandOutput),
Setup(SetupCommandOutput),
Enable(SetupCommandOutput),
Disable(SetupCommandOutput),
Stop(StopCommandOutput),
}

Expand All @@ -85,7 +86,7 @@ impl TraceCommandOutput {
display_name: impl Into<String>,
settings_path: impl Into<PathBuf>,
) -> Self {
Self::Setup(SetupCommandOutput {
Self::Enable(SetupCommandOutput {
source: source.into(),
display_name: display_name.into(),
settings_path: settings_path.into(),
Expand All @@ -97,6 +98,19 @@ impl TraceCommandOutput {
Self::Stop(StopCommandOutput { running, stopped })
}

pub fn disable(
source: impl Into<String>,
display_name: impl Into<String>,
settings_path: impl Into<PathBuf>,
) -> Self {
Self::Disable(SetupCommandOutput {
source: source.into(),
display_name: display_name.into(),
settings_path: settings_path.into(),
restart_required: true,
})
}

pub fn render(&self, format: OutputFormat) -> anyhow::Result<String> {
match format {
OutputFormat::Json => Ok(serde_json::to_string(self)?),
Expand All @@ -112,11 +126,16 @@ impl TraceCommandOutput {
uptime_ms: status.uptime_ms.unwrap_or_default(),
sessions: status.sessions.clone(),
})?),
Self::Setup(setup) => Ok(format!(
Self::Enable(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::Disable(disable) => Ok(format!(
"The Braintrust tracing plugin and configuration were removed for {} from {}.\nRestart the coding agent to apply the change.",
disable.display_name,
disable.settings_path.display()
)),
Self::Stop(stop) if stop.stopped => Ok("Tracing daemon stopped.".into()),
Self::Stop(_) => Ok("No tracing daemon is running.".into()),
}
Expand All @@ -140,15 +159,15 @@ mod tests {
}

#[test]
fn setup_json_contains_stable_selection_fields_without_prose() {
fn enable_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["command"], "enable");
assert_eq!(value["source"], "opencode");
assert_eq!(value["restart_required"], true);
assert!(!rendered.contains("installed for"));
Expand Down
6 changes: 4 additions & 2 deletions bt-daemon/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@ pub use command_output::{
OutputFormat, SetupCommandOutput, StatusCommandOutput, StopCommandOutput, TraceCommandOutput,
};
pub use server::{AuthLease, AuthProvider, AuthResolveReason, ServeOptions};
pub use setup::run_setup;
pub use setup::{run_disable, run_enable, run_setup};
pub use sink::{BraintrustSinkConfig, BraintrustSinkFactory, DebugSinkFactory, Sink, SinkFactory};
pub use trace_command::{SetupAgent, SetupArgs, StopArgs, TraceArgs, TraceCommand};
pub use trace_command::{
DisableArgs, EnableArgs, SetupAgent, SetupArgs, StopArgs, TraceArgs, TraceCommand,
};
pub use trace_runtime::{run_trace, RouteRequirements, TraceHostContext, TraceHostServices};
pub use translate::{
AgentTranslator, Registry, SessionCtx, SpanOp, SpanRow, SpanType, TranslatorFactory,
Expand Down
168 changes: 166 additions & 2 deletions bt-daemon/src/setup.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Persistent installation and configuration for coding-agent tracing plugins.

use crate::paths;
use crate::trace_command::{SetupAgent, SetupArgs};
use crate::trace_command::{EnableArgs, SetupAgent};
use crate::wire::SessionRoute;
use crate::TraceCommandOutput;
use anyhow::{bail, Context};
Expand Down Expand Up @@ -109,6 +109,22 @@ fn setup_codex(runner: &mut impl CommandRunner) -> anyhow::Result<()> {
runner.run("codex", &["plugin", "add", CODEX_PLUGIN])
}

fn codex_plugin(value: &Value) -> Option<&Value> {
value
.get("installed")
.and_then(Value::as_array)?
.iter()
.find(|item| item.get("pluginId").and_then(Value::as_str) == Some(CODEX_PLUGIN))
}

fn disable_codex(runner: &mut impl CommandRunner) -> anyhow::Result<()> {
let plugins = runner.json("codex", &["plugin", "list", "--json"])?;
if codex_plugin(&plugins).is_some() {
runner.run("codex", &["plugin", "remove", CODEX_PLUGIN, "--json"])?;
}
Ok(())
}

fn claude_marketplace(value: &Value) -> Option<&Value> {
value
.as_array()?
Expand Down Expand Up @@ -180,6 +196,14 @@ fn setup_claude(runner: &mut impl CommandRunner) -> anyhow::Result<()> {
}
}

fn disable_claude(runner: &mut impl CommandRunner) -> anyhow::Result<()> {
let plugins = runner.json("claude", &["plugin", "list", "--json"])?;
if claude_plugin(&plugins).is_some() {
runner.run("claude", &["plugin", "uninstall", CLAUDE_PLUGIN])?;
}
Ok(())
}

fn load_object(path: &Path) -> anyhow::Result<Map<String, Value>> {
match std::fs::read(path) {
Ok(raw) => {
Expand Down Expand Up @@ -254,10 +278,60 @@ fn setup_opencode() -> anyhow::Result<()> {
setup_opencode_at(&path)
}

fn remove_opencode_plugin_at(path: &Path) -> anyhow::Result<()> {
let mut config = match std::fs::read(path) {
Ok(raw) => serde_json::from_slice::<Value>(&raw)
.with_context(|| format!("invalid JSON configuration: {}", path.display()))?
.as_object()
.cloned()
.ok_or_else(|| {
anyhow::anyhow!("configuration must be a JSON object: {}", path.display())
})?,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(error)
.with_context(|| format!("failed to read configuration: {}", path.display()))
}
};
let Some(plugins) = config.get_mut("plugin") else {
return Ok(());
};
let plugins = plugins.as_array_mut().ok_or_else(|| {
anyhow::anyhow!(
"OpenCode `plugin` config must be an array: {}",
path.display()
)
})?;
let original_len = plugins.len();
plugins.retain(|plugin| {
plugin.as_str().is_none_or(|plugin| {
plugin != "@braintrust/trace-opencode"
&& !plugin.starts_with("@braintrust/trace-opencode@")
})
});
if plugins.len() != original_len {
write_object_atomic(path, config)?;
}
Ok(())
}

fn disable_opencode() -> anyhow::Result<()> {
let settings_path = paths::agent_settings_path("opencode", None);
let path = settings_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join("opencode.json");
remove_opencode_plugin_at(&path)
}

fn setup_pi(runner: &mut impl CommandRunner) -> anyhow::Result<()> {
runner.run("pi", &["install", PI_PLUGIN])
}

fn disable_pi(runner: &mut impl CommandRunner) -> anyhow::Result<()> {
runner.run("pi", &["uninstall", PI_PLUGIN])
}

fn enable_tracing_at(path: &Path, mut route: SessionRoute) -> anyhow::Result<()> {
let mut settings = load_object(path)?;
if route.additional_metadata.is_none() {
Expand Down Expand Up @@ -288,9 +362,46 @@ fn enable_tracing(source: &str, route: SessionRoute) -> anyhow::Result<PathBuf>
Ok(path)
}

fn remove_tracing_settings(path: &Path) -> anyhow::Result<()> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error)
.with_context(|| format!("failed to remove tracing settings: {}", path.display())),
}
}

/// Uninstall an agent's tracing adapter and remove its Braintrust-owned settings.
pub fn run_disable(agent: SetupAgent) -> anyhow::Result<TraceCommandOutput> {
let mut runner = SystemCommandRunner;
let (source, display_name) = agent_details(agent);
match agent {
SetupAgent::Codex => disable_codex(&mut runner)?,
SetupAgent::Claude => disable_claude(&mut runner)?,
SetupAgent::OpenCode => disable_opencode()?,
SetupAgent::Pi => disable_pi(&mut runner)?,
}
let settings_path = paths::agent_settings_path(source, None);
remove_tracing_settings(&settings_path)?;
Ok(TraceCommandOutput::disable(
source,
display_name,
settings_path,
))
}

fn agent_details(agent: SetupAgent) -> (&'static str, &'static str) {
match agent {
SetupAgent::Codex => ("codex", "Codex"),
SetupAgent::Claude => ("claude", "Claude Code"),
SetupAgent::OpenCode => ("opencode", "OpenCode"),
SetupAgent::Pi => ("pi", "Pi"),
}
}

/// Install or refresh one agent's published tracing adapter and persist its
/// non-secret route selection.
pub fn run_setup(args: SetupArgs, route: SessionRoute) -> anyhow::Result<TraceCommandOutput> {
pub fn run_enable(args: EnableArgs, route: SessionRoute) -> anyhow::Result<TraceCommandOutput> {
let mut runner = SystemCommandRunner;
let (source, display_name) = match args.agent {
SetupAgent::Codex => {
Expand Down Expand Up @@ -318,6 +429,11 @@ pub fn run_setup(args: SetupArgs, route: SessionRoute) -> anyhow::Result<TraceCo
))
}

/// Backwards-compatible library entry point for hosts that used the former setup name.
pub fn run_setup(args: EnableArgs, route: SessionRoute) -> anyhow::Result<TraceCommandOutput> {
run_enable(args, route)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -575,4 +691,52 @@ mod tests {
serde_json::json!({"run_id": "new"})
);
}

#[test]
fn disabling_removes_only_the_braintrust_settings_file() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("braintrust.json");
std::fs::write(
&path,
r#"{"traceToBraintrust":true,"route":{"destination":{"project_name":"coding-agents"}},"other":true}"#,
)
.unwrap();

remove_tracing_settings(&path).unwrap();
assert!(!path.exists());
}

#[test]
fn disabling_installed_plugins_uses_each_agents_uninstall_command() {
let mut codex = FakeRunner::new([serde_json::json!({
"installed": [{"pluginId": CODEX_PLUGIN}]
})]);
disable_codex(&mut codex).unwrap();
assert!(codex.called("codex plugin remove trace-codex@braintrust-codex-plugins --json"));

let mut claude = FakeRunner::new([serde_json::json!([{"id": CLAUDE_PLUGIN}])]);
disable_claude(&mut claude).unwrap();
assert!(claude.called("claude plugin uninstall trace-claude-code@braintrust-claude-plugin"));

let mut pi = FakeRunner::new([]);
disable_pi(&mut pi).unwrap();
assert!(pi.called("pi uninstall npm:@braintrust/pi-extension@^1"));
}

#[test]
fn disabling_opencode_removes_only_the_managed_plugin() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("opencode.json");
std::fs::write(
&path,
r#"{"plugin":["other","@braintrust/trace-opencode@^1"],"model":"test/model"}"#,
)
.unwrap();

remove_opencode_plugin_at(&path).unwrap();

let config: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
assert_eq!(config["plugin"], serde_json::json!(["other"]));
assert_eq!(config["model"], "test/model");
}
}
27 changes: 24 additions & 3 deletions bt-daemon/src/trace_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ pub struct TraceArgs {
// clearer than boxing individual command variants for stack-size savings.
#[allow(clippy::large_enum_variant)]
pub enum TraceCommand {
/// Install the published Braintrust tracing plugin for a coding agent.
Setup(SetupArgs),
/// Install or enable the published Braintrust tracing plugin for a coding agent.
#[command(name = "enable", alias = "setup")]
Setup(EnableArgs),
/// Uninstall the Braintrust tracing plugin and remove its saved configuration.
Disable(DisableArgs),
/// Run the tracing daemon (foreground).
#[command(hide = true)]
Daemon(ServeArgs),
Expand All @@ -47,14 +50,23 @@ pub struct StopArgs {
}

#[derive(Debug, Clone, Args)]
pub struct SetupArgs {
pub struct EnableArgs {
#[command(subcommand)]
pub agent: SetupAgent,
/// JSON object persisted in this agent's tracing route and merged into root-span metadata.
#[arg(long, global = true, env = "BRAINTRUST_ADDITIONAL_METADATA")]
pub additional_metadata: Option<String>,
}

/// Backwards-compatible API name for hosts that mounted the former setup command.
pub type SetupArgs = EnableArgs;

#[derive(Debug, Clone, Args)]
pub struct DisableArgs {
#[command(subcommand)]
pub agent: SetupAgent,
}

#[derive(Debug, Clone, Copy, Subcommand)]
pub enum SetupAgent {
/// Install the published Codex tracing plugin.
Expand Down Expand Up @@ -97,6 +109,15 @@ mod tests {
}) if value == r#"{"setup":true}"#
));

let legacy_setup = Cli::try_parse_from(["bt", "setup", "codex"]).unwrap();
assert!(matches!(
legacy_setup.trace.command,
TraceCommand::Setup(SetupArgs {
agent: SetupAgent::Codex,
..
})
));

let hook = Cli::try_parse_from([
"bt",
"hook",
Expand Down
Loading
Loading