Skip to content

Commit 1a75455

Browse files
committed
v0.9.10: status-line auto-wiring in init + burnwall uninstall
- `burnwall init --apply` now merges a `statusLine` block into ~/.claude/settings.json when Claude Code is detected. Idempotent, preserves your other settings, writes the PATH-resolved `burnwall statusline` command, and never overwrites a status line you already configured. - new `burnwall uninstall` reverses everything install + init set up: stops the proxy, removes the login service, the Claude Code status line, shell routing (env file + rc hook), and the binary. Cost history is kept unless `--purge`. Confirms first (skip with `--yes`); refuses non-interactive without `--yes`. - bump 0.9.9 -> 0.9.10 (Cargo, vscode, mcp server.json, CHANGELOG).
1 parent 007734f commit 1a75455

10 files changed

Lines changed: 597 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,24 @@ All notable changes to Burnwall.
44

55
## Unreleased
66

7+
## [0.9.10] — 2026-06-08
8+
9+
### Added
10+
11+
- **`burnwall init` now wires up the Claude Code status line.** When Claude Code
12+
is detected, `init --apply` merges a `statusLine` block into
13+
`~/.claude/settings.json` so the Burnwall ribbon (model · ↑/↓ tokens · spend)
14+
appears automatically — no hand-editing JSON. The merge is idempotent,
15+
preserves your other settings, writes the PATH-resolved `burnwall statusline`
16+
command, and never overwrites a status line you already configured.
17+
- **`burnwall uninstall`** — one command to undo everything `install` + `init`
18+
set up: stops the proxy, removes the login service, removes the Claude Code
19+
status line (a foreign one is left untouched), empties the routing env file and
20+
removes the rc-source hook, and removes the binary. Your cost-history database
21+
is kept by default; `--purge` deletes the whole `~/.burnwall` data directory.
22+
Confirms before acting (skip with `--yes`); refuses to run non-interactively
23+
without `--yes`.
24+
725
### Changed
826

927
- `burnwall upgrade` now sweeps the leftover `burnwall.exe.old` from a previous

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "burnwall"
3-
version = "0.9.9"
3+
version = "0.9.10"
44
edition = "2024"
55
rust-version = "1.87"
66
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."

editor/vscode/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "burnwall",
33
"displayName": "Burnwall",
44
"description": "Cost + security for your AI coding agents, at a glance — reads your local Burnwall CLI.",
5-
"version": "0.9.9",
5+
"version": "0.9.10",
66
"publisher": "intbot",
77
"license": "FSL-1.1-MIT",
88
"repository": { "type": "git", "url": "https://github.com/intbot/burnwall" },

packaging/mcp/server.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"url": "https://github.com/intbot/burnwall",
77
"source": "github"
88
},
9-
"version": "0.9.9",
9+
"version": "0.9.10",
1010
"packages": [
1111
{
1212
"registryType": "oci",

src/cli/claude_settings.rs

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
//! Wire (and unwire) the Burnwall ribbon into Claude Code's
2+
//! `~/.claude/settings.json` `statusLine` block.
3+
//!
4+
//! Claude Code reads a custom status line from a `statusLine` object in its
5+
//! settings file. `burnwall statusline` renders that line, but nothing wired
6+
//! it up for the user — they had to hand-edit JSON. `init --apply` now calls
7+
//! [`install`]; `uninstall` calls [`remove`].
8+
//!
9+
//! ## Principles
10+
//!
11+
//! - **Idempotent merge.** We parse the existing settings, set *only* the
12+
//! `statusLine` key, and write everything else back untouched. Re-running is
13+
//! a no-op.
14+
//! - **Never clobber a foreign status line.** If the user already points
15+
//! `statusLine` at something that isn't ours, we leave it alone and report
16+
//! it — security software doesn't silently overwrite your config.
17+
//! - **PATH-resolved command.** We write `"burnwall statusline"`, not an
18+
//! absolute path, so the wiring survives a reinstall to a different dir
19+
//! (the installer puts `burnwall` on PATH).
20+
21+
use std::path::{Path, PathBuf};
22+
23+
use anyhow::{Context, Result};
24+
25+
/// The command we write into `statusLine.command`. PATH-resolved on purpose —
26+
/// see the module docs.
27+
pub const STATUSLINE_COMMAND: &str = "burnwall statusline";
28+
29+
/// `~/.claude/settings.json`. Same location on every OS.
30+
pub fn settings_path() -> Option<PathBuf> {
31+
dirs::home_dir().map(|h| h.join(".claude").join("settings.json"))
32+
}
33+
34+
/// Our canonical `statusLine` value.
35+
fn our_statusline() -> serde_json::Value {
36+
serde_json::json!({
37+
"type": "command",
38+
"command": STATUSLINE_COMMAND,
39+
"padding": 0
40+
})
41+
}
42+
43+
/// Does an existing `statusLine` value look like ours? True if its `command`
44+
/// mentions both `burnwall` and `statusline` — this matches the PATH form
45+
/// (`burnwall statusline`) and any absolute-path form
46+
/// (`…/burnwall.exe statusline`) a user may have hand-written, so `remove`
47+
/// cleans those up too.
48+
fn is_ours(statusline: &serde_json::Value) -> bool {
49+
statusline
50+
.get("command")
51+
.and_then(|c| c.as_str())
52+
.map(|c| {
53+
let lc = c.to_lowercase();
54+
lc.contains("burnwall") && lc.contains("statusline")
55+
})
56+
.unwrap_or(false)
57+
}
58+
59+
/// Outcome of [`install`], so the caller can print an honest status line.
60+
#[derive(Debug, PartialEq, Eq)]
61+
pub enum InstallOutcome {
62+
/// We added (or refreshed) the Burnwall status line.
63+
Wrote,
64+
/// A Burnwall status line identical to ours was already present.
65+
AlreadyOurs,
66+
/// A *different* `statusLine` is configured — we left it untouched. The
67+
/// string is its `command`, for the message.
68+
ForeignPresent(String),
69+
}
70+
71+
/// Parse `settings.json` into an object, tolerating a missing file (→ empty
72+
/// object) but not malformed JSON (we won't blindly overwrite a file we can't
73+
/// understand).
74+
fn read_object(path: &Path) -> Result<serde_json::Map<String, serde_json::Value>> {
75+
match std::fs::read_to_string(path) {
76+
Ok(s) if s.trim().is_empty() => Ok(serde_json::Map::new()),
77+
Ok(s) => {
78+
let v: serde_json::Value = serde_json::from_str(&s)
79+
.with_context(|| format!("parsing {} (not valid JSON)", path.display()))?;
80+
match v {
81+
serde_json::Value::Object(m) => Ok(m),
82+
_ => anyhow::bail!("{} is not a JSON object", path.display()),
83+
}
84+
}
85+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(serde_json::Map::new()),
86+
Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
87+
}
88+
}
89+
90+
/// Pretty-write the object back as `settings.json`, creating `~/.claude` if
91+
/// needed. Trailing newline so the file is POSIX-tidy.
92+
fn write_object(path: &Path, obj: &serde_json::Map<String, serde_json::Value>) -> Result<()> {
93+
if let Some(parent) = path.parent() {
94+
std::fs::create_dir_all(parent)
95+
.with_context(|| format!("creating {}", parent.display()))?;
96+
}
97+
let mut s = serde_json::to_string_pretty(&serde_json::Value::Object(obj.clone()))?;
98+
s.push('\n');
99+
std::fs::write(path, s).with_context(|| format!("writing {}", path.display()))?;
100+
Ok(())
101+
}
102+
103+
/// Merge the Burnwall `statusLine` into `path`. Idempotent; never clobbers a
104+
/// foreign status line.
105+
pub fn install(path: &Path) -> Result<InstallOutcome> {
106+
let mut obj = read_object(path)?;
107+
if let Some(existing) = obj.get("statusLine") {
108+
if is_ours(existing) {
109+
// Refresh only if the value drifted from canonical (e.g. an old
110+
// absolute-path form) — otherwise it's a true no-op.
111+
if existing == &our_statusline() {
112+
return Ok(InstallOutcome::AlreadyOurs);
113+
}
114+
} else {
115+
let cmd = existing
116+
.get("command")
117+
.and_then(|c| c.as_str())
118+
.unwrap_or("<non-command status line>")
119+
.to_string();
120+
return Ok(InstallOutcome::ForeignPresent(cmd));
121+
}
122+
}
123+
obj.insert("statusLine".to_string(), our_statusline());
124+
write_object(path, &obj)?;
125+
Ok(InstallOutcome::Wrote)
126+
}
127+
128+
/// Remove the Burnwall `statusLine` from `path`. Returns `true` if we removed
129+
/// it, `false` if there was nothing of ours to remove (missing file, no
130+
/// `statusLine`, or a foreign one we won't touch).
131+
pub fn remove(path: &Path) -> Result<bool> {
132+
let mut obj = match std::fs::read_to_string(path) {
133+
Ok(s) if s.trim().is_empty() => return Ok(false),
134+
Ok(s) => match serde_json::from_str::<serde_json::Value>(&s) {
135+
Ok(serde_json::Value::Object(m)) => m,
136+
// Unparseable / non-object: leave it alone.
137+
_ => return Ok(false),
138+
},
139+
Err(_) => return Ok(false),
140+
};
141+
match obj.get("statusLine") {
142+
Some(v) if is_ours(v) => {
143+
obj.remove("statusLine");
144+
write_object(path, &obj)?;
145+
Ok(true)
146+
}
147+
_ => Ok(false),
148+
}
149+
}
150+
151+
#[cfg(test)]
152+
mod tests {
153+
use super::*;
154+
155+
fn tmp() -> (tempfile::TempDir, PathBuf) {
156+
let dir = tempfile::tempdir().unwrap();
157+
let path = dir.path().join("settings.json");
158+
(dir, path)
159+
}
160+
161+
#[test]
162+
fn install_into_missing_file_creates_it() {
163+
let (_d, path) = tmp();
164+
assert_eq!(install(&path).unwrap(), InstallOutcome::Wrote);
165+
let v: serde_json::Value =
166+
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
167+
assert_eq!(v["statusLine"]["command"], STATUSLINE_COMMAND);
168+
assert_eq!(v["statusLine"]["type"], "command");
169+
}
170+
171+
#[test]
172+
fn install_preserves_existing_keys() {
173+
let (_d, path) = tmp();
174+
std::fs::write(
175+
&path,
176+
r#"{"theme":"dark","permissions":{"allow":["Bash(*)"]}}"#,
177+
)
178+
.unwrap();
179+
assert_eq!(install(&path).unwrap(), InstallOutcome::Wrote);
180+
let v: serde_json::Value =
181+
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
182+
assert_eq!(v["theme"], "dark");
183+
assert_eq!(v["permissions"]["allow"][0], "Bash(*)");
184+
assert_eq!(v["statusLine"]["command"], STATUSLINE_COMMAND);
185+
}
186+
187+
#[test]
188+
fn install_is_idempotent() {
189+
let (_d, path) = tmp();
190+
assert_eq!(install(&path).unwrap(), InstallOutcome::Wrote);
191+
assert_eq!(install(&path).unwrap(), InstallOutcome::AlreadyOurs);
192+
}
193+
194+
#[test]
195+
fn install_refreshes_absolute_path_form() {
196+
let (_d, path) = tmp();
197+
std::fs::write(
198+
&path,
199+
r#"{"statusLine":{"type":"command","command":"C:\\x\\burnwall.exe statusline","padding":0}}"#,
200+
)
201+
.unwrap();
202+
// Recognized as ours (burnwall + statusline) but drifted → rewritten.
203+
assert_eq!(install(&path).unwrap(), InstallOutcome::Wrote);
204+
let v: serde_json::Value =
205+
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
206+
assert_eq!(v["statusLine"]["command"], STATUSLINE_COMMAND);
207+
}
208+
209+
#[test]
210+
fn install_will_not_clobber_foreign_statusline() {
211+
let (_d, path) = tmp();
212+
std::fs::write(
213+
&path,
214+
r#"{"statusLine":{"type":"command","command":"my-custom-bar.sh"}}"#,
215+
)
216+
.unwrap();
217+
match install(&path).unwrap() {
218+
InstallOutcome::ForeignPresent(cmd) => assert_eq!(cmd, "my-custom-bar.sh"),
219+
other => panic!("expected ForeignPresent, got {other:?}"),
220+
}
221+
// And the foreign value is untouched on disk.
222+
let v: serde_json::Value =
223+
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
224+
assert_eq!(v["statusLine"]["command"], "my-custom-bar.sh");
225+
}
226+
227+
#[test]
228+
fn install_bails_on_malformed_json() {
229+
let (_d, path) = tmp();
230+
std::fs::write(&path, "{not json").unwrap();
231+
assert!(install(&path).is_err());
232+
}
233+
234+
#[test]
235+
fn remove_takes_out_ours_and_keeps_the_rest() {
236+
let (_d, path) = tmp();
237+
std::fs::write(&path, r#"{"theme":"dark"}"#).unwrap();
238+
install(&path).unwrap();
239+
assert!(remove(&path).unwrap());
240+
let v: serde_json::Value =
241+
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
242+
assert!(v.get("statusLine").is_none());
243+
assert_eq!(v["theme"], "dark");
244+
}
245+
246+
#[test]
247+
fn remove_leaves_foreign_statusline() {
248+
let (_d, path) = tmp();
249+
std::fs::write(
250+
&path,
251+
r#"{"statusLine":{"type":"command","command":"my-custom-bar.sh"}}"#,
252+
)
253+
.unwrap();
254+
assert!(!remove(&path).unwrap());
255+
let v: serde_json::Value =
256+
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
257+
assert_eq!(v["statusLine"]["command"], "my-custom-bar.sh");
258+
}
259+
260+
#[test]
261+
fn remove_on_missing_file_is_false() {
262+
let (_d, path) = tmp();
263+
assert!(!remove(&path).unwrap());
264+
}
265+
}

src/cli/init.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,39 @@ pub fn run_cmd(args: InitArgs) -> anyhow::Result<()> {
302302
}
303303
writeln!(out)?;
304304

305+
// 3. Claude Code status line — wire the Burnwall ribbon into
306+
// ~/.claude/settings.json. Only offered when Claude Code is detected;
307+
// the rest of init is shell-routing, this is the one editor integration.
308+
let claude_found = detections.iter().any(|d| d.binary == "claude" && d.found);
309+
if claude_found {
310+
writeln!(out, "3. Claude Code status line")?;
311+
writeln!(out, " ───────────────────────")?;
312+
if let Some(path) = super::claude_settings::settings_path() {
313+
if args.apply {
314+
match super::claude_settings::install(&path) {
315+
Ok(super::claude_settings::InstallOutcome::Wrote) => {
316+
writeln!(out, " ✓ added `statusLine` to {}", path.display())?;
317+
writeln!(out, " restart Claude Code to see: 🔥 model · ↑/↓ tokens · $ spend")?;
318+
}
319+
Ok(super::claude_settings::InstallOutcome::AlreadyOurs) => {
320+
writeln!(out, " • already wired up in {}", path.display())?;
321+
}
322+
Ok(super::claude_settings::InstallOutcome::ForeignPresent(cmd)) => {
323+
writeln!(out, " • left your existing status line untouched (command: {cmd})")?;
324+
writeln!(out, " to use Burnwall's, set statusLine.command to `burnwall statusline`")?;
325+
}
326+
Err(e) => writeln!(out, " ⚠ skipped: {}", e)?,
327+
}
328+
} else {
329+
writeln!(out, " {action_label}: merge `statusLine` → {}", path.display())?;
330+
writeln!(out, " command: burnwall statusline")?;
331+
}
332+
} else {
333+
writeln!(out, " (could not locate ~/.claude/settings.json)")?;
334+
}
335+
writeln!(out)?;
336+
}
337+
305338
// 3. Next steps.
306339
writeln!(out, "▶ Next steps")?;
307340
if args.apply {

src/cli/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use clap::{Parser, Subcommand};
44

55
#[cfg(feature = "audit")]
66
pub mod audit;
7+
pub mod claude_settings;
78
pub mod completions;
89
pub mod config_cmd;
910
#[cfg(feature = "observe")]
@@ -40,6 +41,7 @@ pub mod status;
4041
pub mod statusline;
4142
pub mod upgrade;
4243
pub mod stop;
44+
pub mod uninstall;
4345
pub mod watch;
4446
#[cfg(feature = "waste")]
4547
pub mod waste;
@@ -106,6 +108,8 @@ pub enum Command {
106108
InstallService(service::InstallServiceArgs),
107109
/// Remove the burnwall login-time service.
108110
UninstallService(service::UninstallServiceArgs),
111+
/// Uninstall Burnwall: stop the proxy, remove the service, status line, routing, and binary.
112+
Uninstall(uninstall::UninstallArgs),
109113
/// Roll back to a prior burnwall release via the dist installer.
110114
SelfRollback(self_rollback::SelfRollbackArgs),
111115
/// Upgrade to the latest release (stops the proxy, installs, restarts).
@@ -160,6 +164,7 @@ impl Cli {
160164
Command::DisableRouting(args) => disable_routing::run_cmd(args),
161165
Command::InstallService(args) => service::install_cmd(args),
162166
Command::UninstallService(args) => service::uninstall_cmd(args),
167+
Command::Uninstall(args) => uninstall::run_cmd(args),
163168
Command::SelfRollback(args) => self_rollback::run_cmd(args),
164169
Command::Upgrade(args) => upgrade::run_cmd(args),
165170
Command::Pricing(args) => pricing::run_cmd(args),

0 commit comments

Comments
 (0)