Compare commits

..
10 Commits
Author SHA1 Message Date
Yeachan-Heo 84a0973f6c Clarify the resumed JSON parity audit record
Rust CI / docs source-of-truth (push) Has been cancelled
Rust CI / cargo fmt (push) Has been cancelled
Rust CI / cargo test --workspace (push) Has been cancelled
Rust CI / cargo clippy --workspace (push) Has been cancelled
The audit fix already landed, but the roadmap entry was split across two separate done items for /sandbox and inventory even though the underlying defect was one resumed-local-command JSON parity surface. Consolidating the note makes the machine-readable gap precise and keeps the backlog trail aligned with the actual fix scope.

Constraint: Preserve the existing issue ordering and backlog context around issues 23-24
Rejected: Leave the split entries as-is | obscures that one parity bug covered the same resumed JSON dispatch path
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Record future parity audits as one backlog item per underlying contract gap, not per individual command symptom
Tested: Existing green verification from HEAD remains applicable; docs-only wording update
Not-tested: No additional code-path verification required for this wording-only change
2026-04-06 02:00:33 +00:00
Yeachan-Heo fe4da2aa65 Keep resumed JSON command surfaces machine-readable
Resumed slash dispatch was still dropping back to prose for several JSON-capable local commands, which forced automation to special-case direct CLI invocations versus --resume flows. This routes resumed local-command handlers through the same structured JSON payloads used by direct status, sandbox, inventory, version, and init commands, and records the inventory parity audit result in the roadmap.

Constraint: Text-mode resumed output must stay unchanged for existing shell users
Rejected: Teach callers to scrape resumed text output | brittle and defeats the JSON contract
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: When a direct local command has a JSON renderer, keep resumed slash dispatch on the same serializer instead of adding one-off format branches
Tested: cargo fmt --check; cargo test --workspace; cargo clippy --workspace --all-targets -- -D warnings
Not-tested: Live provider-backed REPL resume flows outside the local test harness
2026-04-06 02:00:33 +00:00
Yeachan-Heo 53d6909b9b Emit structured doctor JSON diagnostics 2026-04-06 01:42:59 +00:00
Yeachan-Heo ceaf9cbc23 Preserve structured JSON parity for claw agents
`claw agents --output-format json` was still wrapping the text report,
which meant automation could not distinguish empty inventories from
populated agent definitions. Add a dedicated structured handler in the
commands crate, wire the CLI to it, and extend the contracts to cover
both empty and populated agent listings.

Constraint: Keep text-mode `claw agents` output unchanged while aligning JSON behavior with existing structured inventory handlers
Rejected: Parse the text report into JSON in the CLI layer | brittle duplication and no reusable structured handler
Confidence: high
Scope-risk: narrow
Directive: Keep inventory subcommands on dedicated structured handlers instead of serializing human-readable reports
Tested: cargo test -p commands renders_agents_reports_as_json; cargo test -p rusty-claude-cli --test output_format_contract; cargo test --workspace; cargo fmt --check; cargo clippy --workspace --all-targets -- -D warnings
Not-tested: Manual invocation of `claw agents --output-format json` outside automated tests
2026-04-06 01:42:59 +00:00
Yeachan-Heo ee92f131b0 Stabilize plugin lifecycle temp dirs across parallel tests 2026-04-06 01:18:56 +00:00
Yeachan-Heo df0908b10e docs: record plugin lifecycle test flake 2026-04-06 01:15:30 +00:00
Yeachan-Heo 22e3f8c5e3 Fix retry exhaustion failure classification 2026-04-06 01:10:36 +00:00
Yeachan-Heo d94d792a48 Expose actionable ids for opaque provider failures
Issue #22 was triggered by generic upstream fatal wrappers that only surfaced 'Something went wrong', which left repeated Jobdori-style failures opaque in the CLI. Capture provider request ids on error responses, classify the known generic wrapper as provider_internal, and prefix the user-visible runtime error with the failure class plus session/trace identifiers so operators can correlate the failure quickly.

Constraint: Keep the fix small and user-safe without redesigning the broader runtime error taxonomy
Constraint: Preserve existing non-generic error text unless the wrapper is the known opaque fatal surface
Rejected: Broadly rewriting every runtime error into classified envelopes | unnecessary scope expansion for issue #22
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If more opaque wrappers appear, extend the marker list and classification helper rather than reintroducing raw wrapper text alone
Tested: cargo test -p api detects_generic_fatal_wrapper_and_classifies_it_as_provider_internal -- --nocapture; cargo test -p api retries_exhausted_preserves_nested_request_id_and_failure_class -- --nocapture; cargo test -p rusty-claude-cli opaque_provider_wrapper_surfaces_failure_class_session_and_trace -- --nocapture; cargo test -p rusty-claude-cli retry_exhaustion_preserves_internal_failure_class_for_generic_provider_wrapper -- --nocapture; cargo test --workspace
Not-tested: Live upstream reproduction of the Jobdori failure against a real provider session
2026-04-06 00:30:28 +00:00
Yeachan-Heo 2bab4080d6 Keep resumed /status JSON aligned with live status output
The resumed slash-command path built a reduced status JSON payload by hand, so it drifted from the fresh status schema and dropped metadata like model, permission mode, workspace counters, and sandbox details. Reuse a shared status JSON builder for both code paths and tighten the resume regression tests to lock parity in place.

Constraint: Resume mode does not carry an active runtime model, so restored sessions continue to report the existing restored-session sentinel value
Rejected: Copy the fresh status JSON shape into the resume path again | would recreate the same schema drift risk
Confidence: high
Scope-risk: narrow
Directive: Keep resumed and fresh /status JSON on the same helper so future schema changes stay in parity
Tested: Reproduced failure in temporary HEAD worktree with strengthened resumed_status_command_emits_structured_json_when_requested
Tested: cargo test -p rusty-claude-cli resumed_status_command_emits_structured_json_when_requested --test resume_slash_commands -- --exact --nocapture
Tested: cargo test -p rusty-claude-cli doctor_and_resume_status_emit_json_when_requested --test output_format_contract -- --exact --nocapture
Tested: cargo test --workspace
Tested: cargo fmt --check
Tested: cargo clippy --workspace --all-targets -- -D warnings
2026-04-05 23:30:39 +00:00
Yeachan-Heo f7321ca05d docs: record doctor json structure gap 2026-04-05 20:58:38 +00:00
8 changed files with 1026 additions and 179 deletions
+5
View File
@@ -307,6 +307,11 @@ Priority order: P0 = blocks CI/green state, P1 = blocks integration wiring, P2 =
18. **Context-window preflight gap** — **done**: provider request sizing now emits `context_window_blocked` before oversized requests leave the process, using a model-context registry instead of the old naive max-token heuristic.
19. **Subcommand help falls through into runtime/API path** — **done**: `claw doctor --help`, `claw status --help`, `claw sandbox --help`, and nested `mcp`/`skills` help are now intercepted locally without runtime/provider startup, with regression tests covering the direct CLI paths.
20. **Session state classification gap (working vs blocked vs finished vs truly stale)** — **done**: agent manifests now derive machine states such as `working`, `blocked_background_job`, `blocked_merge_conflict`, `degraded_mcp`, `interrupted_transport`, `finished_pending_report`, and `finished_cleanable`, and terminal-state persistence records commit provenance plus derived state so downstream monitoring can distinguish quiet progress from truly idle sessions.
21. **Resumed `/status` JSON parity gap** — dogfooding shows fresh `claw status --output-format json` now emits structured JSON, but resumed slash-command status still leaks through a text-shaped path in at least one dispatch path. Local CI-equivalent repro fails `rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs::resumed_status_command_emits_structured_json_when_requested` with `expected value at line 1 column 1`, so resumed automation can receive text where JSON was explicitly requested. **Action:** unify fresh vs resumed `/status` rendering through one output-format contract and add regression coverage so resumed JSON output is guaranteed valid.
22. **Opaque failure surface for session/runtime crashes** — repeated dogfood-facing failures can currently collapse to generic wrappers like `Something went wrong while processing your request. Please try again, or use /new to start a fresh session.` without exposing whether the fault was provider auth, session corruption, slash-command dispatch, render failure, or transport/runtime panic. This blocks fast self-recovery and turns actionable clawability bugs into blind retries. **Action:** preserve a short user-safe failure class (`provider_auth`, `session_load`, `command_dispatch`, `render`, `runtime_panic`, etc.), attach a local trace/session id, and ensure operators can jump from the chat-visible error to the exact failure log quickly.
23. **`doctor --output-format json` check-level structure gap** — **done**: `claw doctor --output-format json` now keeps the human-readable `message`/`report` while also emitting structured per-check diagnostics (`name`, `status`, `summary`, `details`, plus typed fields like workspace paths and sandbox fallback data), with regression coverage in `output_format_contract.rs`.
24. **Plugin lifecycle init/shutdown test flakes under workspace-parallel execution** — dogfooding surfaced that `build_runtime_runs_plugin_lifecycle_init_and_shutdown` can fail under `cargo test --workspace` while passing in isolation because sibling tests race on tempdir-backed shell init script paths. This is test brittleness rather than a code-path regression, but it still destabilizes CI confidence and wastes diagnosis cycles. **Action:** isolate temp resources per test robustly (unique dirs + no shared cwd assumptions), audit cleanup timing, and add a regression guard so the plugin lifecycle test remains stable under parallel workspace execution.
26. **Resumed local-command JSON parity gap** — **done**: direct `claw --output-format json` already had structured renderers for `sandbox`, `mcp`, `skills`, `version`, and `init`, but resumed `claw --output-format json --resume <session> /…` paths still fell back to prose because resumed slash dispatch only emitted JSON for `/status`. Resumed `/sandbox`, `/mcp`, `/skills`, `/version`, and `/init` now reuse the same JSON envelopes as their direct CLI counterparts, with regression coverage in `rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs` and `rust/crates/rusty-claude-cli/tests/output_format_contract.rs`.
**P3 — Swarm efficiency**
13. Swarm branch-lock protocol — **done**: `branch_lock::detect_branch_lock_collisions()` now detects same-branch/same-scope and nested-module collisions before parallel lanes drift into duplicate implementation
14. Commit provenance / worktree-aware push events — **done**: lane event provenance now includes branch/worktree/superseded/canonical lineage metadata, and manifest persistence de-dupes superseded commit events before downstream consumers render them
+135 -5
View File
@@ -2,6 +2,11 @@ use std::env::VarError;
use std::fmt::{Display, Formatter};
use std::time::Duration;
const GENERIC_FATAL_WRAPPER_MARKERS: &[&str] = &[
"something went wrong while processing your request",
"please try again, or use /new to start a fresh session",
];
#[derive(Debug)]
pub enum ApiError {
MissingCredentials {
@@ -25,6 +30,7 @@ pub enum ApiError {
status: reqwest::StatusCode,
error_type: Option<String>,
message: Option<String>,
request_id: Option<String>,
body: String,
retryable: bool,
},
@@ -65,6 +71,66 @@ impl ApiError {
| Self::BackoffOverflow { .. } => false,
}
}
#[must_use]
pub fn request_id(&self) -> Option<&str> {
match self {
Self::Api { request_id, .. } => request_id.as_deref(),
Self::RetriesExhausted { last_error, .. } => last_error.request_id(),
Self::MissingCredentials { .. }
| Self::ContextWindowExceeded { .. }
| Self::ExpiredOAuthToken
| Self::Auth(_)
| Self::InvalidApiKeyEnv(_)
| Self::Http(_)
| Self::Io(_)
| Self::Json(_)
| Self::InvalidSseFrame(_)
| Self::BackoffOverflow { .. } => None,
}
}
#[must_use]
pub fn safe_failure_class(&self) -> &'static str {
match self {
Self::RetriesExhausted { .. } => "provider_retry_exhausted",
Self::MissingCredentials { .. } | Self::ExpiredOAuthToken | Self::Auth(_) => {
"provider_auth"
}
Self::Api { status, .. } if matches!(status.as_u16(), 401 | 403) => "provider_auth",
Self::ContextWindowExceeded { .. } => "context_window",
Self::Api { status, .. } if status.as_u16() == 429 => "provider_rate_limit",
Self::Api { .. } if self.is_generic_fatal_wrapper() => "provider_internal",
Self::Api { .. } => "provider_error",
Self::Http(_) | Self::InvalidSseFrame(_) | Self::BackoffOverflow { .. } => {
"provider_transport"
}
Self::InvalidApiKeyEnv(_) | Self::Io(_) | Self::Json(_) => "runtime_io",
}
}
#[must_use]
pub fn is_generic_fatal_wrapper(&self) -> bool {
match self {
Self::Api { message, body, .. } => {
message
.as_deref()
.is_some_and(looks_like_generic_fatal_wrapper)
|| looks_like_generic_fatal_wrapper(body)
}
Self::RetriesExhausted { last_error, .. } => last_error.is_generic_fatal_wrapper(),
Self::MissingCredentials { .. }
| Self::ContextWindowExceeded { .. }
| Self::ExpiredOAuthToken
| Self::Auth(_)
| Self::InvalidApiKeyEnv(_)
| Self::Http(_)
| Self::Io(_)
| Self::Json(_)
| Self::InvalidSseFrame(_)
| Self::BackoffOverflow { .. } => false,
}
}
}
impl Display for ApiError {
@@ -102,14 +168,24 @@ impl Display for ApiError {
status,
error_type,
message,
request_id,
body,
..
} => match (error_type, message) {
(Some(error_type), Some(message)) => {
write!(f, "api returned {status} ({error_type}): {message}")
} => {
if let (Some(error_type), Some(message)) = (error_type, message) {
write!(f, "api returned {status} ({error_type})")?;
if let Some(request_id) = request_id {
write!(f, " [trace {request_id}]")?;
}
write!(f, ": {message}")
} else {
write!(f, "api returned {status}")?;
if let Some(request_id) = request_id {
write!(f, " [trace {request_id}]")?;
}
write!(f, ": {body}")
}
_ => write!(f, "api returned {status}: {body}"),
},
}
Self::RetriesExhausted {
attempts,
last_error,
@@ -151,3 +227,57 @@ impl From<VarError> for ApiError {
Self::InvalidApiKeyEnv(value)
}
}
fn looks_like_generic_fatal_wrapper(text: &str) -> bool {
let lowered = text.to_ascii_lowercase();
GENERIC_FATAL_WRAPPER_MARKERS
.iter()
.any(|marker| lowered.contains(marker))
}
#[cfg(test)]
mod tests {
use super::ApiError;
#[test]
fn detects_generic_fatal_wrapper_and_classifies_it_as_provider_internal() {
let error = ApiError::Api {
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
error_type: Some("api_error".to_string()),
message: Some(
"Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
.to_string(),
),
request_id: Some("req_jobdori_123".to_string()),
body: String::new(),
retryable: true,
};
assert!(error.is_generic_fatal_wrapper());
assert_eq!(error.safe_failure_class(), "provider_internal");
assert_eq!(error.request_id(), Some("req_jobdori_123"));
assert!(error.to_string().contains("[trace req_jobdori_123]"));
}
#[test]
fn retries_exhausted_preserves_nested_request_id_and_failure_class() {
let error = ApiError::RetriesExhausted {
attempts: 3,
last_error: Box::new(ApiError::Api {
status: reqwest::StatusCode::BAD_GATEWAY,
error_type: Some("api_error".to_string()),
message: Some(
"Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
.to_string(),
),
request_id: Some("req_nested_456".to_string()),
body: String::new(),
retryable: true,
}),
};
assert!(error.is_generic_fatal_wrapper());
assert_eq!(error.safe_failure_class(), "provider_retry_exhausted");
assert_eq!(error.request_id(), Some("req_nested_456"));
}
}
@@ -808,6 +808,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
return Ok(response);
}
let request_id = request_id_from_headers(response.headers());
let body = response.text().await.unwrap_or_else(|_| String::new());
let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&body).ok();
let retryable = is_retryable_status(status);
@@ -820,6 +821,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
message: parsed_error
.as_ref()
.map(|error| error.error.message.clone()),
request_id,
body,
retryable,
})
@@ -906,6 +906,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
return Ok(response);
}
let request_id = request_id_from_headers(response.headers());
let body = response.text().await.unwrap_or_default();
let parsed_error = serde_json::from_str::<ErrorEnvelope>(&body).ok();
let retryable = is_retryable_status(status);
@@ -918,6 +919,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
message: parsed_error
.as_ref()
.and_then(|error| error.error.message.clone()),
request_id,
body,
retryable,
})
+134 -2
View File
@@ -2187,6 +2187,27 @@ pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
}
}
pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::io::Result<Value> {
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_json(None),
_ => render_agents_usage_json(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_json(cwd, &agents))
}
Some(args) if is_help_arg(args) => Ok(render_agents_usage_json(None)),
Some(args) => Ok(render_agents_usage_json(Some(args))),
}
}
pub fn handle_mcp_slash_command(
args: Option<&str>,
cwd: &Path,
@@ -3039,6 +3060,25 @@ fn render_agents_report(agents: &[AgentSummary]) -> String {
lines.join("\n").trim_end().to_string()
}
fn render_agents_report_json(cwd: &Path, agents: &[AgentSummary]) -> Value {
let active = agents
.iter()
.filter(|agent| agent.shadowed_by.is_none())
.count();
json!({
"kind": "agents",
"action": "list",
"working_directory": cwd.display().to_string(),
"count": agents.len(),
"summary": {
"total": agents.len(),
"active": active,
"shadowed": agents.len().saturating_sub(active),
},
"agents": agents.iter().map(agent_summary_json).collect::<Vec<_>>(),
})
}
fn agent_detail(agent: &AgentSummary) -> String {
let mut parts = vec![agent.name.clone()];
if let Some(description) = &agent.description {
@@ -3327,6 +3367,19 @@ fn render_agents_usage(unexpected: Option<&str>) -> String {
lines.join("\n")
}
fn render_agents_usage_json(unexpected: Option<&str>) -> Value {
json!({
"kind": "agents",
"action": "help",
"usage": {
"slash_command": "/agents [list|help]",
"direct_cli": "claw agents [list|help]",
"sources": [".claw/agents", "~/.claw/agents", "$CLAW_CONFIG_HOME/agents"],
},
"unexpected": unexpected,
})
}
fn render_skills_usage(unexpected: Option<&str>) -> String {
let mut lines = vec![
"Skills".to_string(),
@@ -3478,6 +3531,18 @@ fn definition_source_json(source: DefinitionSource) -> Value {
})
}
fn agent_summary_json(agent: &AgentSummary) -> Value {
json!({
"name": &agent.name,
"description": &agent.description,
"model": &agent.model,
"reasoning_effort": &agent.reasoning_effort,
"source": definition_source_json(agent.source),
"active": agent.shadowed_by.is_none(),
"shadowed_by": agent.shadowed_by.map(definition_source_json),
})
}
fn skill_origin_id(origin: SkillOrigin) -> &'static str {
match origin {
SkillOrigin::SkillsDir => "skills_dir",
@@ -3686,8 +3751,9 @@ pub fn handle_slash_command(
#[cfg(test)]
mod tests {
use super::{
handle_plugins_slash_command, handle_skills_slash_command_json, handle_slash_command,
load_agents_from_roots, load_skills_from_roots, render_agents_report,
handle_agents_slash_command_json, handle_plugins_slash_command,
handle_skills_slash_command_json, handle_slash_command, load_agents_from_roots,
load_skills_from_roots, render_agents_report, render_agents_report_json,
render_mcp_report_json_for, render_plugins_report, render_skills_report,
render_slash_command_help, render_slash_command_help_detail,
resume_supported_slash_commands, slash_command_specs, suggest_slash_commands,
@@ -4363,6 +4429,72 @@ mod tests {
let _ = fs::remove_dir_all(user_home);
}
#[test]
fn renders_agents_reports_as_json() {
let workspace = temp_dir("agents-json-workspace");
let project_agents = workspace.join(".codex").join("agents");
let user_home = temp_dir("agents-json-home");
let user_agents = user_home.join(".codex").join("agents");
write_agent(
&project_agents,
"planner",
"Project planner",
"gpt-5.4",
"medium",
);
write_agent(
&project_agents,
"verifier",
"Verification agent",
"gpt-5.4-mini",
"high",
);
write_agent(
&user_agents,
"planner",
"User planner",
"gpt-5.4-mini",
"high",
);
let roots = vec![
(DefinitionSource::ProjectCodex, project_agents),
(DefinitionSource::UserCodex, user_agents),
];
let report = render_agents_report_json(
&workspace,
&load_agents_from_roots(&roots).expect("agent roots should load"),
);
assert_eq!(report["kind"], "agents");
assert_eq!(report["action"], "list");
assert_eq!(report["working_directory"], workspace.display().to_string());
assert_eq!(report["count"], 3);
assert_eq!(report["summary"]["active"], 2);
assert_eq!(report["summary"]["shadowed"], 1);
assert_eq!(report["agents"][0]["name"], "planner");
assert_eq!(report["agents"][0]["model"], "gpt-5.4");
assert_eq!(report["agents"][0]["active"], true);
assert_eq!(report["agents"][1]["name"], "verifier");
assert_eq!(report["agents"][2]["name"], "planner");
assert_eq!(report["agents"][2]["active"], false);
assert_eq!(report["agents"][2]["shadowed_by"]["id"], "project_claw");
let help = handle_agents_slash_command_json(Some("help"), &workspace).expect("agents help");
assert_eq!(help["kind"], "agents");
assert_eq!(help["action"], "help");
assert_eq!(help["usage"]["direct_cli"], "claw agents [list|help]");
let unexpected = handle_agents_slash_command_json(Some("show planner"), &workspace)
.expect("agents usage");
assert_eq!(unexpected["action"], "help");
assert_eq!(unexpected["unexpected"], "show planner");
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(user_home);
}
#[test]
fn lists_skills_from_project_and_user_roots() {
let workspace = temp_dir("skills-workspace");
+460 -169
View File
@@ -31,10 +31,10 @@ use api::{
};
use commands::{
handle_agents_slash_command, handle_mcp_slash_command, handle_mcp_slash_command_json,
handle_plugins_slash_command, handle_skills_slash_command, handle_skills_slash_command_json,
render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
validate_slash_command_input, SlashCommand,
handle_agents_slash_command, handle_agents_slash_command_json, handle_mcp_slash_command,
handle_mcp_slash_command_json, handle_plugins_slash_command, handle_skills_slash_command,
handle_skills_slash_command_json, render_slash_command_help, resume_supported_slash_commands,
slash_command_specs, validate_slash_command_input, SlashCommand,
};
use compat_harness::{extract_manifest, UpstreamPaths};
use init::initialize_repo;
@@ -51,7 +51,7 @@ use runtime::{
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
};
use serde::Deserialize;
use serde_json::json;
use serde_json::{json, Map, Value};
use tools::{GlobalToolRegistry, RuntimeToolDefinition, ToolSearchOutput};
const DEFAULT_MODEL: &str = "claude-opus-4-6";
@@ -870,6 +870,7 @@ struct DiagnosticCheck {
level: DiagnosticLevel,
summary: String,
details: Vec<String>,
data: Map<String, Value>,
}
impl DiagnosticCheck {
@@ -879,6 +880,7 @@ impl DiagnosticCheck {
level,
summary: summary.into(),
details: Vec::new(),
data: Map::new(),
}
}
@@ -886,6 +888,37 @@ impl DiagnosticCheck {
self.details = details;
self
}
fn with_data(mut self, data: Map<String, Value>) -> Self {
self.data = data;
self
}
fn json_value(&self) -> Value {
let mut value = Map::from_iter([
(
"name".to_string(),
Value::String(self.name.to_ascii_lowercase()),
),
(
"status".to_string(),
Value::String(self.level.label().to_string()),
),
("summary".to_string(), Value::String(self.summary.clone())),
(
"details".to_string(),
Value::Array(
self.details
.iter()
.cloned()
.map(Value::String)
.collect::<Vec<_>>(),
),
),
]);
value.extend(self.data.clone());
Value::Object(value)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -894,26 +927,29 @@ struct DoctorReport {
}
impl DoctorReport {
fn counts(&self) -> (usize, usize, usize) {
(
self.checks
.iter()
.filter(|check| check.level == DiagnosticLevel::Ok)
.count(),
self.checks
.iter()
.filter(|check| check.level == DiagnosticLevel::Warn)
.count(),
self.checks
.iter()
.filter(|check| check.level == DiagnosticLevel::Fail)
.count(),
)
}
fn has_failures(&self) -> bool {
self.checks.iter().any(|check| check.level.is_failure())
}
fn render(&self) -> String {
let ok_count = self
.checks
.iter()
.filter(|check| check.level == DiagnosticLevel::Ok)
.count();
let warn_count = self
.checks
.iter()
.filter(|check| check.level == DiagnosticLevel::Warn)
.count();
let fail_count = self
.checks
.iter()
.filter(|check| check.level == DiagnosticLevel::Fail)
.count();
let (ok_count, warn_count, fail_count) = self.counts();
let mut lines = vec![
"Doctor".to_string(),
format!(
@@ -923,6 +959,28 @@ impl DoctorReport {
lines.extend(self.checks.iter().map(render_diagnostic_check));
lines.join("\n\n")
}
fn json_value(&self) -> Value {
let report = self.render();
let (ok_count, warn_count, fail_count) = self.counts();
json!({
"kind": "doctor",
"message": report,
"report": report,
"has_failures": self.has_failures(),
"summary": {
"total": self.checks.len(),
"ok": ok_count,
"warnings": warn_count,
"failures": fail_count,
},
"checks": self
.checks
.iter()
.map(DiagnosticCheck::json_value)
.collect::<Vec<_>>(),
})
}
}
fn render_diagnostic_check(check: &DiagnosticCheck) -> String {
@@ -980,15 +1038,9 @@ fn run_doctor(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::
let message = report.render();
match output_format {
CliOutputFormat::Text => println!("{message}"),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "doctor",
"message": message,
"report": message,
"has_failures": report.has_failures(),
}))?
),
CliOutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(&report.json_value())?);
}
}
if report.has_failures() {
return Err("doctor found failing checks".into());
@@ -996,6 +1048,7 @@ fn run_doctor(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::
Ok(())
}
#[allow(clippy::too_many_lines)]
fn check_auth_health() -> DiagnosticCheck {
let api_key_present = env::var("ANTHROPIC_API_KEY")
.ok()
@@ -1060,6 +1113,21 @@ fn check_auth_health() -> DiagnosticCheck {
},
)
.with_details(details)
.with_data(Map::from_iter([
("api_key_present".to_string(), json!(api_key_present)),
("auth_token_present".to_string(), json!(auth_token_present)),
("saved_oauth_present".to_string(), json!(true)),
("saved_oauth_expired".to_string(), json!(expired)),
(
"saved_oauth_expires_at".to_string(),
json!(token_set.expires_at),
),
(
"refresh_token_present".to_string(),
json!(token_set.refresh_token.is_some()),
),
("scopes".to_string(), json!(token_set.scopes)),
]))
}
Ok(None) => DiagnosticCheck::new(
"Auth",
@@ -1082,12 +1150,31 @@ fn check_auth_health() -> DiagnosticCheck {
} else {
"absent"
}
)]),
)])
.with_data(Map::from_iter([
("api_key_present".to_string(), json!(api_key_present)),
("auth_token_present".to_string(), json!(auth_token_present)),
("saved_oauth_present".to_string(), json!(false)),
("saved_oauth_expired".to_string(), json!(false)),
("saved_oauth_expires_at".to_string(), Value::Null),
("refresh_token_present".to_string(), json!(false)),
("scopes".to_string(), json!(Vec::<String>::new())),
])),
Err(error) => DiagnosticCheck::new(
"Auth",
DiagnosticLevel::Fail,
format!("failed to inspect saved credentials: {error}"),
),
)
.with_data(Map::from_iter([
("api_key_present".to_string(), json!(api_key_present)),
("auth_token_present".to_string(), json!(auth_token_present)),
("saved_oauth_present".to_string(), Value::Null),
("saved_oauth_expired".to_string(), Value::Null),
("saved_oauth_expires_at".to_string(), Value::Null),
("refresh_token_present".to_string(), Value::Null),
("scopes".to_string(), Value::Null),
("saved_oauth_error".to_string(), json!(error.to_string())),
])),
}
}
@@ -1121,7 +1208,7 @@ fn check_config_health(
} else {
details.extend(
discovered_paths
.into_iter()
.iter()
.map(|path| format!("Discovered file {path}")),
);
}
@@ -1139,6 +1226,22 @@ fn check_config_health(
},
)
.with_details(details)
.with_data(Map::from_iter([
("discovered_files".to_string(), json!(discovered_paths)),
(
"discovered_files_count".to_string(),
json!(discovered_count),
),
(
"loaded_config_files".to_string(),
json!(loaded_entries.len()),
),
("resolved_model".to_string(), json!(runtime_config.model())),
(
"mcp_servers".to_string(),
json!(runtime_config.mcp().servers().len()),
),
]))
}
Err(error) => DiagnosticCheck::new(
"Config",
@@ -1149,10 +1252,21 @@ fn check_config_health(
vec!["Discovered files <none>".to_string()]
} else {
discovered_paths
.into_iter()
.iter()
.map(|path| format!("Discovered file {path}"))
.collect()
}),
})
.with_data(Map::from_iter([
("discovered_files".to_string(), json!(discovered_paths)),
(
"discovered_files_count".to_string(),
json!(discovered_count),
),
("loaded_config_files".to_string(), json!(0)),
("resolved_model".to_string(), Value::Null),
("mcp_servers".to_string(), Value::Null),
("load_error".to_string(), json!(error.to_string())),
])),
}
}
@@ -1194,6 +1308,38 @@ fn check_workspace_health(context: &StatusContext) -> DiagnosticCheck {
context.memory_file_count, context.loaded_config_files, context.discovered_config_files
),
])
.with_data(Map::from_iter([
("cwd".to_string(), json!(context.cwd.display().to_string())),
(
"project_root".to_string(),
json!(context
.project_root
.as_ref()
.map(|path| path.display().to_string())),
),
("in_git_repo".to_string(), json!(in_repo)),
("git_branch".to_string(), json!(context.git_branch)),
(
"git_state".to_string(),
json!(context.git_summary.headline()),
),
(
"changed_files".to_string(),
json!(context.git_summary.changed_files),
),
(
"memory_file_count".to_string(),
json!(context.memory_file_count),
),
(
"loaded_config_files".to_string(),
json!(context.loaded_config_files),
),
(
"discovered_config_files".to_string(),
json!(context.discovered_config_files),
),
]))
}
fn check_sandbox_health(status: &runtime::SandboxStatus) -> DiagnosticCheck {
@@ -1224,9 +1370,43 @@ fn check_sandbox_health(status: &runtime::SandboxStatus) -> DiagnosticCheck {
},
)
.with_details(details)
.with_data(Map::from_iter([
("enabled".to_string(), json!(status.enabled)),
("active".to_string(), json!(status.active)),
("supported".to_string(), json!(status.supported)),
(
"namespace_supported".to_string(),
json!(status.namespace_supported),
),
(
"namespace_active".to_string(),
json!(status.namespace_active),
),
(
"network_supported".to_string(),
json!(status.network_supported),
),
("network_active".to_string(), json!(status.network_active)),
(
"filesystem_mode".to_string(),
json!(status.filesystem_mode.as_str()),
),
(
"filesystem_active".to_string(),
json!(status.filesystem_active),
),
("allowed_mounts".to_string(), json!(status.allowed_mounts)),
("in_container".to_string(), json!(status.in_container)),
(
"container_markers".to_string(),
json!(status.container_markers),
),
("fallback_reason".to_string(), json!(status.fallback_reason)),
]))
}
fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> DiagnosticCheck {
let default_model = config.and_then(runtime::RuntimeConfig::model);
let mut details = vec![
format!("OS {} {}", env::consts::OS, env::consts::ARCH),
format!("Working dir {}", cwd.display()),
@@ -1234,7 +1414,7 @@ fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> D
format!("Build target {}", BUILD_TARGET.unwrap_or("<unknown>")),
format!("Git SHA {}", GIT_SHA.unwrap_or("<unknown>")),
];
if let Some(model) = config.and_then(runtime::RuntimeConfig::model) {
if let Some(model) = default_model {
details.push(format!("Default model {model}"));
}
DiagnosticCheck::new(
@@ -1243,6 +1423,15 @@ fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> D
"captured local runtime metadata",
)
.with_details(details)
.with_data(Map::from_iter([
("os".to_string(), json!(env::consts::OS)),
("arch".to_string(), json!(env::consts::ARCH)),
("working_dir".to_string(), json!(cwd.display().to_string())),
("version".to_string(), json!(VERSION)),
("build_target".to_string(), json!(BUILD_TARGET)),
("git_sha".to_string(), json!(GIT_SHA)),
("default_model".to_string(), json!(default_model)),
]))
}
fn resume_command_can_absorb_token(current_command: &str, token: &str) -> bool {
@@ -1499,23 +1688,25 @@ fn print_system_prompt(
}
fn print_version(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let report = render_version_report();
match output_format {
CliOutputFormat::Text => println!("{report}"),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "version",
"message": report,
"version": VERSION,
"git_sha": GIT_SHA,
"target": BUILD_TARGET,
}))?
),
CliOutputFormat::Text => println!("{}", render_version_report()),
CliOutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(&version_json_value())?);
}
}
Ok(())
}
fn version_json_value() -> serde_json::Value {
json!({
"kind": "version",
"message": render_version_report(),
"version": VERSION,
"git_sha": GIT_SHA,
"target": BUILD_TARGET,
})
}
fn resume_session(session_path: &Path, commands: &[String], output_format: CliOutputFormat) {
let resolved_path = if session_path.exists() {
session_path.to_path_buf()
@@ -1563,38 +1754,21 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
Ok(ResumeCommandOutcome {
session: next_session,
message,
json,
}) => {
session = next_session;
if let Some(message) = message {
if output_format == CliOutputFormat::Json
&& matches!(command, SlashCommand::Status)
{
let tracker = UsageTracker::from_session(&session);
let usage = tracker.cumulative_usage();
let context = status_context(Some(&resolved_path)).expect("status context");
let value = json!({
"kind": "status",
"messages": session.messages.len(),
"turns": tracker.turns(),
"latest_total": tracker.current_turn_usage().total_tokens(),
"cumulative_input": usage.input_tokens,
"cumulative_output": usage.output_tokens,
"cumulative_total": usage.total_tokens(),
"workspace": {
"cwd": context.cwd,
"project_root": context.project_root,
"git_branch": context.git_branch,
"git_state": context.git_summary.headline(),
"session": context.session_path.as_ref().map_or_else(|| "live-repl".to_string(), |path| path.display().to_string()),
}
});
if output_format == CliOutputFormat::Json {
if let Some(value) = json {
println!(
"{}",
serde_json::to_string_pretty(&value).expect("status json")
serde_json::to_string_pretty(&value)
.expect("resume command json output")
);
} else {
} else if let Some(message) = message {
println!("{message}");
}
} else if let Some(message) = message {
println!("{message}");
}
}
Err(error) => {
@@ -1609,6 +1783,7 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
struct ResumeCommandOutcome {
session: Session,
message: Option<String>,
json: Option<serde_json::Value>,
}
#[derive(Debug, Clone)]
@@ -1943,6 +2118,7 @@ fn run_resume_command(
SlashCommand::Help => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_repl_help()),
json: None,
}),
SlashCommand::Compact => {
let result = runtime::compact_session(
@@ -1959,6 +2135,7 @@ fn run_resume_command(
Ok(ResumeCommandOutcome {
session: result.compacted_session,
message: Some(format_compact_report(removed, kept, skipped)),
json: None,
})
}
SlashCommand::Clear { confirm } => {
@@ -1968,6 +2145,7 @@ fn run_resume_command(
message: Some(
"clear: confirmation required; rerun with /clear --confirm".to_string(),
),
json: None,
});
}
let backup_path = write_session_clear_backup(session, session_path)?;
@@ -1983,11 +2161,13 @@ fn run_resume_command(
backup_path.display(),
session_path.display()
)),
json: None,
})
}
SlashCommand::Status => {
let tracker = UsageTracker::from_session(session);
let usage = tracker.cumulative_usage();
let context = status_context(Some(session_path))?;
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format_status_report(
@@ -2000,7 +2180,19 @@ fn run_resume_command(
estimated_tokens: 0,
},
default_permission_mode().as_str(),
&status_context(Some(session_path))?,
&context,
)),
json: Some(status_json_value(
"restored-session",
StatusUsage {
message_count: session.messages.len(),
turns: tracker.turns(),
latest: tracker.current_turn_usage(),
cumulative: usage,
estimated_tokens: 0,
},
default_permission_mode().as_str(),
&context,
)),
})
}
@@ -2008,12 +2200,11 @@ fn run_resume_command(
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
let runtime_config = loader.load()?;
let status = resolve_sandbox_status(runtime_config.sandbox(), &cwd);
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format_sandbox_report(&resolve_sandbox_status(
runtime_config.sandbox(),
&cwd,
))),
message: Some(format_sandbox_report(&status)),
json: Some(sandbox_json_value(&status)),
})
}
SlashCommand::Cost => {
@@ -2021,11 +2212,13 @@ fn run_resume_command(
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format_cost_report(usage)),
json: None,
})
}
SlashCommand::Config { section } => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_config_report(section.as_deref())?),
json: None,
}),
SlashCommand::Mcp { action, target } => {
let cwd = env::current_dir()?;
@@ -2038,25 +2231,33 @@ fn run_resume_command(
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(handle_mcp_slash_command(args.as_deref(), &cwd)?),
json: Some(handle_mcp_slash_command_json(args.as_deref(), &cwd)?),
})
}
SlashCommand::Memory => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_memory_report()?),
json: None,
}),
SlashCommand::Init => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(init_claude_md()?),
}),
SlashCommand::Init => {
let message = init_claude_md()?;
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(message.clone()),
json: Some(init_json_value(&message)),
})
}
SlashCommand::Diff => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_diff_report_for(
session_path.parent().unwrap_or_else(|| Path::new(".")),
)?),
json: None,
}),
SlashCommand::Version => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_version_report()),
json: Some(version_json_value()),
}),
SlashCommand::Export { path } => {
let export_path = resolve_export_path(path.as_deref(), session)?;
@@ -2068,6 +2269,7 @@ fn run_resume_command(
export_path.display(),
session.messages.len(),
)),
json: None,
})
}
SlashCommand::Agents { args } => {
@@ -2075,6 +2277,7 @@ fn run_resume_command(
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?),
json: None,
})
}
SlashCommand::Skills { args } => {
@@ -2082,11 +2285,13 @@ fn run_resume_command(
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?),
json: Some(handle_skills_slash_command_json(args.as_deref(), &cwd)?),
})
}
SlashCommand::Doctor => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_doctor_report()?.render()),
json: None,
}),
SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()),
SlashCommand::Bughunter { .. }
@@ -3281,16 +3486,11 @@ impl LiveCli {
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let message = handle_agents_slash_command(args, &cwd)?;
match output_format {
CliOutputFormat::Text => println!("{message}"),
CliOutputFormat::Text => println!("{}", handle_agents_slash_command(args, &cwd)?),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "agents",
"message": message,
"args": args,
}))?
serde_json::to_string_pretty(&handle_agents_slash_command_json(args, &cwd)?)?
),
}
Ok(())
@@ -3845,54 +4045,68 @@ fn print_status_snapshot(
),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "status",
"model": model,
"permission_mode": permission_mode.as_str(),
"usage": {
"messages": usage.message_count,
"turns": usage.turns,
"latest_total": usage.latest.total_tokens(),
"cumulative_input": usage.cumulative.input_tokens,
"cumulative_output": usage.cumulative.output_tokens,
"cumulative_total": usage.cumulative.total_tokens(),
"estimated_tokens": usage.estimated_tokens,
},
"workspace": {
"cwd": context.cwd,
"project_root": context.project_root,
"git_branch": context.git_branch,
"git_state": context.git_summary.headline(),
"changed_files": context.git_summary.changed_files,
"staged_files": context.git_summary.staged_files,
"unstaged_files": context.git_summary.unstaged_files,
"untracked_files": context.git_summary.untracked_files,
"session": context.session_path.as_ref().map_or_else(|| "live-repl".to_string(), |path| path.display().to_string()),
"loaded_config_files": context.loaded_config_files,
"discovered_config_files": context.discovered_config_files,
"memory_file_count": context.memory_file_count,
},
"sandbox": {
"enabled": context.sandbox_status.enabled,
"active": context.sandbox_status.active,
"supported": context.sandbox_status.supported,
"in_container": context.sandbox_status.in_container,
"requested_namespace": context.sandbox_status.requested.namespace_restrictions,
"active_namespace": context.sandbox_status.namespace_active,
"requested_network": context.sandbox_status.requested.network_isolation,
"active_network": context.sandbox_status.network_active,
"filesystem_mode": context.sandbox_status.filesystem_mode.as_str(),
"filesystem_active": context.sandbox_status.filesystem_active,
"allowed_mounts": context.sandbox_status.allowed_mounts,
"markers": context.sandbox_status.container_markers,
"fallback_reason": context.sandbox_status.fallback_reason,
}
}))?
serde_json::to_string_pretty(&status_json_value(
model,
usage,
permission_mode.as_str(),
&context,
))?
),
}
Ok(())
}
fn status_json_value(
model: &str,
usage: StatusUsage,
permission_mode: &str,
context: &StatusContext,
) -> serde_json::Value {
json!({
"kind": "status",
"model": model,
"permission_mode": permission_mode,
"usage": {
"messages": usage.message_count,
"turns": usage.turns,
"latest_total": usage.latest.total_tokens(),
"cumulative_input": usage.cumulative.input_tokens,
"cumulative_output": usage.cumulative.output_tokens,
"cumulative_total": usage.cumulative.total_tokens(),
"estimated_tokens": usage.estimated_tokens,
},
"workspace": {
"cwd": context.cwd,
"project_root": context.project_root,
"git_branch": context.git_branch,
"git_state": context.git_summary.headline(),
"changed_files": context.git_summary.changed_files,
"staged_files": context.git_summary.staged_files,
"unstaged_files": context.git_summary.unstaged_files,
"untracked_files": context.git_summary.untracked_files,
"session": context.session_path.as_ref().map_or_else(|| "live-repl".to_string(), |path| path.display().to_string()),
"loaded_config_files": context.loaded_config_files,
"discovered_config_files": context.discovered_config_files,
"memory_file_count": context.memory_file_count,
},
"sandbox": {
"enabled": context.sandbox_status.enabled,
"active": context.sandbox_status.active,
"supported": context.sandbox_status.supported,
"in_container": context.sandbox_status.in_container,
"requested_namespace": context.sandbox_status.requested.namespace_restrictions,
"active_namespace": context.sandbox_status.namespace_active,
"requested_network": context.sandbox_status.requested.network_isolation,
"active_network": context.sandbox_status.network_active,
"filesystem_mode": context.sandbox_status.filesystem_mode.as_str(),
"filesystem_active": context.sandbox_status.filesystem_active,
"allowed_mounts": context.sandbox_status.allowed_mounts,
"markers": context.sandbox_status.container_markers,
"fallback_reason": context.sandbox_status.fallback_reason,
}
})
}
fn status_context(
session_path: Option<&Path>,
) -> Result<StatusContext, Box<dyn std::error::Error>> {
@@ -4066,27 +4280,31 @@ fn print_sandbox_status_snapshot(
CliOutputFormat::Text => println!("{}", format_sandbox_report(&status)),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "sandbox",
"enabled": status.enabled,
"active": status.active,
"supported": status.supported,
"in_container": status.in_container,
"requested_namespace": status.requested.namespace_restrictions,
"active_namespace": status.namespace_active,
"requested_network": status.requested.network_isolation,
"active_network": status.network_active,
"filesystem_mode": status.filesystem_mode.as_str(),
"filesystem_active": status.filesystem_active,
"allowed_mounts": status.allowed_mounts,
"markers": status.container_markers,
"fallback_reason": status.fallback_reason,
}))?
serde_json::to_string_pretty(&sandbox_json_value(&status))?
),
}
Ok(())
}
fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value {
json!({
"kind": "sandbox",
"enabled": status.enabled,
"active": status.active,
"supported": status.supported,
"in_container": status.in_container,
"requested_namespace": status.requested.namespace_restrictions,
"active_namespace": status.namespace_active,
"requested_network": status.requested.network_isolation,
"active_network": status.network_active,
"filesystem_mode": status.filesystem_mode.as_str(),
"filesystem_active": status.filesystem_active,
"allowed_mounts": status.allowed_mounts,
"markers": status.container_markers,
"fallback_reason": status.fallback_reason,
})
}
fn render_help_topic(topic: LocalHelpTopic) -> String {
match topic {
LocalHelpTopic::Status => "Status
@@ -4243,15 +4461,19 @@ fn run_init(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Er
CliOutputFormat::Text => println!("{message}"),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "init",
"message": message,
}))?
serde_json::to_string_pretty(&init_json_value(&message))?
),
}
Ok(())
}
fn init_json_value(message: &str) -> serde_json::Value {
json!({
"kind": "init",
"message": message,
})
}
fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
match mode.trim() {
"read-only" => Some("read-only"),
@@ -5230,6 +5452,7 @@ impl runtime::PermissionPrompter for CliPermissionPrompter {
struct AnthropicRuntimeClient {
runtime: tokio::runtime::Runtime,
client: AnthropicClient,
session_id: String,
model: String,
enable_tools: bool,
emit_output: bool,
@@ -5253,6 +5476,7 @@ impl AnthropicRuntimeClient {
client: AnthropicClient::from_auth(resolve_cli_auth_source()?)
.with_base_url(api::read_base_url())
.with_prompt_cache(PromptCache::new(session_id)),
session_id: session_id.to_string(),
model,
enable_tools,
emit_output,
@@ -5292,11 +5516,13 @@ impl ApiClient for AnthropicRuntimeClient {
};
self.runtime.block_on(async {
let mut stream = self
.client
.stream_message(&message_request)
.await
.map_err(|error| RuntimeError::new(error.to_string()))?;
let mut stream =
self.client
.stream_message(&message_request)
.await
.map_err(|error| {
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
})?;
let mut stdout = io::stdout();
let mut sink = io::sink();
let out: &mut dyn Write = if self.emit_output {
@@ -5310,11 +5536,9 @@ impl ApiClient for AnthropicRuntimeClient {
let mut pending_tool: Option<(String, String, String)> = None;
let mut saw_stop = false;
while let Some(event) = stream
.next_event()
.await
.map_err(|error| RuntimeError::new(error.to_string()))?
{
while let Some(event) = stream.next_event().await.map_err(|error| {
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
})? {
match event {
ApiStreamEvent::MessageStart(start) => {
for block in start.message.content {
@@ -5409,7 +5633,9 @@ impl ApiClient for AnthropicRuntimeClient {
..message_request.clone()
})
.await
.map_err(|error| RuntimeError::new(error.to_string()))?;
.map_err(|error| {
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
})?;
let mut events = response_to_events(response, out)?;
push_prompt_cache_record(&self.client, &mut events);
Ok(events)
@@ -5417,6 +5643,23 @@ impl ApiClient for AnthropicRuntimeClient {
}
}
fn format_user_visible_api_error(session_id: &str, error: &api::ApiError) -> String {
if error.is_generic_fatal_wrapper() {
let mut qualifiers = vec![format!("session {session_id}")];
if let Some(request_id) = error.request_id() {
qualifiers.push(format!("trace {request_id}"));
}
format!(
"{} ({}): {}",
error.safe_failure_class(),
qualifiers.join(", "),
error
)
} else {
error.to_string()
}
}
fn final_assistant_text(summary: &runtime::TurnSummary) -> String {
summary
.assistant_messages
@@ -6415,18 +6658,19 @@ mod tests {
format_permissions_report, format_permissions_switch_report, format_pr_report,
format_resume_report, format_status_report, format_tool_call_start, format_tool_result,
format_ultraplan_report, format_unknown_slash_command,
format_unknown_slash_command_message, normalize_permission_mode, parse_args,
parse_git_status_branch, parse_git_status_metadata_for, parse_git_workspace_summary,
permission_policy, print_help_to, push_output_block, render_config_report,
render_diff_report, render_diff_report_for, render_memory_report, render_repl_help,
render_resume_usage, resolve_model_alias, resolve_session_reference, response_to_events,
format_unknown_slash_command_message, format_user_visible_api_error,
normalize_permission_mode, parse_args, parse_git_status_branch,
parse_git_status_metadata_for, parse_git_workspace_summary, permission_policy,
print_help_to, push_output_block, render_config_report, render_diff_report,
render_diff_report_for, render_memory_report, render_repl_help, render_resume_usage,
resolve_model_alias, resolve_session_reference, response_to_events,
resume_supported_slash_commands, run_resume_command,
slash_command_completion_candidates_with_sessions, status_context, validate_no_args,
write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary,
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic,
SlashCommand, StatusUsage, DEFAULT_MODEL,
};
use api::{MessageResponse, OutputContentBlock, Usage};
use api::{ApiError, MessageResponse, OutputContentBlock, Usage};
use plugins::{
PluginManager, PluginManagerConfig, PluginTool, PluginToolDefinition, PluginToolPermission,
};
@@ -6466,12 +6710,59 @@ mod tests {
.expect("plugin tool registry should build")
}
#[test]
fn opaque_provider_wrapper_surfaces_failure_class_session_and_trace() {
let error = ApiError::Api {
status: "500".parse().expect("status"),
error_type: Some("api_error".to_string()),
message: Some(
"Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
.to_string(),
),
request_id: Some("req_jobdori_789".to_string()),
body: String::new(),
retryable: true,
};
let rendered = format_user_visible_api_error("session-issue-22", &error);
assert!(rendered.contains("provider_internal"));
assert!(rendered.contains("session session-issue-22"));
assert!(rendered.contains("trace req_jobdori_789"));
}
#[test]
fn retry_exhaustion_uses_retry_failure_class_for_generic_provider_wrapper() {
let error = ApiError::RetriesExhausted {
attempts: 3,
last_error: Box::new(ApiError::Api {
status: "502".parse().expect("status"),
error_type: Some("api_error".to_string()),
message: Some(
"Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
.to_string(),
),
request_id: Some("req_jobdori_790".to_string()),
body: String::new(),
retryable: true,
}),
};
let rendered = format_user_visible_api_error("session-issue-22", &error);
assert!(rendered.contains("provider_retry_exhausted"), "{rendered}");
assert!(rendered.contains("session session-issue-22"));
assert!(rendered.contains("trace req_jobdori_790"));
}
fn temp_dir() -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!("rusty-claude-cli-{nanos}"))
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("rusty-claude-cli-{nanos}-{unique}"))
}
fn git(args: &[&str], cwd: &Path) {
@@ -50,8 +50,34 @@ fn inventory_commands_emit_structured_json_when_requested() {
let root = unique_temp_dir("inventory-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let agents = assert_json_command(&root, &["--output-format", "json", "agents"]);
let isolated_home = root.join("home");
let isolated_config = root.join("config-home");
let isolated_codex = root.join("codex-home");
fs::create_dir_all(&isolated_home).expect("isolated home should exist");
let agents = assert_json_command_with_env(
&root,
&["--output-format", "json", "agents"],
&[
("HOME", isolated_home.to_str().expect("utf8 home")),
(
"CLAW_CONFIG_HOME",
isolated_config.to_str().expect("utf8 config home"),
),
(
"CODEX_HOME",
isolated_codex.to_str().expect("utf8 codex home"),
),
],
);
assert_eq!(agents["kind"], "agents");
assert_eq!(agents["action"], "list");
assert_eq!(agents["count"], 0);
assert_eq!(agents["summary"]["active"], 0);
assert!(agents["agents"]
.as_array()
.expect("agents array")
.is_empty());
let mcp = assert_json_command(&root, &["--output-format", "json", "mcp"]);
assert_eq!(mcp["kind"], "mcp");
@@ -62,6 +88,68 @@ fn inventory_commands_emit_structured_json_when_requested() {
assert_eq!(skills["action"], "list");
}
#[test]
fn agents_command_emits_structured_agent_entries_when_requested() {
let root = unique_temp_dir("agents-json-populated");
let workspace = root.join("workspace");
let project_agents = workspace.join(".codex").join("agents");
let home = root.join("home");
let user_agents = home.join(".codex").join("agents");
let isolated_config = root.join("config-home");
let isolated_codex = root.join("codex-home");
fs::create_dir_all(&workspace).expect("workspace should exist");
write_agent(
&project_agents,
"planner",
"Project planner",
"gpt-5.4",
"medium",
);
write_agent(
&project_agents,
"verifier",
"Verification agent",
"gpt-5.4-mini",
"high",
);
write_agent(
&user_agents,
"planner",
"User planner",
"gpt-5.4-mini",
"high",
);
let parsed = assert_json_command_with_env(
&workspace,
&["--output-format", "json", "agents"],
&[
("HOME", home.to_str().expect("utf8 home")),
(
"CLAW_CONFIG_HOME",
isolated_config.to_str().expect("utf8 config home"),
),
(
"CODEX_HOME",
isolated_codex.to_str().expect("utf8 codex home"),
),
],
);
assert_eq!(parsed["kind"], "agents");
assert_eq!(parsed["action"], "list");
assert_eq!(parsed["count"], 3);
assert_eq!(parsed["summary"]["active"], 2);
assert_eq!(parsed["summary"]["shadowed"], 1);
assert_eq!(parsed["agents"][0]["name"], "planner");
assert_eq!(parsed["agents"][0]["source"]["id"], "project_claw");
assert_eq!(parsed["agents"][0]["active"], true);
assert_eq!(parsed["agents"][1]["name"], "verifier");
assert_eq!(parsed["agents"][2]["name"], "planner");
assert_eq!(parsed["agents"][2]["active"], false);
assert_eq!(parsed["agents"][2]["shadowed_by"]["id"], "project_claw");
}
#[test]
fn bootstrap_and_system_prompt_emit_json_when_requested() {
let root = unique_temp_dir("bootstrap-system-prompt-json");
@@ -112,6 +200,41 @@ fn doctor_and_resume_status_emit_json_when_requested() {
let doctor = assert_json_command(&root, &["--output-format", "json", "doctor"]);
assert_eq!(doctor["kind"], "doctor");
assert!(doctor["message"].is_string());
let summary = doctor["summary"].as_object().expect("doctor summary");
assert!(summary["ok"].as_u64().is_some());
assert!(summary["warnings"].as_u64().is_some());
assert!(summary["failures"].as_u64().is_some());
let checks = doctor["checks"].as_array().expect("doctor checks");
assert_eq!(checks.len(), 5);
let check_names = checks
.iter()
.map(|check| {
assert!(check["status"].as_str().is_some());
assert!(check["summary"].as_str().is_some());
assert!(check["details"].is_array());
check["name"].as_str().expect("doctor check name")
})
.collect::<Vec<_>>();
assert_eq!(
check_names,
vec!["auth", "config", "workspace", "sandbox", "system"]
);
let workspace = checks
.iter()
.find(|check| check["name"] == "workspace")
.expect("workspace check");
assert!(workspace["cwd"].as_str().is_some());
assert!(workspace["in_git_repo"].is_boolean());
let sandbox = checks
.iter()
.find(|check| check["name"] == "sandbox")
.expect("sandbox check");
assert!(sandbox["filesystem_mode"].as_str().is_some());
assert!(sandbox["enabled"].is_boolean());
assert!(sandbox["fallback_reason"].is_null() || sandbox["fallback_reason"].is_string());
let session_path = root.join("session.jsonl");
fs::write(
@@ -130,7 +253,108 @@ fn doctor_and_resume_status_emit_json_when_requested() {
],
);
assert_eq!(resumed["kind"], "status");
assert_eq!(resumed["messages"], 1);
assert_eq!(resumed["model"], "restored-session");
assert_eq!(resumed["usage"]["messages"], 1);
assert!(resumed["workspace"]["cwd"].as_str().is_some());
assert!(resumed["sandbox"]["filesystem_mode"].as_str().is_some());
}
#[test]
fn resumed_inventory_commands_emit_structured_json_when_requested() {
let root = unique_temp_dir("resume-inventory-json");
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");
let session_path = root.join("session.jsonl");
fs::write(
&session_path,
"{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-inventory-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n{\"type\":\"message\",\"message\":{\"role\":\"user\",\"blocks\":[{\"type\":\"text\",\"text\":\"inventory\"}]}}\n",
)
.expect("session should write");
let mcp = assert_json_command_with_env(
&root,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 session path"),
"/mcp",
],
&[
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
],
);
assert_eq!(mcp["kind"], "mcp");
assert_eq!(mcp["action"], "list");
assert!(mcp["servers"].is_array());
let skills = assert_json_command_with_env(
&root,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 session path"),
"/skills",
],
&[
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
],
);
assert_eq!(skills["kind"], "skills");
assert_eq!(skills["action"], "list");
assert!(skills["summary"]["total"].is_number());
assert!(skills["skills"].is_array());
}
#[test]
fn resumed_version_and_init_emit_structured_json_when_requested() {
let root = unique_temp_dir("resume-version-init-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let session_path = root.join("session.jsonl");
fs::write(
&session_path,
"{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-version-init-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n",
)
.expect("session should write");
let version = assert_json_command(
&root,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 session path"),
"/version",
],
);
assert_eq!(version["kind"], "version");
assert_eq!(version["version"], env!("CARGO_PKG_VERSION"));
let init = assert_json_command(
&root,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 session path"),
"/init",
],
);
assert_eq!(init["kind"], "init");
assert!(root.join("CLAUDE.md").exists());
}
fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
@@ -180,6 +404,17 @@ fn write_upstream_fixture(root: &Path) -> PathBuf {
upstream
}
fn write_agent(root: &Path, name: &str, description: &str, model: &str, reasoning: &str) {
fs::create_dir_all(root).expect("agent root should exist");
fs::write(
root.join(format!("{name}.toml")),
format!(
"name = \"{name}\"\ndescription = \"{description}\"\nmodel = \"{model}\"\nmodel_reasoning_effort = \"{reasoning}\"\n"
),
)
.expect("agent fixture should write");
}
fn unique_temp_dir(label: &str) -> PathBuf {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -261,11 +261,61 @@ fn resumed_status_command_emits_structured_json_when_requested() {
let parsed: Value =
serde_json::from_str(stdout.trim()).expect("resume status output should be json");
assert_eq!(parsed["kind"], "status");
assert_eq!(parsed["messages"], 1);
assert_eq!(parsed["model"], "restored-session");
assert_eq!(parsed["permission_mode"], "danger-full-access");
assert_eq!(parsed["usage"]["messages"], 1);
assert!(parsed["usage"]["turns"].is_number());
assert!(parsed["workspace"]["cwd"].as_str().is_some());
assert_eq!(
parsed["workspace"]["session"],
session_path.to_str().expect("utf8 path")
);
assert!(parsed["workspace"]["changed_files"].is_number());
assert_eq!(parsed["workspace"]["loaded_config_files"].as_u64(), Some(0));
assert!(parsed["sandbox"]["filesystem_mode"].as_str().is_some());
}
#[test]
fn resumed_sandbox_command_emits_structured_json_when_requested() {
// given
let temp_dir = unique_temp_dir("resume-sandbox-json");
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
let session_path = temp_dir.join("session.jsonl");
Session::new()
.save_to_path(&session_path)
.expect("session should persist");
// when
let output = run_claw(
&temp_dir,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 path"),
"/sandbox",
],
);
// then
assert!(
output.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
let parsed: Value =
serde_json::from_str(stdout.trim()).expect("resume sandbox output should be json");
assert_eq!(parsed["kind"], "sandbox");
assert!(parsed["enabled"].is_boolean());
assert!(parsed["active"].is_boolean());
assert!(parsed["supported"].is_boolean());
assert!(parsed["filesystem_mode"].as_str().is_some());
assert!(parsed["allowed_mounts"].is_array());
assert!(parsed["markers"].is_array());
}
fn run_claw(current_dir: &Path, args: &[&str]) -> Output {