mirror of
https://github.com/lWolvesl/claw-code.git
synced 2026-04-03 03:01:51 +08:00
Honor Claude config defaults across runtime sessions
The runtime now discovers both legacy and current Claude config files at user and project scope, merges them in precedence order, and carries the resolved model, permission mode, instruction files, and MCP server configuration into session startup. This keeps CLI defaults aligned with project policy and exposes configured MCP tools without requiring manual flags. Constraint: Must support both legacy .claude.json and current .claude/settings.json layouts Constraint: Session startup must preserve CLI flag precedence over config defaults Rejected: Read only project settings files | would ignore user-scoped defaults and MCP servers Rejected: Delay MCP tool discovery until first tool call | model would not see configured MCP tools during planning Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep config precedence synchronized between prompt loading, session startup, and status reporting Tested: cargo fmt --all --check; cargo clippy --workspace --all-targets --all-features -- -D warnings; cargo test --workspace --all-features Not-tested: Live remote MCP servers and interactive REPL session startup against external services
This commit is contained in:
@@ -14,6 +14,13 @@ pub enum ConfigSource {
|
|||||||
Local,
|
Local,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ResolvedPermissionMode {
|
||||||
|
ReadOnly,
|
||||||
|
WorkspaceWrite,
|
||||||
|
DangerFullAccess,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ConfigEntry {
|
pub struct ConfigEntry {
|
||||||
pub source: ConfigSource,
|
pub source: ConfigSource,
|
||||||
@@ -31,6 +38,8 @@ pub struct RuntimeConfig {
|
|||||||
pub struct RuntimeFeatureConfig {
|
pub struct RuntimeFeatureConfig {
|
||||||
mcp: McpConfigCollection,
|
mcp: McpConfigCollection,
|
||||||
oauth: Option<OAuthConfig>,
|
oauth: Option<OAuthConfig>,
|
||||||
|
model: Option<String>,
|
||||||
|
permission_mode: Option<ResolvedPermissionMode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
@@ -165,11 +174,23 @@ impl ConfigLoader {
|
|||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn discover(&self) -> Vec<ConfigEntry> {
|
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![
|
vec![
|
||||||
|
ConfigEntry {
|
||||||
|
source: ConfigSource::User,
|
||||||
|
path: user_legacy_path,
|
||||||
|
},
|
||||||
ConfigEntry {
|
ConfigEntry {
|
||||||
source: ConfigSource::User,
|
source: ConfigSource::User,
|
||||||
path: self.config_home.join("settings.json"),
|
path: self.config_home.join("settings.json"),
|
||||||
},
|
},
|
||||||
|
ConfigEntry {
|
||||||
|
source: ConfigSource::Project,
|
||||||
|
path: self.cwd.join(".claude.json"),
|
||||||
|
},
|
||||||
ConfigEntry {
|
ConfigEntry {
|
||||||
source: ConfigSource::Project,
|
source: ConfigSource::Project,
|
||||||
path: self.cwd.join(".claude").join("settings.json"),
|
path: self.cwd.join(".claude").join("settings.json"),
|
||||||
@@ -195,14 +216,15 @@ impl ConfigLoader {
|
|||||||
loaded_entries.push(entry);
|
loaded_entries.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let merged_value = JsonValue::Object(merged.clone());
|
||||||
|
|
||||||
let feature_config = RuntimeFeatureConfig {
|
let feature_config = RuntimeFeatureConfig {
|
||||||
mcp: McpConfigCollection {
|
mcp: McpConfigCollection {
|
||||||
servers: mcp_servers,
|
servers: mcp_servers,
|
||||||
},
|
},
|
||||||
oauth: parse_optional_oauth_config(
|
oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
|
||||||
&JsonValue::Object(merged.clone()),
|
model: parse_optional_model(&merged_value),
|
||||||
"merged settings.oauth",
|
permission_mode: parse_optional_permission_mode(&merged_value)?,
|
||||||
)?,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(RuntimeConfig {
|
Ok(RuntimeConfig {
|
||||||
@@ -257,6 +279,16 @@ impl RuntimeConfig {
|
|||||||
pub fn oauth(&self) -> Option<&OAuthConfig> {
|
pub fn oauth(&self) -> Option<&OAuthConfig> {
|
||||||
self.feature_config.oauth.as_ref()
|
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 {
|
impl RuntimeFeatureConfig {
|
||||||
@@ -269,6 +301,16 @@ impl RuntimeFeatureConfig {
|
|||||||
pub fn oauth(&self) -> Option<&OAuthConfig> {
|
pub fn oauth(&self) -> Option<&OAuthConfig> {
|
||||||
self.oauth.as_ref()
|
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 {
|
impl McpConfigCollection {
|
||||||
@@ -307,6 +349,7 @@ impl McpServerConfig {
|
|||||||
fn read_optional_json_object(
|
fn read_optional_json_object(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
) -> Result<Option<BTreeMap<String, JsonValue>>, ConfigError> {
|
) -> 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) {
|
let contents = match fs::read_to_string(path) {
|
||||||
Ok(contents) => contents,
|
Ok(contents) => contents,
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
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()));
|
return Ok(Some(BTreeMap::new()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed = JsonValue::parse(&contents)
|
let parsed = match JsonValue::parse(&contents) {
|
||||||
.map_err(|error| ConfigError::Parse(format!("{}: {error}", path.display())))?;
|
Ok(parsed) => parsed,
|
||||||
let object = parsed.as_object().ok_or_else(|| {
|
Err(error) if is_legacy_config => return Ok(None),
|
||||||
ConfigError::Parse(format!(
|
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",
|
"{}: top-level settings value must be a JSON object",
|
||||||
path.display()
|
path.display()
|
||||||
))
|
)));
|
||||||
})?;
|
};
|
||||||
Ok(Some(object.clone()))
|
Ok(Some(object.clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,6 +404,47 @@ fn merge_mcp_servers(
|
|||||||
Ok(())
|
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(
|
fn parse_optional_oauth_config(
|
||||||
root: &JsonValue,
|
root: &JsonValue,
|
||||||
context: &str,
|
context: &str,
|
||||||
@@ -594,7 +684,8 @@ fn deep_merge_objects(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
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 crate::json::JsonValue;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -635,14 +726,24 @@ mod tests {
|
|||||||
fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
|
fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
|
||||||
fs::create_dir_all(&home).expect("home 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(
|
fs::write(
|
||||||
home.join("settings.json"),
|
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");
|
.expect("write user settings");
|
||||||
|
fs::write(
|
||||||
|
cwd.join(".claude.json"),
|
||||||
|
r#"{"model":"project-compat","env":{"B":"2"}}"#,
|
||||||
|
)
|
||||||
|
.expect("write project compat config");
|
||||||
fs::write(
|
fs::write(
|
||||||
cwd.join(".claude").join("settings.json"),
|
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");
|
.expect("write project settings");
|
||||||
fs::write(
|
fs::write(
|
||||||
@@ -656,25 +757,37 @@ mod tests {
|
|||||||
.expect("config should load");
|
.expect("config should load");
|
||||||
|
|
||||||
assert_eq!(CLAUDE_CODE_SETTINGS_SCHEMA_NAME, "SettingsSchema");
|
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.loaded_entries()[0].source, ConfigSource::User);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
loaded.get("model"),
|
loaded.get("model"),
|
||||||
Some(&JsonValue::String("opus".to_string()))
|
Some(&JsonValue::String("opus".to_string()))
|
||||||
);
|
);
|
||||||
|
assert_eq!(loaded.model(), Some("opus"));
|
||||||
|
assert_eq!(
|
||||||
|
loaded.permission_mode(),
|
||||||
|
Some(ResolvedPermissionMode::WorkspaceWrite)
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
loaded
|
loaded
|
||||||
.get("env")
|
.get("env")
|
||||||
.and_then(JsonValue::as_object)
|
.and_then(JsonValue::as_object)
|
||||||
.expect("env object")
|
.expect("env object")
|
||||||
.len(),
|
.len(),
|
||||||
2
|
4
|
||||||
);
|
);
|
||||||
assert!(loaded
|
assert!(loaded
|
||||||
.get("hooks")
|
.get("hooks")
|
||||||
.and_then(JsonValue::as_object)
|
.and_then(JsonValue::as_object)
|
||||||
.expect("hooks object")
|
.expect("hooks object")
|
||||||
.contains_key("PreToolUse"));
|
.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");
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ pub use config::{
|
|||||||
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpClaudeAiProxyServerConfig,
|
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpClaudeAiProxyServerConfig,
|
||||||
McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
|
McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
|
||||||
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
|
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::{
|
pub use conversation::{
|
||||||
ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor,
|
ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor,
|
||||||
@@ -76,3 +77,11 @@ pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, Sessi
|
|||||||
pub use usage::{
|
pub use usage::{
|
||||||
format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::sync::{Mutex, OnceLock};
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
@@ -470,10 +469,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
crate::test_env_lock()
|
||||||
LOCK.get_or_init(|| Mutex::new(()))
|
|
||||||
.lock()
|
|
||||||
.expect("env lock")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn temp_config_home() -> std::path::PathBuf {
|
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.md"),
|
||||||
dir.join("CLAUDE.local.md"),
|
dir.join("CLAUDE.local.md"),
|
||||||
dir.join(".claude").join("CLAUDE.md"),
|
dir.join(".claude").join("CLAUDE.md"),
|
||||||
|
dir.join(".claude").join("instructions.md"),
|
||||||
] {
|
] {
|
||||||
push_context_file(&mut files, candidate)?;
|
push_context_file(&mut files, candidate)?;
|
||||||
}
|
}
|
||||||
@@ -468,6 +469,10 @@ mod tests {
|
|||||||
std::env::temp_dir().join(format!("runtime-prompt-{nanos}"))
|
std::env::temp_dir().join(format!("runtime-prompt-{nanos}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||||
|
crate::test_env_lock()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn discovers_instruction_files_from_ancestor_chain() {
|
fn discovers_instruction_files_from_ancestor_chain() {
|
||||||
let root = temp_dir();
|
let root = temp_dir();
|
||||||
@@ -477,10 +482,21 @@ mod tests {
|
|||||||
fs::write(root.join("CLAUDE.local.md"), "local instructions")
|
fs::write(root.join("CLAUDE.local.md"), "local instructions")
|
||||||
.expect("write local instructions");
|
.expect("write local instructions");
|
||||||
fs::create_dir_all(root.join("apps")).expect("apps dir");
|
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")
|
fs::write(root.join("apps").join("CLAUDE.md"), "apps instructions")
|
||||||
.expect("write 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")
|
fs::write(nested.join(".claude").join("CLAUDE.md"), "nested rules")
|
||||||
.expect("write 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 context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
|
||||||
let contents = context
|
let contents = context
|
||||||
@@ -495,7 +511,9 @@ mod tests {
|
|||||||
"root instructions",
|
"root instructions",
|
||||||
"local instructions",
|
"local instructions",
|
||||||
"apps instructions",
|
"apps instructions",
|
||||||
"nested rules"
|
"apps dot claude instructions",
|
||||||
|
"nested rules",
|
||||||
|
"nested instructions"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
@@ -574,7 +592,12 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("write settings");
|
.expect("write settings");
|
||||||
|
|
||||||
|
let _guard = env_lock();
|
||||||
let previous = std::env::current_dir().expect("cwd");
|
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");
|
std::env::set_current_dir(&root).expect("change cwd");
|
||||||
let prompt = super::load_system_prompt(&root, "2026-03-31", "linux", "6.8")
|
let prompt = super::load_system_prompt(&root, "2026-03-31", "linux", "6.8")
|
||||||
.expect("system prompt should load")
|
.expect("system prompt should load")
|
||||||
@@ -584,6 +607,16 @@ mod tests {
|
|||||||
",
|
",
|
||||||
);
|
);
|
||||||
std::env::set_current_dir(previous).expect("restore cwd");
|
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("Project rules"));
|
||||||
assert!(prompt.contains("permissionMode"));
|
assert!(prompt.contains("permissionMode"));
|
||||||
@@ -631,6 +664,29 @@ mod tests {
|
|||||||
assert!(rendered.chars().count() <= 4_000 + "\n\n[truncated]".chars().count());
|
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]
|
#[test]
|
||||||
fn renders_instruction_file_metadata() {
|
fn renders_instruction_file_metadata() {
|
||||||
let rendered = render_instruction_files(&[ContextFile {
|
let rendered = render_instruction_files(&[ContextFile {
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ use runtime::{
|
|||||||
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
|
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
|
||||||
parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest,
|
parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest,
|
||||||
AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
|
AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
|
||||||
ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest,
|
ConversationMessage, ConversationRuntime, McpServerManager, MessageRole,
|
||||||
OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
|
OAuthAuthorizationRequest, OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy,
|
||||||
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
|
ProjectContext, ResolvedPermissionMode, RuntimeError, Session, TokenUsage, ToolError,
|
||||||
|
ToolExecutor, UsageTracker,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tools::{execute_tool, mvp_tool_specs};
|
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>> {
|
fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let args: Vec<String> = env::args().skip(1).collect();
|
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::DumpManifests => dump_manifests(),
|
||||||
CliAction::BootstrapPlan => print_bootstrap_plan(),
|
CliAction::BootstrapPlan => print_bootstrap_plan(),
|
||||||
CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
|
CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
|
||||||
@@ -80,6 +83,11 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Ok(())
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
enum CliAction {
|
enum CliAction {
|
||||||
DumpManifests,
|
DumpManifests,
|
||||||
@@ -127,8 +135,8 @@ impl CliOutputFormat {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
fn parse_args(args: &[String], defaults: &RuntimeDefaults) -> Result<CliAction, String> {
|
||||||
let mut model = DEFAULT_MODEL.to_string();
|
let mut model = defaults.model.clone();
|
||||||
let mut output_format = CliOutputFormat::Text;
|
let mut output_format = CliOutputFormat::Text;
|
||||||
let mut wants_version = false;
|
let mut wants_version = false;
|
||||||
let mut allowed_tool_values = Vec::new();
|
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> {
|
fn normalize_allowed_tools(values: &[String]) -> Result<Option<AllowedToolSet>, String> {
|
||||||
if values.is_empty() {
|
if values.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -892,14 +926,18 @@ impl LiveCli {
|
|||||||
enable_tools: bool,
|
enable_tools: bool,
|
||||||
allowed_tools: Option<AllowedToolSet>,
|
allowed_tools: Option<AllowedToolSet>,
|
||||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
|
let config = load_runtime_config()?;
|
||||||
let system_prompt = build_system_prompt()?;
|
let system_prompt = build_system_prompt()?;
|
||||||
let session = create_managed_session_handle()?;
|
let session = create_managed_session_handle()?;
|
||||||
|
let permission_mode = resolved_permission_mode_label(&config);
|
||||||
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,
|
||||||
allowed_tools.clone(),
|
allowed_tools.clone(),
|
||||||
|
&config,
|
||||||
|
permission_mode,
|
||||||
)?;
|
)?;
|
||||||
let cli = Self {
|
let cli = Self {
|
||||||
model,
|
model,
|
||||||
@@ -1089,12 +1127,15 @@ impl LiveCli {
|
|||||||
let previous = self.model.clone();
|
let previous = self.model.clone();
|
||||||
let session = self.runtime.session().clone();
|
let session = self.runtime.session().clone();
|
||||||
let message_count = session.messages.len();
|
let message_count = session.messages.len();
|
||||||
|
let config = load_runtime_config()?;
|
||||||
self.runtime = build_runtime(
|
self.runtime = build_runtime(
|
||||||
session,
|
session,
|
||||||
model.clone(),
|
model.clone(),
|
||||||
self.system_prompt.clone(),
|
self.system_prompt.clone(),
|
||||||
true,
|
true,
|
||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
|
&config,
|
||||||
|
resolved_permission_mode_label(&config),
|
||||||
)?;
|
)?;
|
||||||
self.model.clone_from(&model);
|
self.model.clone_from(&model);
|
||||||
self.persist_session()?;
|
self.persist_session()?;
|
||||||
@@ -1124,12 +1165,14 @@ impl LiveCli {
|
|||||||
|
|
||||||
let previous = permission_mode_label().to_string();
|
let previous = permission_mode_label().to_string();
|
||||||
let session = self.runtime.session().clone();
|
let session = self.runtime.session().clone();
|
||||||
|
let config = load_runtime_config()?;
|
||||||
self.runtime = build_runtime_with_permission_mode(
|
self.runtime = build_runtime_with_permission_mode(
|
||||||
session,
|
session,
|
||||||
self.model.clone(),
|
self.model.clone(),
|
||||||
self.system_prompt.clone(),
|
self.system_prompt.clone(),
|
||||||
true,
|
true,
|
||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
|
&config,
|
||||||
normalized,
|
normalized,
|
||||||
)?;
|
)?;
|
||||||
self.persist_session()?;
|
self.persist_session()?;
|
||||||
@@ -1149,12 +1192,14 @@ impl LiveCli {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.session = create_managed_session_handle()?;
|
self.session = create_managed_session_handle()?;
|
||||||
|
let config = load_runtime_config()?;
|
||||||
self.runtime = build_runtime_with_permission_mode(
|
self.runtime = build_runtime_with_permission_mode(
|
||||||
Session::new(),
|
Session::new(),
|
||||||
self.model.clone(),
|
self.model.clone(),
|
||||||
self.system_prompt.clone(),
|
self.system_prompt.clone(),
|
||||||
true,
|
true,
|
||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
|
&config,
|
||||||
permission_mode_label(),
|
permission_mode_label(),
|
||||||
)?;
|
)?;
|
||||||
self.persist_session()?;
|
self.persist_session()?;
|
||||||
@@ -1184,12 +1229,14 @@ impl LiveCli {
|
|||||||
let handle = resolve_session_reference(&session_ref)?;
|
let handle = resolve_session_reference(&session_ref)?;
|
||||||
let session = Session::load_from_path(&handle.path)?;
|
let session = Session::load_from_path(&handle.path)?;
|
||||||
let message_count = session.messages.len();
|
let message_count = session.messages.len();
|
||||||
|
let config = load_runtime_config()?;
|
||||||
self.runtime = build_runtime_with_permission_mode(
|
self.runtime = build_runtime_with_permission_mode(
|
||||||
session,
|
session,
|
||||||
self.model.clone(),
|
self.model.clone(),
|
||||||
self.system_prompt.clone(),
|
self.system_prompt.clone(),
|
||||||
true,
|
true,
|
||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
|
&config,
|
||||||
permission_mode_label(),
|
permission_mode_label(),
|
||||||
)?;
|
)?;
|
||||||
self.session = handle;
|
self.session = handle;
|
||||||
@@ -1261,12 +1308,14 @@ impl LiveCli {
|
|||||||
let handle = resolve_session_reference(target)?;
|
let handle = resolve_session_reference(target)?;
|
||||||
let session = Session::load_from_path(&handle.path)?;
|
let session = Session::load_from_path(&handle.path)?;
|
||||||
let message_count = session.messages.len();
|
let message_count = session.messages.len();
|
||||||
|
let config = load_runtime_config()?;
|
||||||
self.runtime = build_runtime_with_permission_mode(
|
self.runtime = build_runtime_with_permission_mode(
|
||||||
session,
|
session,
|
||||||
self.model.clone(),
|
self.model.clone(),
|
||||||
self.system_prompt.clone(),
|
self.system_prompt.clone(),
|
||||||
true,
|
true,
|
||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
|
&config,
|
||||||
permission_mode_label(),
|
permission_mode_label(),
|
||||||
)?;
|
)?;
|
||||||
self.session = handle;
|
self.session = handle;
|
||||||
@@ -1291,12 +1340,14 @@ impl LiveCli {
|
|||||||
let removed = result.removed_message_count;
|
let removed = result.removed_message_count;
|
||||||
let kept = result.compacted_session.messages.len();
|
let kept = result.compacted_session.messages.len();
|
||||||
let skipped = removed == 0;
|
let skipped = removed == 0;
|
||||||
|
let config = load_runtime_config()?;
|
||||||
self.runtime = build_runtime_with_permission_mode(
|
self.runtime = build_runtime_with_permission_mode(
|
||||||
result.compacted_session,
|
result.compacted_session,
|
||||||
self.model.clone(),
|
self.model.clone(),
|
||||||
self.system_prompt.clone(),
|
self.system_prompt.clone(),
|
||||||
true,
|
true,
|
||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
|
&config,
|
||||||
permission_mode_label(),
|
permission_mode_label(),
|
||||||
)?;
|
)?;
|
||||||
self.persist_session()?;
|
self.persist_session()?;
|
||||||
@@ -1687,11 +1738,11 @@ fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn permission_mode_label() -> &'static str {
|
fn permission_mode_label() -> &'static str {
|
||||||
match env::var("RUSTY_CLAUDE_PERMISSION_MODE") {
|
let cwd = env::current_dir().ok();
|
||||||
Ok(value) if value == "read-only" => "read-only",
|
let config = cwd.and_then(|cwd| ConfigLoader::default_for(cwd).load().ok());
|
||||||
Ok(value) if value == "danger-full-access" => "danger-full-access",
|
config
|
||||||
_ => "workspace-write",
|
.as_ref()
|
||||||
}
|
.map_or("workspace-write", resolved_permission_mode_label)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> {
|
fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> {
|
||||||
@@ -1823,6 +1874,8 @@ fn build_runtime(
|
|||||||
system_prompt: Vec<String>,
|
system_prompt: Vec<String>,
|
||||||
enable_tools: bool,
|
enable_tools: bool,
|
||||||
allowed_tools: Option<AllowedToolSet>,
|
allowed_tools: Option<AllowedToolSet>,
|
||||||
|
config: &runtime::RuntimeConfig,
|
||||||
|
permission_mode: &str,
|
||||||
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
|
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
|
||||||
{
|
{
|
||||||
build_runtime_with_permission_mode(
|
build_runtime_with_permission_mode(
|
||||||
@@ -1831,7 +1884,8 @@ fn build_runtime(
|
|||||||
system_prompt,
|
system_prompt,
|
||||||
enable_tools,
|
enable_tools,
|
||||||
allowed_tools,
|
allowed_tools,
|
||||||
permission_mode_label(),
|
config,
|
||||||
|
permission_mode,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1841,13 +1895,14 @@ fn build_runtime_with_permission_mode(
|
|||||||
system_prompt: Vec<String>,
|
system_prompt: Vec<String>,
|
||||||
enable_tools: bool,
|
enable_tools: bool,
|
||||||
allowed_tools: Option<AllowedToolSet>,
|
allowed_tools: Option<AllowedToolSet>,
|
||||||
|
config: &runtime::RuntimeConfig,
|
||||||
permission_mode: &str,
|
permission_mode: &str,
|
||||||
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
|
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
|
||||||
{
|
{
|
||||||
Ok(ConversationRuntime::new(
|
Ok(ConversationRuntime::new(
|
||||||
session,
|
session,
|
||||||
AnthropicRuntimeClient::new(model, enable_tools, allowed_tools.clone())?,
|
AnthropicRuntimeClient::new(model, enable_tools, allowed_tools.clone(), config)?,
|
||||||
CliToolExecutor::new(allowed_tools),
|
CliToolExecutor::new(allowed_tools, config),
|
||||||
permission_policy(permission_mode),
|
permission_policy(permission_mode),
|
||||||
system_prompt,
|
system_prompt,
|
||||||
))
|
))
|
||||||
@@ -1859,6 +1914,7 @@ struct AnthropicRuntimeClient {
|
|||||||
model: String,
|
model: String,
|
||||||
enable_tools: bool,
|
enable_tools: bool,
|
||||||
allowed_tools: Option<AllowedToolSet>,
|
allowed_tools: Option<AllowedToolSet>,
|
||||||
|
mcp_tool_definitions: Vec<ToolDefinition>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AnthropicRuntimeClient {
|
impl AnthropicRuntimeClient {
|
||||||
@@ -1866,17 +1922,49 @@ impl AnthropicRuntimeClient {
|
|||||||
model: String,
|
model: String,
|
||||||
enable_tools: bool,
|
enable_tools: bool,
|
||||||
allowed_tools: Option<AllowedToolSet>,
|
allowed_tools: Option<AllowedToolSet>,
|
||||||
|
config: &runtime::RuntimeConfig,
|
||||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
|
let mcp_tool_definitions = discover_mcp_tool_definitions(config, allowed_tools.as_ref())?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
runtime: tokio::runtime::Runtime::new()?,
|
runtime: tokio::runtime::Runtime::new()?,
|
||||||
client: AnthropicClient::from_auth(resolve_cli_auth_source()?),
|
client: AnthropicClient::from_auth(resolve_cli_auth_source()?),
|
||||||
model,
|
model,
|
||||||
enable_tools,
|
enable_tools,
|
||||||
allowed_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>> {
|
fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
|
||||||
match AuthSource::from_env() {
|
match AuthSource::from_env() {
|
||||||
Ok(auth) => Ok(auth),
|
Ok(auth) => Ok(auth),
|
||||||
@@ -1910,6 +1998,7 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
description: Some(spec.description.to_string()),
|
description: Some(spec.description.to_string()),
|
||||||
input_schema: spec.input_schema,
|
input_schema: spec.input_schema,
|
||||||
})
|
})
|
||||||
|
.chain(self.mcp_tool_definitions.iter().cloned())
|
||||||
.collect()
|
.collect()
|
||||||
}),
|
}),
|
||||||
tool_choice: self.enable_tools.then_some(ToolChoice::Auto),
|
tool_choice: self.enable_tools.then_some(ToolChoice::Auto),
|
||||||
@@ -2059,13 +2148,22 @@ fn response_to_events(
|
|||||||
struct CliToolExecutor {
|
struct CliToolExecutor {
|
||||||
renderer: TerminalRenderer,
|
renderer: TerminalRenderer,
|
||||||
allowed_tools: Option<AllowedToolSet>,
|
allowed_tools: Option<AllowedToolSet>,
|
||||||
|
mcp_runtime: Option<tokio::runtime::Runtime>,
|
||||||
|
mcp_servers: Option<McpServerManager>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CliToolExecutor {
|
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 {
|
Self {
|
||||||
renderer: TerminalRenderer::new(),
|
renderer: TerminalRenderer::new(),
|
||||||
allowed_tools,
|
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"
|
"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}")))?;
|
.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) {
|
match execute_tool(tool_name, &value) {
|
||||||
Ok(output) => {
|
Ok(output) => {
|
||||||
let markdown = format!("### Tool `{tool_name}`\n\n```json\n{output}\n```\n");
|
let markdown = format!("### Tool `{tool_name}`\n\n```json\n{output}\n```\n");
|
||||||
@@ -2195,21 +2320,116 @@ fn print_help() {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
filter_tool_specs, format_compact_report, format_cost_report, format_init_report,
|
discover_mcp_tool_definitions, filter_tool_specs, format_compact_report,
|
||||||
format_model_report, format_model_switch_report, format_permissions_report,
|
format_cost_report, format_init_report, format_model_report, format_model_switch_report,
|
||||||
format_permissions_switch_report, format_resume_report, format_status_report,
|
format_permissions_report, format_permissions_switch_report, format_resume_report,
|
||||||
normalize_permission_mode, parse_args, parse_git_status_metadata, render_config_report,
|
format_status_report, normalize_permission_mode, parse_args, parse_git_status_metadata,
|
||||||
render_init_claude_md, render_memory_report, render_repl_help,
|
render_config_report, render_init_claude_md, render_memory_report, render_repl_help,
|
||||||
resume_supported_slash_commands, status_context, CliAction, CliOutputFormat, SlashCommand,
|
resolved_permission_mode_label, resume_supported_slash_commands, status_context, CliAction,
|
||||||
StatusUsage, DEFAULT_MODEL,
|
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::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]
|
#[test]
|
||||||
fn defaults_to_repl_when_no_args() {
|
fn defaults_to_repl_when_no_args() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_args(&[]).expect("args should parse"),
|
parse_args(
|
||||||
|
&[],
|
||||||
|
&RuntimeDefaults {
|
||||||
|
model: DEFAULT_MODEL.to_string()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.expect("args should parse"),
|
||||||
CliAction::Repl {
|
CliAction::Repl {
|
||||||
model: DEFAULT_MODEL.to_string(),
|
model: DEFAULT_MODEL.to_string(),
|
||||||
allowed_tools: None,
|
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]
|
#[test]
|
||||||
fn parses_prompt_subcommand() {
|
fn parses_prompt_subcommand() {
|
||||||
let args = vec![
|
let args = vec![
|
||||||
@@ -2225,7 +2481,13 @@ mod tests {
|
|||||||
"world".to_string(),
|
"world".to_string(),
|
||||||
];
|
];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_args(&args).expect("args should parse"),
|
parse_args(
|
||||||
|
&args,
|
||||||
|
&RuntimeDefaults {
|
||||||
|
model: DEFAULT_MODEL.to_string()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.expect("args should parse"),
|
||||||
CliAction::Prompt {
|
CliAction::Prompt {
|
||||||
prompt: "hello world".to_string(),
|
prompt: "hello world".to_string(),
|
||||||
model: DEFAULT_MODEL.to_string(),
|
model: DEFAULT_MODEL.to_string(),
|
||||||
@@ -2245,7 +2507,13 @@ mod tests {
|
|||||||
"this".to_string(),
|
"this".to_string(),
|
||||||
];
|
];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_args(&args).expect("args should parse"),
|
parse_args(
|
||||||
|
&args,
|
||||||
|
&RuntimeDefaults {
|
||||||
|
model: DEFAULT_MODEL.to_string()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.expect("args should parse"),
|
||||||
CliAction::Prompt {
|
CliAction::Prompt {
|
||||||
prompt: "explain this".to_string(),
|
prompt: "explain this".to_string(),
|
||||||
model: "claude-opus".to_string(),
|
model: "claude-opus".to_string(),
|
||||||
@@ -2258,11 +2526,23 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn parses_version_flags_without_initializing_prompt_mode() {
|
fn parses_version_flags_without_initializing_prompt_mode() {
|
||||||
assert_eq!(
|
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
|
CliAction::Version
|
||||||
);
|
);
|
||||||
assert_eq!(
|
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
|
CliAction::Version
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2275,7 +2555,13 @@ mod tests {
|
|||||||
"--allowed-tools=write_file".to_string(),
|
"--allowed-tools=write_file".to_string(),
|
||||||
];
|
];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_args(&args).expect("args should parse"),
|
parse_args(
|
||||||
|
&args,
|
||||||
|
&RuntimeDefaults {
|
||||||
|
model: DEFAULT_MODEL.to_string()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.expect("args should parse"),
|
||||||
CliAction::Repl {
|
CliAction::Repl {
|
||||||
model: DEFAULT_MODEL.to_string(),
|
model: DEFAULT_MODEL.to_string(),
|
||||||
allowed_tools: Some(
|
allowed_tools: Some(
|
||||||
@@ -2290,7 +2576,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_unknown_allowed_tools() {
|
fn rejects_unknown_allowed_tools() {
|
||||||
let error = parse_args(&["--allowedTools".to_string(), "teleport".to_string()])
|
let error = parse_args(
|
||||||
|
&["--allowedTools".to_string(), "teleport".to_string()],
|
||||||
|
&RuntimeDefaults {
|
||||||
|
model: DEFAULT_MODEL.to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
.expect_err("tool should be rejected");
|
.expect_err("tool should be rejected");
|
||||||
assert!(error.contains("unsupported tool in --allowedTools: teleport"));
|
assert!(error.contains("unsupported tool in --allowedTools: teleport"));
|
||||||
}
|
}
|
||||||
@@ -2305,7 +2596,13 @@ mod tests {
|
|||||||
"2026-04-01".to_string(),
|
"2026-04-01".to_string(),
|
||||||
];
|
];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_args(&args).expect("args should parse"),
|
parse_args(
|
||||||
|
&args,
|
||||||
|
&RuntimeDefaults {
|
||||||
|
model: DEFAULT_MODEL.to_string()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.expect("args should parse"),
|
||||||
CliAction::PrintSystemPrompt {
|
CliAction::PrintSystemPrompt {
|
||||||
cwd: PathBuf::from("/tmp/project"),
|
cwd: PathBuf::from("/tmp/project"),
|
||||||
date: "2026-04-01".to_string(),
|
date: "2026-04-01".to_string(),
|
||||||
@@ -2316,11 +2613,23 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn parses_login_and_logout_subcommands() {
|
fn parses_login_and_logout_subcommands() {
|
||||||
assert_eq!(
|
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
|
CliAction::Login
|
||||||
);
|
);
|
||||||
assert_eq!(
|
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
|
CliAction::Logout
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2333,7 +2642,13 @@ mod tests {
|
|||||||
"/compact".to_string(),
|
"/compact".to_string(),
|
||||||
];
|
];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_args(&args).expect("args should parse"),
|
parse_args(
|
||||||
|
&args,
|
||||||
|
&RuntimeDefaults {
|
||||||
|
model: DEFAULT_MODEL.to_string()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.expect("args should parse"),
|
||||||
CliAction::ResumeSession {
|
CliAction::ResumeSession {
|
||||||
session_path: PathBuf::from("session.json"),
|
session_path: PathBuf::from("session.json"),
|
||||||
commands: vec!["/compact".to_string()],
|
commands: vec!["/compact".to_string()],
|
||||||
@@ -2351,7 +2666,13 @@ mod tests {
|
|||||||
"/cost".to_string(),
|
"/cost".to_string(),
|
||||||
];
|
];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_args(&args).expect("args should parse"),
|
parse_args(
|
||||||
|
&args,
|
||||||
|
&RuntimeDefaults {
|
||||||
|
model: DEFAULT_MODEL.to_string()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.expect("args should parse"),
|
||||||
CliAction::ResumeSession {
|
CliAction::ResumeSession {
|
||||||
session_path: PathBuf::from("session.json"),
|
session_path: PathBuf::from("session.json"),
|
||||||
commands: vec![
|
commands: vec![
|
||||||
@@ -2586,10 +2907,25 @@ mod tests {
|
|||||||
fn status_context_reads_real_workspace_metadata() {
|
fn status_context_reads_real_workspace_metadata() {
|
||||||
let context = status_context(None).expect("status context should load");
|
let context = status_context(None).expect("status context should load");
|
||||||
assert!(context.cwd.is_absolute());
|
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);
|
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]
|
#[test]
|
||||||
fn normalizes_supported_permission_modes() {
|
fn normalizes_supported_permission_modes() {
|
||||||
assert_eq!(normalize_permission_mode("read-only"), Some("read-only"));
|
assert_eq!(normalize_permission_mode("read-only"), Some("read-only"));
|
||||||
@@ -2604,6 +2940,66 @@ mod tests {
|
|||||||
assert_eq!(normalize_permission_mode("unknown"), None);
|
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]
|
#[test]
|
||||||
fn clear_command_requires_explicit_confirmation_flag() {
|
fn clear_command_requires_explicit_confirmation_flag() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
Reference in New Issue
Block a user