diff --git a/README.md b/README.md index ce89394..66ea13e 100644 --- a/README.md +++ b/README.md @@ -342,7 +342,7 @@ Lossless *and* cheap is easy to get wrong. A few things that make it hold: |---|---| | `secondwind run [-- ]` | route an agent through the optimizer (auto-detects one if unnamed), print a verified receipt | | `secondwind serve` | host the optimizing endpoint for any agent (Anthropic, OpenAI, or Bedrock shapes) | -| `secondwind setup` | register the `resolve` tool with Claude Code (`--hook` also compresses Bash output) | +| `secondwind setup` | register the `resolve` tool with Claude Code (and remove the retired Bash hook) | | `secondwind proof` | live dashboard of tokens removed, with per-block lossless proof | | `secondwind watch` | live terminal view of savings, refreshed each second (companion to `proof`) | | `secondwind check` | verify the install and agent integration | diff --git a/crates/cli/src/dashboard.rs b/crates/cli/src/dashboard.rs index 7a98cff..c9af12a 100644 --- a/crates/cli/src/dashboard.rs +++ b/crates/cli/src/dashboard.rs @@ -15,7 +15,7 @@ pub fn serve(home: &Path, listen: &str) -> ExitCode { let url = format!("http://{listen}"); eprintln!("secondwind proof on {url}"); eprintln!( - "optimize something (secondwind run, exec, the Bash hook, or the mcp server) and it lands here live" + "optimize something (secondwind run, exec, the Claude Code mod, or the mcp server) and it lands here live" ); open_browser(&url); @@ -524,7 +524,7 @@ html,body{margin:0; background:#0B0D13;}

This ledger only shows real events. Run your agent through secondwind and the first block appears here within a second.

$ secondwind run -- claude
-
$ secondwind exec -- ls -la  # or serve, the Bash hook, the mcp server
+
$ secondwind exec -- ls -la  # or serve, the Claude Code mod, the mcp server
diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 772dae9..e1054aa 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -182,15 +182,13 @@ enum Command { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] command: Vec, }, - #[command( - about = "Wire the agent: register the resolve tool with Claude Code (and the Bash hook with --hook)" - )] + #[command(about = "Wire the agent: register the resolve tool with Claude Code")] Setup { #[arg(help = "Agent to route (e.g. cursor); omit to wire Claude Code")] target: Option, #[arg( long, - help = "Also install the PreToolUse hook that compresses Bash output" + help = "Retired: installs nothing; setup removes a hook an earlier version installed" )] hook: bool, #[arg( @@ -217,9 +215,7 @@ enum Command { Watch, #[command(about = "Check the install: tokenizer, writable state, agent integration")] Check, - #[command( - about = "PreToolUse hook that wraps Bash commands in `exec` so their output is compressed" - )] + #[command(about = "Retired PreToolUse hook: passes every command through unchanged")] Hook, #[command( about = "Replay real decisions on compressed vs uncompressed context and compare the agent's choice" @@ -514,15 +510,11 @@ fn check(home: &Path) -> ExitCode { let hooked = std::fs::read_to_string(&settings) .map(|s| s.contains("secondwind hook")) .unwrap_or(false); - println!( - " {} bash hook {}", - if hooked { ok } else { no }, - if hooked { - "installed, Bash output compresses transparently" - } else { - "not installed (run: secondwind setup --hook)" - } - ); + if hooked { + println!( + " {no} bash hook retired but still installed, it no longer compresses (run: secondwind setup)" + ); + } println!(); if !mcp_registered { @@ -843,56 +835,26 @@ fn reopen_rate(home: &Path, limit: usize) -> ExitCode { ExitCode::SUCCESS } -const HOOK_SETUP: &str = r#"Add this to .claude/settings.json to compress Bash output transparently: - - { - "hooks": { - "PreToolUse": [ - { "matcher": "Bash", "hooks": [ { "type": "command", "command": "secondwind hook" } ] } - ] - } - } - -Then Bash commands run through `secondwind exec -c`, keeping every value present or -recoverable. The command runs in a child shell, so a `cd` or variable assignment in it -lasts only for that command. Verify the rewrite lands in a live session; if the field -name differs in your build, it is a one-line change in `secondwind hook`."#; +const HOOK_RETIRED: &str = + "secondwind hook is retired. It rewrote every Bash command before Claude Code's +permission check, and Claude Code checks the rewritten command, so your Bash allow and deny rules +stopped applying. It now passes commands through unchanged. Remove it with `secondwind setup`; the +Claude Code mod compresses Bash output after the command runs instead (docs/CLAUDE_CODE.md)."; +// Retired: settings an earlier version wrote still call this, so it answers every event with +// nothing, which leaves the command and its permission check untouched. fn hook() -> ExitCode { use std::io::Read; let stdin = std::io::stdin(); if stdin.is_terminal() { - println!("{HOOK_SETUP}"); + println!("{HOOK_RETIRED}"); return ExitCode::SUCCESS; } - let mut input = String::new(); - if stdin.lock().read_to_string(&mut input).is_err() { - return ExitCode::SUCCESS; - } - let Ok(event) = serde_json::from_str::(&input) else { - return ExitCode::SUCCESS; - }; - let tool = event["tool_name"].as_str().unwrap_or_default(); - let command = event["tool_input"]["command"].as_str().unwrap_or_default(); - if tool == "Bash" && !command.is_empty() && !command.trim_start().starts_with("secondwind") { - let rewrite = serde_json::json!({ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "allow", - "updatedInput": { "command": format!("secondwind exec -c {}", shell_quote(command)) } - } - }); - println!("{rewrite}"); - } + let _ = stdin.lock().read_to_string(&mut String::new()); ExitCode::SUCCESS } -// Single-quoted for POSIX shells, so the wrapped command survives the outer parse intact. -fn shell_quote(s: &str) -> String { - format!("'{}'", s.replace('\'', "'\\''")) -} - // On Windows the `bash` on PATH is usually the WSL launcher, so Git's `sh` is the default there. fn shells() -> Vec { if let Ok(shell) = std::env::var("SECONDWIND_SHELL") { diff --git a/crates/cli/src/setup.rs b/crates/cli/src/setup.rs index 66defb6..a409db2 100644 --- a/crates/cli/src/setup.rs +++ b/crates/cli/src/setup.rs @@ -8,8 +8,9 @@ const RULES_END: &str = ""; const RESOLVE_RULE: &str = "When a tool result contains a `<>` marker, its full content was offloaded by secondwind to save tokens. Call the `resolve` tool with that exact marker to fetch the original verbatim."; // Turnkey agent wiring. With a target it routes that agent; with none it registers the secondwind MCP -// server (so the resolve tool rides every request and `serve`/`run` can offload) plus the PreToolUse Bash -// hook under --hook. Edits are idempotent and back up first; a file that does not parse is left alone. +// server (so the resolve tool rides every request and `serve`/`run` can offload) and removes the +// retired Bash hook an earlier version installed. Edits are idempotent and back up first; a file that +// does not parse is left alone. pub fn run( home: &Path, with_hook: bool, @@ -42,20 +43,21 @@ pub fn run( } if with_hook { - let settings = home.join(".claude").join("settings.json"); - match install_hook(&settings) { - Ok(true) => println!( - " {ok} hook Bash outputs compress via {}", - settings.display() - ), - Ok(false) => println!(" {ok} hook already installed"), - Err(err) => { - eprintln!(" \u{2717} hook {err}"); - failed = true; - } + println!( + " - hook retired, nothing installed: the Claude Code mod compresses Bash output (docs/CLAUDE_CODE.md)" + ); + } + let settings = home.join(".claude").join("settings.json"); + match uninstall_hook(&settings) { + Ok(true) => println!( + " {ok} hook removed the retired Bash hook from {}", + settings.display() + ), + Ok(false) => {} + Err(err) => { + eprintln!(" \u{2717} hook {err}"); + failed = true; } - } else { - println!(" - hook skipped (add with: secondwind setup --hook)"); } println!(); @@ -524,6 +526,8 @@ fn register_mcp(config: &Path, home: &Path) -> Result { Ok(true) } +// What earlier versions wrote; kept so tests can stand up a stale install. +#[cfg(test)] fn install_hook(settings: &Path) -> Result { let mut root = load(settings)?; if !root.is_object() { @@ -790,6 +794,28 @@ mod tests { assert_eq!(std::fs::read_to_string(&config).unwrap(), "{not json"); } + #[test] + fn setup_never_installs_the_hook_and_removes_a_stale_one() { + let dir = scratch("retired"); + let settings = dir.join(".claude").join("settings.json"); + let _ = run(&dir, true, None, false, false, false); + let root: Value = + serde_json::from_str(&std::fs::read_to_string(dir.join(".claude.json")).unwrap()) + .unwrap(); + assert_eq!(root["mcpServers"]["secondwind"]["command"], "secondwind"); + assert!( + !settings.exists() + || !std::fs::read_to_string(&settings) + .unwrap() + .contains("secondwind hook") + ); + + assert!(install_hook(&settings).unwrap()); + let _ = run(&dir, false, None, false, false, false); + let after = std::fs::read_to_string(&settings).unwrap(); + assert!(!after.contains("secondwind hook"), "{after}"); + } + #[test] fn installs_the_hook_once() { let dir = scratch("hook"); diff --git a/crates/cli/tests/exec.rs b/crates/cli/tests/exec.rs index 04be022..ab6d467 100644 --- a/crates/cli/tests/exec.rs +++ b/crates/cli/tests/exec.rs @@ -73,10 +73,10 @@ fn a_missing_program_exits_127() { } #[test] -fn the_hook_rewrite_survives_the_outer_shell_parse() { +fn the_retired_hook_leaves_every_command_to_the_permission_check() { let home = home("hook"); - let command = "echo 'a|b' && cd / && pwd"; - let event = serde_json::json!({ "tool_name": "Bash", "tool_input": { "command": command } }); + let event = + serde_json::json!({ "tool_name": "Bash", "tool_input": { "command": "rm -rf build" } }); let mut hook = secondwind(&home) .arg("hook") .stdin(Stdio::piped()) @@ -89,30 +89,9 @@ fn the_hook_rewrite_survives_the_outer_shell_parse() { .write_all(event.to_string().as_bytes()) .unwrap(); let out = hook.wait_with_output().unwrap(); - let rewrite: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); - let rewritten = rewrite["hookSpecificOutput"]["updatedInput"]["command"] - .as_str() - .unwrap(); - assert_eq!( - rewritten, - "secondwind exec -c 'echo '\\''a|b'\\'' && cd / && pwd'" - ); - - let bin_dir = Path::new(env!("CARGO_BIN_EXE_secondwind")) - .parent() - .unwrap(); - let path = format!( - "{}:{}", - bin_dir.display(), - std::env::var("PATH").unwrap_or_default() - ); - let out = Command::new("sh") - .arg("-c") - .arg(rewritten) - .env("PATH", path) - .env("HOME", &home) - .output() - .unwrap(); - assert_eq!(stdout(&out), "a|b\n/\n"); assert_eq!(out.status.code(), Some(0)); + assert!( + out.stdout.is_empty(), + "no rewrite and no permission decision" + ); }