2026-04-01 04:39:39 +10:00
|
|
|
|
mod input;
|
|
|
|
|
|
mod render;
|
|
|
|
|
|
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
use std::env;
|
2026-04-01 05:22:56 +10:00
|
|
|
|
use std::fs;
|
2026-04-01 04:39:39 +10:00
|
|
|
|
use std::io::{self, Write};
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
use std::path::{Path, PathBuf};
|
2026-04-01 05:22:56 +10:00
|
|
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-04-01 03:43:09 +10:00
|
|
|
|
|
2026-04-01 04:39:39 +10:00
|
|
|
|
use api::{
|
|
|
|
|
|
AnthropicClient, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
|
|
|
|
|
|
MessageResponse, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
|
|
|
|
|
|
ToolResultContentBlock,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
use commands::handle_slash_command;
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-04-01 03:43:09 +10:00
|
|
|
|
use compat_harness::{extract_manifest, UpstreamPaths};
|
2026-04-01 04:39:39 +10:00
|
|
|
|
use render::{Spinner, TerminalRenderer};
|
|
|
|
|
|
use runtime::{
|
2026-04-01 05:18:42 +10:00
|
|
|
|
estimate_session_tokens, load_system_prompt, ApiClient, ApiRequest, AssistantEvent,
|
|
|
|
|
|
CompactionConfig, ContentBlock, ConversationMessage, ConversationRuntime, MessageRole,
|
2026-04-01 05:28:07 +10:00
|
|
|
|
PermissionMode, PermissionPolicy, PermissionPromptDecision, PermissionPrompter,
|
|
|
|
|
|
PermissionRequest, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
|
2026-04-01 04:39:39 +10:00
|
|
|
|
};
|
|
|
|
|
|
use tools::{execute_tool, mvp_tool_specs};
|
|
|
|
|
|
|
|
|
|
|
|
const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
|
|
|
|
|
|
const DEFAULT_MAX_TOKENS: u32 = 32;
|
|
|
|
|
|
const DEFAULT_DATE: &str = "2026-03-31";
|
2026-04-01 05:22:56 +10:00
|
|
|
|
const DEFAULT_SESSION_LIMIT: usize = 20;
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-04-01 03:43:09 +10:00
|
|
|
|
|
|
|
|
|
|
fn main() {
|
2026-04-01 04:39:39 +10:00
|
|
|
|
if let Err(error) = run() {
|
|
|
|
|
|
eprintln!("{error}");
|
|
|
|
|
|
std::process::exit(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-04-01 03:43:09 +10:00
|
|
|
|
|
2026-04-01 04:39:39 +10:00
|
|
|
|
fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
|
|
|
let args: Vec<String> = env::args().skip(1).collect();
|
|
|
|
|
|
match parse_args(&args)? {
|
|
|
|
|
|
CliAction::DumpManifests => dump_manifests(),
|
|
|
|
|
|
CliAction::BootstrapPlan => print_bootstrap_plan(),
|
|
|
|
|
|
CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
|
|
|
|
|
|
CliAction::ResumeSession {
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
session_path,
|
|
|
|
|
|
command,
|
2026-04-01 04:39:39 +10:00
|
|
|
|
} => resume_session(&session_path, command),
|
2026-04-01 05:22:56 +10:00
|
|
|
|
CliAction::ResumeNamed { target, command } => resume_named_session(&target, command),
|
|
|
|
|
|
CliAction::ListSessions { query, limit } => list_sessions(query.as_deref(), limit),
|
2026-04-01 04:39:39 +10:00
|
|
|
|
CliAction::Prompt { prompt, model } => LiveCli::new(model, false)?.run_turn(&prompt)?,
|
|
|
|
|
|
CliAction::Repl { model } => run_repl(model)?,
|
|
|
|
|
|
CliAction::Help => print_help(),
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
}
|
2026-04-01 04:39:39 +10:00
|
|
|
|
Ok(())
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
|
enum CliAction {
|
|
|
|
|
|
DumpManifests,
|
|
|
|
|
|
BootstrapPlan,
|
|
|
|
|
|
PrintSystemPrompt {
|
|
|
|
|
|
cwd: PathBuf,
|
|
|
|
|
|
date: String,
|
|
|
|
|
|
},
|
|
|
|
|
|
ResumeSession {
|
|
|
|
|
|
session_path: PathBuf,
|
|
|
|
|
|
command: Option<String>,
|
|
|
|
|
|
},
|
2026-04-01 05:22:56 +10:00
|
|
|
|
ResumeNamed {
|
|
|
|
|
|
target: String,
|
|
|
|
|
|
command: Option<String>,
|
|
|
|
|
|
},
|
|
|
|
|
|
ListSessions {
|
|
|
|
|
|
query: Option<String>,
|
|
|
|
|
|
limit: usize,
|
|
|
|
|
|
},
|
2026-04-01 04:39:39 +10:00
|
|
|
|
Prompt {
|
|
|
|
|
|
prompt: String,
|
|
|
|
|
|
model: String,
|
|
|
|
|
|
},
|
|
|
|
|
|
Repl {
|
|
|
|
|
|
model: String,
|
|
|
|
|
|
},
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
Help,
|
|
|
|
|
|
}
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-04-01 03:43:09 +10:00
|
|
|
|
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
2026-04-01 04:39:39 +10:00
|
|
|
|
let mut model = DEFAULT_MODEL.to_string();
|
|
|
|
|
|
let mut rest = Vec::new();
|
|
|
|
|
|
let mut index = 0;
|
|
|
|
|
|
|
|
|
|
|
|
while index < args.len() {
|
|
|
|
|
|
match args[index].as_str() {
|
|
|
|
|
|
"--model" => {
|
|
|
|
|
|
let value = args
|
|
|
|
|
|
.get(index + 1)
|
|
|
|
|
|
.ok_or_else(|| "missing value for --model".to_string())?;
|
2026-04-01 05:18:42 +10:00
|
|
|
|
model.clone_from(value);
|
2026-04-01 04:39:39 +10:00
|
|
|
|
index += 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
flag if flag.starts_with("--model=") => {
|
|
|
|
|
|
model = flag[8..].to_string();
|
|
|
|
|
|
index += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
other => {
|
|
|
|
|
|
rest.push(other.to_string());
|
|
|
|
|
|
index += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 04:39:39 +10:00
|
|
|
|
if rest.is_empty() {
|
|
|
|
|
|
return Ok(CliAction::Repl { model });
|
|
|
|
|
|
}
|
|
|
|
|
|
if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
return Ok(CliAction::Help);
|
|
|
|
|
|
}
|
2026-04-01 04:39:39 +10:00
|
|
|
|
if rest.first().map(String::as_str) == Some("--resume") {
|
|
|
|
|
|
return parse_resume_args(&rest[1..]);
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 04:39:39 +10:00
|
|
|
|
match rest[0].as_str() {
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
"dump-manifests" => Ok(CliAction::DumpManifests),
|
|
|
|
|
|
"bootstrap-plan" => Ok(CliAction::BootstrapPlan),
|
2026-04-01 05:22:56 +10:00
|
|
|
|
"resume" => parse_named_resume_args(&rest[1..]),
|
|
|
|
|
|
"sessions" => parse_sessions_args(&rest[1..]),
|
2026-04-01 04:39:39 +10:00
|
|
|
|
"system-prompt" => parse_system_prompt_args(&rest[1..]),
|
|
|
|
|
|
"prompt" => {
|
|
|
|
|
|
let prompt = rest[1..].join(" ");
|
|
|
|
|
|
if prompt.trim().is_empty() {
|
|
|
|
|
|
return Err("prompt subcommand requires a prompt string".to_string());
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(CliAction::Prompt { prompt, model })
|
|
|
|
|
|
}
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
other => Err(format!("unknown subcommand: {other}")),
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-04-01 03:43:09 +10:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
|
|
|
|
|
|
let mut cwd = env::current_dir().map_err(|error| error.to_string())?;
|
2026-04-01 04:39:39 +10:00
|
|
|
|
let mut date = DEFAULT_DATE.to_string();
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
let mut index = 0;
|
|
|
|
|
|
|
|
|
|
|
|
while index < args.len() {
|
|
|
|
|
|
match args[index].as_str() {
|
|
|
|
|
|
"--cwd" => {
|
|
|
|
|
|
let value = args
|
|
|
|
|
|
.get(index + 1)
|
|
|
|
|
|
.ok_or_else(|| "missing value for --cwd".to_string())?;
|
|
|
|
|
|
cwd = PathBuf::from(value);
|
|
|
|
|
|
index += 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
"--date" => {
|
|
|
|
|
|
let value = args
|
|
|
|
|
|
.get(index + 1)
|
|
|
|
|
|
.ok_or_else(|| "missing value for --date".to_string())?;
|
|
|
|
|
|
date.clone_from(value);
|
|
|
|
|
|
index += 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
other => return Err(format!("unknown system-prompt option: {other}")),
|
|
|
|
|
|
}
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-04-01 03:43:09 +10:00
|
|
|
|
}
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
|
|
|
|
|
|
Ok(CliAction::PrintSystemPrompt { cwd, date })
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-04-01 03:43:09 +10:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 05:22:56 +10:00
|
|
|
|
fn parse_named_resume_args(args: &[String]) -> Result<CliAction, String> {
|
|
|
|
|
|
let target = args
|
|
|
|
|
|
.first()
|
|
|
|
|
|
.ok_or_else(|| "missing session id, path, or 'latest' for resume".to_string())?
|
|
|
|
|
|
.clone();
|
|
|
|
|
|
let command = args.get(1).cloned();
|
|
|
|
|
|
if args.len() > 2 {
|
|
|
|
|
|
return Err("resume accepts at most one trailing slash command".to_string());
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(CliAction::ResumeNamed { target, command })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn parse_sessions_args(args: &[String]) -> Result<CliAction, String> {
|
|
|
|
|
|
let mut query = None;
|
|
|
|
|
|
let mut limit = DEFAULT_SESSION_LIMIT;
|
|
|
|
|
|
let mut index = 0;
|
|
|
|
|
|
|
|
|
|
|
|
while index < args.len() {
|
|
|
|
|
|
match args[index].as_str() {
|
|
|
|
|
|
"--query" => {
|
|
|
|
|
|
let value = args
|
|
|
|
|
|
.get(index + 1)
|
|
|
|
|
|
.ok_or_else(|| "missing value for --query".to_string())?;
|
|
|
|
|
|
query = Some(value.clone());
|
|
|
|
|
|
index += 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
"--limit" => {
|
|
|
|
|
|
let value = args
|
|
|
|
|
|
.get(index + 1)
|
|
|
|
|
|
.ok_or_else(|| "missing value for --limit".to_string())?;
|
|
|
|
|
|
limit = value
|
|
|
|
|
|
.parse::<usize>()
|
|
|
|
|
|
.map_err(|error| format!("invalid --limit value: {error}"))?;
|
|
|
|
|
|
index += 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
other => return Err(format!("unknown sessions option: {other}")),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Ok(CliAction::ListSessions { query, limit })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
|
|
|
|
|
|
let session_path = args
|
|
|
|
|
|
.first()
|
|
|
|
|
|
.ok_or_else(|| "missing session path for --resume".to_string())
|
|
|
|
|
|
.map(PathBuf::from)?;
|
|
|
|
|
|
let command = args.get(1).cloned();
|
|
|
|
|
|
if args.len() > 2 {
|
|
|
|
|
|
return Err("--resume accepts at most one trailing slash command".to_string());
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(CliAction::ResumeSession {
|
|
|
|
|
|
session_path,
|
|
|
|
|
|
command,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn dump_manifests() {
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-04-01 03:43:09 +10:00
|
|
|
|
let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
|
|
|
|
|
let paths = UpstreamPaths::from_workspace_dir(&workspace_dir);
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
match extract_manifest(&paths) {
|
|
|
|
|
|
Ok(manifest) => {
|
|
|
|
|
|
println!("commands: {}", manifest.commands.entries().len());
|
|
|
|
|
|
println!("tools: {}", manifest.tools.entries().len());
|
|
|
|
|
|
println!("bootstrap phases: {}", manifest.bootstrap.phases().len());
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(error) => {
|
|
|
|
|
|
eprintln!("failed to extract manifests: {error}");
|
|
|
|
|
|
std::process::exit(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-04-01 03:43:09 +10:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn print_bootstrap_plan() {
|
2026-04-01 04:39:39 +10:00
|
|
|
|
for phase in runtime::BootstrapPlan::claude_code_default().phases() {
|
feat: Rust port of Claude Code CLI
Crates:
- api: Anthropic Messages API client with SSE streaming
- tools: Claude-compatible tool implementations (Bash, Read, Write, Edit, Glob, Grep + extended suite)
- runtime: conversation loop, session persistence, permissions, system prompt builder
- rusty-claude-cli: terminal UI with markdown rendering, syntax highlighting, spinners
- commands: subcommand definitions
- compat-harness: upstream TS parity verification
All crates pass cargo fmt/clippy/test.
2026-04-01 03:43:09 +10:00
|
|
|
|
println!("- {phase:?}");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
|
|
|
|
|
|
fn print_system_prompt(cwd: PathBuf, date: String) {
|
|
|
|
|
|
match load_system_prompt(cwd, date, env::consts::OS, "unknown") {
|
|
|
|
|
|
Ok(sections) => println!("{}", sections.join("\n\n")),
|
|
|
|
|
|
Err(error) => {
|
|
|
|
|
|
eprintln!("failed to build system prompt: {error}");
|
|
|
|
|
|
std::process::exit(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn resume_session(session_path: &Path, command: Option<String>) {
|
|
|
|
|
|
let session = match Session::load_from_path(session_path) {
|
|
|
|
|
|
Ok(session) => session,
|
|
|
|
|
|
Err(error) => {
|
|
|
|
|
|
eprintln!("failed to restore session: {error}");
|
|
|
|
|
|
std::process::exit(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
match command {
|
|
|
|
|
|
Some(command) if command.starts_with('/') => {
|
|
|
|
|
|
let Some(result) = handle_slash_command(
|
|
|
|
|
|
&command,
|
|
|
|
|
|
&session,
|
|
|
|
|
|
CompactionConfig {
|
|
|
|
|
|
max_estimated_tokens: 0,
|
|
|
|
|
|
..CompactionConfig::default()
|
|
|
|
|
|
},
|
|
|
|
|
|
) else {
|
|
|
|
|
|
eprintln!("unknown slash command: {command}");
|
|
|
|
|
|
std::process::exit(2);
|
|
|
|
|
|
};
|
|
|
|
|
|
if let Err(error) = result.session.save_to_path(session_path) {
|
|
|
|
|
|
eprintln!("failed to persist resumed session: {error}");
|
|
|
|
|
|
std::process::exit(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
println!("{}", result.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
Some(other) => {
|
|
|
|
|
|
eprintln!("unsupported resumed command: {other}");
|
|
|
|
|
|
std::process::exit(2);
|
|
|
|
|
|
}
|
|
|
|
|
|
None => {
|
|
|
|
|
|
println!(
|
|
|
|
|
|
"Restored session from {} ({} messages).",
|
|
|
|
|
|
session_path.display(),
|
|
|
|
|
|
session.messages.len()
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 05:22:56 +10:00
|
|
|
|
fn resume_named_session(target: &str, command: Option<String>) {
|
|
|
|
|
|
let session_path = match resolve_session_target(target) {
|
|
|
|
|
|
Ok(path) => path,
|
|
|
|
|
|
Err(error) => {
|
|
|
|
|
|
eprintln!("{error}");
|
|
|
|
|
|
std::process::exit(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
resume_session(&session_path, command);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn list_sessions(query: Option<&str>, limit: usize) {
|
|
|
|
|
|
match load_session_entries(query, limit) {
|
|
|
|
|
|
Ok(entries) => {
|
|
|
|
|
|
if entries.is_empty() {
|
|
|
|
|
|
println!("No saved sessions found.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
println!("Saved sessions:");
|
|
|
|
|
|
for entry in entries {
|
|
|
|
|
|
println!(
|
|
|
|
|
|
"- {} | updated={} | messages={} | tokens={} | {}",
|
|
|
|
|
|
entry.id,
|
|
|
|
|
|
entry.updated_unix,
|
|
|
|
|
|
entry.message_count,
|
|
|
|
|
|
entry.total_tokens,
|
|
|
|
|
|
entry.preview
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(error) => {
|
|
|
|
|
|
eprintln!("failed to list sessions: {error}");
|
|
|
|
|
|
std::process::exit(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 04:39:39 +10:00
|
|
|
|
fn run_repl(model: String) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
|
|
|
let mut cli = LiveCli::new(model, true)?;
|
|
|
|
|
|
let editor = input::LineEditor::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()? {
|
|
|
|
|
|
let trimmed = input.trim();
|
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
match trimmed {
|
|
|
|
|
|
"/exit" | "/quit" => break,
|
|
|
|
|
|
"/help" => {
|
|
|
|
|
|
println!("Available commands:");
|
2026-04-01 05:28:07 +10:00
|
|
|
|
println!(" /help Show help");
|
|
|
|
|
|
println!(" /status Show session status");
|
|
|
|
|
|
println!(" /tools Show tool catalog and permission policy");
|
|
|
|
|
|
println!(" /permissions Show permission mode details");
|
|
|
|
|
|
println!(" /compact Compact session history");
|
|
|
|
|
|
println!(" /exit Quit the REPL");
|
2026-04-01 04:39:39 +10:00
|
|
|
|
}
|
|
|
|
|
|
"/status" => cli.print_status(),
|
2026-04-01 05:28:07 +10:00
|
|
|
|
"/tools" => cli.print_tools(),
|
|
|
|
|
|
"/permissions" => cli.print_permissions(),
|
2026-04-01 04:39:39 +10:00
|
|
|
|
"/compact" => cli.compact()?,
|
|
|
|
|
|
_ => cli.run_turn(trimmed)?,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
struct LiveCli {
|
|
|
|
|
|
model: String,
|
|
|
|
|
|
system_prompt: Vec<String>,
|
|
|
|
|
|
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
|
2026-04-01 05:22:56 +10:00
|
|
|
|
session_path: PathBuf,
|
2026-04-01 05:28:07 +10:00
|
|
|
|
permission_policy: PermissionPolicy,
|
2026-04-01 04:39:39 +10:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl LiveCli {
|
|
|
|
|
|
fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> {
|
|
|
|
|
|
let system_prompt = build_system_prompt()?;
|
2026-04-01 05:22:56 +10:00
|
|
|
|
let session_path = new_session_path()?;
|
2026-04-01 05:28:07 +10:00
|
|
|
|
let permission_policy = permission_policy_from_env();
|
2026-04-01 04:39:39 +10:00
|
|
|
|
let runtime = build_runtime(
|
|
|
|
|
|
Session::new(),
|
|
|
|
|
|
model.clone(),
|
|
|
|
|
|
system_prompt.clone(),
|
|
|
|
|
|
enable_tools,
|
2026-04-01 05:28:07 +10:00
|
|
|
|
permission_policy.clone(),
|
2026-04-01 04:39:39 +10:00
|
|
|
|
)?;
|
|
|
|
|
|
Ok(Self {
|
|
|
|
|
|
model,
|
|
|
|
|
|
system_prompt,
|
|
|
|
|
|
runtime,
|
2026-04-01 05:22:56 +10:00
|
|
|
|
session_path,
|
2026-04-01 05:28:07 +10:00
|
|
|
|
permission_policy,
|
2026-04-01 04:39:39 +10:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
|
|
|
let mut spinner = Spinner::new();
|
|
|
|
|
|
let mut stdout = io::stdout();
|
|
|
|
|
|
spinner.tick(
|
|
|
|
|
|
"Waiting for Claude",
|
|
|
|
|
|
TerminalRenderer::new().color_theme(),
|
|
|
|
|
|
&mut stdout,
|
|
|
|
|
|
)?;
|
2026-04-01 05:28:07 +10:00
|
|
|
|
let mut permission_prompter = CliPermissionPrompter::new();
|
|
|
|
|
|
let result = self.runtime.run_turn(input, Some(&mut permission_prompter));
|
2026-04-01 04:39:39 +10:00
|
|
|
|
match result {
|
2026-04-01 05:18:42 +10:00
|
|
|
|
Ok(turn) => {
|
2026-04-01 04:39:39 +10:00
|
|
|
|
spinner.finish(
|
|
|
|
|
|
"Claude response complete",
|
|
|
|
|
|
TerminalRenderer::new().color_theme(),
|
|
|
|
|
|
&mut stdout,
|
|
|
|
|
|
)?;
|
|
|
|
|
|
println!();
|
2026-04-01 05:22:56 +10:00
|
|
|
|
self.persist_session()?;
|
2026-04-01 05:18:42 +10:00
|
|
|
|
self.print_turn_usage(turn.usage);
|
2026-04-01 04:39:39 +10:00
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(error) => {
|
|
|
|
|
|
spinner.fail(
|
|
|
|
|
|
"Claude request failed",
|
|
|
|
|
|
TerminalRenderer::new().color_theme(),
|
|
|
|
|
|
&mut stdout,
|
|
|
|
|
|
)?;
|
|
|
|
|
|
Err(Box::new(error))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn print_status(&self) {
|
|
|
|
|
|
let usage = self.runtime.usage().cumulative_usage();
|
|
|
|
|
|
println!(
|
2026-04-01 05:18:42 +10:00
|
|
|
|
"status: messages={} turns={} estimated_session_tokens={}",
|
2026-04-01 04:39:39 +10:00
|
|
|
|
self.runtime.session().messages.len(),
|
|
|
|
|
|
self.runtime.usage().turns(),
|
2026-04-01 05:18:42 +10:00
|
|
|
|
self.runtime.estimated_tokens()
|
2026-04-01 04:39:39 +10:00
|
|
|
|
);
|
2026-04-01 05:18:42 +10:00
|
|
|
|
for line in usage.summary_lines("usage") {
|
|
|
|
|
|
println!("{line}");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn print_turn_usage(&self, cumulative_usage: TokenUsage) {
|
|
|
|
|
|
let latest = self.runtime.usage().current_turn_usage();
|
|
|
|
|
|
println!("\nTurn usage:");
|
|
|
|
|
|
for line in latest.summary_lines(" latest") {
|
|
|
|
|
|
println!("{line}");
|
|
|
|
|
|
}
|
|
|
|
|
|
println!("Cumulative usage:");
|
|
|
|
|
|
for line in cumulative_usage.summary_lines(" total") {
|
|
|
|
|
|
println!("{line}");
|
|
|
|
|
|
}
|
2026-04-01 04:39:39 +10:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 05:28:07 +10:00
|
|
|
|
fn print_permissions(&self) {
|
|
|
|
|
|
let mode = env::var("RUSTY_CLAUDE_PERMISSION_MODE")
|
|
|
|
|
|
.unwrap_or_else(|_| "workspace-write".to_string());
|
|
|
|
|
|
println!("Permission mode: {mode}");
|
|
|
|
|
|
println!(
|
|
|
|
|
|
"Default policy: {}",
|
|
|
|
|
|
permission_mode_label(self.permission_policy.mode_for("bash"))
|
|
|
|
|
|
);
|
|
|
|
|
|
println!("Read-only safe tools stay auto-allowed when read-only mode is active.");
|
|
|
|
|
|
println!("Interactive approvals appear when permission mode is set to prompt.");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn print_tools(&self) {
|
|
|
|
|
|
println!("Tool catalog:");
|
|
|
|
|
|
for spec in mvp_tool_specs() {
|
|
|
|
|
|
let mode = self.permission_policy.mode_for(spec.name);
|
|
|
|
|
|
let summary = summarize_tool_schema(&spec.input_schema);
|
|
|
|
|
|
println!(
|
|
|
|
|
|
"- {} [{}] — {}{}",
|
|
|
|
|
|
spec.name,
|
|
|
|
|
|
permission_mode_label(mode),
|
|
|
|
|
|
spec.description,
|
|
|
|
|
|
if summary.is_empty() {
|
|
|
|
|
|
String::new()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
format!(" | args: {summary}")
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 04:39:39 +10:00
|
|
|
|
fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
2026-04-01 05:18:42 +10:00
|
|
|
|
let estimated_before = self.runtime.estimated_tokens();
|
2026-04-01 04:39:39 +10:00
|
|
|
|
let result = self.runtime.compact(CompactionConfig::default());
|
|
|
|
|
|
let removed = result.removed_message_count;
|
2026-04-01 05:18:42 +10:00
|
|
|
|
let estimated_after = estimate_session_tokens(&result.compacted_session);
|
|
|
|
|
|
let formatted_summary = result.formatted_summary.clone();
|
|
|
|
|
|
let compacted_session = result.compacted_session;
|
|
|
|
|
|
|
2026-04-01 04:39:39 +10:00
|
|
|
|
self.runtime = build_runtime(
|
2026-04-01 05:18:42 +10:00
|
|
|
|
compacted_session,
|
2026-04-01 04:39:39 +10:00
|
|
|
|
self.model.clone(),
|
|
|
|
|
|
self.system_prompt.clone(),
|
|
|
|
|
|
true,
|
2026-04-01 05:28:07 +10:00
|
|
|
|
self.permission_policy.clone(),
|
2026-04-01 04:39:39 +10:00
|
|
|
|
)?;
|
2026-04-01 05:18:42 +10:00
|
|
|
|
|
|
|
|
|
|
if removed == 0 {
|
|
|
|
|
|
println!("Compaction skipped: session is below the compaction threshold.");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
println!("Compacted {removed} messages into a resumable system summary.");
|
|
|
|
|
|
if !formatted_summary.is_empty() {
|
|
|
|
|
|
println!("\n{formatted_summary}");
|
|
|
|
|
|
}
|
|
|
|
|
|
let estimated_saved = estimated_before.saturating_sub(estimated_after);
|
|
|
|
|
|
println!("Estimated tokens saved: {estimated_saved}");
|
|
|
|
|
|
}
|
2026-04-01 05:22:56 +10:00
|
|
|
|
self.persist_session()?;
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
|
|
|
self.runtime.session().save_to_path(&self.session_path)?;
|
2026-04-01 04:39:39 +10:00
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 05:22:56 +10:00
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
|
struct SessionListEntry {
|
|
|
|
|
|
id: String,
|
|
|
|
|
|
path: PathBuf,
|
|
|
|
|
|
updated_unix: u64,
|
|
|
|
|
|
message_count: usize,
|
|
|
|
|
|
total_tokens: u32,
|
|
|
|
|
|
preview: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn new_session_path() -> io::Result<PathBuf> {
|
|
|
|
|
|
let session_dir = default_session_dir()?;
|
|
|
|
|
|
fs::create_dir_all(&session_dir)?;
|
|
|
|
|
|
let timestamp = current_unix_timestamp();
|
|
|
|
|
|
let process_id = std::process::id();
|
|
|
|
|
|
Ok(session_dir.join(format!("session-{timestamp}-{process_id}.json")))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn default_session_dir() -> io::Result<PathBuf> {
|
|
|
|
|
|
Ok(env::current_dir()?.join(".rusty-claude").join("sessions"))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn current_unix_timestamp() -> u64 {
|
|
|
|
|
|
SystemTime::now()
|
|
|
|
|
|
.duration_since(UNIX_EPOCH)
|
|
|
|
|
|
.map_or(0, |duration| duration.as_secs())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn resolve_session_target(target: &str) -> io::Result<PathBuf> {
|
|
|
|
|
|
let direct_path = PathBuf::from(target);
|
|
|
|
|
|
if direct_path.is_file() {
|
|
|
|
|
|
return Ok(direct_path);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let entries = load_session_entries(None, usize::MAX)?;
|
|
|
|
|
|
if target == "latest" {
|
|
|
|
|
|
return entries
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.next()
|
|
|
|
|
|
.map(|entry| entry.path)
|
|
|
|
|
|
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no saved sessions found"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let mut matches = entries
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.filter(|entry| entry.id.contains(target) || entry.preview.contains(target))
|
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
|
if matches.is_empty() {
|
|
|
|
|
|
return Err(io::Error::new(
|
|
|
|
|
|
io::ErrorKind::NotFound,
|
|
|
|
|
|
format!("no saved session matched '{target}'"),
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
matches.sort_by(|left, right| right.updated_unix.cmp(&left.updated_unix));
|
|
|
|
|
|
Ok(matches.remove(0).path)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn load_session_entries(query: Option<&str>, limit: usize) -> io::Result<Vec<SessionListEntry>> {
|
|
|
|
|
|
let session_dir = default_session_dir()?;
|
|
|
|
|
|
if !session_dir.exists() {
|
|
|
|
|
|
return Ok(Vec::new());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let query = query.map(str::to_lowercase);
|
|
|
|
|
|
let mut entries = Vec::new();
|
|
|
|
|
|
for entry in fs::read_dir(session_dir)? {
|
|
|
|
|
|
let entry = entry?;
|
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
|
if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let Ok(session) = Session::load_from_path(&path) else {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let preview = session_preview(&session);
|
|
|
|
|
|
let id = path
|
|
|
|
|
|
.file_stem()
|
|
|
|
|
|
.map_or_else(String::new, |stem| stem.to_string_lossy().into_owned());
|
|
|
|
|
|
let searchable = format!("{} {}", id.to_lowercase(), preview.to_lowercase());
|
|
|
|
|
|
if let Some(query) = &query {
|
|
|
|
|
|
if !searchable.contains(query) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let updated_unix = entry
|
|
|
|
|
|
.metadata()
|
|
|
|
|
|
.and_then(|metadata| metadata.modified())
|
|
|
|
|
|
.ok()
|
|
|
|
|
|
.and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
|
|
|
|
|
|
.map_or(0, |duration| duration.as_secs());
|
|
|
|
|
|
|
|
|
|
|
|
entries.push(SessionListEntry {
|
|
|
|
|
|
id,
|
|
|
|
|
|
path,
|
|
|
|
|
|
updated_unix,
|
|
|
|
|
|
message_count: session.messages.len(),
|
|
|
|
|
|
total_tokens: runtime::UsageTracker::from_session(&session)
|
|
|
|
|
|
.cumulative_usage()
|
|
|
|
|
|
.total_tokens(),
|
|
|
|
|
|
preview,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
entries.sort_by(|left, right| right.updated_unix.cmp(&left.updated_unix));
|
|
|
|
|
|
if limit < entries.len() {
|
|
|
|
|
|
entries.truncate(limit);
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(entries)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn session_preview(session: &Session) -> String {
|
|
|
|
|
|
for message in session.messages.iter().rev() {
|
|
|
|
|
|
for block in &message.blocks {
|
|
|
|
|
|
if let ContentBlock::Text { text } = block {
|
|
|
|
|
|
let trimmed = text.trim();
|
|
|
|
|
|
if !trimmed.is_empty() {
|
|
|
|
|
|
return truncate_preview(trimmed, 80);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
"No text preview available".to_string()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn truncate_preview(text: &str, max_chars: usize) -> String {
|
|
|
|
|
|
if text.chars().count() <= max_chars {
|
|
|
|
|
|
return text.to_string();
|
|
|
|
|
|
}
|
|
|
|
|
|
let mut output = text.chars().take(max_chars).collect::<String>();
|
|
|
|
|
|
output.push('…');
|
|
|
|
|
|
output
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 04:39:39 +10:00
|
|
|
|
fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
|
|
|
|
|
Ok(load_system_prompt(
|
|
|
|
|
|
env::current_dir()?,
|
|
|
|
|
|
DEFAULT_DATE,
|
|
|
|
|
|
env::consts::OS,
|
|
|
|
|
|
"unknown",
|
|
|
|
|
|
)?)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn build_runtime(
|
|
|
|
|
|
session: Session,
|
|
|
|
|
|
model: String,
|
|
|
|
|
|
system_prompt: Vec<String>,
|
|
|
|
|
|
enable_tools: bool,
|
2026-04-01 05:28:07 +10:00
|
|
|
|
permission_policy: PermissionPolicy,
|
2026-04-01 04:39:39 +10:00
|
|
|
|
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
|
|
|
|
|
|
{
|
|
|
|
|
|
Ok(ConversationRuntime::new(
|
|
|
|
|
|
session,
|
|
|
|
|
|
AnthropicRuntimeClient::new(model, enable_tools)?,
|
|
|
|
|
|
CliToolExecutor::new(),
|
2026-04-01 05:28:07 +10:00
|
|
|
|
permission_policy,
|
2026-04-01 04:39:39 +10:00
|
|
|
|
system_prompt,
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
struct AnthropicRuntimeClient {
|
|
|
|
|
|
runtime: tokio::runtime::Runtime,
|
|
|
|
|
|
client: AnthropicClient,
|
|
|
|
|
|
model: String,
|
|
|
|
|
|
enable_tools: bool,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl AnthropicRuntimeClient {
|
|
|
|
|
|
fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> {
|
|
|
|
|
|
Ok(Self {
|
|
|
|
|
|
runtime: tokio::runtime::Runtime::new()?,
|
|
|
|
|
|
client: AnthropicClient::from_env()?,
|
|
|
|
|
|
model,
|
|
|
|
|
|
enable_tools,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl ApiClient for AnthropicRuntimeClient {
|
2026-04-01 05:18:42 +10:00
|
|
|
|
#[allow(clippy::too_many_lines)]
|
2026-04-01 04:39:39 +10:00
|
|
|
|
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
|
|
|
|
|
let message_request = MessageRequest {
|
|
|
|
|
|
model: self.model.clone(),
|
|
|
|
|
|
max_tokens: DEFAULT_MAX_TOKENS,
|
|
|
|
|
|
messages: convert_messages(&request.messages),
|
|
|
|
|
|
system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")),
|
|
|
|
|
|
tools: self.enable_tools.then(|| {
|
|
|
|
|
|
mvp_tool_specs()
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.map(|spec| ToolDefinition {
|
|
|
|
|
|
name: spec.name.to_string(),
|
|
|
|
|
|
description: Some(spec.description.to_string()),
|
|
|
|
|
|
input_schema: spec.input_schema,
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}),
|
|
|
|
|
|
tool_choice: self.enable_tools.then_some(ToolChoice::Auto),
|
|
|
|
|
|
stream: true,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
self.runtime.block_on(async {
|
|
|
|
|
|
let mut stream = self
|
|
|
|
|
|
.client
|
|
|
|
|
|
.stream_message(&message_request)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
|
|
|
|
|
let mut stdout = io::stdout();
|
|
|
|
|
|
let mut events = Vec::new();
|
|
|
|
|
|
let mut pending_tool: Option<(String, String, String)> = None;
|
|
|
|
|
|
let mut saw_stop = false;
|
|
|
|
|
|
|
|
|
|
|
|
while let Some(event) = stream
|
|
|
|
|
|
.next_event()
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|error| RuntimeError::new(error.to_string()))?
|
|
|
|
|
|
{
|
|
|
|
|
|
match event {
|
|
|
|
|
|
ApiStreamEvent::MessageStart(start) => {
|
|
|
|
|
|
for block in start.message.content {
|
|
|
|
|
|
push_output_block(block, &mut stdout, &mut events, &mut pending_tool)?;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
ApiStreamEvent::ContentBlockStart(start) => {
|
|
|
|
|
|
push_output_block(
|
|
|
|
|
|
start.content_block,
|
|
|
|
|
|
&mut stdout,
|
|
|
|
|
|
&mut events,
|
|
|
|
|
|
&mut pending_tool,
|
|
|
|
|
|
)?;
|
|
|
|
|
|
}
|
|
|
|
|
|
ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
|
|
|
|
|
|
ContentBlockDelta::TextDelta { text } => {
|
|
|
|
|
|
if !text.is_empty() {
|
|
|
|
|
|
write!(stdout, "{text}")
|
2026-04-01 05:18:42 +10:00
|
|
|
|
.and_then(|()| stdout.flush())
|
2026-04-01 04:39:39 +10:00
|
|
|
|
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
|
|
|
|
|
events.push(AssistantEvent::TextDelta(text));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
ContentBlockDelta::InputJsonDelta { partial_json } => {
|
|
|
|
|
|
if let Some((_, _, input)) = &mut pending_tool {
|
|
|
|
|
|
input.push_str(&partial_json);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
ApiStreamEvent::ContentBlockStop(_) => {
|
|
|
|
|
|
if let Some((id, name, input)) = pending_tool.take() {
|
|
|
|
|
|
events.push(AssistantEvent::ToolUse { id, name, input });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
ApiStreamEvent::MessageDelta(delta) => {
|
|
|
|
|
|
events.push(AssistantEvent::Usage(TokenUsage {
|
|
|
|
|
|
input_tokens: delta.usage.input_tokens,
|
|
|
|
|
|
output_tokens: delta.usage.output_tokens,
|
|
|
|
|
|
cache_creation_input_tokens: 0,
|
|
|
|
|
|
cache_read_input_tokens: 0,
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
ApiStreamEvent::MessageStop(_) => {
|
|
|
|
|
|
saw_stop = true;
|
|
|
|
|
|
events.push(AssistantEvent::MessageStop);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if !saw_stop
|
|
|
|
|
|
&& events.iter().any(|event| {
|
|
|
|
|
|
matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty())
|
|
|
|
|
|
|| matches!(event, AssistantEvent::ToolUse { .. })
|
|
|
|
|
|
})
|
|
|
|
|
|
{
|
|
|
|
|
|
events.push(AssistantEvent::MessageStop);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if events
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|event| matches!(event, AssistantEvent::MessageStop))
|
|
|
|
|
|
{
|
|
|
|
|
|
return Ok(events);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let response = self
|
|
|
|
|
|
.client
|
|
|
|
|
|
.send_message(&MessageRequest {
|
|
|
|
|
|
stream: false,
|
|
|
|
|
|
..message_request.clone()
|
|
|
|
|
|
})
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
|
|
|
|
|
response_to_events(response, &mut stdout)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn push_output_block(
|
|
|
|
|
|
block: OutputContentBlock,
|
|
|
|
|
|
out: &mut impl Write,
|
|
|
|
|
|
events: &mut Vec<AssistantEvent>,
|
|
|
|
|
|
pending_tool: &mut Option<(String, String, String)>,
|
|
|
|
|
|
) -> Result<(), RuntimeError> {
|
|
|
|
|
|
match block {
|
|
|
|
|
|
OutputContentBlock::Text { text } => {
|
|
|
|
|
|
if !text.is_empty() {
|
|
|
|
|
|
write!(out, "{text}")
|
2026-04-01 05:18:42 +10:00
|
|
|
|
.and_then(|()| out.flush())
|
2026-04-01 04:39:39 +10:00
|
|
|
|
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
|
|
|
|
|
events.push(AssistantEvent::TextDelta(text));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
OutputContentBlock::ToolUse { id, name, input } => {
|
|
|
|
|
|
*pending_tool = Some((id, name, input.to_string()));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn response_to_events(
|
|
|
|
|
|
response: MessageResponse,
|
|
|
|
|
|
out: &mut impl Write,
|
|
|
|
|
|
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
|
|
|
|
|
let mut events = Vec::new();
|
|
|
|
|
|
let mut pending_tool = None;
|
|
|
|
|
|
|
|
|
|
|
|
for block in response.content {
|
|
|
|
|
|
push_output_block(block, out, &mut events, &mut pending_tool)?;
|
|
|
|
|
|
if let Some((id, name, input)) = pending_tool.take() {
|
|
|
|
|
|
events.push(AssistantEvent::ToolUse { id, name, input });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
events.push(AssistantEvent::Usage(TokenUsage {
|
|
|
|
|
|
input_tokens: response.usage.input_tokens,
|
|
|
|
|
|
output_tokens: response.usage.output_tokens,
|
|
|
|
|
|
cache_creation_input_tokens: response.usage.cache_creation_input_tokens,
|
|
|
|
|
|
cache_read_input_tokens: response.usage.cache_read_input_tokens,
|
|
|
|
|
|
}));
|
|
|
|
|
|
events.push(AssistantEvent::MessageStop);
|
|
|
|
|
|
Ok(events)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 05:28:07 +10:00
|
|
|
|
fn permission_mode_label(mode: PermissionMode) -> &'static str {
|
|
|
|
|
|
match mode {
|
|
|
|
|
|
PermissionMode::Allow => "allow",
|
|
|
|
|
|
PermissionMode::Deny => "deny",
|
|
|
|
|
|
PermissionMode::Prompt => "prompt",
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn summarize_tool_schema(schema: &serde_json::Value) -> String {
|
|
|
|
|
|
let Some(properties) = schema
|
|
|
|
|
|
.get("properties")
|
|
|
|
|
|
.and_then(serde_json::Value::as_object)
|
|
|
|
|
|
else {
|
|
|
|
|
|
return String::new();
|
|
|
|
|
|
};
|
|
|
|
|
|
let mut keys = properties.keys().cloned().collect::<Vec<_>>();
|
|
|
|
|
|
keys.sort();
|
|
|
|
|
|
keys.join(", ")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn summarize_tool_output(tool_name: &str, output: &str) -> String {
|
|
|
|
|
|
let compact = output.replace('\n', " ");
|
|
|
|
|
|
let preview = truncate_preview(compact.trim(), 120);
|
|
|
|
|
|
if preview.is_empty() {
|
|
|
|
|
|
format!("{tool_name} completed with no textual output")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
format!("{tool_name} → {preview}")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
struct CliPermissionPrompter {
|
|
|
|
|
|
prompt: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl CliPermissionPrompter {
|
|
|
|
|
|
fn new() -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
prompt: "Allow tool? [y]es / [n]o / [a]lways deny this run: ".to_string(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl PermissionPrompter for CliPermissionPrompter {
|
|
|
|
|
|
fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision {
|
|
|
|
|
|
println!(
|
|
|
|
|
|
"
|
|
|
|
|
|
Tool permission request:"
|
|
|
|
|
|
);
|
|
|
|
|
|
println!("- tool: {}", request.tool_name);
|
|
|
|
|
|
println!("- input: {}", truncate_preview(request.input.trim(), 200));
|
|
|
|
|
|
print!("{}", self.prompt);
|
|
|
|
|
|
let _ = io::stdout().flush();
|
|
|
|
|
|
|
|
|
|
|
|
let mut response = String::new();
|
|
|
|
|
|
match io::stdin().read_line(&mut response) {
|
|
|
|
|
|
Ok(_) => match response.trim().to_ascii_lowercase().as_str() {
|
|
|
|
|
|
"y" | "yes" => PermissionPromptDecision::Allow,
|
|
|
|
|
|
"a" | "always" => PermissionPromptDecision::Deny {
|
|
|
|
|
|
reason: "tool denied for this run by user".to_string(),
|
|
|
|
|
|
},
|
|
|
|
|
|
_ => PermissionPromptDecision::Deny {
|
|
|
|
|
|
reason: "tool denied by user".to_string(),
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
Err(error) => PermissionPromptDecision::Deny {
|
|
|
|
|
|
reason: format!("tool approval failed: {error}"),
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 04:39:39 +10:00
|
|
|
|
struct CliToolExecutor {
|
|
|
|
|
|
renderer: TerminalRenderer,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl CliToolExecutor {
|
|
|
|
|
|
fn new() -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
renderer: TerminalRenderer::new(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl ToolExecutor for CliToolExecutor {
|
|
|
|
|
|
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
|
|
|
|
|
|
let value = serde_json::from_str(input)
|
|
|
|
|
|
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
|
|
|
|
|
|
match execute_tool(tool_name, &value) {
|
|
|
|
|
|
Ok(output) => {
|
2026-04-01 05:28:07 +10:00
|
|
|
|
let summary = summarize_tool_output(tool_name, &output);
|
|
|
|
|
|
let markdown = format!(
|
|
|
|
|
|
"### Tool `{tool_name}`\n\n- Summary: {summary}\n\n```json\n{output}\n```\n"
|
|
|
|
|
|
);
|
2026-04-01 04:39:39 +10:00
|
|
|
|
self.renderer
|
|
|
|
|
|
.stream_markdown(&markdown, &mut io::stdout())
|
|
|
|
|
|
.map_err(|error| ToolError::new(error.to_string()))?;
|
|
|
|
|
|
Ok(output)
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(error) => Err(ToolError::new(error)),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn permission_policy_from_env() -> PermissionPolicy {
|
|
|
|
|
|
let mode =
|
|
|
|
|
|
env::var("RUSTY_CLAUDE_PERMISSION_MODE").unwrap_or_else(|_| "workspace-write".to_string());
|
|
|
|
|
|
match mode.as_str() {
|
|
|
|
|
|
"read-only" => PermissionPolicy::new(PermissionMode::Deny)
|
|
|
|
|
|
.with_tool_mode("read_file", PermissionMode::Allow)
|
|
|
|
|
|
.with_tool_mode("glob_search", PermissionMode::Allow)
|
|
|
|
|
|
.with_tool_mode("grep_search", PermissionMode::Allow),
|
2026-04-01 05:28:07 +10:00
|
|
|
|
"prompt" => PermissionPolicy::new(PermissionMode::Prompt)
|
|
|
|
|
|
.with_tool_mode("read_file", PermissionMode::Allow)
|
|
|
|
|
|
.with_tool_mode("glob_search", PermissionMode::Allow)
|
|
|
|
|
|
.with_tool_mode("grep_search", PermissionMode::Allow),
|
2026-04-01 04:39:39 +10:00
|
|
|
|
_ => PermissionPolicy::new(PermissionMode::Allow),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
|
|
|
|
|
|
messages
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter_map(|message| {
|
|
|
|
|
|
let role = match message.role {
|
|
|
|
|
|
MessageRole::System | MessageRole::User | MessageRole::Tool => "user",
|
|
|
|
|
|
MessageRole::Assistant => "assistant",
|
|
|
|
|
|
};
|
|
|
|
|
|
let content = message
|
|
|
|
|
|
.blocks
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|block| match block {
|
|
|
|
|
|
ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
|
|
|
|
|
|
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
|
|
|
|
|
|
id: id.clone(),
|
|
|
|
|
|
name: name.clone(),
|
|
|
|
|
|
input: serde_json::from_str(input)
|
|
|
|
|
|
.unwrap_or_else(|_| serde_json::json!({ "raw": input })),
|
|
|
|
|
|
},
|
|
|
|
|
|
ContentBlock::ToolResult {
|
|
|
|
|
|
tool_use_id,
|
|
|
|
|
|
output,
|
|
|
|
|
|
is_error,
|
|
|
|
|
|
..
|
|
|
|
|
|
} => InputContentBlock::ToolResult {
|
|
|
|
|
|
tool_use_id: tool_use_id.clone(),
|
|
|
|
|
|
content: vec![ToolResultContentBlock::Text {
|
|
|
|
|
|
text: output.clone(),
|
|
|
|
|
|
}],
|
|
|
|
|
|
is_error: *is_error,
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
|
(!content.is_empty()).then(|| InputMessage {
|
|
|
|
|
|
role: role.to_string(),
|
|
|
|
|
|
content,
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
fn print_help() {
|
|
|
|
|
|
println!("rusty-claude-cli");
|
|
|
|
|
|
println!();
|
2026-04-01 04:39:39 +10:00
|
|
|
|
println!("Usage:");
|
|
|
|
|
|
println!(" rusty-claude-cli [--model MODEL] Start interactive REPL");
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
println!(
|
2026-04-01 04:39:39 +10:00
|
|
|
|
" rusty-claude-cli [--model MODEL] prompt TEXT Send one prompt and stream the response"
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
);
|
2026-04-01 04:39:39 +10:00
|
|
|
|
println!(" rusty-claude-cli dump-manifests");
|
|
|
|
|
|
println!(" rusty-claude-cli bootstrap-plan");
|
2026-04-01 05:22:56 +10:00
|
|
|
|
println!(" rusty-claude-cli sessions [--query TEXT] [--limit N]");
|
|
|
|
|
|
println!(" rusty-claude-cli resume <latest|SESSION|PATH> [/compact]");
|
2026-04-01 05:28:07 +10:00
|
|
|
|
println!(" env RUSTY_CLAUDE_PERMISSION_MODE=prompt enables interactive tool approval");
|
2026-04-01 04:39:39 +10:00
|
|
|
|
println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]");
|
|
|
|
|
|
println!(" rusty-claude-cli --resume SESSION.json [/compact]");
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
2026-04-01 05:22:56 +10:00
|
|
|
|
use super::{parse_args, resolve_session_target, session_preview, CliAction, DEFAULT_MODEL};
|
|
|
|
|
|
use runtime::{ContentBlock, ConversationMessage, MessageRole, Session};
|
|
|
|
|
|
use std::fs;
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
use std::path::PathBuf;
|
2026-04-01 05:22:56 +10:00
|
|
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
|
2026-04-01 04:39:39 +10:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn defaults_to_repl_when_no_args() {
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
parse_args(&[]).expect("args should parse"),
|
|
|
|
|
|
CliAction::Repl {
|
|
|
|
|
|
model: DEFAULT_MODEL.to_string(),
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn parses_prompt_subcommand() {
|
|
|
|
|
|
let args = vec![
|
|
|
|
|
|
"prompt".to_string(),
|
|
|
|
|
|
"hello".to_string(),
|
|
|
|
|
|
"world".to_string(),
|
|
|
|
|
|
];
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
parse_args(&args).expect("args should parse"),
|
|
|
|
|
|
CliAction::Prompt {
|
|
|
|
|
|
prompt: "hello world".to_string(),
|
|
|
|
|
|
model: DEFAULT_MODEL.to_string(),
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn parses_system_prompt_options() {
|
|
|
|
|
|
let args = vec![
|
|
|
|
|
|
"system-prompt".to_string(),
|
|
|
|
|
|
"--cwd".to_string(),
|
|
|
|
|
|
"/tmp/project".to_string(),
|
|
|
|
|
|
"--date".to_string(),
|
|
|
|
|
|
"2026-04-01".to_string(),
|
|
|
|
|
|
];
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
parse_args(&args).expect("args should parse"),
|
|
|
|
|
|
CliAction::PrintSystemPrompt {
|
|
|
|
|
|
cwd: PathBuf::from("/tmp/project"),
|
|
|
|
|
|
date: "2026-04-01".to_string(),
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn parses_resume_flag_with_slash_command() {
|
|
|
|
|
|
let args = vec![
|
|
|
|
|
|
"--resume".to_string(),
|
|
|
|
|
|
"session.json".to_string(),
|
|
|
|
|
|
"/compact".to_string(),
|
|
|
|
|
|
];
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
parse_args(&args).expect("args should parse"),
|
|
|
|
|
|
CliAction::ResumeSession {
|
|
|
|
|
|
session_path: PathBuf::from("session.json"),
|
|
|
|
|
|
command: Some("/compact".to_string()),
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-04-01 04:39:39 +10:00
|
|
|
|
|
2026-04-01 05:22:56 +10:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn parses_sessions_subcommand() {
|
|
|
|
|
|
let args = vec![
|
|
|
|
|
|
"sessions".to_string(),
|
|
|
|
|
|
"--query".to_string(),
|
|
|
|
|
|
"compact".to_string(),
|
|
|
|
|
|
"--limit".to_string(),
|
|
|
|
|
|
"5".to_string(),
|
|
|
|
|
|
];
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
parse_args(&args).expect("args should parse"),
|
|
|
|
|
|
CliAction::ListSessions {
|
|
|
|
|
|
query: Some("compact".to_string()),
|
|
|
|
|
|
limit: 5,
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn parses_named_resume_subcommand() {
|
|
|
|
|
|
let args = vec![
|
|
|
|
|
|
"resume".to_string(),
|
|
|
|
|
|
"latest".to_string(),
|
|
|
|
|
|
"/compact".to_string(),
|
|
|
|
|
|
];
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
parse_args(&args).expect("args should parse"),
|
|
|
|
|
|
CliAction::ResumeNamed {
|
|
|
|
|
|
target: "latest".to_string(),
|
|
|
|
|
|
command: Some("/compact".to_string()),
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 04:39:39 +10:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn converts_tool_roundtrip_messages() {
|
|
|
|
|
|
let messages = vec![
|
|
|
|
|
|
ConversationMessage::user_text("hello"),
|
|
|
|
|
|
ConversationMessage::assistant(vec![ContentBlock::ToolUse {
|
|
|
|
|
|
id: "tool-1".to_string(),
|
|
|
|
|
|
name: "bash".to_string(),
|
|
|
|
|
|
input: "{\"command\":\"pwd\"}".to_string(),
|
|
|
|
|
|
}]),
|
|
|
|
|
|
ConversationMessage {
|
|
|
|
|
|
role: MessageRole::Tool,
|
|
|
|
|
|
blocks: vec![ContentBlock::ToolResult {
|
|
|
|
|
|
tool_use_id: "tool-1".to_string(),
|
|
|
|
|
|
tool_name: "bash".to_string(),
|
|
|
|
|
|
output: "ok".to_string(),
|
|
|
|
|
|
is_error: false,
|
|
|
|
|
|
}],
|
|
|
|
|
|
usage: None,
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
let converted = super::convert_messages(&messages);
|
|
|
|
|
|
assert_eq!(converted.len(), 3);
|
|
|
|
|
|
assert_eq!(converted[1].role, "assistant");
|
|
|
|
|
|
assert_eq!(converted[2].role, "user");
|
|
|
|
|
|
}
|
2026-04-01 05:22:56 +10:00
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn builds_preview_from_latest_text_block() {
|
|
|
|
|
|
let session = Session {
|
|
|
|
|
|
version: 1,
|
|
|
|
|
|
messages: vec![
|
|
|
|
|
|
ConversationMessage::user_text("first"),
|
|
|
|
|
|
ConversationMessage::assistant(vec![ContentBlock::Text {
|
|
|
|
|
|
text: "latest preview".to_string(),
|
|
|
|
|
|
}]),
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
assert_eq!(session_preview(&session), "latest preview");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn resolves_direct_session_path() {
|
|
|
|
|
|
let unique = SystemTime::now()
|
|
|
|
|
|
.duration_since(UNIX_EPOCH)
|
|
|
|
|
|
.map_or(0, |duration| duration.as_nanos());
|
|
|
|
|
|
let path = std::env::temp_dir().join(format!("rusty-claude-session-{unique}.json"));
|
|
|
|
|
|
fs::write(&path, "{\"version\":1,\"messages\":[]}").expect("temp session");
|
|
|
|
|
|
let resolved = resolve_session_target(path.to_string_lossy().as_ref()).expect("resolve");
|
|
|
|
|
|
assert_eq!(resolved, path);
|
|
|
|
|
|
fs::remove_file(resolved).expect("cleanup");
|
|
|
|
|
|
}
|
feat: merge 2nd round from all rcc/* sessions
- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
2026-04-01 03:43:25 +10:00
|
|
|
|
}
|