mirror of
https://github.com/lWolvesl/claw-code.git
synced 2026-04-02 23:01:52 +08:00
feat: make rusty-claude-cli usable end-to-end
Wire the CLI to the Anthropic client, runtime conversation loop, and MVP in-tree tool executor so prompt mode and the default REPL both execute real turns instead of scaffold-only commands. Constraint: Proxy auth uses ANTHROPIC_AUTH_TOKEN as the primary x-api-key source and may stream extra usage fields Constraint: Must preserve existing scaffold commands while enabling real prompt and REPL flows Rejected: Keep prompt mode on the old scaffold path | does not satisfy end-to-end CLI requirement Rejected: Depend solely on raw SSE message_stop from proxy | proxy/event differences required tolerant parsing plus fallback handling Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep prompt mode tool-free unless the one-shot path is explicitly expanded and reverified against the proxy Tested: cargo test -p api; cargo test -p tools; cargo test -p runtime; cargo test -p rusty-claude-cli; cargo build; cargo run -p rusty-claude-cli -- prompt "say hello"; printf '/quit\n' | cargo run -p rusty-claude-cli -- Not-tested: Full interactive tool_use roundtrip against the proxy in REPL mode
This commit is contained in:
@@ -6,9 +6,16 @@ license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
api = { path = "../api" }
|
||||
commands = { path = "../commands" }
|
||||
compat-harness = { path = "../compat-harness" }
|
||||
crossterm = "0.28"
|
||||
pulldown-cmark = "0.13"
|
||||
runtime = { path = "../runtime" }
|
||||
serde_json = "1"
|
||||
syntect = "5"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "time"] }
|
||||
tools = { path = "../tools" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::io::{self, Write};
|
||||
use std::io::{self, IsTerminal, Write};
|
||||
|
||||
use crossterm::cursor::MoveToColumn;
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
|
||||
@@ -100,6 +100,10 @@ impl LineEditor {
|
||||
}
|
||||
|
||||
pub fn read_line(&self) -> io::Result<Option<String>> {
|
||||
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
|
||||
return self.read_line_fallback();
|
||||
}
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
let mut input = InputBuffer::new();
|
||||
@@ -125,6 +129,23 @@ impl LineEditor {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_line_fallback(&self) -> io::Result<Option<String>> {
|
||||
let mut stdout = io::stdout();
|
||||
write!(stdout, "{}", self.prompt)?;
|
||||
stdout.flush()?;
|
||||
|
||||
let mut buffer = String::new();
|
||||
let bytes_read = io::stdin().read_line(&mut buffer)?;
|
||||
if bytes_read == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
while matches!(buffer.chars().last(), Some('\n' | '\r')) {
|
||||
buffer.pop();
|
||||
}
|
||||
Ok(Some(buffer))
|
||||
}
|
||||
|
||||
fn handle_key(key: KeyEvent, input: &mut InputBuffer) -> EditorAction {
|
||||
match key {
|
||||
KeyEvent {
|
||||
|
||||
@@ -1,28 +1,52 @@
|
||||
mod input;
|
||||
mod render;
|
||||
|
||||
use std::env;
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use api::{
|
||||
AnthropicClient, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
|
||||
MessageResponse, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
|
||||
ToolResultContentBlock,
|
||||
};
|
||||
|
||||
use commands::handle_slash_command;
|
||||
use compat_harness::{extract_manifest, UpstreamPaths};
|
||||
use runtime::{load_system_prompt, BootstrapPlan, CompactionConfig, Session};
|
||||
use render::{Spinner, TerminalRenderer};
|
||||
use runtime::{
|
||||
load_system_prompt, ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ContentBlock,
|
||||
ConversationMessage, ConversationRuntime, MessageRole, PermissionMode, PermissionPolicy,
|
||||
RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
|
||||
};
|
||||
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";
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = env::args().skip(1).collect();
|
||||
if let Err(error) = run() {
|
||||
eprintln!("{error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
match parse_args(&args) {
|
||||
Ok(CliAction::DumpManifests) => dump_manifests(),
|
||||
Ok(CliAction::BootstrapPlan) => print_bootstrap_plan(),
|
||||
Ok(CliAction::PrintSystemPrompt { cwd, date }) => print_system_prompt(cwd, date),
|
||||
Ok(CliAction::ResumeSession {
|
||||
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 {
|
||||
session_path,
|
||||
command,
|
||||
}) => resume_session(&session_path, command),
|
||||
Ok(CliAction::Help) => print_help(),
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
print_help();
|
||||
std::process::exit(2);
|
||||
}
|
||||
} => resume_session(&session_path, command),
|
||||
CliAction::Prompt { prompt, model } => LiveCli::new(model, false)?.run_turn(&prompt)?,
|
||||
CliAction::Repl { model } => run_repl(model)?,
|
||||
CliAction::Help => print_help(),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -37,33 +61,69 @@ enum CliAction {
|
||||
session_path: PathBuf,
|
||||
command: Option<String>,
|
||||
},
|
||||
Prompt {
|
||||
prompt: String,
|
||||
model: String,
|
||||
},
|
||||
Repl {
|
||||
model: String,
|
||||
},
|
||||
Help,
|
||||
}
|
||||
|
||||
fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
if args.is_empty() {
|
||||
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())?;
|
||||
model = value.clone();
|
||||
index += 2;
|
||||
}
|
||||
flag if flag.starts_with("--model=") => {
|
||||
model = flag[8..].to_string();
|
||||
index += 1;
|
||||
}
|
||||
other => {
|
||||
rest.push(other.to_string());
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rest.is_empty() {
|
||||
return Ok(CliAction::Repl { model });
|
||||
}
|
||||
if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
|
||||
return Ok(CliAction::Help);
|
||||
}
|
||||
|
||||
if matches!(args.first().map(String::as_str), Some("--help" | "-h")) {
|
||||
return Ok(CliAction::Help);
|
||||
if rest.first().map(String::as_str) == Some("--resume") {
|
||||
return parse_resume_args(&rest[1..]);
|
||||
}
|
||||
|
||||
if args.first().map(String::as_str) == Some("--resume") {
|
||||
return parse_resume_args(&args[1..]);
|
||||
}
|
||||
|
||||
match args[0].as_str() {
|
||||
match rest[0].as_str() {
|
||||
"dump-manifests" => Ok(CliAction::DumpManifests),
|
||||
"bootstrap-plan" => Ok(CliAction::BootstrapPlan),
|
||||
"system-prompt" => parse_system_prompt_args(&args[1..]),
|
||||
"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 })
|
||||
}
|
||||
other => Err(format!("unknown subcommand: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
|
||||
let mut cwd = env::current_dir().map_err(|error| error.to_string())?;
|
||||
let mut date = "2026-03-31".to_string();
|
||||
let mut date = DEFAULT_DATE.to_string();
|
||||
let mut index = 0;
|
||||
|
||||
while index < args.len() {
|
||||
@@ -121,7 +181,7 @@ fn dump_manifests() {
|
||||
}
|
||||
|
||||
fn print_bootstrap_plan() {
|
||||
for phase in BootstrapPlan::claude_code_default().phases() {
|
||||
for phase in runtime::BootstrapPlan::claude_code_default().phases() {
|
||||
println!("- {phase:?}");
|
||||
}
|
||||
}
|
||||
@@ -178,24 +238,444 @@ fn resume_session(session_path: &Path, command: Option<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
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:");
|
||||
println!(" /help Show help");
|
||||
println!(" /status Show session status");
|
||||
println!(" /compact Compact session history");
|
||||
println!(" /exit Quit the REPL");
|
||||
}
|
||||
"/status" => cli.print_status(),
|
||||
"/compact" => cli.compact()?,
|
||||
_ => cli.run_turn(trimmed)?,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct LiveCli {
|
||||
model: String,
|
||||
system_prompt: Vec<String>,
|
||||
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
|
||||
}
|
||||
|
||||
impl LiveCli {
|
||||
fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let system_prompt = build_system_prompt()?;
|
||||
let runtime = build_runtime(
|
||||
Session::new(),
|
||||
model.clone(),
|
||||
system_prompt.clone(),
|
||||
enable_tools,
|
||||
)?;
|
||||
Ok(Self {
|
||||
model,
|
||||
system_prompt,
|
||||
runtime,
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
)?;
|
||||
let result = self.runtime.run_turn(input, None);
|
||||
match result {
|
||||
Ok(_) => {
|
||||
spinner.finish(
|
||||
"Claude response complete",
|
||||
TerminalRenderer::new().color_theme(),
|
||||
&mut stdout,
|
||||
)?;
|
||||
println!();
|
||||
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!(
|
||||
"status: messages={} turns={} input_tokens={} output_tokens={}",
|
||||
self.runtime.session().messages.len(),
|
||||
self.runtime.usage().turns(),
|
||||
usage.input_tokens,
|
||||
usage.output_tokens
|
||||
);
|
||||
}
|
||||
|
||||
fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let result = self.runtime.compact(CompactionConfig::default());
|
||||
let removed = result.removed_message_count;
|
||||
self.runtime = build_runtime(
|
||||
result.compacted_session,
|
||||
self.model.clone(),
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
)?;
|
||||
println!("Compacted {removed} messages.");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
|
||||
{
|
||||
Ok(ConversationRuntime::new(
|
||||
session,
|
||||
AnthropicRuntimeClient::new(model, enable_tools)?,
|
||||
CliToolExecutor::new(),
|
||||
permission_policy_from_env(),
|
||||
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 {
|
||||
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}")
|
||||
.and_then(|_| stdout.flush())
|
||||
.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}")
|
||||
.and_then(|_| out.flush())
|
||||
.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)
|
||||
}
|
||||
|
||||
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) => {
|
||||
let markdown = format!("### Tool `{tool_name}`\n\n```json\n{output}\n```\n");
|
||||
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),
|
||||
_ => 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()
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
println!("rusty-claude-cli");
|
||||
println!();
|
||||
println!("Current scaffold commands:");
|
||||
println!("Usage:");
|
||||
println!(" rusty-claude-cli [--model MODEL] Start interactive REPL");
|
||||
println!(
|
||||
" dump-manifests Read upstream TS sources and print extracted counts"
|
||||
" rusty-claude-cli [--model MODEL] prompt TEXT Send one prompt and stream the response"
|
||||
);
|
||||
println!(" bootstrap-plan Print the current bootstrap phase skeleton");
|
||||
println!(" system-prompt [--cwd PATH] [--date YYYY-MM-DD]");
|
||||
println!(" Build a Claude-style system prompt from CLAUDE.md and config files");
|
||||
println!(" --resume SESSION.json [/compact] Restore a saved session and optionally run a slash command");
|
||||
println!(" rusty-claude-cli dump-manifests");
|
||||
println!(" rusty-claude-cli bootstrap-plan");
|
||||
println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]");
|
||||
println!(" rusty-claude-cli --resume SESSION.json [/compact]");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_args, CliAction};
|
||||
use super::{parse_args, CliAction, DEFAULT_MODEL};
|
||||
use runtime::{ContentBlock, ConversationMessage, MessageRole};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[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(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_system_prompt_options() {
|
||||
let args = vec![
|
||||
@@ -229,4 +709,31 @@ mod tests {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user