mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-08 16:28:25 +02:00
Compare commits
21 Commits
eaa2e320d9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d229a9b022 | |||
| 05f0201ec7 | |||
| 36f6afcafd | |||
| 2e52ea7a67 | |||
| eb21179dde | |||
| 9b3548ca43 | |||
| 01f4dd48a3 | |||
| 222faabd7f | |||
| 7503c1c031 | |||
| 6001156a6c | |||
| a1da1ca8e6 | |||
| 27acfe1014 | |||
| c1646613d1 | |||
| ae2f203eb5 | |||
| 0755ddff3c | |||
| db9ff49256 | |||
| 3acb677d70 | |||
| 43eac8fbec | |||
| c8505092f8 | |||
| d58197cca4 | |||
| 3845040b9d |
@@ -1,5 +1,66 @@
|
||||
# Claw Code
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/code-yeongyu/lazycodex">
|
||||
<img src="https://img.shields.io/badge/LazyCodex-codex%20for%20no--brainers-111111?style=for-the-badge&logo=github&logoColor=white" alt="LazyCodex banner" />
|
||||
</a>
|
||||
<a href="https://github.com/Yeachan-Heo/gajae-code">
|
||||
<img src="https://img.shields.io/badge/Gajae--Code-red--claw%20agent%20harness-B22222?style=for-the-badge&logo=github&logoColor=white" alt="Gajae-Code banner" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/code-yeongyu/lazycodex">
|
||||
<img src="https://opengraph.githubassets.com/lazycodex-card/code-yeongyu/lazycodex" alt="LazyCodex GitHub card" width="280" />
|
||||
</a>
|
||||
<a href="https://github.com/Yeachan-Heo/gajae-code">
|
||||
<img src="https://opengraph.githubassets.com/gajae-code-card/Yeachan-Heo/gajae-code" alt="Gajae-Code GitHub card" width="280" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h3 align="center">start with the real crab-powered harnesses</h3>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/code-yeongyu/lazycodex"><b>github.com/code-yeongyu/lazycodex</b></a>
|
||||
<br/>
|
||||
<a href="https://github.com/Yeachan-Heo/gajae-code"><b>github.com/Yeachan-Heo/gajae-code</b></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/code-yeongyu/lazycodex">
|
||||
<img src="https://img.shields.io/badge/Open-LazyCodex-111111?style=flat-square&logo=github&logoColor=white" alt="Open LazyCodex on GitHub" />
|
||||
</a>
|
||||
<a href="https://github.com/Yeachan-Heo/gajae-code">
|
||||
<img src="https://img.shields.io/badge/Open-Gajae--Code-B22222?style=flat-square&logo=github&logoColor=white" alt="Open Gajae-Code on GitHub" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/GtjhvgjnV">
|
||||
<img src="https://img.shields.io/badge/Discord-join%20the%20harness%20lab-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join the harness lab on Discord" />
|
||||
</a>
|
||||
<a href="https://discord.gg/4Rt79F7dF">
|
||||
<img src="https://img.shields.io/badge/Discord-join%20the%20crab%20tank-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join the crab tank on Discord" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Join the Discords:
|
||||
<a href="https://discord.gg/GtjhvgjnV"><b>ultraworkers discord</b></a>
|
||||
·
|
||||
<a href="https://discord.gg/4Rt79F7dF"><b>gajae-code discord</b></a>
|
||||
</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Claw Code is not the serious production project here.**
|
||||
> This repository is closer to a museum exhibit than a product pitch, a crustacean-run artifact kept alive by clawed gajaes, swept and labeled by agents, and automatically maintained according to the harnesses above.
|
||||
>
|
||||
> As already described in the project philosophy, this is not meant to be hand-operated like a normal product repo. It is an **agent-managed exhibit**: the harnesses plan, execute, verify, label, and preserve the artifact while the crabs keep the tank running.
|
||||
>
|
||||
> If you want to actually run work, start with **[LazyCodex](https://github.com/code-yeongyu/lazycodex)** or **[Gajae-Code](https://github.com/Yeachan-Heo/gajae-code)**. If you want to inspect the strange little fossil of the Claw Code moment, continue below.
|
||||
>
|
||||
> For the longer public explanation behind this philosophy, see [here](https://x.com/realsigridjin/status/2039472968624185713).
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/ultraworkers/claw-code">ultraworkers/claw-code</a>
|
||||
·
|
||||
@@ -73,6 +134,9 @@ export ANTHROPIC_API_KEY="sk-ant-..."
|
||||
|
||||
# 4. Run a prompt
|
||||
./target/debug/claw prompt "say hello"
|
||||
|
||||
# 5. Start an interactive session
|
||||
./target/debug/claw
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
|
||||
@@ -40,6 +40,11 @@ Or provide an OAuth bearer token directly:
|
||||
export ANTHROPIC_AUTH_TOKEN="anthropic-oauth-or-proxy-bearer-token"
|
||||
```
|
||||
|
||||
For local OpenAI-compatible servers such as Ollama, including Qwen reasoning
|
||||
models, see [`../docs/local-openai-compatible-providers.md`](../docs/local-openai-compatible-providers.md).
|
||||
Use the exact model tag exposed by the server, for example `qwen3:latest`, and
|
||||
prefer `OLLAMA_HOST` for Ollama-specific local routing.
|
||||
|
||||
## Mock parity harness
|
||||
|
||||
The workspace now includes a deterministic Anthropic-compatible mock service and a clean-environment CLI harness for end-to-end parity checks.
|
||||
|
||||
@@ -296,6 +296,15 @@ pub fn metadata_for_model(model: &str) -> Option<ProviderMetadata> {
|
||||
None
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn strip_provider_prefix(canonical_model: &str) -> String {
|
||||
if let Some(pos) = canonical_model.find('/') {
|
||||
canonical_model[pos + 1..].to_string()
|
||||
} else {
|
||||
canonical_model.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn provider_diagnostics_for_model(model: &str) -> ProviderDiagnostics {
|
||||
let resolved_model = resolve_model_alias(model);
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::types::{
|
||||
ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
|
||||
};
|
||||
|
||||
use super::{preflight_message_request, Provider, ProviderFuture};
|
||||
use super::{preflight_message_request, resolve_model_alias, Provider, ProviderFuture};
|
||||
|
||||
pub const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1";
|
||||
pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
|
||||
@@ -215,18 +215,19 @@ impl OpenAiCompatClient {
|
||||
&self,
|
||||
request: &MessageRequest,
|
||||
) -> Result<MessageResponse, ApiError> {
|
||||
let request = MessageRequest {
|
||||
let original_model = request.model.clone();
|
||||
let canonical = resolve_model_alias(&request.model);
|
||||
|
||||
let mut request = MessageRequest {
|
||||
stream: false,
|
||||
..request.clone()
|
||||
};
|
||||
request.model = canonical;
|
||||
|
||||
preflight_message_request(&request)?;
|
||||
let response = self.send_with_retry(&request).await?;
|
||||
let request_id = request_id_from_headers(response.headers());
|
||||
let body = response.text().await.map_err(ApiError::from)?;
|
||||
// Some backends return {"error":{"message":"...","type":"...","code":...}}
|
||||
// instead of a valid completion object. Check for this before attempting
|
||||
// full deserialization so the user sees the actual error, not a cryptic
|
||||
// "missing field 'id'" parse failure.
|
||||
if let Ok(raw) = serde_json::from_str::<serde_json::Value>(&body) {
|
||||
if let Some(err_obj) = raw.get("error") {
|
||||
let msg = err_obj
|
||||
@@ -258,12 +259,13 @@ impl OpenAiCompatClient {
|
||||
}
|
||||
}
|
||||
let payload = serde_json::from_str::<ChatCompletionResponse>(&body).map_err(|error| {
|
||||
ApiError::json_deserialize(self.config.provider_name, &request.model, &body, error)
|
||||
ApiError::json_deserialize(self.config.provider_name, &original_model, &body, error)
|
||||
})?;
|
||||
let mut normalized = normalize_response(&request.model, payload)?;
|
||||
if normalized.request_id.is_none() {
|
||||
normalized.request_id = request_id;
|
||||
}
|
||||
normalized.model = original_model;
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
@@ -271,17 +273,25 @@ impl OpenAiCompatClient {
|
||||
&self,
|
||||
request: &MessageRequest,
|
||||
) -> Result<MessageStream, ApiError> {
|
||||
preflight_message_request(request)?;
|
||||
let response = self
|
||||
.send_with_retry(&request.clone().with_streaming())
|
||||
.await?;
|
||||
let original_model = request.model.clone();
|
||||
let canonical = resolve_model_alias(&request.model);
|
||||
|
||||
let mut streaming_request = request.clone().with_streaming();
|
||||
streaming_request.model = canonical;
|
||||
|
||||
preflight_message_request(&streaming_request)?;
|
||||
let response = self.send_with_retry(&streaming_request).await?;
|
||||
|
||||
Ok(MessageStream {
|
||||
request_id: request_id_from_headers(response.headers()),
|
||||
response,
|
||||
parser: OpenAiSseParser::with_context(self.config.provider_name, request.model.clone()),
|
||||
parser: OpenAiSseParser::with_context(
|
||||
self.config.provider_name,
|
||||
original_model.clone(),
|
||||
),
|
||||
pending: VecDeque::new(),
|
||||
done: false,
|
||||
state: StreamState::new(request.model.clone()),
|
||||
state: StreamState::new(original_model),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -562,6 +572,7 @@ impl StreamState {
|
||||
.delta
|
||||
.reasoning_content
|
||||
.filter(|value| !value.is_empty())
|
||||
.or(choice.delta.reasoning.filter(|value| !value.is_empty()))
|
||||
.or(choice
|
||||
.delta
|
||||
.thinking
|
||||
@@ -817,6 +828,8 @@ struct ChatMessage {
|
||||
#[serde(default)]
|
||||
reasoning_content: Option<String>,
|
||||
#[serde(default)]
|
||||
reasoning: Option<String>,
|
||||
#[serde(default)]
|
||||
tool_calls: Vec<ResponseToolCall>,
|
||||
}
|
||||
|
||||
@@ -891,6 +904,8 @@ struct ChunkDelta {
|
||||
#[serde(default)]
|
||||
reasoning_content: Option<String>,
|
||||
#[serde(default)]
|
||||
reasoning: Option<String>,
|
||||
#[serde(default)]
|
||||
thinking: Option<ThinkingDelta>,
|
||||
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
|
||||
tool_calls: Vec<DeltaToolCall>,
|
||||
@@ -1500,6 +1515,7 @@ fn normalize_response(
|
||||
.message
|
||||
.reasoning_content
|
||||
.filter(|value| !value.is_empty())
|
||||
.or(choice.message.reasoning.filter(|value| !value.is_empty()))
|
||||
{
|
||||
content.push(OutputContentBlock::Thinking {
|
||||
thinking,
|
||||
@@ -1982,6 +1998,7 @@ mod tests {
|
||||
role: "assistant".to_string(),
|
||||
content: Some("final answer".to_string()),
|
||||
reasoning_content: Some("hidden thought".to_string()),
|
||||
reasoning: None,
|
||||
tool_calls: Vec::new(),
|
||||
},
|
||||
finish_reason: Some("stop".to_string()),
|
||||
@@ -2019,6 +2036,7 @@ mod tests {
|
||||
delta: super::ChunkDelta {
|
||||
content: None,
|
||||
reasoning_content: Some("think".to_string()),
|
||||
reasoning: None,
|
||||
thinking: None,
|
||||
tool_calls: Vec::new(),
|
||||
},
|
||||
@@ -2036,6 +2054,7 @@ mod tests {
|
||||
delta: super::ChunkDelta {
|
||||
content: Some(" answer".to_string()),
|
||||
reasoning_content: None,
|
||||
reasoning: None,
|
||||
thinking: None,
|
||||
tool_calls: Vec::new(),
|
||||
},
|
||||
|
||||
@@ -166,6 +166,55 @@ async fn send_message_preserves_deepseek_reasoning_content_before_text() {
|
||||
assert_eq!(body["thinking"], json!({"type": "enabled"}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_message_preserves_ollama_reasoning_before_text() {
|
||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
let body = concat!(
|
||||
"{",
|
||||
"\"id\":\"chatcmpl_ollama_reasoning\",",
|
||||
"\"model\":\"qwen3:latest\",",
|
||||
"\"choices\":[{",
|
||||
"\"message\":{\"role\":\"assistant\",\"reasoning\":\"Think locally\",\"content\":\"Answer locally\",\"tool_calls\":[]},",
|
||||
"\"finish_reason\":\"stop\"",
|
||||
"}],",
|
||||
"\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":5}",
|
||||
"}"
|
||||
);
|
||||
let server = spawn_server(
|
||||
state.clone(),
|
||||
vec![http_response("200 OK", "application/json", body)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let client = OpenAiCompatClient::new("ollama-test-key", OpenAiCompatConfig::openai())
|
||||
.with_base_url(server.base_url());
|
||||
let response = client
|
||||
.send_message(&MessageRequest {
|
||||
model: "openai/qwen3:latest".to_string(),
|
||||
..sample_request(false)
|
||||
})
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(
|
||||
response.content,
|
||||
vec![
|
||||
OutputContentBlock::Thinking {
|
||||
thinking: "Think locally".to_string(),
|
||||
signature: None,
|
||||
},
|
||||
OutputContentBlock::Text {
|
||||
text: "Answer locally".to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
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["model"], json!("qwen3:latest"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_openai_gateway_strips_routing_prefix_and_preserves_extra_body_params() {
|
||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
@@ -389,6 +438,83 @@ async fn stream_message_normalizes_text_and_multiple_tool_calls() {
|
||||
assert!(request.body.contains("\"stream\":true"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_message_preserves_ollama_reasoning_before_text() {
|
||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
let sse = concat!(
|
||||
"data: {\"id\":\"chatcmpl_stream_ollama_reasoning\",\"model\":\"qwen3:latest\",\"choices\":[{\"delta\":{\"reasoning\":\"Think\"}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_stream_ollama_reasoning\",\"choices\":[{\"delta\":{\"content\":\" answer\"},\"finish_reason\":\"stop\"}]}\n\n",
|
||||
"data: [DONE]\n\n"
|
||||
);
|
||||
let server = spawn_server(
|
||||
state.clone(),
|
||||
vec![http_response_with_headers(
|
||||
"200 OK",
|
||||
"text/event-stream",
|
||||
sse,
|
||||
&[("x-request-id", "req_ollama_reasoning_stream")],
|
||||
)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let client = OpenAiCompatClient::new("ollama-test-key", OpenAiCompatConfig::openai())
|
||||
.with_base_url(server.base_url());
|
||||
let mut stream = client
|
||||
.stream_message(&MessageRequest {
|
||||
model: "openai/qwen3:latest".to_string(),
|
||||
..sample_request(false)
|
||||
})
|
||||
.await
|
||||
.expect("stream should start");
|
||||
|
||||
assert_eq!(stream.request_id(), Some("req_ollama_reasoning_stream"));
|
||||
|
||||
let mut events = Vec::new();
|
||||
while let Some(event) = stream.next_event().await.expect("event should parse") {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
assert!(matches!(events[0], StreamEvent::MessageStart(_)));
|
||||
assert!(matches!(
|
||||
events[1],
|
||||
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
|
||||
index: 0,
|
||||
content_block: OutputContentBlock::Thinking { .. },
|
||||
})
|
||||
));
|
||||
assert!(matches!(
|
||||
events[2],
|
||||
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
|
||||
index: 0,
|
||||
delta: ContentBlockDelta::ThinkingDelta { .. },
|
||||
})
|
||||
));
|
||||
assert!(matches!(
|
||||
events[3],
|
||||
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 0 })
|
||||
));
|
||||
assert!(matches!(
|
||||
events[4],
|
||||
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
|
||||
index: 1,
|
||||
content_block: OutputContentBlock::Text { .. },
|
||||
})
|
||||
));
|
||||
assert!(matches!(
|
||||
events[5],
|
||||
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
|
||||
index: 1,
|
||||
delta: ContentBlockDelta::TextDelta { .. },
|
||||
})
|
||||
));
|
||||
|
||||
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!("qwen3:latest"));
|
||||
assert_eq!(body["stream"], json!(true));
|
||||
}
|
||||
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
#[tokio::test]
|
||||
async fn stream_message_retries_retryable_sse_handshake_failures() {
|
||||
@@ -548,12 +674,13 @@ async fn openai_compatible_client_honors_http_proxy_for_requests() {
|
||||
.with_base_url("http://origin.invalid/v1");
|
||||
let response = client
|
||||
.send_message(&MessageRequest {
|
||||
model: "gpt-4o".to_string(),
|
||||
model: "openai/gpt-4.1-mini".to_string(),
|
||||
..sample_request(false)
|
||||
})
|
||||
.await
|
||||
.expect("proxy should return the OpenAI-compatible response");
|
||||
|
||||
assert_eq!(response.model, "openai/gpt-4.1-mini");
|
||||
assert_eq!(response.total_tokens(), 7);
|
||||
let captured = state.lock().await;
|
||||
let request = captured.first().expect("proxy should capture request");
|
||||
@@ -562,6 +689,8 @@ async fn openai_compatible_client_honors_http_proxy_for_requests() {
|
||||
request.headers.get("authorization").map(String::as_str),
|
||||
Some("Bearer openai-test-key")
|
||||
);
|
||||
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
|
||||
assert_eq!(body["model"], json!("openai/gpt-4.1-mini"));
|
||||
}
|
||||
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
|
||||
@@ -720,6 +720,13 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
||||
argument_hint: None,
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "setup",
|
||||
aliases: &[],
|
||||
summary: "Run the interactive provider setup wizard",
|
||||
argument_hint: None,
|
||||
resume_supported: false,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "notifications",
|
||||
aliases: &[],
|
||||
@@ -1102,6 +1109,7 @@ pub enum SlashCommand {
|
||||
args: Option<String>,
|
||||
},
|
||||
Doctor,
|
||||
Setup,
|
||||
Login,
|
||||
Logout,
|
||||
Vim,
|
||||
@@ -1223,6 +1231,7 @@ impl SlashCommand {
|
||||
Self::Compact { .. } => "/compact",
|
||||
Self::Cost => "/cost",
|
||||
Self::Doctor => "/doctor",
|
||||
Self::Setup => "/setup",
|
||||
Self::Config { .. } => "/config",
|
||||
Self::Memory { .. } => "/memory",
|
||||
Self::History { .. } => "/history",
|
||||
@@ -1392,6 +1401,10 @@ pub fn validate_slash_command_input(
|
||||
validate_no_args(command, &args)?;
|
||||
SlashCommand::Doctor
|
||||
}
|
||||
"setup" => {
|
||||
validate_no_args(command, &args)?;
|
||||
SlashCommand::Setup
|
||||
}
|
||||
"login" | "logout" => {
|
||||
return Err(command_error(
|
||||
"This auth flow was removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead.",
|
||||
@@ -1914,7 +1927,7 @@ fn slash_command_category(name: &str) -> &'static str {
|
||||
| "stickers" | "language" | "profile" | "max-tokens" | "temperature" | "system-prompt"
|
||||
| "api-key" | "terminal-setup" | "notifications" | "telemetry" | "providers" | "env"
|
||||
| "project" | "reasoning" | "budget" | "rate-limit" | "workspace" | "reset" | "ide"
|
||||
| "desktop" | "upgrade" => "Config",
|
||||
| "desktop" | "upgrade" | "setup" => "Config",
|
||||
"debug-tool-call" | "doctor" | "sandbox" | "diagnostics" | "tool-details" | "changelog"
|
||||
| "metrics" => "Debug",
|
||||
_ => "Tools",
|
||||
@@ -5381,6 +5394,7 @@ pub fn handle_slash_command(
|
||||
| SlashCommand::AddDir { .. }
|
||||
| SlashCommand::History { .. }
|
||||
| SlashCommand::Team { .. }
|
||||
| SlashCommand::Setup
|
||||
| SlashCommand::Unknown(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -5997,7 +6011,8 @@ mod tests {
|
||||
assert!(help.contains("aliases: /skill"));
|
||||
assert!(!help.contains("/login"));
|
||||
assert!(!help.contains("/logout"));
|
||||
assert_eq!(slash_command_specs().len(), 139);
|
||||
assert!(help.contains("/setup"));
|
||||
assert_eq!(slash_command_specs().len(), 140);
|
||||
assert!(resume_supported_slash_commands().len() >= 39);
|
||||
}
|
||||
|
||||
|
||||
@@ -162,6 +162,7 @@ pub struct RuntimeFeatureConfig {
|
||||
trusted_roots: Vec<String>,
|
||||
api_timeout: ApiTimeoutConfig,
|
||||
rules_import: RulesImportConfig,
|
||||
provider: RuntimeProviderConfig,
|
||||
}
|
||||
|
||||
/// Controls which external AI coding framework rules are imported into the system prompt.
|
||||
@@ -189,6 +190,41 @@ impl RulesImportConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stored provider configuration from the setup wizard.
|
||||
///
|
||||
/// Represents the `provider` section in `~/.claw/settings.json`, used as a
|
||||
/// fallback when environment variables are absent (3-tier resolution:
|
||||
/// env var > .env file > stored config).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct RuntimeProviderConfig {
|
||||
kind: Option<String>,
|
||||
api_key: Option<String>,
|
||||
base_url: Option<String>,
|
||||
model: Option<String>,
|
||||
}
|
||||
|
||||
impl RuntimeProviderConfig {
|
||||
#[must_use]
|
||||
pub fn kind(&self) -> Option<&str> {
|
||||
self.kind.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn api_key(&self) -> Option<&str> {
|
||||
self.api_key.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn base_url(&self) -> Option<&str> {
|
||||
self.base_url.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn model(&self) -> Option<&str> {
|
||||
self.model.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Ordered chain of fallback model identifiers used when the primary
|
||||
/// provider returns a retryable failure (429/500/503/etc.). The chain is
|
||||
/// strict: each entry is tried in order until one succeeds.
|
||||
@@ -764,6 +800,7 @@ fn build_runtime_config(
|
||||
trusted_roots: parse_optional_trusted_roots(&merged_value)?,
|
||||
api_timeout: parse_optional_api_timeout_config(&merged_value)?,
|
||||
rules_import: parse_optional_rules_import(&merged_value)?,
|
||||
provider: parse_optional_provider_config(&merged_value)?,
|
||||
};
|
||||
|
||||
Ok(RuntimeConfig {
|
||||
@@ -878,6 +915,11 @@ impl RuntimeConfig {
|
||||
&self.feature_config.rules_import
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn provider(&self) -> &RuntimeProviderConfig {
|
||||
&self.feature_config.provider
|
||||
}
|
||||
|
||||
/// Merge config-level default trusted roots with per-call roots.
|
||||
///
|
||||
/// Config roots are defaults and are kept first; per-call roots extend the
|
||||
@@ -891,6 +933,13 @@ impl RuntimeConfig {
|
||||
}
|
||||
|
||||
impl RuntimeFeatureConfig {
|
||||
/// Parsed provider configuration (kind, apiKey, baseUrl, model) from
|
||||
/// merged settings.
|
||||
#[must_use]
|
||||
pub fn provider(&self) -> &RuntimeProviderConfig {
|
||||
&self.provider
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_hooks(mut self, hooks: RuntimeHookConfig) -> Self {
|
||||
self.hooks = hooks;
|
||||
@@ -2104,6 +2153,25 @@ fn parse_optional_rules_import(root: &JsonValue) -> Result<RulesImportConfig, Co
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_optional_provider_config(root: &JsonValue) -> Result<RuntimeProviderConfig, ConfigError> {
|
||||
let Some(provider_value) = root.as_object().and_then(|object| object.get("provider")) else {
|
||||
return Ok(RuntimeProviderConfig::default());
|
||||
};
|
||||
let Some(object) = provider_value.as_object() else {
|
||||
return Ok(RuntimeProviderConfig::default());
|
||||
};
|
||||
let kind = optional_string(object, "kind", "provider")?.map(str::to_string);
|
||||
let api_key = optional_string(object, "apiKey", "provider")?.map(str::to_string);
|
||||
let base_url = optional_string(object, "baseUrl", "provider")?.map(str::to_string);
|
||||
let model = optional_string(object, "model", "provider")?.map(str::to_string);
|
||||
Ok(RuntimeProviderConfig {
|
||||
kind,
|
||||
api_key,
|
||||
base_url,
|
||||
model,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_filesystem_mode_label(value: &str) -> Result<FilesystemIsolationMode, ConfigError> {
|
||||
match value {
|
||||
"off" => Ok(FilesystemIsolationMode::Off),
|
||||
|
||||
@@ -216,6 +216,10 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
|
||||
name: "rulesImport",
|
||||
expected: FieldType::RulesImport,
|
||||
},
|
||||
FieldSpec {
|
||||
name: "subagentModel",
|
||||
expected: FieldType::String,
|
||||
},
|
||||
];
|
||||
|
||||
const HOOKS_FIELDS: &[FieldSpec] = &[
|
||||
@@ -421,8 +425,6 @@ 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 — preserve compatibility by surfacing it as a warning
|
||||
// instead of blocking otherwise valid config files.
|
||||
let suggestion = suggest_field(key, &known_names);
|
||||
result.warnings.push(ConfigDiagnostic {
|
||||
path: path_display.to_string(),
|
||||
@@ -436,56 +438,8 @@ fn validate_object_keys(
|
||||
result
|
||||
}
|
||||
|
||||
/// Emit deprecation warnings for bare string hook entries in the hooks object.
|
||||
/// Legacy `["command-string"]` arrays still load but suggest migration to the
|
||||
/// structured `{matcher, hooks:[{type, command}]}` form.
|
||||
fn validate_hook_entry_format(
|
||||
hooks: &BTreeMap<String, JsonValue>,
|
||||
source: &str,
|
||||
path_display: &str,
|
||||
) -> ValidationResult {
|
||||
let mut result = ValidationResult {
|
||||
errors: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
};
|
||||
for spec in HOOKS_FIELDS {
|
||||
let Some(value) = hooks.get(spec.name) else {
|
||||
continue;
|
||||
};
|
||||
let Some(array) = value.as_array() else {
|
||||
continue;
|
||||
};
|
||||
for item in array {
|
||||
if item.as_str().is_some() {
|
||||
result.warnings.push(ConfigDiagnostic {
|
||||
path: path_display.to_string(),
|
||||
field: format!("hooks.{}", spec.name),
|
||||
line: find_key_line(source, spec.name),
|
||||
kind: DiagnosticKind::Deprecated {
|
||||
replacement: "object-style hook entries with hooks:[{type:\"command\",command:\"...\"}]",
|
||||
},
|
||||
});
|
||||
// One deprecation warning per event is enough
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn suggest_field(input: &str, candidates: &[&str]) -> Option<String> {
|
||||
let input_lower = input.to_ascii_lowercase();
|
||||
// #461: prefix-aware matching — if input is a prefix of a candidate,
|
||||
// treat it as distance 0 (perfect prefix match) to avoid edit-distance
|
||||
// misranking (e.g., "mcp" → "env" instead of "mcpServers").
|
||||
let prefix_match = candidates
|
||||
.iter()
|
||||
.filter(|c| c.to_ascii_lowercase().starts_with(&input_lower))
|
||||
.min_by_key(|c| c.len())
|
||||
.map(|name| name.to_string());
|
||||
if prefix_match.is_some() {
|
||||
return prefix_match;
|
||||
}
|
||||
candidates
|
||||
.iter()
|
||||
.filter_map(|candidate| {
|
||||
@@ -555,7 +509,6 @@ pub fn validate_config_file(
|
||||
source,
|
||||
&path_display,
|
||||
));
|
||||
result.merge(validate_hook_entry_format(hooks, source, &path_display));
|
||||
}
|
||||
if let Some(permissions) = object.get("permissions").and_then(JsonValue::as_object) {
|
||||
result.merge(validate_object_keys(
|
||||
|
||||
@@ -65,6 +65,7 @@ pub use compact::{
|
||||
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
|
||||
};
|
||||
pub use config::{
|
||||
clear_user_provider_settings, default_config_home, save_user_provider_settings,
|
||||
suppress_config_warnings_for_json_mode, ApiTimeoutConfig, ConfigEntry, ConfigError,
|
||||
ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource,
|
||||
McpConfigCollection, McpInvalidServerConfig, McpManagedProxyServerConfig, McpOAuthConfig,
|
||||
@@ -72,7 +73,7 @@ pub use config::{
|
||||
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
|
||||
RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig,
|
||||
RuntimeInvalidHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig,
|
||||
ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
||||
RuntimeProviderConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
||||
};
|
||||
pub use config_validate::{
|
||||
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,
|
||||
|
||||
@@ -832,6 +832,28 @@ mod tests {
|
||||
|
||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
previous: Option<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn set(key: &'static str, value: &Path) -> Self {
|
||||
let previous = std::env::var_os(key);
|
||||
std::env::set_var(key, value);
|
||||
Self { key, previous }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
match &self.previous {
|
||||
Some(value) => std::env::set_var(self.key, value),
|
||||
None => std::env::remove_var(self.key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_dir() -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -1290,8 +1312,11 @@ mod tests {
|
||||
#[test]
|
||||
fn latest_session_returns_all_empty_error_when_sessions_exist_but_have_no_messages() {
|
||||
// given — create sessions with 0 messages (empty)
|
||||
let _env_guard = crate::test_env_lock();
|
||||
let base = temp_dir();
|
||||
fs::create_dir_all(&base).expect("base dir should exist");
|
||||
let isolated_config_home = base.join("config-home");
|
||||
let _claw_config_home = EnvVarGuard::set("CLAW_CONFIG_HOME", &isolated_config_home);
|
||||
let store = SessionStore::from_cwd(&base).expect("store should build");
|
||||
|
||||
let empty_handle = store.create_handle("empty-session");
|
||||
|
||||
@@ -1644,16 +1644,13 @@ mod tests {
|
||||
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let worktree = tmp.path().join("worktree");
|
||||
let git_dir = tmp.path().join("external-gitdir");
|
||||
fs::create_dir_all(&worktree).expect("worktree dir");
|
||||
fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
|
||||
fs::create_dir_all(git_dir.join("refs/heads")).expect("refs dir");
|
||||
fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("HEAD");
|
||||
fs::write(
|
||||
worktree.join(".git"),
|
||||
format!("gitdir: {}\n", git_dir.display()),
|
||||
)
|
||||
.expect(".git file");
|
||||
Command::new("git")
|
||||
.arg("init")
|
||||
.current_dir(&worktree)
|
||||
.output()
|
||||
.expect("git init should run");
|
||||
let git_dir = worktree.join(".git");
|
||||
|
||||
let original_permissions = fs::metadata(&git_dir)
|
||||
.expect("gitdir metadata")
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
mod init;
|
||||
mod input;
|
||||
mod render;
|
||||
mod setup_wizard;
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::env;
|
||||
@@ -1095,6 +1096,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
CliAction::SessionList { output_format } => run_session_list(output_format)?,
|
||||
CliAction::State { output_format } => run_worker_state(output_format)?,
|
||||
CliAction::Init { output_format } => run_init(output_format)?,
|
||||
CliAction::Setup { output_format: _ } => run_setup()?,
|
||||
// #146: dispatch pure-local introspection. Text mode uses existing
|
||||
// render_config_report/render_diff_report; JSON mode uses the
|
||||
// corresponding _json helpers already exposed for resume sessions.
|
||||
@@ -1238,6 +1240,9 @@ enum CliAction {
|
||||
Init {
|
||||
output_format: CliOutputFormat,
|
||||
},
|
||||
Setup {
|
||||
output_format: CliOutputFormat,
|
||||
},
|
||||
// #146: `claw config` and `claw diff` are pure-local read-only
|
||||
// introspection commands; wire them as standalone CLI subcommands.
|
||||
Config {
|
||||
@@ -1301,6 +1306,7 @@ enum LocalHelpTopic {
|
||||
Model,
|
||||
Settings,
|
||||
Diff,
|
||||
Setup,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -1765,6 +1771,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
"doctor" => Some(LocalHelpTopic::Doctor),
|
||||
"acp" => Some(LocalHelpTopic::Acp),
|
||||
"init" => Some(LocalHelpTopic::Init),
|
||||
"setup" => Some(LocalHelpTopic::Setup),
|
||||
"state" => Some(LocalHelpTopic::State),
|
||||
"resume" => Some(LocalHelpTopic::Resume),
|
||||
"session" => Some(LocalHelpTopic::Session),
|
||||
@@ -2144,6 +2151,15 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
}
|
||||
Ok(CliAction::Init { output_format })
|
||||
}
|
||||
"setup" => {
|
||||
if rest.len() > 1 {
|
||||
let extra = rest[1..].join(" ");
|
||||
return Err(format!(
|
||||
"unexpected extra arguments after `claw setup`: {extra}\nUsage: claw setup"
|
||||
));
|
||||
}
|
||||
Ok(CliAction::Setup { output_format })
|
||||
}
|
||||
"export" => parse_export_args(&rest[1..], output_format),
|
||||
"prompt" => {
|
||||
let mut read_stdin = false;
|
||||
@@ -2271,6 +2287,7 @@ fn parse_local_help_action(
|
||||
"doctor" => LocalHelpTopic::Doctor,
|
||||
"acp" => LocalHelpTopic::Acp,
|
||||
"init" => LocalHelpTopic::Init,
|
||||
"setup" => LocalHelpTopic::Setup,
|
||||
"state" => LocalHelpTopic::State,
|
||||
"export" => LocalHelpTopic::Export,
|
||||
"version" => LocalHelpTopic::Version,
|
||||
@@ -2316,7 +2333,7 @@ fn parse_single_word_command_alias(
|
||||
let verb = &rest[0];
|
||||
let is_diagnostic = matches!(
|
||||
verb.as_str(),
|
||||
"help" | "version" | "status" | "sandbox" | "doctor" | "state"
|
||||
"help" | "version" | "status" | "sandbox" | "doctor" | "setup" | "state"
|
||||
);
|
||||
|
||||
if is_diagnostic && rest.len() > 1 {
|
||||
@@ -2336,6 +2353,7 @@ fn parse_single_word_command_alias(
|
||||
"doctor" => Some(LocalHelpTopic::Doctor),
|
||||
"acp" => Some(LocalHelpTopic::Acp),
|
||||
"init" => Some(LocalHelpTopic::Init),
|
||||
"setup" => Some(LocalHelpTopic::Setup),
|
||||
"state" => Some(LocalHelpTopic::State),
|
||||
"export" => Some(LocalHelpTopic::Export),
|
||||
"version" => Some(LocalHelpTopic::Version),
|
||||
@@ -2390,6 +2408,7 @@ fn parse_single_word_command_alias(
|
||||
"doctor" => Some(LocalHelpTopic::Doctor),
|
||||
"acp" => Some(LocalHelpTopic::Acp),
|
||||
"init" => Some(LocalHelpTopic::Init),
|
||||
"setup" => Some(LocalHelpTopic::Setup),
|
||||
"state" => Some(LocalHelpTopic::State),
|
||||
"export" => Some(LocalHelpTopic::Export),
|
||||
"version" => Some(LocalHelpTopic::Version),
|
||||
@@ -2452,6 +2471,7 @@ fn parse_single_word_command_alias(
|
||||
.map(PermissionModeProvenance::from_flag)
|
||||
.unwrap_or_else(permission_mode_provenance_for_current_dir),
|
||||
})),
|
||||
"setup" => Some(Ok(CliAction::Setup { output_format })),
|
||||
"state" => Some(Ok(CliAction::State { output_format })),
|
||||
// #146: let `config` and `diff` fall through to parse_subcommand
|
||||
// where they are wired as pure-local introspection, instead of
|
||||
@@ -2744,6 +2764,7 @@ fn suggest_similar_subcommand(input: &str) -> Option<Vec<String>> {
|
||||
"status",
|
||||
"sandbox",
|
||||
"doctor",
|
||||
"setup",
|
||||
"state",
|
||||
"dump-manifests",
|
||||
"bootstrap-plan",
|
||||
@@ -2939,6 +2960,10 @@ fn validate_model_syntax(model: &str) -> Result<(), String> {
|
||||
err_msg.push_str("\nDid you mean `openai/");
|
||||
err_msg.push_str(trimmed);
|
||||
err_msg.push_str("`? (Requires OPENAI_API_KEY env var)");
|
||||
} else if trimmed.starts_with("qwen") && trimmed.contains(':') {
|
||||
err_msg.push_str("\nFor a local Ollama model, set `OPENAI_BASE_URL=http://127.0.0.1:11434/v1` before using tagged names like `");
|
||||
err_msg.push_str(trimmed);
|
||||
err_msg.push_str("`.");
|
||||
} else if trimmed.starts_with("qwen") {
|
||||
err_msg.push_str("\nDid you mean `qwen/");
|
||||
err_msg.push_str(trimmed);
|
||||
@@ -3725,6 +3750,11 @@ fn run_doctor(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the interactive setup wizard to configure provider, API key, and model.
|
||||
fn run_setup() -> Result<(), Box<dyn std::error::Error>> {
|
||||
setup_wizard::run_setup_wizard()
|
||||
}
|
||||
|
||||
/// Starts a minimal Model Context Protocol server that exposes claw's
|
||||
/// built-in tools over stdio.
|
||||
///
|
||||
@@ -6896,7 +6926,8 @@ fn run_resume_command(
|
||||
| SlashCommand::Tag { .. }
|
||||
| SlashCommand::OutputStyle { .. }
|
||||
| SlashCommand::AddDir { .. }
|
||||
| SlashCommand::Team { .. } => Err("unsupported resumed slash command".into()),
|
||||
| SlashCommand::Team { .. }
|
||||
| SlashCommand::Setup => Err("unsupported resumed slash command".into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8157,6 +8188,12 @@ impl LiveCli {
|
||||
);
|
||||
false
|
||||
}
|
||||
SlashCommand::Setup => {
|
||||
if let Err(e) = setup_wizard::run_setup_wizard() {
|
||||
eprintln!("Setup wizard failed: {e}");
|
||||
}
|
||||
false
|
||||
}
|
||||
SlashCommand::History { count } => {
|
||||
self.print_prompt_history(count.as_deref());
|
||||
false
|
||||
@@ -10226,6 +10263,13 @@ fn render_help_topic(topic: LocalHelpTopic) -> String {
|
||||
Formats text (default), json
|
||||
Related /diff · ROADMAP #148"
|
||||
.to_string(),
|
||||
LocalHelpTopic::Setup => "Setup
|
||||
Usage claw setup
|
||||
Aliases /setup (inside the REPL)
|
||||
Purpose run the interactive provider setup wizard to configure API key, model, and base URL
|
||||
Output writes provider settings to ~/.claw/settings.json (0600 permissions)
|
||||
Related /model · /config · claw doctor"
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10253,6 +10297,7 @@ fn local_help_topic_command(topic: LocalHelpTopic) -> &'static str {
|
||||
LocalHelpTopic::Model => "models",
|
||||
LocalHelpTopic::Settings => "settings",
|
||||
LocalHelpTopic::Diff => "diff",
|
||||
LocalHelpTopic::Setup => "setup",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13737,8 +13782,15 @@ fn push_output_block(
|
||||
};
|
||||
*pending_tool = Some((id, name, initial_input));
|
||||
}
|
||||
OutputContentBlock::Thinking { thinking, .. } => {
|
||||
OutputContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} => {
|
||||
render_thinking_block_summary(out, Some(thinking.chars().count()), false)?;
|
||||
events.push(AssistantEvent::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
});
|
||||
*block_has_thinking_summary = true;
|
||||
}
|
||||
OutputContentBlock::RedactedThinking { .. } => {
|
||||
@@ -19073,6 +19125,13 @@ UU conflicted.rs",
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AssistantEvent::Thinking {
|
||||
thinking,
|
||||
signature
|
||||
} if thinking == "step 1" && signature.as_deref() == Some("sig_123")
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AssistantEvent::TextDelta(text) if text == "Final answer"
|
||||
));
|
||||
let rendered = String::from_utf8(out).expect("utf8");
|
||||
@@ -19649,6 +19708,41 @@ mod dump_manifests_tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod alias_resolution_tests {
|
||||
fn ollama_env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
|
||||
LOCK.get_or_init(|| std::sync::Mutex::new(()))
|
||||
.lock()
|
||||
.expect("ollama env lock poisoned")
|
||||
}
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
previous: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn unset(key: &'static str) -> Self {
|
||||
let previous = std::env::var(key).ok();
|
||||
std::env::remove_var(key);
|
||||
Self { key, previous }
|
||||
}
|
||||
|
||||
fn set(key: &'static str, value: &str) -> Self {
|
||||
let previous = std::env::var(key).ok();
|
||||
std::env::set_var(key, value);
|
||||
Self { key, previous }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
match &self.previous {
|
||||
Some(value) => std::env::set_var(self.key, value),
|
||||
None => std::env::remove_var(self.key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use super::{resolve_model_alias_with_config, validate_model_syntax};
|
||||
|
||||
#[test]
|
||||
@@ -19670,6 +19764,8 @@ mod alias_resolution_tests {
|
||||
|
||||
#[test]
|
||||
fn test_alias_resolution_syntax_validation() {
|
||||
let _guard = ollama_env_lock();
|
||||
let _env = EnvVarGuard::unset("OLLAMA_HOST");
|
||||
// Resolved aliases should pass syntax validation
|
||||
let resolved = resolve_model_alias_with_config("opus");
|
||||
assert!(validate_model_syntax(&resolved).is_ok());
|
||||
@@ -19680,6 +19776,8 @@ mod alias_resolution_tests {
|
||||
|
||||
#[test]
|
||||
fn test_unknown_alias_fails_validation() {
|
||||
let _guard = ollama_env_lock();
|
||||
let _env = EnvVarGuard::unset("OLLAMA_HOST");
|
||||
// Unknown aliases resolve to themselves
|
||||
let resolved = resolve_model_alias_with_config("unknown-alias");
|
||||
assert_eq!(resolved, "unknown-alias");
|
||||
@@ -19690,6 +19788,28 @@ mod alias_resolution_tests {
|
||||
assert!(result.unwrap_err().contains("invalid model syntax"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qwen_invalid_model_hint_mentions_local_ollama_openai_base_url() {
|
||||
let _guard = ollama_env_lock();
|
||||
let _ollama_env = EnvVarGuard::unset("OLLAMA_HOST");
|
||||
let _openai_env = EnvVarGuard::unset("OPENAI_BASE_URL");
|
||||
let result = validate_model_syntax("qwen3:8b");
|
||||
|
||||
let error = result.expect_err("Ollama tag without local base URL should fail");
|
||||
assert!(
|
||||
error.contains("Ollama"),
|
||||
"Qwen Ollama tag error should mention Ollama: {error}"
|
||||
);
|
||||
assert!(
|
||||
error.contains("OPENAI_BASE_URL"),
|
||||
"Qwen Ollama tag error should mention OPENAI_BASE_URL: {error}"
|
||||
);
|
||||
assert!(
|
||||
error.contains("http://127.0.0.1:11434/v1"),
|
||||
"Qwen Ollama tag error should show local Ollama OpenAI URL: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_direct_provider_model_passes() {
|
||||
// Direct provider/model strings should remain unchanged and pass
|
||||
@@ -19699,14 +19819,13 @@ mod alias_resolution_tests {
|
||||
}
|
||||
#[test]
|
||||
fn test_ollama_host_bypasses_model_validation() {
|
||||
// Safety: test sets and clears env var within the test.
|
||||
std::env::set_var("OLLAMA_HOST", "http://127.0.0.1:11434");
|
||||
let _guard = ollama_env_lock();
|
||||
let _env = EnvVarGuard::set("OLLAMA_HOST", "http://127.0.0.1:11434");
|
||||
// Ollama model names with colons pass
|
||||
assert!(validate_model_syntax("qwen3:8b").is_ok());
|
||||
assert!(validate_model_syntax("gemma4:e2b").is_ok());
|
||||
assert!(validate_model_syntax("qwen3.6:27b-nvfp4").is_ok());
|
||||
// Empty model still rejected
|
||||
assert!(validate_model_syntax("").is_err());
|
||||
std::env::remove_var("OLLAMA_HOST");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@ const DEFAULT_BASE_URLS: &[(&str, &str)] = &[
|
||||
("anthropic", "https://api.anthropic.com"),
|
||||
("xai", "https://api.x.ai/v1"),
|
||||
("openai", "https://api.openai.com/v1"),
|
||||
("dashscope", "https://dashscope.aliyuncs.com/compatible-mode/v1"),
|
||||
(
|
||||
"dashscope",
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
),
|
||||
];
|
||||
|
||||
const API_KEY_ENV_VARS: &[(&str, &str)] = &[
|
||||
@@ -51,12 +54,7 @@ pub fn run_setup_wizard() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let model = prompt_model(&kind, ¤t)?;
|
||||
let fast_model = prompt_fast_model(¤t, model.as_deref())?;
|
||||
|
||||
save_user_provider_settings(
|
||||
&kind,
|
||||
&api_key,
|
||||
base_url.as_deref(),
|
||||
model.as_deref(),
|
||||
)?;
|
||||
save_user_provider_settings(&kind, &api_key, base_url.as_deref(), model.as_deref())?;
|
||||
|
||||
if let Some(fast) = &fast_model {
|
||||
save_settings_field("subagentModel", fast)?;
|
||||
@@ -64,7 +62,10 @@ pub fn run_setup_wizard() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
println!();
|
||||
println!(" \x1b[32mProvider saved to ~/.claw/settings.json\x1b[0m");
|
||||
println!(" Run \x1b[1m/model {}\x1b[0m or restart claw to activate.", model.as_deref().unwrap_or(&kind));
|
||||
println!(
|
||||
" Run \x1b[1m/model {}\x1b[0m or restart claw to activate.",
|
||||
model.as_deref().unwrap_or(&kind)
|
||||
);
|
||||
println!();
|
||||
|
||||
Ok(())
|
||||
@@ -82,7 +83,11 @@ fn prompt_provider(current: &RuntimeProviderConfig) -> Result<String, Box<dyn st
|
||||
let current_kind = current.kind().unwrap_or("anthropic");
|
||||
println!(" \x1b[1mProvider\x1b[0m");
|
||||
for (num, label, kind) in PROVIDERS {
|
||||
let marker = if *kind == current_kind { " (current)" } else { "" };
|
||||
let marker = if *kind == current_kind {
|
||||
" (current)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
println!(" [{num}] {label}{marker}");
|
||||
}
|
||||
let default = PROVIDERS
|
||||
@@ -129,9 +134,7 @@ fn prompt_api_key(
|
||||
};
|
||||
|
||||
// Check if env var is already set
|
||||
let env_set = std::env::var(env_var)
|
||||
.ok()
|
||||
.is_some_and(|v| !v.is_empty());
|
||||
let env_set = std::env::var(env_var).ok().is_some_and(|v| !v.is_empty());
|
||||
if env_set {
|
||||
println!(" {env_var} is set in environment (will take priority over stored key)");
|
||||
}
|
||||
@@ -144,7 +147,9 @@ fn prompt_api_key(
|
||||
};
|
||||
|
||||
if key.is_empty() && !env_set {
|
||||
eprintln!(" \x1b[33mWarning: no API key configured. Set {env_var} or re-run setup.\x1b[0m");
|
||||
eprintln!(
|
||||
" \x1b[33mWarning: no API key configured. Set {env_var} or re-run setup.\x1b[0m"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(key)
|
||||
@@ -174,9 +179,7 @@ fn prompt_base_url(
|
||||
"dashscope" => "DASHSCOPE_BASE_URL",
|
||||
_ => "BASE_URL",
|
||||
};
|
||||
let env_set = std::env::var(env_var)
|
||||
.ok()
|
||||
.is_some_and(|v| !v.is_empty());
|
||||
let env_set = std::env::var(env_var).ok().is_some_and(|v| !v.is_empty());
|
||||
if env_set {
|
||||
println!(" {env_var} is set in environment (will take priority over stored URL)");
|
||||
}
|
||||
@@ -203,7 +206,9 @@ fn prompt_model(
|
||||
.find(|(k, _)| *k == kind)
|
||||
.map_or(empty, |(_, models)| *models);
|
||||
|
||||
let current_model = current.model().unwrap_or(aliases.first().copied().unwrap_or(""));
|
||||
let current_model = current
|
||||
.model()
|
||||
.unwrap_or(aliases.first().copied().unwrap_or(""));
|
||||
|
||||
println!(" \x1b[1mModel\x1b[0m");
|
||||
if !aliases.is_empty() {
|
||||
@@ -235,12 +240,16 @@ fn prompt_fast_model(
|
||||
println!(" Press Enter to skip (agents will use your main model).");
|
||||
|
||||
let current_fast = load_current_settings_field("subagentModel");
|
||||
let default_hint = current_fast
|
||||
.as_deref()
|
||||
.or(main_model)
|
||||
.unwrap_or("");
|
||||
let default_hint = current_fast.as_deref().or(main_model).unwrap_or("");
|
||||
|
||||
let input = read_line(&format!(" Fast model [{}]: ", if default_hint.is_empty() { "same as main" } else { default_hint }))?;
|
||||
let input = read_line(&format!(
|
||||
" Fast model [{}]: ",
|
||||
if default_hint.is_empty() {
|
||||
"same as main"
|
||||
} else {
|
||||
default_hint
|
||||
}
|
||||
))?;
|
||||
if input.trim().is_empty() {
|
||||
Ok(current_fast)
|
||||
} else {
|
||||
@@ -269,7 +278,10 @@ fn save_settings_field(field: &str, value: &str) -> Result<(), Box<dyn std::erro
|
||||
};
|
||||
|
||||
if let Some(obj) = settings.as_object_mut() {
|
||||
obj.insert(field.to_string(), serde_json::Value::String(value.to_string()));
|
||||
obj.insert(
|
||||
field.to_string(),
|
||||
serde_json::Value::String(value.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(&settings_dir)?;
|
||||
|
||||
@@ -11,6 +11,7 @@ _GLOB_META = set('*?[')
|
||||
_WINDOWS_DRIVE_RE = re.compile(r'^[A-Za-z]:[\\/]')
|
||||
_WINDOWS_UNC_RE = re.compile(r'^(?:\\\\|//)[^\\/]+[\\/][^\\/]+')
|
||||
_ENV_ASSIGNMENT_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*=')
|
||||
_REDIRECTION_TARGET_RE = re.compile(r'^(?:\d*)?(?:<>|>>?|<)(.+)$|^&>>?(.+)$')
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -118,6 +119,7 @@ def extract_path_candidates(payload: str) -> tuple[str, ...]:
|
||||
for token in (*tokens, *raw_tokens):
|
||||
if not token or token.startswith('-') or _ENV_ASSIGNMENT_RE.match(token):
|
||||
continue
|
||||
token = _strip_redirection_operator(token)
|
||||
expanded = os.path.expandvars(os.path.expanduser(token))
|
||||
if _looks_like_path(token) or _looks_like_path(expanded):
|
||||
candidate = expanded if _looks_like_path(expanded) else token
|
||||
@@ -138,6 +140,13 @@ def _looks_like_path(token: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _strip_redirection_operator(token: str) -> str:
|
||||
match = _REDIRECTION_TARGET_RE.match(token)
|
||||
if match is None:
|
||||
return token
|
||||
return next(group for group in match.groups() if group is not None)
|
||||
|
||||
|
||||
def _is_windows_absolute(value: str) -> bool:
|
||||
return bool(_WINDOWS_DRIVE_RE.match(value) or _WINDOWS_UNC_RE.match(value))
|
||||
|
||||
|
||||
@@ -72,6 +72,28 @@ class WorkspacePathScopeTests(unittest.TestCase):
|
||||
self.assertFalse(decision.allowed)
|
||||
self.assertIn(str(outside.resolve()), decision.resolved or '')
|
||||
|
||||
def test_attached_shell_redirection_targets_are_validated(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
workspace = root / 'workspace'
|
||||
outside = root / 'outside'
|
||||
workspace.mkdir()
|
||||
outside.mkdir()
|
||||
(outside / 'secret.txt').write_text('secret')
|
||||
|
||||
self.assertEqual(
|
||||
('../outside/secret.txt', '../outside/error.log'),
|
||||
extract_path_candidates(
|
||||
'cat <../outside/secret.txt 2>../outside/error.log'
|
||||
),
|
||||
)
|
||||
decision = WorkspacePathScope.from_root(workspace).validate_payload(
|
||||
'cat <../outside/secret.txt 2>../outside/error.log'
|
||||
)
|
||||
|
||||
self.assertFalse(decision.allowed)
|
||||
self.assertIn(str(outside.resolve()), decision.resolved or '')
|
||||
|
||||
def test_explicit_worktree_roots_are_allowed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
|
||||
Reference in New Issue
Block a user