3 Commits

Author SHA1 Message Date
Yeachan-Heo
ccebabe605 Preserve verified session persistence while syncing remote runtime branch history
Origin/rcc/runtime advanced independently while this branch implemented
conversation history persistence. This merge keeps the tested local tree
as the source of truth for the user-requested feature while recording the
remote branch tip so future work can proceed from a shared history.

Constraint: Push required incorporating origin/rcc/runtime history without breaking the verified session-persistence implementation
Rejected: Force-push over origin/rcc/runtime | would discard remote branch history
Confidence: medium
Scope-risk: narrow
Reversibility: clean
Directive: Before the next broad CLI/runtime refactor, compare this branch against origin/rcc/runtime for any remote-only startup behavior worth porting deliberately
Tested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace
Not-tested: Remote-only runtime startup semantics not exercised by the session persistence change
2026-04-01 01:02:05 +00:00
Yeachan-Heo
146260083c Persist CLI conversation history across sessions
The Rust CLI now stores managed sessions under ~/.claude/sessions,
records additive session metadata in the canonical JSON transcript,
and exposes a /sessions listing alias alongside ID-or-path resume.
Inactive oversized sessions are compacted automatically so old
transcripts remain resumable without growing unchecked.

Constraint: Session JSON must stay backward-compatible with legacy files that lack metadata
Constraint: Managed sessions must use a single canonical JSON file per session without new dependencies
Rejected: Sidecar metadata/index files | duplicated state and diverged from the requested single-file persistence model
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep CLI policy in the CLI; only add transcript-adjacent metadata to runtime::Session unless another consumer truly needs more
Tested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace
Not-tested: Manual interactive REPL smoke test against the live Anthropic API
2026-04-01 00:58:14 +00:00
Yeachan-Heo
cd01d0e387 Honor Claude config defaults across runtime sessions
The runtime now discovers both legacy and current Claude config files at
user and project scope, merges them in precedence order, and carries the
resolved model, permission mode, instruction files, and MCP server
configuration into session startup.

This keeps CLI defaults aligned with project policy and exposes configured
MCP tools without requiring manual flags.

Constraint: Must support both legacy .claude.json and current .claude/settings.json layouts
Constraint: Session startup must preserve CLI flag precedence over config defaults
Rejected: Read only project settings files | would ignore user-scoped defaults and MCP servers
Rejected: Delay MCP tool discovery until first tool call | model would not see configured MCP tools during planning
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep config precedence synchronized between prompt loading, session startup, and status reporting
Tested: cargo fmt --all --check; cargo clippy --workspace --all-targets --all-features -- -D warnings; cargo test --workspace --all-features
Not-tested: Live remote MCP servers and interactive REPL session startup against external services
2026-04-01 00:36:32 +00:00
9 changed files with 337 additions and 498 deletions

3
rust/Cargo.lock generated
View File

@@ -1091,11 +1091,8 @@ 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,15 +84,6 @@ 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
@@ -142,6 +133,7 @@ Inside the REPL, useful commands include:
/diff /diff
/version /version
/export notes.txt /export notes.txt
/sessions
/session list /session list
/exit /exit
``` ```
@@ -152,14 +144,14 @@ Inspect or maintain a saved session file without entering the REPL:
```bash ```bash
cd rust cd rust
cargo run -p rusty-claude-cli -- --resume session.json /status /compact /cost cargo run -p rusty-claude-cli -- --resume session-123456 /status /compact /cost
``` ```
You can also inspect memory/config state for a restored session: You can also inspect memory/config state for a restored session:
```bash ```bash
cd rust cd rust
cargo run -p rusty-claude-cli -- --resume session.json /memory /config cargo run -p rusty-claude-cli -- --resume ~/.claude/sessions/session-123456.json /memory /config
``` ```
## Available commands ## Available commands
@@ -167,11 +159,10 @@ cargo run -p rusty-claude-cli -- --resume session.json /memory /config
### Top-level CLI commands ### Top-level CLI commands
- `prompt <text...>` — run one prompt non-interactively - `prompt <text...>` — run one prompt non-interactively
- `--resume <session.json> [/commands...]` — inspect or maintain a saved session - `--resume <session-id-or-path> [/commands...]` — inspect or maintain a saved session stored under `~/.claude/sessions/`
- `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
@@ -186,13 +177,14 @@ cargo run -p rusty-claude-cli -- --resume session.json /memory /config
- `/permissions [read-only|workspace-write|danger-full-access]` — inspect or switch permissions - `/permissions [read-only|workspace-write|danger-full-access]` — inspect or switch permissions
- `/clear [--confirm]` — clear the current local session - `/clear [--confirm]` — clear the current local session
- `/cost` — show token usage totals - `/cost` — show token usage totals
- `/resume <session-path>` — load a saved session into the REPL - `/resume <session-id-or-path>` — load a saved session into the REPL
- `/config [env|hooks|model]` — inspect discovered Claude config - `/config [env|hooks|model]` — inspect discovered Claude config
- `/memory` — inspect loaded instruction memory files - `/memory` — inspect loaded instruction memory files
- `/init` — create a starter `CLAUDE.md` - `/init` — create a starter `CLAUDE.md`
- `/diff` — show the current git diff for the workspace - `/diff` — show the current git diff for the workspace
- `/version` — print version and build metadata locally - `/version` — print version and build metadata locally
- `/export [file]` — export the current conversation transcript - `/export [file]` — export the current conversation transcript
- `/sessions` — list recent managed local sessions from `~/.claude/sessions/`
- `/session [list|switch <session-id>]` — inspect or switch managed local sessions - `/session [list|switch <session-id>]` — inspect or switch managed local sessions
- `/exit` — leave the REPL - `/exit` — leave the REPL

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,11 +11,8 @@ 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,7 +3,6 @@ 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;
@@ -22,18 +21,15 @@ 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,
AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest,
OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, Session, SessionMetadata, 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";
@@ -41,20 +37,9 @@ const DEFAULT_MAX_TOKENS: u32 = 32;
const DEFAULT_DATE: &str = "2026-03-31"; const DEFAULT_DATE: &str = "2026-03-31";
const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545; const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
const VERSION: &str = env!("CARGO_PKG_VERSION"); const VERSION: &str = env!("CARGO_PKG_VERSION");
const OLD_SESSION_COMPACTION_AGE_SECS: u64 = 60 * 60 * 24;
const BUILD_TARGET: Option<&str> = option_env!("TARGET"); const 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>;
@@ -76,7 +61,6 @@ 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,
@@ -110,7 +94,6 @@ enum CliAction {
date: String, date: String,
}, },
Version, Version,
SelfUpdate,
ResumeSession { ResumeSession {
session_path: PathBuf, session_path: PathBuf,
commands: Vec<String>, commands: Vec<String>,
@@ -246,7 +229,6 @@ 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" => {
@@ -553,377 +535,15 @@ 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 handle = match resolve_session_reference(&session_path.display().to_string()) {
Ok(handle) => handle,
Err(error) => {
eprintln!("failed to resolve session: {error}");
std::process::exit(1);
}
};
let session = match Session::load_from_path(&handle.path) {
Ok(session) => session, Ok(session) => session,
Err(error) => { Err(error) => {
eprintln!("failed to restore session: {error}"); eprintln!("failed to restore session: {error}");
@@ -934,7 +554,7 @@ fn resume_session(session_path: &Path, commands: &[String]) {
if commands.is_empty() { if commands.is_empty() {
println!( println!(
"Restored session from {} ({} messages).", "Restored session from {} ({} messages).",
session_path.display(), handle.path.display(),
session.messages.len() session.messages.len()
); );
return; return;
@@ -946,7 +566,7 @@ fn resume_session(session_path: &Path, commands: &[String]) {
eprintln!("unsupported resumed command: {raw_command}"); eprintln!("unsupported resumed command: {raw_command}");
std::process::exit(2); std::process::exit(2);
}; };
match run_resume_command(session_path, &session, &command) { match run_resume_command(&handle.path, &session, &command) {
Ok(ResumeCommandOutcome { Ok(ResumeCommandOutcome {
session: next_session, session: next_session,
message, message,
@@ -1271,6 +891,7 @@ fn run_resume_command(
| SlashCommand::Model { .. } | SlashCommand::Model { .. }
| SlashCommand::Permissions { .. } | SlashCommand::Permissions { .. }
| SlashCommand::Session { .. } | SlashCommand::Session { .. }
| SlashCommand::Sessions
| SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()), | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
} }
} }
@@ -1327,6 +948,9 @@ struct ManagedSessionSummary {
path: PathBuf, path: PathBuf,
modified_epoch_secs: u64, modified_epoch_secs: u64,
message_count: usize, message_count: usize,
model: Option<String>,
started_at: Option<String>,
last_prompt: Option<String>,
} }
struct LiveCli { struct LiveCli {
@@ -1347,6 +971,7 @@ impl LiveCli {
) -> Result<Self, Box<dyn std::error::Error>> { ) -> Result<Self, Box<dyn std::error::Error>> {
let system_prompt = build_system_prompt()?; let system_prompt = build_system_prompt()?;
let session = create_managed_session_handle()?; let session = create_managed_session_handle()?;
auto_compact_inactive_sessions(&session.id)?;
let runtime = build_runtime( let runtime = build_runtime(
Session::new(), Session::new(),
model.clone(), model.clone(),
@@ -1518,6 +1143,10 @@ impl LiveCli {
SlashCommand::Session { action, target } => { SlashCommand::Session { action, target } => {
self.handle_session_command(action.as_deref(), target.as_deref())? self.handle_session_command(action.as_deref(), target.as_deref())?
} }
SlashCommand::Sessions => {
println!("{}", render_session_list(&self.session.id)?);
false
}
SlashCommand::Unknown(name) => { SlashCommand::Unknown(name) => {
eprintln!("unknown slash command: /{name}"); eprintln!("unknown slash command: /{name}");
false false
@@ -1526,7 +1155,10 @@ impl LiveCli {
} }
fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> { fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> {
self.runtime.session().save_to_path(&self.session.path)?; let mut session = self.runtime.session().clone();
session.metadata = Some(derive_session_metadata(&session, &self.model));
session.save_to_path(&self.session.path)?;
auto_compact_inactive_sessions(&self.session.id)?;
Ok(()) Ok(())
} }
@@ -1671,13 +1303,20 @@ impl LiveCli {
session_path: Option<String>, session_path: Option<String>,
) -> Result<bool, Box<dyn std::error::Error>> { ) -> Result<bool, Box<dyn std::error::Error>> {
let Some(session_ref) = session_path else { let Some(session_ref) = session_path else {
println!("Usage: /resume <session-path>"); println!("Usage: /resume <session-id-or-path>");
return Ok(false); return Ok(false);
}; };
let handle = resolve_session_reference(&session_ref)?; let handle = resolve_session_reference(&session_ref)?;
let session = Session::load_from_path(&handle.path)?; let session = Session::load_from_path(&handle.path)?;
let message_count = session.messages.len(); let message_count = session.messages.len();
if let Some(model) = session
.metadata
.as_ref()
.map(|metadata| metadata.model.clone())
{
self.model = model;
}
self.runtime = build_runtime( self.runtime = build_runtime(
session, session,
self.model.clone(), self.model.clone(),
@@ -1754,6 +1393,13 @@ impl LiveCli {
let handle = resolve_session_reference(target)?; let handle = resolve_session_reference(target)?;
let session = Session::load_from_path(&handle.path)?; let session = Session::load_from_path(&handle.path)?;
let message_count = session.messages.len(); let message_count = session.messages.len();
if let Some(model) = session
.metadata
.as_ref()
.map(|metadata| metadata.model.clone())
{
self.model = model;
}
self.runtime = build_runtime( self.runtime = build_runtime(
session, session,
self.model.clone(), self.model.clone(),
@@ -1798,8 +1444,10 @@ impl LiveCli {
} }
fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> { fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?; let home = env::var_os("HOME")
let path = cwd.join(".claude").join("sessions"); .map(PathBuf::from)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME is not set"))?;
let path = home.join(".claude").join("sessions");
fs::create_dir_all(&path)?; fs::create_dir_all(&path)?;
Ok(path) Ok(path)
} }
@@ -1820,8 +1468,19 @@ fn generate_session_id() -> String {
fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> { fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> {
let direct = PathBuf::from(reference); let direct = PathBuf::from(reference);
let expanded = if let Some(stripped) = reference.strip_prefix("~/") {
sessions_dir()?
.parent()
.and_then(|claude| claude.parent())
.map(|home| home.join(stripped))
.unwrap_or(direct.clone())
} else {
direct.clone()
};
let path = if direct.exists() { let path = if direct.exists() {
direct direct
} else if expanded.exists() {
expanded
} else { } else {
sessions_dir()?.join(format!("{reference}.json")) sessions_dir()?.join(format!("{reference}.json"))
}; };
@@ -1851,9 +1510,11 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
.and_then(|time| time.duration_since(UNIX_EPOCH).ok()) .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_secs()) .map(|duration| duration.as_secs())
.unwrap_or_default(); .unwrap_or_default();
let message_count = Session::load_from_path(&path) let session = Session::load_from_path(&path).ok();
.map(|session| session.messages.len()) let derived_message_count = session.as_ref().map_or(0, |session| session.messages.len());
.unwrap_or_default(); let stored = session
.as_ref()
.and_then(|session| session.metadata.as_ref());
let id = path let id = path
.file_stem() .file_stem()
.and_then(|value| value.to_str()) .and_then(|value| value.to_str())
@@ -1863,7 +1524,12 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
id, id,
path, path,
modified_epoch_secs, modified_epoch_secs,
message_count, message_count: stored.map_or(derived_message_count, |metadata| {
metadata.message_count as usize
}),
model: stored.map(|metadata| metadata.model.clone()),
started_at: stored.map(|metadata| metadata.started_at.clone()),
last_prompt: stored.and_then(|metadata| metadata.last_prompt.clone()),
}); });
} }
sessions.sort_by(|left, right| right.modified_epoch_secs.cmp(&left.modified_epoch_secs)); sessions.sort_by(|left, right| right.modified_epoch_secs.cmp(&left.modified_epoch_secs));
@@ -1886,17 +1552,99 @@ fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::e
} else { } else {
"○ saved" "○ saved"
}; };
let model = session.model.as_deref().unwrap_or("unknown");
let started = session.started_at.as_deref().unwrap_or("unknown");
let last_prompt = session.last_prompt.as_deref().map_or_else(
|| "-".to_string(),
|prompt| truncate_for_summary(prompt, 36),
);
lines.push(format!( lines.push(format!(
" {id:<20} {marker:<10} msgs={msgs:<4} modified={modified} path={path}", " {id:<20} {marker:<10} msgs={msgs:<4} model={model:<24} started={started} modified={modified} last={last_prompt} path={path}",
id = session.id, id = session.id,
msgs = session.message_count, msgs = session.message_count,
model = model,
started = started,
modified = session.modified_epoch_secs, modified = session.modified_epoch_secs,
last_prompt = last_prompt,
path = session.path.display(), path = session.path.display(),
)); ));
} }
Ok(lines.join("\n")) Ok(lines.join("\n"))
} }
fn current_epoch_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or_default()
}
fn current_timestamp_rfc3339ish() -> String {
format!("{}Z", current_epoch_secs())
}
fn last_prompt_from_session(session: &Session) -> Option<String> {
session
.messages
.iter()
.rev()
.find(|message| message.role == MessageRole::User)
.and_then(|message| {
message.blocks.iter().find_map(|block| match block {
ContentBlock::Text { text } => Some(text.trim().to_string()),
_ => None,
})
})
.filter(|text| !text.is_empty())
}
fn derive_session_metadata(session: &Session, model: &str) -> SessionMetadata {
let started_at = session
.metadata
.as_ref()
.map_or_else(current_timestamp_rfc3339ish, |metadata| {
metadata.started_at.clone()
});
SessionMetadata {
started_at,
model: model.to_string(),
message_count: session.messages.len().try_into().unwrap_or(u32::MAX),
last_prompt: last_prompt_from_session(session),
}
}
fn session_age_secs(modified_epoch_secs: u64) -> u64 {
current_epoch_secs().saturating_sub(modified_epoch_secs)
}
fn auto_compact_inactive_sessions(
active_session_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
for summary in list_managed_sessions()? {
if summary.id == active_session_id
|| session_age_secs(summary.modified_epoch_secs) < OLD_SESSION_COMPACTION_AGE_SECS
{
continue;
}
let path = summary.path.clone();
let Ok(session) = Session::load_from_path(&path) else {
continue;
};
if !runtime::should_compact(&session, CompactionConfig::default()) {
continue;
}
let mut compacted =
runtime::compact_session(&session, CompactionConfig::default()).compacted_session;
let model = compacted.metadata.as_ref().map_or_else(
|| DEFAULT_MODEL.to_string(),
|metadata| metadata.model.clone(),
);
compacted.metadata = Some(derive_session_metadata(&compacted, &model));
compacted.save_to_path(&path)?;
}
Ok(())
}
fn render_repl_help() -> String { fn render_repl_help() -> String {
[ [
"REPL".to_string(), "REPL".to_string(),
@@ -2746,8 +2494,6 @@ 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");
@@ -2774,24 +2520,78 @@ 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)]
mod tests { mod tests {
use super::{ use super::{
filter_tool_specs, format_compact_report, format_cost_report, format_init_report, derive_session_metadata, filter_tool_specs, format_compact_report, format_cost_report,
format_model_report, format_model_switch_report, format_permissions_report, format_init_report, format_model_report, format_model_switch_report,
format_permissions_switch_report, format_resume_report, format_status_report, format_permissions_report, format_permissions_switch_report, format_resume_report,
format_tool_call_start, format_tool_result, is_newer_version, normalize_permission_mode, format_status_report, format_tool_call_start, format_tool_result, list_managed_sessions,
normalize_version_tag, parse_args, parse_checksum_for_asset, parse_git_status_metadata, normalize_permission_mode, parse_args, parse_git_status_metadata, render_config_report,
render_config_report, render_init_claude_md, render_memory_report, render_repl_help, render_init_claude_md, render_memory_report, render_repl_help,
render_update_report, resume_supported_slash_commands, select_release_assets, resume_supported_slash_commands, sessions_dir, status_context, CliAction, CliOutputFormat,
status_context, CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL, SlashCommand, StatusUsage, DEFAULT_MODEL,
}; };
use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode}; use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode, Session};
use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
#[test]
fn derive_session_metadata_recomputes_prompt_and_count() {
let mut session = Session::new();
session
.messages
.push(ConversationMessage::user_text("first prompt"));
session
.messages
.push(ConversationMessage::assistant(vec![ContentBlock::Text {
text: "reply".to_string(),
}]));
let metadata = derive_session_metadata(&session, "claude-test");
assert_eq!(metadata.model, "claude-test");
assert_eq!(metadata.message_count, 2);
assert_eq!(metadata.last_prompt.as_deref(), Some("first prompt"));
assert!(metadata.started_at.ends_with('Z'));
}
#[test]
fn managed_sessions_use_home_directory_and_list_metadata() {
let temp =
std::env::temp_dir().join(format!("rusty-claude-cli-home-{}", std::process::id()));
let _ = fs::remove_dir_all(&temp);
fs::create_dir_all(&temp).expect("temp home should exist");
let previous_home = std::env::var_os("HOME");
std::env::set_var("HOME", &temp);
let dir = sessions_dir().expect("sessions dir");
assert_eq!(dir, temp.join(".claude").join("sessions"));
let mut session = Session::new();
session
.messages
.push(ConversationMessage::user_text("persist me"));
session.metadata = Some(derive_session_metadata(&session, "claude-home"));
let file = dir.join("session-test.json");
session.save_to_path(&file).expect("session save");
let listed = list_managed_sessions().expect("session list");
let found = listed
.into_iter()
.find(|entry| entry.id == "session-test")
.expect("saved session should be listed");
assert_eq!(found.message_count, 1);
assert_eq!(found.model.as_deref(), Some("claude-home"));
assert_eq!(found.last_prompt.as_deref(), Some("persist me"));
fs::remove_file(file).ok();
if let Some(previous_home) = previous_home {
std::env::set_var("HOME", previous_home);
}
fs::remove_dir_all(temp).ok();
}
#[test] #[test]
fn defaults_to_repl_when_no_args() { fn defaults_to_repl_when_no_args() {
assert_eq!( assert_eq!(
@@ -2856,64 +2656,6 @@ 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()];
@@ -3055,7 +2797,8 @@ mod tests {
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/clear [--confirm]"));
assert!(help.contains("/cost")); assert!(help.contains("/cost"));
assert!(help.contains("/resume <session-path>")); assert!(help.contains("/resume <session-id-or-path>"));
assert!(help.contains("/sessions"));
assert!(help.contains("/config [env|hooks|model]")); assert!(help.contains("/config [env|hooks|model]"));
assert!(help.contains("/memory")); assert!(help.contains("/memory"));
assert!(help.contains("/init")); assert!(help.contains("/init"));