Keep CLI parity features local and controllable
The remaining slash commands already existed in the REPL path, so this change focuses on wiring the active CLI parser and runtime to expose them safely. `--version` now exits through a local reporting path, and `--allowedTools` constrains both advertised and executable tools without changing the underlying command surface. Constraint: The active CLI parser lives in main.rs, so a full parser unification would be broader than requested Constraint: --version must not require API credentials or construct the API client Rejected: Migrate the binary to the clap parser in args.rs | too large for a parity patch Rejected: Enforce allowed tools only at request construction time | execution-time mismatch risk Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep local-only flags like --version on pre-runtime codepaths and mirror tool allowlists in both definition and execution paths Tested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test; cargo run -q -p rusty-claude-cli -- --version; cargo run -q -p rusty-claude-cli -- --help Not-tested: Interactive live API conversation with restricted tool allowlists
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
mod input;
|
||||
mod render;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
@@ -32,6 +33,8 @@ const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const BUILD_TARGET: Option<&str> = option_env!("TARGET");
|
||||
const GIT_SHA: Option<&str> = option_env!("GIT_SHA");
|
||||
|
||||
type AllowedToolSet = BTreeSet<String>;
|
||||
|
||||
fn main() {
|
||||
if let Err(error) = run() {
|
||||
eprintln!(
|
||||
@@ -49,6 +52,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
CliAction::DumpManifests => dump_manifests(),
|
||||
CliAction::BootstrapPlan => print_bootstrap_plan(),
|
||||
CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
|
||||
CliAction::Version => print_version(),
|
||||
CliAction::ResumeSession {
|
||||
session_path,
|
||||
commands,
|
||||
@@ -57,8 +61,13 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
prompt,
|
||||
model,
|
||||
output_format,
|
||||
} => LiveCli::new(model, false)?.run_turn_with_output(&prompt, output_format)?,
|
||||
CliAction::Repl { model } => run_repl(model)?,
|
||||
allowed_tools,
|
||||
} => LiveCli::new(model, false, allowed_tools)?
|
||||
.run_turn_with_output(&prompt, output_format)?,
|
||||
CliAction::Repl {
|
||||
model,
|
||||
allowed_tools,
|
||||
} => run_repl(model, allowed_tools)?,
|
||||
CliAction::Help => print_help(),
|
||||
}
|
||||
Ok(())
|
||||
@@ -72,6 +81,7 @@ enum CliAction {
|
||||
cwd: PathBuf,
|
||||
date: String,
|
||||
},
|
||||
Version,
|
||||
ResumeSession {
|
||||
session_path: PathBuf,
|
||||
commands: Vec<String>,
|
||||
@@ -80,9 +90,11 @@ enum CliAction {
|
||||
prompt: String,
|
||||
model: String,
|
||||
output_format: CliOutputFormat,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
},
|
||||
Repl {
|
||||
model: String,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
},
|
||||
// prompt-mode formatting is only supported for non-interactive runs
|
||||
Help,
|
||||
@@ -109,11 +121,17 @@ impl CliOutputFormat {
|
||||
fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
let mut model = DEFAULT_MODEL.to_string();
|
||||
let mut output_format = CliOutputFormat::Text;
|
||||
let mut wants_version = false;
|
||||
let mut allowed_tool_values = Vec::new();
|
||||
let mut rest = Vec::new();
|
||||
let mut index = 0;
|
||||
|
||||
while index < args.len() {
|
||||
match args[index].as_str() {
|
||||
"--version" | "-V" => {
|
||||
wants_version = true;
|
||||
index += 1;
|
||||
}
|
||||
"--model" => {
|
||||
let value = args
|
||||
.get(index + 1)
|
||||
@@ -136,6 +154,21 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
output_format = CliOutputFormat::parse(&flag[16..])?;
|
||||
index += 1;
|
||||
}
|
||||
"--allowedTools" | "--allowed-tools" => {
|
||||
let value = args
|
||||
.get(index + 1)
|
||||
.ok_or_else(|| "missing value for --allowedTools".to_string())?;
|
||||
allowed_tool_values.push(value.clone());
|
||||
index += 2;
|
||||
}
|
||||
flag if flag.starts_with("--allowedTools=") => {
|
||||
allowed_tool_values.push(flag[15..].to_string());
|
||||
index += 1;
|
||||
}
|
||||
flag if flag.starts_with("--allowed-tools=") => {
|
||||
allowed_tool_values.push(flag[16..].to_string());
|
||||
index += 1;
|
||||
}
|
||||
other => {
|
||||
rest.push(other.to_string());
|
||||
index += 1;
|
||||
@@ -143,8 +176,17 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
}
|
||||
}
|
||||
|
||||
if wants_version {
|
||||
return Ok(CliAction::Version);
|
||||
}
|
||||
|
||||
let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?;
|
||||
|
||||
if rest.is_empty() {
|
||||
return Ok(CliAction::Repl { model });
|
||||
return Ok(CliAction::Repl {
|
||||
model,
|
||||
allowed_tools,
|
||||
});
|
||||
}
|
||||
if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
|
||||
return Ok(CliAction::Help);
|
||||
@@ -166,17 +208,74 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
prompt,
|
||||
model,
|
||||
output_format,
|
||||
allowed_tools,
|
||||
})
|
||||
}
|
||||
other if !other.starts_with('/') => Ok(CliAction::Prompt {
|
||||
prompt: rest.join(" "),
|
||||
model,
|
||||
output_format,
|
||||
allowed_tools,
|
||||
}),
|
||||
other => Err(format!("unknown subcommand: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_allowed_tools(values: &[String]) -> Result<Option<AllowedToolSet>, String> {
|
||||
if values.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let canonical_names = mvp_tool_specs()
|
||||
.into_iter()
|
||||
.map(|spec| spec.name.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
let mut name_map = canonical_names
|
||||
.iter()
|
||||
.map(|name| (normalize_tool_name(name), name.clone()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
|
||||
for (alias, canonical) in [
|
||||
("read", "read_file"),
|
||||
("write", "write_file"),
|
||||
("edit", "edit_file"),
|
||||
("glob", "glob_search"),
|
||||
("grep", "grep_search"),
|
||||
] {
|
||||
name_map.insert(alias.to_string(), canonical.to_string());
|
||||
}
|
||||
|
||||
let mut allowed = AllowedToolSet::new();
|
||||
for value in values {
|
||||
for token in value
|
||||
.split(|ch: char| ch == ',' || ch.is_whitespace())
|
||||
.filter(|token| !token.is_empty())
|
||||
{
|
||||
let normalized = normalize_tool_name(token);
|
||||
let canonical = name_map.get(&normalized).ok_or_else(|| {
|
||||
format!(
|
||||
"unsupported tool in --allowedTools: {token} (expected one of: {})",
|
||||
canonical_names.join(", ")
|
||||
)
|
||||
})?;
|
||||
allowed.insert(canonical.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(allowed))
|
||||
}
|
||||
|
||||
fn normalize_tool_name(value: &str) -> String {
|
||||
value.trim().replace('-', "_").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn filter_tool_specs(allowed_tools: Option<&AllowedToolSet>) -> Vec<tools::ToolSpec> {
|
||||
mvp_tool_specs()
|
||||
.into_iter()
|
||||
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
|
||||
let mut cwd = env::current_dir().map_err(|error| error.to_string())?;
|
||||
let mut date = DEFAULT_DATE.to_string();
|
||||
@@ -255,6 +354,10 @@ fn print_system_prompt(cwd: PathBuf, date: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fn print_version() {
|
||||
println!("{}", render_version_report());
|
||||
}
|
||||
|
||||
fn resume_session(session_path: &Path, commands: &[String]) {
|
||||
let session = match Session::load_from_path(session_path) {
|
||||
Ok(session) => session,
|
||||
@@ -608,8 +711,11 @@ fn run_resume_command(
|
||||
}
|
||||
}
|
||||
|
||||
fn run_repl(model: String) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut cli = LiveCli::new(model, true)?;
|
||||
fn run_repl(
|
||||
model: String,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut cli = LiveCli::new(model, true, allowed_tools)?;
|
||||
let editor = input::LineEditor::new("› ");
|
||||
println!("{}", cli.startup_banner());
|
||||
|
||||
@@ -647,13 +753,18 @@ struct ManagedSessionSummary {
|
||||
|
||||
struct LiveCli {
|
||||
model: String,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
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>> {
|
||||
fn new(
|
||||
model: String,
|
||||
enable_tools: bool,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let system_prompt = build_system_prompt()?;
|
||||
let session = create_managed_session_handle()?;
|
||||
let runtime = build_runtime(
|
||||
@@ -661,9 +772,11 @@ impl LiveCli {
|
||||
model.clone(),
|
||||
system_prompt.clone(),
|
||||
enable_tools,
|
||||
allowed_tools.clone(),
|
||||
)?;
|
||||
let cli = Self {
|
||||
model,
|
||||
allowed_tools,
|
||||
system_prompt,
|
||||
runtime,
|
||||
session,
|
||||
@@ -849,7 +962,13 @@ impl LiveCli {
|
||||
let previous = self.model.clone();
|
||||
let session = self.runtime.session().clone();
|
||||
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.allowed_tools.clone(),
|
||||
)?;
|
||||
self.model.clone_from(&model);
|
||||
self.persist_session()?;
|
||||
println!(
|
||||
@@ -883,6 +1002,7 @@ impl LiveCli {
|
||||
self.model.clone(),
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
self.allowed_tools.clone(),
|
||||
normalized,
|
||||
)?;
|
||||
self.persist_session()?;
|
||||
@@ -907,6 +1027,7 @@ impl LiveCli {
|
||||
self.model.clone(),
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
self.allowed_tools.clone(),
|
||||
permission_mode_label(),
|
||||
)?;
|
||||
self.persist_session()?;
|
||||
@@ -941,6 +1062,7 @@ impl LiveCli {
|
||||
self.model.clone(),
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
self.allowed_tools.clone(),
|
||||
permission_mode_label(),
|
||||
)?;
|
||||
self.session = handle;
|
||||
@@ -1017,6 +1139,7 @@ impl LiveCli {
|
||||
self.model.clone(),
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
self.allowed_tools.clone(),
|
||||
permission_mode_label(),
|
||||
)?;
|
||||
self.session = handle;
|
||||
@@ -1046,6 +1169,7 @@ impl LiveCli {
|
||||
self.model.clone(),
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
self.allowed_tools.clone(),
|
||||
permission_mode_label(),
|
||||
)?;
|
||||
self.persist_session()?;
|
||||
@@ -1571,6 +1695,7 @@ fn build_runtime(
|
||||
model: String,
|
||||
system_prompt: Vec<String>,
|
||||
enable_tools: bool,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
|
||||
{
|
||||
build_runtime_with_permission_mode(
|
||||
@@ -1578,6 +1703,7 @@ fn build_runtime(
|
||||
model,
|
||||
system_prompt,
|
||||
enable_tools,
|
||||
allowed_tools,
|
||||
permission_mode_label(),
|
||||
)
|
||||
}
|
||||
@@ -1587,13 +1713,14 @@ fn build_runtime_with_permission_mode(
|
||||
model: String,
|
||||
system_prompt: Vec<String>,
|
||||
enable_tools: bool,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
permission_mode: &str,
|
||||
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
|
||||
{
|
||||
Ok(ConversationRuntime::new(
|
||||
session,
|
||||
AnthropicRuntimeClient::new(model, enable_tools)?,
|
||||
CliToolExecutor::new(),
|
||||
AnthropicRuntimeClient::new(model, enable_tools, allowed_tools.clone())?,
|
||||
CliToolExecutor::new(allowed_tools),
|
||||
permission_policy(permission_mode),
|
||||
system_prompt,
|
||||
))
|
||||
@@ -1604,15 +1731,21 @@ struct AnthropicRuntimeClient {
|
||||
client: AnthropicClient,
|
||||
model: String,
|
||||
enable_tools: bool,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
}
|
||||
|
||||
impl AnthropicRuntimeClient {
|
||||
fn new(model: String, enable_tools: bool) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
fn new(
|
||||
model: String,
|
||||
enable_tools: bool,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
Ok(Self {
|
||||
runtime: tokio::runtime::Runtime::new()?,
|
||||
client: AnthropicClient::from_env()?,
|
||||
model,
|
||||
enable_tools,
|
||||
allowed_tools,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1626,7 +1759,7 @@ impl ApiClient for AnthropicRuntimeClient {
|
||||
messages: convert_messages(&request.messages),
|
||||
system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")),
|
||||
tools: self.enable_tools.then(|| {
|
||||
mvp_tool_specs()
|
||||
filter_tool_specs(self.allowed_tools.as_ref())
|
||||
.into_iter()
|
||||
.map(|spec| ToolDefinition {
|
||||
name: spec.name.to_string(),
|
||||
@@ -1781,18 +1914,29 @@ fn response_to_events(
|
||||
|
||||
struct CliToolExecutor {
|
||||
renderer: TerminalRenderer,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
}
|
||||
|
||||
impl CliToolExecutor {
|
||||
fn new() -> Self {
|
||||
fn new(allowed_tools: Option<AllowedToolSet>) -> Self {
|
||||
Self {
|
||||
renderer: TerminalRenderer::new(),
|
||||
allowed_tools,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolExecutor for CliToolExecutor {
|
||||
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
|
||||
if self
|
||||
.allowed_tools
|
||||
.as_ref()
|
||||
.is_some_and(|allowed| !allowed.contains(tool_name))
|
||||
{
|
||||
return Err(ToolError::new(format!(
|
||||
"tool `{tool_name}` is not enabled by the current --allowedTools setting"
|
||||
)));
|
||||
}
|
||||
let value = serde_json::from_str(input)
|
||||
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
|
||||
match execute_tool(tool_name, &value) {
|
||||
@@ -1864,7 +2008,7 @@ fn print_help() {
|
||||
println!("rusty-claude-cli v{VERSION}");
|
||||
println!();
|
||||
println!("Usage:");
|
||||
println!(" rusty-claude-cli [--model MODEL]");
|
||||
println!(" rusty-claude-cli [--model MODEL] [--allowedTools TOOL[,TOOL...]]");
|
||||
println!(" Start the interactive REPL");
|
||||
println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] prompt TEXT");
|
||||
println!(" Send one prompt and exit");
|
||||
@@ -1879,6 +2023,8 @@ fn print_help() {
|
||||
println!("Flags:");
|
||||
println!(" --model MODEL Override the active model");
|
||||
println!(" --output-format FORMAT Non-interactive output format: text or json");
|
||||
println!(" --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)");
|
||||
println!(" --version, -V Print version and build information locally");
|
||||
println!();
|
||||
println!("Interactive slash commands:");
|
||||
println!("{}", render_slash_command_help());
|
||||
@@ -1895,18 +2041,20 @@ fn print_help() {
|
||||
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 --allowedTools read,glob \"summarize Cargo.toml\"");
|
||||
println!(" rusty-claude-cli --resume session.json /status /diff /export notes.txt");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
format_compact_report, format_cost_report, format_init_report, format_model_report,
|
||||
format_model_switch_report, format_permissions_report, format_permissions_switch_report,
|
||||
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, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL,
|
||||
filter_tool_specs, format_compact_report, format_cost_report, format_init_report,
|
||||
format_model_report, format_model_switch_report, format_permissions_report,
|
||||
format_permissions_switch_report, 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, CliOutputFormat, SlashCommand,
|
||||
StatusUsage, DEFAULT_MODEL,
|
||||
};
|
||||
use runtime::{ContentBlock, ConversationMessage, MessageRole};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -1917,6 +2065,7 @@ mod tests {
|
||||
parse_args(&[]).expect("args should parse"),
|
||||
CliAction::Repl {
|
||||
model: DEFAULT_MODEL.to_string(),
|
||||
allowed_tools: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1934,6 +2083,7 @@ mod tests {
|
||||
prompt: "hello world".to_string(),
|
||||
model: DEFAULT_MODEL.to_string(),
|
||||
output_format: CliOutputFormat::Text,
|
||||
allowed_tools: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1953,10 +2103,51 @@ mod tests {
|
||||
prompt: "explain this".to_string(),
|
||||
model: "claude-opus".to_string(),
|
||||
output_format: CliOutputFormat::Json,
|
||||
allowed_tools: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_version_flags_without_initializing_prompt_mode() {
|
||||
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 parses_allowed_tools_flags_with_aliases_and_lists() {
|
||||
let args = vec![
|
||||
"--allowedTools".to_string(),
|
||||
"read,glob".to_string(),
|
||||
"--allowed-tools=write_file".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
parse_args(&args).expect("args should parse"),
|
||||
CliAction::Repl {
|
||||
model: DEFAULT_MODEL.to_string(),
|
||||
allowed_tools: Some(
|
||||
["glob_search", "read_file", "write_file"]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_allowed_tools() {
|
||||
let error = parse_args(&["--allowedTools".to_string(), "teleport".to_string()])
|
||||
.expect_err("tool should be rejected");
|
||||
assert!(error.contains("unsupported tool in --allowedTools: teleport"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_system_prompt_options() {
|
||||
let args = vec![
|
||||
@@ -2013,6 +2204,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_tool_specs_respect_allowlist() {
|
||||
let allowed = ["read_file", "grep_search"]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
let filtered = filter_tool_specs(Some(&allowed));
|
||||
let names = filtered
|
||||
.into_iter()
|
||||
.map(|spec| spec.name)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(names, vec!["read_file", "grep_search"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_help_uses_resume_annotation_copy() {
|
||||
let help = commands::render_slash_command_help();
|
||||
|
||||
Reference in New Issue
Block a user