From 52e51c5351c5226cbe5b7b4043f7707453efbcf0 Mon Sep 17 00:00:00 2001 From: Heitor Ramon Ribeiro Date: Thu, 30 Apr 2026 02:23:29 -0300 Subject: [PATCH] Install and monitor the latest canonical claw-code version The update path should track the current canonical claw-code source instead of merging a branch or fork into the user's checkout. This keeps updates explicit: the process periodically checks canonical HEAD and prints a notice, while claw update performs the install from a fresh temporary clone. Constraint: ultraworkers/claw-code has no releases or tags, so latest currently means canonical HEAD. Rejected: Merge origin or upstream branches during update | mutates local worktrees and can clobber fork-specific changes. Rejected: Auto-install from the periodic checker | surprising side effect during an interactive session. Confidence: high Scope-risk: moderate Directive: Keep update checks best-effort and keep installation behind the explicit claw update command. Tested: cargo fmt --all -- --check Tested: cargo test -p rusty-claude-cli update_checker -- --nocapture Tested: cargo test -p rusty-claude-cli update_subcommand_parses_without_repo_options -- --nocapture Tested: cargo check -p runtime -p api -p commands -p tools -p rusty-claude-cli --- USAGE.md | 10 + rust/crates/rusty-claude-cli/src/main.rs | 282 ++++++++++++++++++++++- 2 files changed, 284 insertions(+), 8 deletions(-) diff --git a/USAGE.md b/USAGE.md index c8e7b09692..49a53f8f0e 100644 --- a/USAGE.md +++ b/USAGE.md @@ -308,6 +308,16 @@ The OpenAI-compatible backend also serves as the gateway for **OpenRouter**, **O **Model-name prefix routing:** If a model name starts with `openai/`, `gpt-`, `qwen/`, or `qwen-`, the provider is selected by the prefix regardless of which env vars are set. This prevents accidental misrouting to Anthropic when multiple credentials exist in the environment. +### Updating claw-code + +Use `claw update` to install the latest `claw-code` from the canonical source repository. The command downloads a fresh temporary copy of `https://github.com/ultraworkers/claw-code`, installs `rust/crates/rusty-claude-cli`, and removes the temporary checkout. It does not merge the current branch or mutate your local fork. + +```bash +claw update +``` + +During an interactive session, `claw` also checks the canonical source repository periodically and prints a notice when a newer commit is available. The checker is best-effort and never installs updates automatically. Set `CLAW_DISABLE_UPDATE_CHECK=1` to disable it, or `CLAW_UPDATE_CHECK_INTERVAL_SECONDS=` to change the interval. + ### Tested models and aliases These are the models registered in the built-in alias table with known token limits: diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index dbdbd07b64..dd603563cd 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -166,6 +166,8 @@ const BUILD_TARGET: Option<&str> = option_env!("TARGET"); const GIT_SHA: Option<&str> = option_env!("GIT_SHA"); const INTERNAL_PROGRESS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(3); const POST_TOOL_STALL_TIMEOUT: Duration = Duration::from_secs(10); +const UPDATE_CHECK_INITIAL_DELAY: Duration = Duration::from_secs(10); +const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(60 * 60 * 6); const PRIMARY_SESSION_EXTENSION: &str = "jsonl"; const LEGACY_SESSION_EXTENSION: &str = "json"; const OFFICIAL_REPO_URL: &str = "https://github.com/ultraworkers/claw-code"; @@ -412,6 +414,7 @@ fn run() -> Result<(), Box> { cli.run_turn_with_output(&effective_prompt, output_format, compact)?; } CliAction::Doctor { output_format } => run_doctor(output_format)?, + CliAction::Update => run_update()?, CliAction::Acp { output_format } => print_acp_status(output_format)?, CliAction::State { output_format } => run_worker_state(output_format)?, CliAction::Init { output_format } => run_init(output_format)?, @@ -536,6 +539,7 @@ enum CliAction { Doctor { output_format: CliOutputFormat, }, + Update, Acp { output_format: CliOutputFormat, }, @@ -948,6 +952,7 @@ fn parse_args(args: &[String]) -> Result { } "system-prompt" => parse_system_prompt_args(&rest[1..], output_format), "acp" => parse_acp_args(&rest[1..], output_format), + "update" => parse_update_args(&rest[1..]), "login" | "logout" => Err(removed_auth_surface_error(rest[0].as_str())), "init" => Ok(CliAction::Init { output_format }), "export" => parse_export_args(&rest[1..], output_format), @@ -1153,6 +1158,214 @@ fn removed_auth_surface_error(command_name: &str) -> String { ) } +fn run_update() -> Result<(), Box> { + let update_dir = env::temp_dir().join(format!("claw-code-update-{}", std::process::id())); + if update_dir.exists() { + fs::remove_dir_all(&update_dir)?; + } + + println!("Updating claw-code from {OFFICIAL_REPO_URL}"); + run_command( + "git", + &[ + "clone", + "--depth", + "1", + OFFICIAL_REPO_URL, + update_dir + .to_str() + .ok_or("temporary update path is not valid UTF-8")?, + ], + Path::new("."), + )?; + + let latest_sha = git_rev_parse(&update_dir, "HEAD")?; + let crate_path = update_dir.join("rust/crates/rusty-claude-cli"); + if !crate_path.join("Cargo.toml").exists() { + return Err(format!( + "cannot install claw: {} does not contain Cargo.toml", + crate_path.display() + ) + .into()); + } + let install_root = env::var_os("CLAW_INSTALL_ROOT") + .map(PathBuf::from) + .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".local"))) + .ok_or("cannot determine install root; set CLAW_INSTALL_ROOT or HOME")?; + let install_root_string = install_root.display().to_string(); + run_command( + "cargo", + &[ + "install", + "--path", + "rust/crates/rusty-claude-cli", + "--root", + &install_root_string, + "--force", + ], + &update_dir, + )?; + + fs::remove_dir_all(&update_dir).ok(); + println!("Update complete. Installed claw-code at {latest_sha}."); + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct UpdateNotice { + current_sha: String, + latest_sha: String, +} + +impl UpdateNotice { + fn render(&self) -> String { + format!( + "Update available\n Current {}\n Latest {}\n Install claw update", + short_sha(&self.current_sha), + short_sha(&self.latest_sha) + ) + } +} + +struct UpdateCheckMonitor { + stop_tx: Option>, + join_handle: Option>, +} + +impl UpdateCheckMonitor { + fn spawn() -> Option { + if update_check_disabled() { + return None; + } + let current_sha = GIT_SHA + .map(str::trim) + .filter(|sha| !sha.is_empty() && *sha != "unknown")? + .to_string(); + let (stop_tx, stop_rx) = mpsc::channel(); + let join_handle = thread::spawn(move || { + run_update_check_loop(current_sha, stop_rx); + }); + Some(Self { + stop_tx: Some(stop_tx), + join_handle: Some(join_handle), + }) + } + + fn stop(mut self) { + self.stop_inner(); + } + + fn stop_inner(&mut self) { + if let Some(stop_tx) = self.stop_tx.take() { + let _ = stop_tx.send(()); + } + self.join_handle.take(); + } +} + +impl Drop for UpdateCheckMonitor { + fn drop(&mut self) { + self.stop_inner(); + } +} + +fn run_update_check_loop(current_sha: String, stop_rx: Receiver<()>) { + if stop_rx.recv_timeout(UPDATE_CHECK_INITIAL_DELAY).is_ok() { + return; + } + let mut last_notified_sha: Option = None; + loop { + if let Ok(Some(notice)) = check_for_update(¤t_sha) { + if last_notified_sha.as_deref() != Some(notice.latest_sha.as_str()) { + eprintln!("\n{}", notice.render()); + last_notified_sha = Some(notice.latest_sha); + } + } + if stop_rx.recv_timeout(update_check_interval()).is_ok() { + break; + } + } +} + +fn update_check_interval() -> Duration { + env::var("CLAW_UPDATE_CHECK_INTERVAL_SECONDS") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|seconds| *seconds > 0) + .map(Duration::from_secs) + .unwrap_or(UPDATE_CHECK_INTERVAL) +} + +fn update_check_disabled() -> bool { + env::var("CLAW_DISABLE_UPDATE_CHECK") + .ok() + .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES")) + .unwrap_or(false) +} + +fn check_for_update(current_sha: &str) -> Result, Box> { + let latest_sha = latest_canonical_sha()?; + if sha_matches(current_sha, &latest_sha) { + return Ok(None); + } + Ok(Some(UpdateNotice { + current_sha: current_sha.to_string(), + latest_sha, + })) +} + +fn latest_canonical_sha() -> Result> { + let output = Command::new("git") + .args(["ls-remote", OFFICIAL_REPO_URL, "HEAD"]) + .output()?; + if !output.status.success() { + return Err(format!("git ls-remote failed with status {}", output.status).into()); + } + let stdout = String::from_utf8(output.stdout)?; + let sha = stdout + .split_whitespace() + .next() + .filter(|value| !value.is_empty()) + .ok_or("git ls-remote did not return a HEAD sha")?; + Ok(sha.to_string()) +} + +fn sha_matches(current: &str, latest: &str) -> bool { + let current = current.trim(); + let latest = latest.trim(); + !current.is_empty() + && !latest.is_empty() + && (current == latest || current.starts_with(latest) || latest.starts_with(current)) +} + +fn short_sha(sha: &str) -> String { + sha.chars().take(7).collect() +} + +fn git_rev_parse(repo: &Path, rev: &str) -> Result> { + let output = Command::new("git") + .args(["rev-parse", rev]) + .current_dir(repo) + .output()?; + if output.status.success() { + let value = String::from_utf8(output.stdout)?.trim().to_string(); + if !value.is_empty() { + return Ok(value); + } + } + Err(format!("cannot resolve git revision {rev} in {}", repo.display()).into()) +} + +fn run_command(program: &str, args: &[&str], cwd: &Path) -> Result<(), Box> { + println!("$ {program} {}", args.join(" ")); + let status = Command::new(program).args(args).current_dir(cwd).status()?; + if status.success() { + Ok(()) + } else { + Err(format!("{program} exited with status {status}").into()) + } +} + fn parse_acp_args(args: &[String], output_format: CliOutputFormat) -> Result { match args { [] => Ok(CliAction::Acp { output_format }), @@ -1163,6 +1376,22 @@ fn parse_acp_args(args: &[String], output_format: CliOutputFormat) -> Result Result { + if args.is_empty() { + return Ok(CliAction::Update); + } + if matches!(args[0].as_str(), "--help" | "-h") { + return Err( + "Usage: claw update\nInstall the latest claw-code from the canonical source repository." + .to_string(), + ); + } + Err(format!( + "unexpected argument for claw update: {}. Usage: claw update", + args[0] + )) +} + fn try_resolve_bare_skill_prompt(cwd: &Path, trimmed: &str) -> Option { let bare_first_token = trimmed.split_whitespace().next().unwrap_or_default(); let looks_like_skill_name = !bare_first_token.is_empty() @@ -3778,6 +4007,7 @@ fn run_repl( input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default()); println!("{}", cli.startup_banner()); println!("{}", format_connected_line(&cli.model)); + let _update_check_monitor = UpdateCheckMonitor::spawn(); loop { editor.set_completions(cli.repl_completion_candidates().unwrap_or_default()); @@ -9121,6 +9351,11 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { writeln!(out, " claw skills")?; writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?; writeln!(out, " claw init")?; + writeln!(out, " claw update")?; + writeln!( + out, + " Install the latest claw-code from the canonical source repository" + )?; writeln!( out, " claw export [PATH] [--session SESSION] [--output PATH]" @@ -9252,14 +9487,14 @@ mod tests { render_memory_report, render_prompt_history_report, render_repl_help, render_resume_usage, render_session_list, render_session_markdown, resolve_model_alias, resolve_model_alias_with_config, resolve_repl_model, resolve_session_reference, - response_to_events, resume_supported_slash_commands, run_resume_command, short_tool_id, - slash_command_completion_candidates_with_sessions, split_error_hint, status_context, - status_json_value, summarize_tool_payload_for_markdown, try_resolve_bare_skill_prompt, - validate_no_args, write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, - GitWorkspaceSummary, InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, - LocalHelpTopic, PromptHistoryEntry, SessionLifecycleKind, SessionLifecycleSummary, - SlashCommand, StatusUsage, TmuxPaneSnapshot, DEFAULT_MODEL, LATEST_SESSION_REFERENCE, - STUB_COMMANDS, + response_to_events, resume_supported_slash_commands, run_resume_command, sha_matches, + short_tool_id, slash_command_completion_candidates_with_sessions, split_error_hint, + status_context, status_json_value, summarize_tool_payload_for_markdown, + try_resolve_bare_skill_prompt, validate_no_args, write_mcp_server_fixture, CliAction, + CliOutputFormat, CliToolExecutor, GitWorkspaceSummary, InternalPromptProgressEvent, + InternalPromptProgressState, LiveCli, LocalHelpTopic, PromptHistoryEntry, + SessionLifecycleKind, SessionLifecycleSummary, SlashCommand, StatusUsage, TmuxPaneSnapshot, + UpdateNotice, DEFAULT_MODEL, LATEST_SESSION_REFERENCE, STUB_COMMANDS, }; use api::{ApiError, MessageResponse, OutputContentBlock, Usage}; use plugins::{ @@ -10346,6 +10581,36 @@ mod tests { ); } + #[test] + fn update_subcommand_parses_without_repo_options() { + assert_eq!( + parse_args(&["update".to_string()]).expect("update should parse"), + CliAction::Update + ); + assert!(parse_args(&["update".to_string(), "--repo".to_string()]).is_err()); + } + + #[test] + fn update_checker_matches_full_and_short_shas() { + assert!(sha_matches("abcdef123456", "abcdef123456")); + assert!(sha_matches("abcdef1", "abcdef123456")); + assert!(sha_matches("abcdef123456", "abcdef1")); + assert!(!sha_matches("abcdef1", "1234567")); + } + + #[test] + fn update_checker_notice_points_to_update_command() { + let notice = UpdateNotice { + current_sha: "abcdef123456".to_string(), + latest_sha: "123456abcdef".to_string(), + }; + let rendered = notice.render(); + assert!(rendered.contains("Update available")); + assert!(rendered.contains("abcdef1")); + assert!(rendered.contains("123456a")); + assert!(rendered.contains("claw update")); + } + #[test] fn parses_acp_command_surfaces() { assert_eq!( @@ -11788,6 +12053,7 @@ mod tests { assert!(help.contains("claw mcp")); assert!(help.contains("claw skills")); assert!(help.contains("claw /skills")); + assert!(help.contains("claw update")); assert!(help.contains("ultraworkers/claw-code")); assert!(help.contains("cargo install claw-code")); assert!(!help.contains("claw login"));