Skip to content
Open
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
10 changes: 10 additions & 0 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<seconds>` to change the interval.

### Tested models and aliases

These are the models registered in the built-in alias table with known token limits:
Expand Down
282 changes: 274 additions & 8 deletions rust/crates/rusty-claude-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -412,6 +414,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
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)?,
Expand Down Expand Up @@ -536,6 +539,7 @@ enum CliAction {
Doctor {
output_format: CliOutputFormat,
},
Update,
Acp {
output_format: CliOutputFormat,
},
Expand Down Expand Up @@ -948,6 +952,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
}
"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),
Expand Down Expand Up @@ -1153,6 +1158,214 @@ fn removed_auth_surface_error(command_name: &str) -> String {
)
}

fn run_update() -> Result<(), Box<dyn std::error::Error>> {
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<Sender<()>>,
join_handle: Option<JoinHandle<()>>,
}

impl UpdateCheckMonitor {
fn spawn() -> Option<Self> {
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<String> = None;
loop {
if let Ok(Some(notice)) = check_for_update(&current_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::<u64>().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<Option<UpdateNotice>, Box<dyn std::error::Error>> {
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<String, Box<dyn std::error::Error>> {
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<String, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<CliAction, String> {
match args {
[] => Ok(CliAction::Acp { output_format }),
Expand All @@ -1163,6 +1376,22 @@ fn parse_acp_args(args: &[String], output_format: CliOutputFormat) -> Result<Cli
}
}

fn parse_update_args(args: &[String]) -> Result<CliAction, String> {
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<String> {
let bare_first_token = trimmed.split_whitespace().next().unwrap_or_default();
let looks_like_skill_name = !bare_first_token.is_empty()
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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]"
Expand Down Expand Up @@ -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::{
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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"));
Expand Down