|
@@ -5,6 +5,7 @@ use std::env;
|
|
|
use std::fs;
|
|
use std::fs;
|
|
|
use std::io::{self, Write};
|
|
use std::io::{self, Write};
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
+use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
|
|
|
|
use api::{
|
|
use api::{
|
|
|
AnthropicClient, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
|
|
AnthropicClient, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
|
|
@@ -21,16 +22,23 @@ use runtime::{
|
|
|
PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, Session, TokenUsage, ToolError,
|
|
PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, Session, TokenUsage, ToolError,
|
|
|
ToolExecutor, UsageTracker,
|
|
ToolExecutor, UsageTracker,
|
|
|
};
|
|
};
|
|
|
|
|
+use serde_json::json;
|
|
|
use tools::{execute_tool, mvp_tool_specs};
|
|
use tools::{execute_tool, mvp_tool_specs};
|
|
|
|
|
|
|
|
const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
|
|
const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
|
|
|
const DEFAULT_MAX_TOKENS: u32 = 32;
|
|
const DEFAULT_MAX_TOKENS: u32 = 32;
|
|
|
const DEFAULT_DATE: &str = "2026-03-31";
|
|
const DEFAULT_DATE: &str = "2026-03-31";
|
|
|
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
|
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
|
|
|
|
+const BUILD_TARGET: Option<&str> = option_env!("TARGET");
|
|
|
|
|
+const GIT_SHA: Option<&str> = option_env!("GIT_SHA");
|
|
|
|
|
|
|
|
fn main() {
|
|
fn main() {
|
|
|
if let Err(error) = run() {
|
|
if let Err(error) = run() {
|
|
|
- eprintln!("{error}");
|
|
|
|
|
|
|
+ eprintln!(
|
|
|
|
|
+ "error: {error}
|
|
|
|
|
+
|
|
|
|
|
+Run `rusty-claude-cli --help` for usage."
|
|
|
|
|
+ );
|
|
|
std::process::exit(1);
|
|
std::process::exit(1);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -45,10 +53,13 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
session_path,
|
|
session_path,
|
|
|
commands,
|
|
commands,
|
|
|
} => resume_session(&session_path, &commands),
|
|
} => resume_session(&session_path, &commands),
|
|
|
- CliAction::Prompt { prompt, model } => LiveCli::new(model, false)?.run_turn(&prompt)?,
|
|
|
|
|
|
|
+ CliAction::Prompt {
|
|
|
|
|
+ prompt,
|
|
|
|
|
+ model,
|
|
|
|
|
+ output_format,
|
|
|
|
|
+ } => LiveCli::new(model, false)?.run_turn_with_output(&prompt, output_format)?,
|
|
|
CliAction::Repl { model } => run_repl(model)?,
|
|
CliAction::Repl { model } => run_repl(model)?,
|
|
|
CliAction::Help => print_help(),
|
|
CliAction::Help => print_help(),
|
|
|
- CliAction::Version => print_version(),
|
|
|
|
|
}
|
|
}
|
|
|
Ok(())
|
|
Ok(())
|
|
|
}
|
|
}
|
|
@@ -68,16 +79,36 @@ enum CliAction {
|
|
|
Prompt {
|
|
Prompt {
|
|
|
prompt: String,
|
|
prompt: String,
|
|
|
model: String,
|
|
model: String,
|
|
|
|
|
+ output_format: CliOutputFormat,
|
|
|
},
|
|
},
|
|
|
Repl {
|
|
Repl {
|
|
|
model: String,
|
|
model: String,
|
|
|
},
|
|
},
|
|
|
|
|
+ // prompt-mode formatting is only supported for non-interactive runs
|
|
|
Help,
|
|
Help,
|
|
|
- Version,
|
|
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
+enum CliOutputFormat {
|
|
|
|
|
+ Text,
|
|
|
|
|
+ Json,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+impl CliOutputFormat {
|
|
|
|
|
+ fn parse(value: &str) -> Result<Self, String> {
|
|
|
|
|
+ match value {
|
|
|
|
|
+ "text" => Ok(Self::Text),
|
|
|
|
|
+ "json" => Ok(Self::Json),
|
|
|
|
|
+ other => Err(format!(
|
|
|
|
|
+ "unsupported value for --output-format: {other} (expected text or json)"
|
|
|
|
|
+ )),
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|
fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|
|
let mut model = DEFAULT_MODEL.to_string();
|
|
let mut model = DEFAULT_MODEL.to_string();
|
|
|
|
|
+ let mut output_format = CliOutputFormat::Text;
|
|
|
let mut rest = Vec::new();
|
|
let mut rest = Vec::new();
|
|
|
let mut index = 0;
|
|
let mut index = 0;
|
|
|
|
|
|
|
@@ -94,6 +125,17 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|
|
model = flag[8..].to_string();
|
|
model = flag[8..].to_string();
|
|
|
index += 1;
|
|
index += 1;
|
|
|
}
|
|
}
|
|
|
|
|
+ "--output-format" => {
|
|
|
|
|
+ let value = args
|
|
|
|
|
+ .get(index + 1)
|
|
|
|
|
+ .ok_or_else(|| "missing value for --output-format".to_string())?;
|
|
|
|
|
+ output_format = CliOutputFormat::parse(value)?;
|
|
|
|
|
+ index += 2;
|
|
|
|
|
+ }
|
|
|
|
|
+ flag if flag.starts_with("--output-format=") => {
|
|
|
|
|
+ output_format = CliOutputFormat::parse(&flag[16..])?;
|
|
|
|
|
+ index += 1;
|
|
|
|
|
+ }
|
|
|
other => {
|
|
other => {
|
|
|
rest.push(other.to_string());
|
|
rest.push(other.to_string());
|
|
|
index += 1;
|
|
index += 1;
|
|
@@ -107,9 +149,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|
|
if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
|
|
if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
|
|
|
return Ok(CliAction::Help);
|
|
return Ok(CliAction::Help);
|
|
|
}
|
|
}
|
|
|
- if matches!(rest.first().map(String::as_str), Some("--version" | "-V")) {
|
|
|
|
|
- return Ok(CliAction::Version);
|
|
|
|
|
- }
|
|
|
|
|
if rest.first().map(String::as_str) == Some("--resume") {
|
|
if rest.first().map(String::as_str) == Some("--resume") {
|
|
|
return parse_resume_args(&rest[1..]);
|
|
return parse_resume_args(&rest[1..]);
|
|
|
}
|
|
}
|
|
@@ -123,8 +162,17 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|
|
if prompt.trim().is_empty() {
|
|
if prompt.trim().is_empty() {
|
|
|
return Err("prompt subcommand requires a prompt string".to_string());
|
|
return Err("prompt subcommand requires a prompt string".to_string());
|
|
|
}
|
|
}
|
|
|
- Ok(CliAction::Prompt { prompt, model })
|
|
|
|
|
|
|
+ Ok(CliAction::Prompt {
|
|
|
|
|
+ prompt,
|
|
|
|
|
+ model,
|
|
|
|
|
+ output_format,
|
|
|
|
|
+ })
|
|
|
}
|
|
}
|
|
|
|
|
+ other if !other.starts_with('/') => Ok(CliAction::Prompt {
|
|
|
|
|
+ prompt: rest.join(" "),
|
|
|
|
|
+ model,
|
|
|
|
|
+ output_format,
|
|
|
|
|
+ }),
|
|
|
other => Err(format!("unknown subcommand: {other}")),
|
|
other => Err(format!("unknown subcommand: {other}")),
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -447,6 +495,7 @@ fn find_git_root() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
|
|
Ok(PathBuf::from(path))
|
|
Ok(PathBuf::from(path))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+#[allow(clippy::too_many_lines)]
|
|
|
fn run_resume_command(
|
|
fn run_resume_command(
|
|
|
session_path: &Path,
|
|
session_path: &Path,
|
|
|
session: &Session,
|
|
session: &Session,
|
|
@@ -531,9 +580,30 @@ fn run_resume_command(
|
|
|
session: session.clone(),
|
|
session: session.clone(),
|
|
|
message: Some(init_claude_md()?),
|
|
message: Some(init_claude_md()?),
|
|
|
}),
|
|
}),
|
|
|
|
|
+ SlashCommand::Diff => Ok(ResumeCommandOutcome {
|
|
|
|
|
+ session: session.clone(),
|
|
|
|
|
+ message: Some(render_diff_report()?),
|
|
|
|
|
+ }),
|
|
|
|
|
+ SlashCommand::Version => Ok(ResumeCommandOutcome {
|
|
|
|
|
+ session: session.clone(),
|
|
|
|
|
+ message: Some(render_version_report()),
|
|
|
|
|
+ }),
|
|
|
|
|
+ SlashCommand::Export { path } => {
|
|
|
|
|
+ let export_path = resolve_export_path(path.as_deref(), session)?;
|
|
|
|
|
+ fs::write(&export_path, render_export_text(session))?;
|
|
|
|
|
+ Ok(ResumeCommandOutcome {
|
|
|
|
|
+ session: session.clone(),
|
|
|
|
|
+ message: Some(format!(
|
|
|
|
|
+ "Export\n Result wrote transcript\n File {}\n Messages {}",
|
|
|
|
|
+ export_path.display(),
|
|
|
|
|
+ session.messages.len(),
|
|
|
|
|
+ )),
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
SlashCommand::Resume { .. }
|
|
SlashCommand::Resume { .. }
|
|
|
| SlashCommand::Model { .. }
|
|
| SlashCommand::Model { .. }
|
|
|
| SlashCommand::Permissions { .. }
|
|
| SlashCommand::Permissions { .. }
|
|
|
|
|
+ | SlashCommand::Session { .. }
|
|
|
| SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
|
|
| SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -541,8 +611,7 @@ fn run_resume_command(
|
|
|
fn run_repl(model: String) -> Result<(), Box<dyn std::error::Error>> {
|
|
fn run_repl(model: String) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
let mut cli = LiveCli::new(model, true)?;
|
|
let mut cli = LiveCli::new(model, true)?;
|
|
|
let editor = input::LineEditor::new("› ");
|
|
let editor = input::LineEditor::new("› ");
|
|
|
- println!("Rusty Claude CLI interactive mode");
|
|
|
|
|
- println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
|
|
|
|
|
|
|
+ println!("{}", cli.startup_banner());
|
|
|
|
|
|
|
|
while let Some(input) = editor.read_line()? {
|
|
while let Some(input) = editor.read_line()? {
|
|
|
let trimmed = input.trim();
|
|
let trimmed = input.trim();
|
|
@@ -562,26 +631,57 @@ fn run_repl(model: String) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
Ok(())
|
|
Ok(())
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+#[derive(Debug, Clone)]
|
|
|
|
|
+struct SessionHandle {
|
|
|
|
|
+ id: String,
|
|
|
|
|
+ path: PathBuf,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+#[derive(Debug, Clone)]
|
|
|
|
|
+struct ManagedSessionSummary {
|
|
|
|
|
+ id: String,
|
|
|
|
|
+ path: PathBuf,
|
|
|
|
|
+ modified_epoch_secs: u64,
|
|
|
|
|
+ message_count: usize,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
struct LiveCli {
|
|
struct LiveCli {
|
|
|
model: String,
|
|
model: String,
|
|
|
system_prompt: Vec<String>,
|
|
system_prompt: Vec<String>,
|
|
|
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
|
|
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
|
|
|
|
|
+ session: SessionHandle,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
impl LiveCli {
|
|
impl LiveCli {
|
|
|
fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> {
|
|
fn new(model: String, enable_tools: bool) -> 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 runtime = build_runtime(
|
|
let runtime = build_runtime(
|
|
|
Session::new(),
|
|
Session::new(),
|
|
|
model.clone(),
|
|
model.clone(),
|
|
|
system_prompt.clone(),
|
|
system_prompt.clone(),
|
|
|
enable_tools,
|
|
enable_tools,
|
|
|
)?;
|
|
)?;
|
|
|
- Ok(Self {
|
|
|
|
|
|
|
+ let cli = Self {
|
|
|
model,
|
|
model,
|
|
|
system_prompt,
|
|
system_prompt,
|
|
|
runtime,
|
|
runtime,
|
|
|
- })
|
|
|
|
|
|
|
+ session,
|
|
|
|
|
+ };
|
|
|
|
|
+ cli.persist_session()?;
|
|
|
|
|
+ Ok(cli)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn startup_banner(&self) -> String {
|
|
|
|
|
+ format!(
|
|
|
|
|
+ "Rusty Claude CLI\n Model {}\n Working directory {}\n Session {}\n\nType /help for commands. Shift+Enter or Ctrl+J inserts a newline.",
|
|
|
|
|
+ self.model,
|
|
|
|
|
+ env::current_dir().map_or_else(
|
|
|
|
|
+ |_| "<unknown>".to_string(),
|
|
|
|
|
+ |path| path.display().to_string(),
|
|
|
|
|
+ ),
|
|
|
|
|
+ self.session.id,
|
|
|
|
|
+ )
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
|
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
|
@@ -601,6 +701,7 @@ impl LiveCli {
|
|
|
&mut stdout,
|
|
&mut stdout,
|
|
|
)?;
|
|
)?;
|
|
|
println!();
|
|
println!();
|
|
|
|
|
+ self.persist_session()?;
|
|
|
Ok(())
|
|
Ok(())
|
|
|
}
|
|
}
|
|
|
Err(error) => {
|
|
Err(error) => {
|
|
@@ -614,6 +715,60 @@ impl LiveCli {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ fn run_turn_with_output(
|
|
|
|
|
+ &mut self,
|
|
|
|
|
+ input: &str,
|
|
|
|
|
+ output_format: CliOutputFormat,
|
|
|
|
|
+ ) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
|
|
+ match output_format {
|
|
|
|
|
+ CliOutputFormat::Text => self.run_turn(input),
|
|
|
|
|
+ CliOutputFormat::Json => self.run_prompt_json(input),
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn run_prompt_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
|
|
+ let client = AnthropicClient::from_env()?;
|
|
|
|
|
+ let request = MessageRequest {
|
|
|
|
|
+ model: self.model.clone(),
|
|
|
|
|
+ max_tokens: DEFAULT_MAX_TOKENS,
|
|
|
|
|
+ messages: vec![InputMessage {
|
|
|
|
|
+ role: "user".to_string(),
|
|
|
|
|
+ content: vec![InputContentBlock::Text {
|
|
|
|
|
+ text: input.to_string(),
|
|
|
|
|
+ }],
|
|
|
|
|
+ }],
|
|
|
|
|
+ system: (!self.system_prompt.is_empty()).then(|| self.system_prompt.join("\n\n")),
|
|
|
|
|
+ tools: None,
|
|
|
|
|
+ tool_choice: None,
|
|
|
|
|
+ stream: false,
|
|
|
|
|
+ };
|
|
|
|
|
+ let runtime = tokio::runtime::Runtime::new()?;
|
|
|
|
|
+ let response = runtime.block_on(client.send_message(&request))?;
|
|
|
|
|
+ let text = response
|
|
|
|
|
+ .content
|
|
|
|
|
+ .iter()
|
|
|
|
|
+ .filter_map(|block| match block {
|
|
|
|
|
+ OutputContentBlock::Text { text } => Some(text.as_str()),
|
|
|
|
|
+ OutputContentBlock::ToolUse { .. } => None,
|
|
|
|
|
+ })
|
|
|
|
|
+ .collect::<Vec<_>>()
|
|
|
|
|
+ .join("");
|
|
|
|
|
+ println!(
|
|
|
|
|
+ "{}",
|
|
|
|
|
+ json!({
|
|
|
|
|
+ "message": text,
|
|
|
|
|
+ "model": self.model,
|
|
|
|
|
+ "usage": {
|
|
|
|
|
+ "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,
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ );
|
|
|
|
|
+ Ok(())
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
fn handle_repl_command(
|
|
fn handle_repl_command(
|
|
|
&mut self,
|
|
&mut self,
|
|
|
command: SlashCommand,
|
|
command: SlashCommand,
|
|
@@ -630,11 +785,22 @@ impl LiveCli {
|
|
|
SlashCommand::Config { section } => Self::print_config(section.as_deref())?,
|
|
SlashCommand::Config { section } => Self::print_config(section.as_deref())?,
|
|
|
SlashCommand::Memory => Self::print_memory()?,
|
|
SlashCommand::Memory => Self::print_memory()?,
|
|
|
SlashCommand::Init => Self::run_init()?,
|
|
SlashCommand::Init => Self::run_init()?,
|
|
|
|
|
+ SlashCommand::Diff => Self::print_diff()?,
|
|
|
|
|
+ SlashCommand::Version => Self::print_version(),
|
|
|
|
|
+ SlashCommand::Export { path } => self.export_session(path.as_deref())?,
|
|
|
|
|
+ SlashCommand::Session { action, target } => {
|
|
|
|
|
+ self.handle_session_command(action.as_deref(), target.as_deref())?;
|
|
|
|
|
+ }
|
|
|
SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"),
|
|
SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"),
|
|
|
}
|
|
}
|
|
|
Ok(())
|
|
Ok(())
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
|
|
+ self.runtime.session().save_to_path(&self.session.path)?;
|
|
|
|
|
+ Ok(())
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
fn print_status(&self) {
|
|
fn print_status(&self) {
|
|
|
let cumulative = self.runtime.usage().cumulative_usage();
|
|
let cumulative = self.runtime.usage().cumulative_usage();
|
|
|
let latest = self.runtime.usage().current_turn_usage();
|
|
let latest = self.runtime.usage().current_turn_usage();
|
|
@@ -650,7 +816,7 @@ impl LiveCli {
|
|
|
estimated_tokens: self.runtime.estimated_tokens(),
|
|
estimated_tokens: self.runtime.estimated_tokens(),
|
|
|
},
|
|
},
|
|
|
permission_mode_label(),
|
|
permission_mode_label(),
|
|
|
- &status_context(None).expect("status context should load"),
|
|
|
|
|
|
|
+ &status_context(Some(&self.session.path)).expect("status context should load"),
|
|
|
)
|
|
)
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
@@ -685,6 +851,7 @@ impl LiveCli {
|
|
|
let message_count = session.messages.len();
|
|
let message_count = session.messages.len();
|
|
|
self.runtime = build_runtime(session, model.clone(), self.system_prompt.clone(), true)?;
|
|
self.runtime = build_runtime(session, model.clone(), self.system_prompt.clone(), true)?;
|
|
|
self.model.clone_from(&model);
|
|
self.model.clone_from(&model);
|
|
|
|
|
+ self.persist_session()?;
|
|
|
println!(
|
|
println!(
|
|
|
"{}",
|
|
"{}",
|
|
|
format_model_switch_report(&previous, &model, message_count)
|
|
format_model_switch_report(&previous, &model, message_count)
|
|
@@ -700,7 +867,7 @@ impl LiveCli {
|
|
|
|
|
|
|
|
let normalized = normalize_permission_mode(&mode).ok_or_else(|| {
|
|
let normalized = normalize_permission_mode(&mode).ok_or_else(|| {
|
|
|
format!(
|
|
format!(
|
|
|
- "Unsupported permission mode '{mode}'. Use read-only, workspace-write, or danger-full-access."
|
|
|
|
|
|
|
+ "unsupported permission mode '{mode}'. Use read-only, workspace-write, or danger-full-access."
|
|
|
)
|
|
)
|
|
|
})?;
|
|
})?;
|
|
|
|
|
|
|
@@ -718,6 +885,7 @@ impl LiveCli {
|
|
|
true,
|
|
true,
|
|
|
normalized,
|
|
normalized,
|
|
|
)?;
|
|
)?;
|
|
|
|
|
+ self.persist_session()?;
|
|
|
println!(
|
|
println!(
|
|
|
"{}",
|
|
"{}",
|
|
|
format_permissions_switch_report(&previous, normalized)
|
|
format_permissions_switch_report(&previous, normalized)
|
|
@@ -733,6 +901,7 @@ impl LiveCli {
|
|
|
return Ok(());
|
|
return Ok(());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ self.session = create_managed_session_handle()?;
|
|
|
self.runtime = build_runtime_with_permission_mode(
|
|
self.runtime = build_runtime_with_permission_mode(
|
|
|
Session::new(),
|
|
Session::new(),
|
|
|
self.model.clone(),
|
|
self.model.clone(),
|
|
@@ -740,13 +909,12 @@ impl LiveCli {
|
|
|
true,
|
|
true,
|
|
|
permission_mode_label(),
|
|
permission_mode_label(),
|
|
|
)?;
|
|
)?;
|
|
|
|
|
+ self.persist_session()?;
|
|
|
println!(
|
|
println!(
|
|
|
- "Session cleared
|
|
|
|
|
- Mode fresh session
|
|
|
|
|
- Preserved model {}
|
|
|
|
|
- Permission mode {}",
|
|
|
|
|
|
|
+ "Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}",
|
|
|
self.model,
|
|
self.model,
|
|
|
- permission_mode_label()
|
|
|
|
|
|
|
+ permission_mode_label(),
|
|
|
|
|
+ self.session.id,
|
|
|
);
|
|
);
|
|
|
Ok(())
|
|
Ok(())
|
|
|
}
|
|
}
|
|
@@ -760,12 +928,13 @@ impl LiveCli {
|
|
|
&mut self,
|
|
&mut self,
|
|
|
session_path: Option<String>,
|
|
session_path: Option<String>,
|
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
- let Some(session_path) = session_path else {
|
|
|
|
|
|
|
+ let Some(session_ref) = session_path else {
|
|
|
println!("Usage: /resume <session-path>");
|
|
println!("Usage: /resume <session-path>");
|
|
|
return Ok(());
|
|
return Ok(());
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- let session = Session::load_from_path(&session_path)?;
|
|
|
|
|
|
|
+ let handle = resolve_session_reference(&session_ref)?;
|
|
|
|
|
+ let session = Session::load_from_path(&handle.path)?;
|
|
|
let message_count = session.messages.len();
|
|
let message_count = session.messages.len();
|
|
|
self.runtime = build_runtime_with_permission_mode(
|
|
self.runtime = build_runtime_with_permission_mode(
|
|
|
session,
|
|
session,
|
|
@@ -774,9 +943,15 @@ impl LiveCli {
|
|
|
true,
|
|
true,
|
|
|
permission_mode_label(),
|
|
permission_mode_label(),
|
|
|
)?;
|
|
)?;
|
|
|
|
|
+ self.session = handle;
|
|
|
|
|
+ self.persist_session()?;
|
|
|
println!(
|
|
println!(
|
|
|
"{}",
|
|
"{}",
|
|
|
- format_resume_report(&session_path, message_count, self.runtime.usage().turns())
|
|
|
|
|
|
|
+ format_resume_report(
|
|
|
|
|
+ &self.session.path.display().to_string(),
|
|
|
|
|
+ message_count,
|
|
|
|
|
+ self.runtime.usage().turns(),
|
|
|
|
|
+ )
|
|
|
);
|
|
);
|
|
|
Ok(())
|
|
Ok(())
|
|
|
}
|
|
}
|
|
@@ -796,6 +971,71 @@ impl LiveCli {
|
|
|
Ok(())
|
|
Ok(())
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ fn print_diff() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
|
|
+ println!("{}", render_diff_report()?);
|
|
|
|
|
+ Ok(())
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn print_version() {
|
|
|
|
|
+ println!("{}", render_version_report());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn export_session(
|
|
|
|
|
+ &self,
|
|
|
|
|
+ requested_path: Option<&str>,
|
|
|
|
|
+ ) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
|
|
+ let export_path = resolve_export_path(requested_path, self.runtime.session())?;
|
|
|
|
|
+ fs::write(&export_path, render_export_text(self.runtime.session()))?;
|
|
|
|
|
+ println!(
|
|
|
|
|
+ "Export\n Result wrote transcript\n File {}\n Messages {}",
|
|
|
|
|
+ export_path.display(),
|
|
|
|
|
+ self.runtime.session().messages.len(),
|
|
|
|
|
+ );
|
|
|
|
|
+ Ok(())
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn handle_session_command(
|
|
|
|
|
+ &mut self,
|
|
|
|
|
+ action: Option<&str>,
|
|
|
|
|
+ target: Option<&str>,
|
|
|
|
|
+ ) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
|
|
+ match action {
|
|
|
|
|
+ None | Some("list") => {
|
|
|
|
|
+ println!("{}", render_session_list(&self.session.id)?);
|
|
|
|
|
+ Ok(())
|
|
|
|
|
+ }
|
|
|
|
|
+ Some("switch") => {
|
|
|
|
|
+ let Some(target) = target else {
|
|
|
|
|
+ println!("Usage: /session switch <session-id>");
|
|
|
|
|
+ return Ok(());
|
|
|
|
|
+ };
|
|
|
|
|
+ let handle = resolve_session_reference(target)?;
|
|
|
|
|
+ let session = Session::load_from_path(&handle.path)?;
|
|
|
|
|
+ let message_count = session.messages.len();
|
|
|
|
|
+ self.runtime = build_runtime_with_permission_mode(
|
|
|
|
|
+ session,
|
|
|
|
|
+ self.model.clone(),
|
|
|
|
|
+ self.system_prompt.clone(),
|
|
|
|
|
+ true,
|
|
|
|
|
+ permission_mode_label(),
|
|
|
|
|
+ )?;
|
|
|
|
|
+ self.session = handle;
|
|
|
|
|
+ self.persist_session()?;
|
|
|
|
|
+ println!(
|
|
|
|
|
+ "Session switched\n Active session {}\n File {}\n Messages {}",
|
|
|
|
|
+ self.session.id,
|
|
|
|
|
+ self.session.path.display(),
|
|
|
|
|
+ message_count,
|
|
|
|
|
+ );
|
|
|
|
|
+ Ok(())
|
|
|
|
|
+ }
|
|
|
|
|
+ Some(other) => {
|
|
|
|
|
+ println!("Unknown /session action '{other}'. Use /session list or /session switch <session-id>.");
|
|
|
|
|
+ Ok(())
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
|
fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
|
|
let result = self.runtime.compact(CompactionConfig::default());
|
|
let result = self.runtime.compact(CompactionConfig::default());
|
|
|
let removed = result.removed_message_count;
|
|
let removed = result.removed_message_count;
|
|
@@ -808,11 +1048,112 @@ impl LiveCli {
|
|
|
true,
|
|
true,
|
|
|
permission_mode_label(),
|
|
permission_mode_label(),
|
|
|
)?;
|
|
)?;
|
|
|
|
|
+ self.persist_session()?;
|
|
|
println!("{}", format_compact_report(removed, kept, skipped));
|
|
println!("{}", format_compact_report(removed, kept, skipped));
|
|
|
Ok(())
|
|
Ok(())
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
|
|
|
|
+ let cwd = env::current_dir()?;
|
|
|
|
|
+ let path = cwd.join(".claude").join("sessions");
|
|
|
|
|
+ fs::create_dir_all(&path)?;
|
|
|
|
|
+ Ok(path)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn create_managed_session_handle() -> Result<SessionHandle, Box<dyn std::error::Error>> {
|
|
|
|
|
+ let id = generate_session_id();
|
|
|
|
|
+ let path = sessions_dir()?.join(format!("{id}.json"));
|
|
|
|
|
+ Ok(SessionHandle { id, path })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn generate_session_id() -> String {
|
|
|
|
|
+ let millis = SystemTime::now()
|
|
|
|
|
+ .duration_since(UNIX_EPOCH)
|
|
|
|
|
+ .map(|duration| duration.as_millis())
|
|
|
|
|
+ .unwrap_or_default();
|
|
|
|
|
+ format!("session-{millis}")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> {
|
|
|
|
|
+ let direct = PathBuf::from(reference);
|
|
|
|
|
+ let path = if direct.exists() {
|
|
|
|
|
+ direct
|
|
|
|
|
+ } else {
|
|
|
|
|
+ sessions_dir()?.join(format!("{reference}.json"))
|
|
|
|
|
+ };
|
|
|
|
|
+ if !path.exists() {
|
|
|
|
|
+ return Err(format!("session not found: {reference}").into());
|
|
|
|
|
+ }
|
|
|
|
|
+ let id = path
|
|
|
|
|
+ .file_stem()
|
|
|
|
|
+ .and_then(|value| value.to_str())
|
|
|
|
|
+ .unwrap_or(reference)
|
|
|
|
|
+ .to_string();
|
|
|
|
|
+ Ok(SessionHandle { id, path })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::error::Error>> {
|
|
|
|
|
+ let mut sessions = Vec::new();
|
|
|
|
|
+ for entry in fs::read_dir(sessions_dir()?)? {
|
|
|
|
|
+ let entry = entry?;
|
|
|
|
|
+ let path = entry.path();
|
|
|
|
|
+ if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ let metadata = entry.metadata()?;
|
|
|
|
|
+ let modified_epoch_secs = metadata
|
|
|
|
|
+ .modified()
|
|
|
|
|
+ .ok()
|
|
|
|
|
+ .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
|
|
|
|
+ .map(|duration| duration.as_secs())
|
|
|
|
|
+ .unwrap_or_default();
|
|
|
|
|
+ let message_count = Session::load_from_path(&path)
|
|
|
|
|
+ .map(|session| session.messages.len())
|
|
|
|
|
+ .unwrap_or_default();
|
|
|
|
|
+ let id = path
|
|
|
|
|
+ .file_stem()
|
|
|
|
|
+ .and_then(|value| value.to_str())
|
|
|
|
|
+ .unwrap_or("unknown")
|
|
|
|
|
+ .to_string();
|
|
|
|
|
+ sessions.push(ManagedSessionSummary {
|
|
|
|
|
+ id,
|
|
|
|
|
+ path,
|
|
|
|
|
+ modified_epoch_secs,
|
|
|
|
|
+ message_count,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ sessions.sort_by(|left, right| right.modified_epoch_secs.cmp(&left.modified_epoch_secs));
|
|
|
|
|
+ Ok(sessions)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::error::Error>> {
|
|
|
|
|
+ let sessions = list_managed_sessions()?;
|
|
|
|
|
+ let mut lines = vec![
|
|
|
|
|
+ "Sessions".to_string(),
|
|
|
|
|
+ format!(" Directory {}", sessions_dir()?.display()),
|
|
|
|
|
+ ];
|
|
|
|
|
+ if sessions.is_empty() {
|
|
|
|
|
+ lines.push(" No managed sessions saved yet.".to_string());
|
|
|
|
|
+ return Ok(lines.join("\n"));
|
|
|
|
|
+ }
|
|
|
|
|
+ for session in sessions {
|
|
|
|
|
+ let marker = if session.id == active_session_id {
|
|
|
|
|
+ "● current"
|
|
|
|
|
+ } else {
|
|
|
|
|
+ "○ saved"
|
|
|
|
|
+ };
|
|
|
|
|
+ lines.push(format!(
|
|
|
|
|
+ " {id:<20} {marker:<10} msgs={msgs:<4} modified={modified} path={path}",
|
|
|
|
|
+ id = session.id,
|
|
|
|
|
+ msgs = session.message_count,
|
|
|
|
|
+ modified = session.modified_epoch_secs,
|
|
|
|
|
+ path = session.path.display(),
|
|
|
|
|
+ ));
|
|
|
|
|
+ }
|
|
|
|
|
+ Ok(lines.join("\n"))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
fn render_repl_help() -> String {
|
|
fn render_repl_help() -> String {
|
|
|
[
|
|
[
|
|
|
"REPL".to_string(),
|
|
"REPL".to_string(),
|
|
@@ -1102,6 +1443,120 @@ fn permission_mode_label() -> &'static str {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> {
|
|
|
|
|
+ let output = std::process::Command::new("git")
|
|
|
|
|
+ .args(["diff", "--", ":(exclude).omx"])
|
|
|
|
|
+ .current_dir(env::current_dir()?)
|
|
|
|
|
+ .output()?;
|
|
|
|
|
+ if !output.status.success() {
|
|
|
|
|
+ let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
|
|
|
|
+ return Err(format!("git diff failed: {stderr}").into());
|
|
|
|
|
+ }
|
|
|
|
|
+ let diff = String::from_utf8(output.stdout)?;
|
|
|
|
|
+ if diff.trim().is_empty() {
|
|
|
|
|
+ return Ok(
|
|
|
|
|
+ "Diff\n Result clean working tree\n Detail no current changes"
|
|
|
|
|
+ .to_string(),
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ Ok(format!("Diff\n\n{}", diff.trim_end()))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn render_version_report() -> String {
|
|
|
|
|
+ let git_sha = GIT_SHA.unwrap_or("unknown");
|
|
|
|
|
+ let target = BUILD_TARGET.unwrap_or("unknown");
|
|
|
|
|
+ format!(
|
|
|
|
|
+ "Version\n Version {VERSION}\n Git SHA {git_sha}\n Target {target}\n Build date {DEFAULT_DATE}"
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn render_export_text(session: &Session) -> String {
|
|
|
|
|
+ let mut lines = vec!["# Conversation Export".to_string(), String::new()];
|
|
|
|
|
+ for (index, message) in session.messages.iter().enumerate() {
|
|
|
|
|
+ let role = match message.role {
|
|
|
|
|
+ MessageRole::System => "system",
|
|
|
|
|
+ MessageRole::User => "user",
|
|
|
|
|
+ MessageRole::Assistant => "assistant",
|
|
|
|
|
+ MessageRole::Tool => "tool",
|
|
|
|
|
+ };
|
|
|
|
|
+ lines.push(format!("## {}. {role}", index + 1));
|
|
|
|
|
+ for block in &message.blocks {
|
|
|
|
|
+ match block {
|
|
|
|
|
+ ContentBlock::Text { text } => lines.push(text.clone()),
|
|
|
|
|
+ ContentBlock::ToolUse { id, name, input } => {
|
|
|
|
|
+ lines.push(format!("[tool_use id={id} name={name}] {input}"));
|
|
|
|
|
+ }
|
|
|
|
|
+ ContentBlock::ToolResult {
|
|
|
|
|
+ tool_use_id,
|
|
|
|
|
+ tool_name,
|
|
|
|
|
+ output,
|
|
|
|
|
+ is_error,
|
|
|
|
|
+ } => {
|
|
|
|
|
+ lines.push(format!(
|
|
|
|
|
+ "[tool_result id={tool_use_id} name={tool_name} error={is_error}] {output}"
|
|
|
|
|
+ ));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ lines.push(String::new());
|
|
|
|
|
+ }
|
|
|
|
|
+ lines.join("\n")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn default_export_filename(session: &Session) -> String {
|
|
|
|
|
+ let stem = session
|
|
|
|
|
+ .messages
|
|
|
|
|
+ .iter()
|
|
|
|
|
+ .find_map(|message| match message.role {
|
|
|
|
|
+ MessageRole::User => message.blocks.iter().find_map(|block| match block {
|
|
|
|
|
+ ContentBlock::Text { text } => Some(text.as_str()),
|
|
|
|
|
+ _ => None,
|
|
|
|
|
+ }),
|
|
|
|
|
+ _ => None,
|
|
|
|
|
+ })
|
|
|
|
|
+ .map_or("conversation", |text| {
|
|
|
|
|
+ text.lines().next().unwrap_or("conversation")
|
|
|
|
|
+ })
|
|
|
|
|
+ .chars()
|
|
|
|
|
+ .map(|ch| {
|
|
|
|
|
+ if ch.is_ascii_alphanumeric() {
|
|
|
|
|
+ ch.to_ascii_lowercase()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ '-'
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ .collect::<String>()
|
|
|
|
|
+ .split('-')
|
|
|
|
|
+ .filter(|part| !part.is_empty())
|
|
|
|
|
+ .take(8)
|
|
|
|
|
+ .collect::<Vec<_>>()
|
|
|
|
|
+ .join("-");
|
|
|
|
|
+ let fallback = if stem.is_empty() {
|
|
|
|
|
+ "conversation"
|
|
|
|
|
+ } else {
|
|
|
|
|
+ &stem
|
|
|
|
|
+ };
|
|
|
|
|
+ format!("{fallback}.txt")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn resolve_export_path(
|
|
|
|
|
+ requested_path: Option<&str>,
|
|
|
|
|
+ session: &Session,
|
|
|
|
|
+) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
|
|
|
|
+ let cwd = env::current_dir()?;
|
|
|
|
|
+ let file_name =
|
|
|
|
|
+ requested_path.map_or_else(|| default_export_filename(session), ToOwned::to_owned);
|
|
|
|
|
+ let final_name = if Path::new(&file_name)
|
|
|
|
|
+ .extension()
|
|
|
|
|
+ .is_some_and(|ext| ext.eq_ignore_ascii_case("txt"))
|
|
|
|
|
+ {
|
|
|
|
|
+ file_name
|
|
|
|
|
+ } else {
|
|
|
|
|
+ format!("{file_name}.txt")
|
|
|
|
|
+ };
|
|
|
|
|
+ Ok(cwd.join(final_name))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
|
fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
|
|
Ok(load_system_prompt(
|
|
Ok(load_system_prompt(
|
|
|
env::current_dir()?,
|
|
env::current_dir()?,
|
|
@@ -1406,91 +1861,28 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
fn print_help() {
|
|
fn print_help() {
|
|
|
- let mut stdout = io::stdout();
|
|
|
|
|
- let _ = print_help_to(&mut stdout);
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
|
|
|
|
- writeln!(out, "rusty-claude-cli")?;
|
|
|
|
|
- writeln!(out, "Version: {VERSION}")?;
|
|
|
|
|
- writeln!(out)?;
|
|
|
|
|
- writeln!(
|
|
|
|
|
- out,
|
|
|
|
|
- "Rust-first Claude Code-style CLI for prompt, REPL, and saved-session workflows."
|
|
|
|
|
- )?;
|
|
|
|
|
- writeln!(out)?;
|
|
|
|
|
- writeln!(out, "Usage:")?;
|
|
|
|
|
- writeln!(out, " rusty-claude-cli [--model MODEL]")?;
|
|
|
|
|
- writeln!(out, " Start interactive REPL")?;
|
|
|
|
|
- writeln!(out, " rusty-claude-cli [--model MODEL] prompt TEXT")?;
|
|
|
|
|
- writeln!(out, " Send one prompt and stream the response")?;
|
|
|
|
|
- writeln!(
|
|
|
|
|
- out,
|
|
|
|
|
- " rusty-claude-cli --resume SESSION.json [/status] [/compact] [...]"
|
|
|
|
|
- )?;
|
|
|
|
|
- writeln!(
|
|
|
|
|
- out,
|
|
|
|
|
- " Inspect or maintain a saved session without entering the REPL"
|
|
|
|
|
- )?;
|
|
|
|
|
- writeln!(out, " rusty-claude-cli dump-manifests")?;
|
|
|
|
|
- writeln!(
|
|
|
|
|
- out,
|
|
|
|
|
- " Inspect extracted upstream command/tool metadata"
|
|
|
|
|
- )?;
|
|
|
|
|
- writeln!(out, " rusty-claude-cli bootstrap-plan")?;
|
|
|
|
|
- writeln!(out, " Print the current bootstrap phase skeleton")?;
|
|
|
|
|
- writeln!(
|
|
|
|
|
- out,
|
|
|
|
|
- " rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]"
|
|
|
|
|
- )?;
|
|
|
|
|
- writeln!(
|
|
|
|
|
- out,
|
|
|
|
|
- " Render the synthesized system prompt for debugging"
|
|
|
|
|
- )?;
|
|
|
|
|
- writeln!(out, " rusty-claude-cli --help")?;
|
|
|
|
|
- writeln!(out, " rusty-claude-cli --version")?;
|
|
|
|
|
- writeln!(out)?;
|
|
|
|
|
- writeln!(out, "Options:")?;
|
|
|
|
|
- writeln!(
|
|
|
|
|
- out,
|
|
|
|
|
- " --model MODEL Override the active Anthropic model"
|
|
|
|
|
- )?;
|
|
|
|
|
- writeln!(
|
|
|
|
|
- out,
|
|
|
|
|
- " --resume PATH Restore a saved session file and optionally run slash commands"
|
|
|
|
|
- )?;
|
|
|
|
|
- writeln!(out, " -h, --help Show this help page")?;
|
|
|
|
|
- writeln!(out, " -V, --version Print the CLI version")?;
|
|
|
|
|
- writeln!(out)?;
|
|
|
|
|
- writeln!(out, "Environment:")?;
|
|
|
|
|
- writeln!(
|
|
|
|
|
- out,
|
|
|
|
|
- " ANTHROPIC_AUTH_TOKEN Preferred bearer token for Anthropic API requests"
|
|
|
|
|
- )?;
|
|
|
|
|
- writeln!(
|
|
|
|
|
- out,
|
|
|
|
|
- " ANTHROPIC_API_KEY Legacy API key fallback if auth token is unset"
|
|
|
|
|
- )?;
|
|
|
|
|
- writeln!(
|
|
|
|
|
- out,
|
|
|
|
|
- " ANTHROPIC_BASE_URL Override the Anthropic API base URL"
|
|
|
|
|
- )?;
|
|
|
|
|
- writeln!(
|
|
|
|
|
- out,
|
|
|
|
|
- " ANTHROPIC_MODEL Default model for selected integration tests"
|
|
|
|
|
- )?;
|
|
|
|
|
- writeln!(
|
|
|
|
|
- out,
|
|
|
|
|
- " RUSTY_CLAUDE_PERMISSION_MODE Default permission mode for REPL sessions"
|
|
|
|
|
- )?;
|
|
|
|
|
- writeln!(
|
|
|
|
|
- out,
|
|
|
|
|
- " CLAUDE_CONFIG_HOME Override Claude config discovery root"
|
|
|
|
|
- )?;
|
|
|
|
|
- writeln!(out)?;
|
|
|
|
|
- writeln!(out, "Interactive slash commands:")?;
|
|
|
|
|
- writeln!(out, "{}", render_slash_command_help())?;
|
|
|
|
|
- writeln!(out)?;
|
|
|
|
|
|
|
+ println!("rusty-claude-cli v{VERSION}");
|
|
|
|
|
+ println!();
|
|
|
|
|
+ println!("Usage:");
|
|
|
|
|
+ println!(" rusty-claude-cli [--model MODEL]");
|
|
|
|
|
+ println!(" Start the interactive REPL");
|
|
|
|
|
+ println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] prompt TEXT");
|
|
|
|
|
+ println!(" Send one prompt and exit");
|
|
|
|
|
+ println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] TEXT");
|
|
|
|
|
+ println!(" Shorthand non-interactive prompt mode");
|
|
|
|
|
+ println!(" rusty-claude-cli --resume SESSION.json [/status] [/compact] [...]");
|
|
|
|
|
+ println!(" Inspect or maintain a saved session without entering the REPL");
|
|
|
|
|
+ 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!();
|
|
|
|
|
+ println!("Flags:");
|
|
|
|
|
+ println!(" --model MODEL Override the active model");
|
|
|
|
|
+ println!(" --output-format FORMAT Non-interactive output format: text or json");
|
|
|
|
|
+ println!();
|
|
|
|
|
+ println!("Interactive slash commands:");
|
|
|
|
|
+ println!("{}", render_slash_command_help());
|
|
|
|
|
+ println!();
|
|
|
let resume_commands = resume_supported_slash_commands()
|
|
let resume_commands = resume_supported_slash_commands()
|
|
|
.into_iter()
|
|
.into_iter()
|
|
|
.map(|spec| match spec.argument_hint {
|
|
.map(|spec| match spec.argument_hint {
|
|
@@ -1499,26 +1891,11 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
|
|
})
|
|
})
|
|
|
.collect::<Vec<_>>()
|
|
.collect::<Vec<_>>()
|
|
|
.join(", ");
|
|
.join(", ");
|
|
|
- writeln!(out, "Resume-safe commands: {resume_commands}")?;
|
|
|
|
|
- writeln!(out, "Examples:")?;
|
|
|
|
|
- writeln!(
|
|
|
|
|
- out,
|
|
|
|
|
- " rusty-claude-cli prompt \"Summarize the repo architecture\""
|
|
|
|
|
- )?;
|
|
|
|
|
- writeln!(out, " rusty-claude-cli --model claude-sonnet-4-20250514")?;
|
|
|
|
|
- writeln!(
|
|
|
|
|
- out,
|
|
|
|
|
- " rusty-claude-cli --resume session.json /status /compact /cost"
|
|
|
|
|
- )?;
|
|
|
|
|
- writeln!(
|
|
|
|
|
- out,
|
|
|
|
|
- " rusty-claude-cli --resume session.json /memory /config"
|
|
|
|
|
- )?;
|
|
|
|
|
- Ok(())
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-fn print_version() {
|
|
|
|
|
- println!("rusty-claude-cli {VERSION}");
|
|
|
|
|
|
|
+ println!("Resume-safe commands: {resume_commands}");
|
|
|
|
|
+ println!("Examples:");
|
|
|
|
|
+ println!(" rusty-claude-cli --model claude-opus \"summarize this repo\"");
|
|
|
|
|
+ println!(" rusty-claude-cli --output-format json prompt \"explain src/main.rs\"");
|
|
|
|
|
+ println!(" rusty-claude-cli --resume session.json /status /diff /export notes.txt");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
#[cfg(test)]
|
|
@@ -1529,7 +1906,7 @@ mod tests {
|
|
|
format_resume_report, format_status_report, normalize_permission_mode, parse_args,
|
|
format_resume_report, format_status_report, normalize_permission_mode, parse_args,
|
|
|
parse_git_status_metadata, render_config_report, render_init_claude_md,
|
|
parse_git_status_metadata, render_config_report, render_init_claude_md,
|
|
|
render_memory_report, render_repl_help, resume_supported_slash_commands, status_context,
|
|
render_memory_report, render_repl_help, resume_supported_slash_commands, status_context,
|
|
|
- CliAction, SlashCommand, StatusUsage, DEFAULT_MODEL,
|
|
|
|
|
|
|
+ CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL,
|
|
|
};
|
|
};
|
|
|
use runtime::{ContentBlock, ConversationMessage, MessageRole};
|
|
use runtime::{ContentBlock, ConversationMessage, MessageRole};
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::path::{Path, PathBuf};
|
|
@@ -1556,6 +1933,26 @@ mod tests {
|
|
|
CliAction::Prompt {
|
|
CliAction::Prompt {
|
|
|
prompt: "hello world".to_string(),
|
|
prompt: "hello world".to_string(),
|
|
|
model: DEFAULT_MODEL.to_string(),
|
|
model: DEFAULT_MODEL.to_string(),
|
|
|
|
|
+ output_format: CliOutputFormat::Text,
|
|
|
|
|
+ }
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn parses_bare_prompt_and_json_output_flag() {
|
|
|
|
|
+ let args = vec![
|
|
|
|
|
+ "--output-format=json".to_string(),
|
|
|
|
|
+ "--model".to_string(),
|
|
|
|
|
+ "claude-opus".to_string(),
|
|
|
|
|
+ "explain".to_string(),
|
|
|
|
|
+ "this".to_string(),
|
|
|
|
|
+ ];
|
|
|
|
|
+ assert_eq!(
|
|
|
|
|
+ parse_args(&args).expect("args should parse"),
|
|
|
|
|
+ CliAction::Prompt {
|
|
|
|
|
+ prompt: "explain this".to_string(),
|
|
|
|
|
+ model: "claude-opus".to_string(),
|
|
|
|
|
+ output_format: CliOutputFormat::Json,
|
|
|
}
|
|
}
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
@@ -1616,18 +2013,6 @@ mod tests {
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- #[test]
|
|
|
|
|
- fn parses_version_flags() {
|
|
|
|
|
- assert_eq!(
|
|
|
|
|
- parse_args(&["--version".to_string()]).expect("args should parse"),
|
|
|
|
|
- CliAction::Version
|
|
|
|
|
- );
|
|
|
|
|
- assert_eq!(
|
|
|
|
|
- parse_args(&["-V".to_string()]).expect("args should parse"),
|
|
|
|
|
- CliAction::Version
|
|
|
|
|
- );
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
fn shared_help_uses_resume_annotation_copy() {
|
|
fn shared_help_uses_resume_annotation_copy() {
|
|
|
let help = commands::render_slash_command_help();
|
|
let help = commands::render_slash_command_help();
|
|
@@ -1635,16 +2020,6 @@ mod tests {
|
|
|
assert!(help.contains("works with --resume SESSION.json"));
|
|
assert!(help.contains("works with --resume SESSION.json"));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- #[test]
|
|
|
|
|
- fn cli_help_mentions_version_and_environment() {
|
|
|
|
|
- let mut output = Vec::new();
|
|
|
|
|
- super::print_help_to(&mut output).expect("help should render");
|
|
|
|
|
- let help = String::from_utf8(output).expect("help should be utf8");
|
|
|
|
|
- assert!(help.contains("--version"));
|
|
|
|
|
- assert!(help.contains("ANTHROPIC_AUTH_TOKEN"));
|
|
|
|
|
- assert!(help.contains("RUSTY_CLAUDE_PERMISSION_MODE"));
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
fn repl_help_includes_shared_commands_and_exit() {
|
|
fn repl_help_includes_shared_commands_and_exit() {
|
|
|
let help = render_repl_help();
|
|
let help = render_repl_help();
|
|
@@ -1659,8 +2034,11 @@ mod tests {
|
|
|
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"));
|
|
|
|
|
+ assert!(help.contains("/diff"));
|
|
|
|
|
+ assert!(help.contains("/version"));
|
|
|
|
|
+ assert!(help.contains("/export [file]"));
|
|
|
|
|
+ assert!(help.contains("/session [list|switch <session-id>]"));
|
|
|
assert!(help.contains("/exit"));
|
|
assert!(help.contains("/exit"));
|
|
|
- assert!(help.contains("slash commands"));
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
@@ -1671,7 +2049,10 @@ mod tests {
|
|
|
.collect::<Vec<_>>();
|
|
.collect::<Vec<_>>();
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
names,
|
|
names,
|
|
|
- vec!["help", "status", "compact", "clear", "cost", "config", "memory", "init",]
|
|
|
|
|
|
|
+ vec![
|
|
|
|
|
+ "help", "status", "compact", "clear", "cost", "config", "memory", "init", "diff",
|
|
|
|
|
+ "version", "export",
|
|
|
|
|
+ ]
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|