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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ Lossless *and* cheap is easy to get wrong. A few things that make it hold:
|---|---|
| `secondwind run [-- <agent>]` | 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 |
Expand Down
4 changes: 2 additions & 2 deletions crates/cli/src/dashboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -524,7 +524,7 @@ html,body{margin:0; background:#0B0D13;}
<p>This ledger only shows real events. Run your agent through secondwind and the first block appears here within a second.</p>
<div class="term">
<div><span class="c">$</span> secondwind run -- claude</div>
<div style="margin-top:6px"><span class="c">$</span> secondwind exec -- ls -la &nbsp;<span class="cm"># or serve, the Bash hook, the mcp server</span></div>
<div style="margin-top:6px"><span class="c">$</span> secondwind exec -- ls -la &nbsp;<span class="cm"># or serve, the Claude Code mod, the mcp server</span></div>
</div>
</section>

Expand Down
72 changes: 17 additions & 55 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,15 +182,13 @@ enum Command {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
command: Vec<String>,
},
#[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<String>,
#[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(
Expand All @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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::<serde_json::Value>(&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<String> {
if let Ok(shell) = std::env::var("SECONDWIND_SHELL") {
Expand Down
56 changes: 41 additions & 15 deletions crates/cli/src/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ const RULES_END: &str = "<!-- secondwind:end -->";
const RESOLVE_RULE: &str = "When a tool result contains a `<<swload:...>>` 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,
Expand Down Expand Up @@ -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!();
Expand Down Expand Up @@ -524,6 +526,8 @@ fn register_mcp(config: &Path, home: &Path) -> Result<bool, String> {
Ok(true)
}

// What earlier versions wrote; kept so tests can stand up a stale install.
#[cfg(test)]
fn install_hook(settings: &Path) -> Result<bool, String> {
let mut root = load(settings)?;
if !root.is_object() {
Expand Down Expand Up @@ -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");
Expand Down
35 changes: 7 additions & 28 deletions crates/cli/tests/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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"
);
}
Loading