1 Commits

Author SHA1 Message Date
Yeachan-Heo
cdf24b87b4 Enable safe in-place CLI self-updates from GitHub releases
Add a self-update command to the Rust CLI that checks the latest GitHub release, compares versions, downloads a matching binary plus checksum manifest, verifies SHA-256, and swaps the executable only after validation succeeds. The command reports changelog text from the release body and exits safely when no published release or matching asset exists.\n\nThe workspace verification request also surfaced unrelated stale permission-mode references in runtime tests and a brittle config-count assertion in the CLI tests. Those were updated so the requested fmt/clippy/test pass can complete cleanly in this worktree.\n\nConstraint: GitHub latest release for instructkr/clawd-code currently returns 404, so the updater must degrade safely when no published release exists\nConstraint: Must not replace the current executable before checksum verification succeeds\nRejected: Shell out to an external updater | environment-dependent and does not meet the GitHub API/changelog requirement\nRejected: Add archive extraction support now | no published release assets exist yet to justify broader packaging complexity\nConfidence: medium\nScope-risk: moderate\nReversibility: clean\nDirective: Keep release asset naming and checksum manifest conventions aligned with the eventual GitHub release pipeline before expanding packaging formats\nTested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace --exclude compat-harness; cargo run -q -p rusty-claude-cli -- self-update\nNot-tested: Successful live binary replacement against a real published GitHub release asset
2026-04-01 01:01:26 +00:00
9 changed files with 497 additions and 336 deletions

3
rust/Cargo.lock generated
View File

@@ -1091,8 +1091,11 @@ dependencies = [
"compat-harness",
"crossterm",
"pulldown-cmark",
"reqwest",
"runtime",
"serde",
"serde_json",
"sha2",
"syntect",
"tokio",
"tools",

View File

@@ -84,6 +84,15 @@ cargo run -p rusty-claude-cli -- logout
This removes only the stored OAuth credentials and preserves unrelated JSON fields in `credentials.json`.
### Self-update
```bash
cd rust
cargo run -p rusty-claude-cli -- self-update
```
The command checks the latest GitHub release for `instructkr/clawd-code`, compares it to the current binary version, downloads the matching binary asset plus checksum manifest, verifies SHA-256, replaces the current executable, and prints the release changelog. If no published release or matching asset exists, it exits safely with an explanatory message.
## Usage examples
### 1) Prompt mode
@@ -133,7 +142,6 @@ Inside the REPL, useful commands include:
/diff
/version
/export notes.txt
/sessions
/session list
/exit
```
@@ -144,14 +152,14 @@ Inspect or maintain a saved session file without entering the REPL:
```bash
cd rust
cargo run -p rusty-claude-cli -- --resume session-123456 /status /compact /cost
cargo run -p rusty-claude-cli -- --resume session.json /status /compact /cost
```
You can also inspect memory/config state for a restored session:
```bash
cd rust
cargo run -p rusty-claude-cli -- --resume ~/.claude/sessions/session-123456.json /memory /config
cargo run -p rusty-claude-cli -- --resume session.json /memory /config
```
## Available commands
@@ -159,10 +167,11 @@ cargo run -p rusty-claude-cli -- --resume ~/.claude/sessions/session-123456.json
### Top-level CLI commands
- `prompt <text...>` — run one prompt non-interactively
- `--resume <session-id-or-path> [/commands...]` — inspect or maintain a saved session stored under `~/.claude/sessions/`
- `--resume <session.json> [/commands...]` — inspect or maintain a saved session
- `dump-manifests` — print extracted upstream manifest counts
- `bootstrap-plan` — print the current bootstrap skeleton
- `system-prompt [--cwd PATH] [--date YYYY-MM-DD]` — render the synthesized system prompt
- `self-update` — update the installed binary from the latest GitHub release when a matching asset is available
- `--help` / `-h` — show CLI help
- `--version` / `-V` — print the CLI version and build info locally (no API call)
- `--output-format text|json` — choose non-interactive prompt output rendering
@@ -177,14 +186,13 @@ cargo run -p rusty-claude-cli -- --resume ~/.claude/sessions/session-123456.json
- `/permissions [read-only|workspace-write|danger-full-access]` — inspect or switch permissions
- `/clear [--confirm]` — clear the current local session
- `/cost` — show token usage totals
- `/resume <session-id-or-path>` — load a saved session into the REPL
- `/resume <session-path>` — load a saved session into the REPL
- `/config [env|hooks|model]` — inspect discovered Claude config
- `/memory` — inspect loaded instruction memory files
- `/init` — create a starter `CLAUDE.md`
- `/diff` — show the current git diff for the workspace
- `/version` — print version and build metadata locally
- `/export [file]` — export the current conversation transcript
- `/sessions` — list recent managed local sessions from `~/.claude/sessions/`
- `/session [list|switch <session-id>]` — inspect or switch managed local sessions
- `/exit` — leave the REPL

View File

@@ -84,7 +84,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
SlashCommandSpec {
name: "resume",
summary: "Load a saved session into the REPL",
argument_hint: Some("<session-id-or-path>"),
argument_hint: Some("<session-path>"),
resume_supported: false,
},
SlashCommandSpec {
@@ -129,12 +129,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
argument_hint: Some("[list|switch <session-id>]"),
resume_supported: false,
},
SlashCommandSpec {
name: "sessions",
summary: "List recent managed local sessions",
argument_hint: None,
resume_supported: false,
},
];
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -169,7 +163,6 @@ pub enum SlashCommand {
action: Option<String>,
target: Option<String>,
},
Sessions,
Unknown(String),
}
@@ -214,7 +207,6 @@ impl SlashCommand {
action: parts.next().map(ToOwned::to_owned),
target: parts.next().map(ToOwned::to_owned),
},
"sessions" => Self::Sessions,
other => Self::Unknown(other.to_string()),
})
}
@@ -299,7 +291,6 @@ pub fn handle_slash_command(
| SlashCommand::Version
| SlashCommand::Export { .. }
| SlashCommand::Session { .. }
| SlashCommand::Sessions
| SlashCommand::Unknown(_) => None,
}
}
@@ -374,10 +365,6 @@ mod tests {
target: Some("abc123".to_string())
})
);
assert_eq!(
SlashCommand::parse("/sessions"),
Some(SlashCommand::Sessions)
);
}
#[test]
@@ -391,7 +378,7 @@ mod tests {
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
assert!(help.contains("/clear [--confirm]"));
assert!(help.contains("/cost"));
assert!(help.contains("/resume <session-id-or-path>"));
assert!(help.contains("/resume <session-path>"));
assert!(help.contains("/config [env|hooks|model]"));
assert!(help.contains("/memory"));
assert!(help.contains("/init"));
@@ -399,8 +386,7 @@ mod tests {
assert!(help.contains("/version"));
assert!(help.contains("/export [file]"));
assert!(help.contains("/session [list|switch <session-id>]"));
assert!(help.contains("/sessions"));
assert_eq!(slash_command_specs().len(), 16);
assert_eq!(slash_command_specs().len(), 15);
assert_eq!(resume_supported_slash_commands().len(), 11);
}
@@ -418,7 +404,6 @@ mod tests {
text: "recent".to_string(),
}]),
],
metadata: None,
};
let result = handle_slash_command(
@@ -483,6 +468,5 @@ mod tests {
assert!(
handle_slash_command("/session list", &session, CompactionConfig::default()).is_none()
);
assert!(handle_slash_command("/sessions", &session, CompactionConfig::default()).is_none());
}
}

View File

@@ -105,7 +105,6 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
compacted_session: Session {
version: session.version,
messages: compacted_messages,
metadata: session.metadata.clone(),
},
removed_message_count: removed.len(),
}
@@ -394,7 +393,6 @@ mod tests {
let session = Session {
version: 1,
messages: vec![ConversationMessage::user_text("hello")],
metadata: None,
};
let result = compact_session(&session, CompactionConfig::default());
@@ -422,7 +420,6 @@ mod tests {
usage: None,
},
],
metadata: None,
};
let result = compact_session(

View File

@@ -73,9 +73,7 @@ pub use remote::{
RemoteSessionContext, UpstreamProxyBootstrap, UpstreamProxyState, DEFAULT_REMOTE_BASE_URL,
DEFAULT_SESSION_TOKEN_PATH, DEFAULT_SYSTEM_CA_BUNDLE, NO_PROXY_HOSTS, UPSTREAM_PROXY_ENV_KEYS,
};
pub use session::{
ContentBlock, ConversationMessage, MessageRole, Session, SessionError, SessionMetadata,
};
pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, SessionError};
pub use usage::{
format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker,
};

View File

@@ -39,19 +39,10 @@ pub struct ConversationMessage {
pub usage: Option<TokenUsage>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionMetadata {
pub started_at: String,
pub model: String,
pub message_count: u32,
pub last_prompt: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Session {
pub version: u32,
pub messages: Vec<ConversationMessage>,
pub metadata: Option<SessionMetadata>,
}
#[derive(Debug)]
@@ -91,7 +82,6 @@ impl Session {
Self {
version: 1,
messages: Vec::new(),
metadata: None,
}
}
@@ -121,9 +111,6 @@ impl Session {
.collect(),
),
);
if let Some(metadata) = &self.metadata {
object.insert("metadata".to_string(), metadata.to_json());
}
JsonValue::Object(object)
}
@@ -144,15 +131,7 @@ impl Session {
.iter()
.map(ConversationMessage::from_json)
.collect::<Result<Vec<_>, _>>()?;
let metadata = object
.get("metadata")
.map(SessionMetadata::from_json)
.transpose()?;
Ok(Self {
version,
messages,
metadata,
})
Ok(Self { version, messages })
}
}
@@ -162,41 +141,6 @@ impl Default for Session {
}
}
impl SessionMetadata {
#[must_use]
pub fn to_json(&self) -> JsonValue {
let mut object = BTreeMap::new();
object.insert(
"started_at".to_string(),
JsonValue::String(self.started_at.clone()),
);
object.insert("model".to_string(), JsonValue::String(self.model.clone()));
object.insert(
"message_count".to_string(),
JsonValue::Number(i64::from(self.message_count)),
);
if let Some(last_prompt) = &self.last_prompt {
object.insert(
"last_prompt".to_string(),
JsonValue::String(last_prompt.clone()),
);
}
JsonValue::Object(object)
}
fn from_json(value: &JsonValue) -> Result<Self, SessionError> {
let object = value.as_object().ok_or_else(|| {
SessionError::Format("session metadata must be an object".to_string())
})?;
Ok(Self {
started_at: required_string(object, "started_at")?,
model: required_string(object, "model")?,
message_count: required_u32(object, "message_count")?,
last_prompt: optional_string(object, "last_prompt"),
})
}
}
impl ConversationMessage {
#[must_use]
pub fn user_text(text: impl Into<String>) -> Self {
@@ -424,13 +368,6 @@ fn required_string(
.ok_or_else(|| SessionError::Format(format!("missing {key}")))
}
fn optional_string(object: &BTreeMap<String, JsonValue>, key: &str) -> Option<String> {
object
.get(key)
.and_then(JsonValue::as_str)
.map(ToOwned::to_owned)
}
fn required_u32(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<u32, SessionError> {
let value = object
.get(key)
@@ -441,8 +378,7 @@ fn required_u32(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<u32,
#[cfg(test)]
mod tests {
use super::{ContentBlock, ConversationMessage, MessageRole, Session, SessionMetadata};
use crate::json::JsonValue;
use super::{ContentBlock, ConversationMessage, MessageRole, Session};
use crate::usage::TokenUsage;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -450,12 +386,6 @@ mod tests {
#[test]
fn persists_and_restores_session_json() {
let mut session = Session::new();
session.metadata = Some(SessionMetadata {
started_at: "2026-04-01T00:00:00Z".to_string(),
model: "claude-sonnet".to_string(),
message_count: 3,
last_prompt: Some("hello".to_string()),
});
session
.messages
.push(ConversationMessage::user_text("hello"));
@@ -498,23 +428,5 @@ mod tests {
restored.messages[1].usage.expect("usage").total_tokens(),
17
);
assert_eq!(restored.metadata, session.metadata);
}
#[test]
fn loads_legacy_session_without_metadata() {
let legacy = r#"{
"version": 1,
"messages": [
{
"role": "user",
"blocks": [{"type": "text", "text": "hello"}]
}
]
}"#;
let restored = Session::from_json(&JsonValue::parse(legacy).expect("legacy json"))
.expect("legacy session should parse");
assert_eq!(restored.messages.len(), 1);
assert!(restored.metadata.is_none());
}
}

View File

@@ -300,7 +300,6 @@ mod tests {
cache_read_input_tokens: 0,
}),
}],
metadata: None,
};
let tracker = UsageTracker::from_session(&session);

View File

@@ -11,8 +11,11 @@ commands = { path = "../commands" }
compat-harness = { path = "../compat-harness" }
crossterm = "0.28"
pulldown-cmark = "0.13"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
runtime = { path = "../runtime" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
syntect = "5"
tokio = { version = "1", features = ["rt-multi-thread", "time"] }
tools = { path = "../tools" }

View File

@@ -3,6 +3,7 @@ mod render;
use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::fmt::Write as _;
use std::fs;
use std::io::{self, Read, Write};
use std::net::TcpListener;
@@ -21,15 +22,18 @@ use commands::{
};
use compat_harness::{extract_manifest, UpstreamPaths};
use render::{Spinner, TerminalRenderer};
use reqwest::blocking::Client;
use runtime::{
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest,
AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest,
OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
Session, SessionMetadata, TokenUsage, ToolError, ToolExecutor, UsageTracker,
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
};
use serde::Deserialize;
use serde_json::json;
use sha2::{Digest, Sha256};
use tools::{execute_tool, mvp_tool_specs, ToolSpec};
const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
@@ -37,9 +41,20 @@ const DEFAULT_MAX_TOKENS: u32 = 32;
const DEFAULT_DATE: &str = "2026-03-31";
const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
const VERSION: &str = env!("CARGO_PKG_VERSION");
const OLD_SESSION_COMPACTION_AGE_SECS: u64 = 60 * 60 * 24;
const BUILD_TARGET: Option<&str> = option_env!("TARGET");
const GIT_SHA: Option<&str> = option_env!("GIT_SHA");
const SELF_UPDATE_REPOSITORY: &str = "instructkr/clawd-code";
const SELF_UPDATE_LATEST_RELEASE_URL: &str =
"https://api.github.com/repos/instructkr/clawd-code/releases/latest";
const SELF_UPDATE_USER_AGENT: &str = "rusty-claude-cli-self-update";
const CHECKSUM_ASSET_CANDIDATES: &[&str] = &[
"SHA256SUMS",
"SHA256SUMS.txt",
"sha256sums",
"sha256sums.txt",
"checksums.txt",
"checksums.sha256",
];
type AllowedToolSet = BTreeSet<String>;
@@ -61,6 +76,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
CliAction::BootstrapPlan => print_bootstrap_plan(),
CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
CliAction::Version => print_version(),
CliAction::SelfUpdate => run_self_update()?,
CliAction::ResumeSession {
session_path,
commands,
@@ -94,6 +110,7 @@ enum CliAction {
date: String,
},
Version,
SelfUpdate,
ResumeSession {
session_path: PathBuf,
commands: Vec<String>,
@@ -229,6 +246,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
"dump-manifests" => Ok(CliAction::DumpManifests),
"bootstrap-plan" => Ok(CliAction::BootstrapPlan),
"system-prompt" => parse_system_prompt_args(&rest[1..]),
"self-update" => Ok(CliAction::SelfUpdate),
"login" => Ok(CliAction::Login),
"logout" => Ok(CliAction::Logout),
"prompt" => {
@@ -535,15 +553,377 @@ fn print_version() {
println!("{}", render_version_report());
}
fn resume_session(session_path: &Path, commands: &[String]) {
let handle = match resolve_session_reference(&session_path.display().to_string()) {
Ok(handle) => handle,
Err(error) => {
eprintln!("failed to resolve session: {error}");
std::process::exit(1);
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
struct GitHubRelease {
tag_name: String,
#[serde(default)]
body: String,
#[serde(default)]
assets: Vec<GitHubReleaseAsset>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
struct GitHubReleaseAsset {
name: String,
browser_download_url: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SelectedReleaseAssets {
binary: GitHubReleaseAsset,
checksum: GitHubReleaseAsset,
}
fn run_self_update() -> Result<(), Box<dyn std::error::Error>> {
let Some(release) = fetch_latest_release()? else {
println!(
"{}",
render_update_report(
"No published release available",
Some(VERSION),
None,
Some("GitHub latest release endpoint returned no published release for instructkr/clawd-code."),
None,
)
);
return Ok(());
};
let latest_version = normalize_version_tag(&release.tag_name);
if !is_newer_version(VERSION, &latest_version) {
println!(
"{}",
render_update_report(
"Already up to date",
Some(VERSION),
Some(&latest_version),
Some("Current binary already matches the latest published release."),
Some(&release.body),
)
);
return Ok(());
}
let selected = match select_release_assets(&release) {
Ok(selected) => selected,
Err(message) => {
println!(
"{}",
render_update_report(
"Release found, but no installable asset matched this platform",
Some(VERSION),
Some(&latest_version),
Some(&message),
Some(&release.body),
)
);
return Ok(());
}
};
let session = match Session::load_from_path(&handle.path) {
let client = build_self_update_client()?;
let binary_bytes = download_bytes(&client, &selected.binary.browser_download_url)?;
let checksum_manifest = download_text(&client, &selected.checksum.browser_download_url)?;
let expected_checksum = parse_checksum_for_asset(&checksum_manifest, &selected.binary.name)
.ok_or_else(|| {
format!(
"checksum manifest did not contain an entry for {}",
selected.binary.name
)
})?;
let actual_checksum = sha256_hex(&binary_bytes);
if actual_checksum != expected_checksum {
return Err(format!(
"downloaded asset checksum mismatch for {} (expected {}, got {})",
selected.binary.name, expected_checksum, actual_checksum
)
.into());
}
replace_current_executable(&binary_bytes)?;
println!(
"{}",
render_update_report(
"Update installed",
Some(VERSION),
Some(&latest_version),
Some(&format!(
"Installed {} from GitHub release assets for {}.",
selected.binary.name,
current_target()
)),
Some(&release.body),
)
);
Ok(())
}
fn fetch_latest_release() -> Result<Option<GitHubRelease>, Box<dyn std::error::Error>> {
let client = build_self_update_client()?;
let response = client
.get(SELF_UPDATE_LATEST_RELEASE_URL)
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
.send()?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
let response = response.error_for_status()?;
Ok(Some(response.json()?))
}
fn build_self_update_client() -> Result<Client, reqwest::Error> {
Client::builder().user_agent(SELF_UPDATE_USER_AGENT).build()
}
fn download_bytes(client: &Client, url: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let response = client.get(url).send()?.error_for_status()?;
Ok(response.bytes()?.to_vec())
}
fn download_text(client: &Client, url: &str) -> Result<String, Box<dyn std::error::Error>> {
let response = client.get(url).send()?.error_for_status()?;
Ok(response.text()?)
}
fn normalize_version_tag(version: &str) -> String {
version.trim().trim_start_matches('v').to_string()
}
fn is_newer_version(current: &str, latest: &str) -> bool {
compare_versions(latest, current).is_gt()
}
fn current_target() -> String {
BUILD_TARGET.map_or_else(default_target_triple, str::to_string)
}
fn release_asset_candidates() -> Vec<String> {
let mut candidates = target_name_candidates()
.into_iter()
.flat_map(|target| {
let mut names = vec![format!("rusty-claude-cli-{target}")];
if env::consts::OS == "windows" {
names.push(format!("rusty-claude-cli-{target}.exe"));
}
names
})
.collect::<Vec<_>>();
if env::consts::OS == "windows" {
candidates.push("rusty-claude-cli.exe".to_string());
}
candidates.push("rusty-claude-cli".to_string());
candidates.sort();
candidates.dedup();
candidates
}
fn select_release_assets(release: &GitHubRelease) -> Result<SelectedReleaseAssets, String> {
let binary = release_asset_candidates()
.into_iter()
.find_map(|candidate| {
release
.assets
.iter()
.find(|asset| asset.name == candidate)
.cloned()
})
.ok_or_else(|| {
format!(
"no binary asset matched target {} (expected one of: {})",
current_target(),
release_asset_candidates().join(", ")
)
})?;
let checksum = CHECKSUM_ASSET_CANDIDATES
.iter()
.find_map(|candidate| {
release
.assets
.iter()
.find(|asset| asset.name == *candidate)
.cloned()
})
.ok_or_else(|| {
format!(
"release did not include a checksum manifest (expected one of: {})",
CHECKSUM_ASSET_CANDIDATES.join(", ")
)
})?;
Ok(SelectedReleaseAssets { binary, checksum })
}
fn parse_checksum_for_asset(manifest: &str, asset_name: &str) -> Option<String> {
manifest.lines().find_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
if let Some((left, right)) = trimmed.split_once(" = ") {
return left
.strip_prefix("SHA256 (")
.and_then(|value| value.strip_suffix(')'))
.filter(|file| *file == asset_name)
.map(|_| right.to_ascii_lowercase());
}
let mut parts = trimmed.split_whitespace();
let checksum = parts.next()?;
let file = parts
.next_back()
.or_else(|| parts.next())?
.trim_start_matches('*');
(file == asset_name).then(|| checksum.to_ascii_lowercase())
})
}
fn sha256_hex(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
fn replace_current_executable(binary_bytes: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
let current = env::current_exe()?;
replace_executable_at(&current, binary_bytes)
}
fn replace_executable_at(
current: &Path,
binary_bytes: &[u8],
) -> Result<(), Box<dyn std::error::Error>> {
let temp_path = current.with_extension("download");
let backup_path = current.with_extension("bak");
if backup_path.exists() {
fs::remove_file(&backup_path)?;
}
fs::write(&temp_path, binary_bytes)?;
copy_executable_permissions(current, &temp_path)?;
fs::rename(current, &backup_path)?;
if let Err(error) = fs::rename(&temp_path, current) {
let _ = fs::rename(&backup_path, current);
let _ = fs::remove_file(&temp_path);
return Err(format!("failed to replace current executable: {error}").into());
}
if let Err(error) = fs::remove_file(&backup_path) {
eprintln!(
"warning: failed to remove self-update backup {}: {error}",
backup_path.display()
);
}
Ok(())
}
#[cfg(unix)]
fn copy_executable_permissions(
source: &Path,
destination: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(source)?.permissions().mode();
fs::set_permissions(destination, fs::Permissions::from_mode(mode))?;
Ok(())
}
#[cfg(not(unix))]
fn copy_executable_permissions(
_source: &Path,
_destination: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
fn render_update_report(
result: &str,
current_version: Option<&str>,
latest_version: Option<&str>,
detail: Option<&str>,
changelog: Option<&str>,
) -> String {
let mut report = String::from(
"Self-update
",
);
let _ = writeln!(report, " Repository {SELF_UPDATE_REPOSITORY}");
let _ = writeln!(report, " Result {result}");
if let Some(current_version) = current_version {
let _ = writeln!(report, " Current version {current_version}");
}
if let Some(latest_version) = latest_version {
let _ = writeln!(report, " Latest version {latest_version}");
}
if let Some(detail) = detail {
let _ = writeln!(report, " Detail {detail}");
}
let trimmed = changelog.map(str::trim).filter(|value| !value.is_empty());
if let Some(changelog) = trimmed {
report.push_str(
"
Changelog
",
);
report.push_str(changelog);
}
report.trim_end().to_string()
}
fn compare_versions(left: &str, right: &str) -> std::cmp::Ordering {
let left = normalize_version_tag(left);
let right = normalize_version_tag(right);
let left_parts = version_components(&left);
let right_parts = version_components(&right);
let max_len = left_parts.len().max(right_parts.len());
for index in 0..max_len {
let left_part = *left_parts.get(index).unwrap_or(&0);
let right_part = *right_parts.get(index).unwrap_or(&0);
match left_part.cmp(&right_part) {
std::cmp::Ordering::Equal => {}
ordering => return ordering,
}
}
std::cmp::Ordering::Equal
}
fn version_components(version: &str) -> Vec<u64> {
version
.split(['.', '-'])
.map(|part| {
part.chars()
.take_while(char::is_ascii_digit)
.collect::<String>()
})
.filter(|part| !part.is_empty())
.filter_map(|part| part.parse::<u64>().ok())
.collect()
}
fn default_target_triple() -> String {
let os = match env::consts::OS {
"linux" => "unknown-linux-gnu",
"macos" => "apple-darwin",
"windows" => "pc-windows-msvc",
other => other,
};
format!("{}-{os}", env::consts::ARCH)
}
fn target_name_candidates() -> Vec<String> {
let mut candidates = Vec::new();
if let Some(target) = BUILD_TARGET {
candidates.push(target.to_string());
}
candidates.push(default_target_triple());
candidates.push(format!("{}-{}", env::consts::ARCH, env::consts::OS));
candidates
}
fn resume_session(session_path: &Path, commands: &[String]) {
let session = match Session::load_from_path(session_path) {
Ok(session) => session,
Err(error) => {
eprintln!("failed to restore session: {error}");
@@ -554,7 +934,7 @@ fn resume_session(session_path: &Path, commands: &[String]) {
if commands.is_empty() {
println!(
"Restored session from {} ({} messages).",
handle.path.display(),
session_path.display(),
session.messages.len()
);
return;
@@ -566,7 +946,7 @@ fn resume_session(session_path: &Path, commands: &[String]) {
eprintln!("unsupported resumed command: {raw_command}");
std::process::exit(2);
};
match run_resume_command(&handle.path, &session, &command) {
match run_resume_command(session_path, &session, &command) {
Ok(ResumeCommandOutcome {
session: next_session,
message,
@@ -891,7 +1271,6 @@ fn run_resume_command(
| SlashCommand::Model { .. }
| SlashCommand::Permissions { .. }
| SlashCommand::Session { .. }
| SlashCommand::Sessions
| SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
}
}
@@ -948,9 +1327,6 @@ struct ManagedSessionSummary {
path: PathBuf,
modified_epoch_secs: u64,
message_count: usize,
model: Option<String>,
started_at: Option<String>,
last_prompt: Option<String>,
}
struct LiveCli {
@@ -971,7 +1347,6 @@ impl LiveCli {
) -> Result<Self, Box<dyn std::error::Error>> {
let system_prompt = build_system_prompt()?;
let session = create_managed_session_handle()?;
auto_compact_inactive_sessions(&session.id)?;
let runtime = build_runtime(
Session::new(),
model.clone(),
@@ -1143,10 +1518,6 @@ impl LiveCli {
SlashCommand::Session { action, target } => {
self.handle_session_command(action.as_deref(), target.as_deref())?
}
SlashCommand::Sessions => {
println!("{}", render_session_list(&self.session.id)?);
false
}
SlashCommand::Unknown(name) => {
eprintln!("unknown slash command: /{name}");
false
@@ -1155,10 +1526,7 @@ impl LiveCli {
}
fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> {
let mut session = self.runtime.session().clone();
session.metadata = Some(derive_session_metadata(&session, &self.model));
session.save_to_path(&self.session.path)?;
auto_compact_inactive_sessions(&self.session.id)?;
self.runtime.session().save_to_path(&self.session.path)?;
Ok(())
}
@@ -1303,20 +1671,13 @@ impl LiveCli {
session_path: Option<String>,
) -> Result<bool, Box<dyn std::error::Error>> {
let Some(session_ref) = session_path else {
println!("Usage: /resume <session-id-or-path>");
println!("Usage: /resume <session-path>");
return Ok(false);
};
let handle = resolve_session_reference(&session_ref)?;
let session = Session::load_from_path(&handle.path)?;
let message_count = session.messages.len();
if let Some(model) = session
.metadata
.as_ref()
.map(|metadata| metadata.model.clone())
{
self.model = model;
}
self.runtime = build_runtime(
session,
self.model.clone(),
@@ -1393,13 +1754,6 @@ impl LiveCli {
let handle = resolve_session_reference(target)?;
let session = Session::load_from_path(&handle.path)?;
let message_count = session.messages.len();
if let Some(model) = session
.metadata
.as_ref()
.map(|metadata| metadata.model.clone())
{
self.model = model;
}
self.runtime = build_runtime(
session,
self.model.clone(),
@@ -1444,10 +1798,8 @@ impl LiveCli {
}
fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
let home = env::var_os("HOME")
.map(PathBuf::from)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME is not set"))?;
let path = home.join(".claude").join("sessions");
let cwd = env::current_dir()?;
let path = cwd.join(".claude").join("sessions");
fs::create_dir_all(&path)?;
Ok(path)
}
@@ -1468,19 +1820,8 @@ fn generate_session_id() -> String {
fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> {
let direct = PathBuf::from(reference);
let expanded = if let Some(stripped) = reference.strip_prefix("~/") {
sessions_dir()?
.parent()
.and_then(|claude| claude.parent())
.map(|home| home.join(stripped))
.unwrap_or(direct.clone())
} else {
direct.clone()
};
let path = if direct.exists() {
direct
} else if expanded.exists() {
expanded
} else {
sessions_dir()?.join(format!("{reference}.json"))
};
@@ -1510,11 +1851,9 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_secs())
.unwrap_or_default();
let session = Session::load_from_path(&path).ok();
let derived_message_count = session.as_ref().map_or(0, |session| session.messages.len());
let stored = session
.as_ref()
.and_then(|session| session.metadata.as_ref());
let message_count = Session::load_from_path(&path)
.map(|session| session.messages.len())
.unwrap_or_default();
let id = path
.file_stem()
.and_then(|value| value.to_str())
@@ -1524,12 +1863,7 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
id,
path,
modified_epoch_secs,
message_count: stored.map_or(derived_message_count, |metadata| {
metadata.message_count as usize
}),
model: stored.map(|metadata| metadata.model.clone()),
started_at: stored.map(|metadata| metadata.started_at.clone()),
last_prompt: stored.and_then(|metadata| metadata.last_prompt.clone()),
message_count,
});
}
sessions.sort_by(|left, right| right.modified_epoch_secs.cmp(&left.modified_epoch_secs));
@@ -1552,99 +1886,17 @@ fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::e
} else {
"○ saved"
};
let model = session.model.as_deref().unwrap_or("unknown");
let started = session.started_at.as_deref().unwrap_or("unknown");
let last_prompt = session.last_prompt.as_deref().map_or_else(
|| "-".to_string(),
|prompt| truncate_for_summary(prompt, 36),
);
lines.push(format!(
" {id:<20} {marker:<10} msgs={msgs:<4} model={model:<24} started={started} modified={modified} last={last_prompt} path={path}",
" {id:<20} {marker:<10} msgs={msgs:<4} modified={modified} path={path}",
id = session.id,
msgs = session.message_count,
model = model,
started = started,
modified = session.modified_epoch_secs,
last_prompt = last_prompt,
path = session.path.display(),
));
}
Ok(lines.join("\n"))
}
fn current_epoch_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or_default()
}
fn current_timestamp_rfc3339ish() -> String {
format!("{}Z", current_epoch_secs())
}
fn last_prompt_from_session(session: &Session) -> Option<String> {
session
.messages
.iter()
.rev()
.find(|message| message.role == MessageRole::User)
.and_then(|message| {
message.blocks.iter().find_map(|block| match block {
ContentBlock::Text { text } => Some(text.trim().to_string()),
_ => None,
})
})
.filter(|text| !text.is_empty())
}
fn derive_session_metadata(session: &Session, model: &str) -> SessionMetadata {
let started_at = session
.metadata
.as_ref()
.map_or_else(current_timestamp_rfc3339ish, |metadata| {
metadata.started_at.clone()
});
SessionMetadata {
started_at,
model: model.to_string(),
message_count: session.messages.len().try_into().unwrap_or(u32::MAX),
last_prompt: last_prompt_from_session(session),
}
}
fn session_age_secs(modified_epoch_secs: u64) -> u64 {
current_epoch_secs().saturating_sub(modified_epoch_secs)
}
fn auto_compact_inactive_sessions(
active_session_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
for summary in list_managed_sessions()? {
if summary.id == active_session_id
|| session_age_secs(summary.modified_epoch_secs) < OLD_SESSION_COMPACTION_AGE_SECS
{
continue;
}
let path = summary.path.clone();
let Ok(session) = Session::load_from_path(&path) else {
continue;
};
if !runtime::should_compact(&session, CompactionConfig::default()) {
continue;
}
let mut compacted =
runtime::compact_session(&session, CompactionConfig::default()).compacted_session;
let model = compacted.metadata.as_ref().map_or_else(
|| DEFAULT_MODEL.to_string(),
|metadata| metadata.model.clone(),
);
compacted.metadata = Some(derive_session_metadata(&compacted, &model));
compacted.save_to_path(&path)?;
}
Ok(())
}
fn render_repl_help() -> String {
[
"REPL".to_string(),
@@ -2494,6 +2746,8 @@ fn print_help() {
println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]");
println!(" rusty-claude-cli login");
println!(" rusty-claude-cli logout");
println!(" rusty-claude-cli self-update");
println!(" Update the installed binary from the latest GitHub release");
println!();
println!("Flags:");
println!(" --model MODEL Override the active model");
@@ -2520,78 +2774,24 @@ fn print_help() {
println!(" rusty-claude-cli --allowedTools read,glob \"summarize Cargo.toml\"");
println!(" rusty-claude-cli --resume session.json /status /diff /export notes.txt");
println!(" rusty-claude-cli login");
println!(" rusty-claude-cli self-update");
}
#[cfg(test)]
mod tests {
use super::{
derive_session_metadata, filter_tool_specs, format_compact_report, format_cost_report,
format_init_report, format_model_report, format_model_switch_report,
format_permissions_report, format_permissions_switch_report, format_resume_report,
format_status_report, format_tool_call_start, format_tool_result, list_managed_sessions,
normalize_permission_mode, parse_args, parse_git_status_metadata, render_config_report,
render_init_claude_md, render_memory_report, render_repl_help,
resume_supported_slash_commands, sessions_dir, status_context, CliAction, CliOutputFormat,
SlashCommand, StatusUsage, DEFAULT_MODEL,
filter_tool_specs, format_compact_report, format_cost_report, format_init_report,
format_model_report, format_model_switch_report, format_permissions_report,
format_permissions_switch_report, format_resume_report, format_status_report,
format_tool_call_start, format_tool_result, is_newer_version, normalize_permission_mode,
normalize_version_tag, parse_args, parse_checksum_for_asset, parse_git_status_metadata,
render_config_report, render_init_claude_md, render_memory_report, render_repl_help,
render_update_report, resume_supported_slash_commands, select_release_assets,
status_context, CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL,
};
use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode, Session};
use std::fs;
use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode};
use std::path::{Path, PathBuf};
#[test]
fn derive_session_metadata_recomputes_prompt_and_count() {
let mut session = Session::new();
session
.messages
.push(ConversationMessage::user_text("first prompt"));
session
.messages
.push(ConversationMessage::assistant(vec![ContentBlock::Text {
text: "reply".to_string(),
}]));
let metadata = derive_session_metadata(&session, "claude-test");
assert_eq!(metadata.model, "claude-test");
assert_eq!(metadata.message_count, 2);
assert_eq!(metadata.last_prompt.as_deref(), Some("first prompt"));
assert!(metadata.started_at.ends_with('Z'));
}
#[test]
fn managed_sessions_use_home_directory_and_list_metadata() {
let temp =
std::env::temp_dir().join(format!("rusty-claude-cli-home-{}", std::process::id()));
let _ = fs::remove_dir_all(&temp);
fs::create_dir_all(&temp).expect("temp home should exist");
let previous_home = std::env::var_os("HOME");
std::env::set_var("HOME", &temp);
let dir = sessions_dir().expect("sessions dir");
assert_eq!(dir, temp.join(".claude").join("sessions"));
let mut session = Session::new();
session
.messages
.push(ConversationMessage::user_text("persist me"));
session.metadata = Some(derive_session_metadata(&session, "claude-home"));
let file = dir.join("session-test.json");
session.save_to_path(&file).expect("session save");
let listed = list_managed_sessions().expect("session list");
let found = listed
.into_iter()
.find(|entry| entry.id == "session-test")
.expect("saved session should be listed");
assert_eq!(found.message_count, 1);
assert_eq!(found.model.as_deref(), Some("claude-home"));
assert_eq!(found.last_prompt.as_deref(), Some("persist me"));
fs::remove_file(file).ok();
if let Some(previous_home) = previous_home {
std::env::set_var("HOME", previous_home);
}
fs::remove_dir_all(temp).ok();
}
#[test]
fn defaults_to_repl_when_no_args() {
assert_eq!(
@@ -2656,6 +2856,64 @@ mod tests {
);
}
#[test]
fn parses_self_update_subcommand() {
assert_eq!(
parse_args(&["self-update".to_string()]).expect("self-update should parse"),
CliAction::SelfUpdate
);
}
#[test]
fn normalize_version_tag_trims_v_prefix() {
assert_eq!(normalize_version_tag("v0.1.0"), "0.1.0");
assert_eq!(normalize_version_tag("0.1.0"), "0.1.0");
}
#[test]
fn detects_when_latest_version_differs() {
assert!(!is_newer_version("0.1.0", "v0.1.0"));
assert!(is_newer_version("0.1.0", "v0.2.0"));
}
#[test]
fn parses_checksum_manifest_for_named_asset() {
let manifest = "abc123 *rusty-claude-cli\ndef456 other-file\n";
assert_eq!(
parse_checksum_for_asset(manifest, "rusty-claude-cli"),
Some("abc123".to_string())
);
}
#[test]
fn select_release_assets_requires_checksum_file() {
let release = super::GitHubRelease {
tag_name: "v0.2.0".to_string(),
body: String::new(),
assets: vec![super::GitHubReleaseAsset {
name: "rusty-claude-cli".to_string(),
browser_download_url: "https://example.invalid/rusty-claude-cli".to_string(),
}],
};
let error = select_release_assets(&release).expect_err("missing checksum should error");
assert!(error.contains("checksum manifest"));
}
#[test]
fn update_report_includes_changelog_when_present() {
let report = render_update_report(
"Already up to date",
Some("0.1.0"),
Some("0.1.0"),
Some("No action taken."),
Some("- Added self-update"),
);
assert!(report.contains("Self-update"));
assert!(report.contains("Changelog"));
assert!(report.contains("- Added self-update"));
}
#[test]
fn parses_permission_mode_flag() {
let args = vec!["--permission-mode=read-only".to_string()];
@@ -2797,8 +3055,7 @@ mod tests {
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
assert!(help.contains("/clear [--confirm]"));
assert!(help.contains("/cost"));
assert!(help.contains("/resume <session-id-or-path>"));
assert!(help.contains("/sessions"));
assert!(help.contains("/resume <session-path>"));
assert!(help.contains("/config [env|hooks|model]"));
assert!(help.contains("/memory"));
assert!(help.contains("/init"));