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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

All notable changes to Burnwall.

## [0.12.0] — 2026-06-26

### Fixed

- **`burnwall status` now tells you when the Claude Code status line isn't
wired.** If Claude Code is in use but its Burnwall ribbon was never set up —
the state a fresh install or a prior `burnwall uninstall` leaves behind, since
neither `start` nor `upgrade` touch the editor integration — `status` now says
so and points at `burnwall init --apply`. The same one-line nudge appears in
the `burnwall start` banner and in `burnwall doctor`, and the state is exposed
as `claude_statusline` in `status --json` for the editor extension. Quiet for
users who don't run Claude Code or who set their own status line.

## [0.11.1] — 2026-06-18

A resilience release for the proxy lifecycle: stopping Burnwall can no longer
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "burnwall"
version = "0.11.1"
version = "0.12.0"
edition = "2024"
rust-version = "1.87"
description = "Local proxy for AI coding tools (Claude Code, Codex CLI, Aider): cache-aware cost tracking, path/command security checks, daily budget enforcement. Zero telemetry."
Expand Down
2 changes: 1 addition & 1 deletion editor/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "burnwall",
"displayName": "Burnwall",
"description": "Cost + security for your AI coding agents, at a glance — reads your local Burnwall CLI.",
"version": "0.11.1",
"version": "0.12.0",
"publisher": "intbot",
"license": "FSL-1.1-MIT",
"repository": { "type": "git", "url": "https://github.com/intbot/burnwall" },
Expand Down
2 changes: 1 addition & 1 deletion packaging/mcp/server.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"url": "https://github.com/intbot/burnwall",
"source": "github"
},
"version": "0.11.1",
"version": "0.12.0",
"packages": [
{
"registryType": "oci",
Expand Down
131 changes: 131 additions & 0 deletions src/cli/claude_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,75 @@ fn is_ours(statusline: &serde_json::Value) -> bool {
.unwrap_or(false)
}

/// The Burnwall ↔ Claude Code status-line wiring, as seen by read-only surfaces
/// (`burnwall status`, `doctor`, the start banner). Lets them nudge
/// `burnwall init` when Claude Code is in use but the ribbon was never wired —
/// the gap a fresh install or a prior `uninstall` leaves, since `start` /
/// `upgrade` only manage the proxy and never touch `settings.json`. Stays quiet
/// for users who don't run Claude Code or who chose their own status line.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum StatuslineState {
/// No `~/.claude` directory — Claude Code isn't in use here. Say nothing.
NoClaudeCode,
/// Our `burnwall statusline` is wired up. All good.
Wired,
/// Claude Code is present but no Burnwall status line is configured — what a
/// fresh install or a prior `uninstall` leaves behind. Nudge `init`.
Missing,
/// A *different* `statusLine` is configured. The user's choice — leave it,
/// and don't nudge.
Foreign,
}

impl StatuslineState {
/// Stable lowercase tag for JSON / the IDE extension.
pub fn tag(self) -> &'static str {
match self {
StatuslineState::NoClaudeCode => "none",
StatuslineState::Wired => "wired",
StatuslineState::Missing => "missing",
StatuslineState::Foreign => "foreign",
}
}
}

/// Inspect the Claude Code status-line wiring. `settings` is
/// `~/.claude/settings.json`; its parent (`~/.claude`) existing is how we tell
/// Claude Code is in use at all. Read-only and best-effort: an unreadable or
/// unparseable settings file is reported as [`StatuslineState::Foreign`], so we
/// neither nudge into nor offer to rewrite a file we can't understand.
pub fn statusline_state(settings: &Path) -> StatuslineState {
let claude_present = settings.parent().map(|d| d.is_dir()).unwrap_or(false);
if !claude_present {
return StatuslineState::NoClaudeCode;
}
match std::fs::read_to_string(settings) {
// Claude Code is here, but settings are empty/absent → not wired yet.
Ok(s) if s.trim().is_empty() => StatuslineState::Missing,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => StatuslineState::Missing,
Ok(s) => match serde_json::from_str::<serde_json::Value>(&s) {
Ok(v) => match v.get("statusLine") {
Some(sl) if is_ours(sl) => StatuslineState::Wired,
Some(_) => StatuslineState::Foreign,
None => StatuslineState::Missing,
},
// Unparseable: stay quiet rather than nudge into a file `install`
// would refuse to touch.
Err(_) => StatuslineState::Foreign,
},
Err(_) => StatuslineState::Foreign,
}
}

/// [`statusline_state`] at the default `~/.claude/settings.json`. Returns
/// [`StatuslineState::NoClaudeCode`] when the home directory can't be resolved.
pub fn statusline_state_default() -> StatuslineState {
match settings_path() {
Some(p) => statusline_state(&p),
None => StatuslineState::NoClaudeCode,
}
}

/// Outcome of [`install`], so the caller can print an honest status line.
#[derive(Debug, PartialEq, Eq)]
pub enum InstallOutcome {
Expand Down Expand Up @@ -262,4 +331,66 @@ mod tests {
let (_d, path) = tmp();
assert!(!remove(&path).unwrap());
}

// ----- statusline_state (the read-only discoverability primitive) -----

#[test]
fn statusline_state_no_claude_dir_is_silent() {
// Parent `.claude` directory absent → Claude Code isn't in use → never
// nudge.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".claude").join("settings.json");
assert_eq!(statusline_state(&path), StatuslineState::NoClaudeCode);
}

#[test]
fn statusline_state_missing_when_claude_present_but_unwired() {
// The post-`uninstall` / fresh-install gap: Claude Code dir + settings
// exist, but no `statusLine`.
let (_d, path) = tmp();
std::fs::write(&path, r#"{"theme":"dark"}"#).unwrap();
assert_eq!(statusline_state(&path), StatuslineState::Missing);
}

#[test]
fn statusline_state_missing_when_settings_file_absent() {
// `~/.claude` exists (tempdir is the parent) but settings.json doesn't:
// Claude Code present, nothing wired → Missing, not NoClaudeCode.
let (_d, path) = tmp();
assert_eq!(statusline_state(&path), StatuslineState::Missing);
}

#[test]
fn statusline_state_wired_after_install() {
let (_d, path) = tmp();
install(&path).unwrap();
assert_eq!(statusline_state(&path), StatuslineState::Wired);
}

#[test]
fn statusline_state_foreign_is_left_alone() {
let (_d, path) = tmp();
std::fs::write(
&path,
r#"{"statusLine":{"type":"command","command":"my-custom-bar.sh"}}"#,
)
.unwrap();
assert_eq!(statusline_state(&path), StatuslineState::Foreign);
}

#[test]
fn statusline_state_malformed_json_stays_quiet() {
// We won't nudge into (or offer to rewrite) a file we can't parse.
let (_d, path) = tmp();
std::fs::write(&path, "{not json").unwrap();
assert_eq!(statusline_state(&path), StatuslineState::Foreign);
}

#[test]
fn statusline_state_tags_are_stable() {
assert_eq!(StatuslineState::NoClaudeCode.tag(), "none");
assert_eq!(StatuslineState::Wired.tag(), "wired");
assert_eq!(StatuslineState::Missing.tag(), "missing");
assert_eq!(StatuslineState::Foreign.tag(), "foreign");
}
}
62 changes: 62 additions & 0 deletions src/cli/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,10 @@ pub struct DoctorInput {
pub security_enabled: bool,
pub canaries_armed: usize,
pub pricing_age_days: Option<i64>,
/// Claude Code status-line wiring: `wired`, `missing` (Claude Code present
/// but the ribbon isn't set up — `burnwall init` fixes it), `foreign` (a
/// custom status line we leave alone), or `none` (Claude Code not in use).
pub claude_statusline: &'static str,
pub total_cost: f64,
pub total_requests: i64,
/// Enforcement blocks in the window (requests actually stopped) — kept
Expand Down Expand Up @@ -381,6 +385,7 @@ fn gather(storage: &Storage, days: i64) -> anyhow::Result<DoctorInput> {
security_enabled: cfg.security.enabled,
canaries_armed,
pricing_age_days: crate::pricing::pricing_age_days(chrono::Local::now().date_naive()),
claude_statusline: crate::cli::claude_settings::statusline_state_default().tag(),
total_cost,
total_requests,
blocked_events,
Expand Down Expand Up @@ -602,6 +607,18 @@ fn print_health(out: &mut impl Write, i: &DoctorInput) -> anyhow::Result<()> {
writeln!(out)?;
}

// Editor integration: Claude Code is in use but the Burnwall ribbon isn't
// wired (a fresh install / a prior `uninstall` left it off). Informational,
// not an alarm — protection is unaffected; the user just loses the glance.
if i.claude_statusline == "missing" {
writeln!(
out,
" {} Claude Code status line not wired — `burnwall init --apply` shows cost + protection in the editor.",
sty.orange("ℹ")
)?;
writeln!(out)?;
}

let bs = |n: i64| if n == 1 { "" } else { "s" };
let window = format!("Last {} day{}", i.days, bs(i.days));
writeln!(
Expand Down Expand Up @@ -674,6 +691,10 @@ pub fn build_report(i: &DoctorInput) -> String {
}
s.push_str(&format!("- security enabled: {}\n", i.security_enabled));
s.push_str(&format!("- canary tripwires armed: {}\n", i.canaries_armed));
s.push_str(&format!(
"- claude code status line: {}\n",
i.claude_statusline
));
if let Some(age) = i.pricing_age_days {
s.push_str(&format!("- pricing data age (days): {age}\n"));
}
Expand Down Expand Up @@ -888,6 +909,7 @@ mod tests {
security_enabled: true,
canaries_armed: 1,
pricing_age_days: Some(3),
claude_statusline: "wired",
total_cost: 1.23,
total_requests: 10,
blocked_events: 1,
Expand Down Expand Up @@ -1075,4 +1097,44 @@ mod tests {
assert!(r.contains("UNPROTECTED"), "{r}");
assert!(r.contains("routing configured (env file): active"), "{r}");
}

#[test]
fn health_nudges_when_claude_statusline_missing() {
let i = DoctorInput {
claude_statusline: "missing",
..sample_input()
};
let mut buf = Vec::new();
print_health(&mut buf, &i).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("status line not wired"), "{out}");
assert!(out.contains("burnwall init --apply"), "{out}");
}

#[test]
fn health_quiet_when_statusline_wired_or_foreign() {
for state in ["wired", "foreign", "none"] {
let i = DoctorInput {
claude_statusline: state,
..sample_input()
};
let mut buf = Vec::new();
print_health(&mut buf, &i).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(
!out.contains("status line not wired"),
"state {state} must not nudge: {out}"
);
}
}

#[test]
fn export_records_claude_statusline_state() {
let i = DoctorInput {
claude_statusline: "missing",
..sample_input()
};
let r = build_report(&i);
assert!(r.contains("claude code status line: missing"), "{r}");
}
}
15 changes: 15 additions & 0 deletions src/cli/start.rs
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,21 @@ pub(crate) fn resume_and_report(proxy_url: &str) {
left.join(", ")
);
}
print_statusline_hint();
}

/// One-line start-banner nudge when Claude Code is installed but its Burnwall
/// status line isn't wired (a fresh install, or a prior `uninstall` that
/// stripped it — neither `start` nor `upgrade` re-adds it). Mirrors the
/// `burnwall status` readout so the same fix surfaces at both first-run
/// touchpoints. Quiet for non-Claude-Code users and a user's own status line.
fn print_statusline_hint() {
use crate::cli::claude_settings::{StatuslineState, statusline_state_default};
if statusline_state_default() == StatuslineState::Missing {
println!(
" Status line: Claude Code detected, but its Burnwall ribbon isn't wired — `burnwall init --apply` to show cost + protection in the editor."
);
}
}

/// Resolve `logging.file` (with `~/` expansion) to a concrete path. Empty
Expand Down
35 changes: 35 additions & 0 deletions src/cli/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,12 @@ pub fn run_cmd(args: StatusArgs) -> anyhow::Result<()> {

write_coverage(&mut out, &coverage, &sty)?;

// Editor integration: nudge when Claude Code is in use but its Burnwall
// status line was never wired (a fresh install, or a prior `uninstall`
// that stripped it — `start`/`upgrade` never re-add it). Silent for
// non-Claude-Code users and for a user's own custom status line.
write_statusline_hint(&mut out, &sty)?;

// Contextual usage nudge (v0.11): at most one data-driven line, gated
// to once/day. Drawn from the user's own data; quiet when there's no
// real finding. Never on the glanceable status line.
Expand Down Expand Up @@ -429,6 +435,29 @@ fn write_routing(w: &mut impl Write, sty: &Styler) -> std::io::Result<()> {
}
}

/// Nudge when Claude Code is installed but its Burnwall status line isn't wired.
/// The ribbon is set up by `burnwall init`, never by `start`/`upgrade`, so a
/// fresh install or a prior `uninstall` leaves it off with nothing to say so —
/// this is that missing signal. Quiet for non-Claude-Code users (`NoClaudeCode`)
/// and for a user's own custom status line (`Foreign`).
fn write_statusline_hint(w: &mut impl Write, sty: &Styler) -> std::io::Result<()> {
use crate::cli::claude_settings::{StatuslineState, statusline_state_default};
if statusline_state_default() == StatuslineState::Missing {
writeln!(w)?;
writeln!(
w,
" {} Claude Code is set up here, but its Burnwall status line isn't wired.",
sty.orange("ℹ No status line —")
)?;
writeln!(
w,
" Show live cost + protection in the editor: {}",
sty.bold("burnwall init --apply")
)?;
}
Ok(())
}

#[allow(clippy::too_many_arguments)]
fn write_table(
w: &mut impl Write,
Expand Down Expand Up @@ -842,6 +871,11 @@ fn write_json(
// retiring when idle — surfaces should show it as stopped, not green.
let protection_draining = matches!(bypass_now, crate::bypass::Bypass::Draining);

// Claude Code editor-integration state for the IDE extension / scripts:
// `wired`, `missing` (Claude Code present but the ribbon isn't set up —
// run `burnwall init`), `foreign` (a custom status line), or `none`.
let claude_statusline = crate::cli::claude_settings::statusline_state_default().tag();

// De-duplicated cross-tool total (X4): excludes log rows of tools whose
// provider flowed through the proxy today, so proxied Claude Code isn't
// counted twice in the headline figure.
Expand All @@ -859,6 +893,7 @@ fn write_json(
"protection_paused": protection_paused,
"pause_resumes_in_secs": pause_resumes_in_secs,
"protection_draining": protection_draining,
"claude_statusline": claude_statusline,
"total_cost_usd": today_cost,
"total_requests": total_requests,
"blocked_requests": blocked,
Expand Down
Loading