mirror of
https://github.com/instructkr/claw-code.git
synced 2026-07-08 10:49:15 +02:00
Compare commits
7 Commits
421ead7dba
...
be561bfdeb
| Author | SHA1 | Date | |
|---|---|---|---|
| be561bfdeb | |||
| c1883d0f66 | |||
| 1fc5a1c457 | |||
| 549ad7c3af | |||
| ecadc5554a | |||
| 8ff9c1b15a | |||
| 6bd464bbe7 |
@@ -103,7 +103,11 @@ impl ApiError {
|
||||
#[must_use]
|
||||
pub fn safe_failure_class(&self) -> &'static str {
|
||||
match self {
|
||||
Self::RetriesExhausted { .. } => "provider_retry_exhausted",
|
||||
Self::RetriesExhausted { .. } if self.is_context_window_failure() => "context_window",
|
||||
Self::RetriesExhausted { .. } if self.is_generic_fatal_wrapper() => {
|
||||
"provider_retry_exhausted"
|
||||
}
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.safe_failure_class(),
|
||||
Self::MissingCredentials { .. } | Self::ExpiredOAuthToken | Self::Auth(_) => {
|
||||
"provider_auth"
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use telemetry::{AnalyticsEvent, AnthropicRequestProfile, ClientIdentity, Session
|
||||
use crate::error::ApiError;
|
||||
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
|
||||
|
||||
use super::{preflight_message_request, Provider, ProviderFuture};
|
||||
use super::{model_token_limit, resolve_model_alias, Provider, ProviderFuture};
|
||||
use crate::sse::SseParser;
|
||||
use crate::types::{MessageDeltaEvent, MessageRequest, MessageResponse, StreamEvent, Usage};
|
||||
|
||||
@@ -294,7 +294,7 @@ impl AnthropicClient {
|
||||
}
|
||||
}
|
||||
|
||||
preflight_message_request(&request)?;
|
||||
self.preflight_message_request(&request).await?;
|
||||
|
||||
let response = self.send_with_retry(&request).await?;
|
||||
let request_id = request_id_from_headers(response.headers());
|
||||
@@ -339,7 +339,7 @@ impl AnthropicClient {
|
||||
&self,
|
||||
request: &MessageRequest,
|
||||
) -> Result<MessageStream, ApiError> {
|
||||
preflight_message_request(request)?;
|
||||
self.preflight_message_request(request).await?;
|
||||
let response = self
|
||||
.send_with_retry(&request.clone().with_streaming())
|
||||
.await?;
|
||||
@@ -466,18 +466,67 @@ impl AnthropicClient {
|
||||
request: &MessageRequest,
|
||||
) -> Result<reqwest::Response, ApiError> {
|
||||
let request_url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
|
||||
let request_body = self.request_profile.render_json_body(request)?;
|
||||
let request_builder = self.build_request(&request_url).json(&request_body);
|
||||
request_builder.send().await.map_err(ApiError::from)
|
||||
}
|
||||
|
||||
fn build_request(&self, request_url: &str) -> reqwest::RequestBuilder {
|
||||
let request_builder = self
|
||||
.http
|
||||
.post(&request_url)
|
||||
.post(request_url)
|
||||
.header("content-type", "application/json");
|
||||
let mut request_builder = self.auth.apply(request_builder);
|
||||
for (header_name, header_value) in self.request_profile.header_pairs() {
|
||||
request_builder = request_builder.header(header_name, header_value);
|
||||
}
|
||||
request_builder
|
||||
}
|
||||
|
||||
async fn preflight_message_request(&self, request: &MessageRequest) -> Result<(), ApiError> {
|
||||
let Some(limit) = model_token_limit(&request.model) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let counted_input_tokens = match self.count_tokens(request).await {
|
||||
Ok(count) => count,
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
let estimated_total_tokens = counted_input_tokens.saturating_add(request.max_tokens);
|
||||
if estimated_total_tokens > limit.context_window_tokens {
|
||||
return Err(ApiError::ContextWindowExceeded {
|
||||
model: resolve_model_alias(&request.model),
|
||||
estimated_input_tokens: counted_input_tokens,
|
||||
requested_output_tokens: request.max_tokens,
|
||||
estimated_total_tokens,
|
||||
context_window_tokens: limit.context_window_tokens,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn count_tokens(&self, request: &MessageRequest) -> Result<u32, ApiError> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CountTokensResponse {
|
||||
input_tokens: u32,
|
||||
}
|
||||
|
||||
let request_url = format!("{}/v1/messages/count_tokens", self.base_url.trim_end_matches('/'));
|
||||
let request_body = self.request_profile.render_json_body(request)?;
|
||||
request_builder = request_builder.json(&request_body);
|
||||
request_builder.send().await.map_err(ApiError::from)
|
||||
let response = self
|
||||
.build_request(&request_url)
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let parsed = expect_success(response)
|
||||
.await?
|
||||
.json::<CountTokensResponse>()
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
Ok(parsed.input_tokens)
|
||||
}
|
||||
|
||||
fn record_request_failure(&self, attempt: u32, error: &ApiError) {
|
||||
|
||||
@@ -2293,10 +2293,53 @@ pub fn classify_skills_slash_command(args: Option<&str>) -> SkillSlashDispatch {
|
||||
Some(args) if args == "install" || args.starts_with("install ") => {
|
||||
SkillSlashDispatch::Local
|
||||
}
|
||||
Some(args) => SkillSlashDispatch::Invoke(format!("${args}")),
|
||||
Some(args) => SkillSlashDispatch::Invoke(format!("${}", args.trim_start_matches('/'))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a skill invocation by validating the skill exists on disk before
|
||||
/// returning the dispatch. When the skill is not found, returns `Err` with a
|
||||
/// human-readable message that lists nearby skill names.
|
||||
pub fn resolve_skill_invocation(
|
||||
cwd: &Path,
|
||||
args: Option<&str>,
|
||||
) -> Result<SkillSlashDispatch, String> {
|
||||
let dispatch = classify_skills_slash_command(args);
|
||||
if let SkillSlashDispatch::Invoke(ref prompt) = dispatch {
|
||||
// Extract the skill name from the "$skill [args]" prompt.
|
||||
let skill_token = prompt
|
||||
.trim_start_matches('$')
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
if !skill_token.is_empty() {
|
||||
if let Err(error) = resolve_skill_path(cwd, skill_token) {
|
||||
let mut message =
|
||||
format!("Unknown skill: {skill_token} ({error})");
|
||||
let roots = discover_skill_roots(cwd);
|
||||
if let Ok(available) = load_skills_from_roots(&roots) {
|
||||
let names: Vec<String> = available
|
||||
.iter()
|
||||
.filter(|s| s.shadowed_by.is_none())
|
||||
.map(|s| s.name.clone())
|
||||
.collect();
|
||||
if !names.is_empty() {
|
||||
message.push_str(&format!(
|
||||
"\n Available skills: {}",
|
||||
names.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
message.push_str(
|
||||
"\n Usage: /skills [list|install <path>|help|<skill> [args]]",
|
||||
);
|
||||
return Err(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(dispatch)
|
||||
}
|
||||
|
||||
pub fn resolve_skill_path(cwd: &Path, skill: &str) -> std::io::Result<PathBuf> {
|
||||
let requested = skill.trim().trim_start_matches('/').trim_start_matches('$');
|
||||
if requested.is_empty() {
|
||||
@@ -4301,6 +4344,10 @@ mod tests {
|
||||
classify_skills_slash_command(Some("help overview")),
|
||||
SkillSlashDispatch::Invoke("$help overview".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
classify_skills_slash_command(Some("/test")),
|
||||
SkillSlashDispatch::Invoke("$test".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
classify_skills_slash_command(Some("install ./skill-pack")),
|
||||
SkillSlashDispatch::Local
|
||||
|
||||
@@ -1599,8 +1599,13 @@ fn run_login(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::E
|
||||
println!("Listening for callback on {redirect_uri}");
|
||||
}
|
||||
if let Err(error) = open_browser(&authorize_url) {
|
||||
eprintln!("warning: failed to open browser automatically: {error}");
|
||||
println!("Open this URL manually:\n{authorize_url}");
|
||||
emit_login_browser_open_failure(
|
||||
output_format,
|
||||
&authorize_url,
|
||||
&error,
|
||||
&mut io::stdout(),
|
||||
&mut io::stderr(),
|
||||
)?;
|
||||
}
|
||||
|
||||
let callback = wait_for_oauth_callback(callback_port)?;
|
||||
@@ -1651,6 +1656,23 @@ fn run_login(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::E
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn emit_login_browser_open_failure(
|
||||
output_format: CliOutputFormat,
|
||||
authorize_url: &str,
|
||||
error: &io::Error,
|
||||
stdout: &mut impl Write,
|
||||
stderr: &mut impl Write,
|
||||
) -> io::Result<()> {
|
||||
writeln!(
|
||||
stderr,
|
||||
"warning: failed to open browser automatically: {error}"
|
||||
)?;
|
||||
match output_format {
|
||||
CliOutputFormat::Text => writeln!(stdout, "Open this URL manually:\n{authorize_url}"),
|
||||
CliOutputFormat::Json => writeln!(stderr, "Open this URL manually:\n{authorize_url}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_logout(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||
clear_oauth_credentials()?;
|
||||
match output_format {
|
||||
@@ -5586,13 +5608,29 @@ impl AnthropicRuntimeClient {
|
||||
}
|
||||
|
||||
fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
|
||||
Ok(resolve_startup_auth_source(|| {
|
||||
let cwd = env::current_dir().map_err(api::ApiError::from)?;
|
||||
let config = ConfigLoader::default_for(&cwd).load().map_err(|error| {
|
||||
api::ApiError::Auth(format!("failed to load runtime OAuth config: {error}"))
|
||||
})?;
|
||||
Ok(config.oauth().cloned())
|
||||
})?)
|
||||
let cwd = env::current_dir()?;
|
||||
Ok(resolve_cli_auth_source_for_cwd(&cwd, default_oauth_config)?)
|
||||
}
|
||||
|
||||
fn resolve_cli_auth_source_for_cwd<F>(
|
||||
cwd: &Path,
|
||||
default_oauth: F,
|
||||
) -> Result<AuthSource, api::ApiError>
|
||||
where
|
||||
F: FnOnce() -> OAuthConfig,
|
||||
{
|
||||
resolve_startup_auth_source(|| {
|
||||
Ok(Some(
|
||||
load_runtime_oauth_config_for(cwd)?.unwrap_or_else(default_oauth),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn load_runtime_oauth_config_for(cwd: &Path) -> Result<Option<OAuthConfig>, api::ApiError> {
|
||||
let config = ConfigLoader::default_for(cwd).load().map_err(|error| {
|
||||
api::ApiError::Auth(format!("failed to load runtime OAuth config: {error}"))
|
||||
})?;
|
||||
Ok(config.oauth().cloned())
|
||||
}
|
||||
|
||||
impl ApiClient for AnthropicRuntimeClient {
|
||||
@@ -5632,6 +5670,7 @@ impl ApiClient for AnthropicRuntimeClient {
|
||||
let mut markdown_stream = MarkdownStreamState::default();
|
||||
let mut events = Vec::new();
|
||||
let mut pending_tool: Option<(String, String, String)> = None;
|
||||
let mut block_has_thinking_summary = false;
|
||||
let mut saw_stop = false;
|
||||
|
||||
while let Some(event) = stream.next_event().await.map_err(|error| {
|
||||
@@ -5640,7 +5679,14 @@ impl ApiClient for AnthropicRuntimeClient {
|
||||
match event {
|
||||
ApiStreamEvent::MessageStart(start) => {
|
||||
for block in start.message.content {
|
||||
push_output_block(block, out, &mut events, &mut pending_tool, true)?;
|
||||
push_output_block(
|
||||
block,
|
||||
out,
|
||||
&mut events,
|
||||
&mut pending_tool,
|
||||
true,
|
||||
&mut block_has_thinking_summary,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
ApiStreamEvent::ContentBlockStart(start) => {
|
||||
@@ -5650,6 +5696,7 @@ impl ApiClient for AnthropicRuntimeClient {
|
||||
&mut events,
|
||||
&mut pending_tool,
|
||||
true,
|
||||
&mut block_has_thinking_summary,
|
||||
)?;
|
||||
}
|
||||
ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
|
||||
@@ -5671,10 +5718,16 @@ impl ApiClient for AnthropicRuntimeClient {
|
||||
input.push_str(&partial_json);
|
||||
}
|
||||
}
|
||||
ContentBlockDelta::ThinkingDelta { .. }
|
||||
| ContentBlockDelta::SignatureDelta { .. } => {}
|
||||
ContentBlockDelta::ThinkingDelta { .. } => {
|
||||
if !block_has_thinking_summary {
|
||||
render_thinking_block_summary(out, None, false)?;
|
||||
block_has_thinking_summary = true;
|
||||
}
|
||||
}
|
||||
ContentBlockDelta::SignatureDelta { .. } => {}
|
||||
},
|
||||
ApiStreamEvent::ContentBlockStop(_) => {
|
||||
block_has_thinking_summary = false;
|
||||
if let Some(rendered) = markdown_stream.flush(&renderer) {
|
||||
write!(out, "{rendered}")
|
||||
.and_then(|()| out.flush())
|
||||
@@ -5742,7 +5795,7 @@ impl ApiClient for AnthropicRuntimeClient {
|
||||
}
|
||||
|
||||
fn format_user_visible_api_error(session_id: &str, error: &api::ApiError) -> String {
|
||||
if error.safe_failure_class() == "context_window" {
|
||||
if error.is_context_window_failure() {
|
||||
format_context_window_blocked_error(session_id, error)
|
||||
} else if error.is_generic_fatal_wrapper() {
|
||||
let mut qualifiers = vec![format!("session {session_id}")];
|
||||
@@ -5780,10 +5833,10 @@ fn format_context_window_blocked_error(session_id: &str, error: &api::ApiError)
|
||||
context_window_tokens,
|
||||
} => {
|
||||
lines.push(format!(" Model {model}"));
|
||||
lines.push(format!(" Estimated input {estimated_input_tokens}"));
|
||||
lines.push(format!(" Requested output {requested_output_tokens}"));
|
||||
lines.push(format!(" Estimated total {estimated_total_tokens}"));
|
||||
lines.push(format!(" Context window {context_window_tokens}"));
|
||||
lines.push(format!(" Input estimate ~{estimated_input_tokens} tokens (heuristic)"));
|
||||
lines.push(format!(" Requested output {requested_output_tokens} tokens"));
|
||||
lines.push(format!(" Total estimate ~{estimated_total_tokens} tokens (heuristic)"));
|
||||
lines.push(format!(" Context window {context_window_tokens} tokens"));
|
||||
}
|
||||
api::ApiError::Api { message, body, .. } => {
|
||||
let detail = message.as_deref().unwrap_or(body).trim();
|
||||
@@ -6409,12 +6462,30 @@ fn truncate_output_for_display(content: &str, max_lines: usize, max_chars: usize
|
||||
preview
|
||||
}
|
||||
|
||||
fn render_thinking_block_summary(
|
||||
out: &mut (impl Write + ?Sized),
|
||||
char_count: Option<usize>,
|
||||
redacted: bool,
|
||||
) -> Result<(), RuntimeError> {
|
||||
let summary = if redacted {
|
||||
"\n▶ Thinking block hidden by provider\n".to_string()
|
||||
} else if let Some(char_count) = char_count {
|
||||
format!("\n▶ Thinking ({char_count} chars hidden)\n")
|
||||
} else {
|
||||
"\n▶ Thinking hidden\n".to_string()
|
||||
};
|
||||
write!(out, "{summary}")
|
||||
.and_then(|()| out.flush())
|
||||
.map_err(|error| RuntimeError::new(error.to_string()))
|
||||
}
|
||||
|
||||
fn push_output_block(
|
||||
block: OutputContentBlock,
|
||||
out: &mut (impl Write + ?Sized),
|
||||
events: &mut Vec<AssistantEvent>,
|
||||
pending_tool: &mut Option<(String, String, String)>,
|
||||
streaming_tool_input: bool,
|
||||
block_has_thinking_summary: &mut bool,
|
||||
) -> Result<(), RuntimeError> {
|
||||
match block {
|
||||
OutputContentBlock::Text { text } => {
|
||||
@@ -6440,7 +6511,14 @@ fn push_output_block(
|
||||
};
|
||||
*pending_tool = Some((id, name, initial_input));
|
||||
}
|
||||
OutputContentBlock::Thinking { .. } | OutputContentBlock::RedactedThinking { .. } => {}
|
||||
OutputContentBlock::Thinking { thinking, .. } => {
|
||||
render_thinking_block_summary(out, Some(thinking.chars().count()), false)?;
|
||||
*block_has_thinking_summary = true;
|
||||
}
|
||||
OutputContentBlock::RedactedThinking { .. } => {
|
||||
render_thinking_block_summary(out, None, true)?;
|
||||
*block_has_thinking_summary = true;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -6453,7 +6531,15 @@ fn response_to_events(
|
||||
let mut pending_tool = None;
|
||||
|
||||
for block in response.content {
|
||||
push_output_block(block, out, &mut events, &mut pending_tool, false)?;
|
||||
let mut block_has_thinking_summary = false;
|
||||
push_output_block(
|
||||
block,
|
||||
out,
|
||||
&mut events,
|
||||
&mut pending_tool,
|
||||
false,
|
||||
&mut block_has_thinking_summary,
|
||||
)?;
|
||||
if let Some((id, name, input)) = pending_tool.take() {
|
||||
events.push(AssistantEvent::ToolUse { id, name, input });
|
||||
}
|
||||
@@ -6841,14 +6927,17 @@ mod tests {
|
||||
PluginManager, PluginManagerConfig, PluginTool, PluginToolDefinition, PluginToolPermission,
|
||||
};
|
||||
use runtime::{
|
||||
AssistantEvent, ConfigLoader, ContentBlock, ConversationMessage, MessageRole,
|
||||
PermissionMode, Session, ToolExecutor,
|
||||
load_oauth_credentials, save_oauth_credentials, AssistantEvent, ConfigLoader, ContentBlock,
|
||||
ConversationMessage, MessageRole, OAuthConfig, PermissionMode, Session, ToolExecutor,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::{Mutex, MutexGuard, OnceLock};
|
||||
use std::thread;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tools::GlobalToolRegistry;
|
||||
|
||||
@@ -6940,7 +7029,14 @@ mod tests {
|
||||
rendered.contains("Model claude-sonnet-4-6"),
|
||||
"{rendered}"
|
||||
);
|
||||
assert!(rendered.contains("Estimated total 246000"), "{rendered}");
|
||||
assert!(
|
||||
rendered.contains("Input estimate ~182000 tokens (heuristic)"),
|
||||
"{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("Total estimate ~246000 tokens (heuristic)"),
|
||||
"{rendered}"
|
||||
);
|
||||
assert!(rendered.contains("Compact /compact"), "{rendered}");
|
||||
assert!(
|
||||
rendered.contains("Resume compact claw --resume session-issue-32 /compact"),
|
||||
@@ -6986,6 +7082,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_wrapped_context_window_errors_keep_recovery_guidance() {
|
||||
let error = ApiError::RetriesExhausted {
|
||||
attempts: 2,
|
||||
last_error: Box::new(ApiError::Api {
|
||||
status: "413".parse().expect("status"),
|
||||
error_type: Some("invalid_request_error".to_string()),
|
||||
message: Some("Request is too large for this model's context window.".to_string()),
|
||||
request_id: Some("req_ctx_retry_789".to_string()),
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
}),
|
||||
};
|
||||
|
||||
let rendered = format_user_visible_api_error("session-issue-32", &error);
|
||||
assert!(rendered.contains("Context window blocked"), "{rendered}");
|
||||
assert!(rendered.contains("context_window_blocked"), "{rendered}");
|
||||
assert!(
|
||||
rendered.contains("Trace req_ctx_retry_789"),
|
||||
"{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered
|
||||
.contains("Detail Request is too large for this model's context window."),
|
||||
"{rendered}"
|
||||
);
|
||||
assert!(rendered.contains("Compact /compact"), "{rendered}");
|
||||
assert!(
|
||||
rendered.contains("Resume compact claw --resume session-issue-32 /compact"),
|
||||
"{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
fn temp_dir() -> PathBuf {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
@@ -7018,6 +7147,36 @@ mod tests {
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn sample_oauth_config(token_url: String) -> OAuthConfig {
|
||||
OAuthConfig {
|
||||
client_id: "runtime-client".to_string(),
|
||||
authorize_url: "https://console.test/oauth/authorize".to_string(),
|
||||
token_url,
|
||||
callback_port: Some(4545),
|
||||
manual_redirect_url: Some("https://console.test/oauth/callback".to_string()),
|
||||
scopes: vec!["org:create_api_key".to_string(), "user:profile".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_token_server(response_body: &'static str) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
|
||||
let address = listener.local_addr().expect("local addr");
|
||||
thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("accept connection");
|
||||
let mut buffer = [0_u8; 4096];
|
||||
let _ = stream.read(&mut buffer).expect("read request");
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
|
||||
response_body.len(),
|
||||
response_body
|
||||
);
|
||||
stream
|
||||
.write_all(response.as_bytes())
|
||||
.expect("write response");
|
||||
});
|
||||
format!("http://{address}/oauth/token")
|
||||
}
|
||||
|
||||
fn with_current_dir<T>(cwd: &Path, f: impl FnOnce() -> T) -> T {
|
||||
let _guard = cwd_lock()
|
||||
.lock()
|
||||
@@ -7156,6 +7315,78 @@ mod tests {
|
||||
assert_eq!(resolved, PermissionMode::ReadOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_runtime_oauth_config_for_returns_none_without_project_config() {
|
||||
let _guard = env_lock();
|
||||
let root = temp_dir();
|
||||
std::fs::create_dir_all(&root).expect("workspace should exist");
|
||||
|
||||
let oauth = super::load_runtime_oauth_config_for(&root)
|
||||
.expect("loading config should succeed when files are absent");
|
||||
|
||||
std::fs::remove_dir_all(root).expect("temp workspace should clean up");
|
||||
|
||||
assert_eq!(oauth, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_cli_auth_source_uses_default_oauth_when_runtime_config_is_missing() {
|
||||
let _guard = env_lock();
|
||||
let workspace = temp_dir();
|
||||
let config_home = temp_dir();
|
||||
std::fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
std::fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
|
||||
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
|
||||
let original_api_key = std::env::var("ANTHROPIC_API_KEY").ok();
|
||||
let original_auth_token = std::env::var("ANTHROPIC_AUTH_TOKEN").ok();
|
||||
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
||||
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
|
||||
|
||||
save_oauth_credentials(&runtime::OAuthTokenSet {
|
||||
access_token: "expired-access-token".to_string(),
|
||||
refresh_token: Some("refresh-token".to_string()),
|
||||
expires_at: Some(0),
|
||||
scopes: vec!["org:create_api_key".to_string(), "user:profile".to_string()],
|
||||
})
|
||||
.expect("save expired oauth credentials");
|
||||
|
||||
let token_url = spawn_token_server(
|
||||
r#"{"access_token":"refreshed-access-token","refresh_token":"refreshed-refresh-token","expires_at":4102444800,"scopes":["org:create_api_key","user:profile"]}"#,
|
||||
);
|
||||
|
||||
let auth =
|
||||
super::resolve_cli_auth_source_for_cwd(&workspace, || sample_oauth_config(token_url))
|
||||
.expect("expired saved oauth should refresh via default config");
|
||||
|
||||
let stored = load_oauth_credentials()
|
||||
.expect("load stored credentials")
|
||||
.expect("stored credentials should exist");
|
||||
|
||||
match original_config_home {
|
||||
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
|
||||
None => std::env::remove_var("CLAW_CONFIG_HOME"),
|
||||
}
|
||||
match original_api_key {
|
||||
Some(value) => std::env::set_var("ANTHROPIC_API_KEY", value),
|
||||
None => std::env::remove_var("ANTHROPIC_API_KEY"),
|
||||
}
|
||||
match original_auth_token {
|
||||
Some(value) => std::env::set_var("ANTHROPIC_AUTH_TOKEN", value),
|
||||
None => std::env::remove_var("ANTHROPIC_AUTH_TOKEN"),
|
||||
}
|
||||
std::fs::remove_dir_all(workspace).expect("temp workspace should clean up");
|
||||
std::fs::remove_dir_all(config_home).expect("temp config home should clean up");
|
||||
|
||||
assert_eq!(auth.bearer_token(), Some("refreshed-access-token"));
|
||||
assert_eq!(stored.access_token, "refreshed-access-token");
|
||||
assert_eq!(
|
||||
stored.refresh_token.as_deref(),
|
||||
Some("refreshed-refresh-token")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_prompt_subcommand() {
|
||||
let _guard = env_lock();
|
||||
@@ -7554,6 +7785,17 @@ mod tests {
|
||||
output_format: CliOutputFormat::Text,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_args(&["/skills".to_string(), "/test".to_string()])
|
||||
.expect("/skills /test should normalize to a single skill prompt prefix"),
|
||||
CliAction::Prompt {
|
||||
prompt: "$test".to_string(),
|
||||
model: DEFAULT_MODEL.to_string(),
|
||||
output_format: CliOutputFormat::Text,
|
||||
allowed_tools: None,
|
||||
permission_mode: crate::default_permission_mode(),
|
||||
}
|
||||
);
|
||||
let error = parse_args(&["/status".to_string()])
|
||||
.expect_err("/status should remain REPL-only when invoked directly");
|
||||
assert!(error.contains("interactive-only"));
|
||||
@@ -8636,6 +8878,7 @@ UU conflicted.rs",
|
||||
let mut out = Vec::new();
|
||||
let mut events = Vec::new();
|
||||
let mut pending_tool = None;
|
||||
let mut block_has_thinking_summary = false;
|
||||
|
||||
push_output_block(
|
||||
OutputContentBlock::Text {
|
||||
@@ -8645,6 +8888,7 @@ UU conflicted.rs",
|
||||
&mut events,
|
||||
&mut pending_tool,
|
||||
false,
|
||||
&mut block_has_thinking_summary,
|
||||
)
|
||||
.expect("text block should render");
|
||||
|
||||
@@ -8658,6 +8902,7 @@ UU conflicted.rs",
|
||||
let mut out = Vec::new();
|
||||
let mut events = Vec::new();
|
||||
let mut pending_tool = None;
|
||||
let mut block_has_thinking_summary = false;
|
||||
|
||||
push_output_block(
|
||||
OutputContentBlock::ToolUse {
|
||||
@@ -8669,6 +8914,7 @@ UU conflicted.rs",
|
||||
&mut events,
|
||||
&mut pending_tool,
|
||||
true,
|
||||
&mut block_has_thinking_summary,
|
||||
)
|
||||
.expect("tool block should accumulate");
|
||||
|
||||
@@ -8750,7 +8996,7 @@ UU conflicted.rs",
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_to_events_ignores_thinking_blocks() {
|
||||
fn response_to_events_renders_collapsed_thinking_summary() {
|
||||
let mut out = Vec::new();
|
||||
let events = response_to_events(
|
||||
MessageResponse {
|
||||
@@ -8785,7 +9031,34 @@ UU conflicted.rs",
|
||||
&events[0],
|
||||
AssistantEvent::TextDelta(text) if text == "Final answer"
|
||||
));
|
||||
assert!(!String::from_utf8(out).expect("utf8").contains("step 1"));
|
||||
let rendered = String::from_utf8(out).expect("utf8");
|
||||
assert!(rendered.contains("▶ Thinking (6 chars hidden)"));
|
||||
assert!(!rendered.contains("step 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_browser_failure_keeps_json_stdout_clean() {
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
let error = std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"no supported browser opener command found",
|
||||
);
|
||||
|
||||
super::emit_login_browser_open_failure(
|
||||
CliOutputFormat::Json,
|
||||
"https://example.test/oauth/authorize",
|
||||
&error,
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
)
|
||||
.expect("browser warning should render");
|
||||
|
||||
assert!(stdout.is_empty());
|
||||
let stderr = String::from_utf8(stderr).expect("utf8");
|
||||
assert!(stderr.contains("failed to open browser automatically"));
|
||||
assert!(stderr.contains("Open this URL manually:"));
|
||||
assert!(stderr.contains("https://example.test/oauth/authorize"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2974,7 +2974,263 @@ fn todo_store_path() -> Result<std::path::PathBuf, String> {
|
||||
|
||||
fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
|
||||
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
|
||||
commands::resolve_skill_path(&cwd, skill).map_err(|error| error.to_string())
|
||||
match commands::resolve_skill_path(&cwd, skill) {
|
||||
Ok(path) => Ok(path),
|
||||
Err(_) => resolve_skill_path_from_compat_roots(skill),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_skill_path_from_compat_roots(skill: &str) -> Result<std::path::PathBuf, String> {
|
||||
let requested = skill.trim().trim_start_matches('/').trim_start_matches('$');
|
||||
if requested.is_empty() {
|
||||
return Err(String::from("skill must not be empty"));
|
||||
}
|
||||
|
||||
for root in skill_lookup_roots() {
|
||||
if let Some(path) = resolve_skill_path_in_root(&root, requested) {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("unknown skill: {requested}"))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SkillLookupOrigin {
|
||||
SkillsDir,
|
||||
LegacyCommandsDir,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct SkillLookupRoot {
|
||||
path: std::path::PathBuf,
|
||||
origin: SkillLookupOrigin,
|
||||
}
|
||||
|
||||
fn skill_lookup_roots() -> Vec<SkillLookupRoot> {
|
||||
let mut roots = Vec::new();
|
||||
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
push_project_skill_lookup_roots(&mut roots, &cwd);
|
||||
}
|
||||
|
||||
if let Ok(claw_config_home) = std::env::var("CLAW_CONFIG_HOME") {
|
||||
push_prefixed_skill_lookup_roots(&mut roots, std::path::Path::new(&claw_config_home));
|
||||
}
|
||||
if let Ok(codex_home) = std::env::var("CODEX_HOME") {
|
||||
push_prefixed_skill_lookup_roots(&mut roots, std::path::Path::new(&codex_home));
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
push_home_skill_lookup_roots(&mut roots, std::path::Path::new(&home));
|
||||
}
|
||||
if let Ok(claude_config_dir) = std::env::var("CLAUDE_CONFIG_DIR") {
|
||||
let claude_config_dir = std::path::PathBuf::from(claude_config_dir);
|
||||
push_skill_lookup_root(
|
||||
&mut roots,
|
||||
claude_config_dir.join("skills"),
|
||||
SkillLookupOrigin::SkillsDir,
|
||||
);
|
||||
push_skill_lookup_root(
|
||||
&mut roots,
|
||||
claude_config_dir.join("skills").join("omc-learned"),
|
||||
SkillLookupOrigin::SkillsDir,
|
||||
);
|
||||
push_skill_lookup_root(
|
||||
&mut roots,
|
||||
claude_config_dir.join("commands"),
|
||||
SkillLookupOrigin::LegacyCommandsDir,
|
||||
);
|
||||
}
|
||||
push_skill_lookup_root(
|
||||
&mut roots,
|
||||
std::path::PathBuf::from("/home/bellman/.claw/skills"),
|
||||
SkillLookupOrigin::SkillsDir,
|
||||
);
|
||||
push_skill_lookup_root(
|
||||
&mut roots,
|
||||
std::path::PathBuf::from("/home/bellman/.codex/skills"),
|
||||
SkillLookupOrigin::SkillsDir,
|
||||
);
|
||||
|
||||
roots
|
||||
}
|
||||
|
||||
fn push_project_skill_lookup_roots(roots: &mut Vec<SkillLookupRoot>, cwd: &std::path::Path) {
|
||||
for ancestor in cwd.ancestors() {
|
||||
push_prefixed_skill_lookup_roots(roots, &ancestor.join(".omc"));
|
||||
push_prefixed_skill_lookup_roots(roots, &ancestor.join(".agents"));
|
||||
push_prefixed_skill_lookup_roots(roots, &ancestor.join(".claw"));
|
||||
push_prefixed_skill_lookup_roots(roots, &ancestor.join(".codex"));
|
||||
push_prefixed_skill_lookup_roots(roots, &ancestor.join(".claude"));
|
||||
}
|
||||
}
|
||||
|
||||
fn push_home_skill_lookup_roots(roots: &mut Vec<SkillLookupRoot>, home: &std::path::Path) {
|
||||
push_prefixed_skill_lookup_roots(roots, &home.join(".omc"));
|
||||
push_prefixed_skill_lookup_roots(roots, &home.join(".claw"));
|
||||
push_prefixed_skill_lookup_roots(roots, &home.join(".codex"));
|
||||
push_prefixed_skill_lookup_roots(roots, &home.join(".claude"));
|
||||
push_skill_lookup_root(
|
||||
roots,
|
||||
home.join(".agents").join("skills"),
|
||||
SkillLookupOrigin::SkillsDir,
|
||||
);
|
||||
push_skill_lookup_root(
|
||||
roots,
|
||||
home.join(".config").join("opencode").join("skills"),
|
||||
SkillLookupOrigin::SkillsDir,
|
||||
);
|
||||
push_skill_lookup_root(
|
||||
roots,
|
||||
home.join(".claude").join("skills").join("omc-learned"),
|
||||
SkillLookupOrigin::SkillsDir,
|
||||
);
|
||||
}
|
||||
|
||||
fn push_prefixed_skill_lookup_roots(roots: &mut Vec<SkillLookupRoot>, prefix: &std::path::Path) {
|
||||
push_skill_lookup_root(roots, prefix.join("skills"), SkillLookupOrigin::SkillsDir);
|
||||
push_skill_lookup_root(
|
||||
roots,
|
||||
prefix.join("commands"),
|
||||
SkillLookupOrigin::LegacyCommandsDir,
|
||||
);
|
||||
}
|
||||
|
||||
fn push_skill_lookup_root(
|
||||
roots: &mut Vec<SkillLookupRoot>,
|
||||
path: std::path::PathBuf,
|
||||
origin: SkillLookupOrigin,
|
||||
) {
|
||||
if path.is_dir() && !roots.iter().any(|existing| existing.path == path) {
|
||||
roots.push(SkillLookupRoot { path, origin });
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_skill_path_in_root(
|
||||
root: &SkillLookupRoot,
|
||||
requested: &str,
|
||||
) -> Option<std::path::PathBuf> {
|
||||
match root.origin {
|
||||
SkillLookupOrigin::SkillsDir => resolve_skill_path_in_skills_dir(&root.path, requested),
|
||||
SkillLookupOrigin::LegacyCommandsDir => {
|
||||
resolve_skill_path_in_legacy_commands_dir(&root.path, requested)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_skill_path_in_skills_dir(
|
||||
root: &std::path::Path,
|
||||
requested: &str,
|
||||
) -> Option<std::path::PathBuf> {
|
||||
let direct = root.join(requested).join("SKILL.md");
|
||||
if direct.is_file() {
|
||||
return Some(direct);
|
||||
}
|
||||
|
||||
let entries = std::fs::read_dir(root).ok()?;
|
||||
for entry in entries.flatten() {
|
||||
if !entry.path().is_dir() {
|
||||
continue;
|
||||
}
|
||||
let skill_path = entry.path().join("SKILL.md");
|
||||
if !skill_path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.eq_ignore_ascii_case(requested)
|
||||
|| skill_frontmatter_name_matches(&skill_path, requested)
|
||||
{
|
||||
return Some(skill_path);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve_skill_path_in_legacy_commands_dir(
|
||||
root: &std::path::Path,
|
||||
requested: &str,
|
||||
) -> Option<std::path::PathBuf> {
|
||||
let direct_dir = root.join(requested).join("SKILL.md");
|
||||
if direct_dir.is_file() {
|
||||
return Some(direct_dir);
|
||||
}
|
||||
|
||||
let direct_markdown = root.join(format!("{requested}.md"));
|
||||
if direct_markdown.is_file() {
|
||||
return Some(direct_markdown);
|
||||
}
|
||||
|
||||
let entries = std::fs::read_dir(root).ok()?;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let candidate_path = if path.is_dir() {
|
||||
let skill_path = path.join("SKILL.md");
|
||||
if !skill_path.is_file() {
|
||||
continue;
|
||||
}
|
||||
skill_path
|
||||
} else if path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md"))
|
||||
{
|
||||
path
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let matches_entry_name = candidate_path
|
||||
.file_stem()
|
||||
.is_some_and(|stem| stem.to_string_lossy().eq_ignore_ascii_case(requested))
|
||||
|| entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.trim_end_matches(".md")
|
||||
.eq_ignore_ascii_case(requested);
|
||||
if matches_entry_name || skill_frontmatter_name_matches(&candidate_path, requested) {
|
||||
return Some(candidate_path);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn skill_frontmatter_name_matches(path: &std::path::Path, requested: &str) -> bool {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|contents| parse_skill_name(&contents))
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case(requested))
|
||||
}
|
||||
|
||||
fn parse_skill_name(contents: &str) -> Option<String> {
|
||||
parse_skill_frontmatter_value(contents, "name")
|
||||
}
|
||||
|
||||
fn parse_skill_frontmatter_value(contents: &str, key: &str) -> Option<String> {
|
||||
let mut lines = contents.lines();
|
||||
if lines.next().map(str::trim) != Some("---") {
|
||||
return None;
|
||||
}
|
||||
|
||||
for line in lines {
|
||||
let trimmed = line.trim();
|
||||
if trimmed == "---" {
|
||||
break;
|
||||
}
|
||||
if let Some(value) = trimmed.strip_prefix(&format!("{key}:")) {
|
||||
let value = value
|
||||
.trim()
|
||||
.trim_matches(|ch| matches!(ch, '"' | '\''))
|
||||
.trim();
|
||||
if !value.is_empty() {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6";
|
||||
|
||||
Reference in New Issue
Block a user