From ac6c5d00a80b695e545000e145bb878131e2935b Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 03:35:25 +0000 Subject: [PATCH] Enable Claude-compatible tool hooks in the Rust runtime This threads typed hook settings through runtime config, adds a shell-based hook runner, and executes PreToolUse/PostToolUse around each tool call in the conversation loop. The CLI now rebuilds runtimes with settings-derived hook configuration so user-defined Claude hook commands actually run before and after tools. Constraint: Hook behavior needed to match Claude-style settings.json hooks without broad plugin/MCP parity work in this change Rejected: Delay hook loading to the tool executor layer | would miss denied tool calls and duplicate runtime policy plumbing Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep hook execution in the runtime loop so permission decisions and tool results remain wrapped by the same conversation semantics Tested: cargo test; cargo build --release Not-tested: Real user hook scripts outside the test harness; broader plugin/skills parity --- rust/crates/runtime/src/config.rs | 62 ++++ rust/crates/runtime/src/conversation.rs | 237 ++++++++++++++- rust/crates/runtime/src/hooks.rs | 349 +++++++++++++++++++++++ rust/crates/runtime/src/lib.rs | 6 +- rust/crates/rusty-claude-cli/src/main.rs | 16 +- 5 files changed, 655 insertions(+), 15 deletions(-) create mode 100644 rust/crates/runtime/src/hooks.rs diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index edf1144..368e7c5 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -37,6 +37,7 @@ pub struct RuntimeConfig { #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct RuntimeFeatureConfig { + hooks: RuntimeHookConfig, mcp: McpConfigCollection, oauth: Option, model: Option, @@ -44,6 +45,12 @@ pub struct RuntimeFeatureConfig { sandbox: SandboxConfig, } +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct RuntimeHookConfig { + pre_tool_use: Vec, + post_tool_use: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct McpConfigCollection { servers: BTreeMap, @@ -221,6 +228,7 @@ impl ConfigLoader { let merged_value = JsonValue::Object(merged.clone()); let feature_config = RuntimeFeatureConfig { + hooks: parse_optional_hooks_config(&merged_value)?, mcp: McpConfigCollection { servers: mcp_servers, }, @@ -278,6 +286,11 @@ impl RuntimeConfig { &self.feature_config.mcp } + #[must_use] + pub fn hooks(&self) -> &RuntimeHookConfig { + &self.feature_config.hooks + } + #[must_use] pub fn oauth(&self) -> Option<&OAuthConfig> { self.feature_config.oauth.as_ref() @@ -300,6 +313,17 @@ impl RuntimeConfig { } impl RuntimeFeatureConfig { + #[must_use] + pub fn with_hooks(mut self, hooks: RuntimeHookConfig) -> Self { + self.hooks = hooks; + self + } + + #[must_use] + pub fn hooks(&self) -> &RuntimeHookConfig { + &self.hooks + } + #[must_use] pub fn mcp(&self) -> &McpConfigCollection { &self.mcp @@ -326,6 +350,26 @@ impl RuntimeFeatureConfig { } } +impl RuntimeHookConfig { + #[must_use] + pub fn new(pre_tool_use: Vec, post_tool_use: Vec) -> Self { + Self { + pre_tool_use, + post_tool_use, + } + } + + #[must_use] + pub fn pre_tool_use(&self) -> &[String] { + &self.pre_tool_use + } + + #[must_use] + pub fn post_tool_use(&self) -> &[String] { + &self.post_tool_use + } +} + impl McpConfigCollection { #[must_use] pub fn servers(&self) -> &BTreeMap { @@ -424,6 +468,22 @@ fn parse_optional_model(root: &JsonValue) -> Option { .map(ToOwned::to_owned) } +fn parse_optional_hooks_config(root: &JsonValue) -> Result { + let Some(object) = root.as_object() else { + return Ok(RuntimeHookConfig::default()); + }; + let Some(hooks_value) = object.get("hooks") else { + return Ok(RuntimeHookConfig::default()); + }; + let hooks = expect_object(hooks_value, "merged settings.hooks")?; + Ok(RuntimeHookConfig { + pre_tool_use: optional_string_array(hooks, "PreToolUse", "merged settings.hooks")? + .unwrap_or_default(), + post_tool_use: optional_string_array(hooks, "PostToolUse", "merged settings.hooks")? + .unwrap_or_default(), + }) +} + fn parse_optional_permission_mode( root: &JsonValue, ) -> Result, ConfigError> { @@ -836,6 +896,8 @@ mod tests { .and_then(JsonValue::as_object) .expect("hooks object") .contains_key("PostToolUse")); + assert_eq!(loaded.hooks().pre_tool_use(), &["base".to_string()]); + assert_eq!(loaded.hooks().post_tool_use(), &["project".to_string()]); assert!(loaded.mcp().get("home").is_some()); assert!(loaded.mcp().get("project").is_some()); diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index b979e61..4ffbabc 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -4,6 +4,8 @@ use std::fmt::{Display, Formatter}; use crate::compact::{ compact_session, estimate_session_tokens, CompactionConfig, CompactionResult, }; +use crate::config::RuntimeFeatureConfig; +use crate::hooks::{HookRunResult, HookRunner}; use crate::permissions::{PermissionOutcome, PermissionPolicy, PermissionPrompter}; use crate::session::{ContentBlock, ConversationMessage, Session}; use crate::usage::{TokenUsage, UsageTracker}; @@ -94,6 +96,7 @@ pub struct ConversationRuntime { system_prompt: Vec, max_iterations: usize, usage_tracker: UsageTracker, + hook_runner: HookRunner, } impl ConversationRuntime @@ -108,6 +111,25 @@ where tool_executor: T, permission_policy: PermissionPolicy, system_prompt: Vec, + ) -> Self { + Self::new_with_features( + session, + api_client, + tool_executor, + permission_policy, + system_prompt, + RuntimeFeatureConfig::default(), + ) + } + + #[must_use] + pub fn new_with_features( + session: Session, + api_client: C, + tool_executor: T, + permission_policy: PermissionPolicy, + system_prompt: Vec, + feature_config: RuntimeFeatureConfig, ) -> Self { let usage_tracker = UsageTracker::from_session(&session); Self { @@ -118,6 +140,7 @@ where system_prompt, max_iterations: usize::MAX, usage_tracker, + hook_runner: HookRunner::from_feature_config(&feature_config), } } @@ -185,19 +208,41 @@ where let result_message = match permission_outcome { PermissionOutcome::Allow => { - match self.tool_executor.execute(&tool_name, &input) { - Ok(output) => ConversationMessage::tool_result( + let pre_hook_result = self.hook_runner.run_pre_tool_use(&tool_name, &input); + if pre_hook_result.is_denied() { + let deny_message = format!("PreToolUse hook denied tool `{tool_name}`"); + ConversationMessage::tool_result( + tool_use_id, + tool_name, + format_hook_message(&pre_hook_result, &deny_message), + true, + ) + } else { + let (mut output, mut is_error) = + match self.tool_executor.execute(&tool_name, &input) { + Ok(output) => (output, false), + Err(error) => (error.to_string(), true), + }; + output = merge_hook_feedback(pre_hook_result.messages(), output, false); + + let post_hook_result = self + .hook_runner + .run_post_tool_use(&tool_name, &input, &output, is_error); + if post_hook_result.is_denied() { + is_error = true; + } + output = merge_hook_feedback( + post_hook_result.messages(), + output, + post_hook_result.is_denied(), + ); + + ConversationMessage::tool_result( tool_use_id, tool_name, output, - false, - ), - Err(error) => ConversationMessage::tool_result( - tool_use_id, - tool_name, - error.to_string(), - true, - ), + is_error, + ) } } PermissionOutcome::Deny { reason } => { @@ -290,6 +335,32 @@ fn flush_text_block(text: &mut String, blocks: &mut Vec) { } } +fn format_hook_message(result: &HookRunResult, fallback: &str) -> String { + if result.messages().is_empty() { + fallback.to_string() + } else { + result.messages().join("\n") + } +} + +fn merge_hook_feedback(messages: &[String], output: String, denied: bool) -> String { + if messages.is_empty() { + return output; + } + + let mut sections = Vec::new(); + if !output.trim().is_empty() { + sections.push(output); + } + let label = if denied { + "Hook feedback (denied)" + } else { + "Hook feedback" + }; + sections.push(format!("{label}:\n{}", messages.join("\n"))); + sections.join("\n\n") +} + type ToolHandler = Box Result>; #[derive(Default)] @@ -329,6 +400,7 @@ mod tests { StaticToolExecutor, }; use crate::compact::CompactionConfig; + use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig}; use crate::permissions::{ PermissionMode, PermissionPolicy, PermissionPromptDecision, PermissionPrompter, PermissionRequest, @@ -503,6 +575,141 @@ mod tests { )); } + #[test] + fn denies_tool_use_when_pre_tool_hook_blocks() { + struct SingleCallApiClient; + impl ApiClient for SingleCallApiClient { + fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { + if request + .messages + .iter() + .any(|message| message.role == MessageRole::Tool) + { + return Ok(vec![ + AssistantEvent::TextDelta("blocked".to_string()), + AssistantEvent::MessageStop, + ]); + } + Ok(vec![ + AssistantEvent::ToolUse { + id: "tool-1".to_string(), + name: "blocked".to_string(), + input: r#"{"path":"secret.txt"}"#.to_string(), + }, + AssistantEvent::MessageStop, + ]) + } + } + + let mut runtime = ConversationRuntime::new_with_features( + Session::new(), + SingleCallApiClient, + StaticToolExecutor::new().register("blocked", |_input| { + panic!("tool should not execute when hook denies") + }), + PermissionPolicy::new(PermissionMode::DangerFullAccess), + vec!["system".to_string()], + RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new( + vec![shell_snippet("printf 'blocked by hook'; exit 2")], + Vec::new(), + )), + ); + + let summary = runtime + .run_turn("use the tool", None) + .expect("conversation should continue after hook denial"); + + assert_eq!(summary.tool_results.len(), 1); + let ContentBlock::ToolResult { + is_error, output, .. + } = &summary.tool_results[0].blocks[0] + else { + panic!("expected tool result block"); + }; + assert!( + *is_error, + "hook denial should produce an error result: {output}" + ); + assert!( + output.contains("denied tool") || output.contains("blocked by hook"), + "unexpected hook denial output: {output:?}" + ); + } + + #[test] + fn appends_post_tool_hook_feedback_to_tool_result() { + struct TwoCallApiClient { + calls: usize, + } + + impl ApiClient for TwoCallApiClient { + fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { + self.calls += 1; + match self.calls { + 1 => Ok(vec![ + AssistantEvent::ToolUse { + id: "tool-1".to_string(), + name: "add".to_string(), + input: r#"{"lhs":2,"rhs":2}"#.to_string(), + }, + AssistantEvent::MessageStop, + ]), + 2 => { + assert!(request + .messages + .iter() + .any(|message| message.role == MessageRole::Tool)); + Ok(vec![ + AssistantEvent::TextDelta("done".to_string()), + AssistantEvent::MessageStop, + ]) + } + _ => Err(RuntimeError::new("unexpected extra API call")), + } + } + } + + let mut runtime = ConversationRuntime::new_with_features( + Session::new(), + TwoCallApiClient { calls: 0 }, + StaticToolExecutor::new().register("add", |_input| Ok("4".to_string())), + PermissionPolicy::new(PermissionMode::DangerFullAccess), + vec!["system".to_string()], + RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new( + vec![shell_snippet("printf 'pre hook ran'")], + vec![shell_snippet("printf 'post hook ran'")], + )), + ); + + let summary = runtime + .run_turn("use add", None) + .expect("tool loop succeeds"); + + assert_eq!(summary.tool_results.len(), 1); + let ContentBlock::ToolResult { + is_error, output, .. + } = &summary.tool_results[0].blocks[0] + else { + panic!("expected tool result block"); + }; + assert!( + !*is_error, + "post hook should preserve non-error result: {output:?}" + ); + assert!( + output.contains("4"), + "tool output missing value: {output:?}" + ); + assert!( + output.contains("pre hook ran"), + "tool output missing pre hook feedback: {output:?}" + ); + assert!( + output.contains("post hook ran"), + "tool output missing post hook feedback: {output:?}" + ); + } + #[test] fn reconstructs_usage_tracker_from_restored_session() { struct SimpleApi; @@ -581,4 +788,14 @@ mod tests { MessageRole::System ); } + + #[cfg(windows)] + fn shell_snippet(script: &str) -> String { + script.replace('\'', "\"") + } + + #[cfg(not(windows))] + fn shell_snippet(script: &str) -> String { + script.to_string() + } } diff --git a/rust/crates/runtime/src/hooks.rs b/rust/crates/runtime/src/hooks.rs new file mode 100644 index 0000000..36756a0 --- /dev/null +++ b/rust/crates/runtime/src/hooks.rs @@ -0,0 +1,349 @@ +use std::ffi::OsStr; +use std::process::Command; + +use serde_json::json; + +use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HookEvent { + PreToolUse, + PostToolUse, +} + +impl HookEvent { + fn as_str(self) -> &'static str { + match self { + Self::PreToolUse => "PreToolUse", + Self::PostToolUse => "PostToolUse", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HookRunResult { + denied: bool, + messages: Vec, +} + +impl HookRunResult { + #[must_use] + pub fn allow(messages: Vec) -> Self { + Self { + denied: false, + messages, + } + } + + #[must_use] + pub fn is_denied(&self) -> bool { + self.denied + } + + #[must_use] + pub fn messages(&self) -> &[String] { + &self.messages + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct HookRunner { + config: RuntimeHookConfig, +} + +impl HookRunner { + #[must_use] + pub fn new(config: RuntimeHookConfig) -> Self { + Self { config } + } + + #[must_use] + pub fn from_feature_config(feature_config: &RuntimeFeatureConfig) -> Self { + Self::new(feature_config.hooks().clone()) + } + + #[must_use] + pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult { + self.run_commands( + HookEvent::PreToolUse, + self.config.pre_tool_use(), + tool_name, + tool_input, + None, + false, + ) + } + + #[must_use] + pub fn run_post_tool_use( + &self, + tool_name: &str, + tool_input: &str, + tool_output: &str, + is_error: bool, + ) -> HookRunResult { + self.run_commands( + HookEvent::PostToolUse, + self.config.post_tool_use(), + tool_name, + tool_input, + Some(tool_output), + is_error, + ) + } + + fn run_commands( + &self, + event: HookEvent, + commands: &[String], + tool_name: &str, + tool_input: &str, + tool_output: Option<&str>, + is_error: bool, + ) -> HookRunResult { + if commands.is_empty() { + return HookRunResult::allow(Vec::new()); + } + + let payload = json!({ + "hook_event_name": event.as_str(), + "tool_name": tool_name, + "tool_input": parse_tool_input(tool_input), + "tool_input_json": tool_input, + "tool_output": tool_output, + "tool_result_is_error": is_error, + }) + .to_string(); + + let mut messages = Vec::new(); + + for command in commands { + match self.run_command( + command, + event, + tool_name, + tool_input, + tool_output, + is_error, + &payload, + ) { + HookCommandOutcome::Allow { message } => { + if let Some(message) = message { + messages.push(message); + } + } + HookCommandOutcome::Deny { message } => { + let message = message.unwrap_or_else(|| { + format!("{} hook denied tool `{tool_name}`", event.as_str()) + }); + messages.push(message); + return HookRunResult { + denied: true, + messages, + }; + } + HookCommandOutcome::Warn { message } => messages.push(message), + } + } + + HookRunResult::allow(messages) + } + + fn run_command( + &self, + command: &str, + event: HookEvent, + tool_name: &str, + tool_input: &str, + tool_output: Option<&str>, + is_error: bool, + payload: &str, + ) -> HookCommandOutcome { + let mut child = shell_command(command); + child.stdin(std::process::Stdio::piped()); + child.stdout(std::process::Stdio::piped()); + child.stderr(std::process::Stdio::piped()); + child.env("HOOK_EVENT", event.as_str()); + child.env("HOOK_TOOL_NAME", tool_name); + child.env("HOOK_TOOL_INPUT", tool_input); + child.env("HOOK_TOOL_IS_ERROR", if is_error { "1" } else { "0" }); + if let Some(tool_output) = tool_output { + child.env("HOOK_TOOL_OUTPUT", tool_output); + } + + match child.output_with_stdin(payload.as_bytes()) { + Ok(output) => { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let message = (!stdout.is_empty()).then_some(stdout); + match output.status.code() { + Some(0) => HookCommandOutcome::Allow { message }, + Some(2) => HookCommandOutcome::Deny { message }, + Some(code) => HookCommandOutcome::Warn { + message: format_hook_warning( + command, + code, + message.as_deref(), + stderr.as_str(), + ), + }, + None => HookCommandOutcome::Warn { + message: format!( + "{} hook `{command}` terminated by signal while handling `{tool_name}`", + event.as_str() + ), + }, + } + } + Err(error) => HookCommandOutcome::Warn { + message: format!( + "{} hook `{command}` failed to start for `{tool_name}`: {error}", + event.as_str() + ), + }, + } + } +} + +enum HookCommandOutcome { + Allow { message: Option }, + Deny { message: Option }, + Warn { message: String }, +} + +fn parse_tool_input(tool_input: &str) -> serde_json::Value { + serde_json::from_str(tool_input).unwrap_or_else(|_| json!({ "raw": tool_input })) +} + +fn format_hook_warning(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String { + let mut message = + format!("Hook `{command}` exited with status {code}; allowing tool execution to continue"); + if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) { + message.push_str(": "); + message.push_str(stdout); + } else if !stderr.is_empty() { + message.push_str(": "); + message.push_str(stderr); + } + message +} + +fn shell_command(command: &str) -> CommandWithStdin { + #[cfg(windows)] + let mut command_builder = { + let mut command_builder = Command::new("cmd"); + command_builder.arg("/C").arg(command); + CommandWithStdin::new(command_builder) + }; + + #[cfg(not(windows))] + let command_builder = { + let mut command_builder = Command::new("sh"); + command_builder.arg("-lc").arg(command); + CommandWithStdin::new(command_builder) + }; + + command_builder +} + +struct CommandWithStdin { + command: Command, +} + +impl CommandWithStdin { + fn new(command: Command) -> Self { + Self { command } + } + + fn stdin(&mut self, cfg: std::process::Stdio) -> &mut Self { + self.command.stdin(cfg); + self + } + + fn stdout(&mut self, cfg: std::process::Stdio) -> &mut Self { + self.command.stdout(cfg); + self + } + + fn stderr(&mut self, cfg: std::process::Stdio) -> &mut Self { + self.command.stderr(cfg); + self + } + + fn env(&mut self, key: K, value: V) -> &mut Self + where + K: AsRef, + V: AsRef, + { + self.command.env(key, value); + self + } + + fn output_with_stdin(&mut self, stdin: &[u8]) -> std::io::Result { + let mut child = self.command.spawn()?; + if let Some(mut child_stdin) = child.stdin.take() { + use std::io::Write; + child_stdin.write_all(stdin)?; + } + child.wait_with_output() + } +} + +#[cfg(test)] +mod tests { + use super::{HookRunResult, HookRunner}; + use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig}; + + #[test] + fn allows_exit_code_zero_and_captures_stdout() { + let runner = HookRunner::new(RuntimeHookConfig::new( + vec![shell_snippet("printf 'pre ok'")], + Vec::new(), + )); + + let result = runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#); + + assert_eq!(result, HookRunResult::allow(vec!["pre ok".to_string()])); + } + + #[test] + fn denies_exit_code_two() { + let runner = HookRunner::new(RuntimeHookConfig::new( + vec![shell_snippet("printf 'blocked by hook'; exit 2")], + Vec::new(), + )); + + let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#); + + assert!(result.is_denied()); + assert_eq!(result.messages(), &["blocked by hook".to_string()]); + } + + #[test] + fn warns_for_other_non_zero_statuses() { + let runner = HookRunner::from_feature_config(&RuntimeFeatureConfig::default().with_hooks( + RuntimeHookConfig::new( + vec![shell_snippet("printf 'warning hook'; exit 1")], + Vec::new(), + ), + )); + + let result = runner.run_pre_tool_use("Edit", r#"{"file":"src/lib.rs"}"#); + + assert!(!result.is_denied()); + assert!(result + .messages() + .iter() + .any(|message| message.contains("allowing tool execution to continue"))); + } + + #[cfg(windows)] + fn shell_snippet(script: &str) -> String { + script.replace('\'', "\"") + } + + #[cfg(not(windows))] + fn shell_snippet(script: &str) -> String { + script.to_string() + } +} diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 2861d47..da745e5 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -4,6 +4,7 @@ mod compact; mod config; mod conversation; mod file_ops; +mod hooks; mod json; mod mcp; mod mcp_client; @@ -26,8 +27,8 @@ pub use config::{ ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpClaudeAiProxyServerConfig, McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, - ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, ScopedMcpServerConfig, - CLAUDE_CODE_SETTINGS_SCHEMA_NAME, + ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig, + ScopedMcpServerConfig, CLAUDE_CODE_SETTINGS_SCHEMA_NAME, }; pub use conversation::{ ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor, @@ -38,6 +39,7 @@ pub use file_ops::{ GrepSearchInput, GrepSearchOutput, ReadFileOutput, StructuredPatchHunk, TextFilePayload, WriteFileOutput, }; +pub use hooks::{HookEvent, HookRunResult, HookRunner}; pub use mcp::{ mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp, scoped_mcp_config_hash, unwrap_ccr_proxy_url, diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 6a1fdc1..5f8a7a6 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -27,8 +27,8 @@ 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, - OAuthConfig, OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, + ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthConfig, + OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, }; use serde_json::json; @@ -1903,6 +1903,15 @@ fn build_system_prompt() -> Result, Box> { )?) } +fn build_runtime_feature_config( +) -> Result> { + let cwd = env::current_dir()?; + Ok(ConfigLoader::default_for(cwd) + .load()? + .feature_config() + .clone()) +} + fn build_runtime( session: Session, model: String, @@ -1913,12 +1922,13 @@ fn build_runtime( permission_mode: PermissionMode, ) -> Result, Box> { - Ok(ConversationRuntime::new( + Ok(ConversationRuntime::new_with_features( session, AnthropicRuntimeClient::new(model, enable_tools, emit_output, allowed_tools.clone())?, CliToolExecutor::new(allowed_tools, emit_output), permission_policy(permission_mode), system_prompt, + build_runtime_feature_config()?, )) }