Compare commits

...

7 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
Yeachan-Heo
d6341d54c1 feat: config discovery and CLAUDE.md loading (cherry-picked from rcc/runtime) 2026-04-01 00:40:34 +00:00
Yeachan-Heo
863958b94c Merge remote-tracking branch 'origin/rcc/api' into dev/rust 2026-04-01 00:30:20 +00:00
Yeachan-Heo
9455280f24 Enable saved OAuth startup auth without breaking local version output
Startup auth was split between the CLI and API crates, which made saved OAuth refresh behavior eager and easy to drift. This change adds a startup-specific resolver in the API layer, keeps env-only auth semantics intact, preserves saved refresh tokens when refresh responses omit them, and lets the CLI reuse the shared resolver while keeping --version on a purely local path.

Constraint: Saved OAuth credentials live in ~/.claude/credentials.json and must remain compatible with existing runtime helpers
Constraint: --version must not require config loading or any API/auth client initialization
Rejected: Keep refresh orchestration only in rusty-claude-cli | would preserve split auth policy and lazy-load bugs
Rejected: Change AnthropicClient::from_env to load config | would broaden configless API semantics for non-CLI callers
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep startup-only OAuth refresh separate from AuthSource::from_env() / AnthropicClient::from_env() unless all non-CLI callers are re-evaluated
Tested: cargo fmt --all; cargo build; cargo clippy --workspace --all-targets -- -D warnings; cargo test; cargo run -p rusty-claude-cli -- --version
Not-tested: Live OAuth refresh against a real auth server
2026-04-01 00:24:55 +00:00
Yeachan-Heo
c92403994d Merge remote-tracking branch 'origin/rcc/cli' into dev/rust
# Conflicts:
#	rust/crates/rusty-claude-cli/src/main.rs
2026-04-01 00:20:39 +00:00
Yeachan-Heo
8d4a739c05 Make the REPL resilient enough for real interactive workflows
The custom crossterm editor now supports prompt history, slash-command tab
completion, multiline editing, and Ctrl-C semantics that clear partial input
without always terminating the session. The live REPL loop now distinguishes
buffer cancellation from clean exit, persists session state on meaningful
boundaries, and renders tool activity in a more structured way for terminal
use.

Constraint: Keep the active REPL on the existing crossterm path without adding a line-editor dependency
Rejected: Swap to rustyline or reedline | broader integration risk than this polish pass justifies
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Keep editor state logic generic in input.rs and leave REPL policy decisions in main.rs
Tested: cargo fmt --manifest-path rust/Cargo.toml --all; cargo clippy --manifest-path rust/Cargo.toml --all-targets --all-features -- -D warnings; cargo test --manifest-path rust/Cargo.toml
Not-tested: Interactive manual terminal smoke test for arrow keys/tab/Ctrl-C in a live TTY
2026-04-01 00:15:33 +00:00
Yeachan-Heo
6a7cea810e Clarify the expanded CLI surface for local parity
The branch already carries the new local slash commands and flag behavior,
so this follow-up captures how to use them from the Rust README. That keeps
the documented REPL and resume workflows aligned with the verified binary
surface after the implementation and green verification pass.

Constraint: Keep scope narrow and avoid touching ignored .omx planning artifacts
Constraint: Documentation must reflect the active handwritten parser in main.rs
Rejected: Re-open parser refactors in args.rs | outside the requested bounded change
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep README command examples aligned with main.rs help output when CLI flags or slash commands change
Tested: cargo run -p rusty-claude-cli -- --version; cargo run -p rusty-claude-cli -- --help; cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test
Not-tested: Interactive REPL manual slash-command session in a live API-backed conversation
2026-03-31 23:40:57 +00:00
13 changed files with 1456 additions and 145 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
@@ -102,6 +111,13 @@ cd rust
cargo run -p rusty-claude-cli -- --model claude-sonnet-4-20250514 prompt "List the key crates in this workspace"
```
Restrict enabled tools in an interactive session:
```bash
cd rust
cargo run -p rusty-claude-cli -- --allowedTools read,glob
```
### 2) REPL mode
Start the interactive shell:
@@ -123,6 +139,10 @@ Inside the REPL, useful commands include:
/memory
/config
/init
/diff
/version
/export notes.txt
/session list
/exit
```
@@ -151,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
@@ -169,6 +190,10 @@ cargo run -p rusty-claude-cli -- --resume session.json /memory /config
- `/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
- `/session [list|switch <session-id>]` — inspect or switch managed local sessions
- `/exit` — leave the REPL
## Environment variables

View File

@@ -392,8 +392,52 @@ pub fn resolve_saved_oauth_token(config: &OAuthConfig) -> Result<Option<OAuthTok
let Some(token_set) = load_saved_oauth_token()? else {
return Ok(None);
};
resolve_saved_oauth_token_set(config, token_set).map(Some)
}
pub fn resolve_startup_auth_source<F>(load_oauth_config: F) -> Result<AuthSource, ApiError>
where
F: FnOnce() -> Result<Option<OAuthConfig>, ApiError>,
{
if let Some(api_key) = read_env_non_empty("ANTHROPIC_API_KEY")? {
return match read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
Some(bearer_token) => Ok(AuthSource::ApiKeyAndBearer {
api_key,
bearer_token,
}),
None => Ok(AuthSource::ApiKey(api_key)),
};
}
if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
return Ok(AuthSource::BearerToken(bearer_token));
}
let Some(token_set) = load_saved_oauth_token()? else {
return Err(ApiError::MissingApiKey);
};
if !oauth_token_is_expired(&token_set) {
return Ok(Some(token_set));
return Ok(AuthSource::BearerToken(token_set.access_token));
}
if token_set.refresh_token.is_none() {
return Err(ApiError::ExpiredOAuthToken);
}
let Some(config) = load_oauth_config()? else {
return Err(ApiError::Auth(
"saved OAuth token is expired; runtime OAuth config is missing".to_string(),
));
};
Ok(AuthSource::from(resolve_saved_oauth_token_set(
&config, token_set,
)?))
}
fn resolve_saved_oauth_token_set(
config: &OAuthConfig,
token_set: OAuthTokenSet,
) -> Result<OAuthTokenSet, ApiError> {
if !oauth_token_is_expired(&token_set) {
return Ok(token_set);
}
let Some(refresh_token) = token_set.refresh_token.clone() else {
return Err(ApiError::ExpiredOAuthToken);
@@ -403,18 +447,28 @@ pub fn resolve_saved_oauth_token(config: &OAuthConfig) -> Result<Option<OAuthTok
client
.refresh_oauth_token(
config,
&OAuthRefreshRequest::from_config(config, refresh_token, Some(token_set.scopes)),
&OAuthRefreshRequest::from_config(
config,
refresh_token,
Some(token_set.scopes.clone()),
),
)
.await
})?;
save_oauth_credentials(&runtime::OAuthTokenSet {
access_token: refreshed.access_token.clone(),
refresh_token: refreshed.refresh_token.clone(),
let resolved = OAuthTokenSet {
access_token: refreshed.access_token,
refresh_token: refreshed.refresh_token.or(token_set.refresh_token),
expires_at: refreshed.expires_at,
scopes: refreshed.scopes.clone(),
scopes: refreshed.scopes,
};
save_oauth_credentials(&runtime::OAuthTokenSet {
access_token: resolved.access_token.clone(),
refresh_token: resolved.refresh_token.clone(),
expires_at: resolved.expires_at,
scopes: resolved.scopes.clone(),
})
.map_err(ApiError::from)?;
Ok(Some(refreshed))
Ok(resolved)
}
fn client_runtime_block_on<F, T>(future: F) -> Result<T, ApiError>
@@ -571,8 +625,8 @@ mod tests {
use runtime::{clear_oauth_credentials, save_oauth_credentials, OAuthConfig};
use crate::client::{
now_unix_timestamp, oauth_token_is_expired, resolve_saved_oauth_token, AnthropicClient,
AuthSource, OAuthTokenSet,
now_unix_timestamp, oauth_token_is_expired, resolve_saved_oauth_token,
resolve_startup_auth_source, AnthropicClient, AuthSource, OAuthTokenSet,
};
use crate::types::{ContentBlockDelta, MessageRequest};
@@ -760,6 +814,95 @@ mod tests {
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
}
#[test]
fn resolve_startup_auth_source_uses_saved_oauth_without_loading_config() {
let _guard = env_lock();
let config_home = temp_config_home();
std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
std::env::remove_var("ANTHROPIC_API_KEY");
save_oauth_credentials(&runtime::OAuthTokenSet {
access_token: "saved-access-token".to_string(),
refresh_token: Some("refresh".to_string()),
expires_at: Some(now_unix_timestamp() + 300),
scopes: vec!["scope:a".to_string()],
})
.expect("save oauth credentials");
let auth = resolve_startup_auth_source(|| panic!("config should not be loaded"))
.expect("startup auth");
assert_eq!(auth.bearer_token(), Some("saved-access-token"));
clear_oauth_credentials().expect("clear credentials");
std::env::remove_var("CLAUDE_CONFIG_HOME");
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
}
#[test]
fn resolve_startup_auth_source_errors_when_refreshable_token_lacks_config() {
let _guard = env_lock();
let config_home = temp_config_home();
std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
std::env::remove_var("ANTHROPIC_API_KEY");
save_oauth_credentials(&runtime::OAuthTokenSet {
access_token: "expired-access-token".to_string(),
refresh_token: Some("refresh-token".to_string()),
expires_at: Some(1),
scopes: vec!["scope:a".to_string()],
})
.expect("save expired oauth credentials");
let error =
resolve_startup_auth_source(|| Ok(None)).expect_err("missing config should error");
assert!(
matches!(error, crate::error::ApiError::Auth(message) if message.contains("runtime OAuth config is missing"))
);
let stored = runtime::load_oauth_credentials()
.expect("load stored credentials")
.expect("stored token set");
assert_eq!(stored.access_token, "expired-access-token");
assert_eq!(stored.refresh_token.as_deref(), Some("refresh-token"));
clear_oauth_credentials().expect("clear credentials");
std::env::remove_var("CLAUDE_CONFIG_HOME");
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
}
#[test]
fn resolve_saved_oauth_token_preserves_refresh_token_when_refresh_response_omits_it() {
let _guard = env_lock();
let config_home = temp_config_home();
std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
std::env::remove_var("ANTHROPIC_API_KEY");
save_oauth_credentials(&runtime::OAuthTokenSet {
access_token: "expired-access-token".to_string(),
refresh_token: Some("refresh-token".to_string()),
expires_at: Some(1),
scopes: vec!["scope:a".to_string()],
})
.expect("save expired oauth credentials");
let token_url = spawn_token_server(
"{\"access_token\":\"refreshed-token\",\"expires_at\":9999999999,\"scopes\":[\"scope:a\"]}",
);
let resolved = resolve_saved_oauth_token(&sample_oauth_config(token_url))
.expect("resolve refreshed token")
.expect("token set present");
assert_eq!(resolved.access_token, "refreshed-token");
assert_eq!(resolved.refresh_token.as_deref(), Some("refresh-token"));
let stored = runtime::load_oauth_credentials()
.expect("load stored credentials")
.expect("stored token set");
assert_eq!(stored.refresh_token.as_deref(), Some("refresh-token"));
clear_oauth_credentials().expect("clear credentials");
std::env::remove_var("CLAUDE_CONFIG_HOME");
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
}
#[test]
fn message_request_stream_helper_sets_stream_true() {
let request = MessageRequest {

View File

@@ -4,8 +4,8 @@ mod sse;
mod types;
pub use client::{
oauth_token_is_expired, resolve_saved_oauth_token, AnthropicClient, AuthSource, MessageStream,
OAuthTokenSet,
oauth_token_is_expired, resolve_saved_oauth_token, resolve_startup_auth_source,
AnthropicClient, AuthSource, MessageStream, OAuthTokenSet,
};
pub use error::ApiError;
pub use sse::{parse_frame, SseParser};

View File

@@ -14,6 +14,13 @@ pub enum ConfigSource {
Local,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolvedPermissionMode {
ReadOnly,
WorkspaceWrite,
DangerFullAccess,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigEntry {
pub source: ConfigSource,
@@ -31,6 +38,8 @@ pub struct RuntimeConfig {
pub struct RuntimeFeatureConfig {
mcp: McpConfigCollection,
oauth: Option<OAuthConfig>,
model: Option<String>,
permission_mode: Option<ResolvedPermissionMode>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
@@ -165,11 +174,23 @@ impl ConfigLoader {
#[must_use]
pub fn discover(&self) -> Vec<ConfigEntry> {
let user_legacy_path = self.config_home.parent().map_or_else(
|| PathBuf::from(".claude.json"),
|parent| parent.join(".claude.json"),
);
vec![
ConfigEntry {
source: ConfigSource::User,
path: user_legacy_path,
},
ConfigEntry {
source: ConfigSource::User,
path: self.config_home.join("settings.json"),
},
ConfigEntry {
source: ConfigSource::Project,
path: self.cwd.join(".claude.json"),
},
ConfigEntry {
source: ConfigSource::Project,
path: self.cwd.join(".claude").join("settings.json"),
@@ -195,14 +216,15 @@ impl ConfigLoader {
loaded_entries.push(entry);
}
let merged_value = JsonValue::Object(merged.clone());
let feature_config = RuntimeFeatureConfig {
mcp: McpConfigCollection {
servers: mcp_servers,
},
oauth: parse_optional_oauth_config(
&JsonValue::Object(merged.clone()),
"merged settings.oauth",
)?,
oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
model: parse_optional_model(&merged_value),
permission_mode: parse_optional_permission_mode(&merged_value)?,
};
Ok(RuntimeConfig {
@@ -257,6 +279,16 @@ impl RuntimeConfig {
pub fn oauth(&self) -> Option<&OAuthConfig> {
self.feature_config.oauth.as_ref()
}
#[must_use]
pub fn model(&self) -> Option<&str> {
self.feature_config.model.as_deref()
}
#[must_use]
pub fn permission_mode(&self) -> Option<ResolvedPermissionMode> {
self.feature_config.permission_mode
}
}
impl RuntimeFeatureConfig {
@@ -269,6 +301,16 @@ impl RuntimeFeatureConfig {
pub fn oauth(&self) -> Option<&OAuthConfig> {
self.oauth.as_ref()
}
#[must_use]
pub fn model(&self) -> Option<&str> {
self.model.as_deref()
}
#[must_use]
pub fn permission_mode(&self) -> Option<ResolvedPermissionMode> {
self.permission_mode
}
}
impl McpConfigCollection {
@@ -307,6 +349,7 @@ impl McpServerConfig {
fn read_optional_json_object(
path: &Path,
) -> Result<Option<BTreeMap<String, JsonValue>>, ConfigError> {
let is_legacy_config = path.file_name().and_then(|name| name.to_str()) == Some(".claude.json");
let contents = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
@@ -317,14 +360,20 @@ fn read_optional_json_object(
return Ok(Some(BTreeMap::new()));
}
let parsed = JsonValue::parse(&contents)
.map_err(|error| ConfigError::Parse(format!("{}: {error}", path.display())))?;
let object = parsed.as_object().ok_or_else(|| {
ConfigError::Parse(format!(
let parsed = match JsonValue::parse(&contents) {
Ok(parsed) => parsed,
Err(error) if is_legacy_config => return Ok(None),
Err(error) => return Err(ConfigError::Parse(format!("{}: {error}", path.display()))),
};
let Some(object) = parsed.as_object() else {
if is_legacy_config {
return Ok(None);
}
return Err(ConfigError::Parse(format!(
"{}: top-level settings value must be a JSON object",
path.display()
))
})?;
)));
};
Ok(Some(object.clone()))
}
@@ -355,6 +404,47 @@ fn merge_mcp_servers(
Ok(())
}
fn parse_optional_model(root: &JsonValue) -> Option<String> {
root.as_object()
.and_then(|object| object.get("model"))
.and_then(JsonValue::as_str)
.map(ToOwned::to_owned)
}
fn parse_optional_permission_mode(
root: &JsonValue,
) -> Result<Option<ResolvedPermissionMode>, ConfigError> {
let Some(object) = root.as_object() else {
return Ok(None);
};
if let Some(mode) = object.get("permissionMode").and_then(JsonValue::as_str) {
return parse_permission_mode_label(mode, "merged settings.permissionMode").map(Some);
}
let Some(mode) = object
.get("permissions")
.and_then(JsonValue::as_object)
.and_then(|permissions| permissions.get("defaultMode"))
.and_then(JsonValue::as_str)
else {
return Ok(None);
};
parse_permission_mode_label(mode, "merged settings.permissions.defaultMode").map(Some)
}
fn parse_permission_mode_label(
mode: &str,
context: &str,
) -> Result<ResolvedPermissionMode, ConfigError> {
match mode {
"default" | "plan" | "read-only" => Ok(ResolvedPermissionMode::ReadOnly),
"acceptEdits" | "auto" | "workspace-write" => Ok(ResolvedPermissionMode::WorkspaceWrite),
"dontAsk" | "danger-full-access" => Ok(ResolvedPermissionMode::DangerFullAccess),
other => Err(ConfigError::Parse(format!(
"{context}: unsupported permission mode {other}"
))),
}
}
fn parse_optional_oauth_config(
root: &JsonValue,
context: &str,
@@ -594,7 +684,8 @@ fn deep_merge_objects(
#[cfg(test)]
mod tests {
use super::{
ConfigLoader, ConfigSource, McpServerConfig, McpTransport, CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
ConfigLoader, ConfigSource, McpServerConfig, McpTransport, ResolvedPermissionMode,
CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
};
use crate::json::JsonValue;
use std::fs;
@@ -635,14 +726,24 @@ mod tests {
fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
fs::create_dir_all(&home).expect("home config dir");
fs::write(
home.parent().expect("home parent").join(".claude.json"),
r#"{"model":"haiku","env":{"A":"1"},"mcpServers":{"home":{"command":"uvx","args":["home"]}}}"#,
)
.expect("write user compat config");
fs::write(
home.join("settings.json"),
r#"{"model":"sonnet","env":{"A":"1"},"hooks":{"PreToolUse":["base"]}}"#,
r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan"}}"#,
)
.expect("write user settings");
fs::write(
cwd.join(".claude.json"),
r#"{"model":"project-compat","env":{"B":"2"}}"#,
)
.expect("write project compat config");
fs::write(
cwd.join(".claude").join("settings.json"),
r#"{"env":{"B":"2"},"hooks":{"PostToolUse":["project"]}}"#,
r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#,
)
.expect("write project settings");
fs::write(
@@ -656,25 +757,37 @@ mod tests {
.expect("config should load");
assert_eq!(CLAUDE_CODE_SETTINGS_SCHEMA_NAME, "SettingsSchema");
assert_eq!(loaded.loaded_entries().len(), 3);
assert_eq!(loaded.loaded_entries().len(), 5);
assert_eq!(loaded.loaded_entries()[0].source, ConfigSource::User);
assert_eq!(
loaded.get("model"),
Some(&JsonValue::String("opus".to_string()))
);
assert_eq!(loaded.model(), Some("opus"));
assert_eq!(
loaded.permission_mode(),
Some(ResolvedPermissionMode::WorkspaceWrite)
);
assert_eq!(
loaded
.get("env")
.and_then(JsonValue::as_object)
.expect("env object")
.len(),
2
4
);
assert!(loaded
.get("hooks")
.and_then(JsonValue::as_object)
.expect("hooks object")
.contains_key("PreToolUse"));
assert!(loaded
.get("hooks")
.and_then(JsonValue::as_object)
.expect("hooks object")
.contains_key("PostToolUse"));
assert!(loaded.mcp().get("home").is_some());
assert!(loaded.mcp().get("project").is_some());
fs::remove_dir_all(root).expect("cleanup temp dir");
}

View File

@@ -408,8 +408,7 @@ mod tests {
.sum::<i32>();
Ok(total.to_string())
});
let permission_policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite)
.with_tool_requirement("add", PermissionMode::DangerFullAccess);
let permission_policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite);
let system_prompt = SystemPromptBuilder::new()
.with_project_context(ProjectContext {
cwd: PathBuf::from("/tmp/project"),
@@ -488,8 +487,7 @@ mod tests {
Session::new(),
SingleCallApiClient,
StaticToolExecutor::new(),
PermissionPolicy::new(PermissionMode::WorkspaceWrite)
.with_tool_requirement("blocked", PermissionMode::DangerFullAccess),
PermissionPolicy::new(PermissionMode::WorkspaceWrite),
vec!["system".to_string()],
);
@@ -538,7 +536,7 @@ mod tests {
session,
SimpleApi,
StaticToolExecutor::new(),
PermissionPolicy::new(PermissionMode::ReadOnly),
PermissionPolicy::new(PermissionMode::DangerFullAccess),
vec!["system".to_string()],
);
@@ -565,7 +563,7 @@ mod tests {
Session::new(),
SimpleApi,
StaticToolExecutor::new(),
PermissionPolicy::new(PermissionMode::ReadOnly),
PermissionPolicy::new(PermissionMode::DangerFullAccess),
vec!["system".to_string()],
);
runtime.run_turn("a", None).expect("turn a");

View File

@@ -25,7 +25,8 @@ pub use config::{
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpClaudeAiProxyServerConfig,
McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
RuntimeConfig, RuntimeFeatureConfig, ScopedMcpServerConfig, CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, ScopedMcpServerConfig,
CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
};
pub use conversation::{
ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor,
@@ -76,3 +77,11 @@ pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, Sessi
pub use usage::{
format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker,
};
#[cfg(test)]
pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}

View File

@@ -448,7 +448,6 @@ fn decode_hex(byte: u8) -> Result<u8, String> {
#[cfg(test)]
mod tests {
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
use super::{
@@ -470,10 +469,7 @@ mod tests {
}
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock")
crate::test_env_lock()
}
fn temp_config_home() -> std::path::PathBuf {

View File

@@ -201,6 +201,7 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
dir.join("CLAUDE.md"),
dir.join("CLAUDE.local.md"),
dir.join(".claude").join("CLAUDE.md"),
dir.join(".claude").join("instructions.md"),
] {
push_context_file(&mut files, candidate)?;
}
@@ -468,6 +469,10 @@ mod tests {
std::env::temp_dir().join(format!("runtime-prompt-{nanos}"))
}
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
crate::test_env_lock()
}
#[test]
fn discovers_instruction_files_from_ancestor_chain() {
let root = temp_dir();
@@ -477,10 +482,21 @@ mod tests {
fs::write(root.join("CLAUDE.local.md"), "local instructions")
.expect("write local instructions");
fs::create_dir_all(root.join("apps")).expect("apps dir");
fs::create_dir_all(root.join("apps").join(".claude")).expect("apps claude dir");
fs::write(root.join("apps").join("CLAUDE.md"), "apps instructions")
.expect("write apps instructions");
fs::write(
root.join("apps").join(".claude").join("instructions.md"),
"apps dot claude instructions",
)
.expect("write apps dot claude instructions");
fs::write(nested.join(".claude").join("CLAUDE.md"), "nested rules")
.expect("write nested rules");
fs::write(
nested.join(".claude").join("instructions.md"),
"nested instructions",
)
.expect("write nested instructions");
let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
let contents = context
@@ -495,7 +511,9 @@ mod tests {
"root instructions",
"local instructions",
"apps instructions",
"nested rules"
"apps dot claude instructions",
"nested rules",
"nested instructions"
]
);
fs::remove_dir_all(root).expect("cleanup temp dir");
@@ -574,7 +592,12 @@ mod tests {
)
.expect("write settings");
let _guard = env_lock();
let previous = std::env::current_dir().expect("cwd");
let original_home = std::env::var("HOME").ok();
let original_claude_home = std::env::var("CLAUDE_CONFIG_HOME").ok();
std::env::set_var("HOME", &root);
std::env::set_var("CLAUDE_CONFIG_HOME", root.join("missing-home"));
std::env::set_current_dir(&root).expect("change cwd");
let prompt = super::load_system_prompt(&root, "2026-03-31", "linux", "6.8")
.expect("system prompt should load")
@@ -584,6 +607,16 @@ mod tests {
",
);
std::env::set_current_dir(previous).expect("restore cwd");
if let Some(value) = original_home {
std::env::set_var("HOME", value);
} else {
std::env::remove_var("HOME");
}
if let Some(value) = original_claude_home {
std::env::set_var("CLAUDE_CONFIG_HOME", value);
} else {
std::env::remove_var("CLAUDE_CONFIG_HOME");
}
assert!(prompt.contains("Project rules"));
assert!(prompt.contains("permissionMode"));
@@ -631,6 +664,29 @@ mod tests {
assert!(rendered.chars().count() <= 4_000 + "\n\n[truncated]".chars().count());
}
#[test]
fn discovers_dot_claude_instructions_markdown() {
let root = temp_dir();
let nested = root.join("apps").join("api");
fs::create_dir_all(nested.join(".claude")).expect("nested claude dir");
fs::write(
nested.join(".claude").join("instructions.md"),
"instruction markdown",
)
.expect("write instructions.md");
let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
assert!(context
.instruction_files
.iter()
.any(|file| file.path.ends_with(".claude/instructions.md")));
assert!(
render_instruction_files(&context.instruction_files).contains("instruction markdown")
);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn renders_instruction_file_metadata() {
let rendered = render_instruction_files(&[ContextFile {

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

@@ -2,7 +2,7 @@ use std::io::{self, Write};
use std::path::PathBuf;
use crate::args::{OutputFormat, PermissionMode};
use crate::input::LineEditor;
use crate::input::{LineEditor, ReadOutcome};
use crate::render::{Spinner, TerminalRenderer};
use runtime::{ConversationClient, ConversationMessage, RuntimeError, StreamEvent, UsageSummary};
@@ -111,16 +111,21 @@ impl CliApp {
}
pub fn run_repl(&mut self) -> io::Result<()> {
let editor = LineEditor::new(" ");
let mut editor = LineEditor::new(" ", Vec::new());
println!("Rusty Claude CLI interactive mode");
println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
while let Some(input) = editor.read_line()? {
if input.trim().is_empty() {
continue;
loop {
match editor.read_line()? {
ReadOutcome::Submit(input) => {
if input.trim().is_empty() {
continue;
}
self.handle_submission(&input, &mut io::stdout())?;
}
ReadOutcome::Cancel => continue,
ReadOutcome::Exit => break,
}
self.handle_submission(&input, &mut io::stdout())?;
}
Ok(())

View File

@@ -1,9 +1,8 @@
use std::io::{self, IsTerminal, Write};
use crossterm::cursor::MoveToColumn;
use crossterm::cursor::{MoveDown, MoveToColumn, MoveUp};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use crossterm::queue;
use crossterm::style::Print;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType};
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -85,21 +84,124 @@ impl InputBuffer {
self.buffer.clear();
self.cursor = 0;
}
pub fn replace(&mut self, value: impl Into<String>) {
self.buffer = value.into();
self.cursor = self.buffer.len();
}
#[must_use]
fn current_command_prefix(&self) -> Option<&str> {
if self.cursor != self.buffer.len() {
return None;
}
let prefix = &self.buffer[..self.cursor];
if prefix.contains(char::is_whitespace) || !prefix.starts_with('/') {
return None;
}
Some(prefix)
}
pub fn complete_slash_command(&mut self, candidates: &[String]) -> bool {
let Some(prefix) = self.current_command_prefix() else {
return false;
};
let matches = candidates
.iter()
.filter(|candidate| candidate.starts_with(prefix))
.map(String::as_str)
.collect::<Vec<_>>();
if matches.is_empty() {
return false;
}
let replacement = longest_common_prefix(&matches);
if replacement == prefix {
return false;
}
self.replace(replacement);
true
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedBuffer {
lines: Vec<String>,
cursor_row: u16,
cursor_col: u16,
}
impl RenderedBuffer {
#[must_use]
pub fn line_count(&self) -> usize {
self.lines.len()
}
fn write(&self, out: &mut impl Write) -> io::Result<()> {
for (index, line) in self.lines.iter().enumerate() {
if index > 0 {
writeln!(out)?;
}
write!(out, "{line}")?;
}
Ok(())
}
#[cfg(test)]
#[must_use]
pub fn lines(&self) -> &[String] {
&self.lines
}
#[cfg(test)]
#[must_use]
pub fn cursor_position(&self) -> (u16, u16) {
(self.cursor_row, self.cursor_col)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReadOutcome {
Submit(String),
Cancel,
Exit,
}
pub struct LineEditor {
prompt: String,
continuation_prompt: String,
history: Vec<String>,
history_index: Option<usize>,
draft: Option<String>,
completions: Vec<String>,
}
impl LineEditor {
#[must_use]
pub fn new(prompt: impl Into<String>) -> Self {
pub fn new(prompt: impl Into<String>, completions: Vec<String>) -> Self {
Self {
prompt: prompt.into(),
continuation_prompt: String::from("> "),
history: Vec::new(),
history_index: None,
draft: None,
completions,
}
}
pub fn read_line(&self) -> io::Result<Option<String>> {
pub fn push_history(&mut self, entry: impl Into<String>) {
let entry = entry.into();
if entry.trim().is_empty() {
return;
}
self.history.push(entry);
self.history_index = None;
self.draft = None;
}
pub fn read_line(&mut self) -> io::Result<ReadOutcome> {
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
return self.read_line_fallback();
}
@@ -107,29 +209,43 @@ impl LineEditor {
enable_raw_mode()?;
let mut stdout = io::stdout();
let mut input = InputBuffer::new();
self.redraw(&mut stdout, &input)?;
let mut rendered_lines = 1usize;
self.redraw(&mut stdout, &input, rendered_lines)?;
loop {
let event = event::read()?;
if let Event::Key(key) = event {
match Self::handle_key(key, &mut input) {
EditorAction::Continue => self.redraw(&mut stdout, &input)?,
match self.handle_key(key, &mut input) {
EditorAction::Continue => {
rendered_lines = self.redraw(&mut stdout, &input, rendered_lines)?;
}
EditorAction::Submit => {
disable_raw_mode()?;
writeln!(stdout)?;
return Ok(Some(input.as_str().to_owned()));
self.history_index = None;
self.draft = None;
return Ok(ReadOutcome::Submit(input.as_str().to_owned()));
}
EditorAction::Cancel => {
disable_raw_mode()?;
writeln!(stdout)?;
return Ok(None);
self.history_index = None;
self.draft = None;
return Ok(ReadOutcome::Cancel);
}
EditorAction::Exit => {
disable_raw_mode()?;
writeln!(stdout)?;
self.history_index = None;
self.draft = None;
return Ok(ReadOutcome::Exit);
}
}
}
}
}
fn read_line_fallback(&self) -> io::Result<Option<String>> {
fn read_line_fallback(&self) -> io::Result<ReadOutcome> {
let mut stdout = io::stdout();
write!(stdout, "{}", self.prompt)?;
stdout.flush()?;
@@ -137,22 +253,32 @@ impl LineEditor {
let mut buffer = String::new();
let bytes_read = io::stdin().read_line(&mut buffer)?;
if bytes_read == 0 {
return Ok(None);
return Ok(ReadOutcome::Exit);
}
while matches!(buffer.chars().last(), Some('\n' | '\r')) {
buffer.pop();
}
Ok(Some(buffer))
Ok(ReadOutcome::Submit(buffer))
}
fn handle_key(key: KeyEvent, input: &mut InputBuffer) -> EditorAction {
#[allow(clippy::too_many_lines)]
fn handle_key(&mut self, key: KeyEvent, input: &mut InputBuffer) -> EditorAction {
match key {
KeyEvent {
code: KeyCode::Char('c'),
modifiers,
..
} if modifiers.contains(KeyModifiers::CONTROL) => EditorAction::Cancel,
} if modifiers.contains(KeyModifiers::CONTROL) => {
if input.as_str().is_empty() {
EditorAction::Exit
} else {
input.clear();
self.history_index = None;
self.draft = None;
EditorAction::Cancel
}
}
KeyEvent {
code: KeyCode::Char('j'),
modifiers,
@@ -194,6 +320,25 @@ impl LineEditor {
input.move_right();
EditorAction::Continue
}
KeyEvent {
code: KeyCode::Up, ..
} => {
self.navigate_history_up(input);
EditorAction::Continue
}
KeyEvent {
code: KeyCode::Down,
..
} => {
self.navigate_history_down(input);
EditorAction::Continue
}
KeyEvent {
code: KeyCode::Tab, ..
} => {
input.complete_slash_command(&self.completions);
EditorAction::Continue
}
KeyEvent {
code: KeyCode::Home,
..
@@ -211,6 +356,8 @@ impl LineEditor {
code: KeyCode::Esc, ..
} => {
input.clear();
self.history_index = None;
self.draft = None;
EditorAction::Cancel
}
KeyEvent {
@@ -219,22 +366,74 @@ impl LineEditor {
..
} if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT => {
input.insert(ch);
self.history_index = None;
self.draft = None;
EditorAction::Continue
}
_ => EditorAction::Continue,
}
}
fn redraw(&self, out: &mut impl Write, input: &InputBuffer) -> io::Result<()> {
let display = input.as_str().replace('\n', "\\n\n> ");
fn navigate_history_up(&mut self, input: &mut InputBuffer) {
if self.history.is_empty() {
return;
}
match self.history_index {
Some(0) => {}
Some(index) => {
let next_index = index - 1;
input.replace(self.history[next_index].clone());
self.history_index = Some(next_index);
}
None => {
self.draft = Some(input.as_str().to_owned());
let next_index = self.history.len() - 1;
input.replace(self.history[next_index].clone());
self.history_index = Some(next_index);
}
}
}
fn navigate_history_down(&mut self, input: &mut InputBuffer) {
let Some(index) = self.history_index else {
return;
};
if index + 1 < self.history.len() {
let next_index = index + 1;
input.replace(self.history[next_index].clone());
self.history_index = Some(next_index);
return;
}
input.replace(self.draft.take().unwrap_or_default());
self.history_index = None;
}
fn redraw(
&self,
out: &mut impl Write,
input: &InputBuffer,
previous_line_count: usize,
) -> io::Result<usize> {
let rendered = render_buffer(&self.prompt, &self.continuation_prompt, input);
if previous_line_count > 1 {
queue!(out, MoveUp(saturating_u16(previous_line_count - 1)))?;
}
queue!(out, MoveToColumn(0), Clear(ClearType::FromCursorDown),)?;
rendered.write(out)?;
queue!(
out,
MoveUp(saturating_u16(rendered.line_count().saturating_sub(1))),
MoveToColumn(0),
Clear(ClearType::CurrentLine),
Print(&self.prompt),
Print(display),
)?;
out.flush()
if rendered.cursor_row > 0 {
queue!(out, MoveDown(rendered.cursor_row))?;
}
queue!(out, MoveToColumn(rendered.cursor_col))?;
out.flush()?;
Ok(rendered.line_count())
}
}
@@ -243,11 +442,76 @@ enum EditorAction {
Continue,
Submit,
Cancel,
Exit,
}
#[must_use]
pub fn render_buffer(
prompt: &str,
continuation_prompt: &str,
input: &InputBuffer,
) -> RenderedBuffer {
let before_cursor = &input.as_str()[..input.cursor];
let cursor_row = saturating_u16(before_cursor.chars().filter(|ch| *ch == '\n').count());
let cursor_line = before_cursor.rsplit('\n').next().unwrap_or_default();
let cursor_prompt = if cursor_row == 0 {
prompt
} else {
continuation_prompt
};
let cursor_col = saturating_u16(cursor_prompt.chars().count() + cursor_line.chars().count());
let mut lines = Vec::new();
for (index, line) in input.as_str().split('\n').enumerate() {
let prefix = if index == 0 {
prompt
} else {
continuation_prompt
};
lines.push(format!("{prefix}{line}"));
}
if lines.is_empty() {
lines.push(prompt.to_string());
}
RenderedBuffer {
lines,
cursor_row,
cursor_col,
}
}
#[must_use]
fn longest_common_prefix(values: &[&str]) -> String {
let Some(first) = values.first() else {
return String::new();
};
let mut prefix = (*first).to_string();
for value in values.iter().skip(1) {
while !value.starts_with(&prefix) {
prefix.pop();
if prefix.is_empty() {
break;
}
}
}
prefix
}
#[must_use]
fn saturating_u16(value: usize) -> u16 {
u16::try_from(value).unwrap_or(u16::MAX)
}
#[cfg(test)]
mod tests {
use super::InputBuffer;
use super::{render_buffer, InputBuffer, LineEditor};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn supports_basic_line_editing() {
@@ -266,4 +530,119 @@ mod tests {
assert_eq!(input.as_str(), "hix");
assert_eq!(input.cursor(), 2);
}
#[test]
fn completes_unique_slash_command() {
let mut input = InputBuffer::new();
for ch in "/he".chars() {
input.insert(ch);
}
assert!(input.complete_slash_command(&[
"/help".to_string(),
"/hello".to_string(),
"/status".to_string(),
]));
assert_eq!(input.as_str(), "/hel");
assert!(input.complete_slash_command(&["/help".to_string(), "/status".to_string()]));
assert_eq!(input.as_str(), "/help");
}
#[test]
fn ignores_completion_when_prefix_is_not_a_slash_command() {
let mut input = InputBuffer::new();
for ch in "hello".chars() {
input.insert(ch);
}
assert!(!input.complete_slash_command(&["/help".to_string()]));
assert_eq!(input.as_str(), "hello");
}
#[test]
fn history_navigation_restores_current_draft() {
let mut editor = LineEditor::new(" ", vec![]);
editor.push_history("/help");
editor.push_history("status report");
let mut input = InputBuffer::new();
for ch in "draft".chars() {
input.insert(ch);
}
let _ = editor.handle_key(key(KeyCode::Up), &mut input);
assert_eq!(input.as_str(), "status report");
let _ = editor.handle_key(key(KeyCode::Up), &mut input);
assert_eq!(input.as_str(), "/help");
let _ = editor.handle_key(key(KeyCode::Down), &mut input);
assert_eq!(input.as_str(), "status report");
let _ = editor.handle_key(key(KeyCode::Down), &mut input);
assert_eq!(input.as_str(), "draft");
}
#[test]
fn tab_key_completes_from_editor_candidates() {
let mut editor = LineEditor::new(
" ",
vec![
"/help".to_string(),
"/status".to_string(),
"/session".to_string(),
],
);
let mut input = InputBuffer::new();
for ch in "/st".chars() {
input.insert(ch);
}
let _ = editor.handle_key(key(KeyCode::Tab), &mut input);
assert_eq!(input.as_str(), "/status");
}
#[test]
fn renders_multiline_buffers_with_continuation_prompt() {
let mut input = InputBuffer::new();
for ch in "hello\nworld".chars() {
if ch == '\n' {
input.insert_newline();
} else {
input.insert(ch);
}
}
let rendered = render_buffer(" ", "> ", &input);
assert_eq!(
rendered.lines(),
&[" hello".to_string(), "> world".to_string()]
);
assert_eq!(rendered.cursor_position(), (1, 7));
}
#[test]
fn ctrl_c_exits_only_when_buffer_is_empty() {
let mut editor = LineEditor::new(" ", vec![]);
let mut empty = InputBuffer::new();
assert!(matches!(
editor.handle_key(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
&mut empty,
),
super::EditorAction::Exit
));
let mut filled = InputBuffer::new();
filled.insert('x');
assert!(matches!(
editor.handle_key(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
&mut filled,
),
super::EditorAction::Cancel
));
assert!(filled.as_str().is_empty());
}
}

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;
@@ -11,14 +12,17 @@ use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use api::{
resolve_saved_oauth_token, AnthropicClient, AuthSource, ContentBlockDelta, InputContentBlock,
resolve_startup_auth_source, AnthropicClient, AuthSource, ContentBlockDelta, InputContentBlock,
InputMessage, MessageRequest, MessageResponse, OutputContentBlock,
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
};
use commands::{render_slash_command_help, resume_supported_slash_commands, SlashCommand};
use commands::{
render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand,
};
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,
@@ -27,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";
@@ -37,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>;
@@ -58,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,
@@ -91,6 +110,7 @@ enum CliAction {
date: String,
},
Version,
SelfUpdate,
ResumeSession {
session_path: PathBuf,
commands: Vec<String>,
@@ -226,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" => {
@@ -532,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,
@@ -891,22 +1281,35 @@ fn run_repl(
permission_mode: PermissionMode,
) -> Result<(), Box<dyn std::error::Error>> {
let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?;
let editor = input::LineEditor::new(" ");
let mut editor = input::LineEditor::new(" ", slash_command_completion_candidates());
println!("{}", cli.startup_banner());
while let Some(input) = editor.read_line()? {
let trimmed = input.trim();
if trimmed.is_empty() {
continue;
loop {
match editor.read_line()? {
input::ReadOutcome::Submit(input) => {
let trimmed = input.trim().to_string();
if trimmed.is_empty() {
continue;
}
if matches!(trimmed.as_str(), "/exit" | "/quit") {
cli.persist_session()?;
break;
}
if let Some(command) = SlashCommand::parse(&trimmed) {
if cli.handle_repl_command(command)? {
cli.persist_session()?;
}
continue;
}
editor.push_history(input);
cli.run_turn(&trimmed)?;
}
input::ReadOutcome::Cancel => {}
input::ReadOutcome::Exit => {
cli.persist_session()?;
break;
}
}
if matches!(trimmed, "/exit" | "/quit") {
break;
}
if let Some(command) = SlashCommand::parse(trimmed) {
cli.handle_repl_command(command)?;
continue;
}
cli.run_turn(trimmed)?;
}
Ok(())
@@ -1066,28 +1469,60 @@ impl LiveCli {
fn handle_repl_command(
&mut self,
command: SlashCommand,
) -> Result<(), Box<dyn std::error::Error>> {
match command {
SlashCommand::Help => println!("{}", render_repl_help()),
SlashCommand::Status => self.print_status(),
SlashCommand::Compact => self.compact()?,
) -> Result<bool, Box<dyn std::error::Error>> {
Ok(match command {
SlashCommand::Help => {
println!("{}", render_repl_help());
false
}
SlashCommand::Status => {
self.print_status();
false
}
SlashCommand::Compact => {
self.compact()?;
false
}
SlashCommand::Model { model } => self.set_model(model)?,
SlashCommand::Permissions { mode } => self.set_permissions(mode)?,
SlashCommand::Clear { confirm } => self.clear_session(confirm)?,
SlashCommand::Cost => self.print_cost(),
SlashCommand::Resume { session_path } => self.resume_session(session_path)?,
SlashCommand::Config { section } => Self::print_config(section.as_deref())?,
SlashCommand::Memory => Self::print_memory()?,
SlashCommand::Init => Self::run_init()?,
SlashCommand::Diff => Self::print_diff()?,
SlashCommand::Version => Self::print_version(),
SlashCommand::Export { path } => self.export_session(path.as_deref())?,
SlashCommand::Session { action, target } => {
self.handle_session_command(action.as_deref(), target.as_deref())?;
SlashCommand::Cost => {
self.print_cost();
false
}
SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"),
}
Ok(())
SlashCommand::Resume { session_path } => self.resume_session(session_path)?,
SlashCommand::Config { section } => {
Self::print_config(section.as_deref())?;
false
}
SlashCommand::Memory => {
Self::print_memory()?;
false
}
SlashCommand::Init => {
Self::run_init()?;
false
}
SlashCommand::Diff => {
Self::print_diff()?;
false
}
SlashCommand::Version => {
Self::print_version();
false
}
SlashCommand::Export { path } => {
self.export_session(path.as_deref())?;
false
}
SlashCommand::Session { action, target } => {
self.handle_session_command(action.as_deref(), target.as_deref())?
}
SlashCommand::Unknown(name) => {
eprintln!("unknown slash command: /{name}");
false
}
})
}
fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> {
@@ -1115,7 +1550,7 @@ impl LiveCli {
);
}
fn set_model(&mut self, model: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
fn set_model(&mut self, model: Option<String>) -> Result<bool, Box<dyn std::error::Error>> {
let Some(model) = model else {
println!(
"{}",
@@ -1125,7 +1560,7 @@ impl LiveCli {
self.runtime.usage().turns(),
)
);
return Ok(());
return Ok(false);
};
if model == self.model {
@@ -1137,7 +1572,7 @@ impl LiveCli {
self.runtime.usage().turns(),
)
);
return Ok(());
return Ok(false);
}
let previous = self.model.clone();
@@ -1152,21 +1587,23 @@ impl LiveCli {
self.permission_mode,
)?;
self.model.clone_from(&model);
self.persist_session()?;
println!(
"{}",
format_model_switch_report(&previous, &model, message_count)
);
Ok(())
Ok(true)
}
fn set_permissions(&mut self, mode: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
fn set_permissions(
&mut self,
mode: Option<String>,
) -> Result<bool, Box<dyn std::error::Error>> {
let Some(mode) = mode else {
println!(
"{}",
format_permissions_report(self.permission_mode.as_str())
);
return Ok(());
return Ok(false);
};
let normalized = normalize_permission_mode(&mode).ok_or_else(|| {
@@ -1177,7 +1614,7 @@ impl LiveCli {
if normalized == self.permission_mode.as_str() {
println!("{}", format_permissions_report(normalized));
return Ok(());
return Ok(false);
}
let previous = self.permission_mode.as_str().to_string();
@@ -1191,20 +1628,19 @@ impl LiveCli {
self.allowed_tools.clone(),
self.permission_mode,
)?;
self.persist_session()?;
println!(
"{}",
format_permissions_switch_report(&previous, normalized)
);
Ok(())
Ok(true)
}
fn clear_session(&mut self, confirm: bool) -> Result<(), Box<dyn std::error::Error>> {
fn clear_session(&mut self, confirm: bool) -> Result<bool, Box<dyn std::error::Error>> {
if !confirm {
println!(
"clear: confirmation required; run /clear --confirm to start a fresh session."
);
return Ok(());
return Ok(false);
}
self.session = create_managed_session_handle()?;
@@ -1216,14 +1652,13 @@ impl LiveCli {
self.allowed_tools.clone(),
self.permission_mode,
)?;
self.persist_session()?;
println!(
"Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}",
self.model,
self.permission_mode.as_str(),
self.session.id,
);
Ok(())
Ok(true)
}
fn print_cost(&self) {
@@ -1234,10 +1669,10 @@ impl LiveCli {
fn resume_session(
&mut self,
session_path: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<bool, Box<dyn std::error::Error>> {
let Some(session_ref) = session_path else {
println!("Usage: /resume <session-path>");
return Ok(());
return Ok(false);
};
let handle = resolve_session_reference(&session_ref)?;
@@ -1252,7 +1687,6 @@ impl LiveCli {
self.permission_mode,
)?;
self.session = handle;
self.persist_session()?;
println!(
"{}",
format_resume_report(
@@ -1261,7 +1695,7 @@ impl LiveCli {
self.runtime.usage().turns(),
)
);
Ok(())
Ok(true)
}
fn print_config(section: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
@@ -1306,16 +1740,16 @@ impl LiveCli {
&mut self,
action: Option<&str>,
target: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<bool, Box<dyn std::error::Error>> {
match action {
None | Some("list") => {
println!("{}", render_session_list(&self.session.id)?);
Ok(())
Ok(false)
}
Some("switch") => {
let Some(target) = target else {
println!("Usage: /session switch <session-id>");
return Ok(());
return Ok(false);
};
let handle = resolve_session_reference(target)?;
let session = Session::load_from_path(&handle.path)?;
@@ -1329,18 +1763,17 @@ impl LiveCli {
self.permission_mode,
)?;
self.session = handle;
self.persist_session()?;
println!(
"Session switched\n Active session {}\n File {}\n Messages {}",
self.session.id,
self.session.path.display(),
message_count,
);
Ok(())
Ok(true)
}
Some(other) => {
println!("Unknown /session action '{other}'. Use /session list or /session switch <session-id>.");
Ok(())
Ok(false)
}
}
}
@@ -1469,6 +1902,10 @@ fn render_repl_help() -> String {
"REPL".to_string(),
" /exit Quit the REPL".to_string(),
" /quit Quit the REPL".to_string(),
" Up/Down Navigate prompt history".to_string(),
" Tab Complete slash commands".to_string(),
" Ctrl-C Clear input (or exit on empty prompt)".to_string(),
" Shift+Enter/Ctrl+J Insert a newline".to_string(),
String::new(),
render_slash_command_help(),
]
@@ -1957,20 +2394,13 @@ impl AnthropicRuntimeClient {
}
fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
match AuthSource::from_env() {
Ok(auth) => Ok(auth),
Err(api::ApiError::MissingApiKey) => {
let cwd = env::current_dir()?;
let config = ConfigLoader::default_for(&cwd).load()?;
if let Some(oauth) = config.oauth() {
if let Some(token_set) = resolve_saved_oauth_token(oauth)? {
return Ok(AuthSource::from(token_set));
}
}
Ok(AuthSource::from_env_or_saved()?)
}
Err(error) => Err(Box::new(error)),
}
Ok(resolve_startup_auth_source(|| {
let cwd = env::current_dir().map_err(api::ApiError::from)?;
let config = ConfigLoader::default_for(&cwd).load().map_err(|error| {
api::ApiError::Auth(format!("failed to load runtime OAuth config: {error}"))
})?;
Ok(config.oauth().cloned())
})?)
}
impl ApiClient for AnthropicRuntimeClient {
@@ -2089,6 +2519,63 @@ impl ApiClient for AnthropicRuntimeClient {
}
}
fn slash_command_completion_candidates() -> Vec<String> {
slash_command_specs()
.iter()
.map(|spec| format!("/{}", spec.name))
.collect()
}
fn format_tool_call_start(name: &str, input: &str) -> String {
format!(
"Tool call
Name {name}
Input {}",
summarize_tool_payload(input)
)
}
fn format_tool_result(name: &str, output: &str, is_error: bool) -> String {
let status = if is_error { "error" } else { "ok" };
format!(
"### Tool `{name}`
- Status: {status}
- Output:
```json
{}
```
",
prettify_tool_payload(output)
)
}
fn summarize_tool_payload(payload: &str) -> String {
let compact = match serde_json::from_str::<serde_json::Value>(payload) {
Ok(value) => value.to_string(),
Err(_) => payload.trim().to_string(),
};
truncate_for_summary(&compact, 96)
}
fn prettify_tool_payload(payload: &str) -> String {
match serde_json::from_str::<serde_json::Value>(payload) {
Ok(value) => serde_json::to_string_pretty(&value).unwrap_or_else(|_| payload.to_string()),
Err(_) => payload.to_string(),
}
}
fn truncate_for_summary(value: &str, limit: usize) -> String {
let mut chars = value.chars();
let truncated = chars.by_ref().take(limit).collect::<String>();
if chars.next().is_some() {
format!("{truncated}")
} else {
truncated
}
}
fn push_output_block(
block: OutputContentBlock,
out: &mut impl Write,
@@ -2105,6 +2592,14 @@ fn push_output_block(
}
}
OutputContentBlock::ToolUse { id, name, input } => {
writeln!(
out,
"
{}",
format_tool_call_start(&name, &input.to_string())
)
.and_then(|()| out.flush())
.map_err(|error| RuntimeError::new(error.to_string()))?;
*pending_tool = Some((id, name, input.to_string()));
}
}
@@ -2164,13 +2659,19 @@ impl ToolExecutor for CliToolExecutor {
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
match execute_tool(tool_name, &value) {
Ok(output) => {
let markdown = format!("### Tool `{tool_name}`\n\n```json\n{output}\n```\n");
let markdown = format_tool_result(tool_name, &output, false);
self.renderer
.stream_markdown(&markdown, &mut io::stdout())
.map_err(|error| ToolError::new(error.to_string()))?;
Ok(output)
}
Err(error) => Err(ToolError::new(error)),
Err(error) => {
let markdown = format_tool_result(tool_name, &error, true);
self.renderer
.stream_markdown(&markdown, &mut io::stdout())
.map_err(|stream_error| ToolError::new(stream_error.to_string()))?;
Err(ToolError::new(error))
}
}
}
}
@@ -2245,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");
@@ -2271,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)]
@@ -2279,10 +2783,11 @@ 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,
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, 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};
use std::path::{Path, PathBuf};
@@ -2351,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()];
@@ -2684,7 +3247,7 @@ mod tests {
fn status_context_reads_real_workspace_metadata() {
let context = status_context(None).expect("status context should load");
assert!(context.cwd.is_absolute());
assert_eq!(context.discovered_config_files, 3);
assert!(context.discovered_config_files >= 3);
assert!(context.loaded_config_files <= context.discovered_config_files);
}
@@ -2773,4 +3336,22 @@ mod tests {
assert_eq!(converted[1].role, "assistant");
assert_eq!(converted[2].role, "user");
}
#[test]
fn repl_help_mentions_history_completion_and_multiline() {
let help = render_repl_help();
assert!(help.contains("Up/Down"));
assert!(help.contains("Tab"));
assert!(help.contains("Shift+Enter/Ctrl+J"));
}
#[test]
fn tool_rendering_helpers_compact_output() {
let start = format_tool_call_start("read_file", r#"{"path":"src/main.rs"}"#);
assert!(start.contains("Tool call"));
assert!(start.contains("src/main.rs"));
let done = format_tool_result("read_file", r#"{"contents":"hello"}"#, false);
assert!(done.contains("Tool `read_file`"));
assert!(done.contains("contents"));
}
}