Compare commits

...

8 Commits

Author SHA1 Message Date
Yeachan-Heo
00b557c8b7 Enforce machine-readable output across CLI surfaces 2026-04-05 17:35:36 +00:00
Yeachan-Heo
31163be347 style: cargo fmt 2026-04-05 16:56:48 +00:00
Yeachan-Heo
eb4d3b11ee merge fix/p2-19-subcommand-help-fallthrough 2026-04-05 16:54:59 +00:00
Yeachan-Heo
9bd7a78ca8 Merge branch 'fix/p2-18-context-window-preflight' 2026-04-05 16:54:45 +00:00
Yeachan-Heo
24d8f916c8 merge fix/p0-10-json-status 2026-04-05 16:54:38 +00:00
Yeachan-Heo
30883bddbd Keep doctor and local help paths shell-native
Promote doctor into a real top-level CLI action, reuse the same local report for resumed and REPL doctor invocations, and intercept doctor/status/sandbox help flags before prompt-mode dispatch. The parser change also closes the help fallthrough that previously wandered into runtime startup for local-info commands.

Constraint: Preserve prompt shorthand for normal multi-word text input while fixing exact local subcommand help paths
Rejected: Route \7⠋ 🦀 Thinking...8✘  Request failed
 through prompt/slash guidance | still shells out through the wrong surface and keeps health checks hidden
Rejected: Reuse the status report as doctor output | status does not explain auth/config health or expose a dedicated diagnostic summary
Confidence: high
Scope-risk: narrow
Directive: Keep doctor local-only unless an explicit network probe is intentionally added and separately tested
Tested: cargo build -p rusty-claude-cli; cargo test -p rusty-claude-cli; cargo run -p rusty-claude-cli -- doctor --help; CLAW_CONFIG_HOME=/tmp/tmp.7pm9SVzOPN ANTHROPIC_API_KEY= ANTHROPIC_AUTH_TOKEN= cargo run -p rusty-claude-cli -- doctor
Not-tested: direct /doctor outside the REPL remains interactive-only
2026-04-05 16:44:36 +00:00
Yeachan-Heo
fa72cd665e Block oversized requests before providers hard-fail
The runtime already tracked rough token estimates for compaction, but provider-bound
requests still relied on naive model output limits and could be sent upstream even
when the selected model could not fit the estimated prompt plus requested output.

This adds a small model token/context registry in the API layer, estimates request
size from the serialized prompt payload, and fails locally with a dedicated
context-window error before Anthropic or xAI calls are made. Focused integration
coverage asserts the preflight fires before any HTTP request leaves the process.

Constraint: Keep the first pass minimal and reusable across both Anthropic and OpenAI-compatible providers
Rejected: Auto-compact-and-retry in the same patch | broader control-flow change than the requested minimal preflight
Confidence: medium
Scope-risk: narrow
Reversibility: clean
Directive: Expand the model registry before enabling preflight for additional providers or aliases
Tested: cargo build -p api -p tools -p rusty-claude-cli; cargo test -p api
Not-tested: End-to-end CLI auto-compaction or retry behavior after a local context_window_blocked failure
2026-04-05 16:39:58 +00:00
Yeachan-Heo
1f53d961ff Route nested CLI help requests to usage instead of operand fallthrough
The direct CLI wrappers for agents, skills, and mcp treated nested help flags as ordinary operands. That made commands like `claw mcp show --help` report a missing server and `claw skills install --help` fall into filesystem install logic instead of surfacing usage.

This change normalizes help-path arguments before dispatch so nested help stays on the help path. The regression tests cover both handler-level behavior and end-to-end CLI output for nested help and unknown subcommands with trailing help flags.

Constraint: Keep the fix scoped to direct CLI slash-command wrappers without changing unrelated parser behavior
Rejected: Rework top-level argument parsing for all subcommands | broader risk than needed for the regression
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If more nested subcommands are added, extend the help-path normalization table before relying on raw operand dispatch
Tested: cargo build -p commands -p rusty-claude-cli
Tested: cargo test -p commands -p rusty-claude-cli
Not-tested: cargo clippy -p commands -p rusty-claude-cli --all-targets --no-deps -- -D warnings (pre-existing warnings in untouched files block clean run)
2026-04-05 16:38:43 +00:00
24 changed files with 1280 additions and 270 deletions

BIN
assets/sigrid-photo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View File

@@ -0,0 +1,2 @@
{"created_at_ms":1775386832313,"session_id":"session-1775386832313-0","type":"session_meta","updated_at_ms":1775386832313,"version":1}
{"message":{"blocks":[{"text":"status --help","type":"text"}],"role":"user"},"type":"message"}

View File

@@ -0,0 +1,2 @@
{"created_at_ms":1775386842352,"session_id":"session-1775386842352-0","type":"session_meta","updated_at_ms":1775386842352,"version":1}
{"message":{"blocks":[{"text":"doctor --help","type":"text"}],"role":"user"},"type":"message"}

View File

@@ -0,0 +1,2 @@
{"created_at_ms":1775386852257,"session_id":"session-1775386852257-0","type":"session_meta","updated_at_ms":1775386852257,"version":1}
{"message":{"blocks":[{"text":"doctor --help","type":"text"}],"role":"user"},"type":"message"}

View File

@@ -0,0 +1,2 @@
{"created_at_ms":1775386853666,"session_id":"session-1775386853666-0","type":"session_meta","updated_at_ms":1775386853666,"version":1}
{"message":{"blocks":[{"text":"status --help","type":"text"}],"role":"user"},"type":"message"}

View File

@@ -8,6 +8,13 @@ pub enum ApiError {
provider: &'static str,
env_vars: &'static [&'static str],
},
ContextWindowExceeded {
model: String,
estimated_input_tokens: u32,
requested_output_tokens: u32,
estimated_total_tokens: u32,
context_window_tokens: u32,
},
ExpiredOAuthToken,
Auth(String),
InvalidApiKeyEnv(VarError),
@@ -48,6 +55,7 @@ impl ApiError {
Self::Api { retryable, .. } => *retryable,
Self::RetriesExhausted { last_error, .. } => last_error.is_retryable(),
Self::MissingCredentials { .. }
| Self::ContextWindowExceeded { .. }
| Self::ExpiredOAuthToken
| Self::Auth(_)
| Self::InvalidApiKeyEnv(_)
@@ -67,6 +75,16 @@ impl Display for ApiError {
"missing {provider} credentials; export {} before calling the {provider} API",
env_vars.join(" or ")
),
Self::ContextWindowExceeded {
model,
estimated_input_tokens,
requested_output_tokens,
estimated_total_tokens,
context_window_tokens,
} => write!(
f,
"context_window_blocked for {model}: estimated input {estimated_input_tokens} + requested output {requested_output_tokens} = {estimated_total_tokens} tokens exceeds the {context_window_tokens}-token context window; compact the session or reduce request size before retrying"
),
Self::ExpiredOAuthToken => {
write!(
f,

View File

@@ -14,7 +14,7 @@ use telemetry::{AnalyticsEvent, AnthropicRequestProfile, ClientIdentity, Session
use crate::error::ApiError;
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
use super::{Provider, ProviderFuture};
use super::{preflight_message_request, Provider, ProviderFuture};
use crate::sse::SseParser;
use crate::types::{MessageDeltaEvent, MessageRequest, MessageResponse, StreamEvent, Usage};
@@ -294,6 +294,8 @@ impl AnthropicClient {
}
}
preflight_message_request(&request)?;
let response = self.send_with_retry(&request).await?;
let request_id = request_id_from_headers(response.headers());
let mut response = response
@@ -337,6 +339,7 @@ impl AnthropicClient {
&self,
request: &MessageRequest,
) -> Result<MessageStream, ApiError> {
preflight_message_request(request)?;
let response = self
.send_with_retry(&request.clone().with_streaming())
.await?;

View File

@@ -1,6 +1,8 @@
use std::future::Future;
use std::pin::Pin;
use serde::Serialize;
use crate::error::ApiError;
use crate::types::{MessageRequest, MessageResponse};
@@ -40,6 +42,12 @@ pub struct ProviderMetadata {
pub default_base_url: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ModelTokenLimit {
pub max_output_tokens: u32,
pub context_window_tokens: u32,
}
const MODEL_REGISTRY: &[(&str, ProviderMetadata)] = &[
(
"opus",
@@ -182,17 +190,86 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind {
#[must_use]
pub fn max_tokens_for_model(model: &str) -> u32 {
model_token_limit(model).map_or_else(
|| {
let canonical = resolve_model_alias(model);
if canonical.contains("opus") {
32_000
} else {
64_000
}
},
|limit| limit.max_output_tokens,
)
}
#[must_use]
pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
let canonical = resolve_model_alias(model);
if canonical.contains("opus") {
32_000
} else {
64_000
match canonical.as_str() {
"claude-opus-4-6" => Some(ModelTokenLimit {
max_output_tokens: 32_000,
context_window_tokens: 200_000,
}),
"claude-sonnet-4-6" | "claude-haiku-4-5-20251213" => Some(ModelTokenLimit {
max_output_tokens: 64_000,
context_window_tokens: 200_000,
}),
"grok-3" | "grok-3-mini" => Some(ModelTokenLimit {
max_output_tokens: 64_000,
context_window_tokens: 131_072,
}),
_ => None,
}
}
pub fn preflight_message_request(request: &MessageRequest) -> Result<(), ApiError> {
let Some(limit) = model_token_limit(&request.model) else {
return Ok(());
};
let estimated_input_tokens = estimate_message_request_input_tokens(request);
let estimated_total_tokens = estimated_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,
requested_output_tokens: request.max_tokens,
estimated_total_tokens,
context_window_tokens: limit.context_window_tokens,
});
}
Ok(())
}
fn estimate_message_request_input_tokens(request: &MessageRequest) -> u32 {
let mut estimate = estimate_serialized_tokens(&request.messages);
estimate = estimate.saturating_add(estimate_serialized_tokens(&request.system));
estimate = estimate.saturating_add(estimate_serialized_tokens(&request.tools));
estimate = estimate.saturating_add(estimate_serialized_tokens(&request.tool_choice));
estimate
}
fn estimate_serialized_tokens<T: Serialize>(value: &T) -> u32 {
serde_json::to_vec(value)
.ok()
.map_or(0, |bytes| (bytes.len() / 4 + 1) as u32)
}
#[cfg(test)]
mod tests {
use super::{detect_provider_kind, max_tokens_for_model, resolve_model_alias, ProviderKind};
use serde_json::json;
use crate::error::ApiError;
use crate::types::{
InputContentBlock, InputMessage, MessageRequest, ToolChoice, ToolDefinition,
};
use super::{
detect_provider_kind, max_tokens_for_model, model_token_limit, preflight_message_request,
resolve_model_alias, ProviderKind,
};
#[test]
fn resolves_grok_aliases() {
@@ -215,4 +292,86 @@ mod tests {
assert_eq!(max_tokens_for_model("opus"), 32_000);
assert_eq!(max_tokens_for_model("grok-3"), 64_000);
}
#[test]
fn returns_context_window_metadata_for_supported_models() {
assert_eq!(
model_token_limit("claude-sonnet-4-6")
.expect("claude-sonnet-4-6 should be registered")
.context_window_tokens,
200_000
);
assert_eq!(
model_token_limit("grok-mini")
.expect("grok-mini should resolve to a registered model")
.context_window_tokens,
131_072
);
}
#[test]
fn preflight_blocks_requests_that_exceed_the_model_context_window() {
let request = MessageRequest {
model: "claude-sonnet-4-6".to_string(),
max_tokens: 64_000,
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Text {
text: "x".repeat(600_000),
}],
}],
system: Some("Keep the answer short.".to_string()),
tools: Some(vec![ToolDefinition {
name: "weather".to_string(),
description: Some("Fetches weather".to_string()),
input_schema: json!({
"type": "object",
"properties": { "city": { "type": "string" } },
}),
}]),
tool_choice: Some(ToolChoice::Auto),
stream: true,
};
let error = preflight_message_request(&request)
.expect_err("oversized request should be rejected before the provider call");
match error {
ApiError::ContextWindowExceeded {
model,
estimated_input_tokens,
requested_output_tokens,
estimated_total_tokens,
context_window_tokens,
} => {
assert_eq!(model, "claude-sonnet-4-6");
assert!(estimated_input_tokens > 136_000);
assert_eq!(requested_output_tokens, 64_000);
assert!(estimated_total_tokens > context_window_tokens);
assert_eq!(context_window_tokens, 200_000);
}
other => panic!("expected context-window preflight failure, got {other:?}"),
}
}
#[test]
fn preflight_skips_unknown_models() {
let request = MessageRequest {
model: "unknown-model".to_string(),
max_tokens: 64_000,
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Text {
text: "x".repeat(600_000),
}],
}],
system: None,
tools: None,
tool_choice: None,
stream: false,
};
preflight_message_request(&request)
.expect("models without context metadata should skip the guarded preflight");
}
}

View File

@@ -12,7 +12,7 @@ use crate::types::{
ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
};
use super::{Provider, ProviderFuture};
use super::{preflight_message_request, 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";
@@ -128,6 +128,7 @@ impl OpenAiCompatClient {
stream: false,
..request.clone()
};
preflight_message_request(&request)?;
let response = self.send_with_retry(&request).await?;
let request_id = request_id_from_headers(response.headers());
let payload = response.json::<ChatCompletionResponse>().await?;
@@ -142,6 +143,7 @@ impl OpenAiCompatClient {
&self,
request: &MessageRequest,
) -> Result<MessageStream, ApiError> {
preflight_message_request(request)?;
let response = self
.send_with_retry(&request.clone().with_streaming())
.await?;

View File

@@ -103,6 +103,41 @@ async fn send_message_posts_json_and_parses_response() {
);
}
#[tokio::test]
async fn send_message_blocks_oversized_requests_before_the_http_call() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![http_response("200 OK", "application/json", "{}")],
)
.await;
let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
let error = client
.send_message(&MessageRequest {
model: "claude-sonnet-4-6".to_string(),
max_tokens: 64_000,
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Text {
text: "x".repeat(600_000),
}],
}],
system: Some("Keep the answer short.".to_string()),
tools: None,
tool_choice: None,
stream: false,
})
.await
.expect_err("oversized request should fail local context-window preflight");
assert!(matches!(error, ApiError::ContextWindowExceeded { .. }));
assert!(
state.lock().await.is_empty(),
"preflight failure should avoid any upstream HTTP request"
);
}
#[tokio::test]
async fn send_message_applies_request_profile_and_records_telemetry() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));

View File

@@ -4,10 +4,10 @@ use std::sync::Arc;
use std::sync::{Mutex as StdMutex, OnceLock};
use api::{
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest, OpenAiCompatClient,
OpenAiCompatConfig, OutputContentBlock, ProviderClient, StreamEvent, ToolChoice,
ToolDefinition,
ApiError, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent,
ContentBlockStopEvent, InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest,
OpenAiCompatClient, OpenAiCompatConfig, OutputContentBlock, ProviderClient, StreamEvent,
ToolChoice, ToolDefinition,
};
use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -63,6 +63,42 @@ async fn send_message_uses_openai_compatible_endpoint_and_auth() {
assert_eq!(body["tools"][0]["type"], json!("function"));
}
#[tokio::test]
async fn send_message_blocks_oversized_xai_requests_before_the_http_call() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![http_response("200 OK", "application/json", "{}")],
)
.await;
let client = OpenAiCompatClient::new("xai-test-key", OpenAiCompatConfig::xai())
.with_base_url(server.base_url());
let error = client
.send_message(&MessageRequest {
model: "grok-3".to_string(),
max_tokens: 64_000,
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Text {
text: "x".repeat(300_000),
}],
}],
system: Some("Keep the answer short.".to_string()),
tools: None,
tool_choice: None,
stream: false,
})
.await
.expect_err("oversized request should fail local context-window preflight");
assert!(matches!(error, ApiError::ContextWindowExceeded { .. }));
assert!(
state.lock().await.is_empty(),
"preflight failure should avoid any upstream HTTP request"
);
}
#[tokio::test]
async fn send_message_accepts_full_chat_completions_endpoint_override() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));

View File

@@ -2142,13 +2142,22 @@ pub fn handle_plugins_slash_command(
}
pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
if let Some(args) = normalize_optional_args(args) {
if let Some(help_path) = help_path_from_args(args) {
return Ok(match help_path.as_slice() {
[] => render_agents_usage(None),
_ => render_agents_usage(Some(&help_path.join(" "))),
});
}
}
match normalize_optional_args(args) {
None | Some("list") => {
let roots = discover_definition_roots(cwd, "agents");
let agents = load_agents_from_roots(&roots)?;
Ok(render_agents_report(&agents))
}
Some("-h" | "--help" | "help") => Ok(render_agents_usage(None)),
Some(args) if is_help_arg(args) => Ok(render_agents_usage(None)),
Some(args) => Ok(render_agents_usage(Some(args))),
}
}
@@ -2162,6 +2171,16 @@ pub fn handle_mcp_slash_command(
}
pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
if let Some(args) = normalize_optional_args(args) {
if let Some(help_path) = help_path_from_args(args) {
return Ok(match help_path.as_slice() {
[] => render_skills_usage(None),
["install", ..] => render_skills_usage(Some("install")),
_ => render_skills_usage(Some(&help_path.join(" "))),
});
}
}
match normalize_optional_args(args) {
None | Some("list") => {
let roots = discover_skill_roots(cwd);
@@ -2177,7 +2196,7 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
let install = install_skill(target, cwd)?;
Ok(render_skill_install_report(&install))
}
Some("-h" | "--help" | "help") => Ok(render_skills_usage(None)),
Some(args) if is_help_arg(args) => Ok(render_skills_usage(None)),
Some(args) => Ok(render_skills_usage(Some(args))),
}
}
@@ -2187,6 +2206,16 @@ fn render_mcp_report_for(
cwd: &Path,
args: Option<&str>,
) -> Result<String, runtime::ConfigError> {
if let Some(args) = normalize_optional_args(args) {
if let Some(help_path) = help_path_from_args(args) {
return Ok(match help_path.as_slice() {
[] => render_mcp_usage(None),
["show", ..] => render_mcp_usage(Some("show")),
_ => render_mcp_usage(Some(&help_path.join(" "))),
});
}
}
match normalize_optional_args(args) {
None | Some("list") => {
let runtime_config = loader.load()?;
@@ -2195,7 +2224,7 @@ fn render_mcp_report_for(
runtime_config.mcp().servers(),
))
}
Some("-h" | "--help" | "help") => Ok(render_mcp_usage(None)),
Some(args) if is_help_arg(args) => Ok(render_mcp_usage(None)),
Some("show") => Ok(render_mcp_usage(Some("show"))),
Some(args) if args.split_whitespace().next() == Some("show") => {
let mut parts = args.split_whitespace();
@@ -3036,6 +3065,16 @@ fn normalize_optional_args(args: Option<&str>) -> Option<&str> {
args.map(str::trim).filter(|value| !value.is_empty())
}
fn is_help_arg(arg: &str) -> bool {
matches!(arg, "help" | "-h" | "--help")
}
fn help_path_from_args(args: &str) -> Option<Vec<&str>> {
let parts = args.split_whitespace().collect::<Vec<_>>();
let help_index = parts.iter().position(|part| is_help_arg(part))?;
Some(parts[..help_index].to_vec())
}
fn render_agents_usage(unexpected: Option<&str>) -> String {
let mut lines = vec![
"Agents".to_string(),
@@ -4005,7 +4044,17 @@ mod tests {
let skills_unexpected =
super::handle_skills_slash_command(Some("show help"), &cwd).expect("skills usage");
assert!(skills_unexpected.contains("Unexpected show help"));
assert!(skills_unexpected.contains("Unexpected show"));
let skills_install_help = super::handle_skills_slash_command(Some("install --help"), &cwd)
.expect("nested skills help");
assert!(skills_install_help.contains("Usage /skills [list|install <path>|help]"));
assert!(skills_install_help.contains("Unexpected install"));
let skills_unknown_help =
super::handle_skills_slash_command(Some("show --help"), &cwd).expect("skills help");
assert!(skills_unknown_help.contains("Usage /skills [list|install <path>|help]"));
assert!(skills_unknown_help.contains("Unexpected show"));
let _ = fs::remove_dir_all(cwd);
}
@@ -4022,6 +4071,16 @@ mod tests {
super::handle_mcp_slash_command(Some("show alpha beta"), &cwd).expect("mcp usage");
assert!(unexpected.contains("Unexpected show alpha beta"));
let nested_help =
super::handle_mcp_slash_command(Some("show --help"), &cwd).expect("mcp help");
assert!(nested_help.contains("Usage /mcp [list|show <server>|help]"));
assert!(nested_help.contains("Unexpected show"));
let unknown_help =
super::handle_mcp_slash_command(Some("inspect --help"), &cwd).expect("mcp usage");
assert!(unknown_help.contains("Usage /mcp [list|show <server>|help]"));
assert!(unknown_help.contains("Unexpected inspect"));
let _ = fs::remove_dir_all(cwd);
}

View File

@@ -114,8 +114,12 @@ impl LaneEvent {
#[must_use]
pub fn finished(emitted_at: impl Into<String>, detail: Option<String>) -> Self {
Self::new(LaneEventName::Finished, LaneEventStatus::Completed, emitted_at)
.with_optional_detail(detail)
Self::new(
LaneEventName::Finished,
LaneEventStatus::Completed,
emitted_at,
)
.with_optional_detail(detail)
}
#[must_use]
@@ -161,19 +165,14 @@ impl LaneEvent {
mod tests {
use serde_json::json;
use super::{
LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass,
};
use super::{LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass};
#[test]
fn canonical_lane_event_names_serialize_to_expected_wire_values() {
let cases = [
(LaneEventName::Started, "lane.started"),
(LaneEventName::Ready, "lane.ready"),
(
LaneEventName::PromptMisdelivery,
"lane.prompt_misdelivery",
),
(LaneEventName::PromptMisdelivery, "lane.prompt_misdelivery"),
(LaneEventName::Blocked, "lane.blocked"),
(LaneEventName::Red, "lane.red"),
(LaneEventName::Green, "lane.green"),
@@ -193,7 +192,10 @@ mod tests {
];
for (event, expected) in cases {
assert_eq!(serde_json::to_value(event).expect("serialize event"), json!(expected));
assert_eq!(
serde_json::to_value(event).expect("serialize event"),
json!(expected)
);
}
}

View File

@@ -599,7 +599,10 @@ mod tests {
));
match result {
McpPhaseResult::Failure { phase: failed_phase, error } => {
McpPhaseResult::Failure {
phase: failed_phase,
error,
} => {
assert_eq!(failed_phase, phase);
assert_eq!(error.phase, phase);
assert_eq!(

View File

@@ -360,8 +360,10 @@ impl McpServerManagerError {
}
fn recoverable(&self) -> bool {
!matches!(self.lifecycle_phase(), McpLifecyclePhase::InitializeHandshake)
&& matches!(self, Self::Transport { .. } | Self::Timeout { .. })
!matches!(
self.lifecycle_phase(),
McpLifecyclePhase::InitializeHandshake
) && matches!(self, Self::Transport { .. } | Self::Timeout { .. })
}
fn discovery_failure(&self, server_name: &str) -> McpDiscoveryFailure {
@@ -417,10 +419,9 @@ impl McpServerManagerError {
("method".to_string(), (*method).to_string()),
("timeout_ms".to_string(), timeout_ms.to_string()),
]),
Self::UnknownTool { qualified_name } => BTreeMap::from([(
"qualified_tool".to_string(),
qualified_name.clone(),
)]),
Self::UnknownTool { qualified_name } => {
BTreeMap::from([("qualified_tool".to_string(), qualified_name.clone())])
}
Self::UnknownServer { server_name } => {
BTreeMap::from([("server".to_string(), server_name.clone())])
}
@@ -1425,11 +1426,10 @@ mod tests {
use crate::mcp_client::McpClientBootstrap;
use super::{
spawn_mcp_stdio_process, JsonRpcId, JsonRpcRequest, JsonRpcResponse,
McpInitializeClientInfo, McpInitializeParams, McpInitializeResult, McpInitializeServerInfo,
McpListToolsResult, McpReadResourceParams, McpReadResourceResult, McpServerManager,
McpServerManagerError, McpStdioProcess, McpTool, McpToolCallParams,
unsupported_server_failed_server,
spawn_mcp_stdio_process, unsupported_server_failed_server, JsonRpcId, JsonRpcRequest,
JsonRpcResponse, McpInitializeClientInfo, McpInitializeParams, McpInitializeResult,
McpInitializeServerInfo, McpListToolsResult, McpReadResourceParams, McpReadResourceResult,
McpServerManager, McpServerManagerError, McpStdioProcess, McpTool, McpToolCallParams,
};
use crate::McpLifecyclePhase;
@@ -2698,7 +2698,10 @@ mod tests {
);
assert!(!report.failed_servers[0].recoverable);
assert_eq!(
report.failed_servers[0].context.get("method").map(String::as_str),
report.failed_servers[0]
.context
.get("method")
.map(String::as_str),
Some("initialize")
);
assert!(report.failed_servers[0].error.contains("initialize"));

View File

@@ -4,7 +4,6 @@ use std::fs;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use crate::session::{Session, SessionError};
pub const PRIMARY_SESSION_EXTENSION: &str = "jsonl";

View File

@@ -66,11 +66,7 @@ pub fn validate_packet(packet: TaskPacket) -> Result<ValidatedPacket, TaskPacket
&packet.reporting_contract,
&mut errors,
);
validate_required(
"escalation_policy",
&packet.escalation_policy,
&mut errors,
);
validate_required("escalation_policy", &packet.escalation_policy, &mut errors);
for (index, test) in packet.acceptance_tests.iter().enumerate() {
if test.trim().is_empty() {
@@ -146,9 +142,9 @@ mod tests {
assert!(error
.errors()
.contains(&"repo must not be empty".to_string()));
assert!(error.errors().contains(
&"acceptance_tests contains an empty value at index 1".to_string()
));
assert!(error
.errors()
.contains(&"acceptance_tests contains an empty value at index 1".to_string()));
}
#[test]

View File

@@ -76,11 +76,7 @@ impl TaskRegistry {
}
pub fn create(&self, prompt: &str, description: Option<&str>) -> Task {
self.create_task(
prompt.to_owned(),
description.map(str::to_owned),
None,
)
self.create_task(prompt.to_owned(), description.map(str::to_owned), None)
}
pub fn create_from_packet(

View File

@@ -257,7 +257,9 @@ impl WorkerRegistry {
let prompt_preview = prompt_preview(worker.last_prompt.as_deref().unwrap_or_default());
let message = match observation.target {
WorkerPromptTarget::Shell => {
format!("worker prompt landed in shell instead of coding agent: {prompt_preview}")
format!(
"worker prompt landed in shell instead of coding agent: {prompt_preview}"
)
}
WorkerPromptTarget::WrongTarget => format!(
"worker prompt landed in the wrong target instead of {}: {}",
@@ -312,7 +314,9 @@ impl WorkerRegistry {
worker.last_error = None;
}
if detect_ready_for_prompt(screen_text, &lowered) && worker.status != WorkerStatus::ReadyForPrompt {
if detect_ready_for_prompt(screen_text, &lowered)
&& worker.status != WorkerStatus::ReadyForPrompt
{
worker.status = WorkerStatus::ReadyForPrompt;
worker.prompt_in_flight = false;
if matches!(
@@ -412,7 +416,10 @@ impl WorkerRegistry {
worker_id: worker.worker_id.clone(),
status: worker.status,
ready: worker.status == WorkerStatus::ReadyForPrompt,
blocked: matches!(worker.status, WorkerStatus::TrustRequired | WorkerStatus::Failed),
blocked: matches!(
worker.status,
WorkerStatus::TrustRequired | WorkerStatus::Failed
),
replay_prompt_ready: worker.replay_prompt.is_some(),
last_error: worker.last_error.clone(),
})

View File

@@ -107,13 +107,26 @@ Run `claw --help` for usage."
fn run() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().skip(1).collect();
match parse_args(&args)? {
CliAction::DumpManifests => dump_manifests(),
CliAction::BootstrapPlan => print_bootstrap_plan(),
CliAction::Agents { args } => LiveCli::print_agents(args.as_deref())?,
CliAction::Mcp { args } => LiveCli::print_mcp(args.as_deref())?,
CliAction::Skills { args } => LiveCli::print_skills(args.as_deref())?,
CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
CliAction::Version => print_version(),
CliAction::DumpManifests { output_format } => dump_manifests(output_format),
CliAction::BootstrapPlan { output_format } => print_bootstrap_plan(output_format)?,
CliAction::Agents {
args,
output_format,
} => LiveCli::print_agents(args.as_deref(), output_format)?,
CliAction::Mcp {
args,
output_format,
} => LiveCli::print_mcp(args.as_deref(), output_format)?,
CliAction::Skills {
args,
output_format,
} => LiveCli::print_skills(args.as_deref(), output_format)?,
CliAction::PrintSystemPrompt {
cwd,
date,
output_format,
} => print_system_prompt(cwd, date, output_format)?,
CliAction::Version { output_format } => print_version(output_format)?,
CliAction::ResumeSession {
session_path,
commands,
@@ -133,37 +146,47 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
permission_mode,
} => LiveCli::new(model, true, allowed_tools, permission_mode)?
.run_turn_with_output(&prompt, output_format)?,
CliAction::Login => run_login()?,
CliAction::Logout => run_logout()?,
CliAction::Init => run_init()?,
CliAction::Login { output_format } => run_login(output_format)?,
CliAction::Logout { output_format } => run_logout(output_format)?,
CliAction::Init { output_format } => run_init(output_format)?,
CliAction::Repl {
model,
allowed_tools,
permission_mode,
} => run_repl(model, allowed_tools, permission_mode)?,
CliAction::Help => print_help(),
CliAction::Help { output_format } => print_help(output_format)?,
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum CliAction {
DumpManifests,
BootstrapPlan,
DumpManifests {
output_format: CliOutputFormat,
},
BootstrapPlan {
output_format: CliOutputFormat,
},
Agents {
args: Option<String>,
output_format: CliOutputFormat,
},
Mcp {
args: Option<String>,
output_format: CliOutputFormat,
},
Skills {
args: Option<String>,
output_format: CliOutputFormat,
},
PrintSystemPrompt {
cwd: PathBuf,
date: String,
output_format: CliOutputFormat,
},
Version {
output_format: CliOutputFormat,
},
Version,
ResumeSession {
session_path: PathBuf,
commands: Vec<String>,
@@ -184,16 +207,24 @@ enum CliAction {
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
},
Login,
Logout,
Init,
Login {
output_format: CliOutputFormat,
},
Logout {
output_format: CliOutputFormat,
},
Init {
output_format: CliOutputFormat,
},
Repl {
model: String,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
},
// prompt-mode formatting is only supported for non-interactive runs
Help,
Help {
output_format: CliOutputFormat,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -327,11 +358,11 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
}
if wants_help {
return Ok(CliAction::Help);
return Ok(CliAction::Help { output_format });
}
if wants_version {
return Ok(CliAction::Version);
return Ok(CliAction::Version { output_format });
}
let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?;
@@ -356,21 +387,24 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode);
match rest[0].as_str() {
"dump-manifests" => Ok(CliAction::DumpManifests),
"bootstrap-plan" => Ok(CliAction::BootstrapPlan),
"dump-manifests" => Ok(CliAction::DumpManifests { output_format }),
"bootstrap-plan" => Ok(CliAction::BootstrapPlan { output_format }),
"agents" => Ok(CliAction::Agents {
args: join_optional_args(&rest[1..]),
output_format,
}),
"mcp" => Ok(CliAction::Mcp {
args: join_optional_args(&rest[1..]),
output_format,
}),
"skills" => Ok(CliAction::Skills {
args: join_optional_args(&rest[1..]),
output_format,
}),
"system-prompt" => parse_system_prompt_args(&rest[1..]),
"login" => Ok(CliAction::Login),
"logout" => Ok(CliAction::Logout),
"init" => Ok(CliAction::Init),
"system-prompt" => parse_system_prompt_args(&rest[1..], output_format),
"login" => Ok(CliAction::Login { output_format }),
"logout" => Ok(CliAction::Logout { output_format }),
"init" => Ok(CliAction::Init { output_format }),
"prompt" => {
let prompt = rest[1..].join(" ");
if prompt.trim().is_empty() {
@@ -384,7 +418,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
permission_mode,
})
}
other if other.starts_with('/') => parse_direct_slash_cli_action(&rest),
other if other.starts_with('/') => parse_direct_slash_cli_action(&rest, output_format),
_other => Ok(CliAction::Prompt {
prompt: rest.join(" "),
model,
@@ -406,8 +440,8 @@ fn parse_single_word_command_alias(
}
match rest[0].as_str() {
"help" => Some(Ok(CliAction::Help)),
"version" => Some(Ok(CliAction::Version)),
"help" => Some(Ok(CliAction::Help { output_format })),
"version" => Some(Ok(CliAction::Version { output_format })),
"status" => Some(Ok(CliAction::Status {
model: model.to_string(),
permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode),
@@ -455,11 +489,17 @@ fn join_optional_args(args: &[String]) -> Option<String> {
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
fn parse_direct_slash_cli_action(rest: &[String]) -> Result<CliAction, String> {
fn parse_direct_slash_cli_action(
rest: &[String],
output_format: CliOutputFormat,
) -> Result<CliAction, String> {
let raw = rest.join(" ");
match SlashCommand::parse(&raw) {
Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help),
Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents { args }),
Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help { output_format }),
Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents {
args,
output_format,
}),
Ok(Some(SlashCommand::Mcp { action, target })) => Ok(CliAction::Mcp {
args: match (action, target) {
(None, None) => None,
@@ -467,8 +507,12 @@ fn parse_direct_slash_cli_action(rest: &[String]) -> Result<CliAction, String> {
(Some(action), Some(target)) => Some(format!("{action} {target}")),
(None, Some(target)) => Some(target),
},
output_format,
}),
Ok(Some(SlashCommand::Skills { args })) => Ok(CliAction::Skills {
args,
output_format,
}),
Ok(Some(SlashCommand::Skills { args })) => Ok(CliAction::Skills { args }),
Ok(Some(SlashCommand::Unknown(name))) => Err(format_unknown_direct_slash_command(&name)),
Ok(Some(command)) => Err({
let _ = command;
@@ -679,7 +723,10 @@ fn filter_tool_specs(
tool_registry.definitions(allowed_tools)
}
fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
fn parse_system_prompt_args(
args: &[String],
output_format: CliOutputFormat,
) -> Result<CliAction, String> {
let mut cwd = env::current_dir().map_err(|error| error.to_string())?;
let mut date = DEFAULT_DATE.to_string();
let mut index = 0;
@@ -704,7 +751,11 @@ fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
}
}
Ok(CliAction::PrintSystemPrompt { cwd, date })
Ok(CliAction::PrintSystemPrompt {
cwd,
date,
output_format,
})
}
fn parse_resume_args(args: &[String], output_format: CliOutputFormat) -> Result<CliAction, String> {
@@ -775,25 +826,73 @@ fn looks_like_slash_command_token(token: &str) -> bool {
.any(|spec| spec.name == name || spec.aliases.contains(&name))
}
fn dump_manifests() {
fn print_json_value(value: &Value) -> Result<(), Box<dyn std::error::Error>> {
println!("{}", serialize_json_output(value)?);
Ok(())
}
fn json_error_payload(kind: &str, error: &dyn std::fmt::Display) -> Value {
json!({
"kind": kind,
"error": error.to_string(),
})
}
fn render_help_report() -> io::Result<String> {
let mut buffer = Vec::new();
print_help_to(&mut buffer)?;
String::from_utf8(buffer)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.utf8_error()))
}
fn dump_manifests(output_format: CliOutputFormat) {
let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
let paths = UpstreamPaths::from_workspace_dir(&workspace_dir);
match extract_manifest(&paths) {
Ok(manifest) => {
println!("commands: {}", manifest.commands.entries().len());
println!("tools: {}", manifest.tools.entries().len());
println!("bootstrap phases: {}", manifest.bootstrap.phases().len());
}
Ok(manifest) => match output_format {
CliOutputFormat::Text => {
println!("commands: {}", manifest.commands.entries().len());
println!("tools: {}", manifest.tools.entries().len());
println!("bootstrap phases: {}", manifest.bootstrap.phases().len());
}
CliOutputFormat::Json => {
let _ = print_json_value(&json!({
"kind": "dump-manifests",
"commands": manifest.commands.entries().len(),
"tools": manifest.tools.entries().len(),
"bootstrap_phases": manifest.bootstrap.phases().len(),
}));
}
},
Err(error) => {
eprintln!("failed to extract manifests: {error}");
match output_format {
CliOutputFormat::Text => eprintln!("failed to extract manifests: {error}"),
CliOutputFormat::Json => {
let _ = print_json_value(&json_error_payload("dump-manifests", &error));
}
}
std::process::exit(1);
}
}
}
fn print_bootstrap_plan() {
for phase in runtime::BootstrapPlan::claude_code_default().phases() {
println!("- {phase:?}");
fn print_bootstrap_plan(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let phases = runtime::BootstrapPlan::claude_code_default()
.phases()
.iter()
.map(|phase| format!("{phase:?}"))
.collect::<Vec<_>>();
match output_format {
CliOutputFormat::Text => {
for phase in &phases {
println!("- {phase}");
}
Ok(())
}
CliOutputFormat::Json => print_json_value(&json!({
"kind": "bootstrap-plan",
"phases": phases,
})),
}
}
@@ -812,7 +911,7 @@ fn default_oauth_config() -> OAuthConfig {
}
}
fn run_login() -> Result<(), Box<dyn std::error::Error>> {
fn run_login(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let config = ConfigLoader::default_for(&cwd).load()?;
let default_oauth = default_oauth_config();
@@ -825,11 +924,20 @@ fn run_login() -> Result<(), Box<dyn std::error::Error>> {
OAuthAuthorizationRequest::from_config(oauth, redirect_uri.clone(), state.clone(), &pkce)
.build_url();
println!("Starting Claude OAuth login...");
println!("Listening for callback on {redirect_uri}");
if matches!(output_format, CliOutputFormat::Text) {
println!("Starting Claude OAuth login...");
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}");
if matches!(output_format, CliOutputFormat::Text) {
eprintln!("warning: failed to open browser automatically: {error}");
println!("Open this URL manually:\n{authorize_url}");
} else {
return Err(io::Error::other(format!(
"failed to open browser automatically: {error}; authorization URL: {authorize_url}"
))
.into());
}
}
let callback = wait_for_oauth_callback(callback_port)?;
@@ -850,8 +958,13 @@ fn run_login() -> Result<(), Box<dyn std::error::Error>> {
}
let client = AnthropicClient::from_auth(AuthSource::None).with_base_url(api::read_base_url());
let exchange_request =
OAuthTokenExchangeRequest::from_config(oauth, code, state, pkce.verifier, redirect_uri);
let exchange_request = OAuthTokenExchangeRequest::from_config(
oauth,
code,
state,
pkce.verifier,
redirect_uri.clone(),
);
let runtime = tokio::runtime::Runtime::new()?;
let token_set = runtime.block_on(client.exchange_oauth_code(oauth, &exchange_request))?;
save_oauth_credentials(&runtime::OAuthTokenSet {
@@ -860,14 +973,33 @@ fn run_login() -> Result<(), Box<dyn std::error::Error>> {
expires_at: token_set.expires_at,
scopes: token_set.scopes,
})?;
println!("Claude OAuth login complete.");
Ok(())
match output_format {
CliOutputFormat::Text => {
println!("Claude OAuth login complete.");
Ok(())
}
CliOutputFormat::Json => print_json_value(&json!({
"kind": "login",
"message": "Claude OAuth login complete.",
"authorize_url": authorize_url,
"redirect_uri": redirect_uri,
"callback_port": callback_port,
})),
}
}
fn run_logout() -> Result<(), Box<dyn std::error::Error>> {
fn run_logout(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
clear_oauth_credentials()?;
println!("Claude OAuth credentials cleared.");
Ok(())
match output_format {
CliOutputFormat::Text => {
println!("Claude OAuth credentials cleared.");
Ok(())
}
CliOutputFormat::Json => print_json_value(&json!({
"kind": "logout",
"message": "Claude OAuth credentials cleared.",
})),
}
}
fn open_browser(url: &str) -> io::Result<()> {
@@ -924,18 +1056,49 @@ fn wait_for_oauth_callback(
Ok(callback)
}
fn print_system_prompt(cwd: PathBuf, date: String) {
fn print_system_prompt(
cwd: PathBuf,
date: String,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match load_system_prompt(cwd, date, env::consts::OS, "unknown") {
Ok(sections) => println!("{}", sections.join("\n\n")),
Ok(sections) => match output_format {
CliOutputFormat::Text => {
println!("{}", sections.join("\n\n"));
Ok(())
}
CliOutputFormat::Json => print_json_value(&json!({
"kind": "system-prompt",
"message": sections.join("\n\n"),
})),
},
Err(error) => {
eprintln!("failed to build system prompt: {error}");
match output_format {
CliOutputFormat::Text => eprintln!("failed to build system prompt: {error}"),
CliOutputFormat::Json => {
print_json_value(&json_error_payload("system-prompt", &error))?;
}
}
std::process::exit(1);
}
}
}
fn print_version() {
println!("{}", render_version_report());
fn print_version(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match output_format {
CliOutputFormat::Text => {
println!("{}", render_version_report());
Ok(())
}
CliOutputFormat::Json => print_json_value(&json!({
"kind": "version",
"message": render_version_report(),
"version": VERSION,
"git_sha": GIT_SHA.unwrap_or("unknown"),
"target": BUILD_TARGET.unwrap_or("unknown"),
"build_date": DEFAULT_DATE,
})),
}
}
fn resume_session(session_path: &Path, commands: &[String], output_format: CliOutputFormat) {
@@ -1006,7 +1169,7 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
if let Some(message) = message {
match output_format {
CliOutputFormat::Text => {
println!("{}", render_resume_text_output(&message))
println!("{}", render_resume_text_output(&message));
}
CliOutputFormat::Json => json_outputs.push(message),
}
@@ -2467,7 +2630,7 @@ impl LiveCli {
(Some(action), Some(target)) => Some(format!("{action} {target}")),
(None, Some(target)) => Some(target.to_string()),
};
Self::print_mcp(args.as_deref())?;
Self::print_mcp(args.as_deref(), CliOutputFormat::Text)?;
false
}
SlashCommand::Memory => {
@@ -2475,7 +2638,7 @@ impl LiveCli {
false
}
SlashCommand::Init => {
run_init()?;
run_init(CliOutputFormat::Text)?;
false
}
SlashCommand::Diff => {
@@ -2483,7 +2646,7 @@ impl LiveCli {
false
}
SlashCommand::Version => {
Self::print_version();
Self::print_version()?;
false
}
SlashCommand::Export { path } => {
@@ -2497,11 +2660,11 @@ impl LiveCli {
self.handle_plugins_command(action.as_deref(), target.as_deref())?
}
SlashCommand::Agents { args } => {
Self::print_agents(args.as_deref())?;
Self::print_agents(args.as_deref(), CliOutputFormat::Text)?;
false
}
SlashCommand::Skills { args } => {
Self::print_skills(args.as_deref())?;
Self::print_skills(args.as_deref(), CliOutputFormat::Text)?;
false
}
SlashCommand::Doctor
@@ -2776,22 +2939,61 @@ impl LiveCli {
Ok(())
}
fn print_agents(args: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
fn print_agents(
args: Option<&str>,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
println!("{}", handle_agents_slash_command(args, &cwd)?);
Ok(())
let message = handle_agents_slash_command(args, &cwd)?;
match output_format {
CliOutputFormat::Text => {
println!("{message}");
Ok(())
}
CliOutputFormat::Json => print_json_value(&json!({
"kind": "agents",
"message": message,
"args": args,
})),
}
}
fn print_mcp(args: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
fn print_mcp(
args: Option<&str>,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
println!("{}", handle_mcp_slash_command(args, &cwd)?);
Ok(())
let message = handle_mcp_slash_command(args, &cwd)?;
match output_format {
CliOutputFormat::Text => {
println!("{message}");
Ok(())
}
CliOutputFormat::Json => print_json_value(&json!({
"kind": "mcp",
"message": message,
"args": args,
})),
}
}
fn print_skills(args: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
fn print_skills(
args: Option<&str>,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
println!("{}", handle_skills_slash_command(args, &cwd)?);
Ok(())
let message = handle_skills_slash_command(args, &cwd)?;
match output_format {
CliOutputFormat::Text => {
println!("{message}");
Ok(())
}
CliOutputFormat::Json => print_json_value(&json!({
"kind": "skills",
"message": message,
"args": args,
})),
}
}
fn print_diff() -> Result<(), Box<dyn std::error::Error>> {
@@ -2799,8 +3001,8 @@ impl LiveCli {
Ok(())
}
fn print_version() {
println!("{}", render_version_report());
fn print_version() -> Result<(), Box<dyn std::error::Error>> {
crate::print_version(CliOutputFormat::Text)
}
fn export_session(
@@ -3709,9 +3911,18 @@ fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
Ok(initialize_repo(&cwd)?.render())
}
fn run_init() -> Result<(), Box<dyn std::error::Error>> {
println!("{}", init_claude_md()?);
Ok(())
fn run_init(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let init_report = init_claude_md()?;
match output_format {
CliOutputFormat::Text => {
println!("{init_report}");
Ok(())
}
CliOutputFormat::Json => print_json_value(&json!({
"kind": "init",
"message": init_report,
})),
}
}
fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
@@ -5846,8 +6057,17 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
Ok(())
}
fn print_help() {
let _ = print_help_to(&mut io::stdout());
fn print_help(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match output_format {
CliOutputFormat::Text => {
print_help_to(&mut io::stdout())?;
Ok(())
}
CliOutputFormat::Json => print_json_value(&json!({
"kind": "help",
"message": render_help_report()?,
})),
}
}
#[cfg(test)]
@@ -6156,11 +6376,15 @@ mod tests {
fn parses_version_flags_without_initializing_prompt_mode() {
assert_eq!(
parse_args(&["--version".to_string()]).expect("args should parse"),
CliAction::Version
CliAction::Version {
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["-V".to_string()]).expect("args should parse"),
CliAction::Version
CliAction::Version {
output_format: CliOutputFormat::Text,
}
);
}
@@ -6222,6 +6446,7 @@ mod tests {
CliAction::PrintSystemPrompt {
cwd: PathBuf::from("/tmp/project"),
date: "2026-04-01".to_string(),
output_format: CliOutputFormat::Text,
}
);
}
@@ -6230,33 +6455,49 @@ mod tests {
fn parses_login_and_logout_subcommands() {
assert_eq!(
parse_args(&["login".to_string()]).expect("login should parse"),
CliAction::Login
CliAction::Login {
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["logout".to_string()]).expect("logout should parse"),
CliAction::Logout
CliAction::Logout {
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["init".to_string()]).expect("init should parse"),
CliAction::Init
CliAction::Init {
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["agents".to_string()]).expect("agents should parse"),
CliAction::Agents { args: None }
CliAction::Agents {
args: None,
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["mcp".to_string()]).expect("mcp should parse"),
CliAction::Mcp { args: None }
CliAction::Mcp {
args: None,
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["skills".to_string()]).expect("skills should parse"),
CliAction::Skills { args: None }
CliAction::Skills {
args: None,
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["agents".to_string(), "--help".to_string()])
.expect("agents help should parse"),
CliAction::Agents {
args: Some("--help".to_string())
args: Some("--help".to_string()),
output_format: CliOutputFormat::Text,
}
);
}
@@ -6267,11 +6508,15 @@ mod tests {
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
assert_eq!(
parse_args(&["help".to_string()]).expect("help should parse"),
CliAction::Help
CliAction::Help {
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["version".to_string()]).expect("version should parse"),
CliAction::Version
CliAction::Version {
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["status".to_string()]).expect("status should parse"),
@@ -6339,24 +6584,32 @@ mod tests {
fn parses_direct_agents_mcp_and_skills_slash_commands() {
assert_eq!(
parse_args(&["/agents".to_string()]).expect("/agents should parse"),
CliAction::Agents { args: None }
CliAction::Agents {
args: None,
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["/mcp".to_string(), "show".to_string(), "demo".to_string()])
.expect("/mcp show demo should parse"),
CliAction::Mcp {
args: Some("show demo".to_string())
args: Some("show demo".to_string()),
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["/skills".to_string()]).expect("/skills should parse"),
CliAction::Skills { args: None }
CliAction::Skills {
args: None,
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["/skills".to_string(), "help".to_string()])
.expect("/skills help should parse"),
CliAction::Skills {
args: Some("help".to_string())
args: Some("help".to_string()),
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
@@ -6367,7 +6620,8 @@ mod tests {
])
.expect("/skills install should parse"),
CliAction::Skills {
args: Some("install ./fixtures/help-skill".to_string())
args: Some("install ./fixtures/help-skill".to_string()),
output_format: CliOutputFormat::Text,
}
);
let error = parse_args(&["/status".to_string()])

View File

@@ -5,7 +5,6 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use runtime::Session;
use serde_json::Value;
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -38,64 +37,6 @@ fn status_command_applies_model_and_permission_mode_flags() {
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
}
#[test]
fn status_command_emits_structured_json_when_requested() {
// given
let temp_dir = unique_temp_dir("status-json");
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
// when
let output = Command::new(env!("CARGO_BIN_EXE_claw"))
.current_dir(&temp_dir)
.args([
"--model",
"sonnet",
"--permission-mode",
"read-only",
"--output-format",
"json",
"status",
])
.output()
.expect("claw should launch");
// then
assert_success(&output);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
let parsed: Value = serde_json::from_str(stdout.trim()).expect("status output should be json");
assert_eq!(parsed["kind"], "status");
assert_eq!(parsed["model"], "claude-sonnet-4-6");
assert_eq!(parsed["permission_mode"], "read-only");
assert_eq!(parsed["workspace"]["session"], "live-repl");
assert!(parsed["sandbox"].is_object());
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
}
#[test]
fn sandbox_command_emits_structured_json_when_requested() {
// given
let temp_dir = unique_temp_dir("sandbox-json");
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
// when
let output = Command::new(env!("CARGO_BIN_EXE_claw"))
.current_dir(&temp_dir)
.args(["--output-format", "json", "sandbox"])
.output()
.expect("claw should launch");
// then
assert_success(&output);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
let parsed: Value = serde_json::from_str(stdout.trim()).expect("sandbox output should be json");
assert_eq!(parsed["kind"], "sandbox");
assert!(parsed["sandbox"].is_object());
assert!(parsed["sandbox"]["requested"].is_object());
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
}
#[test]
fn resume_flag_loads_a_saved_session_and_dispatches_status() {
// given
@@ -219,6 +160,42 @@ fn config_command_loads_defaults_from_standard_config_locations() {
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
}
#[test]
fn nested_help_flags_render_usage_instead_of_falling_through() {
let temp_dir = unique_temp_dir("nested-help");
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
let mcp_output = command_in(&temp_dir)
.args(["mcp", "show", "--help"])
.output()
.expect("claw should launch");
assert_success(&mcp_output);
let mcp_stdout = String::from_utf8(mcp_output.stdout).expect("stdout should be utf8");
assert!(mcp_stdout.contains("Usage /mcp [list|show <server>|help]"));
assert!(mcp_stdout.contains("Unexpected show"));
assert!(!mcp_stdout.contains("server `--help` is not configured"));
let skills_output = command_in(&temp_dir)
.args(["skills", "install", "--help"])
.output()
.expect("claw should launch");
assert_success(&skills_output);
let skills_stdout = String::from_utf8(skills_output.stdout).expect("stdout should be utf8");
assert!(skills_stdout.contains("Usage /skills [list|install <path>|help]"));
assert!(skills_stdout.contains("Unexpected install"));
let unknown_output = command_in(&temp_dir)
.args(["mcp", "inspect", "--help"])
.output()
.expect("claw should launch");
assert_success(&unknown_output);
let unknown_stdout = String::from_utf8(unknown_output.stdout).expect("stdout should be utf8");
assert!(unknown_stdout.contains("Usage /mcp [list|show <server>|help]"));
assert!(unknown_stdout.contains("Unexpected inspect"));
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
}
fn command_in(cwd: &Path) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
command.current_dir(cwd);

View File

@@ -0,0 +1,449 @@
use std::fs;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
use mock_anthropic_service::{MockAnthropicService, SCENARIO_PREFIX};
use serde_json::{json, Value};
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
#[test]
fn help_emits_json_when_requested() {
let root = unique_temp_dir("help-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let envs = isolated_env(&root);
let parsed = assert_json_command(&root, &["--output-format", "json", "help"], &envs);
assert_eq!(parsed["kind"], "help");
assert!(parsed["message"]
.as_str()
.expect("help message")
.contains("Usage:"));
}
#[test]
fn version_emits_json_when_requested() {
let root = unique_temp_dir("version-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let envs = isolated_env(&root);
let parsed = assert_json_command(&root, &["--output-format", "json", "version"], &envs);
assert_eq!(parsed["kind"], "version");
assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
}
#[test]
fn status_emits_json_when_requested() {
let root = unique_temp_dir("status-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let envs = isolated_env(&root);
let parsed = assert_json_command(&root, &["--output-format", "json", "status"], &envs);
assert_eq!(parsed["kind"], "status");
assert!(parsed["workspace"]["cwd"].as_str().is_some());
}
#[test]
fn sandbox_emits_json_when_requested() {
let root = unique_temp_dir("sandbox-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let envs = isolated_env(&root);
let parsed = assert_json_command(&root, &["--output-format", "json", "sandbox"], &envs);
assert_eq!(parsed["kind"], "sandbox");
assert!(parsed["sandbox"].is_object());
}
#[test]
fn dump_manifests_emits_json_when_requested() {
let root = unique_temp_dir("dump-manifests-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let upstream = write_upstream_fixture(&root);
let mut envs = isolated_env(&root);
envs.push((
"CLAUDE_CODE_UPSTREAM".to_string(),
upstream.display().to_string(),
));
let parsed = assert_json_command(&root, &["--output-format", "json", "dump-manifests"], &envs);
assert_eq!(parsed["kind"], "dump-manifests");
assert_eq!(parsed["commands"], 1);
assert_eq!(parsed["tools"], 1);
}
#[test]
fn bootstrap_plan_emits_json_when_requested() {
let root = unique_temp_dir("bootstrap-plan-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let envs = isolated_env(&root);
let parsed = assert_json_command(&root, &["--output-format", "json", "bootstrap-plan"], &envs);
assert_eq!(parsed["kind"], "bootstrap-plan");
assert!(parsed["phases"].as_array().expect("phases array").len() > 1);
}
#[test]
fn agents_emits_json_when_requested() {
let root = unique_temp_dir("agents-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let envs = isolated_env(&root);
let parsed = assert_json_command(&root, &["--output-format", "json", "agents"], &envs);
assert_eq!(parsed["kind"], "agents");
assert!(!parsed["message"].as_str().expect("agents text").is_empty());
}
#[test]
fn mcp_emits_json_when_requested() {
let root = unique_temp_dir("mcp-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let envs = isolated_env(&root);
let parsed = assert_json_command(&root, &["--output-format", "json", "mcp"], &envs);
assert_eq!(parsed["kind"], "mcp");
assert!(parsed["message"]
.as_str()
.expect("mcp text")
.contains("MCP"));
}
#[test]
fn skills_emits_json_when_requested() {
let root = unique_temp_dir("skills-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let envs = isolated_env(&root);
let parsed = assert_json_command(&root, &["--output-format", "json", "skills"], &envs);
assert_eq!(parsed["kind"], "skills");
assert!(!parsed["message"].as_str().expect("skills text").is_empty());
}
#[test]
fn system_prompt_emits_json_when_requested() {
let root = unique_temp_dir("system-prompt-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let envs = isolated_env(&root);
let parsed = assert_json_command(&root, &["--output-format", "json", "system-prompt"], &envs);
assert_eq!(parsed["kind"], "system-prompt");
assert!(parsed["message"]
.as_str()
.expect("system prompt text")
.contains("You are an interactive agent"));
}
#[test]
fn login_emits_json_when_requested() {
let root = unique_temp_dir("login-json");
let workspace = root.join("workspace");
fs::create_dir_all(&workspace).expect("workspace should exist");
let mut envs = isolated_env(&root);
let callback_port = reserve_port();
let token_port = reserve_port();
fs::create_dir_all(workspace.join(".claw")).expect("config dir should exist");
fs::write(
workspace.join(".claw").join("settings.json"),
json!({
"oauth": {
"clientId": "test-client",
"authorizeUrl": format!("http://127.0.0.1:{token_port}/authorize"),
"tokenUrl": format!("http://127.0.0.1:{token_port}/token"),
"callbackPort": callback_port,
"scopes": ["user:test"]
}
})
.to_string(),
)
.expect("oauth config should write");
let token_server = thread::spawn(move || {
let listener = TcpListener::bind(("127.0.0.1", token_port)).expect("token server bind");
let (mut stream, _) = listener.accept().expect("token request");
let mut request = [0_u8; 4096];
let _ = stream
.read(&mut request)
.expect("token request should read");
let body = json!({
"access_token": "test-access-token",
"refresh_token": "test-refresh-token",
"expires_at": 9_999_999_999_u64,
"scopes": ["user:test"]
})
.to_string();
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.expect("token response should write");
});
let bin_dir = root.join("bin");
fs::create_dir_all(&bin_dir).expect("bin dir should exist");
let opener_path = bin_dir.join("xdg-open");
fs::write(
&opener_path,
format!(
"#!/usr/bin/env python3\nimport http.client\nimport sys\nimport urllib.parse\nurl = sys.argv[1]\nquery = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)\nstate = query['state'][0]\nconn = http.client.HTTPConnection('127.0.0.1', {callback_port}, timeout=5)\nconn.request('GET', f\"/callback?code=test-code&state={{urllib.parse.quote(state)}}\")\nresp = conn.getresponse()\nresp.read()\nconn.close()\n"
),
)
.expect("xdg-open wrapper should write");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = fs::metadata(&opener_path)
.expect("wrapper metadata")
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&opener_path, permissions).expect("wrapper permissions");
}
let original_path = envs
.iter()
.find(|(key, _)| key == "PATH")
.map(|(_, value)| value.clone())
.unwrap_or_default();
for (key, value) in &mut envs {
if key == "PATH" {
*value = format!("{}:{original_path}", bin_dir.display());
}
}
let parsed = assert_json_command(&workspace, &["--output-format", "json", "login"], &envs);
token_server.join().expect("token server should finish");
assert_eq!(parsed["kind"], "login");
assert_eq!(parsed["callback_port"], callback_port);
}
#[test]
fn logout_emits_json_when_requested() {
let root = unique_temp_dir("logout-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let envs = isolated_env(&root);
let parsed = assert_json_command(&root, &["--output-format", "json", "logout"], &envs);
assert_eq!(parsed["kind"], "logout");
assert!(parsed["message"]
.as_str()
.expect("logout text")
.contains("cleared"));
}
#[test]
fn init_emits_json_when_requested() {
let root = unique_temp_dir("init-json");
let workspace = root.join("workspace");
fs::create_dir_all(&workspace).expect("workspace should exist");
let envs = isolated_env(&root);
let parsed = assert_json_command(&workspace, &["--output-format", "json", "init"], &envs);
assert_eq!(parsed["kind"], "init");
assert!(workspace.join("CLAUDE.md").exists());
}
#[test]
fn prompt_subcommand_emits_json_when_requested() {
let root = unique_temp_dir("prompt-subcommand-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let mut envs = isolated_env(&root);
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
let server = runtime
.block_on(MockAnthropicService::spawn())
.expect("mock service should start");
envs.push(("ANTHROPIC_API_KEY".to_string(), "test-key".to_string()));
envs.push(("ANTHROPIC_BASE_URL".to_string(), server.base_url()));
let prompt = format!("{SCENARIO_PREFIX}streaming_text");
let args = vec![
"--model".to_string(),
"sonnet".to_string(),
"--permission-mode".to_string(),
"read-only".to_string(),
"--output-format".to_string(),
"json".to_string(),
"prompt".to_string(),
prompt,
];
let output = run_claw_with_env_owned(&root, &args, &envs);
let parsed = parse_json_stdout(&output);
assert_eq!(parsed["model"], "claude-sonnet-4-6");
assert!(parsed["message"]
.as_str()
.expect("assistant text")
.contains("streaming"));
}
#[test]
fn bare_prompt_mode_emits_json_when_requested() {
let root = unique_temp_dir("bare-prompt-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let mut envs = isolated_env(&root);
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
let server = runtime
.block_on(MockAnthropicService::spawn())
.expect("mock service should start");
envs.push(("ANTHROPIC_API_KEY".to_string(), "test-key".to_string()));
envs.push(("ANTHROPIC_BASE_URL".to_string(), server.base_url()));
let prompt = format!("{SCENARIO_PREFIX}streaming_text");
let args = vec![
"--model".to_string(),
"sonnet".to_string(),
"--permission-mode".to_string(),
"read-only".to_string(),
"--output-format".to_string(),
"json".to_string(),
prompt,
];
let output = run_claw_with_env_owned(&root, &args, &envs);
let parsed = parse_json_stdout(&output);
assert_eq!(parsed["model"], "claude-sonnet-4-6");
assert!(parsed["message"]
.as_str()
.expect("assistant text")
.contains("streaming"));
}
#[test]
fn resume_restore_emits_json_when_requested() {
let root = unique_temp_dir("resume-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let envs = isolated_env(&root);
let session_path = root.join("session.jsonl");
fs::write(
&session_path,
"{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n{\"type\":\"message\",\"message\":{\"role\":\"user\",\"blocks\":[{\"type\":\"text\",\"text\":\"hello\"}]}}\n",
)
.expect("session should write");
let args = vec![
"--output-format".to_string(),
"json".to_string(),
"--resume".to_string(),
session_path.display().to_string(),
];
let output = run_claw_with_env_owned(&root, &args, &envs);
let parsed = parse_json_stdout(&output);
assert_eq!(parsed["kind"], "resume");
assert_eq!(parsed["messages"], 1);
}
fn assert_json_command(current_dir: &Path, args: &[&str], envs: &[(String, String)]) -> Value {
let output = run_claw_with_env(current_dir, args, envs);
parse_json_stdout(&output)
}
fn parse_json_stdout(output: &Output) -> Value {
assert!(
output.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice(&output.stdout).expect("stdout should be json")
}
fn run_claw_with_env(current_dir: &Path, args: &[&str], envs: &[(String, String)]) -> Output {
let owned_args = args
.iter()
.map(|value| (*value).to_string())
.collect::<Vec<_>>();
run_claw_with_env_owned(current_dir, &owned_args, envs)
}
fn run_claw_with_env_owned(
current_dir: &Path,
args: &[String],
envs: &[(String, String)],
) -> Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
command.current_dir(current_dir).args(args).env_clear();
for (key, value) in envs {
command.env(key, value);
}
command.output().expect("claw should launch")
}
fn isolated_env(root: &Path) -> Vec<(String, String)> {
let config_home = root.join("config-home");
let home = root.join("home");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
vec![
(
"CLAW_CONFIG_HOME".to_string(),
config_home.display().to_string(),
),
("HOME".to_string(), home.display().to_string()),
(
"PATH".to_string(),
std::env::var("PATH").unwrap_or_else(|_| "/usr/bin:/bin".to_string()),
),
("NO_COLOR".to_string(), "1".to_string()),
]
}
fn write_upstream_fixture(root: &Path) -> PathBuf {
let upstream = root.join("claw-code");
let src = upstream.join("src");
let entrypoints = src.join("entrypoints");
fs::create_dir_all(&entrypoints).expect("upstream entrypoints dir should exist");
fs::write(
src.join("commands.ts"),
"import FooCommand from './commands/foo'\n",
)
.expect("commands fixture should write");
fs::write(
src.join("tools.ts"),
"import ReadTool from './tools/read'\n",
)
.expect("tools fixture should write");
fs::write(
entrypoints.join("cli.tsx"),
"if (args[0] === '--version') {}\nstartupProfiler()\n",
)
.expect("cli fixture should write");
upstream
}
fn reserve_port() -> u16 {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("ephemeral port should bind");
let port = listener.local_addr().expect("local addr").port();
drop(listener);
port
}
fn unique_temp_dir(label: &str) -> PathBuf {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be after epoch")
.as_millis();
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"claw-output-format-{label}-{}-{millis}-{counter}",
std::process::id()
))
}

View File

@@ -16,7 +16,7 @@ use runtime::{
use crate::AgentOutput;
/// Detects if a lane should be automatically marked as completed.
///
///
/// Returns `Some(LaneContext)` with `completed = true` if all conditions met,
/// `None` if lane should remain active.
#[allow(dead_code)]
@@ -29,29 +29,29 @@ pub(crate) fn detect_lane_completion(
if output.error.is_some() {
return None;
}
// Must have finished status
if !output.status.eq_ignore_ascii_case("completed")
&& !output.status.eq_ignore_ascii_case("finished")
{
return None;
}
// Must have no current blocker
if output.current_blocker.is_some() {
return None;
}
// Must have green tests
if !test_green {
return None;
}
// Must have pushed code
if !has_pushed {
return None;
}
// All conditions met — create completed context
Some(LaneContext {
lane_id: output.agent_id.clone(),
@@ -67,9 +67,7 @@ pub(crate) fn detect_lane_completion(
/// Evaluates policy actions for a completed lane.
#[allow(dead_code)]
pub(crate) fn evaluate_completed_lane(
context: &LaneContext,
) -> Vec<PolicyAction> {
pub(crate) fn evaluate_completed_lane(context: &LaneContext) -> Vec<PolicyAction> {
let engine = PolicyEngine::new(vec![
PolicyRule::new(
"closeout-completed-lane",
@@ -87,7 +85,7 @@ pub(crate) fn evaluate_completed_lane(
5,
),
]);
evaluate(&engine, context)
}
@@ -114,53 +112,53 @@ mod tests {
error: None,
}
}
#[test]
fn detects_completion_when_all_conditions_met() {
let output = test_output();
let result = detect_lane_completion(&output, true, true);
assert!(result.is_some());
let context = result.unwrap();
assert!(context.completed);
assert_eq!(context.green_level, 3);
assert_eq!(context.blocker, LaneBlocker::None);
}
#[test]
fn no_completion_when_error_present() {
let mut output = test_output();
output.error = Some("Build failed".to_string());
let result = detect_lane_completion(&output, true, true);
assert!(result.is_none());
}
#[test]
fn no_completion_when_not_finished() {
let mut output = test_output();
output.status = "Running".to_string();
let result = detect_lane_completion(&output, true, true);
assert!(result.is_none());
}
#[test]
fn no_completion_when_tests_not_green() {
let output = test_output();
let result = detect_lane_completion(&output, false, true);
assert!(result.is_none());
}
#[test]
fn no_completion_when_not_pushed() {
let output = test_output();
let result = detect_lane_completion(&output, true, false);
assert!(result.is_none());
}
#[test]
fn evaluate_triggers_closeout_for_completed_lane() {
let context = LaneContext {
@@ -173,9 +171,9 @@ mod tests {
completed: true,
reconciled: false,
};
let actions = evaluate_completed_lane(&context);
assert!(actions.contains(&PolicyAction::CloseoutLane));
assert!(actions.contains(&PolicyAction::CleanupSession));
}

View File

@@ -17,7 +17,6 @@ use runtime::{
permission_enforcer::{EnforcementResult, PermissionEnforcer},
read_file,
summary_compression::compress_summary_text,
TaskPacket,
task_registry::TaskRegistry,
team_cron_registry::{CronRegistry, TeamRegistry},
worker_boot::{WorkerReadySnapshot, WorkerRegistry},
@@ -25,7 +24,7 @@ use runtime::{
BranchFreshness, ContentBlock, ConversationMessage, ConversationRuntime, GrepSearchInput,
LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass,
McpDegradedReport, MessageRole, PermissionMode, PermissionPolicy, PromptCacheEvent,
RuntimeError, Session, ToolError, ToolExecutor,
RuntimeError, Session, TaskPacket, ToolError, ToolExecutor,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
@@ -1878,27 +1877,25 @@ fn branch_divergence_output(
dangerously_disable_sandbox: None,
return_code_interpretation: Some("preflight_blocked:branch_divergence".to_string()),
no_output_expected: Some(false),
structured_content: Some(vec![
serde_json::to_value(
LaneEvent::new(
LaneEventName::BranchStaleAgainstMain,
LaneEventStatus::Blocked,
iso8601_now(),
)
.with_failure_class(LaneFailureClass::BranchDivergence)
.with_detail(stderr.clone())
.with_data(json!({
"branch": branch,
"mainRef": main_ref,
"commitsBehind": commits_behind,
"commitsAhead": commits_ahead,
"missingCommits": missing_fixes,
"blockedCommand": command,
"recommendedAction": format!("merge or rebase {main_ref} before workspace tests")
})),
structured_content: Some(vec![serde_json::to_value(
LaneEvent::new(
LaneEventName::BranchStaleAgainstMain,
LaneEventStatus::Blocked,
iso8601_now(),
)
.expect("lane event should serialize"),
]),
.with_failure_class(LaneFailureClass::BranchDivergence)
.with_detail(stderr.clone())
.with_data(json!({
"branch": branch,
"mainRef": main_ref,
"commitsBehind": commits_behind,
"commitsAhead": commits_ahead,
"missingCommits": missing_fixes,
"blockedCommand": command,
"recommendedAction": format!("merge or rebase {main_ref} before workspace tests")
})),
)
.expect("lane event should serialize")]),
persisted_output_path: None,
persisted_output_size: None,
sandbox_status: None,
@@ -3297,12 +3294,12 @@ fn persist_agent_terminal_state(
next_manifest.current_blocker = blocker.clone();
next_manifest.error = error;
if let Some(blocker) = blocker {
next_manifest.lane_events.push(
LaneEvent::blocked(iso8601_now(), &blocker),
);
next_manifest.lane_events.push(
LaneEvent::failed(iso8601_now(), &blocker),
);
next_manifest
.lane_events
.push(LaneEvent::blocked(iso8601_now(), &blocker));
next_manifest
.lane_events
.push(LaneEvent::failed(iso8601_now(), &blocker));
} else {
next_manifest.current_blocker = None;
let compressed_detail = result
@@ -4952,8 +4949,8 @@ mod tests {
agent_permission_policy, allowed_tools_for_subagent, classify_lane_failure,
execute_agent_with_spawn, execute_tool, final_assistant_text, mvp_tool_specs,
permission_mode_from_plugin, persist_agent_terminal_state, push_output_block,
run_task_packet, AgentInput, AgentJob, GlobalToolRegistry, LaneEventName,
LaneFailureClass, SubagentToolExecutor,
run_task_packet, AgentInput, AgentJob, GlobalToolRegistry, LaneEventName, LaneFailureClass,
SubagentToolExecutor,
};
use api::OutputContentBlock;
use runtime::{
@@ -5977,7 +5974,10 @@ mod tests {
"gateway routing rejected the request",
LaneFailureClass::GatewayRouting,
),
("tool failed: denied tool execution from hook", LaneFailureClass::ToolRuntime),
(
"tool failed: denied tool execution from hook",
LaneFailureClass::ToolRuntime,
),
("thread creation failed", LaneFailureClass::Infra),
];
@@ -6000,11 +6000,17 @@ mod tests {
(LaneEventName::MergeReady, "lane.merge.ready"),
(LaneEventName::Finished, "lane.finished"),
(LaneEventName::Failed, "lane.failed"),
(LaneEventName::BranchStaleAgainstMain, "branch.stale_against_main"),
(
LaneEventName::BranchStaleAgainstMain,
"branch.stale_against_main",
),
];
for (event, expected) in cases {
assert_eq!(serde_json::to_value(event).expect("serialize lane event"), json!(expected));
assert_eq!(
serde_json::to_value(event).expect("serialize lane event"),
json!(expected)
);
}
}