feat: Claw Code 重命名及架构升级
品牌重塑: - Claude Code → Claw Code - .claude → .claw 配置目录 - CLAUDE_* → CLAW_* 环境变量 新增功能: - 多 Provider 架构 (ClawApi/Xai/OpenAI) - 插件系统 (生命周期/钩子/工具扩展) - LSP 集成 (诊断/代码智能) - Hook 系统 (PreToolUse/PostToolUse) - 独立 CLI (claw-cli) - HTTP Server (Axum/SSE) - Slash Commands 扩展 (branch/worktree/commit/pr/plugin等) 优化改进: - Compaction 支持增量压缩 - 全局工具注册表 - 配置文件统一为 .claw.json
This commit is contained in:
@@ -8,9 +8,11 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
sha2 = "0.10"
|
||||
glob = "0.3"
|
||||
lsp = { path = "../lsp" }
|
||||
plugins = { path = "../plugins" }
|
||||
regex = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_json.workspace = true
|
||||
tokio = { version = "1", features = ["io-util", "macros", "process", "rt", "rt-multi-thread", "time"] }
|
||||
walkdir = "2"
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ pub struct BootstrapPlan {
|
||||
|
||||
impl BootstrapPlan {
|
||||
#[must_use]
|
||||
pub fn claude_code_default() -> Self {
|
||||
pub fn claw_default() -> Self {
|
||||
Self::from_phases(vec![
|
||||
BootstrapPhase::CliEntry,
|
||||
BootstrapPhase::FastPathVersion,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
||||
|
||||
const COMPACT_CONTINUATION_PREAMBLE: &str =
|
||||
"This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n";
|
||||
const COMPACT_RECENT_MESSAGES_NOTE: &str = "Recent messages are preserved verbatim.";
|
||||
const COMPACT_DIRECT_RESUME_INSTRUCTION: &str = "Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text.";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CompactionConfig {
|
||||
pub preserve_recent_messages: usize,
|
||||
@@ -30,8 +35,15 @@ pub fn estimate_session_tokens(session: &Session) -> usize {
|
||||
|
||||
#[must_use]
|
||||
pub fn should_compact(session: &Session, config: CompactionConfig) -> bool {
|
||||
session.messages.len() > config.preserve_recent_messages
|
||||
&& estimate_session_tokens(session) >= config.max_estimated_tokens
|
||||
let start = compacted_summary_prefix_len(session);
|
||||
let compactable = &session.messages[start..];
|
||||
|
||||
compactable.len() > config.preserve_recent_messages
|
||||
&& compactable
|
||||
.iter()
|
||||
.map(estimate_message_tokens)
|
||||
.sum::<usize>()
|
||||
>= config.max_estimated_tokens
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -56,16 +68,18 @@ pub fn get_compact_continuation_message(
|
||||
recent_messages_preserved: bool,
|
||||
) -> String {
|
||||
let mut base = format!(
|
||||
"This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n{}",
|
||||
"{COMPACT_CONTINUATION_PREAMBLE}{}",
|
||||
format_compact_summary(summary)
|
||||
);
|
||||
|
||||
if recent_messages_preserved {
|
||||
base.push_str("\n\nRecent messages are preserved verbatim.");
|
||||
base.push_str("\n\n");
|
||||
base.push_str(COMPACT_RECENT_MESSAGES_NOTE);
|
||||
}
|
||||
|
||||
if suppress_follow_up_questions {
|
||||
base.push_str("\nContinue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text.");
|
||||
base.push('\n');
|
||||
base.push_str(COMPACT_DIRECT_RESUME_INSTRUCTION);
|
||||
}
|
||||
|
||||
base
|
||||
@@ -82,13 +96,19 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
|
||||
};
|
||||
}
|
||||
|
||||
let existing_summary = session
|
||||
.messages
|
||||
.first()
|
||||
.and_then(extract_existing_compacted_summary);
|
||||
let compacted_prefix_len = usize::from(existing_summary.is_some());
|
||||
let keep_from = session
|
||||
.messages
|
||||
.len()
|
||||
.saturating_sub(config.preserve_recent_messages);
|
||||
let removed = &session.messages[..keep_from];
|
||||
let removed = &session.messages[compacted_prefix_len..keep_from];
|
||||
let preserved = session.messages[keep_from..].to_vec();
|
||||
let summary = summarize_messages(removed);
|
||||
let summary =
|
||||
merge_compact_summaries(existing_summary.as_deref(), &summarize_messages(removed));
|
||||
let formatted_summary = format_compact_summary(&summary);
|
||||
let continuation = get_compact_continuation_message(&summary, true, !preserved.is_empty());
|
||||
|
||||
@@ -110,6 +130,16 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
|
||||
}
|
||||
}
|
||||
|
||||
fn compacted_summary_prefix_len(session: &Session) -> usize {
|
||||
usize::from(
|
||||
session
|
||||
.messages
|
||||
.first()
|
||||
.and_then(extract_existing_compacted_summary)
|
||||
.is_some(),
|
||||
)
|
||||
}
|
||||
|
||||
fn summarize_messages(messages: &[ConversationMessage]) -> String {
|
||||
let user_messages = messages
|
||||
.iter()
|
||||
@@ -197,6 +227,41 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn merge_compact_summaries(existing_summary: Option<&str>, new_summary: &str) -> String {
|
||||
let Some(existing_summary) = existing_summary else {
|
||||
return new_summary.to_string();
|
||||
};
|
||||
|
||||
let previous_highlights = extract_summary_highlights(existing_summary);
|
||||
let new_formatted_summary = format_compact_summary(new_summary);
|
||||
let new_highlights = extract_summary_highlights(&new_formatted_summary);
|
||||
let new_timeline = extract_summary_timeline(&new_formatted_summary);
|
||||
|
||||
let mut lines = vec!["<summary>".to_string(), "Conversation summary:".to_string()];
|
||||
|
||||
if !previous_highlights.is_empty() {
|
||||
lines.push("- Previously compacted context:".to_string());
|
||||
lines.extend(
|
||||
previous_highlights
|
||||
.into_iter()
|
||||
.map(|line| format!(" {line}")),
|
||||
);
|
||||
}
|
||||
|
||||
if !new_highlights.is_empty() {
|
||||
lines.push("- Newly compacted context:".to_string());
|
||||
lines.extend(new_highlights.into_iter().map(|line| format!(" {line}")));
|
||||
}
|
||||
|
||||
if !new_timeline.is_empty() {
|
||||
lines.push("- Key timeline:".to_string());
|
||||
lines.extend(new_timeline.into_iter().map(|line| format!(" {line}")));
|
||||
}
|
||||
|
||||
lines.push("</summary>".to_string());
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn summarize_block(block: &ContentBlock) -> String {
|
||||
let raw = match block {
|
||||
ContentBlock::Text { text } => text.clone(),
|
||||
@@ -374,11 +439,71 @@ fn collapse_blank_lines(content: &str) -> String {
|
||||
result
|
||||
}
|
||||
|
||||
fn extract_existing_compacted_summary(message: &ConversationMessage) -> Option<String> {
|
||||
if message.role != MessageRole::System {
|
||||
return None;
|
||||
}
|
||||
|
||||
let text = first_text_block(message)?;
|
||||
let summary = text.strip_prefix(COMPACT_CONTINUATION_PREAMBLE)?;
|
||||
let summary = summary
|
||||
.split_once(&format!("\n\n{COMPACT_RECENT_MESSAGES_NOTE}"))
|
||||
.map_or(summary, |(value, _)| value);
|
||||
let summary = summary
|
||||
.split_once(&format!("\n{COMPACT_DIRECT_RESUME_INSTRUCTION}"))
|
||||
.map_or(summary, |(value, _)| value);
|
||||
Some(summary.trim().to_string())
|
||||
}
|
||||
|
||||
fn extract_summary_highlights(summary: &str) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
let mut in_timeline = false;
|
||||
|
||||
for line in format_compact_summary(summary).lines() {
|
||||
let trimmed = line.trim_end();
|
||||
if trimmed.is_empty() || trimmed == "Summary:" || trimmed == "Conversation summary:" {
|
||||
continue;
|
||||
}
|
||||
if trimmed == "- Key timeline:" {
|
||||
in_timeline = true;
|
||||
continue;
|
||||
}
|
||||
if in_timeline {
|
||||
continue;
|
||||
}
|
||||
lines.push(trimmed.to_string());
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn extract_summary_timeline(summary: &str) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
let mut in_timeline = false;
|
||||
|
||||
for line in format_compact_summary(summary).lines() {
|
||||
let trimmed = line.trim_end();
|
||||
if trimmed == "- Key timeline:" {
|
||||
in_timeline = true;
|
||||
continue;
|
||||
}
|
||||
if !in_timeline {
|
||||
continue;
|
||||
}
|
||||
if trimmed.is_empty() {
|
||||
break;
|
||||
}
|
||||
lines.push(trimmed.to_string());
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
collect_key_files, compact_session, estimate_session_tokens, format_compact_summary,
|
||||
infer_pending_work, should_compact, CompactionConfig,
|
||||
get_compact_continuation_message, infer_pending_work, should_compact, CompactionConfig,
|
||||
};
|
||||
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
||||
|
||||
@@ -453,6 +578,98 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_previous_compacted_context_when_compacting_again() {
|
||||
let initial_session = Session {
|
||||
version: 1,
|
||||
messages: vec![
|
||||
ConversationMessage::user_text("Investigate rust/crates/runtime/src/compact.rs"),
|
||||
ConversationMessage::assistant(vec![ContentBlock::Text {
|
||||
text: "I will inspect the compact flow.".to_string(),
|
||||
}]),
|
||||
ConversationMessage::user_text(
|
||||
"Also update rust/crates/runtime/src/conversation.rs",
|
||||
),
|
||||
ConversationMessage::assistant(vec![ContentBlock::Text {
|
||||
text: "Next: preserve prior summary context during auto compact.".to_string(),
|
||||
}]),
|
||||
],
|
||||
};
|
||||
let config = CompactionConfig {
|
||||
preserve_recent_messages: 2,
|
||||
max_estimated_tokens: 1,
|
||||
};
|
||||
|
||||
let first = compact_session(&initial_session, config);
|
||||
let mut follow_up_messages = first.compacted_session.messages.clone();
|
||||
follow_up_messages.extend([
|
||||
ConversationMessage::user_text("Please add regression tests for compaction."),
|
||||
ConversationMessage::assistant(vec![ContentBlock::Text {
|
||||
text: "Working on regression coverage now.".to_string(),
|
||||
}]),
|
||||
]);
|
||||
|
||||
let second = compact_session(
|
||||
&Session {
|
||||
version: 1,
|
||||
messages: follow_up_messages,
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
assert!(second
|
||||
.formatted_summary
|
||||
.contains("Previously compacted context:"));
|
||||
assert!(second
|
||||
.formatted_summary
|
||||
.contains("Scope: 2 earlier messages compacted"));
|
||||
assert!(second
|
||||
.formatted_summary
|
||||
.contains("Newly compacted context:"));
|
||||
assert!(second
|
||||
.formatted_summary
|
||||
.contains("Also update rust/crates/runtime/src/conversation.rs"));
|
||||
assert!(matches!(
|
||||
&second.compacted_session.messages[0].blocks[0],
|
||||
ContentBlock::Text { text }
|
||||
if text.contains("Previously compacted context:")
|
||||
&& text.contains("Newly compacted context:")
|
||||
));
|
||||
assert!(matches!(
|
||||
&second.compacted_session.messages[1].blocks[0],
|
||||
ContentBlock::Text { text } if text.contains("Please add regression tests for compaction.")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_existing_compacted_summary_when_deciding_to_recompact() {
|
||||
let summary = "<summary>Conversation summary:\n- Scope: earlier work preserved.\n- Key timeline:\n - user: large preserved context\n</summary>";
|
||||
let session = Session {
|
||||
version: 1,
|
||||
messages: vec![
|
||||
ConversationMessage {
|
||||
role: MessageRole::System,
|
||||
blocks: vec![ContentBlock::Text {
|
||||
text: get_compact_continuation_message(summary, true, true),
|
||||
}],
|
||||
usage: None,
|
||||
},
|
||||
ConversationMessage::user_text("tiny"),
|
||||
ConversationMessage::assistant(vec![ContentBlock::Text {
|
||||
text: "recent".to_string(),
|
||||
}]),
|
||||
],
|
||||
};
|
||||
|
||||
assert!(!should_compact(
|
||||
&session,
|
||||
CompactionConfig {
|
||||
preserve_recent_messages: 2,
|
||||
max_estimated_tokens: 1,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_long_blocks_in_summary() {
|
||||
let summary = super::summarize_block(&ContentBlock::Text {
|
||||
@@ -465,10 +682,10 @@ mod tests {
|
||||
#[test]
|
||||
fn extracts_key_files_from_message_content() {
|
||||
let files = collect_key_files(&[ConversationMessage::user_text(
|
||||
"Update rust/crates/runtime/src/compact.rs and rust/crates/rusty-claude-cli/src/main.rs next.",
|
||||
"Update rust/crates/runtime/src/compact.rs and rust/crates/tools/src/lib.rs next.",
|
||||
)]);
|
||||
assert!(files.contains(&"rust/crates/runtime/src/compact.rs".to_string()));
|
||||
assert!(files.contains(&"rust/crates/rusty-claude-cli/src/main.rs".to_string()));
|
||||
assert!(files.contains(&"rust/crates/tools/src/lib.rs".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
|
||||
use crate::json::JsonValue;
|
||||
use crate::sandbox::{FilesystemIsolationMode, SandboxConfig};
|
||||
|
||||
pub const CLAUDE_CODE_SETTINGS_SCHEMA_NAME: &str = "SettingsSchema";
|
||||
pub const CLAW_SETTINGS_SCHEMA_NAME: &str = "SettingsSchema";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum ConfigSource {
|
||||
@@ -35,8 +35,19 @@ pub struct RuntimeConfig {
|
||||
feature_config: RuntimeFeatureConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct RuntimePluginConfig {
|
||||
enabled_plugins: BTreeMap<String, bool>,
|
||||
external_directories: Vec<String>,
|
||||
install_root: Option<String>,
|
||||
registry_path: Option<String>,
|
||||
bundled_root: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct RuntimeFeatureConfig {
|
||||
hooks: RuntimeHookConfig,
|
||||
plugins: RuntimePluginConfig,
|
||||
mcp: McpConfigCollection,
|
||||
oauth: Option<OAuthConfig>,
|
||||
model: Option<String>,
|
||||
@@ -44,6 +55,12 @@ pub struct RuntimeFeatureConfig {
|
||||
sandbox: SandboxConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct RuntimeHookConfig {
|
||||
pre_tool_use: Vec<String>,
|
||||
post_tool_use: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct McpConfigCollection {
|
||||
servers: BTreeMap<String, ScopedMcpServerConfig>,
|
||||
@@ -62,7 +79,7 @@ pub enum McpTransport {
|
||||
Http,
|
||||
Ws,
|
||||
Sdk,
|
||||
ClaudeAiProxy,
|
||||
ManagedProxy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -72,7 +89,7 @@ pub enum McpServerConfig {
|
||||
Http(McpRemoteServerConfig),
|
||||
Ws(McpWebSocketServerConfig),
|
||||
Sdk(McpSdkServerConfig),
|
||||
ClaudeAiProxy(McpClaudeAiProxyServerConfig),
|
||||
ManagedProxy(McpManagedProxyServerConfig),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -103,7 +120,7 @@ pub struct McpSdkServerConfig {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpClaudeAiProxyServerConfig {
|
||||
pub struct McpManagedProxyServerConfig {
|
||||
pub url: String,
|
||||
pub id: String,
|
||||
}
|
||||
@@ -167,25 +184,20 @@ impl ConfigLoader {
|
||||
#[must_use]
|
||||
pub fn default_for(cwd: impl Into<PathBuf>) -> Self {
|
||||
let cwd = cwd.into();
|
||||
let config_home = std::env::var_os("CLAUDE_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude")))
|
||||
.or_else(|| {
|
||||
if cfg!(target_os = "windows") {
|
||||
std::env::var_os("USERPROFILE").map(|home| PathBuf::from(home).join(".claude"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| PathBuf::from(".claude"));
|
||||
let config_home = default_config_home();
|
||||
Self { cwd, config_home }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn config_home(&self) -> &Path {
|
||||
&self.config_home
|
||||
}
|
||||
|
||||
#[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"),
|
||||
|| PathBuf::from(".claw.json"),
|
||||
|parent| parent.join(".claw.json"),
|
||||
);
|
||||
vec![
|
||||
ConfigEntry {
|
||||
@@ -198,15 +210,15 @@ impl ConfigLoader {
|
||||
},
|
||||
ConfigEntry {
|
||||
source: ConfigSource::Project,
|
||||
path: self.cwd.join(".claude.json"),
|
||||
path: self.cwd.join(".claw.json"),
|
||||
},
|
||||
ConfigEntry {
|
||||
source: ConfigSource::Project,
|
||||
path: self.cwd.join(".claude").join("settings.json"),
|
||||
path: self.cwd.join(".claw").join("settings.json"),
|
||||
},
|
||||
ConfigEntry {
|
||||
source: ConfigSource::Local,
|
||||
path: self.cwd.join(".claude").join("settings.local.json"),
|
||||
path: self.cwd.join(".claw").join("settings.local.json"),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -228,6 +240,8 @@ impl ConfigLoader {
|
||||
let merged_value = JsonValue::Object(merged.clone());
|
||||
|
||||
let feature_config = RuntimeFeatureConfig {
|
||||
hooks: parse_optional_hooks_config(&merged_value)?,
|
||||
plugins: parse_optional_plugin_config(&merged_value)?,
|
||||
mcp: McpConfigCollection {
|
||||
servers: mcp_servers,
|
||||
},
|
||||
@@ -285,6 +299,16 @@ impl RuntimeConfig {
|
||||
&self.feature_config.mcp
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn hooks(&self) -> &RuntimeHookConfig {
|
||||
&self.feature_config.hooks
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn plugins(&self) -> &RuntimePluginConfig {
|
||||
&self.feature_config.plugins
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn oauth(&self) -> Option<&OAuthConfig> {
|
||||
self.feature_config.oauth.as_ref()
|
||||
@@ -307,6 +331,28 @@ impl RuntimeConfig {
|
||||
}
|
||||
|
||||
impl RuntimeFeatureConfig {
|
||||
#[must_use]
|
||||
pub fn with_hooks(mut self, hooks: RuntimeHookConfig) -> Self {
|
||||
self.hooks = hooks;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_plugins(mut self, plugins: RuntimePluginConfig) -> Self {
|
||||
self.plugins = plugins;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn hooks(&self) -> &RuntimeHookConfig {
|
||||
&self.hooks
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn plugins(&self) -> &RuntimePluginConfig {
|
||||
&self.plugins
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn mcp(&self) -> &McpConfigCollection {
|
||||
&self.mcp
|
||||
@@ -333,6 +379,85 @@ impl RuntimeFeatureConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimePluginConfig {
|
||||
#[must_use]
|
||||
pub fn enabled_plugins(&self) -> &BTreeMap<String, bool> {
|
||||
&self.enabled_plugins
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn external_directories(&self) -> &[String] {
|
||||
&self.external_directories
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn install_root(&self) -> Option<&str> {
|
||||
self.install_root.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn registry_path(&self) -> Option<&str> {
|
||||
self.registry_path.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn bundled_root(&self) -> Option<&str> {
|
||||
self.bundled_root.as_deref()
|
||||
}
|
||||
|
||||
pub fn set_plugin_state(&mut self, plugin_id: String, enabled: bool) {
|
||||
self.enabled_plugins.insert(plugin_id, enabled);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn state_for(&self, plugin_id: &str, default_enabled: bool) -> bool {
|
||||
self.enabled_plugins
|
||||
.get(plugin_id)
|
||||
.copied()
|
||||
.unwrap_or(default_enabled)
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn default_config_home() -> PathBuf {
|
||||
std::env::var_os("CLAW_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claw")))
|
||||
.unwrap_or_else(|| PathBuf::from(".claw"))
|
||||
}
|
||||
|
||||
impl RuntimeHookConfig {
|
||||
#[must_use]
|
||||
pub fn new(pre_tool_use: Vec<String>, post_tool_use: Vec<String>) -> 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
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn merged(&self, other: &Self) -> Self {
|
||||
let mut merged = self.clone();
|
||||
merged.extend(other);
|
||||
merged
|
||||
}
|
||||
|
||||
pub fn extend(&mut self, other: &Self) {
|
||||
extend_unique(&mut self.pre_tool_use, other.pre_tool_use());
|
||||
extend_unique(&mut self.post_tool_use, other.post_tool_use());
|
||||
}
|
||||
}
|
||||
|
||||
impl McpConfigCollection {
|
||||
#[must_use]
|
||||
pub fn servers(&self) -> &BTreeMap<String, ScopedMcpServerConfig> {
|
||||
@@ -361,7 +486,7 @@ impl McpServerConfig {
|
||||
Self::Http(_) => McpTransport::Http,
|
||||
Self::Ws(_) => McpTransport::Ws,
|
||||
Self::Sdk(_) => McpTransport::Sdk,
|
||||
Self::ClaudeAiProxy(_) => McpTransport::ClaudeAiProxy,
|
||||
Self::ManagedProxy(_) => McpTransport::ManagedProxy,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -369,7 +494,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 is_legacy_config = path.file_name().and_then(|name| name.to_str()) == Some(".claw.json");
|
||||
let contents = match fs::read_to_string(path) {
|
||||
Ok(contents) => contents,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
@@ -431,6 +556,52 @@ fn parse_optional_model(root: &JsonValue) -> Option<String> {
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn parse_optional_hooks_config(root: &JsonValue) -> Result<RuntimeHookConfig, ConfigError> {
|
||||
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_plugin_config(root: &JsonValue) -> Result<RuntimePluginConfig, ConfigError> {
|
||||
let Some(object) = root.as_object() else {
|
||||
return Ok(RuntimePluginConfig::default());
|
||||
};
|
||||
|
||||
let mut config = RuntimePluginConfig::default();
|
||||
if let Some(enabled_plugins) = object.get("enabledPlugins") {
|
||||
config.enabled_plugins = parse_bool_map(enabled_plugins, "merged settings.enabledPlugins")?;
|
||||
}
|
||||
|
||||
let Some(plugins_value) = object.get("plugins") else {
|
||||
return Ok(config);
|
||||
};
|
||||
let plugins = expect_object(plugins_value, "merged settings.plugins")?;
|
||||
|
||||
if let Some(enabled_value) = plugins.get("enabled") {
|
||||
config.enabled_plugins = parse_bool_map(enabled_value, "merged settings.plugins.enabled")?;
|
||||
}
|
||||
config.external_directories =
|
||||
optional_string_array(plugins, "externalDirectories", "merged settings.plugins")?
|
||||
.unwrap_or_default();
|
||||
config.install_root =
|
||||
optional_string(plugins, "installRoot", "merged settings.plugins")?.map(str::to_string);
|
||||
config.registry_path =
|
||||
optional_string(plugins, "registryPath", "merged settings.plugins")?.map(str::to_string);
|
||||
config.bundled_root =
|
||||
optional_string(plugins, "bundledRoot", "merged settings.plugins")?.map(str::to_string);
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn parse_optional_permission_mode(
|
||||
root: &JsonValue,
|
||||
) -> Result<Option<ResolvedPermissionMode>, ConfigError> {
|
||||
@@ -553,12 +724,10 @@ fn parse_mcp_server_config(
|
||||
"sdk" => Ok(McpServerConfig::Sdk(McpSdkServerConfig {
|
||||
name: expect_string(object, "name", context)?.to_string(),
|
||||
})),
|
||||
"claudeai-proxy" => Ok(McpServerConfig::ClaudeAiProxy(
|
||||
McpClaudeAiProxyServerConfig {
|
||||
url: expect_string(object, "url", context)?.to_string(),
|
||||
id: expect_string(object, "id", context)?.to_string(),
|
||||
},
|
||||
)),
|
||||
"claudeai-proxy" => Ok(McpServerConfig::ManagedProxy(McpManagedProxyServerConfig {
|
||||
url: expect_string(object, "url", context)?.to_string(),
|
||||
id: expect_string(object, "id", context)?.to_string(),
|
||||
})),
|
||||
other => Err(ConfigError::Parse(format!(
|
||||
"{context}: unsupported MCP server type for {server_name}: {other}"
|
||||
))),
|
||||
@@ -663,6 +832,24 @@ fn optional_u16(
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_bool_map(value: &JsonValue, context: &str) -> Result<BTreeMap<String, bool>, ConfigError> {
|
||||
let Some(map) = value.as_object() else {
|
||||
return Err(ConfigError::Parse(format!(
|
||||
"{context}: expected JSON object"
|
||||
)));
|
||||
};
|
||||
map.iter()
|
||||
.map(|(key, value)| {
|
||||
value
|
||||
.as_bool()
|
||||
.map(|enabled| (key.clone(), enabled))
|
||||
.ok_or_else(|| {
|
||||
ConfigError::Parse(format!("{context}: field {key} must be a boolean"))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn optional_string_array(
|
||||
object: &BTreeMap<String, JsonValue>,
|
||||
key: &str,
|
||||
@@ -737,11 +924,23 @@ fn deep_merge_objects(
|
||||
}
|
||||
}
|
||||
|
||||
fn extend_unique(target: &mut Vec<String>, values: &[String]) {
|
||||
for value in values {
|
||||
push_unique(target, value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn push_unique(target: &mut Vec<String>, value: String) {
|
||||
if !target.iter().any(|existing| existing == &value) {
|
||||
target.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ConfigLoader, ConfigSource, McpServerConfig, McpTransport, ResolvedPermissionMode,
|
||||
CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
|
||||
CLAW_SETTINGS_SCHEMA_NAME,
|
||||
};
|
||||
use crate::json::JsonValue;
|
||||
use crate::sandbox::FilesystemIsolationMode;
|
||||
@@ -760,7 +959,7 @@ mod tests {
|
||||
fn rejects_non_object_settings_files() {
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claude");
|
||||
let home = root.join("home").join(".claw");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
fs::create_dir_all(&cwd).expect("project dir");
|
||||
fs::write(home.join("settings.json"), "[]").expect("write bad settings");
|
||||
@@ -776,15 +975,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_and_merges_claude_code_config_files_by_precedence() {
|
||||
fn loads_and_merges_claw_code_config_files_by_precedence() {
|
||||
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");
|
||||
let home = root.join("home").join(".claw");
|
||||
fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
|
||||
fs::write(
|
||||
home.parent().expect("home parent").join(".claude.json"),
|
||||
home.parent().expect("home parent").join(".claw.json"),
|
||||
r#"{"model":"haiku","env":{"A":"1"},"mcpServers":{"home":{"command":"uvx","args":["home"]}}}"#,
|
||||
)
|
||||
.expect("write user compat config");
|
||||
@@ -794,17 +993,17 @@ mod tests {
|
||||
)
|
||||
.expect("write user settings");
|
||||
fs::write(
|
||||
cwd.join(".claude.json"),
|
||||
cwd.join(".claw.json"),
|
||||
r#"{"model":"project-compat","env":{"B":"2"}}"#,
|
||||
)
|
||||
.expect("write project compat config");
|
||||
fs::write(
|
||||
cwd.join(".claude").join("settings.json"),
|
||||
cwd.join(".claw").join("settings.json"),
|
||||
r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#,
|
||||
)
|
||||
.expect("write project settings");
|
||||
fs::write(
|
||||
cwd.join(".claude").join("settings.local.json"),
|
||||
cwd.join(".claw").join("settings.local.json"),
|
||||
r#"{"model":"opus","permissionMode":"acceptEdits"}"#,
|
||||
)
|
||||
.expect("write local settings");
|
||||
@@ -813,7 +1012,7 @@ mod tests {
|
||||
.load()
|
||||
.expect("config should load");
|
||||
|
||||
assert_eq!(CLAUDE_CODE_SETTINGS_SCHEMA_NAME, "SettingsSchema");
|
||||
assert_eq!(CLAW_SETTINGS_SCHEMA_NAME, "SettingsSchema");
|
||||
assert_eq!(loaded.loaded_entries().len(), 5);
|
||||
assert_eq!(loaded.loaded_entries()[0].source, ConfigSource::User);
|
||||
assert_eq!(
|
||||
@@ -843,6 +1042,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());
|
||||
|
||||
@@ -853,12 +1054,12 @@ mod tests {
|
||||
fn parses_sandbox_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");
|
||||
let home = root.join("home").join(".claw");
|
||||
fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
|
||||
fs::write(
|
||||
cwd.join(".claude").join("settings.local.json"),
|
||||
cwd.join(".claw").join("settings.local.json"),
|
||||
r#"{
|
||||
"sandbox": {
|
||||
"enabled": true,
|
||||
@@ -891,8 +1092,8 @@ mod tests {
|
||||
fn parses_typed_mcp_and_oauth_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");
|
||||
let home = root.join("home").join(".claw");
|
||||
fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
|
||||
fs::write(
|
||||
@@ -929,7 +1130,7 @@ mod tests {
|
||||
)
|
||||
.expect("write user settings");
|
||||
fs::write(
|
||||
cwd.join(".claude").join("settings.local.json"),
|
||||
cwd.join(".claw").join("settings.local.json"),
|
||||
r#"{
|
||||
"mcpServers": {
|
||||
"remote-server": {
|
||||
@@ -978,11 +1179,101 @@ mod tests {
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_plugin_config_from_enabled_plugins() {
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claw");
|
||||
fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
|
||||
fs::write(
|
||||
home.join("settings.json"),
|
||||
r#"{
|
||||
"enabledPlugins": {
|
||||
"tool-guard@builtin": true,
|
||||
"sample-plugin@external": false
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write user settings");
|
||||
|
||||
let loaded = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("config should load");
|
||||
|
||||
assert_eq!(
|
||||
loaded.plugins().enabled_plugins().get("tool-guard@builtin"),
|
||||
Some(&true)
|
||||
);
|
||||
assert_eq!(
|
||||
loaded
|
||||
.plugins()
|
||||
.enabled_plugins()
|
||||
.get("sample-plugin@external"),
|
||||
Some(&false)
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_plugin_config() {
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claw");
|
||||
fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
|
||||
fs::write(
|
||||
home.join("settings.json"),
|
||||
r#"{
|
||||
"enabledPlugins": {
|
||||
"core-helpers@builtin": true
|
||||
},
|
||||
"plugins": {
|
||||
"externalDirectories": ["./external-plugins"],
|
||||
"installRoot": "plugin-cache/installed",
|
||||
"registryPath": "plugin-cache/installed.json",
|
||||
"bundledRoot": "./bundled-plugins"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write plugin settings");
|
||||
|
||||
let loaded = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("config should load");
|
||||
|
||||
assert_eq!(
|
||||
loaded
|
||||
.plugins()
|
||||
.enabled_plugins()
|
||||
.get("core-helpers@builtin"),
|
||||
Some(&true)
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.plugins().external_directories(),
|
||||
&["./external-plugins".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.plugins().install_root(),
|
||||
Some("plugin-cache/installed")
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.plugins().registry_path(),
|
||||
Some("plugin-cache/installed.json")
|
||||
);
|
||||
assert_eq!(loaded.plugins().bundled_root(), Some("./bundled-plugins"));
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_mcp_server_shapes() {
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claude");
|
||||
let home = root.join("home").join(".claw");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
fs::create_dir_all(&cwd).expect("project dir");
|
||||
fs::write(
|
||||
|
||||
@@ -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<C, T> {
|
||||
system_prompt: Vec<String>,
|
||||
max_iterations: usize,
|
||||
usage_tracker: UsageTracker,
|
||||
hook_runner: HookRunner,
|
||||
}
|
||||
|
||||
impl<C, T> ConversationRuntime<C, T>
|
||||
@@ -108,6 +111,25 @@ where
|
||||
tool_executor: T,
|
||||
permission_policy: PermissionPolicy,
|
||||
system_prompt: Vec<String>,
|
||||
) -> 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<String>,
|
||||
feature_config: RuntimeFeatureConfig,
|
||||
) -> Self {
|
||||
let usage_tracker = UsageTracker::from_session(&session);
|
||||
Self {
|
||||
@@ -116,8 +138,9 @@ where
|
||||
tool_executor,
|
||||
permission_policy,
|
||||
system_prompt,
|
||||
max_iterations: 16,
|
||||
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<ContentBlock>) {
|
||||
}
|
||||
}
|
||||
|
||||
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<dyn FnMut(&str) -> Result<String, ToolError>>;
|
||||
|
||||
#[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<Vec<AssistantEvent>, 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<Vec<AssistantEvent>, 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,7 +488,7 @@ mod tests {
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("time should move forward")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("clawd-native-{name}-{unique}"))
|
||||
std::env::temp_dir().join(format!("claw-native-{name}-{unique}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
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<String>,
|
||||
}
|
||||
|
||||
impl HookRunResult {
|
||||
#[must_use]
|
||||
pub fn allow(messages: Vec<String>) -> 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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct HookCommandRequest<'a> {
|
||||
event: HookEvent,
|
||||
tool_name: &'a str,
|
||||
tool_input: &'a str,
|
||||
tool_output: Option<&'a str>,
|
||||
is_error: bool,
|
||||
payload: &'a str,
|
||||
}
|
||||
|
||||
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,
|
||||
HookCommandRequest {
|
||||
event,
|
||||
tool_name,
|
||||
tool_input,
|
||||
tool_output,
|
||||
is_error,
|
||||
payload: &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(command: &str, request: HookCommandRequest<'_>) -> 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", request.event.as_str());
|
||||
child.env("HOOK_TOOL_NAME", request.tool_name);
|
||||
child.env("HOOK_TOOL_INPUT", request.tool_input);
|
||||
child.env(
|
||||
"HOOK_TOOL_IS_ERROR",
|
||||
if request.is_error { "1" } else { "0" },
|
||||
);
|
||||
if let Some(tool_output) = request.tool_output {
|
||||
child.env("HOOK_TOOL_OUTPUT", tool_output);
|
||||
}
|
||||
|
||||
match child.output_with_stdin(request.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 `{}`",
|
||||
request.event.as_str(),
|
||||
request.tool_name
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
Err(error) => HookCommandOutcome::Warn {
|
||||
message: format!(
|
||||
"{} hook `{command}` failed to start for `{}`: {error}",
|
||||
request.event.as_str(),
|
||||
request.tool_name
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum HookCommandOutcome {
|
||||
Allow { message: Option<String> },
|
||||
Deny { message: Option<String> },
|
||||
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<K, V>(&mut self, key: K, value: V) -> &mut Self
|
||||
where
|
||||
K: AsRef<OsStr>,
|
||||
V: AsRef<OsStr>,
|
||||
{
|
||||
self.command.env(key, value);
|
||||
self
|
||||
}
|
||||
|
||||
fn output_with_stdin(&mut self, stdin: &[u8]) -> std::io::Result<std::process::Output> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ mod compact;
|
||||
mod config;
|
||||
mod conversation;
|
||||
mod file_ops;
|
||||
mod hooks;
|
||||
mod json;
|
||||
mod mcp;
|
||||
mod mcp_client;
|
||||
@@ -16,6 +17,10 @@ pub mod sandbox;
|
||||
mod session;
|
||||
mod usage;
|
||||
|
||||
pub use lsp::{
|
||||
FileDiagnostics, LspContextEnrichment, LspError, LspManager, LspServerConfig,
|
||||
SymbolLocation, WorkspaceDiagnostics,
|
||||
};
|
||||
pub use bash::{execute_bash, BashCommandInput, BashCommandOutput};
|
||||
pub use bootstrap::{BootstrapPhase, BootstrapPlan};
|
||||
pub use compact::{
|
||||
@@ -23,11 +28,11 @@ pub use compact::{
|
||||
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
|
||||
};
|
||||
pub use config::{
|
||||
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpClaudeAiProxyServerConfig,
|
||||
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpManagedProxyServerConfig,
|
||||
McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
|
||||
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
|
||||
ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, ScopedMcpServerConfig,
|
||||
CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
|
||||
ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig,
|
||||
RuntimePluginConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
||||
};
|
||||
pub use conversation::{
|
||||
ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor,
|
||||
@@ -38,12 +43,13 @@ 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,
|
||||
};
|
||||
pub use mcp_client::{
|
||||
McpClaudeAiProxyTransport, McpClientAuth, McpClientBootstrap, McpClientTransport,
|
||||
McpManagedProxyTransport, McpClientAuth, McpClientBootstrap, McpClientTransport,
|
||||
McpRemoteTransport, McpSdkTransport, McpStdioTransport,
|
||||
};
|
||||
pub use mcp_stdio::{
|
||||
|
||||
@@ -73,7 +73,7 @@ pub fn mcp_server_signature(config: &McpServerConfig) -> Option<String> {
|
||||
Some(format!("url:{}", unwrap_ccr_proxy_url(&config.url)))
|
||||
}
|
||||
McpServerConfig::Ws(config) => Some(format!("url:{}", unwrap_ccr_proxy_url(&config.url))),
|
||||
McpServerConfig::ClaudeAiProxy(config) => {
|
||||
McpServerConfig::ManagedProxy(config) => {
|
||||
Some(format!("url:{}", unwrap_ccr_proxy_url(&config.url)))
|
||||
}
|
||||
McpServerConfig::Sdk(_) => None,
|
||||
@@ -110,7 +110,7 @@ pub fn scoped_mcp_config_hash(config: &ScopedMcpServerConfig) -> String {
|
||||
ws.headers_helper.as_deref().unwrap_or("")
|
||||
),
|
||||
McpServerConfig::Sdk(sdk) => format!("sdk|{}", sdk.name),
|
||||
McpServerConfig::ClaudeAiProxy(proxy) => {
|
||||
McpServerConfig::ManagedProxy(proxy) => {
|
||||
format!("claudeai-proxy|{}|{}", proxy.url, proxy.id)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ pub enum McpClientTransport {
|
||||
Http(McpRemoteTransport),
|
||||
WebSocket(McpRemoteTransport),
|
||||
Sdk(McpSdkTransport),
|
||||
ClaudeAiProxy(McpClaudeAiProxyTransport),
|
||||
ManagedProxy(McpManagedProxyTransport),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -34,7 +34,7 @@ pub struct McpSdkTransport {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpClaudeAiProxyTransport {
|
||||
pub struct McpManagedProxyTransport {
|
||||
pub url: String,
|
||||
pub id: String,
|
||||
}
|
||||
@@ -97,12 +97,10 @@ impl McpClientTransport {
|
||||
McpServerConfig::Sdk(config) => Self::Sdk(McpSdkTransport {
|
||||
name: config.name.clone(),
|
||||
}),
|
||||
McpServerConfig::ClaudeAiProxy(config) => {
|
||||
Self::ClaudeAiProxy(McpClaudeAiProxyTransport {
|
||||
url: config.url.clone(),
|
||||
id: config.id.clone(),
|
||||
})
|
||||
}
|
||||
McpServerConfig::ManagedProxy(config) => Self::ManagedProxy(McpManagedProxyTransport {
|
||||
url: config.url.clone(),
|
||||
id: config.id.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -809,6 +809,7 @@ mod tests {
|
||||
use std::io::ErrorKind;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde_json::json;
|
||||
@@ -1137,15 +1138,37 @@ mod tests {
|
||||
|
||||
fn script_transport(script_path: &Path) -> crate::mcp_client::McpStdioTransport {
|
||||
crate::mcp_client::McpStdioTransport {
|
||||
command: "python3".to_string(),
|
||||
command: python_command(),
|
||||
args: vec![script_path.to_string_lossy().into_owned()],
|
||||
env: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn python_command() -> String {
|
||||
for key in ["MCP_TEST_PYTHON", "PYTHON3", "PYTHON"] {
|
||||
if let Ok(value) = std::env::var(key) {
|
||||
if !value.trim().is_empty() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for candidate in ["python3", "python"] {
|
||||
if Command::new(candidate).arg("--version").output().is_ok() {
|
||||
return candidate.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
panic!("expected a Python interpreter for MCP stdio tests")
|
||||
}
|
||||
|
||||
fn cleanup_script(script_path: &Path) {
|
||||
fs::remove_file(script_path).expect("cleanup script");
|
||||
fs::remove_dir_all(script_path.parent().expect("script parent")).expect("cleanup dir");
|
||||
if let Err(error) = fs::remove_file(script_path) {
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::NotFound, "cleanup script");
|
||||
}
|
||||
if let Err(error) = fs::remove_dir_all(script_path.parent().expect("script parent")) {
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::NotFound, "cleanup dir");
|
||||
}
|
||||
}
|
||||
|
||||
fn manager_server_config(
|
||||
@@ -1156,7 +1179,7 @@ mod tests {
|
||||
ScopedMcpServerConfig {
|
||||
scope: ConfigSource::Local,
|
||||
config: McpServerConfig::Stdio(McpStdioServerConfig {
|
||||
command: "python3".to_string(),
|
||||
command: python_command(),
|
||||
args: vec![script_path.to_string_lossy().into_owned()],
|
||||
env: BTreeMap::from([
|
||||
("MCP_SERVER_LABEL".to_string(), label.to_string()),
|
||||
|
||||
@@ -324,15 +324,15 @@ fn generate_random_token(bytes: usize) -> io::Result<String> {
|
||||
}
|
||||
|
||||
fn credentials_home_dir() -> io::Result<PathBuf> {
|
||||
if let Some(path) = std::env::var_os("CLAUDE_CONFIG_HOME") {
|
||||
if let Some(path) = std::env::var_os("CLAW_CONFIG_HOME") {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
if let Some(path) = std::env::var_os("HOME") {
|
||||
return Ok(PathBuf::from(path).join(".claude"));
|
||||
return Ok(PathBuf::from(path).join(".claw"));
|
||||
}
|
||||
if cfg!(target_os = "windows") {
|
||||
if let Some(path) = std::env::var_os("USERPROFILE") {
|
||||
return Ok(PathBuf::from(path).join(".claude"));
|
||||
return Ok(PathBuf::from(path).join(".claw"));
|
||||
}
|
||||
}
|
||||
Err(io::Error::new(io::ErrorKind::NotFound, "HOME or USERPROFILE is not set"))
|
||||
@@ -547,7 +547,7 @@ mod tests {
|
||||
fn oauth_credentials_round_trip_and_clear_preserves_other_fields() {
|
||||
let _guard = env_lock();
|
||||
let config_home = temp_config_home();
|
||||
std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
|
||||
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
||||
let path = credentials_path().expect("credentials path");
|
||||
std::fs::create_dir_all(path.parent().expect("parent")).expect("create parent");
|
||||
std::fs::write(&path, "{\"other\":\"value\"}\n").expect("seed credentials");
|
||||
@@ -573,7 +573,7 @@ mod tests {
|
||||
assert!(cleared.contains("\"other\": \"value\""));
|
||||
assert!(!cleared.contains("\"oauth\""));
|
||||
|
||||
std::env::remove_var("CLAUDE_CONFIG_HOME");
|
||||
std::env::remove_var("CLAW_CONFIG_HOME");
|
||||
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::config::{ConfigError, ConfigLoader, RuntimeConfig};
|
||||
use lsp::LspContextEnrichment;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PromptBuildError {
|
||||
@@ -35,7 +36,7 @@ impl From<ConfigError> for PromptBuildError {
|
||||
}
|
||||
|
||||
pub const SYSTEM_PROMPT_DYNAMIC_BOUNDARY: &str = "__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__";
|
||||
pub const FRONTIER_MODEL_NAME: &str = "Claude Opus 4.6";
|
||||
pub const FRONTIER_MODEL_NAME: &str = "Opus 4.6";
|
||||
const MAX_INSTRUCTION_FILE_CHARS: usize = 4_000;
|
||||
const MAX_TOTAL_INSTRUCTION_CHARS: usize = 12_000;
|
||||
|
||||
@@ -130,6 +131,15 @@ impl SystemPromptBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_lsp_context(mut self, enrichment: &LspContextEnrichment) -> Self {
|
||||
if !enrichment.is_empty() {
|
||||
self.append_sections
|
||||
.push(enrichment.render_prompt_section());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn build(&self) -> Vec<String> {
|
||||
let mut sections = Vec::new();
|
||||
@@ -201,10 +211,10 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
|
||||
let mut files = Vec::new();
|
||||
for dir in directories {
|
||||
for candidate in [
|
||||
dir.join("CLAUDE.md"),
|
||||
dir.join("CLAUDE.local.md"),
|
||||
dir.join(".claude").join("CLAUDE.md"),
|
||||
dir.join(".claude").join("instructions.md"),
|
||||
dir.join("CLAW.md"),
|
||||
dir.join("CLAW.local.md"),
|
||||
dir.join(".claw").join("CLAW.md"),
|
||||
dir.join(".claw").join("instructions.md"),
|
||||
] {
|
||||
push_context_file(&mut files, candidate)?;
|
||||
}
|
||||
@@ -282,7 +292,7 @@ fn render_project_context(project_context: &ProjectContext) -> String {
|
||||
];
|
||||
if !project_context.instruction_files.is_empty() {
|
||||
bullets.push(format!(
|
||||
"Claude instruction files discovered: {}.",
|
||||
"Claw instruction files discovered: {}.",
|
||||
project_context.instruction_files.len()
|
||||
));
|
||||
}
|
||||
@@ -301,7 +311,7 @@ fn render_project_context(project_context: &ProjectContext) -> String {
|
||||
}
|
||||
|
||||
fn render_instruction_files(files: &[ContextFile]) -> String {
|
||||
let mut sections = vec!["# Claude instructions".to_string()];
|
||||
let mut sections = vec!["# Claw instructions".to_string()];
|
||||
let mut remaining_chars = MAX_TOTAL_INSTRUCTION_CHARS;
|
||||
for file in files {
|
||||
if remaining_chars == 0 {
|
||||
@@ -421,7 +431,7 @@ fn render_config_section(config: &RuntimeConfig) -> String {
|
||||
let mut lines = vec!["# Runtime config".to_string()];
|
||||
if config.loaded_entries().is_empty() {
|
||||
lines.extend(prepend_bullets(vec![
|
||||
"No Claude Code settings files loaded.".to_string(),
|
||||
"No Claw Code settings files loaded.".to_string()
|
||||
]));
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -517,23 +527,23 @@ mod tests {
|
||||
fn discovers_instruction_files_from_ancestor_chain() {
|
||||
let root = temp_dir();
|
||||
let nested = root.join("apps").join("api");
|
||||
fs::create_dir_all(nested.join(".claude")).expect("nested claude dir");
|
||||
fs::write(root.join("CLAUDE.md"), "root instructions").expect("write root instructions");
|
||||
fs::write(root.join("CLAUDE.local.md"), "local instructions")
|
||||
fs::create_dir_all(nested.join(".claw")).expect("nested claw dir");
|
||||
fs::write(root.join("CLAW.md"), "root instructions").expect("write root instructions");
|
||||
fs::write(root.join("CLAW.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")
|
||||
fs::create_dir_all(root.join("apps").join(".claw")).expect("apps claw dir");
|
||||
fs::write(root.join("apps").join("CLAW.md"), "apps instructions")
|
||||
.expect("write apps instructions");
|
||||
fs::write(
|
||||
root.join("apps").join(".claude").join("instructions.md"),
|
||||
"apps dot claude instructions",
|
||||
root.join("apps").join(".claw").join("instructions.md"),
|
||||
"apps dot claw instructions",
|
||||
)
|
||||
.expect("write apps dot claude instructions");
|
||||
fs::write(nested.join(".claude").join("CLAUDE.md"), "nested rules")
|
||||
.expect("write apps dot claw instructions");
|
||||
fs::write(nested.join(".claw").join("CLAW.md"), "nested rules")
|
||||
.expect("write nested rules");
|
||||
fs::write(
|
||||
nested.join(".claude").join("instructions.md"),
|
||||
nested.join(".claw").join("instructions.md"),
|
||||
"nested instructions",
|
||||
)
|
||||
.expect("write nested instructions");
|
||||
@@ -551,7 +561,7 @@ mod tests {
|
||||
"root instructions",
|
||||
"local instructions",
|
||||
"apps instructions",
|
||||
"apps dot claude instructions",
|
||||
"apps dot claw instructions",
|
||||
"nested rules",
|
||||
"nested instructions"
|
||||
]
|
||||
@@ -564,8 +574,8 @@ mod tests {
|
||||
let root = temp_dir();
|
||||
let nested = root.join("apps").join("api");
|
||||
fs::create_dir_all(&nested).expect("nested dir");
|
||||
fs::write(root.join("CLAUDE.md"), "same rules\n\n").expect("write root");
|
||||
fs::write(nested.join("CLAUDE.md"), "same rules\n").expect("write nested");
|
||||
fs::write(root.join("CLAW.md"), "same rules\n\n").expect("write root");
|
||||
fs::write(nested.join("CLAW.md"), "same rules\n").expect("write nested");
|
||||
|
||||
let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
|
||||
assert_eq!(context.instruction_files.len(), 1);
|
||||
@@ -593,13 +603,14 @@ mod tests {
|
||||
#[test]
|
||||
fn displays_context_paths_compactly() {
|
||||
assert_eq!(
|
||||
display_context_path(Path::new("/tmp/project/.claude/CLAUDE.md")),
|
||||
"CLAUDE.md"
|
||||
display_context_path(Path::new("/tmp/project/.claw/CLAW.md")),
|
||||
"CLAW.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_with_git_includes_status_snapshot() {
|
||||
let _guard = env_lock();
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(&root).expect("root dir");
|
||||
std::process::Command::new("git")
|
||||
@@ -607,7 +618,7 @@ mod tests {
|
||||
.current_dir(&root)
|
||||
.status()
|
||||
.expect("git init should run");
|
||||
fs::write(root.join("CLAUDE.md"), "rules").expect("write instructions");
|
||||
fs::write(root.join("CLAW.md"), "rules").expect("write instructions");
|
||||
fs::write(root.join("tracked.txt"), "hello").expect("write tracked file");
|
||||
|
||||
let context =
|
||||
@@ -615,7 +626,7 @@ mod tests {
|
||||
|
||||
let status = context.git_status.expect("git status should be present");
|
||||
assert!(status.contains("## No commits yet on") || status.contains("## "));
|
||||
assert!(status.contains("?? CLAUDE.md"));
|
||||
assert!(status.contains("?? CLAW.md"));
|
||||
assert!(status.contains("?? tracked.txt"));
|
||||
assert!(context.git_diff.is_none());
|
||||
|
||||
@@ -624,6 +635,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn discover_with_git_includes_diff_snapshot_for_tracked_changes() {
|
||||
let _guard = env_lock();
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(&root).expect("root dir");
|
||||
std::process::Command::new("git")
|
||||
@@ -665,12 +677,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_system_prompt_reads_claude_files_and_config() {
|
||||
fn load_system_prompt_reads_claw_files_and_config() {
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(root.join(".claude")).expect("claude dir");
|
||||
fs::write(root.join("CLAUDE.md"), "Project rules").expect("write instructions");
|
||||
fs::create_dir_all(root.join(".claw")).expect("claw dir");
|
||||
fs::write(root.join("CLAW.md"), "Project rules").expect("write instructions");
|
||||
fs::write(
|
||||
root.join(".claude").join("settings.json"),
|
||||
root.join(".claw").join("settings.json"),
|
||||
r#"{"permissionMode":"acceptEdits"}"#,
|
||||
)
|
||||
.expect("write settings");
|
||||
@@ -678,9 +690,9 @@ mod tests {
|
||||
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();
|
||||
let original_claw_home = std::env::var("CLAW_CONFIG_HOME").ok();
|
||||
std::env::set_var("HOME", &root);
|
||||
std::env::set_var("CLAUDE_CONFIG_HOME", root.join("missing-home"));
|
||||
std::env::set_var("CLAW_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")
|
||||
@@ -695,10 +707,10 @@ mod tests {
|
||||
} else {
|
||||
std::env::remove_var("HOME");
|
||||
}
|
||||
if let Some(value) = original_claude_home {
|
||||
std::env::set_var("CLAUDE_CONFIG_HOME", value);
|
||||
if let Some(value) = original_claw_home {
|
||||
std::env::set_var("CLAW_CONFIG_HOME", value);
|
||||
} else {
|
||||
std::env::remove_var("CLAUDE_CONFIG_HOME");
|
||||
std::env::remove_var("CLAW_CONFIG_HOME");
|
||||
}
|
||||
|
||||
assert!(prompt.contains("Project rules"));
|
||||
@@ -707,12 +719,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_claude_code_style_sections_with_project_context() {
|
||||
fn renders_claw_code_style_sections_with_project_context() {
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(root.join(".claude")).expect("claude dir");
|
||||
fs::write(root.join("CLAUDE.md"), "Project rules").expect("write CLAUDE.md");
|
||||
fs::create_dir_all(root.join(".claw")).expect("claw dir");
|
||||
fs::write(root.join("CLAW.md"), "Project rules").expect("write CLAW.md");
|
||||
fs::write(
|
||||
root.join(".claude").join("settings.json"),
|
||||
root.join(".claw").join("settings.json"),
|
||||
r#"{"permissionMode":"acceptEdits"}"#,
|
||||
)
|
||||
.expect("write settings");
|
||||
@@ -731,7 +743,7 @@ mod tests {
|
||||
|
||||
assert!(prompt.contains("# System"));
|
||||
assert!(prompt.contains("# Project context"));
|
||||
assert!(prompt.contains("# Claude instructions"));
|
||||
assert!(prompt.contains("# Claw instructions"));
|
||||
assert!(prompt.contains("Project rules"));
|
||||
assert!(prompt.contains("permissionMode"));
|
||||
assert!(prompt.contains(SYSTEM_PROMPT_DYNAMIC_BOUNDARY));
|
||||
@@ -748,12 +760,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_dot_claude_instructions_markdown() {
|
||||
fn discovers_dot_claw_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::create_dir_all(nested.join(".claw")).expect("nested claw dir");
|
||||
fs::write(
|
||||
nested.join(".claude").join("instructions.md"),
|
||||
nested.join(".claw").join("instructions.md"),
|
||||
"instruction markdown",
|
||||
)
|
||||
.expect("write instructions.md");
|
||||
@@ -762,7 +774,7 @@ mod tests {
|
||||
assert!(context
|
||||
.instruction_files
|
||||
.iter()
|
||||
.any(|file| file.path.ends_with(".claude/instructions.md")));
|
||||
.any(|file| file.path.ends_with(".claw/instructions.md")));
|
||||
assert!(
|
||||
render_instruction_files(&context.instruction_files).contains("instruction markdown")
|
||||
);
|
||||
@@ -773,10 +785,10 @@ mod tests {
|
||||
#[test]
|
||||
fn renders_instruction_file_metadata() {
|
||||
let rendered = render_instruction_files(&[ContextFile {
|
||||
path: PathBuf::from("/tmp/project/CLAUDE.md"),
|
||||
path: PathBuf::from("/tmp/project/CLAW.md"),
|
||||
content: "Project rules".to_string(),
|
||||
}]);
|
||||
assert!(rendered.contains("# Claude instructions"));
|
||||
assert!(rendered.contains("# Claw instructions"));
|
||||
assert!(rendered.contains("scope: /tmp/project"));
|
||||
assert!(rendered.contains("Project rules"));
|
||||
}
|
||||
|
||||
@@ -72,9 +72,9 @@ impl RemoteSessionContext {
|
||||
#[must_use]
|
||||
pub fn from_env_map(env_map: &BTreeMap<String, String>) -> Self {
|
||||
Self {
|
||||
enabled: env_truthy(env_map.get("CLAUDE_CODE_REMOTE")),
|
||||
enabled: env_truthy(env_map.get("CLAW_CODE_REMOTE")),
|
||||
session_id: env_map
|
||||
.get("CLAUDE_CODE_REMOTE_SESSION_ID")
|
||||
.get("CLAW_CODE_REMOTE_SESSION_ID")
|
||||
.filter(|value| !value.is_empty())
|
||||
.cloned(),
|
||||
base_url: env_map
|
||||
@@ -272,9 +272,9 @@ mod tests {
|
||||
#[test]
|
||||
fn remote_context_reads_env_state() {
|
||||
let env = BTreeMap::from([
|
||||
("CLAUDE_CODE_REMOTE".to_string(), "true".to_string()),
|
||||
("CLAW_CODE_REMOTE".to_string(), "true".to_string()),
|
||||
(
|
||||
"CLAUDE_CODE_REMOTE_SESSION_ID".to_string(),
|
||||
"CLAW_CODE_REMOTE_SESSION_ID".to_string(),
|
||||
"session-123".to_string(),
|
||||
),
|
||||
(
|
||||
@@ -291,7 +291,7 @@ mod tests {
|
||||
#[test]
|
||||
fn bootstrap_fails_open_when_token_or_session_is_missing() {
|
||||
let env = BTreeMap::from([
|
||||
("CLAUDE_CODE_REMOTE".to_string(), "1".to_string()),
|
||||
("CLAW_CODE_REMOTE".to_string(), "1".to_string()),
|
||||
("CCR_UPSTREAM_PROXY_ENABLED".to_string(), "true".to_string()),
|
||||
]);
|
||||
let bootstrap = UpstreamProxyBootstrap::from_env_map(&env);
|
||||
@@ -307,10 +307,10 @@ mod tests {
|
||||
fs::write(&token_path, "secret-token\n").expect("write token");
|
||||
|
||||
let env = BTreeMap::from([
|
||||
("CLAUDE_CODE_REMOTE".to_string(), "1".to_string()),
|
||||
("CLAW_CODE_REMOTE".to_string(), "1".to_string()),
|
||||
("CCR_UPSTREAM_PROXY_ENABLED".to_string(), "true".to_string()),
|
||||
(
|
||||
"CLAUDE_CODE_REMOTE_SESSION_ID".to_string(),
|
||||
"CLAW_CODE_REMOTE_SESSION_ID".to_string(),
|
||||
"session-123".to_string(),
|
||||
),
|
||||
(
|
||||
|
||||
@@ -3,10 +3,13 @@ use std::fmt::{Display, Formatter};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::json::{JsonError, JsonValue};
|
||||
use crate::usage::TokenUsage;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MessageRole {
|
||||
System,
|
||||
User,
|
||||
@@ -14,7 +17,8 @@ pub enum MessageRole {
|
||||
Tool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ContentBlock {
|
||||
Text {
|
||||
text: String,
|
||||
@@ -32,14 +36,14 @@ pub enum ContentBlock {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ConversationMessage {
|
||||
pub role: MessageRole,
|
||||
pub blocks: Vec<ContentBlock>,
|
||||
pub usage: Option<TokenUsage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Session {
|
||||
pub version: u32,
|
||||
pub messages: Vec<ConversationMessage>,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::session::Session;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const DEFAULT_INPUT_COST_PER_MILLION: f64 = 15.0;
|
||||
const DEFAULT_OUTPUT_COST_PER_MILLION: f64 = 75.0;
|
||||
@@ -25,7 +26,7 @@ impl ModelPricing {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
pub struct TokenUsage {
|
||||
pub input_tokens: u32,
|
||||
pub output_tokens: u32,
|
||||
@@ -249,9 +250,9 @@ mod tests {
|
||||
let cost = usage.estimate_cost_usd();
|
||||
assert_eq!(format_usd(cost.input_cost_usd), "$15.0000");
|
||||
assert_eq!(format_usd(cost.output_cost_usd), "$37.5000");
|
||||
let lines = usage.summary_lines_for_model("usage", Some("claude-sonnet-4-20250514"));
|
||||
let lines = usage.summary_lines_for_model("usage", Some("claude-sonnet-4-6"));
|
||||
assert!(lines[0].contains("estimated_cost=$54.6750"));
|
||||
assert!(lines[0].contains("model=claude-sonnet-4-20250514"));
|
||||
assert!(lines[0].contains("model=claude-sonnet-4-6"));
|
||||
assert!(lines[1].contains("cache_read=$0.3000"));
|
||||
}
|
||||
|
||||
@@ -264,7 +265,7 @@ mod tests {
|
||||
cache_read_input_tokens: 0,
|
||||
};
|
||||
|
||||
let haiku = pricing_for_model("claude-haiku-4-5-20251001").expect("haiku pricing");
|
||||
let haiku = pricing_for_model("claude-haiku-4-5-20251213").expect("haiku pricing");
|
||||
let opus = pricing_for_model("claude-opus-4-6").expect("opus pricing");
|
||||
let haiku_cost = usage.estimate_cost_usd_with_pricing(haiku);
|
||||
let opus_cost = usage.estimate_cost_usd_with_pricing(opus);
|
||||
|
||||
Reference in New Issue
Block a user