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
7 changed files with 507 additions and 376 deletions

3
rust/Cargo.lock generated
View File

@@ -1091,8 +1091,11 @@ dependencies = [
"compat-harness", "compat-harness",
"crossterm", "crossterm",
"pulldown-cmark", "pulldown-cmark",
"reqwest",
"runtime", "runtime",
"serde",
"serde_json", "serde_json",
"sha2",
"syntect", "syntect",
"tokio", "tokio",
"tools", "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`. 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 ## Usage examples
### 1) Prompt mode ### 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 - `dump-manifests` — print extracted upstream manifest counts
- `bootstrap-plan` — print the current bootstrap skeleton - `bootstrap-plan` — print the current bootstrap skeleton
- `system-prompt [--cwd PATH] [--date YYYY-MM-DD]` — render the synthesized system prompt - `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 - `--help` / `-h` — show CLI help
- `--version` / `-V` — print the CLI version and build info locally (no API call) - `--version` / `-V` — print the CLI version and build info locally (no API call)
- `--output-format text|json` — choose non-interactive prompt output rendering - `--output-format text|json` — choose non-interactive prompt output rendering

View File

@@ -11,7 +11,7 @@ pub use error::ApiError;
pub use sse::{parse_frame, SseParser}; pub use sse::{parse_frame, SseParser};
pub use types::{ pub use types::{
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
ImageSource, InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest, InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest,
MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent, MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent,
ToolChoice, ToolDefinition, ToolResultContentBlock, Usage, ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
}; };

View File

@@ -64,9 +64,6 @@ pub enum InputContentBlock {
Text { Text {
text: String, text: String,
}, },
Image {
source: ImageSource,
},
ToolUse { ToolUse {
id: String, id: String,
name: String, name: String,
@@ -80,14 +77,6 @@ pub enum InputContentBlock {
}, },
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImageSource {
#[serde(rename = "type")]
pub kind: String,
pub media_type: String,
pub data: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum ToolResultContentBlock { pub enum ToolResultContentBlock {

View File

@@ -4,8 +4,8 @@ use std::time::Duration;
use api::{ use api::{
AnthropicClient, ApiError, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, AnthropicClient, ApiError, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent,
ImageSource, InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest, InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest, OutputContentBlock,
OutputContentBlock, StreamEvent, ToolChoice, ToolDefinition, StreamEvent, ToolChoice, ToolDefinition,
}; };
use serde_json::json; use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -75,39 +75,6 @@ async fn send_message_posts_json_and_parses_response() {
assert_eq!(body["tool_choice"]["type"], json!("auto")); assert_eq!(body["tool_choice"]["type"], json!("auto"));
} }
#[test]
fn image_content_blocks_serialize_with_base64_source() {
let request = MessageRequest {
model: "claude-3-7-sonnet-latest".to_string(),
max_tokens: 64,
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Image {
source: ImageSource {
kind: "base64".to_string(),
media_type: "image/png".to_string(),
data: "AQID".to_string(),
},
}],
}],
system: None,
tools: None,
tool_choice: None,
stream: false,
};
let json = serde_json::to_value(request).expect("request should serialize");
assert_eq!(json["messages"][0]["content"][0]["type"], json!("image"));
assert_eq!(
json["messages"][0]["content"][0]["source"],
json!({
"type": "base64",
"media_type": "image/png",
"data": "AQID"
})
);
}
#[tokio::test] #[tokio::test]
async fn stream_message_parses_sse_events_with_tool_use() { async fn stream_message_parses_sse_events_with_tool_use() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new())); let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));

View File

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

View File

@@ -3,6 +3,7 @@ mod render;
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::env; use std::env;
use std::fmt::Write as _;
use std::fs; use std::fs;
use std::io::{self, Read, Write}; use std::io::{self, Read, Write};
use std::net::TcpListener; use std::net::TcpListener;
@@ -11,8 +12,8 @@ use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use api::{ use api::{
resolve_startup_auth_source, AnthropicClient, AuthSource, ContentBlockDelta, ImageSource, resolve_startup_auth_source, AnthropicClient, AuthSource, ContentBlockDelta, InputContentBlock,
InputContentBlock, InputMessage, MessageRequest, MessageResponse, OutputContentBlock, InputMessage, MessageRequest, MessageResponse, OutputContentBlock,
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
}; };
@@ -21,6 +22,7 @@ use commands::{
}; };
use compat_harness::{extract_manifest, UpstreamPaths}; use compat_harness::{extract_manifest, UpstreamPaths};
use render::{Spinner, TerminalRenderer}; use render::{Spinner, TerminalRenderer};
use reqwest::blocking::Client;
use runtime::{ use runtime::{
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt, clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest, parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest,
@@ -29,7 +31,9 @@ use runtime::{
OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
}; };
use serde::Deserialize;
use serde_json::json; use serde_json::json;
use sha2::{Digest, Sha256};
use tools::{execute_tool, mvp_tool_specs, ToolSpec}; use tools::{execute_tool, mvp_tool_specs, ToolSpec};
const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514"; const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
@@ -39,9 +43,20 @@ const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
const VERSION: &str = env!("CARGO_PKG_VERSION"); const VERSION: &str = env!("CARGO_PKG_VERSION");
const BUILD_TARGET: Option<&str> = option_env!("TARGET"); const BUILD_TARGET: Option<&str> = option_env!("TARGET");
const GIT_SHA: Option<&str> = option_env!("GIT_SHA"); 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>; type AllowedToolSet = BTreeSet<String>;
const IMAGE_REF_PREFIX: &str = "@";
fn main() { fn main() {
if let Err(error) = run() { if let Err(error) = run() {
@@ -61,6 +76,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
CliAction::BootstrapPlan => print_bootstrap_plan(), CliAction::BootstrapPlan => print_bootstrap_plan(),
CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date), CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
CliAction::Version => print_version(), CliAction::Version => print_version(),
CliAction::SelfUpdate => run_self_update()?,
CliAction::ResumeSession { CliAction::ResumeSession {
session_path, session_path,
commands, commands,
@@ -94,6 +110,7 @@ enum CliAction {
date: String, date: String,
}, },
Version, Version,
SelfUpdate,
ResumeSession { ResumeSession {
session_path: PathBuf, session_path: PathBuf,
commands: Vec<String>, commands: Vec<String>,
@@ -229,6 +246,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
"dump-manifests" => Ok(CliAction::DumpManifests), "dump-manifests" => Ok(CliAction::DumpManifests),
"bootstrap-plan" => Ok(CliAction::BootstrapPlan), "bootstrap-plan" => Ok(CliAction::BootstrapPlan),
"system-prompt" => parse_system_prompt_args(&rest[1..]), "system-prompt" => parse_system_prompt_args(&rest[1..]),
"self-update" => Ok(CliAction::SelfUpdate),
"login" => Ok(CliAction::Login), "login" => Ok(CliAction::Login),
"logout" => Ok(CliAction::Logout), "logout" => Ok(CliAction::Logout),
"prompt" => { "prompt" => {
@@ -535,6 +553,375 @@ fn print_version() {
println!("{}", render_version_report()); 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]) { fn resume_session(session_path: &Path, commands: &[String]) {
let session = match Session::load_from_path(session_path) { let session = match Session::load_from_path(session_path) {
Ok(session) => session, Ok(session) => session,
@@ -1043,7 +1430,9 @@ impl LiveCli {
max_tokens: DEFAULT_MAX_TOKENS, max_tokens: DEFAULT_MAX_TOKENS,
messages: vec![InputMessage { messages: vec![InputMessage {
role: "user".to_string(), role: "user".to_string(),
content: prompt_to_content_blocks(input, &env::current_dir()?)?, content: vec![InputContentBlock::Text {
text: input.to_string(),
}],
}], }],
system: (!self.system_prompt.is_empty()).then(|| self.system_prompt.join("\n\n")), system: (!self.system_prompt.is_empty()).then(|| self.system_prompt.join("\n\n")),
tools: None, tools: None,
@@ -2020,7 +2409,7 @@ impl ApiClient for AnthropicRuntimeClient {
let message_request = MessageRequest { let message_request = MessageRequest {
model: self.model.clone(), model: self.model.clone(),
max_tokens: DEFAULT_MAX_TOKENS, max_tokens: DEFAULT_MAX_TOKENS,
messages: convert_messages(&request.messages)?, messages: convert_messages(&request.messages),
system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")), system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")),
tools: self.enable_tools.then(|| { tools: self.enable_tools.then(|| {
filter_tool_specs(self.allowed_tools.as_ref()) filter_tool_specs(self.allowed_tools.as_ref())
@@ -2299,10 +2688,7 @@ fn tool_permission_specs() -> Vec<ToolSpec> {
mvp_tool_specs() mvp_tool_specs()
} }
fn convert_messages(messages: &[ConversationMessage]) -> Result<Vec<InputMessage>, RuntimeError> { fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
let cwd = env::current_dir().map_err(|error| {
RuntimeError::new(format!("failed to resolve current directory: {error}"))
})?;
messages messages
.iter() .iter()
.filter_map(|message| { .filter_map(|message| {
@@ -2313,224 +2699,36 @@ fn convert_messages(messages: &[ConversationMessage]) -> Result<Vec<InputMessage
let content = message let content = message
.blocks .blocks
.iter() .iter()
.try_fold(Vec::new(), |mut acc, block| { .map(|block| match block {
match block { ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
ContentBlock::Text { text } => { ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
if message.role == MessageRole::User { id: id.clone(),
acc.extend( name: name.clone(),
prompt_to_content_blocks(text, &cwd) input: serde_json::from_str(input)
.map_err(RuntimeError::new)?, .unwrap_or_else(|_| serde_json::json!({ "raw": input })),
); },
} else { ContentBlock::ToolResult {
acc.push(InputContentBlock::Text { text: text.clone() }); tool_use_id,
} output,
} is_error,
ContentBlock::ToolUse { id, name, input } => { ..
acc.push(InputContentBlock::ToolUse { } => InputContentBlock::ToolResult {
id: id.clone(), tool_use_id: tool_use_id.clone(),
name: name.clone(), content: vec![ToolResultContentBlock::Text {
input: serde_json::from_str(input) text: output.clone(),
.unwrap_or_else(|_| serde_json::json!({ "raw": input })), }],
}); is_error: *is_error,
} },
ContentBlock::ToolResult { })
tool_use_id, .collect::<Vec<_>>();
output, (!content.is_empty()).then(|| InputMessage {
is_error, role: role.to_string(),
.. content,
} => acc.push(InputContentBlock::ToolResult { })
tool_use_id: tool_use_id.clone(),
content: vec![ToolResultContentBlock::Text {
text: output.clone(),
}],
is_error: *is_error,
}),
}
Ok::<_, RuntimeError>(acc)
});
match content {
Ok(content) if !content.is_empty() => Some(Ok(InputMessage {
role: role.to_string(),
content,
})),
Ok(_) => None,
Err(error) => Some(Err(error)),
}
}) })
.collect() .collect()
} }
fn prompt_to_content_blocks(input: &str, cwd: &Path) -> Result<Vec<InputContentBlock>, String> {
let mut blocks = Vec::new();
let mut text_buffer = String::new();
let mut chars = input.char_indices().peekable();
while let Some((index, ch)) = chars.next() {
if ch == '!' && input[index..].starts_with("![") {
if let Some((alt_end, path_start, path_end)) = parse_markdown_image_ref(input, index) {
let _ = alt_end;
flush_text_block(&mut blocks, &mut text_buffer);
let path = &input[path_start..path_end];
blocks.push(load_image_block(path, cwd)?);
while let Some((next_index, _)) = chars.peek() {
if *next_index < path_end + 1 {
let _ = chars.next();
} else {
break;
}
}
continue;
}
}
if ch == '@' && is_ref_boundary(input[..index].chars().next_back()) {
let path_end = find_path_end(input, index + 1);
if path_end > index + 1 {
let candidate = &input[index + 1..path_end];
if looks_like_image_ref(candidate, cwd) {
flush_text_block(&mut blocks, &mut text_buffer);
blocks.push(load_image_block(candidate, cwd)?);
while let Some((next_index, _)) = chars.peek() {
if *next_index < path_end {
let _ = chars.next();
} else {
break;
}
}
continue;
}
}
}
text_buffer.push(ch);
}
flush_text_block(&mut blocks, &mut text_buffer);
if blocks.is_empty() {
blocks.push(InputContentBlock::Text {
text: input.to_string(),
});
}
Ok(blocks)
}
fn parse_markdown_image_ref(input: &str, start: usize) -> Option<(usize, usize, usize)> {
let after_bang = input.get(start + 2..)?;
let alt_end_offset = after_bang.find("](")?;
let path_start = start + 2 + alt_end_offset + 2;
let remainder = input.get(path_start..)?;
let path_end_offset = remainder.find(')')?;
let path_end = path_start + path_end_offset;
Some((start + 2 + alt_end_offset, path_start, path_end))
}
fn is_ref_boundary(ch: Option<char>) -> bool {
ch.is_none_or(char::is_whitespace)
}
fn find_path_end(input: &str, start: usize) -> usize {
input[start..]
.char_indices()
.find_map(|(offset, ch)| (ch.is_whitespace()).then_some(start + offset))
.unwrap_or(input.len())
}
fn looks_like_image_ref(candidate: &str, cwd: &Path) -> bool {
let resolved = resolve_prompt_path(candidate, cwd);
media_type_for_path(Path::new(candidate)).is_some()
|| resolved.is_file()
|| candidate.contains(std::path::MAIN_SEPARATOR)
|| candidate.starts_with("./")
|| candidate.starts_with("../")
}
fn flush_text_block(blocks: &mut Vec<InputContentBlock>, text_buffer: &mut String) {
if text_buffer.is_empty() {
return;
}
blocks.push(InputContentBlock::Text {
text: std::mem::take(text_buffer),
});
}
fn load_image_block(path_ref: &str, cwd: &Path) -> Result<InputContentBlock, String> {
let resolved = resolve_prompt_path(path_ref, cwd);
let media_type = media_type_for_path(&resolved).ok_or_else(|| {
format!(
"unsupported image format for reference {IMAGE_REF_PREFIX}{path_ref}; supported: png, jpg, jpeg, gif, webp"
)
})?;
let bytes = fs::read(&resolved).map_err(|error| {
format!(
"failed to read image reference {}: {error}",
resolved.display()
)
})?;
Ok(InputContentBlock::Image {
source: ImageSource {
kind: "base64".to_string(),
media_type: media_type.to_string(),
data: encode_base64(&bytes),
},
})
}
fn resolve_prompt_path(path_ref: &str, cwd: &Path) -> PathBuf {
let path = Path::new(path_ref);
if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
}
}
fn media_type_for_path(path: &Path) -> Option<&'static str> {
let extension = path.extension()?.to_str()?.to_ascii_lowercase();
match extension.as_str() {
"png" => Some("image/png"),
"jpg" | "jpeg" => Some("image/jpeg"),
"gif" => Some("image/gif"),
"webp" => Some("image/webp"),
_ => None,
}
}
fn encode_base64(bytes: &[u8]) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut output = String::new();
let mut index = 0;
while index + 3 <= bytes.len() {
let block = (u32::from(bytes[index]) << 16)
| (u32::from(bytes[index + 1]) << 8)
| u32::from(bytes[index + 2]);
output.push(TABLE[((block >> 18) & 0x3F) as usize] as char);
output.push(TABLE[((block >> 12) & 0x3F) as usize] as char);
output.push(TABLE[((block >> 6) & 0x3F) as usize] as char);
output.push(TABLE[(block & 0x3F) as usize] as char);
index += 3;
}
match bytes.len().saturating_sub(index) {
1 => {
let block = u32::from(bytes[index]) << 16;
output.push(TABLE[((block >> 18) & 0x3F) as usize] as char);
output.push(TABLE[((block >> 12) & 0x3F) as usize] as char);
output.push('=');
output.push('=');
}
2 => {
let block = (u32::from(bytes[index]) << 16) | (u32::from(bytes[index + 1]) << 8);
output.push(TABLE[((block >> 18) & 0x3F) as usize] as char);
output.push(TABLE[((block >> 12) & 0x3F) as usize] as char);
output.push(TABLE[((block >> 6) & 0x3F) as usize] as char);
output.push('=');
}
_ => {}
}
output
}
fn print_help() { fn print_help() {
println!("rusty-claude-cli v{VERSION}"); println!("rusty-claude-cli v{VERSION}");
println!(); println!();
@@ -2548,6 +2746,8 @@ fn print_help() {
println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]"); println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]");
println!(" rusty-claude-cli login"); println!(" rusty-claude-cli login");
println!(" rusty-claude-cli logout"); println!(" rusty-claude-cli logout");
println!(" rusty-claude-cli self-update");
println!(" Update the installed binary from the latest GitHub release");
println!(); println!();
println!("Flags:"); println!("Flags:");
println!(" --model MODEL Override the active model"); println!(" --model MODEL Override the active model");
@@ -2574,6 +2774,7 @@ fn print_help() {
println!(" rusty-claude-cli --allowedTools read,glob \"summarize Cargo.toml\""); 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 --resume session.json /status /diff /export notes.txt");
println!(" rusty-claude-cli login"); println!(" rusty-claude-cli login");
println!(" rusty-claude-cli self-update");
} }
#[cfg(test)] #[cfg(test)]
@@ -2582,15 +2783,14 @@ mod tests {
filter_tool_specs, format_compact_report, format_cost_report, format_init_report, filter_tool_specs, format_compact_report, format_cost_report, format_init_report,
format_model_report, format_model_switch_report, format_permissions_report, format_model_report, format_model_switch_report, format_permissions_report,
format_permissions_switch_report, format_resume_report, format_status_report, format_permissions_switch_report, format_resume_report, format_status_report,
format_tool_call_start, format_tool_result, normalize_permission_mode, parse_args, format_tool_call_start, format_tool_result, is_newer_version, normalize_permission_mode,
parse_git_status_metadata, render_config_report, render_init_claude_md, normalize_version_tag, parse_args, parse_checksum_for_asset, parse_git_status_metadata,
render_memory_report, render_repl_help, resume_supported_slash_commands, status_context, render_config_report, render_init_claude_md, render_memory_report, render_repl_help,
CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL, render_update_report, resume_supported_slash_commands, select_release_assets,
status_context, CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL,
}; };
use api::InputContentBlock;
use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode}; use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
#[test] #[test]
fn defaults_to_repl_when_no_args() { fn defaults_to_repl_when_no_args() {
@@ -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] #[test]
fn parses_permission_mode_flag() { fn parses_permission_mode_flag() {
let args = vec!["--permission-mode=read-only".to_string()]; let args = vec!["--permission-mode=read-only".to_string()];
@@ -3073,110 +3331,11 @@ mod tests {
}, },
]; ];
let converted = super::convert_messages(&messages).expect("messages should convert"); let converted = super::convert_messages(&messages);
assert_eq!(converted.len(), 3); assert_eq!(converted.len(), 3);
assert_eq!(converted[1].role, "assistant"); assert_eq!(converted[1].role, "assistant");
assert_eq!(converted[2].role, "user"); assert_eq!(converted[2].role, "user");
} }
#[test]
fn prompt_to_content_blocks_keeps_text_only_prompt() {
let blocks = super::prompt_to_content_blocks("hello world", Path::new("."))
.expect("text prompt should parse");
assert_eq!(
blocks,
vec![InputContentBlock::Text {
text: "hello world".to_string()
}]
);
}
#[test]
fn prompt_to_content_blocks_embeds_at_image_refs() {
let temp = temp_fixture_dir("at-image-ref");
let image_path = temp.join("sample.png");
std::fs::write(&image_path, [1_u8, 2, 3]).expect("fixture write");
let prompt = format!("describe @{} please", image_path.display());
let blocks = super::prompt_to_content_blocks(&prompt, Path::new("."))
.expect("image ref should parse");
assert!(matches!(
&blocks[0],
InputContentBlock::Text { text } if text == "describe "
));
assert!(matches!(
&blocks[1],
InputContentBlock::Image { source }
if source.kind == "base64"
&& source.media_type == "image/png"
&& source.data == "AQID"
));
assert!(matches!(
&blocks[2],
InputContentBlock::Text { text } if text == " please"
));
}
#[test]
fn prompt_to_content_blocks_embeds_markdown_image_refs() {
let temp = temp_fixture_dir("markdown-image-ref");
let image_path = temp.join("sample.webp");
std::fs::write(&image_path, [255_u8]).expect("fixture write");
let prompt = format!("see ![asset]({}) now", image_path.display());
let blocks = super::prompt_to_content_blocks(&prompt, Path::new("."))
.expect("markdown image ref should parse");
assert!(matches!(
&blocks[1],
InputContentBlock::Image { source }
if source.media_type == "image/webp" && source.data == "/w=="
));
}
#[test]
fn prompt_to_content_blocks_rejects_unsupported_formats() {
let temp = temp_fixture_dir("unsupported-image-ref");
let image_path = temp.join("sample.bmp");
std::fs::write(&image_path, [1_u8]).expect("fixture write");
let prompt = format!("describe @{}", image_path.display());
let error = super::prompt_to_content_blocks(&prompt, Path::new("."))
.expect_err("unsupported image ref should fail");
assert!(error.contains("unsupported image format"));
}
#[test]
fn convert_messages_expands_user_text_image_refs() {
let temp = temp_fixture_dir("convert-message-image-ref");
let image_path = temp.join("sample.gif");
std::fs::write(&image_path, [71_u8, 73, 70]).expect("fixture write");
let messages = vec![ConversationMessage::user_text(format!(
"inspect @{}",
image_path.display()
))];
let converted = super::convert_messages(&messages).expect("messages should convert");
assert_eq!(converted.len(), 1);
assert!(matches!(
&converted[0].content[1],
InputContentBlock::Image { source }
if source.media_type == "image/gif" && source.data == "R0lG"
));
}
fn temp_fixture_dir(label: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should advance")
.as_nanos();
let path = std::env::temp_dir().join(format!("rusty-claude-cli-{label}-{unique}"));
std::fs::create_dir_all(&path).expect("temp dir should exist");
path
}
#[test] #[test]
fn repl_help_mentions_history_completion_and_multiline() { fn repl_help_mentions_history_completion_and_multiline() {
let help = render_repl_help(); let help = render_repl_help();