Merge remote-tracking branch 'upstream/main' into worktree-session-resume-fixes

This commit is contained in:
TheArchitectit
2026-06-04 09:15:19 -05:00
33 changed files with 8741 additions and 1192 deletions

1
rust/Cargo.lock generated
View File

@@ -2244,7 +2244,6 @@ version = "0.1.3"
dependencies = [
"api",
"commands",
"compat-harness",
"crossterm",
"log",
"mock-anthropic-service",

View File

@@ -15,7 +15,7 @@ cargo run -p rusty-claude-cli -- --help
cargo build --workspace
# Run the interactive REPL
cargo run -p rusty-claude-cli -- --model claude-opus-4-6
cargo run -p rusty-claude-cli -- --model claude-opus-4-7
# One-shot prompt
cargo run -p rusty-claude-cli -- prompt "explain this codebase"
@@ -87,7 +87,7 @@ Primary artifacts:
| Sub-agent / agent surfaces | ✅ |
| Todo tracking | ✅ |
| Notebook editing | ✅ |
| CLAUDE.md / project memory | ✅ |
| CLAUDE.md / CLAW.md / AGENTS.md project memory | ✅ |
| Config file hierarchy (`.claw.json` + merged config sections) | ✅ |
| Permission system | ✅ |
| MCP server lifecycle + inspection | ✅ |
@@ -100,7 +100,7 @@ Primary artifacts:
| Slash commands (including `/skills`, `/agents`, `/mcp`, `/doctor`, `/plugin`, `/subagent`) | ✅ |
| Hooks (`/hooks`, config-backed lifecycle hooks) | ✅ |
| Plugin management surfaces | ✅ |
| Skills inventory / install surfaces | ✅ |
| Skills inventory / install / uninstall surfaces | ✅ |
| Machine-readable JSON output across core CLI surfaces | ✅ |
## Model Aliases
@@ -109,7 +109,7 @@ Short names resolve to the latest model versions:
| Alias | Resolves To |
|-------|------------|
| `opus` | `claude-opus-4-6` |
| `opus` | `claude-opus-4-7` |
| `sonnet` | `claude-sonnet-4-6` |
| `haiku` | `claude-haiku-4-5-20251213` |
@@ -122,10 +122,11 @@ claw [OPTIONS] [COMMAND]
Flags:
--model MODEL
--output-format text|json
--output-format text|json (case-insensitive; CLAW_OUTPUT_FORMAT supplies the default, flags override env)
--permission-mode MODE
--dangerously-skip-permissions
--allowedTools TOOLS
--cwd PATH, -C PATH, --directory PATH
--dangerously-skip-permissions, --skip-permissions
--allowedTools TOOLS canonical snake_case names or aliases; status JSON exposes allowed_tools.available/aliases
--resume [SESSION.jsonl|session-id|latest]
--version, -V
@@ -146,6 +147,12 @@ Top-level commands:
```
`claw acp` is a local discoverability surface for editor-first users: it reports the current ACP/Zed status without starting the runtime. As of April 16, 2026, claw-code does **not** ship an ACP/Zed daemon or JSON-RPC entrypoint yet, and `claw acp serve` is only a status alias until the real protocol surface lands. Status queries exit 0 and expose the same machine-readable contract via `--output-format json`; malformed ACP invocations exit 1 with `kind: unsupported_acp_invocation`.
`--output-format` accepts `text` or `json` in any casing. `CLAW_OUTPUT_FORMAT=json` selects JSON as the default for non-interactive commands, explicit flags override it, repeated flags warn on stderr, and status JSON exposes `format_source`, `format_raw`, and `format_overridden`. Help and doctor output also surface `CLAW_LOG` / `RUST_LOG` as the logging environment knobs.
`claw version --output-format json` is the provenance probe for automation: it reports full `git_sha`, derived `git_sha_short`, `is_dirty`, `branch`, `commit_date`, `commit_timestamp`, `rustc_version`, runtime `executable_path`, and `binary_provenance`; the text report is available as `human_readable` instead of a duplicate `message` field.
`status --output-format json` reports loaded project memory files under `workspace.memory_files[]` with each file's `path`, `source` (`claude_md`, `claw_md`, `agents_md`, or scoped/rule sources), `origin`, `scope_path`, `outside_project`, `chars`, and `contributes`; `claw doctor --output-format json` includes a dedicated `memory` check. Root instruction-file priority is `CLAUDE.md`, then `CLAW.md`, then `AGENTS.md`, discovery is bounded to the current git root when present (otherwise cwd only), and all non-duplicate loaded files contribute to the rendered system prompt.
`claw mcp --output-format json` reports partial MCP config success: valid servers remain in `servers[]` while malformed siblings appear in `invalid_servers[]`, with `total_configured`, `valid_count`, and `invalid_count` split out for automation. `status` mirrors this as `mcp_validation`, and doctor includes an `mcp validation` check.
Shorthand prompt mode honors the POSIX `--` end-of-flags separator, so `claw -- "-prompt-with-dash"` and unknown dash-prefixed non-flag text stay on the prompt path instead of being treated as CLI options.
`claw dump-manifests` is self-contained: it emits the Rust resolver inventory for the selected workspace (commands, tools, agents, skills, and bootstrap phases) without requiring an upstream Claude Code TypeScript checkout. Use `--manifests-dir PATH` only to scope resolver discovery to another directory.
The command surface is moving quickly. For the canonical live help text, run:
@@ -166,8 +173,8 @@ The REPL now exposes a much broader surface than the original minimal shell:
- plugin management: `/plugin` (with aliases `/plugins`, `/marketplace`)
Notable claw-first surfaces now available directly in slash form:
- `/skills [list|install <path>|help]`
- `/agents [list|help]`
- `/skills [list|show <name>|install <path>|uninstall <name>|help]`
- `/agents [list|show <name>|create <name>|help]`
- `/mcp [list|show <server>|help]`
- `/doctor`
- `/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]`
@@ -184,7 +191,7 @@ rust/
└── crates/
├── api/ # Provider clients + streaming + request preflight
├── commands/ # Shared slash-command registry + help rendering
├── compat-harness/ # TS manifest extraction harness
├── compat-harness/ # Compatibility/parity harness utilities
├── mock-anthropic-service/ # Deterministic local Anthropic-compatible mock
├── plugins/ # Plugin metadata, manager, install/enable/disable surfaces
├── runtime/ # Session, config, permissions, MCP, prompts, auth/runtime loop
@@ -197,7 +204,7 @@ rust/
- **api** — provider clients, SSE streaming, request/response types, auth (`ANTHROPIC_API_KEY` + bearer-token support), request-size/context-window preflight
- **commands** — slash command definitions, parsing, help text generation, JSON/text command rendering
- **compat-harness** — extracts tool/prompt manifests from upstream TS source
- **compat-harness** — compatibility and parity helpers for comparing behavior with upstream fixtures
- **mock-anthropic-service** — deterministic `/v1/messages` mock for CLI parity tests and local harness runs
- **plugins** — plugin metadata, install/enable/disable/update flows, plugin tool definitions, hook integration surfaces
- **runtime** — `ConversationRuntime`, config loading, session persistence, permission policy, MCP client lifecycle, system prompt assembly, usage tracking
@@ -210,8 +217,8 @@ rust/
- **~20K lines** of Rust
- **9 crates** in workspace
- **Binary name:** `claw`
- **Default model:** `claude-opus-4-6`
- **Default permissions:** `danger-full-access`
- **Default model:** `claude-opus-4-7`
- **Default permissions:** `workspace-write`
## License

View File

@@ -161,7 +161,7 @@ mod tests {
#[test]
fn resolves_existing_and_grok_aliases() {
assert_eq!(resolve_model_alias("opus"), "claude-opus-4-6");
assert_eq!(resolve_model_alias("opus"), "claude-opus-4-7");
assert_eq!(resolve_model_alias("grok"), "grok-3");
assert_eq!(resolve_model_alias("grok-mini"), "grok-3-mini");
}
@@ -235,4 +235,22 @@ mod tests {
other => panic!("Expected ProviderClient::OpenAi for qwen-plus, got: {other:?}"),
}
}
#[test]
fn local_openai_base_url_routes_authless_ollama_models() {
let _lock = env_lock();
let _base_url = EnvVarGuard::set("OPENAI_BASE_URL", Some("http://127.0.0.1:11434/v1"));
let _openai_key = EnvVarGuard::set("OPENAI_API_KEY", None);
let _anthropic_key = EnvVarGuard::set("ANTHROPIC_API_KEY", Some("test-anthropic-key"));
let _anthropic_token = EnvVarGuard::set("ANTHROPIC_AUTH_TOKEN", None);
let client = ProviderClient::from_model("qwen2.5-coder:7b")
.expect("local model should route to OpenAI-compatible client without auth");
match client {
ProviderClient::OpenAi(openai_client) => {
assert_eq!(openai_client.base_url(), "http://127.0.0.1:11434/v1")
}
other => panic!("Expected ProviderClient::OpenAi for local model, got: {other:?}"),
}
}
}

View File

@@ -468,8 +468,7 @@ impl AnthropicClient {
request: &MessageRequest,
) -> Result<reqwest::Response, ApiError> {
let request_url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
let mut request_body = self.request_profile.render_json_body(request)?;
strip_unsupported_beta_body_fields(&mut request_body);
let request_body = render_standard_messages_body(&self.request_profile, request)?;
let request_builder = self.build_request(&request_url).json(&request_body);
request_builder.send().await.map_err(ApiError::from)
}
@@ -529,8 +528,7 @@ impl AnthropicClient {
"{}/v1/messages/count_tokens",
self.base_url.trim_end_matches('/')
);
let mut request_body = self.request_profile.render_json_body(request)?;
strip_unsupported_beta_body_fields(&mut request_body);
let request_body = render_standard_messages_body(&self.request_profile, request)?;
let response = self
.build_request(&request_url)
.json(&request_body)
@@ -977,6 +975,21 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
}
}
fn anthropic_wire_model(model: &str) -> &str {
model.strip_prefix("anthropic/").unwrap_or(model)
}
fn render_standard_messages_body(
request_profile: &AnthropicRequestProfile,
request: &MessageRequest,
) -> Result<Value, serde_json::Error> {
let mut wire_request = request.clone();
wire_request.model = anthropic_wire_model(&request.model).to_string();
let mut body = request_profile.render_json_body(&wire_request)?;
strip_unsupported_beta_body_fields(&mut body);
Ok(body)
}
/// Remove beta-only body fields that the standard `/v1/messages` and
/// `/v1/messages/count_tokens` endpoints reject as `Extra inputs are not
/// permitted`. The `betas` opt-in is communicated via the `anthropic-beta`
@@ -1550,6 +1563,27 @@ mod tests {
);
}
#[test]
fn standard_messages_body_strips_anthropic_routing_prefix() {
let client = AnthropicClient::new("test-key");
let request = MessageRequest {
model: "anthropic/claude-opus-4-6".to_string(),
max_tokens: 64,
messages: vec![],
system: None,
tools: None,
tool_choice: None,
stream: false,
..Default::default()
};
let rendered = super::render_standard_messages_body(client.request_profile(), &request)
.expect("body should render");
assert_eq!(rendered["model"], serde_json::json!("claude-opus-4-6"));
assert!(rendered.get("betas").is_none());
}
#[test]
fn enrich_bearer_auth_error_appends_sk_ant_hint_on_401_with_pure_bearer_token() {
// given

View File

@@ -211,7 +211,7 @@ pub fn resolve_model_alias(model: &str) -> String {
.find_map(|(alias, metadata)| {
(*alias == lower).then_some(match metadata.provider {
ProviderKind::Anthropic => match *alias {
"opus" => "claude-opus-4-6",
"opus" => "claude-opus-4-7",
"sonnet" => "claude-sonnet-4-6",
"haiku" => "claude-haiku-4-5-20251213",
_ => trimmed,
@@ -262,6 +262,14 @@ pub fn metadata_for_model(model: &str) -> Option<ProviderMetadata> {
default_base_url: openai_compat::DEFAULT_OPENAI_BASE_URL,
});
}
if canonical.starts_with("local/") {
return Some(ProviderMetadata {
provider: ProviderKind::OpenAi,
auth_env: "OPENAI_API_KEY",
base_url_env: "OPENAI_BASE_URL",
default_base_url: openai_compat::DEFAULT_OPENAI_BASE_URL,
});
}
// Alibaba DashScope compatible-mode endpoint. Routes qwen/* and bare
// qwen-* model names (qwen-max, qwen-plus, qwen-turbo, qwen-qwq, etc.)
// to the OpenAI-compat client pointed at DashScope's /compatible-mode/v1.
@@ -337,17 +345,21 @@ pub fn provider_diagnostics_for_model(model: &str) -> ProviderDiagnostics {
}
}
fn looks_like_local_openai_model(model: &str) -> bool {
model.contains(':') || model.contains('.')
}
#[must_use]
pub fn detect_provider_kind(model: &str) -> ProviderKind {
if let Some(metadata) = metadata_for_model(model) {
let resolved_model = resolve_model_alias(model);
if let Some(metadata) = metadata_for_model(&resolved_model) {
return metadata.provider;
}
// When OPENAI_BASE_URL is set, the user explicitly configured an
// OpenAI-compatible endpoint. Prefer it over the Anthropic fallback
// even when the model name has no recognized prefix — this is the
// common case for local providers (Ollama, LM Studio, vLLM, etc.)
// where model names like "qwen2.5-coder:7b" don't match any prefix.
if std::env::var_os("OPENAI_BASE_URL").is_some() && openai_compat::has_api_key("OPENAI_API_KEY")
// When OPENAI_BASE_URL is set and the unknown model name looks like a
// local server tag (for example `llama3.2` or `qwen2.5-coder:7b`), prefer
// the OpenAI-compatible endpoint over ambient Anthropic credentials.
if std::env::var_os("OPENAI_BASE_URL").is_some()
&& looks_like_local_openai_model(&resolved_model)
{
return ProviderKind::OpenAi;
}
@@ -608,7 +620,7 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
let canonical = resolve_model_alias(model);
let base_model = canonical.rsplit('/').next().unwrap_or(canonical.as_str());
match base_model {
"claude-opus-4-6" => Some(ModelTokenLimit {
"claude-opus-4-7" | "claude-opus-4-6" => Some(ModelTokenLimit {
max_output_tokens: 32_000,
context_window_tokens: 200_000,
}),
@@ -1042,6 +1054,18 @@ mod tests {
assert_eq!(kind2, ProviderKind::OpenAi);
}
#[test]
fn local_prefix_routes_to_openai_not_anthropic() {
let meta = super::metadata_for_model("local/Qwen/Qwen3.6-27B-FP8")
.expect("local/ prefix must resolve to OpenAI-compatible metadata");
assert_eq!(meta.provider, ProviderKind::OpenAi);
assert_eq!(meta.auth_env, "OPENAI_API_KEY");
assert_eq!(meta.base_url_env, "OPENAI_BASE_URL");
let kind = detect_provider_kind("local/Qwen/Qwen3.6-27B-FP8");
assert_eq!(kind, ProviderKind::OpenAi);
}
#[test]
fn qwen_prefix_routes_to_dashscope_not_anthropic() {
// User request from Discord #clawcode-get-help: web3g wants to use

View File

@@ -1,5 +1,6 @@
use std::borrow::Cow;
use std::collections::{BTreeMap, VecDeque};
use std::net::Ipv4Addr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -131,13 +132,22 @@ impl OpenAiCompatClient {
}
pub fn from_env(config: OpenAiCompatConfig) -> Result<Self, ApiError> {
let Some(api_key) = read_env_non_empty(config.api_key_env)? else {
return Err(ApiError::missing_credentials(
config.provider_name,
config.credential_env_vars(),
));
let base_url = read_base_url(config);
let api_key = match read_env_non_empty(config.api_key_env)? {
Some(api_key) => api_key,
None if config.provider_name == "OpenAI"
&& is_local_openai_compatible_base_url(&base_url) =>
{
"local-dev-token".to_string()
}
None => {
return Err(ApiError::missing_credentials(
config.provider_name,
config.credential_env_vars(),
));
}
};
Ok(Self::new(api_key, config))
Ok(Self::new(api_key, config).with_base_url(base_url))
}
#[must_use]
@@ -915,14 +925,18 @@ pub fn model_requires_reasoning_content_in_history(model: &str) -> bool {
/// Strip routing prefix (e.g., "openai/gpt-4" → "gpt-4") for the wire.
/// The prefix is used only to select transport; the backend expects the
/// bare model id.
/// bare model id. Use `local/` to force OpenAI-compatible routing while
/// preserving any slashes that follow the prefix.
#[allow(dead_code)]
fn strip_routing_prefix(model: &str) -> &str {
if let Some(pos) = model.find('/') {
let prefix = &model[..pos];
// Only strip if the prefix before "/" is a known routing prefix,
// not if "/" appears in the middle of the model name for other reasons.
if matches!(prefix, "openai" | "xai" | "grok" | "qwen" | "kimi") {
if matches!(
prefix,
"openai" | "xai" | "grok" | "qwen" | "kimi" | "local"
) {
&model[pos + 1..]
} else {
model
@@ -932,6 +946,44 @@ fn strip_routing_prefix(model: &str) -> &str {
}
}
fn normalize_base_url_for_model_routing(url: &str) -> &str {
let trimmed = url.trim_end_matches('/');
trimmed
.strip_suffix("/chat/completions")
.map(|value| value.trim_end_matches('/'))
.unwrap_or(trimmed)
}
fn url_host(url: &str) -> &str {
let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
let authority = after_scheme.split(['/', '?', '#']).next().unwrap_or("");
let host_port = authority
.rsplit_once('@')
.map_or(authority, |(_, host_port)| host_port);
if host_port.starts_with('[') {
return host_port
.split(']')
.next()
.unwrap_or("")
.trim_start_matches('[');
}
host_port.split(':').next().unwrap_or("")
}
fn is_local_openai_compatible_base_url(url: &str) -> bool {
let host = url_host(url.trim());
if host.eq_ignore_ascii_case("localhost") || host == "::1" {
return true;
}
let Ok(address) = host.parse::<Ipv4Addr>() else {
return false;
};
let [first, second, ..] = address.octets();
matches!(first, 10 | 127)
|| first == 192 && second == 168
|| first == 172 && (16..=31).contains(&second)
}
fn wire_model_for_base_url<'a>(
model: &'a str,
config: OpenAiCompatConfig,
@@ -944,26 +996,22 @@ fn wire_model_for_base_url<'a>(
let lowered_prefix = prefix.to_ascii_lowercase();
if lowered_prefix == "openai" {
let trimmed_base_url = base_url.trim_end_matches('/');
let default_openai = DEFAULT_OPENAI_BASE_URL.trim_end_matches('/');
if matches!(
lowered_prefix.as_str(),
"xai" | "grok" | "kimi" | "gemini" | "gemma"
) {
let normalized_base_url = normalize_base_url_for_model_routing(base_url);
let default_base_url = normalize_base_url_for_model_routing(config.default_base_url);
if normalized_base_url.eq_ignore_ascii_case(default_base_url)
|| is_local_openai_compatible_base_url(base_url)
{
return Cow::Borrowed(&model[pos + 1..]);
}
if config.provider_name == "OpenAI" && trimmed_base_url != default_openai {
// Only preserve the full slug if it's NOT a model we want to strip
if !model.contains("gemini") && !model.contains("gemma") {
return Cow::Borrowed(model);
}
}
return Cow::Borrowed(&model[pos + 1..]);
return Cow::Borrowed(model);
}
if matches!(lowered_prefix.as_str(), "xai" | "grok" | "qwen" | "kimi") {
return Cow::Borrowed(&model[pos + 1..]);
}
if lowered_prefix == "local" {
return Cow::Borrowed(&model[pos + 1..]);
}
Cow::Borrowed(model)
}
@@ -1115,6 +1163,13 @@ fn build_chat_completion_request_for_base_url(
payload[key] = value.clone();
}
// DeepSeek V4 Pro/Flash thinking mode requires this provider-specific opt-in
// and also requires assistant reasoning history to be echoed as `reasoning_content`.
// Apply it after extra_body so callers cannot accidentally override the required shape.
if model_requires_reasoning_content_in_history(wire_model) {
payload["thinking"] = json!({"type": "enabled"});
}
payload
}
@@ -1172,16 +1227,19 @@ pub fn translate_message(message: &InputMessage, model: &str) -> Vec<Value> {
InputContentBlock::ToolResult { .. } => {}
}
}
let include_reasoning =
model_requires_reasoning_content_in_history(model) && !reasoning.is_empty();
if text.is_empty() && tool_calls.is_empty() && !include_reasoning {
let needs_reasoning = model_requires_reasoning_content_in_history(model);
if text.is_empty() && tool_calls.is_empty() && reasoning.is_empty() {
Vec::new()
} else {
let mut msg = serde_json::json!({
"role": "assistant",
"content": (!text.is_empty()).then_some(text),
});
if include_reasoning {
if !text.is_empty() {
msg["content"] = json!(text);
} else if !needs_reasoning {
msg["content"] = Value::Null;
}
if needs_reasoning {
msg["reasoning_content"] = json!(reasoning);
}
// Only include tool_calls when non-empty: some providers reject
@@ -1698,6 +1756,7 @@ mod tests {
ToolChoice, ToolDefinition, ToolResultContentBlock,
};
use serde_json::json;
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::sync::{Mutex, OnceLock};
@@ -1796,6 +1855,31 @@ mod tests {
assert_eq!(assistant["content"], json!("answer"));
}
#[test]
fn deepseek_v4_assistant_with_only_tool_calls_omits_content_and_includes_reasoning() {
let request = MessageRequest {
model: "deepseek-v4-pro".to_string(),
max_tokens: 100,
messages: vec![InputMessage {
role: "assistant".to_string(),
content: vec![InputContentBlock::ToolUse {
id: "call_1".to_string(),
name: "get_weather".to_string(),
input: json!({"city": "Paris"}),
}],
}],
stream: false,
..Default::default()
};
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
let assistant = &payload["messages"][0];
assert!(assistant.get("content").is_none());
assert_eq!(assistant["reasoning_content"], json!(""));
assert_eq!(assistant["tool_calls"].as_array().map(Vec::len), Some(1));
}
#[test]
fn deepseek_v4_flash_request_includes_reasoning_content_for_assistant_history() {
// Given an assistant history turn containing thinking.
@@ -1982,6 +2066,49 @@ mod tests {
assert_eq!(payload["reasoning_effort"], json!("high"));
}
#[test]
fn deepseek_v4_request_includes_thinking_parameter() {
let payload = build_chat_completion_request(
&MessageRequest {
model: "deepseek-v4-pro".to_string(),
max_tokens: 1024,
messages: vec![InputMessage::user_text("hello")],
..Default::default()
},
OpenAiCompatConfig::openai(),
);
assert_eq!(payload["thinking"], json!({"type": "enabled"}));
assert_eq!(payload["model"], json!("deepseek-v4-pro"));
let mut extra_body = BTreeMap::new();
extra_body.insert("thinking".to_string(), json!({"type": "disabled"}));
let payload_with_override = build_chat_completion_request(
&MessageRequest {
model: "openai/deepseek-v4-flash".to_string(),
max_tokens: 1024,
messages: vec![InputMessage::user_text("hello")],
extra_body,
..Default::default()
},
OpenAiCompatConfig::openai(),
);
assert_eq!(
payload_with_override["thinking"],
json!({"type": "enabled"})
);
let non_deepseek_payload = build_chat_completion_request(
&MessageRequest {
model: "gpt-4o".to_string(),
max_tokens: 64,
messages: vec![InputMessage::user_text("hello")],
..Default::default()
},
OpenAiCompatConfig::openai(),
);
assert!(non_deepseek_payload.get("thinking").is_none());
}
#[test]
fn reasoning_effort_omitted_when_not_set() {
let payload = build_chat_completion_request(
@@ -2069,6 +2196,28 @@ mod tests {
));
}
#[test]
fn local_openai_base_url_does_not_require_api_key() {
let _lock = env_lock();
let original_base_url = std::env::var_os("OPENAI_BASE_URL");
let original_api_key = std::env::var_os("OPENAI_API_KEY");
std::env::set_var("OPENAI_BASE_URL", "http://127.0.0.1:11434/v1");
std::env::remove_var("OPENAI_API_KEY");
let client = OpenAiCompatClient::from_env(OpenAiCompatConfig::openai())
.expect("local OpenAI-compatible endpoint should not require an API key");
assert_eq!(client.base_url(), "http://127.0.0.1:11434/v1");
match original_base_url {
Some(value) => std::env::set_var("OPENAI_BASE_URL", value),
None => std::env::remove_var("OPENAI_BASE_URL"),
}
match original_api_key {
Some(value) => std::env::set_var("OPENAI_API_KEY", value),
None => std::env::remove_var("OPENAI_API_KEY"),
}
}
#[test]
fn endpoint_builder_accepts_base_urls_and_full_endpoints() {
assert_eq!(
@@ -2684,6 +2833,66 @@ mod tests {
}
}
#[test]
fn wire_model_strips_openai_prefix_for_default_and_local_preserves_custom_gateways() {
assert_eq!(
super::wire_model_for_base_url(
"openai/gpt-4o",
OpenAiCompatConfig::openai(),
super::DEFAULT_OPENAI_BASE_URL,
),
Cow::Borrowed("gpt-4o")
);
assert_eq!(
super::wire_model_for_base_url(
"openai/qwen2.5-coder:7b",
OpenAiCompatConfig::openai(),
"http://127.0.0.1:11434/v1",
),
Cow::Borrowed("qwen2.5-coder:7b")
);
assert_eq!(
super::wire_model_for_base_url(
"openai/llama3.2",
OpenAiCompatConfig::openai(),
"http://localhost:11434/v1/chat/completions",
),
Cow::Borrowed("llama3.2")
);
assert_eq!(
super::wire_model_for_base_url(
"openai/gpt-4.1-mini",
OpenAiCompatConfig::openai(),
"https://openrouter.ai/api/v1",
),
Cow::Borrowed("openai/gpt-4.1-mini")
);
assert_eq!(
super::wire_model_for_base_url(
"openai/gpt-4.1-mini",
OpenAiCompatConfig::openai(),
"https://not-localhost.example.com/v1",
),
Cow::Borrowed("openai/gpt-4.1-mini")
);
}
#[test]
fn local_routing_prefix_strips_only_escape_hatch() {
assert_eq!(
super::strip_routing_prefix("local/Qwen/Qwen3.6-27B-FP8"),
"Qwen/Qwen3.6-27B-FP8"
);
assert_eq!(
super::wire_model_for_base_url(
"local/Qwen/Qwen3.6-27B-FP8",
OpenAiCompatConfig::openai(),
"http://127.0.0.1:8000/v1",
),
Cow::Borrowed("Qwen/Qwen3.6-27B-FP8")
);
}
#[test]
fn check_request_body_size_allows_large_requests_for_openai() {
// Create a request that exceeds DashScope's limit but is under OpenAI's 100MB limit

View File

@@ -103,6 +103,58 @@ async fn send_message_posts_json_and_parses_response() {
);
}
#[tokio::test]
async fn send_message_strips_anthropic_routing_prefix_on_wire() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![
http_response("200 OK", "application/json", "{\"input_tokens\":1}"),
http_response(
"200 OK",
"application/json",
concat!(
"{",
"\"id\":\"msg_prefixed\",",
"\"type\":\"message\",",
"\"role\":\"assistant\",",
"\"content\":[{\"type\":\"text\",\"text\":\"ok\"}],",
"\"model\":\"claude-opus-4-6\",",
"\"stop_reason\":\"end_turn\",",
"\"stop_sequence\":null,",
"\"usage\":{\"input_tokens\":1,\"output_tokens\":1}",
"}"
),
),
],
)
.await;
let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
client
.send_message(&MessageRequest {
model: "anthropic/claude-opus-4-6".to_string(),
..sample_request(false)
})
.await
.expect("request should succeed");
let captured = state.lock().await;
assert_eq!(
captured.len(),
2,
"count_tokens and messages requests should be captured"
);
let count_tokens_body: serde_json::Value =
serde_json::from_str(&captured[0].body).expect("count_tokens body should be json");
let messages_body: serde_json::Value =
serde_json::from_str(&captured[1].body).expect("request body should be json");
assert_eq!(captured[0].path, "/v1/messages/count_tokens");
assert_eq!(captured[1].path, "/v1/messages");
assert_eq!(count_tokens_body["model"], json!("claude-opus-4-6"));
assert_eq!(messages_body["model"], json!("claude-opus-4-6"));
}
#[tokio::test]
async fn send_message_blocks_oversized_requests_before_the_http_call() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));

View File

@@ -159,10 +159,15 @@ async fn send_message_preserves_deepseek_reasoning_content_before_text() {
},
]
);
let captured = state.lock().await;
let request = captured.first().expect("server should capture request");
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["thinking"], json!({"type": "enabled"}));
}
#[tokio::test]
async fn custom_openai_gateway_preserves_slash_model_ids_and_extra_body_params() {
async fn local_openai_gateway_strips_routing_prefix_and_preserves_extra_body_params() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let body = concat!(
"{",
@@ -206,7 +211,7 @@ async fn custom_openai_gateway_preserves_slash_model_ids_and_extra_body_params()
let captured = state.lock().await;
let request = captured.first().expect("captured request");
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["model"], json!("openai/gpt-4.1-mini"));
assert_eq!(body["model"], json!("gpt-4.1-mini"));
assert_eq!(
body["web_search_options"],
json!({"search_context_size": "low"})

File diff suppressed because it is too large Load Diff

View File

@@ -330,20 +330,24 @@ fn prepare_tokio_command(
prepare_sandbox_dirs(cwd);
}
if let Some(launcher) = build_linux_sandbox_command(command, cwd, sandbox_status) {
let mut prepared = TokioCommand::new(launcher.program);
prepared.args(launcher.args);
prepared.current_dir(cwd);
prepared.envs(launcher.env);
return prepared;
}
let mut prepared =
if let Some(launcher) = build_linux_sandbox_command(command, cwd, sandbox_status) {
let mut cmd = TokioCommand::new(launcher.program);
cmd.args(launcher.args);
cmd.envs(launcher.env);
cmd
} else {
let mut cmd = TokioCommand::new("sh");
cmd.arg("-lc").arg(command);
if sandbox_status.filesystem_active {
cmd.env("HOME", cwd.join(".sandbox-home"));
cmd.env("TMPDIR", cwd.join(".sandbox-tmp"));
}
cmd
};
let mut prepared = TokioCommand::new("sh");
prepared.arg("-lc").arg(command).current_dir(cwd);
if sandbox_status.filesystem_active {
prepared.env("HOME", cwd.join(".sandbox-home"));
prepared.env("TMPDIR", cwd.join(".sandbox-tmp"));
}
prepared.current_dir(cwd);
prepared.stdin(Stdio::null());
prepared
}
@@ -419,6 +423,27 @@ mod tests {
assert_eq!(structured[0]["event"], "test.hung");
assert_eq!(structured[0]["data"]["provenance"], "bash.timeout");
}
#[test]
fn prevents_stdin_hangs_by_redirecting_to_null() {
let output = execute_bash(BashCommandInput {
command: String::from("cat"),
timeout: Some(2_000),
description: None,
run_in_background: Some(false),
dangerously_disable_sandbox: Some(true),
namespace_restrictions: None,
isolate_network: None,
filesystem_mode: None,
allowed_mounts: None,
})
.expect("bash command should execute cleanly");
assert!(
!output.interrupted,
"Command hung and was cut off by the timeout!"
);
}
}
/// Maximum output bytes before truncation (16 KiB, matching upstream).

File diff suppressed because it is too large Load Diff

View File

@@ -92,6 +92,8 @@ enum FieldType {
Bool,
Object,
StringArray,
HookArray,
RulesImport,
Number,
}
@@ -102,6 +104,8 @@ impl FieldType {
Self::Bool => "a boolean",
Self::Object => "an object",
Self::StringArray => "an array of strings",
Self::RulesImport => "a string or an array of strings",
Self::HookArray => "an array of strings or hook objects",
Self::Number => "a number",
}
}
@@ -114,6 +118,16 @@ impl FieldType {
Self::StringArray => value
.as_array()
.is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())),
Self::HookArray => value.as_array().is_some_and(|arr| {
arr.iter()
.all(|entry| entry.as_str().is_some() || entry.as_object().is_some())
}),
Self::RulesImport => {
value.as_str().is_some()
|| value
.as_array()
.is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some()))
}
Self::Number => value.as_i64().is_some(),
}
}
@@ -201,20 +215,24 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
name: "provider",
expected: FieldType::Object,
},
FieldSpec {
name: "rulesImport",
expected: FieldType::RulesImport,
},
];
const HOOKS_FIELDS: &[FieldSpec] = &[
FieldSpec {
name: "PreToolUse",
expected: FieldType::StringArray,
expected: FieldType::HookArray,
},
FieldSpec {
name: "PostToolUse",
expected: FieldType::StringArray,
expected: FieldType::HookArray,
},
FieldSpec {
name: "PostToolUseFailure",
expected: FieldType::StringArray,
expected: FieldType::HookArray,
},
];
@@ -406,9 +424,10 @@ fn validate_object_keys(
} else if DEPRECATED_FIELDS.iter().any(|d| d.name == key) {
// Deprecated key — handled separately, not an unknown-key error.
} else {
// Unknown key.
// Unknown key — preserve compatibility by surfacing it as a warning
// instead of blocking otherwise valid config files.
let suggestion = suggest_field(key, &known_names);
result.errors.push(ConfigDiagnostic {
result.warnings.push(ConfigDiagnostic {
path: path_display.to_string(),
field: field_path,
line: find_key_line(source, key),
@@ -587,10 +606,11 @@ mod tests {
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "unknownField");
assert!(result.errors.is_empty());
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "unknownField");
assert!(matches!(
result.errors[0].kind,
result.warnings[0].kind,
DiagnosticKind::UnknownKey { .. }
));
}
@@ -670,9 +690,10 @@ mod tests {
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].line, Some(3));
assert_eq!(result.errors[0].field, "badKey");
assert!(result.errors.is_empty());
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].line, Some(3));
assert_eq!(result.warnings[0].field, "badKey");
}
#[test]
@@ -701,8 +722,60 @@ mod tests {
let result = validate_config_file(object, source, &test_path());
// then
assert!(result.errors.is_empty());
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "hooks.BadHook");
}
#[test]
fn validates_object_style_hook_entries() {
let source = r#"{"hooks":{"PreToolUse":["legacy",{"matcher":"Bash","hooks":[{"type":"command","command":"echo ok"}]}]}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
let result = validate_config_file(object, source, &test_path());
assert!(result.errors.is_empty(), "{:?}", result.errors);
}
#[test]
fn rejects_wrong_hook_entry_types() {
let source = r#"{"hooks":{"PreToolUse":[42]}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
let result = validate_config_file(object, source, &test_path());
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "hooks.BadHook");
assert_eq!(result.errors[0].field, "hooks.PreToolUse");
}
#[test]
fn validates_rules_import_string_and_array_forms() {
for source in [
r#"{"rulesImport":"auto"}"#,
r#"{"rulesImport":"none"}"#,
r#"{"rulesImport":["cursor","copilot"]}"#,
] {
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
let result = validate_config_file(object, source, &test_path());
assert!(result.errors.is_empty(), "{source}: {:?}", result.errors);
}
}
#[test]
fn rejects_rules_import_wrong_type() {
let source = r#"{"rulesImport":42}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
let result = validate_config_file(object, source, &test_path());
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "rulesImport");
}
#[test]
@@ -716,8 +789,9 @@ mod tests {
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "permissions.denyAll");
assert!(result.errors.is_empty());
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "permissions.denyAll");
}
#[test]
@@ -731,8 +805,9 @@ mod tests {
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "sandbox.containerMode");
assert!(result.errors.is_empty());
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "sandbox.containerMode");
}
#[test]
@@ -746,8 +821,9 @@ mod tests {
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "plugins.autoUpdate");
assert!(result.errors.is_empty());
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "plugins.autoUpdate");
}
#[test]
@@ -761,8 +837,9 @@ mod tests {
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "oauth.secret");
assert!(result.errors.is_empty());
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "oauth.secret");
}
#[test]
@@ -797,8 +874,9 @@ mod tests {
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
match &result.errors[0].kind {
assert!(result.errors.is_empty());
assert_eq!(result.warnings.len(), 1);
match &result.warnings[0].kind {
DiagnosticKind::UnknownKey {
suggestion: Some(s),
} => assert_eq!(s, "model"),
@@ -809,7 +887,7 @@ mod tests {
#[test]
fn format_diagnostics_includes_all_entries() {
// given
let source = r#"{"permissionMode": "plan", "badKey": 1}"#;
let source = r#"{"model": 42, "badKey": 1}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
let result = validate_config_file(object, source, &test_path());
@@ -821,7 +899,7 @@ mod tests {
assert!(output.contains("warning:"));
assert!(output.contains("error:"));
assert!(output.contains("badKey"));
assert!(output.contains("permissionMode"));
assert!(output.contains("model"));
}
#[test]

View File

@@ -11,7 +11,7 @@ use std::time::Duration;
use serde_json::{json, Value};
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
use crate::config::{RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig};
use crate::permissions::PermissionOverride;
const HOOK_PREVIEW_CHAR_LIMIT: usize = 160;
@@ -182,7 +182,7 @@ impl HookRunner {
) -> HookRunResult {
Self::run_commands(
HookEvent::PreToolUse,
self.config.pre_tool_use(),
self.config.pre_tool_use_entries(),
tool_name,
tool_input,
None,
@@ -232,7 +232,7 @@ impl HookRunner {
) -> HookRunResult {
Self::run_commands(
HookEvent::PostToolUse,
self.config.post_tool_use(),
self.config.post_tool_use_entries(),
tool_name,
tool_input,
Some(tool_output),
@@ -282,7 +282,7 @@ impl HookRunner {
) -> HookRunResult {
Self::run_commands(
HookEvent::PostToolUseFailure,
self.config.post_tool_use_failure(),
self.config.post_tool_use_failure_entries(),
tool_name,
tool_input,
Some(tool_error),
@@ -312,7 +312,7 @@ impl HookRunner {
#[allow(clippy::too_many_arguments)]
fn run_commands(
event: HookEvent,
commands: &[String],
commands: &[RuntimeHookCommand],
tool_name: &str,
tool_input: &str,
tool_output: Option<&str>,
@@ -342,17 +342,21 @@ impl HookRunner {
let payload = hook_payload(event, tool_name, tool_input, tool_output, is_error).to_string();
let mut result = HookRunResult::allow(Vec::new());
for command in commands {
for command in commands
.iter()
.filter(|command| command.matches_tool(tool_name))
{
let command_text = command.command();
if let Some(reporter) = reporter.as_deref_mut() {
reporter.on_event(&HookProgressEvent::Started {
event,
tool_name: tool_name.to_string(),
command: command.clone(),
command: command_text.to_string(),
});
}
match Self::run_command(
command,
command_text,
event,
tool_name,
tool_input,
@@ -366,7 +370,7 @@ impl HookRunner {
reporter.on_event(&HookProgressEvent::Completed {
event,
tool_name: tool_name.to_string(),
command: command.clone(),
command: command_text.to_string(),
});
}
merge_parsed_hook_output(&mut result, parsed);
@@ -376,7 +380,7 @@ impl HookRunner {
reporter.on_event(&HookProgressEvent::Completed {
event,
tool_name: tool_name.to_string(),
command: command.clone(),
command: command_text.to_string(),
});
}
merge_parsed_hook_output(&mut result, parsed);
@@ -388,7 +392,7 @@ impl HookRunner {
reporter.on_event(&HookProgressEvent::Completed {
event,
tool_name: tool_name.to_string(),
command: command.clone(),
command: command_text.to_string(),
});
}
merge_parsed_hook_output(&mut result, parsed);
@@ -400,7 +404,7 @@ impl HookRunner {
reporter.on_event(&HookProgressEvent::Cancelled {
event,
tool_name: tool_name.to_string(),
command: command.clone(),
command: command_text.to_string(),
});
}
result.cancelled = true;
@@ -737,7 +741,7 @@ fn format_hook_failure(command: &str, code: i32, stdout: Option<&str>, stderr: &
fn shell_command(command: &str) -> CommandWithStdin {
#[cfg(windows)]
let mut command_builder = {
let command_builder = {
let mut command_builder = Command::new("cmd");
command_builder.arg("/C").arg(command);
CommandWithStdin::new(command_builder)
@@ -825,7 +829,7 @@ mod tests {
HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult,
HookRunner,
};
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
use crate::config::{RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig};
use crate::permissions::PermissionOverride;
struct RecordingReporter {
@@ -851,6 +855,37 @@ mod tests {
assert_eq!(result, HookRunResult::allow(vec!["pre ok".to_string()]));
}
#[test]
fn object_style_hook_matchers_filter_runtime_execution() {
let runner = HookRunner::new(RuntimeHookConfig::from_hook_commands(
vec![
RuntimeHookCommand::new(shell_snippet("printf 'legacy'")),
RuntimeHookCommand::with_matcher(
shell_snippet("printf 'bash only'"),
Some("Bash".to_string()),
),
RuntimeHookCommand::with_matcher(
shell_snippet("printf 'read only'"),
Some("Read*".to_string()),
),
],
Vec::new(),
Vec::new(),
));
let read_result = runner.run_pre_tool_use("ReadFile", r#"{"path":"README.md"}"#);
let bash_result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
assert_eq!(
read_result,
HookRunResult::allow(vec!["legacy".to_string(), "read only".to_string()])
);
assert_eq!(
bash_result,
HookRunResult::allow(vec!["legacy".to_string(), "bash only".to_string()])
);
}
#[test]
fn denies_exit_code_two() {
let runner = HookRunner::new(RuntimeHookConfig::new(

View File

@@ -65,12 +65,14 @@ pub use compact::{
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
};
pub use config::{
suppress_config_warnings_for_json_mode, ConfigEntry, ConfigError, ConfigLoader, ConfigSource,
McpConfigCollection, McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig,
suppress_config_warnings_for_json_mode, ConfigEntry, ConfigError, ConfigFileReport,
ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource, McpConfigCollection,
McpInvalidServerConfig, McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig,
McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig, RuntimePermissionRuleConfig,
RuntimePluginConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig,
RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig,
CLAW_SETTINGS_SCHEMA_NAME,
};
pub use config_validate::{
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,
@@ -141,8 +143,9 @@ pub use policy_engine::{
PolicyEvaluation, PolicyRule, ReconcileReason, ReviewStatus,
};
pub use prompt::{
load_system_prompt, prepend_bullets, ContextFile, ModelFamilyIdentity, ProjectContext,
PromptBuildError, SystemPromptBuilder, FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
load_system_prompt, load_system_prompt_with_context, prepend_bullets, ContextFile,
ModelFamilyIdentity, ProjectContext, PromptBuildError, SystemPromptBuilder,
FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
};
pub use recovery_recipes::{
attempt_recovery, recipe_for, EscalationPolicy, FailureScenario, RecoveryAttemptState,

View File

@@ -3,7 +3,7 @@ use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::config::{ConfigError, ConfigLoader, RuntimeConfig};
use crate::config::{ConfigError, ConfigLoader, RulesImportConfig, RuntimeConfig};
use crate::git_context::GitContext;
/// Errors raised while assembling the final system prompt.
@@ -69,6 +69,18 @@ pub struct ContextFile {
pub content: String,
}
impl ContextFile {
#[must_use]
pub fn source(&self) -> &'static str {
instruction_file_source(&self.path)
}
#[must_use]
pub fn char_count(&self) -> usize {
self.content.chars().count()
}
}
/// Project-local context injected into the rendered system prompt.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProjectContext {
@@ -86,7 +98,24 @@ impl ProjectContext {
current_date: impl Into<String>,
) -> std::io::Result<Self> {
let cwd = cwd.into();
let instruction_files = discover_instruction_files(&cwd)?;
let instruction_files = discover_instruction_files(&cwd, &RulesImportConfig::default())?;
Ok(Self {
cwd,
current_date: current_date.into(),
git_status: None,
git_diff: None,
git_context: None,
instruction_files,
})
}
pub fn discover_with_rules_import(
cwd: impl Into<PathBuf>,
current_date: impl Into<String>,
rules_import: &RulesImportConfig,
) -> std::io::Result<Self> {
let cwd = cwd.into();
let instruction_files = discover_instruction_files(&cwd, rules_import)?;
Ok(Self {
cwd,
current_date: current_date.into(),
@@ -109,6 +138,18 @@ impl ProjectContext {
}
}
fn discover_with_git_and_rules_import(
cwd: impl Into<PathBuf>,
current_date: impl Into<String>,
rules_import: &RulesImportConfig,
) -> std::io::Result<ProjectContext> {
let mut context = ProjectContext::discover_with_rules_import(cwd, current_date, rules_import)?;
context.git_status = read_git_status(&context.cwd);
context.git_diff = read_git_diff(&context.cwd);
context.git_context = GitContext::detect(&context.cwd);
Ok(context)
}
/// Builder for the runtime system prompt and dynamic environment sections.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SystemPromptBuilder {
@@ -227,30 +268,81 @@ pub fn prepend_bullets(items: Vec<String>) -> Vec<String> {
items.into_iter().map(|item| format!(" - {item}")).collect()
}
fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
let mut directories = Vec::new();
let mut cursor = Some(cwd);
while let Some(dir) = cursor {
directories.push(dir.to_path_buf());
cursor = dir.parent();
fn instruction_file_source(path: &Path) -> &'static str {
let file_name = path.file_name().and_then(|name| name.to_str());
let parent_name = path
.parent()
.and_then(|parent| parent.file_name())
.and_then(|name| name.to_str());
match (parent_name, file_name) {
(Some(".claw"), Some("CLAUDE.md")) => "claw_claude_md",
(Some(".claude"), Some("CLAUDE.md")) => "claude_claude_md",
(_, Some("CLAUDE.md")) => "claude_md",
(_, Some("CLAW.md")) => "claw_md",
(_, Some("AGENTS.md")) => "agents_md",
(_, Some("CLAUDE.local.md")) => "claude_local_md",
(Some(".claw"), Some("instructions.md")) => "claw_instructions",
_ => "rule_file",
}
}
fn discover_instruction_files(
cwd: &Path,
rules_import: &RulesImportConfig,
) -> std::io::Result<Vec<ContextFile>> {
let mut directories = instruction_discovery_dirs(cwd);
directories.reverse();
let mut files = Vec::new();
for dir in directories {
for candidate in [
dir.join("CLAUDE.md"),
dir.join("CLAW.md"),
dir.join("AGENTS.md"),
dir.join("CLAUDE.local.md"),
dir.join(".claw").join("CLAUDE.md"),
dir.join(".claude").join("CLAUDE.md"),
dir.join(".claw").join("instructions.md"),
] {
push_context_file(&mut files, candidate)?;
}
push_rules_dir(&mut files, dir.join(".claw").join("rules"))?;
push_rules_dir(&mut files, dir.join(".claw").join("rules.local"))?;
push_framework_imports(&mut files, &dir, rules_import)?
}
Ok(dedupe_instruction_files(files))
}
fn instruction_discovery_dirs(cwd: &Path) -> Vec<PathBuf> {
let boundary = nearest_git_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
let mut directories = Vec::new();
let mut cursor = Some(cwd);
while let Some(dir) = cursor {
directories.push(dir.to_path_buf());
if dir == boundary {
break;
}
cursor = dir.parent();
}
directories
}
fn nearest_git_root(cwd: &Path) -> Option<PathBuf> {
let mut cursor = Some(cwd);
while let Some(dir) = cursor {
let git_marker = dir.join(".git");
if git_marker.is_dir() || git_marker.is_file() {
return Some(dir.to_path_buf());
}
cursor = dir.parent();
}
None
}
fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Result<()> {
if path.is_dir() {
return Ok(());
}
match fs::read_to_string(&path) {
Ok(content) if !content.trim().is_empty() => {
files.push(ContextFile { path, content });
@@ -262,6 +354,64 @@ fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Re
}
}
fn push_rules_dir(files: &mut Vec<ContextFile>, dir: PathBuf) -> std::io::Result<()> {
if dir.is_file() {
return Ok(());
}
let entries = match fs::read_dir(&dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error),
};
let mut paths = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_file() && is_supported_rule_file(path))
.collect::<Vec<_>>();
paths.sort();
for path in paths {
push_context_file(files, path)?;
}
Ok(())
}
fn is_supported_rule_file(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
matches!(
extension.to_ascii_lowercase().as_str(),
"md" | "txt" | "mdc"
)
})
}
fn push_framework_imports(
files: &mut Vec<ContextFile>,
dir: &Path,
rules_import: &RulesImportConfig,
) -> std::io::Result<()> {
if rules_import.should_import("cursor") {
push_context_file(files, dir.join(".cursorrules"))?;
push_rules_dir(files, dir.join(".cursor").join("rules"))?;
}
if rules_import.should_import("copilot") {
push_context_file(files, dir.join(".github").join("copilot-instructions.md"))?;
}
if rules_import.should_import("windsurf") {
push_context_file(files, dir.join(".windsurfrules"))?;
push_rules_dir(files, dir.join(".windsurfrules"))?;
}
if rules_import.should_import("plandex") {
push_context_file(files, dir.join(".plandex").join("instructions.md"))?;
}
if rules_import.should_import("crush") {
push_context_file(files, dir.join(".crush").join("CLAUDE.md"))?;
push_rules_dir(files, dir.join(".crush").join("rules"))?;
}
Ok(())
}
fn read_git_status(cwd: &Path) -> Option<String> {
let output = Command::new("git")
.args(["--no-optional-locks", "status", "--short", "--branch"])
@@ -332,7 +482,7 @@ fn render_project_context(project_context: &ProjectContext) -> String {
];
if !project_context.instruction_files.is_empty() {
bullets.push(format!(
"Claude instruction files discovered: {}.",
"Project instruction files discovered: {}.",
project_context.instruction_files.len()
));
}
@@ -367,7 +517,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!["# Project instructions".to_string()];
let mut remaining_chars = MAX_TOTAL_INSTRUCTION_CHARS;
for file in files {
if remaining_chars == 0 {
@@ -476,14 +626,30 @@ pub fn load_system_prompt(
model_family: ModelFamilyIdentity,
) -> Result<Vec<String>, PromptBuildError> {
let cwd = cwd.into();
let project_context = ProjectContext::discover_with_git(&cwd, current_date.into())?;
let (sections, _) =
load_system_prompt_with_context(cwd, current_date, os_name, os_version, model_family)?;
Ok(sections)
}
/// Loads config and project context, then renders the system prompt text plus metadata.
pub fn load_system_prompt_with_context(
cwd: impl Into<PathBuf>,
current_date: impl Into<String>,
os_name: impl Into<String>,
os_version: impl Into<String>,
model_family: ModelFamilyIdentity,
) -> Result<(Vec<String>, ProjectContext), PromptBuildError> {
let cwd = cwd.into();
let config = ConfigLoader::default_for(&cwd).load()?;
Ok(SystemPromptBuilder::new()
let project_context =
discover_with_git_and_rules_import(&cwd, current_date.into(), config.rules_import())?;
let sections = SystemPromptBuilder::new()
.with_os(os_name, os_version)
.with_model_family(model_family)
.with_project_context(project_context)
.with_project_context(project_context.clone())
.with_runtime_config(config)
.build())
.build();
Ok((sections, project_context))
}
fn render_config_section(config: &RuntimeConfig) -> String {
@@ -590,11 +756,84 @@ mod tests {
}
}
#[test]
fn discovers_claw_rules_files_in_sorted_order() {
let root = temp_dir();
let rules = root.join(".claw").join("rules");
let local_rules = root.join(".claw").join("rules.local");
fs::create_dir_all(&rules).expect("rules dir");
fs::create_dir_all(&local_rules).expect("local rules dir");
fs::write(rules.join("b.txt"), "b rule").expect("write b rule");
fs::write(rules.join("a.md"), "a rule").expect("write a rule");
fs::write(rules.join("ignored.json"), "ignored rule").expect("write ignored");
fs::write(local_rules.join("c.mdc"), "c local rule").expect("write local rule");
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
let contents = context
.instruction_files
.iter()
.map(|file| file.content.as_str())
.collect::<Vec<_>>();
assert_eq!(contents, vec!["a rule", "b rule", "c local rule"]);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn rules_import_none_suppresses_external_framework_rules() {
let root = temp_dir();
fs::create_dir_all(root.join(".claw").join("rules")).expect("rules dir");
fs::write(
root.join(".claw").join("rules").join("project.md"),
"claw rule",
)
.expect("write claw rule");
fs::write(root.join(".cursorrules"), "cursor rule").expect("write cursor rule");
let context = ProjectContext::discover_with_rules_import(
&root,
"2026-03-31",
&crate::config::RulesImportConfig::None,
)
.expect("context should load");
let rendered = render_instruction_files(&context.instruction_files);
assert!(rendered.contains("claw rule"));
assert!(!rendered.contains("cursor rule"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn rules_import_list_loads_only_selected_framework_rules() {
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
fs::write(root.join(".cursorrules"), "cursor rule").expect("write cursor rule");
fs::create_dir_all(root.join(".github")).expect("github dir");
fs::write(
root.join(".github").join("copilot-instructions.md"),
"copilot rule",
)
.expect("write copilot rule");
let context = ProjectContext::discover_with_rules_import(
&root,
"2026-03-31",
&crate::config::RulesImportConfig::List(vec!["copilot".to_string()]),
)
.expect("context should load");
let rendered = render_instruction_files(&context.instruction_files);
assert!(rendered.contains("copilot rule"));
assert!(!rendered.contains("cursor rule"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn discovers_instruction_files_from_ancestor_chain() {
let root = temp_dir();
let nested = root.join("apps").join("api");
fs::create_dir_all(nested.join(".claw")).expect("nested claw dir");
fs::create_dir(root.join(".git")).expect("git boundary");
fs::write(root.join("CLAUDE.md"), "root instructions").expect("write root instructions");
fs::write(root.join("CLAUDE.local.md"), "local instructions")
.expect("write local instructions");
@@ -636,11 +875,80 @@ mod tests {
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn discovers_agents_markdown_instruction_file() {
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
fs::write(root.join("AGENTS.md"), "agents-only instructions").expect("write AGENTS.md");
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
assert_eq!(context.instruction_files.len(), 1);
assert!(context.instruction_files[0].path.ends_with("AGENTS.md"));
assert!(render_instruction_files(&context.instruction_files)
.contains("agents-only instructions"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn discovers_scoped_dot_claude_claude_markdown_instruction_file() {
let root = temp_dir();
fs::create_dir_all(root.join(".claude")).expect("dot claude dir");
fs::write(
root.join(".claude").join("CLAUDE.md"),
"dot-claude-only instructions",
)
.expect("write .claude/CLAUDE.md");
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
assert_eq!(context.instruction_files.len(), 1);
assert!(context.instruction_files[0]
.path
.ends_with(".claude/CLAUDE.md"));
assert!(render_instruction_files(&context.instruction_files)
.contains("dot-claude-only instructions"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn discovers_claude_claw_agents_and_dot_claude_instruction_files_together() {
let root = temp_dir();
fs::create_dir_all(root.join(".claude")).expect("dot claude dir");
fs::write(root.join("CLAUDE.md"), "claude instructions").expect("write CLAUDE.md");
fs::write(root.join("CLAW.md"), "claw instructions").expect("write CLAW.md");
fs::write(root.join("AGENTS.md"), "agents instructions").expect("write AGENTS.md");
fs::write(
root.join(".claude").join("CLAUDE.md"),
"dot claude instructions",
)
.expect("write .claude/CLAUDE.md");
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
let rendered = render_instruction_files(&context.instruction_files);
let sources = context
.instruction_files
.iter()
.map(ContextFile::source)
.collect::<Vec<_>>();
assert_eq!(
sources,
vec!["claude_md", "claw_md", "agents_md", "claude_claude_md"]
);
assert!(rendered.contains("claude instructions"));
assert!(rendered.contains("claw instructions"));
assert!(rendered.contains("agents instructions"));
assert!(rendered.contains("dot claude instructions"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn dedupes_identical_instruction_content_across_scopes() {
let root = temp_dir();
let nested = root.join("apps").join("api");
fs::create_dir_all(&nested).expect("nested dir");
fs::create_dir(root.join(".git")).expect("git boundary");
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");
@@ -653,6 +961,50 @@ mod tests {
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn discovery_stops_at_git_root_boundary_439() {
let root = temp_dir();
let repo = root.join("repo");
let nested = repo.join("subproj").join("deep").join("nest");
fs::create_dir_all(&nested).expect("nested dir");
fs::create_dir(repo.join(".git")).expect("git boundary");
fs::write(root.join("CLAUDE.md"), "PARENT_CLAUDE").expect("write parent");
fs::write(repo.join("CLAUDE.md"), "REPO_CLAUDE").expect("write repo");
fs::write(repo.join("subproj").join("CLAUDE.md"), "CHILD_CLAUDE").expect("write child");
fs::write(
repo.join("subproj").join("deep").join("CLAUDE.md"),
"DEEP_CLAUDE",
)
.expect("write deep");
let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
let rendered = render_instruction_files(&context.instruction_files);
assert!(!rendered.contains("PARENT_CLAUDE"));
assert!(rendered.contains("REPO_CLAUDE"));
assert!(rendered.contains("CHILD_CLAUDE"));
assert!(rendered.contains("DEEP_CLAUDE"));
assert_eq!(context.instruction_files.len(), 3);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn discovery_without_git_root_stays_cwd_local_439() {
let root = temp_dir();
let nested = root.join("scratch");
fs::create_dir_all(&nested).expect("nested dir");
fs::write(root.join("CLAUDE.md"), "PARENT_CLAUDE").expect("write parent");
fs::write(nested.join("CLAUDE.md"), "SCRATCH_CLAUDE").expect("write scratch");
let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
let rendered = render_instruction_files(&context.instruction_files);
assert!(!rendered.contains("PARENT_CLAUDE"));
assert!(rendered.contains("SCRATCH_CLAUDE"));
assert_eq!(context.instruction_files.len(), 1);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn truncates_large_instruction_content_for_rendering() {
let rendered = render_instruction_content(&"x".repeat(4500));
@@ -876,6 +1228,51 @@ mod tests {
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn load_system_prompt_respects_rules_import_config() {
let root = temp_dir();
fs::create_dir_all(root.join(".claw")).expect("claw dir");
fs::write(root.join(".cursorrules"), "cursor rule").expect("write cursor rule");
fs::write(
root.join(".claw").join("settings.json"),
r#"{"rulesImport":"none"}"#,
)
.expect("write settings");
let _guard = env_lock();
ensure_valid_cwd();
let previous = std::env::current_dir().expect("cwd");
let original_home = std::env::var("HOME").ok();
let original_claw_home = std::env::var("CLAW_CONFIG_HOME").ok();
std::env::set_var("HOME", &root);
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",
ModelFamilyIdentity::Claude,
)
.expect("system prompt should load")
.join("\n\n");
std::env::set_current_dir(previous).expect("restore cwd");
if let Some(value) = original_home {
std::env::set_var("HOME", value);
} else {
std::env::remove_var("HOME");
}
if let Some(value) = original_claw_home {
std::env::set_var("CLAW_CONFIG_HOME", value);
} else {
std::env::remove_var("CLAW_CONFIG_HOME");
}
assert!(!prompt.contains("cursor rule"));
assert!(prompt.contains("rulesImport"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn renders_default_claude_model_family_identity() {
// given: a prompt builder without an explicit model family override
@@ -945,7 +1342,7 @@ mod tests {
assert!(prompt.contains("# System"));
assert!(prompt.contains("# Project context"));
assert!(prompt.contains("# Claude instructions"));
assert!(prompt.contains("# Project instructions"));
assert!(prompt.contains("Project rules"));
assert!(prompt.contains("permissionMode"));
assert!(prompt.contains(SYSTEM_PROMPT_DYNAMIC_BOUNDARY));
@@ -990,7 +1387,7 @@ mod tests {
path: PathBuf::from("/tmp/project/CLAUDE.md"),
content: "Project rules".to_string(),
}]);
assert!(rendered.contains("# Claude instructions"));
assert!(rendered.contains("# Project instructions"));
assert!(rendered.contains("scope: /tmp/project"));
assert!(rendered.contains("Project rules"));
}

View File

@@ -28,7 +28,8 @@ pub struct SessionStore {
impl SessionStore {
/// Build a store from the server's current working directory.
///
/// The on-disk layout becomes `<cwd>/.claw/sessions/<workspace_hash>/`.
/// The on-disk layout is `<cwd>/.claw/sessions/<workspace_hash>/`,
/// created lazily on first successful session save.
pub fn from_cwd(cwd: impl AsRef<Path>) -> Result<Self, SessionControlError> {
let cwd = cwd.as_ref();
// #151: canonicalize so equivalent paths (symlinks, relative vs
@@ -40,7 +41,6 @@ impl SessionStore {
.join(".claw")
.join("sessions")
.join(workspace_fingerprint(&canonical_cwd));
fs::create_dir_all(&sessions_root)?;
Ok(Self {
sessions_root,
workspace_root: canonical_cwd,
@@ -49,7 +49,8 @@ impl SessionStore {
/// Build a store from an explicit `--data-dir` flag.
///
/// The on-disk layout becomes `<data_dir>/sessions/<workspace_hash>/`
/// The on-disk layout is `<data_dir>/sessions/<workspace_hash>/`,
/// created lazily on first successful session save.
/// where `<workspace_hash>` is derived from `workspace_root`.
pub fn from_data_dir(
data_dir: impl AsRef<Path>,
@@ -64,7 +65,6 @@ impl SessionStore {
.as_ref()
.join("sessions")
.join(workspace_fingerprint(&canonical_workspace));
fs::create_dir_all(&sessions_root)?;
Ok(Self {
sessions_root,
workspace_root: canonical_workspace,
@@ -833,14 +833,21 @@ mod tests {
use crate::session::Session;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_dir() -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!("runtime-session-control-{nanos}"))
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"runtime-session-control-{}-{nanos}-{counter}",
std::process::id()
))
}
fn persist_session(root: &Path, text: &str) -> Session {
@@ -1054,6 +1061,38 @@ mod tests {
}
}
#[test]
fn session_store_from_cwd_is_side_effect_free_until_save() {
// given
let base = temp_dir();
let workspace = base.join("fresh-workspace");
fs::create_dir_all(&workspace).expect("workspace should exist");
// when
let store = SessionStore::from_cwd(&workspace).expect("store should build");
// then — resolving the store must not create .claw/session partitions.
assert!(
!workspace.join(".claw").exists(),
"session store construction must not create .claw side effects"
);
assert!(
!store.sessions_dir().exists(),
"session partition should be created lazily on save"
);
let session = persist_session_via_store(&store, "first saved turn");
assert!(
store
.sessions_dir()
.join(format!("{}.jsonl", session.session_id))
.exists(),
"saving a managed session should create the lazy session partition"
);
fs::remove_dir_all(base).expect("temp dir should clean up");
}
#[test]
fn session_store_from_cwd_isolates_sessions_by_workspace() {
// given

View File

@@ -12,7 +12,6 @@ path = "src/main.rs"
[dependencies]
api = { path = "../api" }
commands = { path = "../commands" }
compat-harness = { path = "../compat-harness" }
crossterm = "0.28"
pulldown-cmark = "0.13"
rustyline = "15"

View File

@@ -1,10 +1,9 @@
use std::env;
use std::process::Command;
fn main() {
// Get git SHA (short hash)
let git_sha = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
fn command_output(program: &str, args: &[&str]) -> Option<String> {
Command::new(program)
.args(args)
.output()
.ok()
.and_then(|output| {
@@ -14,11 +13,37 @@ fn main() {
None
}
})
.map_or_else(|| "unknown".to_string(), |s| s.trim().to_string());
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn main() {
let git_sha =
command_output("git", &["rev-parse", "HEAD"]).unwrap_or_else(|| "unknown".to_string());
let git_sha_short = command_output("git", &["rev-parse", "--short=12", "HEAD"])
.or_else(|| git_sha.get(..git_sha.len().min(12)).map(str::to_string))
.unwrap_or_else(|| "unknown".to_string());
let git_dirty = command_output("git", &["status", "--porcelain"])
.map(|status| (!status.trim().is_empty()).to_string())
.unwrap_or_else(|| "false".to_string());
let git_branch = command_output("git", &["branch", "--show-current"])
.unwrap_or_else(|| "unknown".to_string());
let git_commit_date = command_output("git", &["show", "-s", "--format=%cI", "HEAD"])
.unwrap_or_else(|| "unknown".to_string());
let git_commit_timestamp = command_output("git", &["show", "-s", "--format=%ct", "HEAD"])
.unwrap_or_else(|| "unknown".to_string());
let rustc_version =
command_output("rustc", &["--version"]).unwrap_or_else(|| "unknown".to_string());
println!("cargo:rustc-env=GIT_SHA={git_sha}");
println!("cargo:rustc-env=GIT_SHA_SHORT={git_sha_short}");
println!("cargo:rustc-env=GIT_DIRTY={git_dirty}");
println!("cargo:rustc-env=GIT_BRANCH={git_branch}");
println!("cargo:rustc-env=GIT_COMMIT_DATE={git_commit_date}");
println!("cargo:rustc-env=GIT_COMMIT_TIMESTAMP={git_commit_timestamp}");
println!("cargo:rustc-env=RUSTC_VERSION={rustc_version}");
// TARGET is always set by Cargo during build
// TARGET is always set by Cargo during build.
let target = env::var("TARGET").unwrap_or_else(|_| "unknown".to_string());
println!("cargo:rustc-env=TARGET={target}");
@@ -35,23 +60,12 @@ fn main() {
})
.or_else(|| std::env::var("BUILD_DATE").ok())
.unwrap_or_else(|| {
// Fall back to current date via `date` command
Command::new("date")
.args(["+%Y-%m-%d"])
.output()
.ok()
.and_then(|o| {
if o.status.success() {
String::from_utf8(o.stdout).ok()
} else {
None
}
})
.map_or_else(|| "unknown".to_string(), |s| s.trim().to_string())
command_output("date", &["+%Y-%m-%d"]).unwrap_or_else(|| "unknown".to_string())
});
println!("cargo:rustc-env=BUILD_DATE={build_date}");
// Rerun if git state changes
println!("cargo:rerun-if-changed=.git/HEAD");
println!("cargo:rerun-if-changed=.git/refs");
// Rerun if git state changes. Paths are relative to this package root.
println!("cargo:rerun-if-changed=../../../.git/HEAD");
println!("cargo:rerun-if-changed=../../../.git/refs");
println!("cargo:rerun-if-changed=../../../.git/index");
}

View File

@@ -4,7 +4,14 @@ use std::path::{Path, PathBuf};
const STARTER_CLAW_JSON: &str = concat!(
"{\n",
" \"permissions\": {\n",
" \"defaultMode\": \"dontAsk\"\n",
" \"defaultMode\": \"acceptEdits\"\n",
" }\n",
"}\n",
);
const STARTER_SETTINGS_JSON: &str = concat!(
"{\n",
" \"permissions\": {\n",
" \"defaultMode\": \"acceptEdits\"\n",
" }\n",
"}\n",
);
@@ -15,6 +22,8 @@ const GITIGNORE_ENTRIES: [&str; 3] = [".claw/settings.local.json", ".claw/sessio
pub(crate) enum InitStatus {
Created,
Updated,
Partial,
Deferred,
Skipped,
}
@@ -24,6 +33,8 @@ impl InitStatus {
match self {
Self::Created => "created",
Self::Updated => "updated",
Self::Partial => "partial (created missing sub-files)",
Self::Deferred => "deferred (created on first session save)",
Self::Skipped => "skipped (already exists)",
}
}
@@ -36,6 +47,8 @@ impl InitStatus {
match self {
Self::Created => "created",
Self::Updated => "updated",
Self::Partial => "partial",
Self::Deferred => "deferred",
Self::Skipped => "skipped",
}
}
@@ -123,9 +136,30 @@ pub(crate) fn initialize_repo(cwd: &Path) -> Result<InitReport, Box<dyn std::err
let mut artifacts = Vec::new();
let claw_dir = cwd.join(".claw");
let claw_dir_status = ensure_dir(&claw_dir)?;
let settings_json = claw_dir.join("settings.json");
let settings_status = write_file_if_missing(&settings_json, STARTER_SETTINGS_JSON)?;
let claw_dir_status =
if claw_dir_status == InitStatus::Skipped && settings_status == InitStatus::Created {
InitStatus::Partial
} else {
claw_dir_status
};
artifacts.push(InitArtifact {
name: ".claw/",
status: ensure_dir(&claw_dir)?,
status: claw_dir_status,
});
artifacts.push(InitArtifact {
name: ".claw/settings.json",
status: settings_status,
});
artifacts.push(InitArtifact {
name: ".claw/sessions/",
status: if claw_dir.join("sessions").is_dir() {
InitStatus::Skipped
} else {
InitStatus::Deferred
},
});
let claw_json = cwd.join(".claw.json");
@@ -414,11 +448,26 @@ mod tests {
concat!(
"{\n",
" \"permissions\": {\n",
" \"defaultMode\": \"dontAsk\"\n",
" \"defaultMode\": \"acceptEdits\"\n",
" }\n",
"}\n",
)
);
assert_eq!(
fs::read_to_string(root.join(".claw").join("settings.json"))
.expect("read project settings"),
concat!(
"{\n",
" \"permissions\": {\n",
" \"defaultMode\": \"acceptEdits\"\n",
" }\n",
"}\n",
)
);
assert!(
!root.join(".claw").join("sessions").exists(),
"sessions directory should be deferred until first session save"
);
let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
assert!(gitignore.contains(".claw/settings.local.json"));
assert!(gitignore.contains(".claw/sessions/"));
@@ -436,14 +485,24 @@ mod tests {
fs::create_dir_all(&root).expect("create root");
fs::write(root.join("CLAUDE.md"), "custom guidance\n").expect("write existing claude md");
fs::write(root.join(".gitignore"), ".claw/settings.local.json\n").expect("write gitignore");
fs::create_dir_all(root.join(".claw")).expect("create existing .claw dir");
let first = initialize_repo(&root).expect("first init should succeed");
assert!(first
.render()
.contains("CLAUDE.md skipped (already exists)"));
assert_eq!(
first.artifacts_with_status(InitStatus::Partial),
vec![".claw/".to_string()],
"existing .claw/ should report partial when init creates missing settings.json"
);
assert!(root.join(".claw").join("settings.json").is_file());
let second = initialize_repo(&root).expect("second init should succeed");
let second_rendered = second.render();
assert!(second_rendered.contains(".claw/"));
assert!(second_rendered.contains(".claw/settings.json"));
assert!(second_rendered.contains(".claw/sessions/"));
assert!(second_rendered.contains(".claw.json"));
assert!(second_rendered.contains("skipped (already exists)"));
assert!(second_rendered.contains(".gitignore skipped (already exists)"));
@@ -474,16 +533,22 @@ mod tests {
created_names,
vec![
".claw/".to_string(),
".claw/settings.json".to_string(),
".claw.json".to_string(),
".gitignore".to_string(),
"CLAUDE.md".to_string(),
],
"fresh init should place all four artifacts in created[]"
"fresh init should place created artifacts in created[]"
);
assert!(
fresh.artifacts_with_status(InitStatus::Skipped).is_empty(),
"fresh init should have no skipped artifacts"
);
assert_eq!(
fresh.artifacts_with_status(InitStatus::Deferred),
vec![".claw/sessions/".to_string()],
"fresh init should report session storage as deferred"
);
let second = initialize_repo(&root).expect("second init should succeed");
let skipped_names = second.artifacts_with_status(InitStatus::Skipped);
@@ -491,27 +556,38 @@ mod tests {
skipped_names,
vec![
".claw/".to_string(),
".claw/settings.json".to_string(),
".claw.json".to_string(),
".gitignore".to_string(),
"CLAUDE.md".to_string(),
],
"idempotent init should place all four artifacts in skipped[]"
"idempotent init should place existing artifacts in skipped[]"
);
assert!(
second.artifacts_with_status(InitStatus::Created).is_empty(),
"idempotent init should have no created artifacts"
);
assert_eq!(
second.artifacts_with_status(InitStatus::Deferred),
vec![".claw/sessions/".to_string()],
"idempotent init should keep session storage deferred until first save"
);
// artifact_json_entries() uses the machine-stable `json_tag()` which
// never changes wording (unlike `label()` which says "skipped (already exists)").
let entries = second.artifact_json_entries();
assert_eq!(entries.len(), 4);
assert_eq!(entries.len(), 6);
for entry in &entries {
let name = entry.get("name").and_then(|v| v.as_str()).unwrap();
let status = entry.get("status").and_then(|v| v.as_str()).unwrap();
assert_eq!(
status, "skipped",
"machine status tag should be the bare word 'skipped', not label()'s 'skipped (already exists)'"
);
if name == ".claw/sessions/" {
assert_eq!(status, "deferred");
} else {
assert_eq!(
status, "skipped",
"machine status tag should be the bare word 'skipped', not label()'s 'skipped (already exists)'"
);
}
}
fs::remove_dir_all(root).expect("cleanup temp dir");

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
#![allow(clippy::while_let_on_iterator)]
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Output, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
@@ -246,8 +247,121 @@ stderr:
}
#[test]
fn compact_subcommand_json_help_fails_fast_when_stdin_closed() {
let workspace = unique_temp_dir("compact-nontty-json-help");
fn prompt_subcommand_reads_prompt_from_stdin_when_no_positional_arg_423() {
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
let server = runtime
.block_on(MockAnthropicService::spawn())
.expect("mock service should start");
let base_url = server.base_url();
let workspace = unique_temp_dir("prompt-stdin-423");
let config_home = workspace.join("config-home");
let home = workspace.join("home");
fs::create_dir_all(&workspace).expect("workspace should exist");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
let prompt = format!("{SCENARIO_PREFIX}streaming_text\n");
let output = run_claw_with_stdin(
&workspace,
&config_home,
&home,
&base_url,
&[
"prompt",
"--output-format",
"json",
"--compact",
"--permission-mode",
"read-only",
"--model",
"sonnet",
],
&prompt,
);
assert!(
output.status.success(),
"prompt stdin run should succeed\nstdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let parsed: Value = serde_json::from_slice(&output.stdout).expect("stdout should parse");
assert_eq!(
parsed["message"],
"Mock streaming says hello from the parity harness."
);
let captured = runtime.block_on(server.captured_requests());
assert!(
captured
.iter()
.any(|request| request.raw_body.contains("PARITY_SCENARIO:streaming_text")),
"stdin prompt should reach the provider request: {captured:?}"
);
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
}
#[test]
fn prompt_subcommand_stdin_flag_appends_pipe_context_423() {
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
let server = runtime
.block_on(MockAnthropicService::spawn())
.expect("mock service should start");
let base_url = server.base_url();
let workspace = unique_temp_dir("prompt-stdin-flag-423");
let config_home = workspace.join("config-home");
let home = workspace.join("home");
fs::create_dir_all(&workspace).expect("workspace should exist");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
let prompt_context = format!("{SCENARIO_PREFIX}streaming_text\n");
let output = run_claw_with_stdin(
&workspace,
&config_home,
&home,
&base_url,
&[
"prompt",
"Use stdin context",
"--stdin",
"--output-format",
"json",
"--compact",
"--permission-mode",
"read-only",
"--model",
"sonnet",
],
&prompt_context,
);
assert!(
output.status.success(),
"prompt --stdin run should succeed\nstdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let captured = runtime.block_on(server.captured_requests());
let provider_body = captured
.iter()
.find(|request| request.raw_body.contains("Use stdin context"))
.expect("merged prompt should reach provider");
assert!(
provider_body
.raw_body
.contains("PARITY_SCENARIO:streaming_text"),
"merged prompt should include stdin context: {provider_body:?}"
);
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
}
#[test]
fn compact_subcommand_json_fails_fast_when_stdin_closed() {
let workspace = unique_temp_dir("compact-nontty-json");
let config_home = workspace.join("config-home");
let home = workspace.join("home");
fs::create_dir_all(&workspace).expect("workspace should exist");
@@ -258,19 +372,19 @@ fn compact_subcommand_json_help_fails_fast_when_stdin_closed() {
&workspace,
&config_home,
&home,
&["compact", "--output-format", "json", "--help"],
&["compact", "--output-format", "json"],
Duration::from_secs(2),
);
assert!(
!output.status.success(),
"compact json help should fail non-zero"
"compact json should fail non-zero"
);
// #819/#820/#823: JSON abort envelopes route to stdout
let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8");
assert!(
stderr.trim().is_empty() || !stderr.trim_start().starts_with('{'),
"compact json help should not emit JSON envelope to stderr (#819/#820/#823): {stderr}"
"compact json should not emit JSON envelope to stderr (#819/#820/#823): {stderr}"
);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
let parsed: Value =
@@ -356,6 +470,39 @@ fn run_claw(
command.output().expect("claw should launch")
}
fn run_claw_with_stdin(
cwd: &std::path::Path,
config_home: &std::path::Path,
home: &std::path::Path,
base_url: &str,
args: &[&str],
stdin: &str,
) -> Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_claw"))
.current_dir(cwd)
.env_clear()
.env("ANTHROPIC_API_KEY", "test-compact-key")
.env("ANTHROPIC_BASE_URL", base_url)
.env("CLAW_CONFIG_HOME", config_home)
.env("HOME", home)
.env("NO_COLOR", "1")
.env("PATH", "/usr/bin:/bin")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.args(args)
.spawn()
.expect("claw should launch");
child
.stdin
.as_mut()
.expect("stdin should be piped")
.write_all(stdin.as_bytes())
.expect("stdin should write");
child.stdin.take();
child.wait_with_output().expect("output should collect")
}
fn run_claw_closed_stdin_with_timeout(
cwd: &std::path::Path,
config_home: &std::path::Path,

File diff suppressed because it is too large Load Diff

View File

@@ -222,6 +222,73 @@ fn resume_latest_restores_the_most_recent_managed_session() {
assert!(stdout.contains(newer_path.to_str().expect("utf8 path")));
}
#[test]
fn resume_latest_missing_session_fails_without_creating_session_dirs_435() {
// given
let temp_dir = unique_temp_dir("resume-latest-missing-435");
let project_dir = temp_dir.join("project");
let config_home = temp_dir.join("config-home");
let home = temp_dir.join("home");
fs::create_dir_all(&project_dir).expect("project dir should exist");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
let envs = [
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
("ANTHROPIC_API_KEY", ""),
("ANTHROPIC_AUTH_TOKEN", ""),
("OPENAI_API_KEY", ""),
];
// when — both text and JSON resume failures should be non-zero and read-only.
let text = run_claw_with_env(&project_dir, &["--resume", "latest"], &envs);
let json = run_claw_with_env(
&project_dir,
&["--output-format", "json", "--resume", "latest"],
&envs,
);
// then
assert_eq!(
text.status.code(),
Some(1),
"text resume failure must be non-zero"
);
assert!(
text.stdout.is_empty(),
"text resume failure should not claim success on stdout: {}",
String::from_utf8_lossy(&text.stdout)
);
let text_stderr = String::from_utf8_lossy(&text.stderr);
assert!(
text_stderr.contains("no managed sessions found"),
"text failure should explain missing sessions: {text_stderr}"
);
assert_eq!(
json.status.code(),
Some(1),
"JSON resume failure must be non-zero"
);
assert!(
json.stderr.is_empty(),
"JSON resume failure should keep stderr empty: {}",
String::from_utf8_lossy(&json.stderr)
);
let parsed: Value = serde_json::from_slice(&json.stdout)
.expect("JSON resume failure should emit JSON to stdout");
assert_eq!(parsed["status"], "error");
assert_eq!(parsed["action"], "restore");
assert_eq!(parsed["error_kind"], "no_managed_sessions");
assert!(
!project_dir.join(".claw").exists(),
"failed resume must not create .claw/session directories"
);
}
#[test]
fn resumed_status_command_emits_structured_json_when_requested() {
// given
@@ -268,7 +335,7 @@ fn resumed_status_command_emits_structured_json_when_requested() {
assert_eq!(parsed["kind"], "status");
// model is null in resume mode (not known without --model flag)
assert!(parsed["model"].is_null());
assert_eq!(parsed["permission_mode"], "danger-full-access");
assert_eq!(parsed["permission_mode"], "workspace-write");
assert_eq!(parsed["usage"]["messages"], 1);
assert!(parsed["usage"]["turns"].is_number());
assert!(parsed["workspace"]["cwd"].as_str().is_some());
@@ -396,6 +463,9 @@ fn resumed_version_command_emits_structured_json() {
assert!(parsed["version"].as_str().is_some());
assert!(parsed["git_sha"].as_str().is_some());
assert!(parsed["target"].as_str().is_some());
assert!(parsed["git_sha_short"].as_str().is_some());
assert!(parsed.get("message").is_none());
assert!(parsed["human_readable"].as_str().is_some());
}
#[test]

View File

@@ -201,30 +201,20 @@ impl GlobalToolRegistry {
return Ok(None);
}
let builtin_specs = mvp_tool_specs();
let canonical_names = builtin_specs
.iter()
.map(|spec| spec.name.to_string())
.chain(
self.plugin_tools
.iter()
.map(|tool| tool.definition().name.clone()),
)
.chain(self.runtime_tools.iter().map(|tool| tool.name.clone()))
.collect::<Vec<_>>();
let mut name_map = canonical_names
.iter()
.map(|name| (normalize_tool_name(name), name.clone()))
.collect::<BTreeMap<_, _>>();
let actual_names = self.actual_tool_names();
let canonical_names = self.canonical_allowed_tool_names();
let canonical_name_set = canonical_names.iter().cloned().collect::<BTreeSet<_>>();
let mut name_map = BTreeMap::new();
for actual in &actual_names {
let canonical = canonical_allowed_tool_name(actual);
name_map.insert(allowed_tool_lookup_key(actual), canonical.clone());
name_map.insert(allowed_tool_lookup_key(&canonical), canonical);
}
for (alias, canonical) in [
("read", "read_file"),
("write", "write_file"),
("edit", "edit_file"),
("glob", "glob_search"),
("grep", "grep_search"),
] {
name_map.insert(alias.to_string(), canonical.to_string());
for (alias, canonical) in self.allowed_tool_aliases() {
if canonical_name_set.contains(&canonical) {
name_map.insert(allowed_tool_lookup_key(&alias), canonical);
}
}
let mut allowed = BTreeSet::new();
@@ -233,11 +223,11 @@ impl GlobalToolRegistry {
.split(|ch: char| ch == ',' || ch.is_whitespace())
.filter(|token| !token.is_empty())
{
let normalized = normalize_tool_name(token);
let canonical = name_map.get(&normalized).ok_or_else(|| {
let canonical = name_map.get(&allowed_tool_lookup_key(token)).ok_or_else(|| {
format!(
"unsupported tool in --allowedTools: {token} (expected one of: {})",
canonical_names.join(", ")
"invalid_tool_name: unsupported tool in --allowedTools: {token}\nAvailable: {}\nAliases: {}\nHint: Use canonical snake_case tool names from Available or aliases from Aliases.",
canonical_names.join(", "),
format_allowed_tool_aliases(&self.allowed_tool_aliases())
)
})?;
allowed.insert(canonical.clone());
@@ -258,7 +248,10 @@ impl GlobalToolRegistry {
pub fn definitions(&self, allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolDefinition> {
let builtin = mvp_tool_specs()
.into_iter()
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
.filter(|spec| {
allowed_tools
.is_none_or(|allowed| allowed.contains(&canonical_allowed_tool_name(spec.name)))
})
.map(|spec| ToolDefinition {
name: spec.name.to_string(),
description: Some(spec.description.to_string()),
@@ -267,7 +260,11 @@ impl GlobalToolRegistry {
let runtime = self
.runtime_tools
.iter()
.filter(|tool| allowed_tools.is_none_or(|allowed| allowed.contains(tool.name.as_str())))
.filter(|tool| {
allowed_tools.is_none_or(|allowed| {
allowed.contains(&canonical_allowed_tool_name(&tool.name))
})
})
.map(|tool| ToolDefinition {
name: tool.name.clone(),
description: tool.description.clone(),
@@ -277,8 +274,11 @@ impl GlobalToolRegistry {
.plugin_tools
.iter()
.filter(|tool| {
allowed_tools
.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str()))
allowed_tools.is_none_or(|allowed| {
allowed.contains(&canonical_allowed_tool_name(
tool.definition().name.as_str(),
))
})
})
.map(|tool| ToolDefinition {
name: tool.definition().name.clone(),
@@ -294,19 +294,29 @@ impl GlobalToolRegistry {
) -> Result<Vec<(String, PermissionMode)>, String> {
let builtin = mvp_tool_specs()
.into_iter()
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
.filter(|spec| {
allowed_tools
.is_none_or(|allowed| allowed.contains(&canonical_allowed_tool_name(spec.name)))
})
.map(|spec| (spec.name.to_string(), spec.required_permission));
let runtime = self
.runtime_tools
.iter()
.filter(|tool| allowed_tools.is_none_or(|allowed| allowed.contains(tool.name.as_str())))
.filter(|tool| {
allowed_tools.is_none_or(|allowed| {
allowed.contains(&canonical_allowed_tool_name(&tool.name))
})
})
.map(|tool| (tool.name.clone(), tool.required_permission));
let plugin = self
.plugin_tools
.iter()
.filter(|tool| {
allowed_tools
.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str()))
allowed_tools.is_none_or(|allowed| {
allowed.contains(&canonical_allowed_tool_name(
tool.definition().name.as_str(),
))
})
})
.map(|tool| {
permission_mode_from_plugin(tool.required_permission())
@@ -316,6 +326,52 @@ impl GlobalToolRegistry {
Ok(builtin.chain(runtime).chain(plugin).collect())
}
#[must_use]
pub fn actual_tool_names(&self) -> Vec<String> {
mvp_tool_specs()
.iter()
.map(|spec| spec.name.to_string())
.chain(
self.plugin_tools
.iter()
.map(|tool| tool.definition().name.clone()),
)
.chain(self.runtime_tools.iter().map(|tool| tool.name.clone()))
.collect()
}
#[must_use]
pub fn canonical_allowed_tool_names(&self) -> Vec<String> {
self.actual_tool_names()
.into_iter()
.map(|name| canonical_allowed_tool_name(&name))
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
#[must_use]
pub fn allowed_tool_aliases(&self) -> BTreeMap<String, String> {
let mut aliases = BTreeMap::from([
("read".to_string(), "read_file".to_string()),
("Read".to_string(), "read_file".to_string()),
("write".to_string(), "write_file".to_string()),
("Write".to_string(), "write_file".to_string()),
("edit".to_string(), "edit_file".to_string()),
("Edit".to_string(), "edit_file".to_string()),
("glob".to_string(), "glob_search".to_string()),
("Glob".to_string(), "glob_search".to_string()),
("grep".to_string(), "grep_search".to_string()),
("Grep".to_string(), "grep_search".to_string()),
]);
for actual in self.actual_tool_names() {
let canonical = canonical_allowed_tool_name(&actual);
if actual != canonical {
aliases.insert(actual, canonical);
}
}
aliases
}
#[must_use]
pub fn has_runtime_tool(&self, name: &str) -> bool {
self.runtime_tools.iter().any(|tool| tool.name == name)
@@ -378,8 +434,40 @@ impl GlobalToolRegistry {
}
}
fn normalize_tool_name(value: &str) -> String {
value.trim().replace('-', "_").to_ascii_lowercase()
pub fn canonical_allowed_tool_name(value: &str) -> String {
let trimmed = value.trim().replace('-', "_");
let mut output = String::new();
let chars = trimmed.chars().collect::<Vec<_>>();
for (index, ch) in chars.iter().copied().enumerate() {
if ch == '_' || ch.is_whitespace() {
output.push('_');
continue;
}
let previous = index.checked_sub(1).and_then(|i| chars.get(i)).copied();
let next = chars.get(index + 1).copied();
if ch.is_ascii_uppercase()
&& index > 0
&& !output.ends_with('_')
&& (previous.is_some_and(|p| p.is_ascii_lowercase() || p.is_ascii_digit())
|| next.is_some_and(|n| n.is_ascii_lowercase()))
{
output.push('_');
}
output.push(ch.to_ascii_lowercase());
}
output.trim_matches('_').to_string()
}
fn allowed_tool_lookup_key(value: &str) -> String {
canonical_allowed_tool_name(value).replace('_', "")
}
fn format_allowed_tool_aliases(aliases: &BTreeMap<String, String>) -> String {
aliases
.iter()
.map(|(alias, canonical)| format!("{alias}={canonical}"))
.collect::<Vec<_>>()
.join(", ")
}
fn permission_mode_from_plugin(value: &str) -> Result<PermissionMode, String> {
@@ -514,7 +602,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["url", "prompt"],
"additionalProperties": false
}),
required_permission: PermissionMode::ReadOnly,
required_permission: PermissionMode::DangerFullAccess,
},
ToolSpec {
name: "WebSearch",
@@ -535,7 +623,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["query"],
"additionalProperties": false
}),
required_permission: PermissionMode::ReadOnly,
required_permission: PermissionMode::DangerFullAccess,
},
ToolSpec {
name: "TodoWrite",
@@ -1225,13 +1313,14 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
},
ToolSpec {
name: "GitShow",
description: "Show a commit, tag, or tree object with its diff. Supports showing a specific file at a commit (commit:path) and stat-only mode. Use this instead of running git show via bash to get structured output.",
description: "Show a commit, tag, or tree object. Use format to control output: patch (default) shows the full diff, stat shows a diffstat summary, and metadata shows commit info without the diff. Supports showing a specific file at a commit (commit:path) for patch/stat output. Use this instead of running git show via bash to get structured output.",
input_schema: json!({
"type": "object",
"properties": {
"commit": { "type": "string" },
"path": { "type": "string" },
"stat": { "type": "boolean" }
"stat": { "type": "boolean" },
"format": { "type": "string", "enum": ["patch", "stat", "metadata"] },
},
"required": ["commit"],
"additionalProperties": false
@@ -1320,8 +1409,26 @@ fn execute_tool_with_enforcer(
maybe_enforce_permission_check_with_mode(enforcer, name, input, required_mode)?;
run_grep_search(grep_input)
}
"WebFetch" => from_value::<WebFetchInput>(input).and_then(run_web_fetch),
"WebSearch" => from_value::<WebSearchInput>(input).and_then(run_web_search),
"WebFetch" => {
let web_input = from_value::<WebFetchInput>(input)?;
maybe_enforce_permission_check_with_mode(
enforcer,
name,
input,
PermissionMode::DangerFullAccess,
)?;
run_web_fetch(web_input)
}
"WebSearch" => {
let web_input = from_value::<WebSearchInput>(input)?;
maybe_enforce_permission_check_with_mode(
enforcer,
name,
input,
PermissionMode::DangerFullAccess,
)?;
run_web_search(web_input)
}
"TodoWrite" => from_value::<TodoWriteInput>(input).and_then(run_todo_write),
"Skill" => from_value::<SkillInput>(input).and_then(run_skill),
"Agent" => from_value::<AgentInput>(input).and_then(run_agent),
@@ -2008,14 +2115,37 @@ fn run_git_log(input: GitLogInput) -> Result<String, String> {
}
}
#[allow(clippy::needless_pass_by_value)]
/// Execute `git show` for a given commit, optionally with --stat or a file path.
/// Uses the `commit:path` syntax when a path is specified.
fn run_git_show(input: GitShowInput) -> Result<String, String> {
let mut args: Vec<String> = vec!["show".to_string()];
if input.stat.unwrap_or(false) {
args.push("--stat".to_string());
match input.format.as_deref() {
Some("metadata") if input.path.is_some() => {
return Err(
"GitShow format \"metadata\" cannot be combined with path; metadata describes a commit, not a blob. Use format \"patch\" or \"stat\" with path, or omit path."
.to_string(),
);
}
Some("metadata") => {
args.push("--format=medium".to_string());
args.push("--no-patch".to_string());
}
Some("stat") => {
args.push("--stat".to_string());
}
Some("patch") | None => {
if input.format.is_none() && input.stat.unwrap_or(false) {
args.push("--stat".to_string());
}
}
Some(other) => {
return Err(format!(
"unknown GitShow format: \"{other}\". Supported values: \"patch\" (default), \"stat\", \"metadata\"."
));
}
}
if let Some(ref path) = input.path {
args.push(format!("{}:{}", input.commit, path));
} else {
@@ -2964,6 +3094,9 @@ struct GitShowInput {
#[serde(default)]
/// If true, show diffstat summary instead of full diff.
stat: Option<bool>,
#[serde(default)]
/// Output format: "patch" (default) shows the full diff, "stat" shows a diffstat summary, and "metadata" shows commit info without the diff. When set, takes priority over `stat`.
format: Option<String>,
}
/// Input for the GitBlame tool: shows per-line author/revision info for a file.
@@ -4165,7 +4298,7 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet<String> {
"PowerShell",
],
};
tools.into_iter().map(str::to_string).collect()
tools.into_iter().map(canonical_allowed_tool_name).collect()
}
fn agent_permission_policy() -> PermissionPolicy {
@@ -5193,7 +5326,10 @@ impl SubagentToolExecutor {
impl ToolExecutor for SubagentToolExecutor {
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
if !self.allowed_tools.contains(tool_name) {
if !self
.allowed_tools
.contains(&canonical_allowed_tool_name(tool_name))
{
return Err(ToolError::new(format!(
"tool `{tool_name}` is not enabled for this sub-agent"
)));
@@ -5208,7 +5344,10 @@ impl ToolExecutor for SubagentToolExecutor {
fn tool_specs_for_allowed_tools(allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolSpec> {
mvp_tool_specs()
.into_iter()
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
.filter(|spec| {
allowed_tools
.is_none_or(|allowed| allowed.contains(&canonical_allowed_tool_name(spec.name)))
})
.collect()
}
@@ -6779,6 +6918,87 @@ mod tests {
assert!(names.contains(&"WorkerSendPrompt"));
}
#[test]
fn git_show_schema_exposes_format_enum() {
let spec = mvp_tool_specs()
.into_iter()
.find(|spec| spec.name == "GitShow")
.expect("GitShow spec");
assert_eq!(
spec.input_schema["properties"]["format"]["enum"],
json!(["patch", "stat", "metadata"])
);
}
#[test]
fn git_show_supports_patch_stat_metadata_and_rejects_metadata_path() {
let _guard = env_guard();
let root = temp_path("git-show-format");
init_git_repo(&root);
commit_file(&root, "README.md", "initial\nupdated\n", "update readme");
let previous = std::env::current_dir().expect("cwd");
std::env::set_current_dir(&root).expect("set cwd");
let patch = execute_tool("GitShow", &json!({"commit": "HEAD", "format": "patch"}))
.expect("patch git show");
let patch: serde_json::Value = serde_json::from_str(&patch).expect("patch json");
assert!(patch["output"]
.as_str()
.expect("patch output")
.contains("diff --git"));
let stat = execute_tool("GitShow", &json!({"commit": "HEAD", "format": "stat"}))
.expect("stat git show");
let stat: serde_json::Value = serde_json::from_str(&stat).expect("stat json");
assert!(stat["output"]
.as_str()
.expect("stat output")
.contains("README.md"));
let legacy_stat = execute_tool("GitShow", &json!({"commit": "HEAD", "stat": true}))
.expect("legacy stat git show");
let legacy_stat: serde_json::Value =
serde_json::from_str(&legacy_stat).expect("legacy stat json");
assert!(legacy_stat["output"]
.as_str()
.expect("legacy stat output")
.contains("README.md"));
let metadata = execute_tool("GitShow", &json!({"commit": "HEAD", "format": "metadata"}))
.expect("metadata git show");
let metadata: serde_json::Value = serde_json::from_str(&metadata).expect("metadata json");
let metadata_output = metadata["output"].as_str().expect("metadata output");
assert!(metadata_output.contains("commit "));
assert!(metadata_output.contains("update readme"));
assert!(!metadata_output.contains("diff --git"));
let file_patch = execute_tool(
"GitShow",
&json!({"commit": "HEAD", "path": "README.md", "format": "patch"}),
)
.expect("file patch git show");
let file_patch: serde_json::Value =
serde_json::from_str(&file_patch).expect("file patch json");
assert_eq!(
file_patch["output"].as_str().expect("file patch output"),
"initial\nupdated"
);
let metadata_path = execute_tool(
"GitShow",
&json!({"commit": "HEAD", "path": "README.md", "format": "metadata"}),
)
.expect_err("metadata with path should be rejected");
assert!(metadata_path.contains("cannot be combined with path"));
let invalid = execute_tool("GitShow", &json!({"commit": "HEAD", "format": "bogus"}))
.expect_err("invalid format should be rejected");
assert!(invalid.contains("unknown GitShow format"));
std::env::set_current_dir(&previous).expect("restore cwd");
let _ = fs::remove_dir_all(root);
}
#[test]
fn rejects_unknown_tool_names() {
let error = execute_tool("nope", &json!({})).expect_err("tool should be rejected");
@@ -7477,6 +7697,29 @@ mod tests {
}
}
#[test]
fn allowed_tools_normalize_to_canonical_snake_case_and_aliases_432() {
let registry = GlobalToolRegistry::builtin();
let allowed = registry
.normalize_allowed_tools(&["Read,WebFetch,MCP".to_string()])
.expect("aliases and legacy names should normalize")
.expect("allow-list should be populated");
assert!(allowed.contains("read_file"));
assert!(allowed.contains("web_fetch"));
assert!(allowed.contains("mcp"));
assert!(!allowed.contains("Read"));
assert!(!allowed.contains("WebFetch"));
let canonical = registry.canonical_allowed_tool_names();
assert!(canonical.contains(&"web_fetch".to_string()));
assert!(canonical.contains(&"todo_write".to_string()));
assert!(!canonical.contains(&"WebFetch".to_string()));
assert_eq!(
registry.allowed_tool_aliases().get("WebFetch"),
Some(&"web_fetch".to_string())
);
}
#[test]
fn runtime_tools_extend_registry_definitions_permissions_and_search() {
let registry = GlobalToolRegistry::builtin()
@@ -8458,7 +8701,7 @@ mod tests {
.expect("spawn job should be captured");
assert_eq!(captured_job.prompt, "Check tests and outstanding work.");
assert!(captured_job.allowed_tools.contains("read_file"));
assert!(!captured_job.allowed_tools.contains("Agent"));
assert!(!captured_job.allowed_tools.contains("agent"));
let normalized = execute_tool(
"Agent",
@@ -9058,7 +9301,7 @@ mod tests {
let general = allowed_tools_for_subagent("general-purpose");
assert!(general.contains("bash"));
assert!(general.contains("write_file"));
assert!(!general.contains("Agent"));
assert!(!general.contains("agent"));
let explore = allowed_tools_for_subagent("Explore");
assert!(explore.contains("read_file"));
@@ -9066,13 +9309,13 @@ mod tests {
assert!(!explore.contains("bash"));
let plan = allowed_tools_for_subagent("Plan");
assert!(plan.contains("TodoWrite"));
assert!(plan.contains("StructuredOutput"));
assert!(!plan.contains("Agent"));
assert!(plan.contains("todo_write"));
assert!(plan.contains("structured_output"));
assert!(!plan.contains("agent"));
let verification = allowed_tools_for_subagent("Verification");
assert!(verification.contains("bash"));
assert!(verification.contains("PowerShell"));
assert!(verification.contains("power_shell"));
assert!(!verification.contains("write_file"));
}
@@ -10156,6 +10399,26 @@ printf 'pwsh:%s' "$1"
);
}
#[test]
fn given_workspace_write_enforcer_when_web_tools_then_denied() {
let registry = workspace_write_registry();
for (tool, input) in [
(
"WebFetch",
json!({"url":"https://example.com", "prompt":"summarize"}),
),
("WebSearch", json!({"query":"rust language"})),
] {
let err = registry
.execute(tool, &input)
.expect_err("network tools should require explicit full access");
assert!(
err.contains("requires 'danger-full-access'"),
"{tool} should require elevated mode: {err}"
);
}
}
#[test]
fn given_workspace_write_enforcer_when_bash_uses_shell_expansion_then_denied() {
let registry = workspace_write_registry();