Compare commits

...

2 Commits

Author SHA1 Message Date
Yeachan-Heo
cc8da08ad3 Restore Rust formatting compliance for origin/main
Run cargo fmt across the Rust workspace after rebasing onto origin/main so the branch matches rustfmt's current output and the CI formatter gate can pass again. This commit intentionally contains formatter output only and keeps it separate from the README brand refresh.

Constraint: origin/main was failing cargo fmt --all --check
Rejected: Manually format only the initially reported files | cargo fmt --all updated additional workspace files that are part of the formatter gate
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Do not mix behavior edits into formatter-only commits
Tested: cargo fmt --all --check
Tested: cargo test --all
Tested: git diff --check
2026-04-23 00:24:18 +00:00
Yeachan-Heo
e43c6b81db Celebrate the 188K-star milestone in the project brand surface
Refresh the root README hero so the repo acknowledges the 188K GitHub star milestone without turning the page into a campaign landing page. The change keeps the existing structure, sharpens the positioning copy, and adds a compact milestone section that fits the current documentation-first surface.

Constraint: Keep the redesign confined to the repo's primary marketing/docs entrypoint
Constraint: Avoid new assets, dependencies, or multi-page brand churn
Rejected: Broader multi-file docs refresh | exceeded the requested reviewable scope
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep milestone copy lightweight so future README updates can age it out without restructuring the page
Tested: git diff --check
Not-tested: Rendered GitHub HTML preview outside local markdown diff
2026-04-23 00:24:09 +00:00
9 changed files with 219 additions and 112 deletions

View File

@@ -1,5 +1,13 @@
# Claw Code # Claw Code
<p align="center">
<strong>188K GitHub stars and climbing.</strong>
</p>
<p align="center">
<strong>Rust-native agent execution for people who want speed, control, and a real terminal.</strong>
</p>
<p align="center"> <p align="center">
<a href="https://github.com/ultraworkers/claw-code">ultraworkers/claw-code</a> <a href="https://github.com/ultraworkers/claw-code">ultraworkers/claw-code</a>
· ·
@@ -28,8 +36,21 @@
<img src="assets/claw-hero.jpeg" alt="Claw Code" width="300" /> <img src="assets/claw-hero.jpeg" alt="Claw Code" width="300" />
</p> </p>
Claw Code is the public Rust implementation of the `claw` CLI agent harness. <p align="center">
The canonical implementation lives in [`rust/`](./rust), and the current source of truth for this repository is **ultraworkers/claw-code**. Claw Code just crossed <strong>188,000 GitHub stars</strong>. This repo is the public Rust implementation of the <code>claw</code> CLI agent harness, built in the open with the UltraWorkers community.
</p>
<p align="center">
The canonical implementation lives in <a href="./rust/">rust/</a>, and the current source of truth for this repository is <strong>ultraworkers/claw-code</strong>.
</p>
## 188K and climbing
Thanks to everyone who starred, tested, reviewed, and pushed the project forward. Claw Code is focused on a straightforward promise: a fast local-first CLI agent runtime with native tools, inspectable behavior, and a Rust workspace that stays close to the metal.
- Native Rust workspace and CLI binary under [`rust/`](./rust)
- Local-first workflows for prompts, sessions, tooling, and parity validation
- Open development across the broader UltraWorkers ecosystem
> [!IMPORTANT] > [!IMPORTANT]
> Start with [`USAGE.md`](./USAGE.md) for build, auth, CLI, session, and parity-harness workflows. Make `claw doctor` your first health check after building, use [`rust/README.md`](./rust/README.md) for crate-level details, read [`PARITY.md`](./PARITY.md) for the current Rust-port checkpoint, and see [`docs/container.md`](./docs/container.md) for the container-first workflow. > Start with [`USAGE.md`](./USAGE.md) for build, auth, CLI, session, and parity-harness workflows. Make `claw doctor` your first health check after building, use [`rust/README.md`](./rust/README.md) for crate-level details, read [`PARITY.md`](./PARITY.md) for the current Rust-port checkpoint, and see [`docs/container.md`](./docs/container.md) for the container-first workflow.

View File

@@ -753,14 +753,14 @@ mod tests {
#[test] #[test]
fn returns_context_window_metadata_for_kimi_models() { fn returns_context_window_metadata_for_kimi_models() {
// kimi-k2.5 // kimi-k2.5
let k25_limit = model_token_limit("kimi-k2.5") let k25_limit =
.expect("kimi-k2.5 should have token limit metadata"); model_token_limit("kimi-k2.5").expect("kimi-k2.5 should have token limit metadata");
assert_eq!(k25_limit.max_output_tokens, 16_384); assert_eq!(k25_limit.max_output_tokens, 16_384);
assert_eq!(k25_limit.context_window_tokens, 256_000); assert_eq!(k25_limit.context_window_tokens, 256_000);
// kimi-k1.5 // kimi-k1.5
let k15_limit = model_token_limit("kimi-k1.5") let k15_limit =
.expect("kimi-k1.5 should have token limit metadata"); model_token_limit("kimi-k1.5").expect("kimi-k1.5 should have token limit metadata");
assert_eq!(k15_limit.max_output_tokens, 16_384); assert_eq!(k15_limit.max_output_tokens, 16_384);
assert_eq!(k15_limit.context_window_tokens, 256_000); assert_eq!(k15_limit.context_window_tokens, 256_000);
} }
@@ -768,11 +768,13 @@ mod tests {
#[test] #[test]
fn kimi_alias_resolves_to_kimi_k25_token_limits() { fn kimi_alias_resolves_to_kimi_k25_token_limits() {
// The "kimi" alias resolves to "kimi-k2.5" via resolve_model_alias() // The "kimi" alias resolves to "kimi-k2.5" via resolve_model_alias()
let alias_limit = model_token_limit("kimi") let alias_limit =
.expect("kimi alias should resolve to kimi-k2.5 limits"); model_token_limit("kimi").expect("kimi alias should resolve to kimi-k2.5 limits");
let direct_limit = model_token_limit("kimi-k2.5") let direct_limit = model_token_limit("kimi-k2.5").expect("kimi-k2.5 should have limits");
.expect("kimi-k2.5 should have limits"); assert_eq!(
assert_eq!(alias_limit.max_output_tokens, direct_limit.max_output_tokens); alias_limit.max_output_tokens,
direct_limit.max_output_tokens
);
assert_eq!( assert_eq!(
alias_limit.context_window_tokens, alias_limit.context_window_tokens,
direct_limit.context_window_tokens direct_limit.context_window_tokens

View File

@@ -2195,9 +2195,16 @@ mod tests {
#[test] #[test]
fn provider_specific_size_limits_are_correct() { fn provider_specific_size_limits_are_correct() {
assert_eq!(OpenAiCompatConfig::dashscope().max_request_body_bytes, 6_291_456); // 6MB assert_eq!(
assert_eq!(OpenAiCompatConfig::openai().max_request_body_bytes, 104_857_600); // 100MB OpenAiCompatConfig::dashscope().max_request_body_bytes,
assert_eq!(OpenAiCompatConfig::xai().max_request_body_bytes, 52_428_800); // 50MB 6_291_456
); // 6MB
assert_eq!(
OpenAiCompatConfig::openai().max_request_body_bytes,
104_857_600
); // 100MB
assert_eq!(OpenAiCompatConfig::xai().max_request_body_bytes, 52_428_800);
// 50MB
} }
#[test] #[test]

View File

@@ -2623,10 +2623,8 @@ fn render_mcp_report_json_for(
// runs, the existing serializer adds `status: "ok"` below. // runs, the existing serializer adds `status: "ok"` below.
match loader.load() { match loader.load() {
Ok(runtime_config) => { Ok(runtime_config) => {
let mut value = render_mcp_summary_report_json( let mut value =
cwd, render_mcp_summary_report_json(cwd, runtime_config.mcp().servers());
runtime_config.mcp().servers(),
);
if let Some(map) = value.as_object_mut() { if let Some(map) = value.as_object_mut() {
map.insert("status".to_string(), Value::String("ok".to_string())); map.insert("status".to_string(), Value::String("ok".to_string()));
map.insert("config_load_error".to_string(), Value::Null); map.insert("config_load_error".to_string(), Value::Null);

View File

@@ -405,7 +405,10 @@ pub enum BlockedSubphase {
#[serde(rename = "blocked.branch_freshness")] #[serde(rename = "blocked.branch_freshness")]
BranchFreshness { behind_main: u32 }, BranchFreshness { behind_main: u32 },
#[serde(rename = "blocked.test_hang")] #[serde(rename = "blocked.test_hang")]
TestHang { elapsed_secs: u32, test_name: Option<String> }, TestHang {
elapsed_secs: u32,
test_name: Option<String>,
},
#[serde(rename = "blocked.report_pending")] #[serde(rename = "blocked.report_pending")]
ReportPending { since_secs: u32 }, ReportPending { since_secs: u32 },
} }
@@ -543,7 +546,8 @@ impl LaneEvent {
.with_failure_class(blocker.failure_class) .with_failure_class(blocker.failure_class)
.with_detail(blocker.detail.clone()); .with_detail(blocker.detail.clone());
if let Some(ref subphase) = blocker.subphase { if let Some(ref subphase) = blocker.subphase {
event = event.with_data(serde_json::to_value(subphase).expect("subphase should serialize")); event =
event.with_data(serde_json::to_value(subphase).expect("subphase should serialize"));
} }
event event
} }
@@ -554,7 +558,8 @@ impl LaneEvent {
.with_failure_class(blocker.failure_class) .with_failure_class(blocker.failure_class)
.with_detail(blocker.detail.clone()); .with_detail(blocker.detail.clone());
if let Some(ref subphase) = blocker.subphase { if let Some(ref subphase) = blocker.subphase {
event = event.with_data(serde_json::to_value(subphase).expect("subphase should serialize")); event =
event.with_data(serde_json::to_value(subphase).expect("subphase should serialize"));
} }
event event
} }
@@ -562,8 +567,12 @@ impl LaneEvent {
/// Ship prepared — §4.44.5 /// Ship prepared — §4.44.5
#[must_use] #[must_use]
pub fn ship_prepared(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self { pub fn ship_prepared(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
Self::new(LaneEventName::ShipPrepared, LaneEventStatus::Ready, emitted_at) Self::new(
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize")) LaneEventName::ShipPrepared,
LaneEventStatus::Ready,
emitted_at,
)
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
} }
/// Ship commits selected — §4.44.5 /// Ship commits selected — §4.44.5
@@ -573,22 +582,34 @@ impl LaneEvent {
commit_count: u32, commit_count: u32,
commit_range: impl Into<String>, commit_range: impl Into<String>,
) -> Self { ) -> Self {
Self::new(LaneEventName::ShipCommitsSelected, LaneEventStatus::Ready, emitted_at) Self::new(
.with_detail(format!("{} commits: {}", commit_count, commit_range.into())) LaneEventName::ShipCommitsSelected,
LaneEventStatus::Ready,
emitted_at,
)
.with_detail(format!("{} commits: {}", commit_count, commit_range.into()))
} }
/// Ship merged — §4.44.5 /// Ship merged — §4.44.5
#[must_use] #[must_use]
pub fn ship_merged(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self { pub fn ship_merged(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
Self::new(LaneEventName::ShipMerged, LaneEventStatus::Completed, emitted_at) Self::new(
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize")) LaneEventName::ShipMerged,
LaneEventStatus::Completed,
emitted_at,
)
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
} }
/// Ship pushed to main — §4.44.5 /// Ship pushed to main — §4.44.5
#[must_use] #[must_use]
pub fn ship_pushed_main(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self { pub fn ship_pushed_main(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
Self::new(LaneEventName::ShipPushedMain, LaneEventStatus::Completed, emitted_at) Self::new(
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize")) LaneEventName::ShipPushedMain,
LaneEventStatus::Completed,
emitted_at,
)
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
} }
#[must_use] #[must_use]

View File

@@ -58,8 +58,8 @@ impl SessionStore {
let workspace_root = workspace_root.as_ref(); let workspace_root = workspace_root.as_ref();
// #151: canonicalize workspace_root for consistent fingerprinting // #151: canonicalize workspace_root for consistent fingerprinting
// across equivalent path representations. // across equivalent path representations.
let canonical_workspace = fs::canonicalize(workspace_root) let canonical_workspace =
.unwrap_or_else(|_| workspace_root.to_path_buf()); fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
let sessions_root = data_dir let sessions_root = data_dir
.as_ref() .as_ref()
.join("sessions") .join("sessions")
@@ -158,10 +158,9 @@ impl SessionStore {
} }
pub fn latest_session(&self) -> Result<ManagedSessionSummary, SessionControlError> { pub fn latest_session(&self) -> Result<ManagedSessionSummary, SessionControlError> {
self.list_sessions()? self.list_sessions()?.into_iter().next().ok_or_else(|| {
.into_iter() SessionControlError::Format(format_no_managed_sessions(&self.sessions_root))
.next() })
.ok_or_else(|| SessionControlError::Format(format_no_managed_sessions(&self.sessions_root)))
} }
pub fn load_session( pub fn load_session(

View File

@@ -228,8 +228,10 @@ fn main() {
// don't need to regex-scrape the prose. // don't need to regex-scrape the prose.
let kind = classify_error_kind(&message); let kind = classify_error_kind(&message);
if message.contains("`claw --help`") { if message.contains("`claw --help`") {
eprintln!("[error-kind: {kind}] eprintln!(
error: {message}"); "[error-kind: {kind}]
error: {message}"
);
} else { } else {
eprintln!( eprintln!(
"[error-kind: {kind}] "[error-kind: {kind}]
@@ -372,7 +374,12 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
model_flag_raw, model_flag_raw,
permission_mode, permission_mode,
output_format, output_format,
} => print_status_snapshot(&model, model_flag_raw.as_deref(), permission_mode, output_format)?, } => print_status_snapshot(
&model,
model_flag_raw.as_deref(),
permission_mode,
output_format,
)?,
CliAction::Sandbox { output_format } => print_sandbox_status_snapshot(output_format)?, CliAction::Sandbox { output_format } => print_sandbox_status_snapshot(output_format)?,
CliAction::Prompt { CliAction::Prompt {
prompt, prompt,
@@ -412,19 +419,17 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
CliAction::Config { CliAction::Config {
section, section,
output_format, output_format,
} => { } => match output_format {
match output_format { CliOutputFormat::Text => {
CliOutputFormat::Text => { println!("{}", render_config_report(section.as_deref())?);
println!("{}", render_config_report(section.as_deref())?);
}
CliOutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(&render_config_json(section.as_deref())?)?
);
}
} }
} CliOutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(&render_config_json(section.as_deref())?)?
);
}
},
CliAction::Diff { output_format } => match output_format { CliAction::Diff { output_format } => match output_format {
CliOutputFormat::Text => { CliOutputFormat::Text => {
println!("{}", render_diff_report()?); println!("{}", render_diff_report()?);
@@ -627,13 +632,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
} }
"--help" | "-h" "--help" | "-h"
if !rest.is_empty() if !rest.is_empty()
&& matches!( && matches!(rest[0].as_str(), "prompt" | "commit" | "pr" | "issue") =>
rest[0].as_str(),
"prompt"
| "commit"
| "pr"
| "issue"
) =>
{ {
// `--help` following a subcommand that would otherwise forward // `--help` following a subcommand that would otherwise forward
// the arg to the API (e.g. `claw prompt --help`) should show // the arg to the API (e.g. `claw prompt --help`) should show
@@ -844,9 +843,13 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
if let Some(action) = parse_local_help_action(&rest) { if let Some(action) = parse_local_help_action(&rest) {
return action; return action;
} }
if let Some(action) = if let Some(action) = parse_single_word_command_alias(
parse_single_word_command_alias(&rest, &model, model_flag_raw.as_deref(), permission_mode_override, output_format) &rest,
{ &model,
model_flag_raw.as_deref(),
permission_mode_override,
output_format,
) {
return action; return action;
} }
@@ -1312,7 +1315,6 @@ fn suggest_closest_term<'a>(input: &str, candidates: &'a [&'a str]) -> Option<&'
ranked_suggestions(input, candidates).into_iter().next() ranked_suggestions(input, candidates).into_iter().next()
} }
fn suggest_similar_subcommand(input: &str) -> Option<Vec<String>> { fn suggest_similar_subcommand(input: &str) -> Option<Vec<String>> {
const KNOWN_SUBCOMMANDS: &[&str] = &[ const KNOWN_SUBCOMMANDS: &[&str] = &[
"help", "help",
@@ -1342,8 +1344,7 @@ fn suggest_similar_subcommand(input: &str) -> Option<Vec<String>> {
let prefix_match = common_prefix_len(&normalized_input, &normalized_candidate) >= 4; let prefix_match = common_prefix_len(&normalized_input, &normalized_candidate) >= 4;
let substring_match = normalized_candidate.contains(&normalized_input) let substring_match = normalized_candidate.contains(&normalized_input)
|| normalized_input.contains(&normalized_candidate); || normalized_input.contains(&normalized_candidate);
((distance <= 2) || prefix_match || substring_match) ((distance <= 2) || prefix_match || substring_match).then_some((distance, *candidate))
.then_some((distance, *candidate))
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1))); ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1)));
@@ -1363,7 +1364,6 @@ fn common_prefix_len(left: &str, right: &str) -> usize {
.count() .count()
} }
fn looks_like_subcommand_typo(input: &str) -> bool { fn looks_like_subcommand_typo(input: &str) -> bool {
!input.is_empty() !input.is_empty()
&& input && input
@@ -1472,13 +1472,11 @@ fn validate_model_syntax(model: &str) -> Result<(), String> {
err_msg.push_str("\nDid you mean `openai/"); err_msg.push_str("\nDid you mean `openai/");
err_msg.push_str(trimmed); err_msg.push_str(trimmed);
err_msg.push_str("`? (Requires OPENAI_API_KEY env var)"); err_msg.push_str("`? (Requires OPENAI_API_KEY env var)");
} } else if trimmed.starts_with("qwen") {
else if trimmed.starts_with("qwen") {
err_msg.push_str("\nDid you mean `qwen/"); err_msg.push_str("\nDid you mean `qwen/");
err_msg.push_str(trimmed); err_msg.push_str(trimmed);
err_msg.push_str("`? (Requires DASHSCOPE_API_KEY env var)"); err_msg.push_str("`? (Requires DASHSCOPE_API_KEY env var)");
} } else if trimmed.starts_with("grok") {
else if trimmed.starts_with("grok") {
err_msg.push_str("\nDid you mean `xai/"); err_msg.push_str("\nDid you mean `xai/");
err_msg.push_str(trimmed); err_msg.push_str(trimmed);
err_msg.push_str("`? (Requires XAI_API_KEY env var)"); err_msg.push_str("`? (Requires XAI_API_KEY env var)");
@@ -4322,7 +4320,6 @@ impl LiveCli {
Ok(()) Ok(())
} }
fn run_prompt_compact_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> { fn run_prompt_compact_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?; let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
@@ -5440,7 +5437,13 @@ fn print_status_snapshot(
match output_format { match output_format {
CliOutputFormat::Text => println!( CliOutputFormat::Text => println!(
"{}", "{}",
format_status_report(&provenance.resolved, usage, permission_mode.as_str(), &context, Some(&provenance)) format_status_report(
&provenance.resolved,
usage,
permission_mode.as_str(),
&context,
Some(&provenance)
)
), ),
CliOutputFormat::Json => println!( CliOutputFormat::Json => println!(
"{}", "{}",
@@ -9006,26 +9009,24 @@ fn print_help(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::
mod tests { mod tests {
use super::{ use super::{
build_runtime_plugin_state_with_loader, build_runtime_with_plugin_state, build_runtime_plugin_state_with_loader, build_runtime_with_plugin_state,
collect_session_prompt_history, create_managed_session_handle, describe_tool_progress, classify_error_kind, collect_session_prompt_history, create_managed_session_handle,
filter_tool_specs, format_bughunter_report, format_commit_preflight_report, describe_tool_progress, filter_tool_specs, format_bughunter_report,
format_commit_skipped_report, format_compact_report, format_connected_line, format_commit_preflight_report, format_commit_skipped_report, format_compact_report,
format_cost_report, format_history_timestamp, format_internal_prompt_progress_line, format_connected_line, format_cost_report, format_history_timestamp,
format_issue_report, format_model_report, format_model_switch_report, format_internal_prompt_progress_line, format_issue_report, format_model_report,
format_permissions_report, format_permissions_switch_report, format_pr_report, format_model_switch_report, format_permissions_report, format_permissions_switch_report,
format_resume_report, format_status_report, format_tool_call_start, format_tool_result, format_pr_report, format_resume_report, format_status_report, format_tool_call_start,
format_ultraplan_report, format_unknown_slash_command, format_tool_result, format_ultraplan_report, format_unknown_slash_command,
format_unknown_slash_command_message, format_user_visible_api_error, format_unknown_slash_command_message, format_user_visible_api_error,
classify_error_kind,
merge_prompt_with_stdin, normalize_permission_mode, parse_args, parse_export_args, merge_prompt_with_stdin, normalize_permission_mode, parse_args, parse_export_args,
parse_git_status_branch, parse_git_status_metadata_for, parse_git_workspace_summary, parse_git_status_branch, parse_git_status_metadata_for, parse_git_workspace_summary,
parse_history_count, permission_policy, print_help_to, push_output_block, parse_history_count, permission_policy, print_help_to, push_output_block,
render_config_report, render_diff_report, render_diff_report_for, render_memory_report, render_config_report, render_diff_report, render_diff_report_for, render_help_topic,
split_error_hint, render_memory_report, render_prompt_history_report, render_repl_help, render_resume_usage,
render_help_topic, render_prompt_history_report, render_repl_help, render_resume_usage,
render_session_markdown, resolve_model_alias, resolve_model_alias_with_config, render_session_markdown, resolve_model_alias, resolve_model_alias_with_config,
resolve_repl_model, resolve_session_reference, response_to_events, resolve_repl_model, resolve_session_reference, response_to_events,
resume_supported_slash_commands, run_resume_command, short_tool_id, resume_supported_slash_commands, run_resume_command, short_tool_id,
slash_command_completion_candidates_with_sessions, status_context, slash_command_completion_candidates_with_sessions, split_error_hint, status_context,
summarize_tool_payload_for_markdown, try_resolve_bare_skill_prompt, validate_no_args, summarize_tool_payload_for_markdown, try_resolve_bare_skill_prompt, validate_no_args,
write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary, write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary,
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic, InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic,
@@ -10006,8 +10007,8 @@ mod tests {
// with a specific error instead of falling through to the prompt // with a specific error instead of falling through to the prompt
// path (where they surface a misleading "missing Anthropic // path (where they surface a misleading "missing Anthropic
// credentials" error or burn API tokens on an empty prompt). // credentials" error or burn API tokens on an empty prompt).
let empty_err = parse_args(&["".to_string()]) let empty_err =
.expect_err("empty positional arg should be rejected"); parse_args(&["".to_string()]).expect_err("empty positional arg should be rejected");
assert!( assert!(
empty_err.starts_with("empty prompt:"), empty_err.starts_with("empty prompt:"),
"empty-arg error should be specific, got: {empty_err}" "empty-arg error should be specific, got: {empty_err}"
@@ -10224,7 +10225,8 @@ mod tests {
.expect("write malformed .claw.json"); .expect("write malformed .claw.json");
let context = with_current_dir(&cwd, || { let context = with_current_dir(&cwd, || {
super::status_context(None).expect("status_context should not hard-fail on config parse errors (#143)") super::status_context(None)
.expect("status_context should not hard-fail on config parse errors (#143)")
}); });
// Phase 1 contract: config_load_error is populated with the parse error. // Phase 1 contract: config_load_error is populated with the parse error.
@@ -10261,7 +10263,8 @@ mod tests {
cumulative: runtime::TokenUsage::default(), cumulative: runtime::TokenUsage::default(),
estimated_tokens: 0, estimated_tokens: 0,
}; };
let json = super::status_json_value(Some("test-model"), usage, "workspace-write", &context, None); let json =
super::status_json_value(Some("test-model"), usage, "workspace-write", &context, None);
assert_eq!( assert_eq!(
json.get("status").and_then(|v| v.as_str()), json.get("status").and_then(|v| v.as_str()),
Some("degraded"), Some("degraded"),
@@ -10278,8 +10281,14 @@ mod tests {
json.get("model").and_then(|v| v.as_str()), json.get("model").and_then(|v| v.as_str()),
Some("test-model") Some("test-model")
); );
assert!(json.get("workspace").is_some(), "workspace field still reported"); assert!(
assert!(json.get("sandbox").is_some(), "sandbox field still reported"); json.get("workspace").is_some(),
"workspace field still reported"
);
assert!(
json.get("sandbox").is_some(),
"sandbox field still reported"
);
// Clean path: no config error → status: "ok", config_load_error: null. // Clean path: no config error → status: "ok", config_load_error: null.
let clean_cwd = root.join("project-with-clean-config"); let clean_cwd = root.join("project-with-clean-config");
@@ -10288,8 +10297,13 @@ mod tests {
super::status_context(None).expect("clean status_context should succeed") super::status_context(None).expect("clean status_context should succeed")
}); });
assert!(clean_context.config_load_error.is_none()); assert!(clean_context.config_load_error.is_none());
let clean_json = let clean_json = super::status_json_value(
super::status_json_value(Some("test-model"), usage, "workspace-write", &clean_context, None); Some("test-model"),
usage,
"workspace-write",
&clean_context,
None,
);
assert_eq!( assert_eq!(
clean_json.get("status").and_then(|v| v.as_str()), clean_json.get("status").and_then(|v| v.as_str()),
Some("ok"), Some("ok"),
@@ -10388,11 +10402,18 @@ mod tests {
// Other unrecognized args should NOT trigger the --json hint. // Other unrecognized args should NOT trigger the --json hint.
let err_other = parse_args(&["doctor".to_string(), "garbage".to_string()]) let err_other = parse_args(&["doctor".to_string(), "garbage".to_string()])
.expect_err("`doctor garbage` should fail without --json hint"); .expect_err("`doctor garbage` should fail without --json hint");
assert!(!err_other.contains("--output-format json"), assert!(
"unrelated args should not trigger --json hint: {err_other}"); !err_other.contains("--output-format json"),
"unrelated args should not trigger --json hint: {err_other}"
);
// #154: model syntax error should hint at provider prefix when applicable // #154: model syntax error should hint at provider prefix when applicable
let err_gpt = parse_args(&["prompt".to_string(), "test".to_string(), "--model".to_string(), "gpt-4".to_string()]) let err_gpt = parse_args(&[
.expect_err("`--model gpt-4` should fail with OpenAI hint"); "prompt".to_string(),
"test".to_string(),
"--model".to_string(),
"gpt-4".to_string(),
])
.expect_err("`--model gpt-4` should fail with OpenAI hint");
assert!( assert!(
err_gpt.contains("Did you mean `openai/gpt-4`?"), err_gpt.contains("Did you mean `openai/gpt-4`?"),
"GPT model error should hint openai/ prefix: {err_gpt}" "GPT model error should hint openai/ prefix: {err_gpt}"
@@ -10401,8 +10422,13 @@ mod tests {
err_gpt.contains("OPENAI_API_KEY"), err_gpt.contains("OPENAI_API_KEY"),
"GPT model error should mention env var: {err_gpt}" "GPT model error should mention env var: {err_gpt}"
); );
let err_qwen = parse_args(&["prompt".to_string(), "test".to_string(), "--model".to_string(), "qwen-plus".to_string()]) let err_qwen = parse_args(&[
.expect_err("`--model qwen-plus` should fail with DashScope hint"); "prompt".to_string(),
"test".to_string(),
"--model".to_string(),
"qwen-plus".to_string(),
])
.expect_err("`--model qwen-plus` should fail with DashScope hint");
assert!( assert!(
err_qwen.contains("Did you mean `qwen/qwen-plus`?"), err_qwen.contains("Did you mean `qwen/qwen-plus`?"),
"Qwen model error should hint qwen/ prefix: {err_qwen}" "Qwen model error should hint qwen/ prefix: {err_qwen}"
@@ -10412,8 +10438,13 @@ mod tests {
"Qwen model error should mention env var: {err_qwen}" "Qwen model error should mention env var: {err_qwen}"
); );
// Unrelated invalid model should NOT get a hint // Unrelated invalid model should NOT get a hint
let err_garbage = parse_args(&["prompt".to_string(), "test".to_string(), "--model".to_string(), "asdfgh".to_string()]) let err_garbage = parse_args(&[
.expect_err("`--model asdfgh` should fail"); "prompt".to_string(),
"test".to_string(),
"--model".to_string(),
"asdfgh".to_string(),
])
.expect_err("`--model asdfgh` should fail");
assert!( assert!(
!err_garbage.contains("Did you mean"), !err_garbage.contains("Did you mean"),
"Unrelated model errors should not get a hint: {err_garbage}" "Unrelated model errors should not get a hint: {err_garbage}"
@@ -10423,15 +10454,42 @@ mod tests {
#[test] #[test]
fn classify_error_kind_returns_correct_discriminants() { fn classify_error_kind_returns_correct_discriminants() {
// #77: error kind classification for JSON error payloads // #77: error kind classification for JSON error payloads
assert_eq!(classify_error_kind("missing Anthropic credentials; export ..."), "missing_credentials"); assert_eq!(
assert_eq!(classify_error_kind("no worker state file found at /tmp/..."), "missing_worker_state"); classify_error_kind("missing Anthropic credentials; export ..."),
assert_eq!(classify_error_kind("session not found: abc123"), "session_not_found"); "missing_credentials"
assert_eq!(classify_error_kind("failed to restore session: no managed sessions found"), "session_load_failed"); );
assert_eq!(classify_error_kind("unrecognized argument `--foo` for subcommand `doctor`"), "cli_parse"); assert_eq!(
assert_eq!(classify_error_kind("invalid model syntax: 'gpt-4'. Expected ..."), "invalid_model_syntax"); classify_error_kind("no worker state file found at /tmp/..."),
assert_eq!(classify_error_kind("unsupported resumed command: /blargh"), "unsupported_resumed_command"); "missing_worker_state"
assert_eq!(classify_error_kind("api failed after 3 attempts: ..."), "api_http_error"); );
assert_eq!(classify_error_kind("something completely unknown"), "unknown"); assert_eq!(
classify_error_kind("session not found: abc123"),
"session_not_found"
);
assert_eq!(
classify_error_kind("failed to restore session: no managed sessions found"),
"session_load_failed"
);
assert_eq!(
classify_error_kind("unrecognized argument `--foo` for subcommand `doctor`"),
"cli_parse"
);
assert_eq!(
classify_error_kind("invalid model syntax: 'gpt-4'. Expected ..."),
"invalid_model_syntax"
);
assert_eq!(
classify_error_kind("unsupported resumed command: /blargh"),
"unsupported_resumed_command"
);
assert_eq!(
classify_error_kind("api failed after 3 attempts: ..."),
"api_http_error"
);
assert_eq!(
classify_error_kind("something completely unknown"),
"unknown"
);
} }
#[test] #[test]
@@ -10902,7 +10960,6 @@ mod tests {
assert!(report.contains("Use /help")); assert!(report.contains("Use /help"));
} }
#[test] #[test]
fn typoed_doctor_subcommand_returns_did_you_mean_error() { fn typoed_doctor_subcommand_returns_did_you_mean_error() {
let error = parse_args(&["doctorr".to_string()]).expect_err("doctorr should error"); let error = parse_args(&["doctorr".to_string()]).expect_err("doctorr should error");
@@ -10985,7 +11042,6 @@ mod tests {
); );
} }
#[test] #[test]
fn punctuation_bearing_single_token_still_dispatches_to_prompt() { fn punctuation_bearing_single_token_still_dispatches_to_prompt() {
// #140: Guard against test pollution — isolate cwd + env so this test // #140: Guard against test pollution — isolate cwd + env so this test

View File

@@ -172,7 +172,10 @@ stderr:
); );
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8"); let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
let parsed: Value = serde_json::from_str(&stdout).expect("compact json stdout should parse"); let parsed: Value = serde_json::from_str(&stdout).expect("compact json stdout should parse");
assert_eq!(parsed["message"], "Mock streaming says hello from the parity harness."); assert_eq!(
parsed["message"],
"Mock streaming says hello from the parity harness."
);
assert_eq!(parsed["compact"], true); assert_eq!(parsed["compact"], true);
assert_eq!(parsed["model"], "claude-sonnet-4-6"); assert_eq!(parsed["model"], "claude-sonnet-4-6");
assert!(parsed["usage"].is_object()); assert!(parsed["usage"].is_object());