mirror of
https://github.com/lWolvesl/claw-code.git
synced 2026-04-02 19:21:51 +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,
|
argument_hint: None,
|
||||||
resume_supported: true,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -112,14 +136,33 @@ pub enum SlashCommand {
|
|||||||
Help,
|
Help,
|
||||||
Status,
|
Status,
|
||||||
Compact,
|
Compact,
|
||||||
Model { model: Option<String> },
|
Model {
|
||||||
Permissions { mode: Option<String> },
|
model: Option<String>,
|
||||||
Clear { confirm: bool },
|
},
|
||||||
|
Permissions {
|
||||||
|
mode: Option<String>,
|
||||||
|
},
|
||||||
|
Clear {
|
||||||
|
confirm: bool,
|
||||||
|
},
|
||||||
Cost,
|
Cost,
|
||||||
Resume { session_path: Option<String> },
|
Resume {
|
||||||
Config { section: Option<String> },
|
session_path: Option<String>,
|
||||||
|
},
|
||||||
|
Config {
|
||||||
|
section: Option<String>,
|
||||||
|
},
|
||||||
Memory,
|
Memory,
|
||||||
Init,
|
Init,
|
||||||
|
Diff,
|
||||||
|
Version,
|
||||||
|
Export {
|
||||||
|
path: Option<String>,
|
||||||
|
},
|
||||||
|
Session {
|
||||||
|
action: Option<String>,
|
||||||
|
target: Option<String>,
|
||||||
|
},
|
||||||
Unknown(String),
|
Unknown(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +198,15 @@ impl SlashCommand {
|
|||||||
},
|
},
|
||||||
"memory" => Self::Memory,
|
"memory" => Self::Memory,
|
||||||
"init" => Self::Init,
|
"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()),
|
other => Self::Unknown(other.to_string()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -235,6 +287,10 @@ pub fn handle_slash_command(
|
|||||||
| SlashCommand::Config { .. }
|
| SlashCommand::Config { .. }
|
||||||
| SlashCommand::Memory
|
| SlashCommand::Memory
|
||||||
| SlashCommand::Init
|
| SlashCommand::Init
|
||||||
|
| SlashCommand::Diff
|
||||||
|
| SlashCommand::Version
|
||||||
|
| SlashCommand::Export { .. }
|
||||||
|
| SlashCommand::Session { .. }
|
||||||
| SlashCommand::Unknown(_) => None,
|
| SlashCommand::Unknown(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,6 +350,21 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
|
assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
|
||||||
assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
|
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]
|
#[test]
|
||||||
@@ -311,8 +382,12 @@ 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_eq!(slash_command_specs().len(), 11);
|
assert!(help.contains("/diff"));
|
||||||
assert_eq!(resume_supported_slash_commands().len(), 8);
|
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]
|
#[test]
|
||||||
@@ -384,5 +459,14 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
handle_slash_command("/config env", &session, CompactionConfig::default()).is_none()
|
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::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!("{}", cli.startup_banner());
|
||||||
println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
|
|
||||||
|
|
||||||
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
|
"Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}",
|
||||||
Mode fresh session
|
|
||||||
Preserved model {}
|
|
||||||
Permission mode {}",
|
|
||||||
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();
|
println!("rusty-claude-cli v{VERSION}");
|
||||||
let _ = print_help_to(&mut stdout);
|
println!();
|
||||||
}
|
println!("Usage:");
|
||||||
|
println!(" rusty-claude-cli [--model MODEL]");
|
||||||
fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
println!(" Start the interactive REPL");
|
||||||
writeln!(out, "rusty-claude-cli")?;
|
println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] prompt TEXT");
|
||||||
writeln!(out, "Version: {VERSION}")?;
|
println!(" Send one prompt and exit");
|
||||||
writeln!(out)?;
|
println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] TEXT");
|
||||||
writeln!(
|
println!(" Shorthand non-interactive prompt mode");
|
||||||
out,
|
println!(" rusty-claude-cli --resume SESSION.json [/status] [/compact] [...]");
|
||||||
"Rust-first Claude Code-style CLI for prompt, REPL, and saved-session workflows."
|
println!(" Inspect or maintain a saved session without entering the REPL");
|
||||||
)?;
|
println!(" rusty-claude-cli dump-manifests");
|
||||||
writeln!(out)?;
|
println!(" rusty-claude-cli bootstrap-plan");
|
||||||
writeln!(out, "Usage:")?;
|
println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]");
|
||||||
writeln!(out, " rusty-claude-cli [--model MODEL]")?;
|
println!();
|
||||||
writeln!(out, " Start interactive REPL")?;
|
println!("Flags:");
|
||||||
writeln!(out, " rusty-claude-cli [--model MODEL] prompt TEXT")?;
|
println!(" --model MODEL Override the active model");
|
||||||
writeln!(out, " Send one prompt and stream the response")?;
|
println!(" --output-format FORMAT Non-interactive output format: text or json");
|
||||||
writeln!(
|
println!();
|
||||||
out,
|
println!("Interactive slash commands:");
|
||||||
" rusty-claude-cli --resume SESSION.json [/status] [/compact] [...]"
|
println!("{}", render_slash_command_help());
|
||||||
)?;
|
println!();
|
||||||
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)?;
|
|
||||||
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}")?;
|
println!("Resume-safe commands: {resume_commands}");
|
||||||
writeln!(out, "Examples:")?;
|
println!("Examples:");
|
||||||
writeln!(
|
println!(" rusty-claude-cli --model claude-opus \"summarize this repo\"");
|
||||||
out,
|
println!(" rusty-claude-cli --output-format json prompt \"explain src/main.rs\"");
|
||||||
" rusty-claude-cli prompt \"Summarize the repo architecture\""
|
println!(" rusty-claude-cli --resume session.json /status /diff /export notes.txt");
|
||||||
)?;
|
|
||||||
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}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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",
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user