mirror of
https://github.com/lWolvesl/claw-code.git
synced 2026-04-02 07:41:52 +08:00
Merge remote-tracking branch 'origin/rcc/cli' into dev/rust
# Conflicts: # rust/crates/rusty-claude-cli/src/main.rs
This commit is contained in:
@@ -105,6 +105,30 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
||||
argument_hint: None,
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "diff",
|
||||
summary: "Show git diff for current workspace changes",
|
||||
argument_hint: None,
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "version",
|
||||
summary: "Show CLI version and build information",
|
||||
argument_hint: None,
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "export",
|
||||
summary: "Export the current conversation to a file",
|
||||
argument_hint: Some("[file]"),
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "session",
|
||||
summary: "List or switch managed local sessions",
|
||||
argument_hint: Some("[list|switch <session-id>]"),
|
||||
resume_supported: false,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -112,14 +136,33 @@ pub enum SlashCommand {
|
||||
Help,
|
||||
Status,
|
||||
Compact,
|
||||
Model { model: Option<String> },
|
||||
Permissions { mode: Option<String> },
|
||||
Clear { confirm: bool },
|
||||
Model {
|
||||
model: Option<String>,
|
||||
},
|
||||
Permissions {
|
||||
mode: Option<String>,
|
||||
},
|
||||
Clear {
|
||||
confirm: bool,
|
||||
},
|
||||
Cost,
|
||||
Resume { session_path: Option<String> },
|
||||
Config { section: Option<String> },
|
||||
Resume {
|
||||
session_path: Option<String>,
|
||||
},
|
||||
Config {
|
||||
section: Option<String>,
|
||||
},
|
||||
Memory,
|
||||
Init,
|
||||
Diff,
|
||||
Version,
|
||||
Export {
|
||||
path: Option<String>,
|
||||
},
|
||||
Session {
|
||||
action: Option<String>,
|
||||
target: Option<String>,
|
||||
},
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
@@ -155,6 +198,15 @@ impl SlashCommand {
|
||||
},
|
||||
"memory" => Self::Memory,
|
||||
"init" => Self::Init,
|
||||
"diff" => Self::Diff,
|
||||
"version" => Self::Version,
|
||||
"export" => Self::Export {
|
||||
path: parts.next().map(ToOwned::to_owned),
|
||||
},
|
||||
"session" => Self::Session {
|
||||
action: parts.next().map(ToOwned::to_owned),
|
||||
target: parts.next().map(ToOwned::to_owned),
|
||||
},
|
||||
other => Self::Unknown(other.to_string()),
|
||||
})
|
||||
}
|
||||
@@ -235,6 +287,10 @@ pub fn handle_slash_command(
|
||||
| SlashCommand::Config { .. }
|
||||
| SlashCommand::Memory
|
||||
| SlashCommand::Init
|
||||
| SlashCommand::Diff
|
||||
| SlashCommand::Version
|
||||
| SlashCommand::Export { .. }
|
||||
| SlashCommand::Session { .. }
|
||||
| SlashCommand::Unknown(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -294,6 +350,21 @@ mod tests {
|
||||
);
|
||||
assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
|
||||
assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
|
||||
assert_eq!(SlashCommand::parse("/diff"), Some(SlashCommand::Diff));
|
||||
assert_eq!(SlashCommand::parse("/version"), Some(SlashCommand::Version));
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/export notes.txt"),
|
||||
Some(SlashCommand::Export {
|
||||
path: Some("notes.txt".to_string())
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/session switch abc123"),
|
||||
Some(SlashCommand::Session {
|
||||
action: Some("switch".to_string()),
|
||||
target: Some("abc123".to_string())
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -311,8 +382,12 @@ mod tests {
|
||||
assert!(help.contains("/config [env|hooks|model]"));
|
||||
assert!(help.contains("/memory"));
|
||||
assert!(help.contains("/init"));
|
||||
assert_eq!(slash_command_specs().len(), 11);
|
||||
assert_eq!(resume_supported_slash_commands().len(), 8);
|
||||
assert!(help.contains("/diff"));
|
||||
assert!(help.contains("/version"));
|
||||
assert!(help.contains("/export [file]"));
|
||||
assert!(help.contains("/session [list|switch <session-id>]"));
|
||||
assert_eq!(slash_command_specs().len(), 15);
|
||||
assert_eq!(resume_supported_slash_commands().len(), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -384,5 +459,14 @@ mod tests {
|
||||
assert!(
|
||||
handle_slash_command("/config env", &session, CompactionConfig::default()).is_none()
|
||||
);
|
||||
assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
|
||||
assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
|
||||
assert!(
|
||||
handle_slash_command("/export note.txt", &session, CompactionConfig::default())
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
handle_slash_command("/session list", &session, CompactionConfig::default()).is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::env;
|
||||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use api::{
|
||||
AnthropicClient, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
|
||||
@@ -21,16 +22,23 @@ use runtime::{
|
||||
PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, Session, TokenUsage, ToolError,
|
||||
ToolExecutor, UsageTracker,
|
||||
};
|
||||
use serde_json::json;
|
||||
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";
|
||||
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() {
|
||||
if let Err(error) = run() {
|
||||
eprintln!("{error}");
|
||||
eprintln!(
|
||||
"error: {error}
|
||||
|
||||
Run `rusty-claude-cli --help` for usage."
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -45,10 +53,13 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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::Help => print_help(),
|
||||
CliAction::Version => print_version(),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -68,16 +79,36 @@ enum CliAction {
|
||||
Prompt {
|
||||
prompt: String,
|
||||
model: String,
|
||||
output_format: CliOutputFormat,
|
||||
},
|
||||
Repl {
|
||||
model: String,
|
||||
},
|
||||
// prompt-mode formatting is only supported for non-interactive runs
|
||||
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> {
|
||||
let mut model = DEFAULT_MODEL.to_string();
|
||||
let mut output_format = CliOutputFormat::Text;
|
||||
let mut rest = Vec::new();
|
||||
let mut index = 0;
|
||||
|
||||
@@ -94,6 +125,17 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
model = flag[8..].to_string();
|
||||
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 => {
|
||||
rest.push(other.to_string());
|
||||
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")) {
|
||||
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") {
|
||||
return parse_resume_args(&rest[1..]);
|
||||
}
|
||||
@@ -123,8 +162,17 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
if prompt.trim().is_empty() {
|
||||
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}")),
|
||||
}
|
||||
}
|
||||
@@ -447,6 +495,7 @@ fn find_git_root() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
Ok(PathBuf::from(path))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn run_resume_command(
|
||||
session_path: &Path,
|
||||
session: &Session,
|
||||
@@ -531,9 +580,30 @@ fn run_resume_command(
|
||||
session: session.clone(),
|
||||
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::Model { .. }
|
||||
| SlashCommand::Permissions { .. }
|
||||
| SlashCommand::Session { .. }
|
||||
| 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>> {
|
||||
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.");
|
||||
println!("{}", cli.startup_banner());
|
||||
|
||||
while let Some(input) = editor.read_line()? {
|
||||
let trimmed = input.trim();
|
||||
@@ -562,26 +631,57 @@ fn run_repl(model: String) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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 {
|
||||
model: String,
|
||||
system_prompt: Vec<String>,
|
||||
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
|
||||
session: SessionHandle,
|
||||
}
|
||||
|
||||
impl LiveCli {
|
||||
fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let system_prompt = build_system_prompt()?;
|
||||
let session = create_managed_session_handle()?;
|
||||
let runtime = build_runtime(
|
||||
Session::new(),
|
||||
model.clone(),
|
||||
system_prompt.clone(),
|
||||
enable_tools,
|
||||
)?;
|
||||
Ok(Self {
|
||||
let cli = Self {
|
||||
model,
|
||||
system_prompt,
|
||||
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>> {
|
||||
@@ -601,6 +701,7 @@ impl LiveCli {
|
||||
&mut stdout,
|
||||
)?;
|
||||
println!();
|
||||
self.persist_session()?;
|
||||
Ok(())
|
||||
}
|
||||
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(
|
||||
&mut self,
|
||||
command: SlashCommand,
|
||||
@@ -630,11 +785,22 @@ impl LiveCli {
|
||||
SlashCommand::Config { section } => Self::print_config(section.as_deref())?,
|
||||
SlashCommand::Memory => Self::print_memory()?,
|
||||
SlashCommand::Init => Self::run_init()?,
|
||||
SlashCommand::Diff => Self::print_diff()?,
|
||||
SlashCommand::Version => Self::print_version(),
|
||||
SlashCommand::Export { path } => self.export_session(path.as_deref())?,
|
||||
SlashCommand::Session { action, target } => {
|
||||
self.handle_session_command(action.as_deref(), target.as_deref())?;
|
||||
}
|
||||
SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"),
|
||||
}
|
||||
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) {
|
||||
let cumulative = self.runtime.usage().cumulative_usage();
|
||||
let latest = self.runtime.usage().current_turn_usage();
|
||||
@@ -650,7 +816,7 @@ impl LiveCli {
|
||||
estimated_tokens: self.runtime.estimated_tokens(),
|
||||
},
|
||||
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();
|
||||
self.runtime = build_runtime(session, model.clone(), self.system_prompt.clone(), true)?;
|
||||
self.model.clone_from(&model);
|
||||
self.persist_session()?;
|
||||
println!(
|
||||
"{}",
|
||||
format_model_switch_report(&previous, &model, message_count)
|
||||
@@ -700,7 +867,7 @@ impl LiveCli {
|
||||
|
||||
let normalized = normalize_permission_mode(&mode).ok_or_else(|| {
|
||||
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,
|
||||
normalized,
|
||||
)?;
|
||||
self.persist_session()?;
|
||||
println!(
|
||||
"{}",
|
||||
format_permissions_switch_report(&previous, normalized)
|
||||
@@ -733,6 +901,7 @@ impl LiveCli {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.session = create_managed_session_handle()?;
|
||||
self.runtime = build_runtime_with_permission_mode(
|
||||
Session::new(),
|
||||
self.model.clone(),
|
||||
@@ -740,13 +909,12 @@ impl LiveCli {
|
||||
true,
|
||||
permission_mode_label(),
|
||||
)?;
|
||||
self.persist_session()?;
|
||||
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,
|
||||
permission_mode_label()
|
||||
permission_mode_label(),
|
||||
self.session.id,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -760,12 +928,13 @@ impl LiveCli {
|
||||
&mut self,
|
||||
session_path: Option<String>,
|
||||
) -> 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>");
|
||||
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();
|
||||
self.runtime = build_runtime_with_permission_mode(
|
||||
session,
|
||||
@@ -774,9 +943,15 @@ impl LiveCli {
|
||||
true,
|
||||
permission_mode_label(),
|
||||
)?;
|
||||
self.session = handle;
|
||||
self.persist_session()?;
|
||||
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(())
|
||||
}
|
||||
@@ -796,6 +971,71 @@ impl LiveCli {
|
||||
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>> {
|
||||
let result = self.runtime.compact(CompactionConfig::default());
|
||||
let removed = result.removed_message_count;
|
||||
@@ -808,11 +1048,112 @@ impl LiveCli {
|
||||
true,
|
||||
permission_mode_label(),
|
||||
)?;
|
||||
self.persist_session()?;
|
||||
println!("{}", format_compact_report(removed, kept, skipped));
|
||||
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 {
|
||||
[
|
||||
"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>> {
|
||||
Ok(load_system_prompt(
|
||||
env::current_dir()?,
|
||||
@@ -1406,91 +1861,28 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
|
||||
}
|
||||
|
||||
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()
|
||||
.into_iter()
|
||||
.map(|spec| match spec.argument_hint {
|
||||
@@ -1499,26 +1891,11 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.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)]
|
||||
@@ -1529,7 +1906,7 @@ mod tests {
|
||||
format_resume_report, format_status_report, normalize_permission_mode, parse_args,
|
||||
parse_git_status_metadata, render_config_report, render_init_claude_md,
|
||||
render_memory_report, render_repl_help, resume_supported_slash_commands, status_context,
|
||||
CliAction, SlashCommand, StatusUsage, DEFAULT_MODEL,
|
||||
CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL,
|
||||
};
|
||||
use runtime::{ContentBlock, ConversationMessage, MessageRole};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -1556,6 +1933,26 @@ mod tests {
|
||||
CliAction::Prompt {
|
||||
prompt: "hello world".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]
|
||||
fn shared_help_uses_resume_annotation_copy() {
|
||||
let help = commands::render_slash_command_help();
|
||||
@@ -1635,16 +2020,6 @@ mod tests {
|
||||
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]
|
||||
fn repl_help_includes_shared_commands_and_exit() {
|
||||
let help = render_repl_help();
|
||||
@@ -1659,8 +2034,11 @@ mod tests {
|
||||
assert!(help.contains("/config [env|hooks|model]"));
|
||||
assert!(help.contains("/memory"));
|
||||
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("slash commands"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1671,7 +2049,10 @@ mod tests {
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec!["help", "status", "compact", "clear", "cost", "config", "memory", "init",]
|
||||
vec![
|
||||
"help", "status", "compact", "clear", "cost", "config", "memory", "init", "diff",
|
||||
"version", "export",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user