mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-05 16:39:04 +08:00
Compare commits
1 Commits
rcc/image
...
cd01d0e387
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd01d0e387 |
@@ -14,6 +14,13 @@ pub enum ConfigSource {
|
||||
Local,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ResolvedPermissionMode {
|
||||
ReadOnly,
|
||||
WorkspaceWrite,
|
||||
DangerFullAccess,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConfigEntry {
|
||||
pub source: ConfigSource,
|
||||
@@ -31,6 +38,8 @@ pub struct RuntimeConfig {
|
||||
pub struct RuntimeFeatureConfig {
|
||||
mcp: McpConfigCollection,
|
||||
oauth: Option<OAuthConfig>,
|
||||
model: Option<String>,
|
||||
permission_mode: Option<ResolvedPermissionMode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
@@ -165,11 +174,23 @@ impl ConfigLoader {
|
||||
|
||||
#[must_use]
|
||||
pub fn discover(&self) -> Vec<ConfigEntry> {
|
||||
let user_legacy_path = self.config_home.parent().map_or_else(
|
||||
|| PathBuf::from(".claude.json"),
|
||||
|parent| parent.join(".claude.json"),
|
||||
);
|
||||
vec![
|
||||
ConfigEntry {
|
||||
source: ConfigSource::User,
|
||||
path: user_legacy_path,
|
||||
},
|
||||
ConfigEntry {
|
||||
source: ConfigSource::User,
|
||||
path: self.config_home.join("settings.json"),
|
||||
},
|
||||
ConfigEntry {
|
||||
source: ConfigSource::Project,
|
||||
path: self.cwd.join(".claude.json"),
|
||||
},
|
||||
ConfigEntry {
|
||||
source: ConfigSource::Project,
|
||||
path: self.cwd.join(".claude").join("settings.json"),
|
||||
@@ -195,14 +216,15 @@ impl ConfigLoader {
|
||||
loaded_entries.push(entry);
|
||||
}
|
||||
|
||||
let merged_value = JsonValue::Object(merged.clone());
|
||||
|
||||
let feature_config = RuntimeFeatureConfig {
|
||||
mcp: McpConfigCollection {
|
||||
servers: mcp_servers,
|
||||
},
|
||||
oauth: parse_optional_oauth_config(
|
||||
&JsonValue::Object(merged.clone()),
|
||||
"merged settings.oauth",
|
||||
)?,
|
||||
oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
|
||||
model: parse_optional_model(&merged_value),
|
||||
permission_mode: parse_optional_permission_mode(&merged_value)?,
|
||||
};
|
||||
|
||||
Ok(RuntimeConfig {
|
||||
@@ -257,6 +279,16 @@ impl RuntimeConfig {
|
||||
pub fn oauth(&self) -> Option<&OAuthConfig> {
|
||||
self.feature_config.oauth.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn model(&self) -> Option<&str> {
|
||||
self.feature_config.model.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn permission_mode(&self) -> Option<ResolvedPermissionMode> {
|
||||
self.feature_config.permission_mode
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeFeatureConfig {
|
||||
@@ -269,6 +301,16 @@ impl RuntimeFeatureConfig {
|
||||
pub fn oauth(&self) -> Option<&OAuthConfig> {
|
||||
self.oauth.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn model(&self) -> Option<&str> {
|
||||
self.model.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn permission_mode(&self) -> Option<ResolvedPermissionMode> {
|
||||
self.permission_mode
|
||||
}
|
||||
}
|
||||
|
||||
impl McpConfigCollection {
|
||||
@@ -307,6 +349,7 @@ impl McpServerConfig {
|
||||
fn read_optional_json_object(
|
||||
path: &Path,
|
||||
) -> Result<Option<BTreeMap<String, JsonValue>>, ConfigError> {
|
||||
let is_legacy_config = path.file_name().and_then(|name| name.to_str()) == Some(".claude.json");
|
||||
let contents = match fs::read_to_string(path) {
|
||||
Ok(contents) => contents,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
@@ -317,14 +360,20 @@ fn read_optional_json_object(
|
||||
return Ok(Some(BTreeMap::new()));
|
||||
}
|
||||
|
||||
let parsed = JsonValue::parse(&contents)
|
||||
.map_err(|error| ConfigError::Parse(format!("{}: {error}", path.display())))?;
|
||||
let object = parsed.as_object().ok_or_else(|| {
|
||||
ConfigError::Parse(format!(
|
||||
let parsed = match JsonValue::parse(&contents) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(error) if is_legacy_config => return Ok(None),
|
||||
Err(error) => return Err(ConfigError::Parse(format!("{}: {error}", path.display()))),
|
||||
};
|
||||
let Some(object) = parsed.as_object() else {
|
||||
if is_legacy_config {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(ConfigError::Parse(format!(
|
||||
"{}: top-level settings value must be a JSON object",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
)));
|
||||
};
|
||||
Ok(Some(object.clone()))
|
||||
}
|
||||
|
||||
@@ -355,6 +404,47 @@ fn merge_mcp_servers(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_optional_model(root: &JsonValue) -> Option<String> {
|
||||
root.as_object()
|
||||
.and_then(|object| object.get("model"))
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn parse_optional_permission_mode(
|
||||
root: &JsonValue,
|
||||
) -> Result<Option<ResolvedPermissionMode>, ConfigError> {
|
||||
let Some(object) = root.as_object() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let Some(mode) = object.get("permissionMode").and_then(JsonValue::as_str) {
|
||||
return parse_permission_mode_label(mode, "merged settings.permissionMode").map(Some);
|
||||
}
|
||||
let Some(mode) = object
|
||||
.get("permissions")
|
||||
.and_then(JsonValue::as_object)
|
||||
.and_then(|permissions| permissions.get("defaultMode"))
|
||||
.and_then(JsonValue::as_str)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
parse_permission_mode_label(mode, "merged settings.permissions.defaultMode").map(Some)
|
||||
}
|
||||
|
||||
fn parse_permission_mode_label(
|
||||
mode: &str,
|
||||
context: &str,
|
||||
) -> Result<ResolvedPermissionMode, ConfigError> {
|
||||
match mode {
|
||||
"default" | "plan" | "read-only" => Ok(ResolvedPermissionMode::ReadOnly),
|
||||
"acceptEdits" | "auto" | "workspace-write" => Ok(ResolvedPermissionMode::WorkspaceWrite),
|
||||
"dontAsk" | "danger-full-access" => Ok(ResolvedPermissionMode::DangerFullAccess),
|
||||
other => Err(ConfigError::Parse(format!(
|
||||
"{context}: unsupported permission mode {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_optional_oauth_config(
|
||||
root: &JsonValue,
|
||||
context: &str,
|
||||
@@ -594,7 +684,8 @@ fn deep_merge_objects(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ConfigLoader, ConfigSource, McpServerConfig, McpTransport, CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
|
||||
ConfigLoader, ConfigSource, McpServerConfig, McpTransport, ResolvedPermissionMode,
|
||||
CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
|
||||
};
|
||||
use crate::json::JsonValue;
|
||||
use std::fs;
|
||||
@@ -635,14 +726,24 @@ mod tests {
|
||||
fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
|
||||
fs::write(
|
||||
home.parent().expect("home parent").join(".claude.json"),
|
||||
r#"{"model":"haiku","env":{"A":"1"},"mcpServers":{"home":{"command":"uvx","args":["home"]}}}"#,
|
||||
)
|
||||
.expect("write user compat config");
|
||||
fs::write(
|
||||
home.join("settings.json"),
|
||||
r#"{"model":"sonnet","env":{"A":"1"},"hooks":{"PreToolUse":["base"]}}"#,
|
||||
r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan"}}"#,
|
||||
)
|
||||
.expect("write user settings");
|
||||
fs::write(
|
||||
cwd.join(".claude.json"),
|
||||
r#"{"model":"project-compat","env":{"B":"2"}}"#,
|
||||
)
|
||||
.expect("write project compat config");
|
||||
fs::write(
|
||||
cwd.join(".claude").join("settings.json"),
|
||||
r#"{"env":{"B":"2"},"hooks":{"PostToolUse":["project"]}}"#,
|
||||
r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#,
|
||||
)
|
||||
.expect("write project settings");
|
||||
fs::write(
|
||||
@@ -656,25 +757,37 @@ mod tests {
|
||||
.expect("config should load");
|
||||
|
||||
assert_eq!(CLAUDE_CODE_SETTINGS_SCHEMA_NAME, "SettingsSchema");
|
||||
assert_eq!(loaded.loaded_entries().len(), 3);
|
||||
assert_eq!(loaded.loaded_entries().len(), 5);
|
||||
assert_eq!(loaded.loaded_entries()[0].source, ConfigSource::User);
|
||||
assert_eq!(
|
||||
loaded.get("model"),
|
||||
Some(&JsonValue::String("opus".to_string()))
|
||||
);
|
||||
assert_eq!(loaded.model(), Some("opus"));
|
||||
assert_eq!(
|
||||
loaded.permission_mode(),
|
||||
Some(ResolvedPermissionMode::WorkspaceWrite)
|
||||
);
|
||||
assert_eq!(
|
||||
loaded
|
||||
.get("env")
|
||||
.and_then(JsonValue::as_object)
|
||||
.expect("env object")
|
||||
.len(),
|
||||
2
|
||||
4
|
||||
);
|
||||
assert!(loaded
|
||||
.get("hooks")
|
||||
.and_then(JsonValue::as_object)
|
||||
.expect("hooks object")
|
||||
.contains_key("PreToolUse"));
|
||||
assert!(loaded
|
||||
.get("hooks")
|
||||
.and_then(JsonValue::as_object)
|
||||
.expect("hooks object")
|
||||
.contains_key("PostToolUse"));
|
||||
assert!(loaded.mcp().get("home").is_some());
|
||||
assert!(loaded.mcp().get("project").is_some());
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ pub use config::{
|
||||
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpClaudeAiProxyServerConfig,
|
||||
McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
|
||||
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
|
||||
RuntimeConfig, RuntimeFeatureConfig, ScopedMcpServerConfig, CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
|
||||
ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, ScopedMcpServerConfig,
|
||||
CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
|
||||
};
|
||||
pub use conversation::{
|
||||
ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor,
|
||||
@@ -76,3 +77,11 @@ pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, Sessi
|
||||
pub use usage::{
|
||||
format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
|
||||
LOCK.get_or_init(|| std::sync::Mutex::new(()))
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
@@ -448,7 +448,6 @@ fn decode_hex(byte: u8) -> Result<u8, String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use super::{
|
||||
@@ -470,10 +469,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.expect("env lock")
|
||||
crate::test_env_lock()
|
||||
}
|
||||
|
||||
fn temp_config_home() -> std::path::PathBuf {
|
||||
|
||||
@@ -201,6 +201,7 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
|
||||
dir.join("CLAUDE.md"),
|
||||
dir.join("CLAUDE.local.md"),
|
||||
dir.join(".claude").join("CLAUDE.md"),
|
||||
dir.join(".claude").join("instructions.md"),
|
||||
] {
|
||||
push_context_file(&mut files, candidate)?;
|
||||
}
|
||||
@@ -468,6 +469,10 @@ mod tests {
|
||||
std::env::temp_dir().join(format!("runtime-prompt-{nanos}"))
|
||||
}
|
||||
|
||||
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
crate::test_env_lock()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_instruction_files_from_ancestor_chain() {
|
||||
let root = temp_dir();
|
||||
@@ -477,10 +482,21 @@ mod tests {
|
||||
fs::write(root.join("CLAUDE.local.md"), "local instructions")
|
||||
.expect("write local instructions");
|
||||
fs::create_dir_all(root.join("apps")).expect("apps dir");
|
||||
fs::create_dir_all(root.join("apps").join(".claude")).expect("apps claude dir");
|
||||
fs::write(root.join("apps").join("CLAUDE.md"), "apps instructions")
|
||||
.expect("write apps instructions");
|
||||
fs::write(
|
||||
root.join("apps").join(".claude").join("instructions.md"),
|
||||
"apps dot claude instructions",
|
||||
)
|
||||
.expect("write apps dot claude instructions");
|
||||
fs::write(nested.join(".claude").join("CLAUDE.md"), "nested rules")
|
||||
.expect("write nested rules");
|
||||
fs::write(
|
||||
nested.join(".claude").join("instructions.md"),
|
||||
"nested instructions",
|
||||
)
|
||||
.expect("write nested instructions");
|
||||
|
||||
let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
|
||||
let contents = context
|
||||
@@ -495,7 +511,9 @@ mod tests {
|
||||
"root instructions",
|
||||
"local instructions",
|
||||
"apps instructions",
|
||||
"nested rules"
|
||||
"apps dot claude instructions",
|
||||
"nested rules",
|
||||
"nested instructions"
|
||||
]
|
||||
);
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
@@ -574,7 +592,12 @@ mod tests {
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let _guard = env_lock();
|
||||
let previous = std::env::current_dir().expect("cwd");
|
||||
let original_home = std::env::var("HOME").ok();
|
||||
let original_claude_home = std::env::var("CLAUDE_CONFIG_HOME").ok();
|
||||
std::env::set_var("HOME", &root);
|
||||
std::env::set_var("CLAUDE_CONFIG_HOME", root.join("missing-home"));
|
||||
std::env::set_current_dir(&root).expect("change cwd");
|
||||
let prompt = super::load_system_prompt(&root, "2026-03-31", "linux", "6.8")
|
||||
.expect("system prompt should load")
|
||||
@@ -584,6 +607,16 @@ mod tests {
|
||||
",
|
||||
);
|
||||
std::env::set_current_dir(previous).expect("restore cwd");
|
||||
if let Some(value) = original_home {
|
||||
std::env::set_var("HOME", value);
|
||||
} else {
|
||||
std::env::remove_var("HOME");
|
||||
}
|
||||
if let Some(value) = original_claude_home {
|
||||
std::env::set_var("CLAUDE_CONFIG_HOME", value);
|
||||
} else {
|
||||
std::env::remove_var("CLAUDE_CONFIG_HOME");
|
||||
}
|
||||
|
||||
assert!(prompt.contains("Project rules"));
|
||||
assert!(prompt.contains("permissionMode"));
|
||||
@@ -631,6 +664,29 @@ mod tests {
|
||||
assert!(rendered.chars().count() <= 4_000 + "\n\n[truncated]".chars().count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_dot_claude_instructions_markdown() {
|
||||
let root = temp_dir();
|
||||
let nested = root.join("apps").join("api");
|
||||
fs::create_dir_all(nested.join(".claude")).expect("nested claude dir");
|
||||
fs::write(
|
||||
nested.join(".claude").join("instructions.md"),
|
||||
"instruction markdown",
|
||||
)
|
||||
.expect("write instructions.md");
|
||||
|
||||
let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
|
||||
assert!(context
|
||||
.instruction_files
|
||||
.iter()
|
||||
.any(|file| file.path.ends_with(".claude/instructions.md")));
|
||||
assert!(
|
||||
render_instruction_files(&context.instruction_files).contains("instruction markdown")
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_instruction_file_metadata() {
|
||||
let rendered = render_instruction_files(&[ContextFile {
|
||||
|
||||
@@ -23,9 +23,10 @@ use runtime::{
|
||||
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
|
||||
parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest,
|
||||
AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
|
||||
ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest,
|
||||
OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
|
||||
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
|
||||
ConversationMessage, ConversationRuntime, McpServerManager, MessageRole,
|
||||
OAuthAuthorizationRequest, OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy,
|
||||
ProjectContext, ResolvedPermissionMode, RuntimeError, Session, TokenUsage, ToolError,
|
||||
ToolExecutor, UsageTracker,
|
||||
};
|
||||
use serde_json::json;
|
||||
use tools::{execute_tool, mvp_tool_specs};
|
||||
@@ -53,7 +54,9 @@ Run `rusty-claude-cli --help` for usage."
|
||||
|
||||
fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<String> = env::args().skip(1).collect();
|
||||
match parse_args(&args)? {
|
||||
let runtime_config = load_runtime_config()?;
|
||||
let defaults = RuntimeDefaults::from_config(&runtime_config);
|
||||
match parse_args(&args, &defaults)? {
|
||||
CliAction::DumpManifests => dump_manifests(),
|
||||
CliAction::BootstrapPlan => print_bootstrap_plan(),
|
||||
CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
|
||||
@@ -80,6 +83,11 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_runtime_config() -> Result<runtime::RuntimeConfig, Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
Ok(ConfigLoader::default_for(&cwd).load()?)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum CliAction {
|
||||
DumpManifests,
|
||||
@@ -127,8 +135,8 @@ impl CliOutputFormat {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
let mut model = DEFAULT_MODEL.to_string();
|
||||
fn parse_args(args: &[String], defaults: &RuntimeDefaults) -> Result<CliAction, String> {
|
||||
let mut model = defaults.model.clone();
|
||||
let mut output_format = CliOutputFormat::Text;
|
||||
let mut wants_version = false;
|
||||
let mut allowed_tool_values = Vec::new();
|
||||
@@ -232,6 +240,32 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct RuntimeDefaults {
|
||||
model: String,
|
||||
}
|
||||
|
||||
impl RuntimeDefaults {
|
||||
fn from_config(config: &runtime::RuntimeConfig) -> Self {
|
||||
Self {
|
||||
model: config.model().unwrap_or(DEFAULT_MODEL).to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolved_permission_mode_label(config: &runtime::RuntimeConfig) -> &'static str {
|
||||
match env::var("RUSTY_CLAUDE_PERMISSION_MODE") {
|
||||
Ok(value) if value == "read-only" => "read-only",
|
||||
Ok(value) if value == "danger-full-access" => "danger-full-access",
|
||||
Ok(value) if value == "workspace-write" => "workspace-write",
|
||||
_ => match config.permission_mode() {
|
||||
Some(ResolvedPermissionMode::ReadOnly) => "read-only",
|
||||
Some(ResolvedPermissionMode::DangerFullAccess) => "danger-full-access",
|
||||
Some(ResolvedPermissionMode::WorkspaceWrite) | None => "workspace-write",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_allowed_tools(values: &[String]) -> Result<Option<AllowedToolSet>, String> {
|
||||
if values.is_empty() {
|
||||
return Ok(None);
|
||||
@@ -892,14 +926,18 @@ impl LiveCli {
|
||||
enable_tools: bool,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let config = load_runtime_config()?;
|
||||
let system_prompt = build_system_prompt()?;
|
||||
let session = create_managed_session_handle()?;
|
||||
let permission_mode = resolved_permission_mode_label(&config);
|
||||
let runtime = build_runtime(
|
||||
Session::new(),
|
||||
model.clone(),
|
||||
system_prompt.clone(),
|
||||
enable_tools,
|
||||
allowed_tools.clone(),
|
||||
&config,
|
||||
permission_mode,
|
||||
)?;
|
||||
let cli = Self {
|
||||
model,
|
||||
@@ -1089,12 +1127,15 @@ impl LiveCli {
|
||||
let previous = self.model.clone();
|
||||
let session = self.runtime.session().clone();
|
||||
let message_count = session.messages.len();
|
||||
let config = load_runtime_config()?;
|
||||
self.runtime = build_runtime(
|
||||
session,
|
||||
model.clone(),
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
self.allowed_tools.clone(),
|
||||
&config,
|
||||
resolved_permission_mode_label(&config),
|
||||
)?;
|
||||
self.model.clone_from(&model);
|
||||
self.persist_session()?;
|
||||
@@ -1124,12 +1165,14 @@ impl LiveCli {
|
||||
|
||||
let previous = permission_mode_label().to_string();
|
||||
let session = self.runtime.session().clone();
|
||||
let config = load_runtime_config()?;
|
||||
self.runtime = build_runtime_with_permission_mode(
|
||||
session,
|
||||
self.model.clone(),
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
self.allowed_tools.clone(),
|
||||
&config,
|
||||
normalized,
|
||||
)?;
|
||||
self.persist_session()?;
|
||||
@@ -1149,12 +1192,14 @@ impl LiveCli {
|
||||
}
|
||||
|
||||
self.session = create_managed_session_handle()?;
|
||||
let config = load_runtime_config()?;
|
||||
self.runtime = build_runtime_with_permission_mode(
|
||||
Session::new(),
|
||||
self.model.clone(),
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
self.allowed_tools.clone(),
|
||||
&config,
|
||||
permission_mode_label(),
|
||||
)?;
|
||||
self.persist_session()?;
|
||||
@@ -1184,12 +1229,14 @@ impl LiveCli {
|
||||
let handle = resolve_session_reference(&session_ref)?;
|
||||
let session = Session::load_from_path(&handle.path)?;
|
||||
let message_count = session.messages.len();
|
||||
let config = load_runtime_config()?;
|
||||
self.runtime = build_runtime_with_permission_mode(
|
||||
session,
|
||||
self.model.clone(),
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
self.allowed_tools.clone(),
|
||||
&config,
|
||||
permission_mode_label(),
|
||||
)?;
|
||||
self.session = handle;
|
||||
@@ -1261,12 +1308,14 @@ impl LiveCli {
|
||||
let handle = resolve_session_reference(target)?;
|
||||
let session = Session::load_from_path(&handle.path)?;
|
||||
let message_count = session.messages.len();
|
||||
let config = load_runtime_config()?;
|
||||
self.runtime = build_runtime_with_permission_mode(
|
||||
session,
|
||||
self.model.clone(),
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
self.allowed_tools.clone(),
|
||||
&config,
|
||||
permission_mode_label(),
|
||||
)?;
|
||||
self.session = handle;
|
||||
@@ -1291,12 +1340,14 @@ impl LiveCli {
|
||||
let removed = result.removed_message_count;
|
||||
let kept = result.compacted_session.messages.len();
|
||||
let skipped = removed == 0;
|
||||
let config = load_runtime_config()?;
|
||||
self.runtime = build_runtime_with_permission_mode(
|
||||
result.compacted_session,
|
||||
self.model.clone(),
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
self.allowed_tools.clone(),
|
||||
&config,
|
||||
permission_mode_label(),
|
||||
)?;
|
||||
self.persist_session()?;
|
||||
@@ -1687,11 +1738,11 @@ fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
|
||||
}
|
||||
|
||||
fn permission_mode_label() -> &'static str {
|
||||
match env::var("RUSTY_CLAUDE_PERMISSION_MODE") {
|
||||
Ok(value) if value == "read-only" => "read-only",
|
||||
Ok(value) if value == "danger-full-access" => "danger-full-access",
|
||||
_ => "workspace-write",
|
||||
}
|
||||
let cwd = env::current_dir().ok();
|
||||
let config = cwd.and_then(|cwd| ConfigLoader::default_for(cwd).load().ok());
|
||||
config
|
||||
.as_ref()
|
||||
.map_or("workspace-write", resolved_permission_mode_label)
|
||||
}
|
||||
|
||||
fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> {
|
||||
@@ -1823,6 +1874,8 @@ fn build_runtime(
|
||||
system_prompt: Vec<String>,
|
||||
enable_tools: bool,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
config: &runtime::RuntimeConfig,
|
||||
permission_mode: &str,
|
||||
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
|
||||
{
|
||||
build_runtime_with_permission_mode(
|
||||
@@ -1831,7 +1884,8 @@ fn build_runtime(
|
||||
system_prompt,
|
||||
enable_tools,
|
||||
allowed_tools,
|
||||
permission_mode_label(),
|
||||
config,
|
||||
permission_mode,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1841,13 +1895,14 @@ fn build_runtime_with_permission_mode(
|
||||
system_prompt: Vec<String>,
|
||||
enable_tools: bool,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
config: &runtime::RuntimeConfig,
|
||||
permission_mode: &str,
|
||||
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
|
||||
{
|
||||
Ok(ConversationRuntime::new(
|
||||
session,
|
||||
AnthropicRuntimeClient::new(model, enable_tools, allowed_tools.clone())?,
|
||||
CliToolExecutor::new(allowed_tools),
|
||||
AnthropicRuntimeClient::new(model, enable_tools, allowed_tools.clone(), config)?,
|
||||
CliToolExecutor::new(allowed_tools, config),
|
||||
permission_policy(permission_mode),
|
||||
system_prompt,
|
||||
))
|
||||
@@ -1859,6 +1914,7 @@ struct AnthropicRuntimeClient {
|
||||
model: String,
|
||||
enable_tools: bool,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
mcp_tool_definitions: Vec<ToolDefinition>,
|
||||
}
|
||||
|
||||
impl AnthropicRuntimeClient {
|
||||
@@ -1866,17 +1922,49 @@ impl AnthropicRuntimeClient {
|
||||
model: String,
|
||||
enable_tools: bool,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
config: &runtime::RuntimeConfig,
|
||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let mcp_tool_definitions = discover_mcp_tool_definitions(config, allowed_tools.as_ref())?;
|
||||
Ok(Self {
|
||||
runtime: tokio::runtime::Runtime::new()?,
|
||||
client: AnthropicClient::from_auth(resolve_cli_auth_source()?),
|
||||
model,
|
||||
enable_tools,
|
||||
allowed_tools,
|
||||
mcp_tool_definitions,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn discover_mcp_tool_definitions(
|
||||
config: &runtime::RuntimeConfig,
|
||||
allowed_tools: Option<&AllowedToolSet>,
|
||||
) -> Result<Vec<ToolDefinition>, Box<dyn std::error::Error>> {
|
||||
if allowed_tools.is_some() || config.mcp().servers().is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let runtime = tokio::runtime::Runtime::new()?;
|
||||
let tools = runtime.block_on(async {
|
||||
let mut manager = McpServerManager::from_runtime_config(config);
|
||||
let tools = manager.discover_tools().await?;
|
||||
manager.shutdown().await?;
|
||||
Ok::<_, runtime::McpServerManagerError>(tools)
|
||||
})?;
|
||||
|
||||
Ok(tools
|
||||
.into_iter()
|
||||
.map(|tool| ToolDefinition {
|
||||
name: tool.qualified_name,
|
||||
description: tool.tool.description,
|
||||
input_schema: tool
|
||||
.tool
|
||||
.input_schema
|
||||
.unwrap_or_else(|| serde_json::json!({"type":"object"})),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
|
||||
match AuthSource::from_env() {
|
||||
Ok(auth) => Ok(auth),
|
||||
@@ -1910,6 +1998,7 @@ impl ApiClient for AnthropicRuntimeClient {
|
||||
description: Some(spec.description.to_string()),
|
||||
input_schema: spec.input_schema,
|
||||
})
|
||||
.chain(self.mcp_tool_definitions.iter().cloned())
|
||||
.collect()
|
||||
}),
|
||||
tool_choice: self.enable_tools.then_some(ToolChoice::Auto),
|
||||
@@ -2059,13 +2148,22 @@ fn response_to_events(
|
||||
struct CliToolExecutor {
|
||||
renderer: TerminalRenderer,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
mcp_runtime: Option<tokio::runtime::Runtime>,
|
||||
mcp_servers: Option<McpServerManager>,
|
||||
}
|
||||
|
||||
impl CliToolExecutor {
|
||||
fn new(allowed_tools: Option<AllowedToolSet>) -> Self {
|
||||
fn new(allowed_tools: Option<AllowedToolSet>, config: &runtime::RuntimeConfig) -> Self {
|
||||
let mcp_servers = (!config.mcp().servers().is_empty())
|
||||
.then(|| McpServerManager::from_runtime_config(config));
|
||||
let mcp_runtime = mcp_servers
|
||||
.as_ref()
|
||||
.map(|_| tokio::runtime::Runtime::new().expect("mcp runtime"));
|
||||
Self {
|
||||
renderer: TerminalRenderer::new(),
|
||||
allowed_tools,
|
||||
mcp_runtime,
|
||||
mcp_servers,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2081,8 +2179,35 @@ impl ToolExecutor for CliToolExecutor {
|
||||
"tool `{tool_name}` is not enabled by the current --allowedTools setting"
|
||||
)));
|
||||
}
|
||||
let value = serde_json::from_str(input)
|
||||
let value: serde_json::Value = serde_json::from_str(input)
|
||||
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
|
||||
if tool_name.starts_with("mcp__") {
|
||||
let runtime = self
|
||||
.mcp_runtime
|
||||
.as_mut()
|
||||
.ok_or_else(|| ToolError::new("MCP runtime is not configured"))?;
|
||||
let manager = self
|
||||
.mcp_servers
|
||||
.as_mut()
|
||||
.ok_or_else(|| ToolError::new("MCP servers are not configured"))?;
|
||||
let response = runtime
|
||||
.block_on(manager.call_tool(tool_name, Some(value.clone())))
|
||||
.map_err(|error| ToolError::new(error.to_string()))?;
|
||||
let output = serde_json::to_string_pretty(&response)
|
||||
.map_err(|error| ToolError::new(error.to_string()))?;
|
||||
let markdown = format!(
|
||||
"### Tool `{tool_name}`
|
||||
|
||||
```json
|
||||
{output}
|
||||
```
|
||||
"
|
||||
);
|
||||
self.renderer
|
||||
.stream_markdown(&markdown, &mut io::stdout())
|
||||
.map_err(|error| ToolError::new(error.to_string()))?;
|
||||
return Ok(output);
|
||||
}
|
||||
match execute_tool(tool_name, &value) {
|
||||
Ok(output) => {
|
||||
let markdown = format!("### Tool `{tool_name}`\n\n```json\n{output}\n```\n");
|
||||
@@ -2195,21 +2320,116 @@ fn print_help() {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
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,
|
||||
discover_mcp_tool_definitions, 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,
|
||||
resolved_permission_mode_label, resume_supported_slash_commands, status_context, CliAction,
|
||||
CliOutputFormat, RuntimeDefaults, SlashCommand, StatusUsage, DEFAULT_MODEL,
|
||||
};
|
||||
use runtime::{ContentBlock, ConversationMessage, MessageRole};
|
||||
use runtime::{
|
||||
ConfigLoader, ContentBlock, ConversationMessage, MessageRole, ResolvedPermissionMode,
|
||||
};
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir() -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("time should be after epoch")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("rusty-claude-cli-tests-{nanos}"))
|
||||
}
|
||||
|
||||
fn write_mcp_server_script() -> PathBuf {
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(&root).expect("temp dir");
|
||||
let path = root.join("mcp-server.py");
|
||||
fs::write(
|
||||
&path,
|
||||
r#"#!/usr/bin/env python3
|
||||
import json
|
||||
import sys
|
||||
|
||||
def send(obj):
|
||||
payload = json.dumps(obj)
|
||||
sys.stdout.write(f"Content-Length: {len(payload)}\r\n\r\n{payload}")
|
||||
sys.stdout.flush()
|
||||
|
||||
def read_request():
|
||||
headers = {}
|
||||
while True:
|
||||
line = sys.stdin.buffer.readline()
|
||||
if not line:
|
||||
return None
|
||||
if line in (b"\r\n", b"\n"):
|
||||
break
|
||||
key, _, value = line.decode().partition(":")
|
||||
headers[key.strip().lower()] = value.strip()
|
||||
length = int(headers.get("content-length", "0"))
|
||||
if length <= 0:
|
||||
return None
|
||||
payload = sys.stdin.buffer.read(length)
|
||||
return json.loads(payload.decode())
|
||||
|
||||
while True:
|
||||
req = read_request()
|
||||
if req is None:
|
||||
break
|
||||
method = req.get("method")
|
||||
req_id = req.get("id")
|
||||
if method == "initialize":
|
||||
send({
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": {},
|
||||
"serverInfo": {"name": "test-server", "version": "0.1.0"}
|
||||
}
|
||||
})
|
||||
elif method == "tools/list":
|
||||
send({
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"tools": [{
|
||||
"name": "echo",
|
||||
"description": "Echo from MCP",
|
||||
"inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}}
|
||||
}]
|
||||
}
|
||||
})
|
||||
elif method == "tools/call":
|
||||
send({
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"content": [{"type": "text", "text": req.get("params", {}).get("name", "")}]
|
||||
}
|
||||
})
|
||||
"#,
|
||||
)
|
||||
.expect("write mcp server");
|
||||
let mut permissions = fs::metadata(&path).expect("metadata").permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&path, permissions).expect("chmod");
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_to_repl_when_no_args() {
|
||||
assert_eq!(
|
||||
parse_args(&[]).expect("args should parse"),
|
||||
parse_args(
|
||||
&[],
|
||||
&RuntimeDefaults {
|
||||
model: DEFAULT_MODEL.to_string()
|
||||
}
|
||||
)
|
||||
.expect("args should parse"),
|
||||
CliAction::Repl {
|
||||
model: DEFAULT_MODEL.to_string(),
|
||||
allowed_tools: None,
|
||||
@@ -2217,6 +2437,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_args_uses_config_default_model_when_no_override_is_supplied() {
|
||||
let parsed = parse_args(
|
||||
&[],
|
||||
&RuntimeDefaults {
|
||||
model: "claude-opus-config".to_string(),
|
||||
},
|
||||
)
|
||||
.expect("args should parse");
|
||||
assert_eq!(
|
||||
parsed,
|
||||
CliAction::Repl {
|
||||
model: "claude-opus-config".to_string(),
|
||||
allowed_tools: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_model_flag_beats_config_default_model() {
|
||||
let parsed = parse_args(
|
||||
&["--model".to_string(), "cli-model".to_string()],
|
||||
&RuntimeDefaults {
|
||||
model: "config-model".to_string(),
|
||||
},
|
||||
)
|
||||
.expect("args should parse");
|
||||
assert_eq!(
|
||||
parsed,
|
||||
CliAction::Repl {
|
||||
model: "cli-model".to_string(),
|
||||
allowed_tools: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_prompt_subcommand() {
|
||||
let args = vec![
|
||||
@@ -2225,7 +2481,13 @@ mod tests {
|
||||
"world".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
parse_args(&args).expect("args should parse"),
|
||||
parse_args(
|
||||
&args,
|
||||
&RuntimeDefaults {
|
||||
model: DEFAULT_MODEL.to_string()
|
||||
}
|
||||
)
|
||||
.expect("args should parse"),
|
||||
CliAction::Prompt {
|
||||
prompt: "hello world".to_string(),
|
||||
model: DEFAULT_MODEL.to_string(),
|
||||
@@ -2245,7 +2507,13 @@ mod tests {
|
||||
"this".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
parse_args(&args).expect("args should parse"),
|
||||
parse_args(
|
||||
&args,
|
||||
&RuntimeDefaults {
|
||||
model: DEFAULT_MODEL.to_string()
|
||||
}
|
||||
)
|
||||
.expect("args should parse"),
|
||||
CliAction::Prompt {
|
||||
prompt: "explain this".to_string(),
|
||||
model: "claude-opus".to_string(),
|
||||
@@ -2258,11 +2526,23 @@ mod tests {
|
||||
#[test]
|
||||
fn parses_version_flags_without_initializing_prompt_mode() {
|
||||
assert_eq!(
|
||||
parse_args(&["--version".to_string()]).expect("args should parse"),
|
||||
parse_args(
|
||||
&["--version".to_string()],
|
||||
&RuntimeDefaults {
|
||||
model: DEFAULT_MODEL.to_string()
|
||||
}
|
||||
)
|
||||
.expect("args should parse"),
|
||||
CliAction::Version
|
||||
);
|
||||
assert_eq!(
|
||||
parse_args(&["-V".to_string()]).expect("args should parse"),
|
||||
parse_args(
|
||||
&["-V".to_string()],
|
||||
&RuntimeDefaults {
|
||||
model: DEFAULT_MODEL.to_string()
|
||||
}
|
||||
)
|
||||
.expect("args should parse"),
|
||||
CliAction::Version
|
||||
);
|
||||
}
|
||||
@@ -2275,7 +2555,13 @@ mod tests {
|
||||
"--allowed-tools=write_file".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
parse_args(&args).expect("args should parse"),
|
||||
parse_args(
|
||||
&args,
|
||||
&RuntimeDefaults {
|
||||
model: DEFAULT_MODEL.to_string()
|
||||
}
|
||||
)
|
||||
.expect("args should parse"),
|
||||
CliAction::Repl {
|
||||
model: DEFAULT_MODEL.to_string(),
|
||||
allowed_tools: Some(
|
||||
@@ -2290,8 +2576,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_allowed_tools() {
|
||||
let error = parse_args(&["--allowedTools".to_string(), "teleport".to_string()])
|
||||
.expect_err("tool should be rejected");
|
||||
let error = parse_args(
|
||||
&["--allowedTools".to_string(), "teleport".to_string()],
|
||||
&RuntimeDefaults {
|
||||
model: DEFAULT_MODEL.to_string(),
|
||||
},
|
||||
)
|
||||
.expect_err("tool should be rejected");
|
||||
assert!(error.contains("unsupported tool in --allowedTools: teleport"));
|
||||
}
|
||||
|
||||
@@ -2305,7 +2596,13 @@ mod tests {
|
||||
"2026-04-01".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
parse_args(&args).expect("args should parse"),
|
||||
parse_args(
|
||||
&args,
|
||||
&RuntimeDefaults {
|
||||
model: DEFAULT_MODEL.to_string()
|
||||
}
|
||||
)
|
||||
.expect("args should parse"),
|
||||
CliAction::PrintSystemPrompt {
|
||||
cwd: PathBuf::from("/tmp/project"),
|
||||
date: "2026-04-01".to_string(),
|
||||
@@ -2316,11 +2613,23 @@ mod tests {
|
||||
#[test]
|
||||
fn parses_login_and_logout_subcommands() {
|
||||
assert_eq!(
|
||||
parse_args(&["login".to_string()]).expect("login should parse"),
|
||||
parse_args(
|
||||
&["login".to_string()],
|
||||
&RuntimeDefaults {
|
||||
model: DEFAULT_MODEL.to_string(),
|
||||
},
|
||||
)
|
||||
.expect("login should parse"),
|
||||
CliAction::Login
|
||||
);
|
||||
assert_eq!(
|
||||
parse_args(&["logout".to_string()]).expect("logout should parse"),
|
||||
parse_args(
|
||||
&["logout".to_string()],
|
||||
&RuntimeDefaults {
|
||||
model: DEFAULT_MODEL.to_string(),
|
||||
},
|
||||
)
|
||||
.expect("logout should parse"),
|
||||
CliAction::Logout
|
||||
);
|
||||
}
|
||||
@@ -2333,7 +2642,13 @@ mod tests {
|
||||
"/compact".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
parse_args(&args).expect("args should parse"),
|
||||
parse_args(
|
||||
&args,
|
||||
&RuntimeDefaults {
|
||||
model: DEFAULT_MODEL.to_string()
|
||||
}
|
||||
)
|
||||
.expect("args should parse"),
|
||||
CliAction::ResumeSession {
|
||||
session_path: PathBuf::from("session.json"),
|
||||
commands: vec!["/compact".to_string()],
|
||||
@@ -2351,7 +2666,13 @@ mod tests {
|
||||
"/cost".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
parse_args(&args).expect("args should parse"),
|
||||
parse_args(
|
||||
&args,
|
||||
&RuntimeDefaults {
|
||||
model: DEFAULT_MODEL.to_string()
|
||||
}
|
||||
)
|
||||
.expect("args should parse"),
|
||||
CliAction::ResumeSession {
|
||||
session_path: PathBuf::from("session.json"),
|
||||
commands: vec![
|
||||
@@ -2586,10 +2907,25 @@ mod tests {
|
||||
fn status_context_reads_real_workspace_metadata() {
|
||||
let context = status_context(None).expect("status context should load");
|
||||
assert!(context.cwd.is_absolute());
|
||||
assert_eq!(context.discovered_config_files, 3);
|
||||
assert_eq!(context.discovered_config_files, 5);
|
||||
assert!(context.loaded_config_files <= context.discovered_config_files);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_permission_mode_prefers_env_override() {
|
||||
let original = std::env::var("RUSTY_CLAUDE_PERMISSION_MODE").ok();
|
||||
std::env::set_var("RUSTY_CLAUDE_PERMISSION_MODE", "danger-full-access");
|
||||
let config = runtime::RuntimeConfig::empty();
|
||||
assert_eq!(
|
||||
super::resolved_permission_mode_label(&config),
|
||||
"danger-full-access"
|
||||
);
|
||||
if let Some(value) = original {
|
||||
std::env::set_var("RUSTY_CLAUDE_PERMISSION_MODE", value);
|
||||
} else {
|
||||
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn normalizes_supported_permission_modes() {
|
||||
assert_eq!(normalize_permission_mode("read-only"), Some("read-only"));
|
||||
@@ -2604,6 +2940,66 @@ mod tests {
|
||||
assert_eq!(normalize_permission_mode("unknown"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_permission_mode_from_config_defaults() {
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claude");
|
||||
fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
fs::write(
|
||||
cwd.join(".claude").join("settings.json"),
|
||||
r#"{"permissions":{"defaultMode":"dontAsk"}}"#,
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let config = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("config should load");
|
||||
assert_eq!(
|
||||
config.permission_mode(),
|
||||
Some(ResolvedPermissionMode::DangerFullAccess)
|
||||
);
|
||||
assert_eq!(
|
||||
resolved_permission_mode_label(&config),
|
||||
"danger-full-access"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_mcp_tool_definitions_from_config() {
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claude");
|
||||
fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
let script = write_mcp_server_script();
|
||||
fs::write(
|
||||
cwd.join(".claude").join("settings.json"),
|
||||
format!(
|
||||
r#"{{"mcpServers":{{"alpha":{{"command":"{}"}}}}}}"#,
|
||||
script.display()
|
||||
),
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let config = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("config should load");
|
||||
let tool_defs =
|
||||
discover_mcp_tool_definitions(&config, None).expect("mcp tool definitions should load");
|
||||
|
||||
assert_eq!(tool_defs.len(), 1);
|
||||
assert_eq!(tool_defs[0].name, "mcp__alpha__echo");
|
||||
assert_eq!(tool_defs[0].description.as_deref(), Some("Echo from MCP"));
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
fs::remove_file(&script).ok();
|
||||
fs::remove_dir_all(script.parent().expect("script parent")).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_command_requires_explicit_confirmation_flag() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user