diff --git a/hooks/copilot-posttooluse.sh b/hooks/copilot-posttooluse.sh index 282a49d..5fb48e2 100644 --- a/hooks/copilot-posttooluse.sh +++ b/hooks/copilot-posttooluse.sh @@ -1,6 +1,10 @@ #!/usr/bin/env bash # squeez Copilot CLI PostToolUse hook — tracks token usage per tool call SQUEEZ="$HOME/.claude/squeez/bin/squeez" +if [ ! -x "$SQUEEZ" ]; then + _sq=$(command -v squeez 2>/dev/null || true) + [ -n "$_sq" ] && SQUEEZ="$_sq" +fi [ ! -x "$SQUEEZ" ] && exit 0 export SQUEEZ_DIR="$HOME/.copilot/squeez" diff --git a/hooks/copilot-pretooluse.sh b/hooks/copilot-pretooluse.sh index 44f2a45..5cfe69e 100644 --- a/hooks/copilot-pretooluse.sh +++ b/hooks/copilot-pretooluse.sh @@ -4,6 +4,10 @@ set -euo pipefail SQUEEZ="$HOME/.claude/squeez/bin/squeez" +if [ ! -x "$SQUEEZ" ]; then + _sq=$(command -v squeez 2>/dev/null || true) + [ -n "$_sq" ] && SQUEEZ="$_sq" +fi [ ! -x "$SQUEEZ" ] && exit 0 export SQUEEZ_DIR="$HOME/.copilot/squeez" diff --git a/hooks/copilot-session-start.sh b/hooks/copilot-session-start.sh index c5cba12..f16bafc 100644 --- a/hooks/copilot-session-start.sh +++ b/hooks/copilot-session-start.sh @@ -3,6 +3,10 @@ # Initialises the session and injects memory into ~/.copilot/copilot-instructions.md # Run this once per session: add to shell RC or invoke manually. SQUEEZ="$HOME/.claude/squeez/bin/squeez" +if [ ! -x "$SQUEEZ" ]; then + _sq=$(command -v squeez 2>/dev/null || true) + [ -n "$_sq" ] && SQUEEZ="$_sq" +fi [ ! -x "$SQUEEZ" ] && exit 0 export SQUEEZ_DIR="$HOME/.copilot/squeez" diff --git a/hooks/posttooluse.sh b/hooks/posttooluse.sh index 06e2af9..ff7e670 100755 --- a/hooks/posttooluse.sh +++ b/hooks/posttooluse.sh @@ -1,6 +1,10 @@ #!/usr/bin/env bash # squeez PostToolUse hook — tracks token usage per tool call SQUEEZ="$HOME/.claude/squeez/bin/squeez" +if [ ! -x "$SQUEEZ" ]; then + _sq=$(command -v squeez 2>/dev/null || true) + [ -n "$_sq" ] && SQUEEZ="$_sq" +fi [ ! -x "$SQUEEZ" ] && exit 0 input=$(cat) diff --git a/hooks/pretooluse.sh b/hooks/pretooluse.sh index e899e00..a6e8df5 100755 --- a/hooks/pretooluse.sh +++ b/hooks/pretooluse.sh @@ -2,6 +2,10 @@ set -euo pipefail SQUEEZ="$HOME/.claude/squeez/bin/squeez" +if [ ! -x "$SQUEEZ" ]; then + _sq=$(command -v squeez 2>/dev/null || true) + [ -n "$_sq" ] && SQUEEZ="$_sq" +fi [ ! -x "$SQUEEZ" ] && exit 0 SQUEEZ_BIN="$SQUEEZ" python3 -c " diff --git a/hooks/session-start.sh b/hooks/session-start.sh index 2b57617..0568f02 100755 --- a/hooks/session-start.sh +++ b/hooks/session-start.sh @@ -1,5 +1,9 @@ #!/usr/bin/env bash # squeez SessionStart hook — runs squeez init, prints memory banner to session context SQUEEZ="$HOME/.claude/squeez/bin/squeez" +if [ ! -x "$SQUEEZ" ]; then + _sq=$(command -v squeez 2>/dev/null || true) + [ -n "$_sq" ] && SQUEEZ="$_sq" +fi [ ! -x "$SQUEEZ" ] && exit 0 "$SQUEEZ" init diff --git a/install.sh b/install.sh index 9138b9d..60eba87 100644 --- a/install.sh +++ b/install.sh @@ -4,6 +4,14 @@ REPO_RAW="https://raw.githubusercontent.com/claudioemmanuel/squeez/main" RELEASES="https://github.com/claudioemmanuel/squeez/releases/latest/download" INSTALL_DIR="$HOME/.claude/squeez" +# Parse flags +SETUP_ONLY=0 +for arg in "$@"; do + case "$arg" in + --setup-only) SETUP_ONLY=1 ;; + esac +done + # Detect OS and architecture OS=$(uname -s) ARCH=$(uname -m) @@ -32,35 +40,50 @@ esac mkdir -p "$INSTALL_DIR/bin" "$INSTALL_DIR/hooks" "$INSTALL_DIR/sessions" "$INSTALL_DIR/memory" chmod 700 "$INSTALL_DIR" "$INSTALL_DIR/sessions" "$INSTALL_DIR/memory" 2>/dev/null || true -echo "Downloading squeez binary for $OS/$ARCH..." -curl -fsSL "$RELEASES/$BINARY" -o "$INSTALL_DIR/bin/$BIN_NAME" - -echo "Verifying checksum..." -curl -fsSL "$RELEASES/checksums.sha256" -o /tmp/squeez-checksums.sha256 -expected=$(grep "$BINARY" /tmp/squeez-checksums.sha256 2>/dev/null | awk '{print $1}') -rm -f /tmp/squeez-checksums.sha256 -if [ -z "$expected" ]; then - echo "ERROR: could not find checksum for $BINARY in release" >&2 - rm -f "$INSTALL_DIR/bin/$BIN_NAME" +if [ "$SETUP_ONLY" -eq 1 ]; then + # --setup-only: skip download, use existing squeez binary from PATH + echo "Setup-only mode: skipping binary download..." + EXISTING=$(command -v squeez 2>/dev/null || true) + if [ -z "$EXISTING" ]; then + echo "ERROR: squeez not found in PATH." >&2 + echo "Install first with: cargo install squeez" >&2 exit 1 -fi - -# Use sha256sum if available (Linux/Windows Git Bash), otherwise fall back to shasum (macOS) -if command -v sha256sum >/dev/null 2>&1; then - actual=$(sha256sum "$INSTALL_DIR/bin/$BIN_NAME" | awk '{print $1}') + fi + echo "Found squeez at: $EXISTING" + cp "$EXISTING" "$INSTALL_DIR/bin/$BIN_NAME" + chmod +x "$INSTALL_DIR/bin/$BIN_NAME" 2>/dev/null || true + echo "Binary copied to $INSTALL_DIR/bin/$BIN_NAME" else - actual=$(shasum -a 256 "$INSTALL_DIR/bin/$BIN_NAME" | awk '{print $1}') + echo "Downloading squeez binary for $OS/$ARCH..." + curl -fsSL "$RELEASES/$BINARY" -o "$INSTALL_DIR/bin/$BIN_NAME" + + echo "Verifying checksum..." + curl -fsSL "$RELEASES/checksums.sha256" -o /tmp/squeez-checksums.sha256 + expected=$(grep "$BINARY" /tmp/squeez-checksums.sha256 2>/dev/null | awk '{print $1}') + rm -f /tmp/squeez-checksums.sha256 + if [ -z "$expected" ]; then + echo "ERROR: could not find checksum for $BINARY in release" >&2 + rm -f "$INSTALL_DIR/bin/$BIN_NAME" + exit 1 + fi + + # Use sha256sum if available (Linux/Windows Git Bash), otherwise fall back to shasum (macOS) + if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum "$INSTALL_DIR/bin/$BIN_NAME" | awk '{print $1}') + else + actual=$(shasum -a 256 "$INSTALL_DIR/bin/$BIN_NAME" | awk '{print $1}') + fi + + if [ "$expected" != "$actual" ]; then + echo "ERROR: checksum mismatch — binary may be corrupted or tampered" >&2 + rm -f "$INSTALL_DIR/bin/$BIN_NAME" + exit 1 + fi + echo "Checksum verified." + + chmod +x "$INSTALL_DIR/bin/$BIN_NAME" 2>/dev/null || true fi -if [ "$expected" != "$actual" ]; then - echo "ERROR: checksum mismatch — binary may be corrupted or tampered" >&2 - rm -f "$INSTALL_DIR/bin/$BIN_NAME" - exit 1 -fi -echo "Checksum verified." - -chmod +x "$INSTALL_DIR/bin/$BIN_NAME" 2>/dev/null || true - echo "Installing hooks..." curl -fsSL "$REPO_RAW/hooks/pretooluse.sh" -o "$INSTALL_DIR/hooks/pretooluse.sh" curl -fsSL "$REPO_RAW/hooks/session-start.sh" -o "$INSTALL_DIR/hooks/session-start.sh" diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 1a0a9ac..a9ad475 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -25,5 +25,6 @@ pub mod track; pub mod track_result; pub mod typescript; pub mod benchmark; +pub mod setup; pub mod update; pub mod wrap; diff --git a/src/commands/setup.rs b/src/commands/setup.rs new file mode 100644 index 0000000..9e61f78 --- /dev/null +++ b/src/commands/setup.rs @@ -0,0 +1,229 @@ +// `squeez setup` — post-install setup for cargo/npm users. +// +// Creates ~/.claude/squeez/ directory structure, copies the running binary +// to the canonical hooks location, downloads hook scripts from GitHub, and +// registers hooks + statusline in ~/.claude/settings.json. + +use std::path::PathBuf; + +use crate::session::home_dir; + +const REPO_RAW: &str = "https://raw.githubusercontent.com/claudioemmanuel/squeez/main"; + +const HOOKS: &[&str] = &[ + "pretooluse.sh", + "session-start.sh", + "posttooluse.sh", + "copilot-pretooluse.sh", + "copilot-session-start.sh", + "copilot-posttooluse.sh", +]; + +// Python script that registers squeez hooks + statusline in ~/.claude/settings.json. +// Mirrors the registration block in install.sh. +const REGISTER_SETTINGS_PY: &str = r#" +import json, os, sys + +path = os.path.expanduser("~/.claude/settings.json") +settings = {} +try: + if os.path.exists(path): + with open(path) as f: + settings = json.load(f) +except (json.JSONDecodeError, IOError) as e: + print("Warning: could not read settings.json: " + str(e), file=sys.stderr) + +def ensure_list(key): + if not isinstance(settings.get(key), list): + settings[key] = [] + +ensure_list("PreToolUse") +pre = {"matcher": "Bash", "hooks": [{"type": "command", "command": "bash ~/.claude/squeez/hooks/pretooluse.sh"}]} +if not any("squeez" in str(h) for h in settings["PreToolUse"]): + settings["PreToolUse"].append(pre) + +ensure_list("SessionStart") +start = {"hooks": [{"type": "command", "command": "bash ~/.claude/squeez/hooks/session-start.sh"}]} +if not any("squeez" in str(h) for h in settings["SessionStart"]): + settings["SessionStart"].append(start) + +ensure_list("PostToolUse") +post = {"hooks": [{"type": "command", "command": "bash ~/.claude/squeez/hooks/posttooluse.sh"}]} +if not any("squeez" in str(h) for h in settings["PostToolUse"]): + settings["PostToolUse"].append(post) + +existing_status = settings.get("statusLine", {}) +existing_cmd = existing_status.get("command", "") if isinstance(existing_status, dict) else "" +squeez_cmd = "bash ~/.claude/squeez/bin/statusline.sh" +if "squeez" not in existing_cmd: + if existing_cmd: + new_cmd = "bash -c 'input=$(cat); echo \"$input\" | { " + existing_cmd.rstrip() + "; } 2>/dev/null; echo \"$input\" | " + squeez_cmd + "'" + settings["statusLine"] = {"type": "command", "command": new_cmd} + else: + settings["statusLine"] = {"type": "command", "command": squeez_cmd} + +os.makedirs(os.path.dirname(path), exist_ok=True) +tmp = path + ".tmp" +with open(tmp, "w") as f: + json.dump(settings, f, indent=2) +os.replace(tmp, path) +print("settings.json updated.") +"#; + +pub fn run(args: &[String]) -> i32 { + let force = args.iter().any(|a| a == "--force" || a == "-f"); + + let home = home_dir(); + let install_dir = format!("{}/.claude/squeez", home); + + // 1. Create directory structure + for sub in &["bin", "hooks", "sessions", "memory"] { + let path = format!("{}/{}", install_dir, sub); + if let Err(e) = std::fs::create_dir_all(&path) { + eprintln!("squeez setup: failed to create {}: {}", path, e); + return 1; + } + } + + // 2. Copy self binary to canonical hooks location + let bin_name = if cfg!(windows) { "squeez.exe" } else { "squeez" }; + let target_bin = PathBuf::from(format!("{}/bin/{}", install_dir, bin_name)); + + let current_exe = match std::env::current_exe() { + Ok(p) => p, + Err(e) => { + eprintln!("squeez setup: cannot determine current exe path: {}", e); + return 1; + } + }; + + let already_in_place = current_exe + .canonicalize() + .ok() + .zip(target_bin.canonicalize().ok()) + .map(|(a, b)| a == b) + .unwrap_or(false); + + if !already_in_place || force { + if let Err(e) = std::fs::copy(¤t_exe, &target_bin) { + eprintln!("squeez setup: failed to copy binary to {}: {}", target_bin.display(), e); + return 1; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&target_bin, std::fs::Permissions::from_mode(0o755)); + } + println!("squeez setup: binary installed → {}", target_bin.display()); + } else { + println!("squeez setup: binary already at {}", target_bin.display()); + } + + // 3. Download hook scripts from GitHub + println!("squeez setup: downloading hooks..."); + for hook in HOOKS { + let url = format!("{}/hooks/{}", REPO_RAW, hook); + let dest = format!("{}/hooks/{}", install_dir, hook); + match crate::commands::update::curl(&url) { + Ok(bytes) => { + if let Err(e) = std::fs::write(&dest, &bytes) { + eprintln!("squeez setup: failed to write hook {}: {}", hook, e); + return 1; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755)); + } + } + Err(e) => { + eprintln!("squeez setup: failed to download hook {}: {}", hook, e); + return 1; + } + } + } + + // 4. Download statusline.sh + let statusline_url = format!("{}/scripts/statusline.sh", REPO_RAW); + let statusline_dest = format!("{}/bin/statusline.sh", install_dir); + match crate::commands::update::curl(&statusline_url) { + Ok(bytes) => { + if let Err(e) = std::fs::write(&statusline_dest, &bytes) { + eprintln!("squeez setup: warning: could not write statusline.sh: {}", e); + } else { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&statusline_dest, std::fs::Permissions::from_mode(0o755)); + } + } + } + Err(e) => eprintln!("squeez setup: warning: could not download statusline.sh: {}", e), + } + + // 5. Register hooks in ~/.claude/settings.json + println!("squeez setup: registering hooks in settings.json..."); + if let Err(e) = register_claude_settings() { + eprintln!("squeez setup: failed to update settings.json: {}", e); + return 1; + } + + let version = crate::commands::update::current_version(); + println!("squeez setup: done — squeez {} ready. Restart Claude Code to activate.", version); + 0 +} + +/// Registers squeez hooks and statusline in ~/.claude/settings.json. +/// Called by both `squeez setup` and `squeez update`. +pub fn register_claude_settings() -> Result<(), String> { + run_python(REGISTER_SETTINGS_PY) +} + +fn run_python(script: &str) -> Result<(), String> { + use std::io::Write; + + // Try python3 first (Unix/modern Windows), then python (older Windows) + for python in &["python3", "python"] { + let mut child = match std::process::Command::new(python) + .arg("-") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn() + { + Ok(c) => c, + Err(_) => continue, + }; + + if let Some(stdin) = child.stdin.as_mut() { + let _ = stdin.write_all(script.as_bytes()); + } + + let status = child.wait().map_err(|e| e.to_string())?; + if status.success() { + return Ok(()); + } + return Err("python script exited with error".to_string()); + } + + Err("python3/python not found — cannot update settings.json".to_string()) +} + +fn print_help() { + println!("squeez setup — configure hooks after cargo/npm install"); + println!(); + println!("Usage:"); + println!(" squeez setup Install hooks and register in settings.json"); + println!(" squeez setup --force Force re-copy binary even if already in place"); + println!(); + println!("Use this after: cargo install squeez OR npm i -g squeez"); + println!("Equivalent to running: curl -fsSL | sh -s -- --setup-only"); +} + +pub fn run_with_help(args: &[String]) -> i32 { + if args.iter().any(|a| a == "-h" || a == "--help") { + print_help(); + return 0; + } + run(args) +} diff --git a/src/commands/update.rs b/src/commands/update.rs index 5a92695..df08e97 100644 --- a/src/commands/update.rs +++ b/src/commands/update.rs @@ -94,6 +94,12 @@ pub fn run(args: &[String]) -> i32 { } println!("squeez update: installed {} → {}", current, latest_clean); + + // Re-register hooks in settings.json (path may have changed, or first-time setup) + if let Err(e) = crate::commands::setup::register_claude_settings() { + eprintln!("squeez update: warning: could not update settings.json: {}", e); + } + 0 } @@ -265,14 +271,44 @@ pub fn install_atomic(bytes: &[u8], target: &Path) -> Result<(), String> { } #[cfg(windows)] { - // On Windows the running .exe cannot be renamed. Leave .new in place - // and ask the user to move it manually (or rely on a wrapper script). + // If target is not the currently-running binary (e.g. running from + // ~/.cargo/bin/squeez.exe while updating ~/.claude/squeez/bin/squeez.exe), + // the target file is not locked — a direct rename works fine. + let is_self = std::env::current_exe() + .ok() + .and_then(|p| p.canonicalize().ok()) + .zip(target.canonicalize().ok()) + .map(|(a, b)| a == b) + .unwrap_or(false); + + if !is_self { + std::fs::rename(&staging, target).map_err(|e| format!("rename: {}", e))?; + return Ok(()); + } + + // Self-update: try rename dance — Windows allows renaming a running exe. + let bak = target.with_extension("exe.bak"); + let _ = std::fs::remove_file(&bak); // remove stale backup if present + if std::fs::rename(target, &bak).is_ok() { + match std::fs::rename(&staging, target) { + Ok(()) => { + let _ = std::fs::remove_file(&bak); + return Ok(()); + } + Err(e) => { + let _ = std::fs::rename(&bak, target); // roll back + return Err(format!("rename new->target failed: {}", e)); + } + } + } + + // Rename of running exe failed — leave .new, print instructions. eprintln!( - "squeez update: wrote {} — Windows cannot replace a running .exe.", + "squeez update: wrote {} — to complete, run:", staging.display() ); eprintln!( - " Run: move /Y \"{}\" \"{}\"", + " move /Y \"{}\" \"{}\"", staging.display(), target.display() ); diff --git a/src/main.rs b/src/main.rs index 98681b8..9adec83 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,6 +41,10 @@ fn main() { let rest: Vec = args.iter().skip(2).cloned().collect(); std::process::exit(squeez::commands::compress_md::run(&rest)); } + Some("setup") => { + let rest: Vec = args.iter().skip(2).cloned().collect(); + std::process::exit(squeez::commands::setup::run_with_help(&rest)); + } Some("update") => { let rest: Vec = args.iter().skip(2).cloned().collect(); std::process::exit(squeez::commands::update::run(&rest)); @@ -61,6 +65,7 @@ fn main() { eprintln!(" squeez track-result (reads stdin)"); eprintln!(" squeez compress-md [--ultra] [--dry-run] [--all] ..."); eprintln!(" squeez benchmark [--json] [--output ] [--scenario ]"); + eprintln!(" squeez setup [--force]"); eprintln!(" squeez update [--check] [--insecure]"); eprintln!(" squeez --version"); std::process::exit(1);