From e88e041637d185473e243266913aef29feacc14d Mon Sep 17 00:00:00 2001 From: codehippie1 Date: Fri, 19 Jun 2026 13:27:42 -0400 Subject: [PATCH 1/2] fix(status): surface a missing Claude Code status line, point at init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit burnwall uninstall strips the statusLine hook from ~/.claude/settings.json, and neither start nor upgrade re-add it — so after a reinstall the editor ribbon silently stays off with nothing to say why. Add a read-only statusline_state() probe (NoClaudeCode / Wired / Missing / Foreign) and nudge `burnwall init --apply` from status, the start banner, and doctor whenever Claude Code is in use but the ribbon isn't wired. Exposed as claude_statusline in status --json. Quiet for non-Claude-Code users and for a user's own custom status line. Bump to 0.12.0. --- CHANGELOG.md | 13 ++++ Cargo.lock | 2 +- Cargo.toml | 2 +- editor/vscode/package.json | 2 +- packaging/mcp/server.json | 2 +- src/cli/claude_settings.rs | 131 +++++++++++++++++++++++++++++++++++++ src/cli/doctor.rs | 62 ++++++++++++++++++ src/cli/start.rs | 15 +++++ src/cli/status.rs | 35 ++++++++++ 9 files changed, 260 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e80dea9..2fccc13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to Burnwall. +## [0.12.0] — Unreleased + +### 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 diff --git a/Cargo.lock b/Cargo.lock index 50aed2c..c3830b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -171,7 +171,7 @@ checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "burnwall" -version = "0.11.1" +version = "0.12.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index d0bed21..c5f22fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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." diff --git a/editor/vscode/package.json b/editor/vscode/package.json index 5a407bf..835aff4 100644 --- a/editor/vscode/package.json +++ b/editor/vscode/package.json @@ -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" }, diff --git a/packaging/mcp/server.json b/packaging/mcp/server.json index da9af88..c79978d 100644 --- a/packaging/mcp/server.json +++ b/packaging/mcp/server.json @@ -6,7 +6,7 @@ "url": "https://github.com/intbot/burnwall", "source": "github" }, - "version": "0.11.1", + "version": "0.12.0", "packages": [ { "registryType": "oci", diff --git a/src/cli/claude_settings.rs b/src/cli/claude_settings.rs index a44bc5b..173c29f 100644 --- a/src/cli/claude_settings.rs +++ b/src/cli/claude_settings.rs @@ -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::(&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 { @@ -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"); + } } diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs index 92ad423..bf758df 100644 --- a/src/cli/doctor.rs +++ b/src/cli/doctor.rs @@ -233,6 +233,10 @@ pub struct DoctorInput { pub security_enabled: bool, pub canaries_armed: usize, pub pricing_age_days: Option, + /// 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 @@ -381,6 +385,7 @@ fn gather(storage: &Storage, days: i64) -> anyhow::Result { 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, @@ -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!( @@ -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")); } @@ -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, @@ -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}"); + } } diff --git a/src/cli/start.rs b/src/cli/start.rs index 8522432..e6558d9 100644 --- a/src/cli/start.rs +++ b/src/cli/start.rs @@ -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 diff --git a/src/cli/status.rs b/src/cli/status.rs index 0701d57..202b82c 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -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. @@ -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, @@ -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. @@ -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, From 7240ffa58c3eb72b6529d56104d09de8dc049806 Mon Sep 17 00:00:00 2001 From: codehippie1 Date: Fri, 26 Jun 2026 22:46:05 -0400 Subject: [PATCH 2/2] docs(changelog): set 0.12.0 release date --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fccc13..93e01d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to Burnwall. -## [0.12.0] — Unreleased +## [0.12.0] — 2026-06-26 ### Fixed