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
6 changed files with 505 additions and 351 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
@@ -162,6 +171,7 @@ cargo run -p rusty-claude-cli -- --resume session.json /memory /config
- `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

View File

@@ -414,7 +414,6 @@ mod tests {
cwd: PathBuf::from("/tmp/project"),
current_date: "2026-03-31".to_string(),
git_status: None,
git_diff: None,
instruction_files: Vec::new(),
})
.with_os("linux", "6.8")

View File

@@ -50,7 +50,6 @@ pub struct ProjectContext {
pub cwd: PathBuf,
pub current_date: String,
pub git_status: Option<String>,
pub git_diff: Option<String>,
pub instruction_files: Vec<ContextFile>,
}
@@ -65,7 +64,6 @@ impl ProjectContext {
cwd,
current_date: current_date.into(),
git_status: None,
git_diff: None,
instruction_files,
})
}
@@ -76,7 +74,6 @@ impl ProjectContext {
) -> std::io::Result<Self> {
let mut context = Self::discover(cwd, current_date)?;
context.git_status = read_git_status(&context.cwd);
context.git_diff = read_git_diff(&context.cwd);
Ok(context)
}
}
@@ -242,38 +239,6 @@ fn read_git_status(cwd: &Path) -> Option<String> {
}
}
fn read_git_diff(cwd: &Path) -> Option<String> {
let mut sections = Vec::new();
let staged = read_git_output(cwd, &["diff", "--cached"])?;
if !staged.trim().is_empty() {
sections.push(format!("Staged changes:\n{}", staged.trim_end()));
}
let unstaged = read_git_output(cwd, &["diff"])?;
if !unstaged.trim().is_empty() {
sections.push(format!("Unstaged changes:\n{}", unstaged.trim_end()));
}
if sections.is_empty() {
None
} else {
Some(sections.join("\n\n"))
}
}
fn read_git_output(cwd: &Path, args: &[&str]) -> Option<String> {
let output = Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout).ok()
}
fn render_project_context(project_context: &ProjectContext) -> String {
let mut lines = vec!["# Project context".to_string()];
let mut bullets = vec![
@@ -292,11 +257,6 @@ fn render_project_context(project_context: &ProjectContext) -> String {
lines.push("Git status snapshot:".to_string());
lines.push(status.clone());
}
if let Some(diff) = &project_context.git_diff {
lines.push(String::new());
lines.push("Git diff snapshot:".to_string());
lines.push(diff.clone());
}
lines.join("\n")
}
@@ -617,49 +577,6 @@ mod tests {
assert!(status.contains("## No commits yet on") || status.contains("## "));
assert!(status.contains("?? CLAUDE.md"));
assert!(status.contains("?? tracked.txt"));
assert!(context.git_diff.is_none());
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn discover_with_git_includes_diff_snapshot_for_tracked_changes() {
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
std::process::Command::new("git")
.args(["init", "--quiet"])
.current_dir(&root)
.status()
.expect("git init should run");
std::process::Command::new("git")
.args(["config", "user.email", "tests@example.com"])
.current_dir(&root)
.status()
.expect("git config email should run");
std::process::Command::new("git")
.args(["config", "user.name", "Runtime Prompt Tests"])
.current_dir(&root)
.status()
.expect("git config name should run");
fs::write(root.join("tracked.txt"), "hello\n").expect("write tracked file");
std::process::Command::new("git")
.args(["add", "tracked.txt"])
.current_dir(&root)
.status()
.expect("git add should run");
std::process::Command::new("git")
.args(["commit", "-m", "init", "--quiet"])
.current_dir(&root)
.status()
.expect("git commit should run");
fs::write(root.join("tracked.txt"), "hello\nworld\n").expect("rewrite tracked file");
let context =
ProjectContext::discover_with_git(&root, "2026-03-31").expect("context should load");
let diff = context.git_diff.expect("git diff should be present");
assert!(diff.contains("Unstaged changes:"));
assert!(diff.contains("tracked.txt"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}

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,6 +22,7 @@ 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,
@@ -29,7 +31,9 @@ use runtime::{
OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
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";
@@ -39,6 +43,18 @@ const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
const VERSION: &str = env!("CARGO_PKG_VERSION");
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>;
@@ -60,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,
@@ -93,6 +110,7 @@ enum CliAction {
date: String,
},
Version,
SelfUpdate,
ResumeSession {
session_path: PathBuf,
commands: Vec<String>,
@@ -228,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" => {
@@ -534,6 +553,375 @@ fn print_version() {
println!("{}", render_version_report());
}
#[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 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,
@@ -742,61 +1130,27 @@ fn format_compact_report(removed: usize, resulting_messages: usize, skipped: boo
}
fn parse_git_status_metadata(status: Option<&str>) -> (Option<PathBuf>, Option<String>) {
parse_git_status_metadata_for(
&env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
status,
)
let Some(status) = status else {
return (None, None);
};
let branch = status.lines().next().and_then(|line| {
line.strip_prefix("## ")
.map(|line| {
line.split(['.', ' '])
.next()
.unwrap_or_default()
.to_string()
})
.filter(|value| !value.is_empty())
});
let project_root = find_git_root().ok();
(project_root, branch)
}
fn parse_git_status_branch(status: Option<&str>) -> Option<String> {
let status = status?;
let first_line = status.lines().next()?;
let line = first_line.strip_prefix("## ")?;
if line.starts_with("HEAD") {
return Some("detached HEAD".to_string());
}
let branch = line.split(['.', ' ']).next().unwrap_or_default().trim();
if branch.is_empty() {
None
} else {
Some(branch.to_string())
}
}
fn resolve_git_branch_for(cwd: &Path) -> Option<String> {
let branch = run_git_capture_in(cwd, &["branch", "--show-current"])?;
let branch = branch.trim();
if !branch.is_empty() {
return Some(branch.to_string());
}
let fallback = run_git_capture_in(cwd, &["rev-parse", "--abbrev-ref", "HEAD"])?;
let fallback = fallback.trim();
if fallback.is_empty() {
None
} else if fallback == "HEAD" {
Some("detached HEAD".to_string())
} else {
Some(fallback.to_string())
}
}
fn run_git_capture_in(cwd: &Path, args: &[&str]) -> Option<String> {
let output = std::process::Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout).ok()
}
fn find_git_root_in(cwd: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
fn find_git_root() -> Result<PathBuf, Box<dyn std::error::Error>> {
let output = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(cwd)
.current_dir(env::current_dir()?)
.output()?;
if !output.status.success() {
return Err("not a git repository".into());
@@ -808,15 +1162,6 @@ fn find_git_root_in(cwd: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
Ok(PathBuf::from(path))
}
fn parse_git_status_metadata_for(
cwd: &Path,
status: Option<&str>,
) -> (Option<PathBuf>, Option<String>) {
let branch = resolve_git_branch_for(cwd).or_else(|| parse_git_status_branch(status));
let project_root = find_git_root_in(cwd).ok();
(project_root, branch)
}
#[allow(clippy::too_many_lines)]
fn run_resume_command(
session_path: &Path,
@@ -904,9 +1249,7 @@ fn run_resume_command(
}),
SlashCommand::Diff => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_diff_report_for(
session_path.parent().unwrap_or_else(|| Path::new(".")),
)?),
message: Some(render_diff_report()?),
}),
SlashCommand::Version => Ok(ResumeCommandOutcome {
session: session.clone(),
@@ -1840,43 +2183,22 @@ fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
}
fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> {
render_diff_report_for(&env::current_dir()?)
}
fn render_diff_report_for(cwd: &Path) -> Result<String, Box<dyn std::error::Error>> {
let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?;
let unstaged = run_git_diff_command_in(cwd, &["diff"])?;
if staged.trim().is_empty() && unstaged.trim().is_empty() {
let output = std::process::Command::new("git")
.args(["diff", "--", ":(exclude).omx"])
.current_dir(env::current_dir()?)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(format!("git diff failed: {stderr}").into());
}
let diff = String::from_utf8(output.stdout)?;
if diff.trim().is_empty() {
return Ok(
"Diff\n Result clean working tree\n Detail no current changes"
.to_string(),
);
}
let mut sections = Vec::new();
if !staged.trim().is_empty() {
sections.push(format!("Staged changes:\n{}", staged.trim_end()));
}
if !unstaged.trim().is_empty() {
sections.push(format!("Unstaged changes:\n{}", unstaged.trim_end()));
}
Ok(format!("Diff\n\n{}", sections.join("\n\n")))
}
fn run_git_diff_command_in(
cwd: &Path,
args: &[&str],
) -> Result<String, Box<dyn std::error::Error>> {
let output = std::process::Command::new("git")
.args(args)
.current_dir(cwd)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(format!("git {} failed: {stderr}", args.join(" ")).into());
}
Ok(String::from_utf8(output.stdout)?)
Ok(format!("Diff\n\n{}", diff.trim_end()))
}
fn render_version_report() -> String {
@@ -2424,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");
@@ -2450,6 +2774,7 @@ 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)]
@@ -2458,54 +2783,14 @@ mod tests {
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, normalize_permission_mode, parse_args,
parse_git_status_branch, parse_git_status_metadata, render_config_report,
render_diff_report, render_init_claude_md, render_memory_report, render_repl_help,
resume_supported_slash_commands, run_resume_command, status_context, CliAction,
CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL,
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};
use std::process::Command;
use std::sync::{Mutex, MutexGuard, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir() -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!("rusty-claude-cli-{nanos}"))
}
fn git(args: &[&str], cwd: &Path) {
let status = Command::new("git")
.args(args)
.current_dir(cwd)
.status()
.expect("git command should run");
assert!(
status.success(),
"git command failed: git {}",
args.join(" ")
);
}
fn env_lock() -> MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn with_current_dir<T>(cwd: &Path, f: impl FnOnce() -> T) -> T {
let previous = std::env::current_dir().expect("cwd should load");
std::env::set_current_dir(cwd).expect("cwd should change");
let result = f();
std::env::set_current_dir(previous).expect("cwd should restore");
result
}
#[test]
fn defaults_to_repl_when_no_args() {
@@ -2571,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()];
@@ -2892,140 +3235,19 @@ mod tests {
#[test]
fn parses_git_status_metadata() {
let _guard = env_lock();
let temp_root = temp_dir();
fs::create_dir_all(&temp_root).expect("root dir");
let (project_root, branch) = with_current_dir(&temp_root, || {
parse_git_status_metadata(Some(
"## rcc/cli...origin/rcc/cli
let (root, branch) = parse_git_status_metadata(Some(
"## rcc/cli...origin/rcc/cli
M src/main.rs",
))
});
));
assert_eq!(branch.as_deref(), Some("rcc/cli"));
assert!(project_root.is_none());
fs::remove_dir_all(temp_root).expect("cleanup temp dir");
}
#[test]
fn parses_detached_head_from_status_snapshot() {
let _guard = env_lock();
assert_eq!(
parse_git_status_branch(Some(
"## HEAD (no branch)
M src/main.rs"
)),
Some("detached HEAD".to_string())
);
}
#[test]
fn render_diff_report_shows_clean_tree_for_committed_repo() {
let _guard = env_lock();
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
git(&["init", "--quiet"], &root);
git(&["config", "user.email", "tests@example.com"], &root);
git(&["config", "user.name", "Rusty Claude Tests"], &root);
fs::write(root.join("tracked.txt"), "hello\n").expect("write file");
git(&["add", "tracked.txt"], &root);
git(&["commit", "-m", "init", "--quiet"], &root);
let report = with_current_dir(&root, || {
render_diff_report().expect("diff report should render")
});
assert!(report.contains("clean working tree"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn render_diff_report_includes_staged_and_unstaged_sections() {
let _guard = env_lock();
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
git(&["init", "--quiet"], &root);
git(&["config", "user.email", "tests@example.com"], &root);
git(&["config", "user.name", "Rusty Claude Tests"], &root);
fs::write(root.join("tracked.txt"), "hello\n").expect("write file");
git(&["add", "tracked.txt"], &root);
git(&["commit", "-m", "init", "--quiet"], &root);
fs::write(root.join("tracked.txt"), "hello\nstaged\n").expect("update file");
git(&["add", "tracked.txt"], &root);
fs::write(root.join("tracked.txt"), "hello\nstaged\nunstaged\n")
.expect("update file twice");
let report = with_current_dir(&root, || {
render_diff_report().expect("diff report should render")
});
assert!(report.contains("Staged changes:"));
assert!(report.contains("Unstaged changes:"));
assert!(report.contains("tracked.txt"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn render_diff_report_omits_ignored_files() {
let _guard = env_lock();
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
git(&["init", "--quiet"], &root);
git(&["config", "user.email", "tests@example.com"], &root);
git(&["config", "user.name", "Rusty Claude Tests"], &root);
fs::write(root.join(".gitignore"), ".omx/\nignored.txt\n").expect("write gitignore");
fs::write(root.join("tracked.txt"), "hello\n").expect("write tracked");
git(&["add", ".gitignore", "tracked.txt"], &root);
git(&["commit", "-m", "init", "--quiet"], &root);
fs::create_dir_all(root.join(".omx")).expect("write omx dir");
fs::write(root.join(".omx").join("state.json"), "{}").expect("write ignored omx");
fs::write(root.join("ignored.txt"), "secret\n").expect("write ignored file");
fs::write(root.join("tracked.txt"), "hello\nworld\n").expect("write tracked change");
let report = with_current_dir(&root, || {
render_diff_report().expect("diff report should render")
});
assert!(report.contains("tracked.txt"));
assert!(!report.contains("+++ b/ignored.txt"));
assert!(!report.contains("+++ b/.omx/state.json"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn resume_diff_command_renders_report_for_saved_session() {
let _guard = env_lock();
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
git(&["init", "--quiet"], &root);
git(&["config", "user.email", "tests@example.com"], &root);
git(&["config", "user.name", "Rusty Claude Tests"], &root);
fs::write(root.join("tracked.txt"), "hello\n").expect("write tracked");
git(&["add", "tracked.txt"], &root);
git(&["commit", "-m", "init", "--quiet"], &root);
fs::write(root.join("tracked.txt"), "hello\nworld\n").expect("modify tracked");
let session_path = root.join("session.json");
Session::new()
.save_to_path(&session_path)
.expect("session should save");
let session = Session::load_from_path(&session_path).expect("session should load");
let outcome = with_current_dir(&root, || {
run_resume_command(&session_path, &session, &SlashCommand::Diff)
.expect("resume diff should work")
});
let message = outcome.message.expect("diff message should exist");
assert!(message.contains("Unstaged changes:"));
assert!(message.contains("tracked.txt"));
fs::remove_dir_all(root).expect("cleanup temp dir");
let _ = root;
}
#[test]
fn status_context_reads_real_workspace_metadata() {
let context = status_context(None).expect("status context should load");
assert!(context.cwd.is_absolute());
assert!(context.discovered_config_files >= context.loaded_config_files);
assert!(context.discovered_config_files >= 3);
assert!(context.loaded_config_files <= context.discovered_config_files);
}