claw-code/rust/crates/rusty-claude-cli/src/main.rs

9382 lines
339 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#![allow(
dead_code,
unused_imports,
unused_variables,
clippy::unneeded_struct_pattern,
clippy::unnecessary_wraps,
clippy::unused_self,
clippy::too_many_lines,
clippy::doc_markdown,
clippy::result_large_err
)]
mod args;
mod constants;
mod doctor;
mod error;
mod formatting;
mod session;
mod init;
mod input;
mod model;
mod models;
mod permission;
mod progress;
mod repl;
mod render;
pub(crate) use args::*;
pub(crate) use constants::*;
pub(crate) use doctor::*;
pub(crate) use error::*;
pub(crate) use formatting::*;
pub(crate) use session::*;
pub(crate) use model::*;
pub(crate) use models::*;
pub(crate) use permission::*;
pub(crate) use progress::*;
use std::collections::BTreeSet;
use std::env;
use std::fs;
use std::io::{self, IsTerminal, Read, Write};
use std::net::TcpListener;
use std::ops::{Deref, DerefMut};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant, UNIX_EPOCH};
use api::{
detect_provider_kind, resolve_startup_auth_source, AnthropicClient, AuthSource,
ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, MessageResponse,
OutputContentBlock, PromptCache, ProviderClient as ApiProviderClient, ProviderKind,
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
};
use commands::{
classify_skills_slash_command, 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,
render_slash_command_help_filtered, resolve_skill_invocation, resume_supported_slash_commands,
slash_command_specs, validate_slash_command_input, SkillSlashDispatch, SlashCommand,
};
use compat_harness::{extract_manifest, UpstreamPaths};
use init::initialize_repo;
use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry};
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
use runtime::{
check_base_commit, format_stale_base_warning, format_usd, load_oauth_credentials,
load_system_prompt, pricing_for_model, resolve_expected_base, resolve_sandbox_status,
ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource,
ContentBlock, ConversationMessage, ConversationRuntime, McpServer, McpServerManager,
McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode, PermissionPolicy,
ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError, Session, TokenUsage,
ToolError, ToolExecutor, UsageTracker,
};
use serde::Deserialize;
use serde_json::{json, Map, Value};
use tools::{
execute_tool, mvp_tool_specs, GlobalToolRegistry, RuntimeToolDefinition, ToolSearchOutput,
};
type RuntimePluginStateBuildOutput = (
Option<Arc<Mutex<RuntimeMcpState>>>,
Vec<RuntimeToolDefinition>,
);
fn main() {
if let Err(error) = run() {
// 出错逻辑
let message = format!(
"{:?}\n\nStack trace:\n{}",
error,
std::backtrace::Backtrace::force_capture()
);
// When --output-format json is active, emit errors as JSON so downstream
// tools can parse failures the same way they parse successes (ROADMAP #42).
let argv: Vec<String> = std::env::args().collect();
let json_output = argv
.windows(2)
.any(|w| w[0] == "--output-format" && w[1] == "json")
|| argv.iter().any(|a| a == "--output-format=json");
if json_output {
// #77: classify error by prefix so downstream claws can route without
// regex-scraping the prose. Split short-reason from hint-runbook.
let kind = classify_error_kind(&message);
let (short_reason, hint) = split_error_hint(&message);
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"error": short_reason,
"kind": kind,
"hint": hint,
})
);
} else {
// #156: Add machine-readable error kind to text output so stderr observers
// don't need to regex-scrape the prose.
let kind = classify_error_kind(&message);
if message.contains("`claw --help`") {
eprintln!(
"[error-kind: {kind}]
error: {message}"
);
} else {
eprintln!(
"[error-kind: {kind}]
error: {message}
Run `claw --help` for usage."
);
}
}
std::process::exit(1);
}
}
/// #77: Classify a stringified error message into a machine-readable kind.
///
/// Returns a snake_case token that downstream consumers can switch on instead
/// of regex-scraping the prose. The classification is best-effort prefix/keyword
/// matching against the error messages produced throughout the CLI surface.
fn classify_error_kind(message: &str) -> &'static str {
// Check specific patterns first (more specific before generic)
if message.contains("missing Anthropic credentials") {
"missing_credentials"
} else if message.contains("Manifest source files are missing") {
"missing_manifests"
} else if message.contains("no worker state file found") {
"missing_worker_state"
} else if message.contains("session not found") {
"session_not_found"
} else if message.contains("failed to restore session") {
"session_load_failed"
} else if message.contains("no managed sessions found") {
"no_managed_sessions"
} else if message.contains("unrecognized argument") || message.contains("unknown option") {
"cli_parse"
} else if message.contains("invalid model syntax") {
"invalid_model_syntax"
} else if message.contains("is not yet implemented") {
"unsupported_command"
} else if message.contains("unsupported resumed command") {
"unsupported_resumed_command"
} else if message.contains("confirmation required") {
"confirmation_required"
} else if message.contains("api failed") || message.contains("api returned") {
"api_http_error"
} else {
"unknown"
}
}
/// #77: Split a multi-line error message into (short_reason, optional_hint).
///
/// The short_reason is the first line (up to the first newline), and the hint
/// is the remaining text or `None` if there's no newline. This prevents the
/// runbook prose from being stuffed into the `error` field that downstream
/// parsers expect to be the short reason alone.
fn split_error_hint(message: &str) -> (String, Option<String>) {
match message.split_once('\n') {
Some((short, hint)) => (short.to_string(), Some(hint.trim().to_string())),
None => (message.to_string(), None),
}
}
/// Read piped stdin content when stdin is not a terminal.
///
/// Returns `None` when stdin is attached to a terminal (interactive REPL use),
/// when reading fails, or when the piped content is empty after trimming.
/// Returns `Some(raw_content)` when a pipe delivered non-empty content.
fn read_piped_stdin() -> Option<String> {
if io::stdin().is_terminal() {
return None;
}
let mut buffer = String::new();
if io::stdin().read_to_string(&mut buffer).is_err() {
return None;
}
if buffer.trim().is_empty() {
return None;
}
Some(buffer)
}
/// Merge a piped stdin payload into a prompt argument.
///
/// When `stdin_content` is `None` or empty after trimming, the prompt is
/// returned unchanged. Otherwise the trimmed stdin content is appended to the
/// prompt separated by a blank line so the model sees the prompt first and the
/// piped context immediately after it.
fn merge_prompt_with_stdin(prompt: &str, stdin_content: Option<&str>) -> String {
let Some(raw) = stdin_content else {
return prompt.to_string();
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return prompt.to_string();
}
if prompt.is_empty() {
return trimmed.to_string();
}
format!("{prompt}\n\n{trimmed}")
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().skip(1).collect();
// 添加打印args 的日志
println!("args: {:?}", args);
match parse_args(&args)? {
// 导出所有工具/插件的 manifest 清单文件到指定目录
CliAction::DumpManifests {
output_format,
manifests_dir,
} => dump_manifests(manifests_dir.as_deref(), output_format)?,
// 打印启动引导计划(初始化阶段的工作流规划)
CliAction::BootstrapPlan { output_format } => print_bootstrap_plan(output_format)?,
// 查看可用的 Agent 信息列表
CliAction::Agents {
args,
output_format,
} => LiveCli::print_agents(args.as_deref(), output_format)?,
CliAction::Mcp {
args,
output_format,
} => LiveCli::print_mcp(args.as_deref(), output_format)?,
CliAction::Skills {
args,
output_format,
} => LiveCli::print_skills(args.as_deref(), output_format)?,
// 查看和管理插件列表(安装/卸载/详情)
CliAction::Plugins {
action,
target,
output_format,
} => LiveCli::print_plugins(action.as_deref(), target.as_deref(), output_format)?,
// 打印当前会话的系统提示词完整内容,用于调试和审查
CliAction::PrintSystemPrompt {
cwd,
date,
output_format,
} => print_system_prompt(cwd, date, output_format)?,
// 输出版本号
CliAction::Version { output_format } => print_version(output_format)?,
// 恢复一个已保存的历史会话,可追加命令继续执行
CliAction::ResumeSession {
session_path,
commands,
output_format,
} => resume_session(&session_path, &commands, output_format),
// 输出当前工作区状态快照(模型、权限、分支、工具白名单等)
CliAction::Status {
model,
model_flag_raw,
permission_mode,
output_format,
allowed_tools,
} => print_status_snapshot(
&model,
model_flag_raw.as_deref(),
permission_mode,
output_format,
allowed_tools.as_ref(),
)?,
// 输出沙箱隔离环境的状态信息
CliAction::Sandbox { output_format } => print_sandbox_status_snapshot(output_format)?,
// 单次提示模式:执行一轮对话后退出,适用于脚本/管道调用
CliAction::Prompt {
prompt,
model,
output_format,
allowed_tools,
permission_mode,
compact,
base_commit,
reasoning_effort,
allow_broad_cwd,
} => {
enforce_broad_cwd_policy(allow_broad_cwd, output_format)?;
run_stale_base_preflight(base_commit.as_deref());
// Only consume piped stdin as prompt context when the permission
// mode is fully unattended. In modes where the permission
// prompter may invoke CliPermissionPrompter::decide(), stdin
// must remain available for interactive approval; otherwise the
// prompter's read_line() would hit EOF and deny every request.
let stdin_context = if matches!(permission_mode, PermissionMode::DangerFullAccess) {
read_piped_stdin()
} else {
None
};
let effective_prompt = merge_prompt_with_stdin(&prompt, stdin_context.as_deref());
let resolved_model = resolve_repl_model(model);
let mut cli = LiveCli::new(resolved_model, true, allowed_tools, permission_mode)?;
cli.set_reasoning_effort(reasoning_effort);
cli.run_turn_with_output(&effective_prompt, output_format, compact)?;
}
// 环境诊断检查,验证配置和依赖是否正常
CliAction::Doctor { output_format } => run_doctor(output_format)?,
// 输出 Agent Control Protocol 状态信息
CliAction::Acp { output_format } => print_acp_status(output_format)?,
// 输出后台 worker 的运行状态
CliAction::State { output_format } => run_worker_state(output_format)?,
// 初始化工作区配置(生成 .claw.json 等)
CliAction::Init { output_format } => run_init(output_format)?,
// 查看当前生效的配置信息(可指定 section 过滤),纯本地只读操作
CliAction::Config {
section,
output_format,
} => match output_format {
CliOutputFormat::Text => {
println!("{}", render_config_report(section.as_deref())?);
}
CliOutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(&render_config_json(section.as_deref())?)?
);
}
},
// 查看 git 工作区变更差异摘要(未提交的改动),纯本地只读操作
CliAction::Diff { output_format } => match output_format {
CliOutputFormat::Text => {
println!("{}", render_diff_report()?);
}
CliOutputFormat::Json => {
let cwd = env::current_dir()?;
println!(
"{}",
serde_json::to_string_pretty(&render_diff_json_for(&cwd)?)?
);
}
},
// 导出指定会话记录到文件(支持 JSON/Markdown 等格式)
CliAction::Export {
session_reference,
output_path,
output_format,
} => run_export(&session_reference, output_path.as_deref(), output_format)?,
// 交互式 REPL 模式:进入持续对话循环,直到用户主动退出
CliAction::Repl {
model,
allowed_tools,
permission_mode,
base_commit,
reasoning_effort,
allow_broad_cwd,
} => run_repl(
model,
allowed_tools,
permission_mode,
base_commit,
reasoning_effort,
allow_broad_cwd,
)?,
// 打印特定子命令的帮助详情(如 claw status --help
CliAction::HelpTopic(topic) => print_help_topic(topic),
// 打印通用帮助信息
CliAction::Help { output_format } => print_help(output_format)?,
}
Ok(())
}
fn run_worker_state(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let state_path = cwd.join(".claw").join("worker-state.json");
if !state_path.exists() {
// #139: this error used to say "run a worker first" without telling
// callers how to run one. "worker" is an internal concept (there is
// no `claw worker` subcommand), so claws/CI had no discoverable path
// from the error to a fix. Emit an actionable, structured error that
// names the two concrete commands that produce worker state.
//
// Format in both text and JSON modes is stable so scripts can match:
// error: no worker state file found at <path>
// Hint: worker state is written by the interactive REPL or a non-interactive prompt.
// Run: claw # start the REPL (writes state on first turn)
// Or: claw prompt <text> # run one non-interactive turn
// Then rerun: claw state [--output-format json]
return Err(format!(
"no worker state file found at {path}\n Hint: worker state is written by the interactive REPL or a non-interactive prompt.\n Run: claw # start the REPL (writes state on first turn)\n Or: claw prompt <text> # run one non-interactive turn\n Then rerun: claw state [--output-format json]",
path = state_path.display()
)
.into());
}
let raw = std::fs::read_to_string(&state_path)?;
match output_format {
CliOutputFormat::Text => println!("{raw}"),
CliOutputFormat::Json => {
// Validate it parses as JSON before re-emitting
let _: serde_json::Value = serde_json::from_str(&raw)?;
println!("{raw}");
}
}
Ok(())
}
/// the same surface the in-process agent loop uses.
fn run_mcp_serve() -> Result<(), Box<dyn std::error::Error>> {
let tools = mvp_tool_specs()
.into_iter()
.map(|spec| McpTool {
name: spec.name.to_string(),
description: Some(spec.description.to_string()),
input_schema: Some(spec.input_schema),
annotations: None,
meta: None,
})
.collect();
let spec = McpServerSpec {
server_name: "claw".to_string(),
server_version: VERSION.to_string(),
tools,
tool_handler: Box::new(execute_tool),
};
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime.block_on(async move {
let mut server = McpServer::new(spec);
server.run().await
})?;
Ok(())
}
#[allow(clippy::too_many_lines)]
pub(crate) fn resume_command_can_absorb_token(current_command: &str, token: &str) -> bool {
matches!(
SlashCommand::parse(current_command),
Ok(Some(SlashCommand::Export { path: None }))
) && !looks_like_slash_command_token(token)
}
pub(crate) fn looks_like_slash_command_token(token: &str) -> bool {
let trimmed = token.trim_start();
let Some(name) = trimmed.strip_prefix('/').and_then(|value| {
value
.split_whitespace()
.next()
.map(str::trim)
.filter(|value| !value.is_empty())
}) else {
return false;
};
slash_command_specs()
.iter()
.any(|spec| spec.name == name || spec.aliases.contains(&name))
}
fn dump_manifests(
manifests_dir: Option<&Path>,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
dump_manifests_at_path(&workspace_dir, manifests_dir, output_format)
}
const DUMP_MANIFESTS_OVERRIDE_HINT: &str =
"Hint: set CLAUDE_CODE_UPSTREAM=/path/to/upstream or pass `claw dump-manifests --manifests-dir /path/to/upstream`.";
// Internal function for testing that accepts a workspace directory path.
fn dump_manifests_at_path(
workspace_dir: &std::path::Path,
manifests_dir: Option<&Path>,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let paths = if let Some(dir) = manifests_dir {
let resolved = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
UpstreamPaths::from_repo_root(resolved)
} else {
// Surface the resolved path in the error so users can diagnose missing
// manifest files without guessing what path the binary expected.
let resolved = workspace_dir
.canonicalize()
.unwrap_or_else(|_| workspace_dir.to_path_buf());
UpstreamPaths::from_workspace_dir(&resolved)
};
let source_root = paths.repo_root();
if !source_root.exists() {
return Err(format!(
"Manifest source directory does not exist.\n looked in: {}\n {DUMP_MANIFESTS_OVERRIDE_HINT}",
source_root.display(),
)
.into());
}
let required_paths = [
("src/commands.ts", paths.commands_path()),
("src/tools.ts", paths.tools_path()),
("src/entrypoints/cli.tsx", paths.cli_path()),
];
let missing = required_paths
.iter()
.filter_map(|(label, path)| (!path.is_file()).then_some(*label))
.collect::<Vec<_>>();
if !missing.is_empty() {
return Err(format!(
"Manifest source files are missing.\n repo root: {}\n missing: {}\n {DUMP_MANIFESTS_OVERRIDE_HINT}",
source_root.display(),
missing.join(", "),
)
.into());
}
match extract_manifest(&paths) {
Ok(manifest) => {
match output_format {
CliOutputFormat::Text => {
println!("commands: {}", manifest.commands.entries().len());
println!("tools: {}", manifest.tools.entries().len());
println!("bootstrap phases: {}", manifest.bootstrap.phases().len());
}
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "dump-manifests",
"commands": manifest.commands.entries().len(),
"tools": manifest.tools.entries().len(),
"bootstrap_phases": manifest.bootstrap.phases().len(),
}))?
),
}
Ok(())
}
Err(error) => Err(format!(
"failed to extract manifests: {error}\n looked in: {path}\n {DUMP_MANIFESTS_OVERRIDE_HINT}",
path = paths.repo_root().display()
)
.into()),
}
}
fn print_bootstrap_plan(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let phases = runtime::BootstrapPlan::claude_code_default()
.phases()
.iter()
.map(|phase| format!("{phase:?}"))
.collect::<Vec<_>>();
match output_format {
CliOutputFormat::Text => {
for phase in &phases {
println!("- {phase}");
}
}
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "bootstrap-plan",
"phases": phases,
}))?
),
}
Ok(())
}
fn print_system_prompt(
cwd: PathBuf,
date: String,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let sections = load_system_prompt(cwd, date, env::consts::OS, "unknown")?;
let message = sections.join(
"
",
);
match output_format {
CliOutputFormat::Text => println!("{message}"),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "system-prompt",
"message": message,
"sections": sections,
}))?
),
}
Ok(())
}
fn print_version(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match output_format {
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,
})
}
#[allow(clippy::too_many_lines)]
fn resume_session(session_path: &Path, commands: &[String], output_format: CliOutputFormat) {
let session_reference = session_path.display().to_string();
let (handle, session) = match load_session_reference(&session_reference) {
Ok(loaded) => loaded,
Err(error) => {
if output_format == CliOutputFormat::Json {
// #77: classify session load errors for downstream consumers
let full_message = format!("failed to restore session: {error}");
let kind = classify_error_kind(&full_message);
let (short_reason, hint) = split_error_hint(&full_message);
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"error": short_reason,
"kind": kind,
"hint": hint,
})
);
} else {
eprintln!("failed to restore session: {error}");
}
std::process::exit(1);
}
};
let resolved_path = handle.path.clone();
if commands.is_empty() {
if output_format == CliOutputFormat::Json {
println!(
"{}",
serde_json::json!({
"kind": "restored",
"session_id": session.session_id,
"path": handle.path.display().to_string(),
"message_count": session.messages.len(),
})
);
} else {
println!(
"Restored session from {} ({} messages).",
handle.path.display(),
session.messages.len()
);
}
return;
}
let mut session = session;
for raw_command in commands {
// Intercept spec commands that have no parse arm before calling
// SlashCommand::parse — they return Err(SlashCommandParseError) which
// formats as the confusing circular "Did you mean /X?" message.
// STUB_COMMANDS covers both completions-filtered stubs and parse-less
// spec entries; treat both as unsupported in resume mode.
{
let cmd_root = raw_command
.trim_start_matches('/')
.split_whitespace()
.next()
.unwrap_or("");
if STUB_COMMANDS.contains(&cmd_root) {
if output_format == CliOutputFormat::Json {
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"error": format!("/{cmd_root} is not yet implemented in this build"),
"kind": "unsupported_command",
"command": raw_command,
})
);
} else {
eprintln!("/{cmd_root} is not yet implemented in this build");
}
std::process::exit(2);
}
}
let command = match SlashCommand::parse(raw_command) {
Ok(Some(command)) => command,
Ok(None) => {
if output_format == CliOutputFormat::Json {
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"error": format!("unsupported resumed command: {raw_command}"),
"kind": "unsupported_resumed_command",
"command": raw_command,
})
);
} else {
eprintln!("unsupported resumed command: {raw_command}");
}
std::process::exit(2);
}
Err(error) => {
if output_format == CliOutputFormat::Json {
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"error": error.to_string(),
"command": raw_command,
})
);
} else {
eprintln!("{error}");
}
std::process::exit(2);
}
};
match run_resume_command(&resolved_path, &session, &command) {
Ok(ResumeCommandOutcome {
session: next_session,
message,
json,
}) => {
session = next_session;
if output_format == CliOutputFormat::Json {
if let Some(value) = json {
println!(
"{}",
serde_json::to_string_pretty(&value)
.expect("resume command json output")
);
} else if let Some(message) = message {
println!("{message}");
}
} else if let Some(message) = message {
println!("{message}");
}
}
Err(error) => {
if output_format == CliOutputFormat::Json {
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"error": error.to_string(),
"command": raw_command,
})
);
} else {
eprintln!("{error}");
}
std::process::exit(2);
}
}
}
}
#[derive(Debug, Clone)]
struct ResumeCommandOutcome {
session: Session,
message: Option<String>,
json: Option<serde_json::Value>,
}
#[derive(Debug, Clone)]
struct StatusContext {
cwd: PathBuf,
session_path: Option<PathBuf>,
loaded_config_files: usize,
discovered_config_files: usize,
memory_file_count: usize,
project_root: Option<PathBuf>,
git_branch: Option<String>,
git_summary: GitWorkspaceSummary,
session_lifecycle: SessionLifecycleSummary,
sandbox_status: runtime::SandboxStatus,
/// #143: when `.claw.json` (or another loaded config file) fails to parse,
/// we capture the parse error here and still populate every field that
/// doesn't depend on runtime config (workspace, git, sandbox defaults,
/// discovery counts). Top-level JSON output then reports
/// `status: "degraded"` so claws can distinguish "status ran but config
/// is broken" from "status ran cleanly".
config_load_error: Option<String>,
}
#[derive(Debug, Clone, Copy)]
struct StatusUsage {
message_count: usize,
turns: u32,
latest: TokenUsage,
cumulative: TokenUsage,
estimated_tokens: usize,
}
#[allow(clippy::struct_field_names)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct GitWorkspaceSummary {
changed_files: usize,
staged_files: usize,
unstaged_files: usize,
untracked_files: usize,
conflicted_files: usize,
}
impl GitWorkspaceSummary {
fn is_clean(self) -> bool {
self.changed_files == 0
}
fn headline(self) -> String {
if self.is_clean() {
"clean".to_string()
} else {
let mut details = Vec::new();
if self.staged_files > 0 {
details.push(format!("{} staged", self.staged_files));
}
if self.unstaged_files > 0 {
details.push(format!("{} unstaged", self.unstaged_files));
}
if self.untracked_files > 0 {
details.push(format!("{} untracked", self.untracked_files));
}
if self.conflicted_files > 0 {
details.push(format!("{} conflicted", self.conflicted_files));
}
format!(
"dirty · {} files · {}",
self.changed_files,
details.join(", ")
)
}
}
}
pub(crate) fn parse_git_status_metadata(status: Option<&str>) -> (Option<PathBuf>, Option<String>) {
parse_git_status_metadata_for(
&env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
status,
)
}
fn parse_git_status_branch(status: Option<&str>) -> Option<String> {
let status = status?;
let first_line = status.lines().next()?;
let line = first_line.strip_prefix("## ")?;
if line.starts_with("HEAD") {
return Some("detached HEAD".to_string());
}
let branch = line.split(['.', ' ']).next().unwrap_or_default().trim();
if branch.is_empty() {
None
} else {
Some(branch.to_string())
}
}
fn parse_git_workspace_summary(status: Option<&str>) -> GitWorkspaceSummary {
let mut summary = GitWorkspaceSummary::default();
let Some(status) = status else {
return summary;
};
for line in status.lines() {
if line.starts_with("## ") || line.trim().is_empty() {
continue;
}
summary.changed_files += 1;
let mut chars = line.chars();
let index_status = chars.next().unwrap_or(' ');
let worktree_status = chars.next().unwrap_or(' ');
if index_status == '?' && worktree_status == '?' {
summary.untracked_files += 1;
continue;
}
if index_status != ' ' {
summary.staged_files += 1;
}
if worktree_status != ' ' {
summary.unstaged_files += 1;
}
if (matches!(index_status, 'U' | 'A') && matches!(worktree_status, 'U' | 'A'))
|| index_status == 'U'
|| worktree_status == 'U'
{
summary.conflicted_files += 1;
}
}
summary
}
fn resolve_git_branch_for(cwd: &Path) -> Option<String> {
let branch = run_git_capture_in(cwd, &["branch", "--show-current"])?;
let branch = branch.trim();
if !branch.is_empty() {
return Some(branch.to_string());
}
let fallback = run_git_capture_in(cwd, &["rev-parse", "--abbrev-ref", "HEAD"])?;
let fallback = fallback.trim();
if fallback.is_empty() {
None
} else if fallback == "HEAD" {
Some("detached HEAD".to_string())
} else {
Some(fallback.to_string())
}
}
fn run_git_capture_in(cwd: &Path, args: &[&str]) -> Option<String> {
let output = std::process::Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout).ok()
}
fn find_git_root_in(cwd: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
let output = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(cwd)
.output()?;
if !output.status.success() {
return Err("not a git repository".into());
}
let path = String::from_utf8(output.stdout)?.trim().to_string();
if path.is_empty() {
return Err("empty git root".into());
}
Ok(PathBuf::from(path))
}
fn parse_git_status_metadata_for(
cwd: &Path,
status: Option<&str>,
) -> (Option<PathBuf>, Option<String>) {
let branch = resolve_git_branch_for(cwd).or_else(|| parse_git_status_branch(status));
let project_root = find_git_root_in(cwd).ok();
(project_root, branch)
}
#[allow(clippy::too_many_lines)]
fn run_resume_command(
session_path: &Path,
session: &Session,
command: &SlashCommand,
) -> Result<ResumeCommandOutcome, Box<dyn std::error::Error>> {
match command {
SlashCommand::Help => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_repl_help()),
json: Some(serde_json::json!({ "kind": "help", "text": render_repl_help() })),
}),
SlashCommand::Compact => {
let result = runtime::compact_session(
session,
CompactionConfig {
max_estimated_tokens: 0,
..CompactionConfig::default()
},
);
let removed = result.removed_message_count;
let kept = result.compacted_session.messages.len();
let skipped = removed == 0;
result.compacted_session.save_to_path(session_path)?;
Ok(ResumeCommandOutcome {
session: result.compacted_session,
message: Some(format_compact_report(removed, kept, skipped)),
json: Some(serde_json::json!({
"kind": "compact",
"skipped": skipped,
"removed_messages": removed,
"kept_messages": kept,
})),
})
}
SlashCommand::Clear { confirm } => {
if !confirm {
return Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(
"clear: confirmation required; rerun with /clear --confirm".to_string(),
),
json: Some(serde_json::json!({
"kind": "error",
"error": "confirmation required",
"hint": "rerun with /clear --confirm",
})),
});
}
let backup_path = write_session_clear_backup(session, session_path)?;
let previous_session_id = session.session_id.clone();
let cleared = new_cli_session()?;
let new_session_id = cleared.session_id.clone();
cleared.save_to_path(session_path)?;
Ok(ResumeCommandOutcome {
session: cleared,
message: Some(format!(
"Session cleared\n Mode resumed session reset\n Previous session {previous_session_id}\n Backup {}\n Resume previous claw --resume {}\n New session {new_session_id}\n Session file {}",
backup_path.display(),
backup_path.display(),
session_path.display()
)),
json: Some(serde_json::json!({
"kind": "clear",
"previous_session_id": previous_session_id,
"new_session_id": new_session_id,
"backup": backup_path.display().to_string(),
"session_file": session_path.display().to_string(),
})),
})
}
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(
session.model.as_deref().unwrap_or("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,
None, // #148: resumed sessions don't have flag provenance
)),
json: Some(status_json_value(
session.model.as_deref(),
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,
None, // #148: resumed sessions don't have flag provenance
None,
)),
})
}
SlashCommand::Sandbox => {
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(&status)),
json: Some(sandbox_json_value(&status)),
})
}
SlashCommand::Cost => {
let usage = UsageTracker::from_session(session).cumulative_usage();
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format_cost_report(usage)),
json: Some(serde_json::json!({
"kind": "cost",
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
"cache_creation_input_tokens": usage.cache_creation_input_tokens,
"cache_read_input_tokens": usage.cache_read_input_tokens,
"total_tokens": usage.total_tokens(),
})),
})
}
SlashCommand::Config { section } => {
let message = render_config_report(section.as_deref())?;
let json = render_config_json(section.as_deref())?;
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(message),
json: Some(json),
})
}
SlashCommand::Mcp { action, target } => {
let cwd = env::current_dir()?;
let args = match (action.as_deref(), target.as_deref()) {
(None, None) => None,
(Some(action), None) => Some(action.to_string()),
(Some(action), Some(target)) => Some(format!("{action} {target}")),
(None, Some(target)) => Some(target.to_string()),
};
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: Some(render_memory_json()?),
}),
SlashCommand::Init => {
// #142: run the init once, then render both text + structured JSON
// from the same InitReport so both surfaces stay in sync.
let cwd = env::current_dir()?;
let report = crate::init::initialize_repo(&cwd)?;
let message = report.render();
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(message.clone()),
json: Some(init_json_value(&report, &message)),
})
}
SlashCommand::Diff => {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let message = render_diff_report_for(&cwd)?;
let json = render_diff_json_for(&cwd)?;
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(message),
json: Some(json),
})
}
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)?;
fs::write(&export_path, render_export_text(session))?;
let msg_count = session.messages.len();
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format!(
"Export\n Result wrote transcript\n File {}\n Messages {}",
export_path.display(),
msg_count,
)),
json: Some(serde_json::json!({
"kind": "export",
"file": export_path.display().to_string(),
"message_count": msg_count,
})),
})
}
SlashCommand::Agents { args } => {
let cwd = env::current_dir()?;
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?),
json: Some(serde_json::json!({
"kind": "agents",
"text": handle_agents_slash_command(args.as_deref(), &cwd)?,
})),
})
}
SlashCommand::Skills { args } => {
if let SkillSlashDispatch::Invoke(_) = classify_skills_slash_command(args.as_deref()) {
return Err(
"resumed /skills invocations are interactive-only; start `claw` and run `/skills <skill>` in the REPL".into(),
);
}
let cwd = env::current_dir()?;
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 => {
let report = render_doctor_report()?;
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(report.render()),
json: Some(report.json_value()),
})
}
SlashCommand::Stats => {
let usage = UsageTracker::from_session(session).cumulative_usage();
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format_cost_report(usage)),
json: Some(serde_json::json!({
"kind": "stats",
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
"cache_creation_input_tokens": usage.cache_creation_input_tokens,
"cache_read_input_tokens": usage.cache_read_input_tokens,
"total_tokens": usage.total_tokens(),
})),
})
}
SlashCommand::History { count } => {
let limit = parse_history_count(count.as_deref())
.map_err(|error| -> Box<dyn std::error::Error> { error.into() })?;
let entries = collect_session_prompt_history(session);
let shown: Vec<_> = entries.iter().rev().take(limit).rev().collect();
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_prompt_history_report(&entries, limit)),
json: Some(serde_json::json!({
"kind": "history",
"total": entries.len(),
"showing": shown.len(),
"entries": shown.iter().map(|e| serde_json::json!({
"timestamp_ms": e.timestamp_ms,
"text": e.text,
})).collect::<Vec<_>>(),
})),
})
}
SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()),
// /session list can be served from the sessions directory without a live session.
SlashCommand::Session {
action: Some(ref act),
..
} if act == "list" => {
let sessions = list_managed_sessions().unwrap_or_default();
let session_ids: Vec<String> = sessions.iter().map(|s| s.id.clone()).collect();
let active_id = session.session_id.clone();
let text = render_session_list(&active_id).unwrap_or_else(|e| format!("error: {e}"));
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(text),
json: Some(serde_json::json!({
"kind": "session_list",
"sessions": session_ids,
"active": active_id,
})),
})
}
SlashCommand::Bughunter { .. }
| SlashCommand::Commit { .. }
| SlashCommand::Pr { .. }
| SlashCommand::Issue { .. }
| SlashCommand::Ultraplan { .. }
| SlashCommand::Teleport { .. }
| SlashCommand::DebugToolCall { .. }
| SlashCommand::Resume { .. }
| SlashCommand::Model { .. }
| SlashCommand::Permissions { .. }
| SlashCommand::Session { .. }
| SlashCommand::Plugins { .. }
| SlashCommand::Login
| SlashCommand::Logout
| SlashCommand::Vim
| SlashCommand::Upgrade
| SlashCommand::Share
| SlashCommand::Feedback
| SlashCommand::Files
| SlashCommand::Fast
| SlashCommand::Exit
| SlashCommand::Summary
| SlashCommand::Desktop
| SlashCommand::Brief
| SlashCommand::Advisor
| SlashCommand::Stickers
| SlashCommand::Insights
| SlashCommand::Thinkback
| SlashCommand::ReleaseNotes
| SlashCommand::SecurityReview
| SlashCommand::Keybindings
| SlashCommand::PrivacySettings
| SlashCommand::Plan { .. }
| SlashCommand::Review { .. }
| SlashCommand::Tasks { .. }
| SlashCommand::Theme { .. }
| SlashCommand::Voice { .. }
| SlashCommand::Usage { .. }
| SlashCommand::Rename { .. }
| SlashCommand::Copy { .. }
| SlashCommand::Hooks { .. }
| SlashCommand::Context { .. }
| SlashCommand::Color { .. }
| SlashCommand::Effort { .. }
| SlashCommand::Branch { .. }
| SlashCommand::Rewind { .. }
| SlashCommand::Ide { .. }
| SlashCommand::Tag { .. }
| SlashCommand::OutputStyle { .. }
| SlashCommand::AddDir { .. } => Err("unsupported resumed slash command".into()),
}
}
/// Detect if the current working directory is "broad" (home directory or
/// filesystem root). Returns the cwd path if broad, None otherwise.
fn detect_broad_cwd() -> Option<PathBuf> {
let Ok(cwd) = env::current_dir() else {
return None;
};
let is_home = env::var_os("HOME")
.or_else(|| env::var_os("USERPROFILE"))
.is_some_and(|h| Path::new(&h) == cwd);
let is_root = cwd.parent().is_none();
if is_home || is_root {
Some(cwd)
} else {
None
}
}
/// Enforce the broad-CWD policy: when running from home or root, either
/// require the --allow-broad-cwd flag, or prompt for confirmation (interactive),
/// or exit with an error (non-interactive).
fn enforce_broad_cwd_policy(
allow_broad_cwd: bool,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
if allow_broad_cwd {
return Ok(());
}
let Some(cwd) = detect_broad_cwd() else {
return Ok(());
};
let is_interactive = io::stdin().is_terminal();
if is_interactive {
// Interactive mode: print warning and ask for confirmation
eprintln!(
"Warning: claw is running from a very broad directory ({}).\n\
The agent can read and search everything under this path.\n\
Consider running from inside your project: cd /path/to/project && claw",
cwd.display()
);
eprint!("Continue anyway? [y/N]: ");
io::stderr().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let trimmed = input.trim().to_lowercase();
if trimmed != "y" && trimmed != "yes" {
eprintln!("Aborted.");
std::process::exit(0);
}
Ok(())
} else {
// Non-interactive mode: exit with error (JSON or text)
let message = format!(
"claw is running from a very broad directory ({}). \
The agent can read and search everything under this path. \
Use --allow-broad-cwd to proceed anyway, \
or run from inside your project: cd /path/to/project && claw",
cwd.display()
);
match output_format {
CliOutputFormat::Json => {
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"error": message,
})
);
}
CliOutputFormat::Text => {
eprintln!("error: {message}");
}
}
std::process::exit(1);
}
}
fn run_stale_base_preflight(flag_value: Option<&str>) {
let Ok(cwd) = env::current_dir() else {
return;
};
let source = resolve_expected_base(flag_value, &cwd);
let state = check_base_commit(&cwd, source.as_ref());
if let Some(warning) = format_stale_base_warning(&state) {
eprintln!("{warning}");
}
}
#[allow(clippy::needless_pass_by_value)]
fn run_repl(
model: String,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
base_commit: Option<String>,
reasoning_effort: Option<String>,
allow_broad_cwd: bool,
) -> Result<(), Box<dyn std::error::Error>> {
enforce_broad_cwd_policy(allow_broad_cwd, CliOutputFormat::Text)?;
run_stale_base_preflight(base_commit.as_deref());
let resolved_model = resolve_repl_model(model);
let mut cli = LiveCli::new(resolved_model, true, allowed_tools, permission_mode)?;
cli.set_reasoning_effort(reasoning_effort);
let mut editor =
input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default());
println!("{}", cli.startup_banner());
println!("{}", format_connected_line(&cli.model));
loop {
editor.set_completions(cli.repl_completion_candidates().unwrap_or_default());
match editor.read_line()? {
input::ReadOutcome::Submit(input) => {
let trimmed = input.trim().to_string();
if trimmed.is_empty() {
continue;
}
if matches!(trimmed.as_str(), "/exit" | "/quit") {
cli.persist_session()?;
break;
}
match SlashCommand::parse(&trimmed) {
Ok(Some(command)) => {
if cli.handle_repl_command(command)? {
cli.persist_session()?;
}
continue;
}
Ok(None) => {}
Err(error) => {
eprintln!("{error}");
continue;
}
}
// Bare-word skill dispatch: if the first token of the input
// matches a known skill name, invoke it as `/skills <input>`
// rather than forwarding raw text to the LLM (ROADMAP #36).
let cwd = std::env::current_dir().unwrap_or_default();
if let Some(prompt) = try_resolve_bare_skill_prompt(&cwd, &trimmed) {
editor.push_history(input);
cli.record_prompt_history(&trimmed);
cli.run_turn(&prompt)?;
continue;
}
editor.push_history(input);
cli.record_prompt_history(&trimmed);
cli.run_turn(&trimmed)?;
}
input::ReadOutcome::Cancel => {}
input::ReadOutcome::Exit => {
cli.persist_session()?;
break;
}
}
}
Ok(())
}
struct LiveCli {
model: String,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
system_prompt: Vec<String>,
runtime: BuiltRuntime,
session: SessionHandle,
prompt_history: Vec<PromptHistoryEntry>,
}
struct RuntimePluginState {
feature_config: runtime::RuntimeFeatureConfig,
tool_registry: GlobalToolRegistry,
plugin_registry: PluginRegistry,
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
}
struct RuntimeMcpState {
runtime: tokio::runtime::Runtime,
manager: McpServerManager,
pending_servers: Vec<String>,
degraded_report: Option<runtime::McpDegradedReport>,
}
struct BuiltRuntime {
runtime: Option<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>>,
plugin_registry: PluginRegistry,
plugins_active: bool,
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
mcp_active: bool,
}
impl BuiltRuntime {
fn new(
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
plugin_registry: PluginRegistry,
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
) -> Self {
Self {
runtime: Some(runtime),
plugin_registry,
plugins_active: true,
mcp_state,
mcp_active: true,
}
}
fn with_hook_abort_signal(mut self, hook_abort_signal: runtime::HookAbortSignal) -> Self {
let runtime = self
.runtime
.take()
.expect("runtime should exist before installing hook abort signal");
self.runtime = Some(runtime.with_hook_abort_signal(hook_abort_signal));
self
}
fn shutdown_plugins(&mut self) -> Result<(), Box<dyn std::error::Error>> {
if self.plugins_active {
self.plugin_registry.shutdown()?;
self.plugins_active = false;
}
Ok(())
}
fn shutdown_mcp(&mut self) -> Result<(), Box<dyn std::error::Error>> {
if self.mcp_active {
if let Some(mcp_state) = &self.mcp_state {
mcp_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.shutdown()?;
}
self.mcp_active = false;
}
Ok(())
}
}
impl Deref for BuiltRuntime {
type Target = ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>;
fn deref(&self) -> &Self::Target {
self.runtime
.as_ref()
.expect("runtime should exist while built runtime is alive")
}
}
impl DerefMut for BuiltRuntime {
fn deref_mut(&mut self) -> &mut Self::Target {
self.runtime
.as_mut()
.expect("runtime should exist while built runtime is alive")
}
}
impl Drop for BuiltRuntime {
fn drop(&mut self) {
let _ = self.shutdown_mcp();
let _ = self.shutdown_plugins();
}
}
#[derive(Debug, Deserialize)]
struct ToolSearchRequest {
query: String,
max_results: Option<usize>,
}
#[derive(Debug, Deserialize)]
struct McpToolRequest {
#[serde(rename = "qualifiedName")]
qualified_name: Option<String>,
tool: Option<String>,
arguments: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
struct ListMcpResourcesRequest {
server: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ReadMcpResourceRequest {
server: String,
uri: String,
}
impl RuntimeMcpState {
fn new(
runtime_config: &runtime::RuntimeConfig,
) -> Result<Option<(Self, runtime::McpToolDiscoveryReport)>, Box<dyn std::error::Error>> {
let mut manager = McpServerManager::from_runtime_config(runtime_config);
if manager.server_names().is_empty() && manager.unsupported_servers().is_empty() {
return Ok(None);
}
let runtime = tokio::runtime::Runtime::new()?;
let discovery = runtime.block_on(manager.discover_tools_best_effort());
let pending_servers = discovery
.failed_servers
.iter()
.map(|failure| failure.server_name.clone())
.chain(
discovery
.unsupported_servers
.iter()
.map(|server| server.server_name.clone()),
)
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
let available_tools = discovery
.tools
.iter()
.map(|tool| tool.qualified_name.clone())
.collect::<Vec<_>>();
let failed_server_names = pending_servers.iter().cloned().collect::<BTreeSet<_>>();
let working_servers = manager
.server_names()
.into_iter()
.filter(|server_name| !failed_server_names.contains(server_name))
.collect::<Vec<_>>();
let failed_servers =
discovery
.failed_servers
.iter()
.map(|failure| runtime::McpFailedServer {
server_name: failure.server_name.clone(),
phase: runtime::McpLifecyclePhase::ToolDiscovery,
error: runtime::McpErrorSurface::new(
runtime::McpLifecyclePhase::ToolDiscovery,
Some(failure.server_name.clone()),
failure.error.clone(),
std::collections::BTreeMap::new(),
true,
),
})
.chain(discovery.unsupported_servers.iter().map(|server| {
runtime::McpFailedServer {
server_name: server.server_name.clone(),
phase: runtime::McpLifecyclePhase::ServerRegistration,
error: runtime::McpErrorSurface::new(
runtime::McpLifecyclePhase::ServerRegistration,
Some(server.server_name.clone()),
server.reason.clone(),
std::collections::BTreeMap::from([(
"transport".to_string(),
format!("{:?}", server.transport).to_ascii_lowercase(),
)]),
false,
),
}
}))
.collect::<Vec<_>>();
let degraded_report = (!failed_servers.is_empty()).then(|| {
runtime::McpDegradedReport::new(
working_servers,
failed_servers,
available_tools.clone(),
available_tools,
)
});
Ok(Some((
Self {
runtime,
manager,
pending_servers,
degraded_report,
},
discovery,
)))
}
fn shutdown(&mut self) -> Result<(), Box<dyn std::error::Error>> {
self.runtime.block_on(self.manager.shutdown())?;
Ok(())
}
fn pending_servers(&self) -> Option<Vec<String>> {
(!self.pending_servers.is_empty()).then(|| self.pending_servers.clone())
}
fn degraded_report(&self) -> Option<runtime::McpDegradedReport> {
self.degraded_report.clone()
}
fn server_names(&self) -> Vec<String> {
self.manager.server_names()
}
fn call_tool(
&mut self,
qualified_tool_name: &str,
arguments: Option<serde_json::Value>,
) -> Result<String, ToolError> {
let response = self
.runtime
.block_on(self.manager.call_tool(qualified_tool_name, arguments))
.map_err(|error| ToolError::new(error.to_string()))?;
if let Some(error) = response.error {
return Err(ToolError::new(format!(
"MCP tool `{qualified_tool_name}` returned JSON-RPC error: {} ({})",
error.message, error.code
)));
}
let result = response.result.ok_or_else(|| {
ToolError::new(format!(
"MCP tool `{qualified_tool_name}` returned no result payload"
))
})?;
serde_json::to_string_pretty(&result).map_err(|error| ToolError::new(error.to_string()))
}
fn list_resources_for_server(&mut self, server_name: &str) -> Result<String, ToolError> {
let result = self
.runtime
.block_on(self.manager.list_resources(server_name))
.map_err(|error| ToolError::new(error.to_string()))?;
serde_json::to_string_pretty(&json!({
"server": server_name,
"resources": result.resources,
}))
.map_err(|error| ToolError::new(error.to_string()))
}
fn list_resources_for_all_servers(&mut self) -> Result<String, ToolError> {
let mut resources = Vec::new();
let mut failures = Vec::new();
for server_name in self.server_names() {
match self
.runtime
.block_on(self.manager.list_resources(&server_name))
{
Ok(result) => resources.push(json!({
"server": server_name,
"resources": result.resources,
})),
Err(error) => failures.push(json!({
"server": server_name,
"error": error.to_string(),
})),
}
}
if resources.is_empty() && !failures.is_empty() {
let message = failures
.iter()
.filter_map(|failure| failure.get("error").and_then(serde_json::Value::as_str))
.collect::<Vec<_>>()
.join("; ");
return Err(ToolError::new(message));
}
serde_json::to_string_pretty(&json!({
"resources": resources,
"failures": failures,
}))
.map_err(|error| ToolError::new(error.to_string()))
}
fn read_resource(&mut self, server_name: &str, uri: &str) -> Result<String, ToolError> {
let result = self
.runtime
.block_on(self.manager.read_resource(server_name, uri))
.map_err(|error| ToolError::new(error.to_string()))?;
serde_json::to_string_pretty(&json!({
"server": server_name,
"contents": result.contents,
}))
.map_err(|error| ToolError::new(error.to_string()))
}
}
fn build_runtime_mcp_state(
runtime_config: &runtime::RuntimeConfig,
) -> Result<RuntimePluginStateBuildOutput, Box<dyn std::error::Error>> {
let Some((mcp_state, discovery)) = RuntimeMcpState::new(runtime_config)? else {
return Ok((None, Vec::new()));
};
let mut runtime_tools = discovery
.tools
.iter()
.map(mcp_runtime_tool_definition)
.collect::<Vec<_>>();
if !mcp_state.server_names().is_empty() {
runtime_tools.extend(mcp_wrapper_tool_definitions());
}
Ok((Some(Arc::new(Mutex::new(mcp_state))), runtime_tools))
}
fn mcp_runtime_tool_definition(tool: &runtime::ManagedMcpTool) -> RuntimeToolDefinition {
RuntimeToolDefinition {
name: tool.qualified_name.clone(),
description: Some(
tool.tool
.description
.clone()
.unwrap_or_else(|| format!("Invoke MCP tool `{}`.", tool.qualified_name)),
),
input_schema: tool
.tool
.input_schema
.clone()
.unwrap_or_else(|| json!({ "type": "object", "additionalProperties": true })),
required_permission: permission_mode_for_mcp_tool(&tool.tool),
}
}
fn mcp_wrapper_tool_definitions() -> Vec<RuntimeToolDefinition> {
vec![
RuntimeToolDefinition {
name: "MCPTool".to_string(),
description: Some(
"Call a configured MCP tool by its qualified name and JSON arguments.".to_string(),
),
input_schema: json!({
"type": "object",
"properties": {
"qualifiedName": { "type": "string" },
"arguments": {}
},
"required": ["qualifiedName"],
"additionalProperties": false
}),
required_permission: PermissionMode::DangerFullAccess,
},
RuntimeToolDefinition {
name: "ListMcpResourcesTool".to_string(),
description: Some(
"List MCP resources from one configured server or from every connected server."
.to_string(),
),
input_schema: json!({
"type": "object",
"properties": {
"server": { "type": "string" }
},
"additionalProperties": false
}),
required_permission: PermissionMode::ReadOnly,
},
RuntimeToolDefinition {
name: "ReadMcpResourceTool".to_string(),
description: Some("Read a specific MCP resource from a configured server.".to_string()),
input_schema: json!({
"type": "object",
"properties": {
"server": { "type": "string" },
"uri": { "type": "string" }
},
"required": ["server", "uri"],
"additionalProperties": false
}),
required_permission: PermissionMode::ReadOnly,
},
]
}
fn permission_mode_for_mcp_tool(tool: &McpTool) -> PermissionMode {
let read_only = mcp_annotation_flag(tool, "readOnlyHint");
let destructive = mcp_annotation_flag(tool, "destructiveHint");
let open_world = mcp_annotation_flag(tool, "openWorldHint");
if read_only && !destructive && !open_world {
PermissionMode::ReadOnly
} else if destructive || open_world {
PermissionMode::DangerFullAccess
} else {
PermissionMode::WorkspaceWrite
}
}
fn mcp_annotation_flag(tool: &McpTool, key: &str) -> bool {
tool.annotations
.as_ref()
.and_then(|annotations| annotations.get(key))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
struct HookAbortMonitor {
stop_tx: Option<Sender<()>>,
join_handle: Option<JoinHandle<()>>,
}
impl HookAbortMonitor {
fn spawn(abort_signal: runtime::HookAbortSignal) -> Self {
Self::spawn_with_waiter(abort_signal, move |stop_rx, abort_signal| {
let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
else {
return;
};
runtime.block_on(async move {
let wait_for_stop = tokio::task::spawn_blocking(move || {
let _ = stop_rx.recv();
});
tokio::select! {
result = tokio::signal::ctrl_c() => {
if result.is_ok() {
abort_signal.abort();
}
}
_ = wait_for_stop => {}
}
});
})
}
fn spawn_with_waiter<F>(abort_signal: runtime::HookAbortSignal, wait_for_interrupt: F) -> Self
where
F: FnOnce(Receiver<()>, runtime::HookAbortSignal) + Send + 'static,
{
let (stop_tx, stop_rx) = mpsc::channel();
let join_handle = thread::spawn(move || wait_for_interrupt(stop_rx, abort_signal));
Self {
stop_tx: Some(stop_tx),
join_handle: Some(join_handle),
}
}
fn stop(mut self) {
if let Some(stop_tx) = self.stop_tx.take() {
let _ = stop_tx.send(());
}
if let Some(join_handle) = self.join_handle.take() {
let _ = join_handle.join();
}
}
}
fn print_status_snapshot(
model: &str,
model_flag_raw: Option<&str>,
permission_mode: PermissionMode,
output_format: CliOutputFormat,
allowed_tools: Option<&AllowedToolSet>,
) -> Result<(), Box<dyn std::error::Error>> {
let usage = StatusUsage {
message_count: 0,
turns: 0,
latest: TokenUsage::default(),
cumulative: TokenUsage::default(),
estimated_tokens: 0,
};
let context = status_context(None)?;
// #148: resolve model provenance. If user passed --model, source is
// "flag" with the raw input preserved. Otherwise probe env -> config
// -> default and record the winning source.
let provenance = match model_flag_raw {
Some(raw) => ModelProvenance {
resolved: model.to_string(),
raw: Some(raw.to_string()),
source: ModelSource::Flag,
},
None => ModelProvenance::from_env_or_config_or_default(model),
};
match output_format {
CliOutputFormat::Text => println!(
"{}",
format_status_report(
&provenance.resolved,
usage,
permission_mode.as_str(),
&context,
Some(&provenance)
)
),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&status_json_value(
Some(&provenance.resolved),
usage,
permission_mode.as_str(),
&context,
Some(&provenance),
allowed_tools,
))?
),
}
Ok(())
}
fn status_json_value(
model: Option<&str>,
usage: StatusUsage,
permission_mode: &str,
context: &StatusContext,
// #148: optional provenance for `model` field. Surfaces `model_source`
// ("flag" | "env" | "config" | "default") and `model_raw` (user input
// before alias resolution, or null when source is "default"). Callers
// that don't have provenance (legacy resume paths) pass None, in which
// case both new fields are omitted.
provenance: Option<&ModelProvenance>,
allowed_tools: Option<&AllowedToolSet>,
) -> serde_json::Value {
// #143: top-level `status` marker so claws can distinguish
// a clean run from a degraded run (config parse failed but other fields
// are still populated). `config_load_error` carries the parse-error string
// when present; it's a string rather than a typed object in Phase 1 and
// will join the typed-error taxonomy in Phase 2 (ROADMAP §4.44).
let degraded = context.config_load_error.is_some();
let model_source = provenance.map(|p| p.source.as_str());
let model_raw = provenance.and_then(|p| p.raw.clone());
let allowed_tool_entries = allowed_tools.map(|tools| tools.iter().cloned().collect::<Vec<_>>());
json!({
"kind": "status",
"status": if degraded { "degraded" } else { "ok" },
"config_load_error": context.config_load_error,
"model": model,
"model_source": model_source,
"model_raw": model_raw,
"permission_mode": permission_mode,
"allowed_tools": {
"source": if allowed_tools.is_some() { "flag" } else { "default" },
"restricted": allowed_tools.is_some(),
"entries": allowed_tool_entries,
},
"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()),
"session_id": context.session_path.as_ref().and_then(|path| {
// Session files are named <session-id>.jsonl directly under
// .claw/sessions/. Extract the stem (drop the .jsonl extension).
path.file_stem().map(|n| n.to_string_lossy().into_owned())
}),
"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>> {
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
let discovered_config_files = loader.discover().len();
// #143: degrade gracefully on config parse failure rather than hard-fail.
// `claw doctor` already does this; `claw status` now matches that contract
// so that one malformed `mcpServers.*` entry doesn't take down the whole
// health surface (workspace, git, model, permission, sandbox can still be
// reported independently).
let (loaded_config_files, sandbox_status, config_load_error) = match loader.load() {
Ok(runtime_config) => (
runtime_config.loaded_entries().len(),
resolve_sandbox_status(runtime_config.sandbox(), &cwd),
None,
),
Err(err) => (
0,
// Fall back to defaults for sandbox resolution so claws still see
// a populated sandbox section instead of a missing field. Defaults
// produce the same output as a runtime config with no sandbox
// overrides, which is the right degraded-mode shape: we cannot
// report what the user *intended*, only what is actually in effect.
resolve_sandbox_status(&runtime::SandboxConfig::default(), &cwd),
Some(err.to_string()),
),
};
let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?;
let (project_root, git_branch) =
parse_git_status_metadata(project_context.git_status.as_deref());
let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref());
let lifecycle = classify_session_lifecycle_for(&cwd);
Ok(StatusContext {
cwd,
session_path: session_path.map(Path::to_path_buf),
loaded_config_files,
discovered_config_files,
memory_file_count: project_context.instruction_files.len(),
project_root,
git_branch,
git_summary,
session_lifecycle: lifecycle,
sandbox_status,
config_load_error,
})
}
fn classify_session_lifecycle_for(workspace: &Path) -> SessionLifecycleSummary {
classify_session_lifecycle_from_panes(workspace, discover_tmux_panes())
}
fn classify_session_lifecycle_from_panes(
workspace: &Path,
panes: Vec<TmuxPaneSnapshot>,
) -> SessionLifecycleSummary {
let workspace_dirty = git_worktree_is_dirty(workspace);
let mut idle_shell = None;
for pane in panes {
if !pane_path_matches_workspace(&pane.current_path, workspace) {
continue;
}
if is_idle_shell_command(&pane.current_command) {
idle_shell.get_or_insert(pane);
} else {
return SessionLifecycleSummary {
kind: SessionLifecycleKind::RunningProcess,
pane_id: Some(pane.pane_id),
pane_command: Some(pane.current_command),
pane_path: Some(pane.current_path),
workspace_dirty,
abandoned: false,
};
}
}
if let Some(pane) = idle_shell {
SessionLifecycleSummary {
kind: SessionLifecycleKind::IdleShell,
pane_id: Some(pane.pane_id),
pane_command: Some(pane.current_command),
pane_path: Some(pane.current_path),
workspace_dirty,
abandoned: workspace_dirty,
}
} else {
SessionLifecycleSummary {
kind: SessionLifecycleKind::SavedOnly,
pane_id: None,
pane_command: None,
pane_path: None,
workspace_dirty,
abandoned: workspace_dirty,
}
}
}
fn discover_tmux_panes() -> Vec<TmuxPaneSnapshot> {
let output = Command::new("tmux")
.args([
"list-panes",
"-a",
"-F",
"#{pane_id}\t#{pane_current_command}\t#{pane_current_path}",
])
.output();
let Ok(output) = output else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
let stdout = String::from_utf8_lossy(&output.stdout);
parse_tmux_pane_snapshots(&stdout)
}
fn parse_tmux_pane_snapshots(output: &str) -> Vec<TmuxPaneSnapshot> {
output
.lines()
.filter_map(|line| {
let mut fields = line.splitn(3, '\t');
let pane_id = fields.next()?.trim();
let current_command = fields.next()?.trim();
let current_path = fields.next()?.trim();
if pane_id.is_empty() || current_path.is_empty() {
return None;
}
Some(TmuxPaneSnapshot {
pane_id: pane_id.to_string(),
current_command: current_command.to_string(),
current_path: PathBuf::from(current_path),
})
})
.collect()
}
fn pane_path_matches_workspace(pane_path: &Path, workspace: &Path) -> bool {
let pane_path = fs::canonicalize(pane_path).unwrap_or_else(|_| pane_path.to_path_buf());
let workspace = fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
pane_path == workspace || pane_path.starts_with(&workspace)
}
fn is_idle_shell_command(command: &str) -> bool {
let command = command.rsplit('/').next().unwrap_or(command);
matches!(
command,
"bash" | "zsh" | "sh" | "fish" | "nu" | "pwsh" | "powershell" | "cmd"
)
}
fn git_worktree_is_dirty(workspace: &Path) -> bool {
let output = Command::new("git")
.arg("-C")
.arg(workspace)
.args(["status", "--porcelain"])
.output();
output
.ok()
.filter(|output| output.status.success())
.is_some_and(|output| !output.stdout.is_empty())
}
fn format_status_report(
model: &str,
usage: StatusUsage,
permission_mode: &str,
context: &StatusContext,
// #148: optional model provenance to surface in a `Model source` line.
// Callers without provenance (legacy resume paths) pass None and the
// source line is omitted for backward compat.
provenance: Option<&ModelProvenance>,
) -> String {
// #143: if config failed to parse, surface a degraded banner at the top
// of the text report so humans see the parse error before the body, while
// the body below still reports everything that could be resolved without
// config (workspace, git, sandbox defaults, etc.).
let status_line = if context.config_load_error.is_some() {
"Status (degraded)"
} else {
"Status"
};
let mut blocks: Vec<String> = Vec::new();
if let Some(err) = context.config_load_error.as_deref() {
blocks.push(format!(
"Config load error\n Status fail\n Summary runtime config failed to load; reporting partial status\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun"
));
}
// #148: render Model source line after Model, showing where the string
// came from (flag / env / config / default) and the raw input if any.
let model_source_line = provenance
.map(|p| match &p.raw {
Some(raw) if raw != model => {
format!("\n Model source {} (raw: {raw})", p.source.as_str())
}
_ => format!("\n Model source {}", p.source.as_str()),
})
.unwrap_or_default();
blocks.extend([
format!(
"{status_line}
Model {model}{model_source_line}
Permission mode {permission_mode}
Messages {}
Turns {}
Estimated tokens {}",
usage.message_count, usage.turns, usage.estimated_tokens,
),
format!(
"Usage
Latest total {}
Cumulative input {}
Cumulative output {}
Cumulative total {}",
usage.latest.total_tokens(),
usage.cumulative.input_tokens,
usage.cumulative.output_tokens,
usage.cumulative.total_tokens(),
),
format!(
"Workspace
Cwd {}
Project root {}
Git branch {}
Git state {}
Changed files {}
Staged {}
Unstaged {}
Untracked {}
Session {}
Config files loaded {}/{}
Memory files {}
Suggested flow /status → /diff → /commit",
context.cwd.display(),
context
.project_root
.as_ref()
.map_or_else(|| "unknown".to_string(), |path| path.display().to_string()),
context.git_branch.as_deref().unwrap_or("unknown"),
context.git_summary.headline(),
context.git_summary.changed_files,
context.git_summary.staged_files,
context.git_summary.unstaged_files,
context.git_summary.untracked_files,
context.session_path.as_ref().map_or_else(
|| "live-repl".to_string(),
|path| path.display().to_string()
),
context.loaded_config_files,
context.discovered_config_files,
context.memory_file_count,
),
format_sandbox_report(&context.sandbox_status),
]);
blocks.join("\n\n")
}
fn format_sandbox_report(status: &runtime::SandboxStatus) -> String {
format!(
"Sandbox
Enabled {}
Active {}
Supported {}
In container {}
Requested ns {}
Active ns {}
Requested net {}
Active net {}
Filesystem mode {}
Filesystem active {}
Allowed mounts {}
Markers {}
Fallback reason {}",
status.enabled,
status.active,
status.supported,
status.in_container,
status.requested.namespace_restrictions,
status.namespace_active,
status.requested.network_isolation,
status.network_active,
status.filesystem_mode.as_str(),
status.filesystem_active,
if status.allowed_mounts.is_empty() {
"<none>".to_string()
} else {
status.allowed_mounts.join(", ")
},
if status.container_markers.is_empty() {
"<none>".to_string()
} else {
status.container_markers.join(", ")
},
status
.fallback_reason
.clone()
.unwrap_or_else(|| "<none>".to_string()),
)
}
fn format_commit_preflight_report(branch: Option<&str>, summary: GitWorkspaceSummary) -> String {
format!(
"Commit
Result ready
Branch {}
Workspace {}
Changed files {}
Action create a git commit from the current workspace changes",
branch.unwrap_or("unknown"),
summary.headline(),
summary.changed_files,
)
}
fn format_commit_skipped_report() -> String {
"Commit
Result skipped
Reason no workspace changes
Action create a git commit from the current workspace changes
Next /status to inspect context · /diff to inspect repo changes"
.to_string()
}
fn print_sandbox_status_snapshot(
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
let runtime_config = loader
.load()
.unwrap_or_else(|_| runtime::RuntimeConfig::empty());
let status = resolve_sandbox_status(runtime_config.sandbox(), &cwd);
match output_format {
CliOutputFormat::Text => println!("{}", format_sandbox_report(&status)),
CliOutputFormat::Json => println!(
"{}",
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
Usage claw status [--output-format <format>]
Purpose show the local workspace snapshot without entering the REPL
Output model, permissions, git state, config files, and sandbox status
Formats text (default), json
Related /status · claw --resume latest /status"
.to_string(),
LocalHelpTopic::Sandbox => "Sandbox
Usage claw sandbox [--output-format <format>]
Purpose inspect the resolved sandbox and isolation state for the current directory
Output namespace, network, filesystem, and fallback details
Formats text (default), json
Related /sandbox · claw status"
.to_string(),
LocalHelpTopic::Doctor => "Doctor
Usage claw doctor [--output-format <format>]
Purpose diagnose local auth, config, workspace, sandbox, and build metadata
Output local-only health report; no provider request or session resume required
Formats text (default), json
Related /doctor · claw --resume latest /doctor"
.to_string(),
LocalHelpTopic::Acp => "ACP / Zed
Usage claw acp [serve] [--output-format <format>]
Aliases claw --acp · claw -acp
Purpose explain the current editor-facing ACP/Zed launch contract without starting the runtime
Status discoverability only; `serve` is a status alias and does not launch a daemon yet
Formats text (default), json
Related ROADMAP #64a (discoverability) · ROADMAP #76 (real ACP support) · claw --help"
.to_string(),
LocalHelpTopic::Init => "Init
Usage claw init [--output-format <format>]
Purpose create .claw/, .claw.json, .gitignore, and CLAUDE.md in the current project
Output list of created vs. skipped files (idempotent: safe to re-run)
Formats text (default), json
Related claw status · claw doctor"
.to_string(),
LocalHelpTopic::State => "State
Usage claw state [--output-format <format>]
Purpose read .claw/worker-state.json written by the interactive REPL or a one-shot prompt
Output worker id, model, permissions, session reference (text or json)
Formats text (default), json
Produces state `claw` (interactive REPL) or `claw prompt <text>` (one non-interactive turn)
Observes state `claw state` reads; clawhip/CI may poll this file without HTTP
Exit codes 0 if state file exists and parses; 1 with actionable hint otherwise
Related claw status · ROADMAP #139 (this worker-concept contract)"
.to_string(),
LocalHelpTopic::Export => "Export
Usage claw export [--session <id|latest>] [--output <path>] [--output-format <format>]
Purpose serialize a managed session to JSON for review, transfer, or archival
Defaults --session latest (most recent managed session in .claw/sessions/)
Formats text (default), json
Related /session list · claw --resume latest"
.to_string(),
LocalHelpTopic::Version => "Version
Usage claw version [--output-format <format>]
Aliases claw --version · claw -V
Purpose print the claw CLI version and build metadata
Formats text (default), json
Related claw doctor (full build/auth/config diagnostic)"
.to_string(),
LocalHelpTopic::SystemPrompt => "System Prompt
Usage claw system-prompt [--cwd <path>] [--date YYYY-MM-DD] [--output-format <format>]
Purpose render the resolved system prompt that `claw` would send for the given cwd + date
Options --cwd overrides the workspace dir · --date injects a deterministic date stamp
Formats text (default), json
Related claw doctor · claw dump-manifests"
.to_string(),
LocalHelpTopic::DumpManifests => "Dump Manifests
Usage claw dump-manifests [--manifests-dir <path>] [--output-format <format>]
Purpose emit every skill/agent/tool manifest the resolver would load for the current cwd
Options --manifests-dir scopes discovery to a specific directory
Formats text (default), json
Related claw skills · claw agents · claw doctor"
.to_string(),
LocalHelpTopic::BootstrapPlan => "Bootstrap Plan
Usage claw bootstrap-plan [--output-format <format>]
Purpose list the ordered startup phases the CLI would execute before dispatch
Output phase names (text) or structured phase list (json) — primary output is the plan itself
Formats text (default), json
Related claw doctor · claw status"
.to_string(),
}
}
fn print_help_topic(topic: LocalHelpTopic) {
println!("{}", render_help_topic(topic));
}
fn print_acp_status(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let message = "ACP/Zed editor integration is not implemented in claw-code yet. `claw acp serve` is only a discoverability alias today; it does not launch a daemon or Zed-specific protocol endpoint. Use the normal terminal surfaces for now and track ROADMAP #76 for real ACP support.";
match output_format {
CliOutputFormat::Text => {
println!(
"ACP / Zed\n Status discoverability only\n Launch `claw acp serve` / `claw --acp` / `claw -acp` report status only; no editor daemon is available yet\n Today use `claw prompt`, the REPL, or `claw doctor` for local verification\n Tracking ROADMAP #76\n Message {message}"
);
}
CliOutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "acp",
"status": "discoverability_only",
"supported": false,
"serve_alias_only": true,
"message": message,
"launch_command": serde_json::Value::Null,
"aliases": ["acp", "--acp", "-acp"],
"discoverability_tracking": "ROADMAP #64a",
"tracking": "ROADMAP #76",
"recommended_workflows": [
"claw prompt TEXT",
"claw",
"claw doctor"
],
}))?
);
}
}
Ok(())
}
fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
let discovered = loader.discover();
let runtime_config = loader.load()?;
let mut lines = vec![
format!(
"Config
Working directory {}
Loaded files {}
Merged keys {}",
cwd.display(),
runtime_config.loaded_entries().len(),
runtime_config.merged().len()
),
"Discovered files".to_string(),
];
for entry in discovered {
let source = match entry.source {
ConfigSource::User => "user",
ConfigSource::Project => "project",
ConfigSource::Local => "local",
};
let status = if runtime_config
.loaded_entries()
.iter()
.any(|loaded_entry| loaded_entry.path == entry.path)
{
"loaded"
} else {
"missing"
};
lines.push(format!(
" {source:<7} {status:<7} {}",
entry.path.display()
));
}
if let Some(section) = section {
lines.push(format!("Merged section: {section}"));
let value = match section {
"env" => runtime_config.get("env"),
"hooks" => runtime_config.get("hooks"),
"model" => runtime_config.get("model"),
"plugins" => runtime_config
.get("plugins")
.or_else(|| runtime_config.get("enabledPlugins")),
other => {
lines.push(format!(
" Unsupported config section '{other}'. Use env, hooks, model, or plugins."
));
return Ok(lines.join(
"
",
));
}
};
lines.push(format!(
" {}",
match value {
Some(value) => value.render(),
None => "<unset>".to_string(),
}
));
return Ok(lines.join(
"
",
));
}
lines.push("Merged JSON".to_string());
lines.push(format!(" {}", runtime_config.as_json().render()));
Ok(lines.join(
"
",
))
}
fn render_config_json(
_section: Option<&str>,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
let discovered = loader.discover();
let runtime_config = loader.load()?;
let loaded_paths: Vec<_> = runtime_config
.loaded_entries()
.iter()
.map(|e| e.path.display().to_string())
.collect();
let files: Vec<_> = discovered
.iter()
.map(|e| {
let source = match e.source {
ConfigSource::User => "user",
ConfigSource::Project => "project",
ConfigSource::Local => "local",
};
let is_loaded = runtime_config
.loaded_entries()
.iter()
.any(|le| le.path == e.path);
serde_json::json!({
"path": e.path.display().to_string(),
"source": source,
"loaded": is_loaded,
})
})
.collect();
Ok(serde_json::json!({
"kind": "config",
"cwd": cwd.display().to_string(),
"loaded_files": loaded_paths.len(),
"merged_keys": runtime_config.merged().len(),
"files": files,
}))
}
fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?;
let mut lines = vec![format!(
"Memory
Working directory {}
Instruction files {}",
cwd.display(),
project_context.instruction_files.len()
)];
if project_context.instruction_files.is_empty() {
lines.push("Discovered files".to_string());
lines.push(
" No CLAUDE instruction files discovered in the current directory ancestry."
.to_string(),
);
} else {
lines.push("Discovered files".to_string());
for (index, file) in project_context.instruction_files.iter().enumerate() {
let preview = file.content.lines().next().unwrap_or("").trim();
let preview = if preview.is_empty() {
"<empty>"
} else {
preview
};
lines.push(format!(" {}. {}", index + 1, file.path.display(),));
lines.push(format!(
" lines={} preview={}",
file.content.lines().count(),
preview
));
}
}
Ok(lines.join(
"
",
))
}
fn render_memory_json() -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?;
let files: Vec<_> = project_context
.instruction_files
.iter()
.map(|f| {
json!({
"path": f.path.display().to_string(),
"lines": f.content.lines().count(),
"preview": f.content.lines().next().unwrap_or("").trim(),
})
})
.collect();
Ok(json!({
"kind": "memory",
"cwd": cwd.display().to_string(),
"instruction_files": files.len(),
"files": files,
}))
}
fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
Ok(initialize_repo(&cwd)?.render())
}
fn run_init(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let report = initialize_repo(&cwd)?;
let message = report.render();
match output_format {
CliOutputFormat::Text => println!("{message}"),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&init_json_value(&report, &message))?
),
}
Ok(())
}
/// #142: emit first-class structured fields alongside the legacy `message`
/// string so claws can detect per-artifact state without substring matching.
fn init_json_value(report: &crate::init::InitReport, message: &str) -> serde_json::Value {
use crate::init::InitStatus;
json!({
"kind": "init",
"project_path": report.project_root.display().to_string(),
"created": report.artifacts_with_status(InitStatus::Created),
"updated": report.artifacts_with_status(InitStatus::Updated),
"skipped": report.artifacts_with_status(InitStatus::Skipped),
"artifacts": report.artifact_json_entries(),
"next_step": crate::init::InitReport::NEXT_STEP,
"message": message,
})
}
pub(crate) fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
match mode.trim() {
"read-only" => Some("read-only"),
"workspace-write" => Some("workspace-write"),
"danger-full-access" => Some("danger-full-access"),
_ => None,
}
}
fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> {
render_diff_report_for(&env::current_dir()?)
}
fn render_diff_report_for(cwd: &Path) -> Result<String, Box<dyn std::error::Error>> {
// Verify we are inside a git repository before calling `git diff`.
// Running `git diff --cached` outside a git tree produces a misleading
// "unknown option `cached`" error because git falls back to --no-index mode.
let in_git_repo = std::process::Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.current_dir(cwd)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !in_git_repo {
return Ok(format!(
"Diff\n Result no git repository\n Detail {} is not inside a git project",
cwd.display()
));
}
let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?;
let unstaged = run_git_diff_command_in(cwd, &["diff"])?;
if staged.trim().is_empty() && unstaged.trim().is_empty() {
return Ok(
"Diff\n Result clean working tree\n Detail no current changes"
.to_string(),
);
}
let mut sections = Vec::new();
if !staged.trim().is_empty() {
sections.push(format!("Staged changes:\n{}", staged.trim_end()));
}
if !unstaged.trim().is_empty() {
sections.push(format!("Unstaged changes:\n{}", unstaged.trim_end()));
}
Ok(format!("Diff\n\n{}", sections.join("\n\n")))
}
fn render_diff_json_for(cwd: &Path) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let in_git_repo = std::process::Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.current_dir(cwd)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !in_git_repo {
return Ok(serde_json::json!({
"kind": "diff",
"result": "no_git_repo",
"detail": format!("{} is not inside a git project", cwd.display()),
}));
}
let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?;
let unstaged = run_git_diff_command_in(cwd, &["diff"])?;
Ok(serde_json::json!({
"kind": "diff",
"result": if staged.trim().is_empty() && unstaged.trim().is_empty() { "clean" } else { "changes" },
"staged": staged.trim(),
"unstaged": unstaged.trim(),
}))
}
fn run_git_diff_command_in(
cwd: &Path,
args: &[&str],
) -> Result<String, Box<dyn std::error::Error>> {
let output = std::process::Command::new("git")
.args(args)
.current_dir(cwd)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(format!("git {} failed: {stderr}", args.join(" ")).into());
}
Ok(String::from_utf8(output.stdout)?)
}
fn render_teleport_report(target: &str) -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let file_list = Command::new("rg")
.args(["--files"])
.current_dir(&cwd)
.output()?;
let file_matches = if file_list.status.success() {
String::from_utf8(file_list.stdout)?
.lines()
.filter(|line| line.contains(target))
.take(10)
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
} else {
Vec::new()
};
let content_output = Command::new("rg")
.args(["-n", "-S", "--color", "never", target, "."])
.current_dir(&cwd)
.output()?;
let mut lines = vec![
"Teleport".to_string(),
format!(" Target {target}"),
" Action search workspace files and content for the target".to_string(),
];
if !file_matches.is_empty() {
lines.push(String::new());
lines.push("File matches".to_string());
lines.extend(file_matches.into_iter().map(|path| format!(" {path}")));
}
if content_output.status.success() {
let matches = String::from_utf8(content_output.stdout)?;
if !matches.trim().is_empty() {
lines.push(String::new());
lines.push("Content matches".to_string());
lines.push(truncate_for_prompt(&matches, 4_000));
}
}
if lines.len() == 1 {
lines.push(" Result no matches found".to_string());
}
Ok(lines.join("\n"))
}
fn render_last_tool_debug_report(session: &Session) -> Result<String, Box<dyn std::error::Error>> {
let last_tool_use = session
.messages
.iter()
.rev()
.find_map(|message| {
message.blocks.iter().rev().find_map(|block| match block {
ContentBlock::ToolUse { id, name, input } => {
Some((id.clone(), name.clone(), input.clone()))
}
_ => None,
})
})
.ok_or_else(|| "no prior tool call found in session".to_string())?;
let tool_result = session.messages.iter().rev().find_map(|message| {
message.blocks.iter().rev().find_map(|block| match block {
ContentBlock::ToolResult {
tool_use_id,
tool_name,
output,
is_error,
} if tool_use_id == &last_tool_use.0 => {
Some((tool_name.clone(), output.clone(), *is_error))
}
_ => None,
})
});
let mut lines = vec![
"Debug tool call".to_string(),
" Action inspect the last recorded tool call and its result".to_string(),
format!(" Tool id {}", last_tool_use.0),
format!(" Tool name {}", last_tool_use.1),
" Input".to_string(),
indent_block(&last_tool_use.2, 4),
];
match tool_result {
Some((tool_name, output, is_error)) => {
lines.push(" Result".to_string());
lines.push(format!(" name {tool_name}"));
lines.push(format!(
" status {}",
if is_error { "error" } else { "ok" }
));
lines.push(indent_block(&output, 4));
}
None => lines.push(" Result missing tool result".to_string()),
}
Ok(lines.join("\n"))
}
fn indent_block(value: &str, spaces: usize) -> String {
let indent = " ".repeat(spaces);
value
.lines()
.map(|line| format!("{indent}{line}"))
.collect::<Vec<_>>()
.join("\n")
}
fn validate_no_args(
command_name: &str,
args: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(args) = args.map(str::trim).filter(|value| !value.is_empty()) {
return Err(format!(
"{command_name} does not accept arguments. Received: {args}\nUsage: {command_name}"
)
.into());
}
Ok(())
}
fn format_bughunter_report(scope: Option<&str>) -> String {
format!(
"Bughunter
Scope {}
Action inspect the selected code for likely bugs and correctness issues
Output findings should include file paths, severity, and suggested fixes",
scope.unwrap_or("the current repository")
)
}
fn format_ultraplan_report(task: Option<&str>) -> String {
format!(
"Ultraplan
Task {}
Action break work into a multi-step execution plan
Output plan should cover goals, risks, sequencing, verification, and rollback",
task.unwrap_or("the current repo work")
)
}
fn format_pr_report(branch: &str, context: Option<&str>) -> String {
format!(
"PR
Branch {branch}
Context {}
Action draft or create a pull request for the current branch
Output title and markdown body suitable for GitHub",
context.unwrap_or("none")
)
}
fn format_issue_report(context: Option<&str>) -> String {
format!(
"Issue
Context {}
Action draft or create a GitHub issue from the current context
Output title and markdown body suitable for GitHub",
context.unwrap_or("none")
)
}
fn git_output(args: &[&str]) -> Result<String, Box<dyn std::error::Error>> {
let output = Command::new("git")
.args(args)
.current_dir(env::current_dir()?)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(format!("git {} failed: {stderr}", args.join(" ")).into());
}
Ok(String::from_utf8(output.stdout)?)
}
fn git_status_ok(args: &[&str]) -> Result<(), Box<dyn std::error::Error>> {
let output = Command::new("git")
.args(args)
.current_dir(env::current_dir()?)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(format!("git {} failed: {stderr}", args.join(" ")).into());
}
Ok(())
}
fn command_exists(name: &str) -> bool {
Command::new("which")
.arg(name)
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
fn write_temp_text_file(
filename: &str,
contents: &str,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
let path = env::temp_dir().join(filename);
fs::write(&path, contents)?;
Ok(path)
}
const DEFAULT_HISTORY_LIMIT: usize = 20;
fn parse_history_count(raw: Option<&str>) -> Result<usize, String> {
let Some(raw) = raw else {
return Ok(DEFAULT_HISTORY_LIMIT);
};
let parsed: usize = raw
.parse()
.map_err(|_| format!("history: invalid count '{raw}'. Expected a positive integer."))?;
if parsed == 0 {
return Err("history: count must be greater than 0.".to_string());
}
Ok(parsed)
}
fn format_history_timestamp(timestamp_ms: u64) -> String {
let secs = timestamp_ms / 1_000;
let subsec_ms = timestamp_ms % 1_000;
let days_since_epoch = secs / 86_400;
let seconds_of_day = secs % 86_400;
let hours = seconds_of_day / 3_600;
let minutes = (seconds_of_day % 3_600) / 60;
let seconds = seconds_of_day % 60;
let (year, month, day) = civil_from_days(i64::try_from(days_since_epoch).unwrap_or(0));
format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}.{subsec_ms:03}Z")
}
// Computes civil (Gregorian) year/month/day from days since the Unix epoch
// (1970-01-01) using Howard Hinnant's `civil_from_days` algorithm.
#[allow(
clippy::cast_sign_loss,
clippy::cast_possible_wrap,
clippy::cast_possible_truncation
)]
fn civil_from_days(days: i64) -> (i32, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 {
z / 146_097
} else {
(z - 146_096) / 146_097
};
let doe = (z - era * 146_097) as u64; // [0, 146_096]
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
let mp = (5 * doy + 2) / 153; // [0, 11]
let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
let y = y + i64::from(m <= 2);
(y as i32, m as u32, d as u32)
}
fn render_prompt_history_report(entries: &[PromptHistoryEntry], limit: usize) -> String {
if entries.is_empty() {
return "Prompt history\n Result no prompts recorded yet".to_string();
}
let total = entries.len();
let start = total.saturating_sub(limit);
let shown = &entries[start..];
let mut lines = vec![
"Prompt history".to_string(),
format!(" Total {total}"),
format!(" Showing {} most recent", shown.len()),
format!(" Reverse search Ctrl-R in the REPL"),
String::new(),
];
for (offset, entry) in shown.iter().enumerate() {
let absolute_index = start + offset + 1;
let timestamp = format_history_timestamp(entry.timestamp_ms);
let first_line = entry.text.lines().next().unwrap_or("").trim();
let display = if first_line.chars().count() > 80 {
let truncated: String = first_line.chars().take(77).collect();
format!("{truncated}...")
} else {
first_line.to_string()
};
lines.push(format!(" {absolute_index:>3}. [{timestamp}] {display}"));
}
lines.join("\n")
}
fn collect_session_prompt_history(session: &Session) -> Vec<PromptHistoryEntry> {
if !session.prompt_history.is_empty() {
return session
.prompt_history
.iter()
.map(|entry| PromptHistoryEntry {
timestamp_ms: entry.timestamp_ms,
text: entry.text.clone(),
})
.collect();
}
let timestamp_ms = session.updated_at_ms;
session
.messages
.iter()
.filter(|message| message.role == MessageRole::User)
.filter_map(|message| {
message.blocks.iter().find_map(|block| match block {
ContentBlock::Text { text } => Some(PromptHistoryEntry {
timestamp_ms,
text: text.clone(),
}),
_ => None,
})
})
.collect()
}
fn recent_user_context(session: &Session, limit: usize) -> String {
let requests = session
.messages
.iter()
.filter(|message| message.role == MessageRole::User)
.filter_map(|message| {
message.blocks.iter().find_map(|block| match block {
ContentBlock::Text { text } => Some(text.trim().to_string()),
_ => None,
})
})
.rev()
.take(limit)
.collect::<Vec<_>>();
if requests.is_empty() {
"<no prior user messages>".to_string()
} else {
requests
.into_iter()
.rev()
.enumerate()
.map(|(index, text)| format!("{}. {}", index + 1, text))
.collect::<Vec<_>>()
.join("\n")
}
}
fn truncate_for_prompt(value: &str, limit: usize) -> String {
if value.chars().count() <= limit {
value.trim().to_string()
} else {
let truncated = value.chars().take(limit).collect::<String>();
format!("{}\n…[truncated]", truncated.trim_end())
}
}
fn sanitize_generated_message(value: &str) -> String {
value.trim().trim_matches('`').trim().replace("\r\n", "\n")
}
fn parse_titled_body(value: &str) -> Option<(String, String)> {
let normalized = sanitize_generated_message(value);
let title = normalized
.lines()
.find_map(|line| line.strip_prefix("TITLE:").map(str::trim))?;
let body_start = normalized.find("BODY:")?;
let body = normalized[body_start + "BODY:".len()..].trim();
Some((title.to_string(), body.to_string()))
}
fn render_version_report() -> String {
let git_sha = GIT_SHA.unwrap_or("unknown");
let target = BUILD_TARGET.unwrap_or("unknown");
format!(
"Claw Code\n Version {VERSION}\n Git SHA {git_sha}\n Target {target}\n Build date {DEFAULT_DATE}"
)
}
fn render_export_text(session: &Session) -> String {
let mut lines = vec!["# Conversation Export".to_string(), String::new()];
for (index, message) in session.messages.iter().enumerate() {
let role = match message.role {
MessageRole::System => "system",
MessageRole::User => "user",
MessageRole::Assistant => "assistant",
MessageRole::Tool => "tool",
};
lines.push(format!("## {}. {role}", index + 1));
for block in &message.blocks {
match block {
ContentBlock::Text { text } => lines.push(text.clone()),
ContentBlock::ToolUse { id, name, input } => {
lines.push(format!("[tool_use id={id} name={name}] {input}"));
}
ContentBlock::ToolResult {
tool_use_id,
tool_name,
output,
is_error,
} => {
lines.push(format!(
"[tool_result id={tool_use_id} name={tool_name} error={is_error}] {output}"
));
}
ContentBlock::Thinking {
thinking,
signature,
} => {
lines.push(format!("[thinking signature={signature:?}] {thinking}"));
}
}
}
lines.push(String::new());
}
lines.join("\n")
}
fn default_export_filename(session: &Session) -> String {
let stem = session
.messages
.iter()
.find_map(|message| match message.role {
MessageRole::User => message.blocks.iter().find_map(|block| match block {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
}),
_ => None,
})
.map_or("conversation", |text| {
text.lines().next().unwrap_or("conversation")
})
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|part| !part.is_empty())
.take(8)
.collect::<Vec<_>>()
.join("-");
let fallback = if stem.is_empty() {
"conversation"
} else {
&stem
};
format!("{fallback}.txt")
}
fn resolve_export_path(
requested_path: Option<&str>,
session: &Session,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let file_name =
requested_path.map_or_else(|| default_export_filename(session), ToOwned::to_owned);
let final_name = if Path::new(&file_name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("txt"))
{
file_name
} else {
format!("{file_name}.txt")
};
Ok(cwd.join(final_name))
}
const SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT: usize = 280;
fn summarize_tool_payload_for_markdown(payload: &str) -> String {
let compact = match serde_json::from_str::<serde_json::Value>(payload) {
Ok(value) => value.to_string(),
Err(_) => payload.split_whitespace().collect::<Vec<_>>().join(" "),
};
if compact.is_empty() {
return String::new();
}
truncate_for_summary(&compact, SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT)
}
fn run_export(
session_reference: &str,
output_path: Option<&Path>,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let (handle, session) = load_session_reference(session_reference)?;
let markdown = render_session_markdown(&session, &handle.id, &handle.path);
if let Some(path) = output_path {
fs::write(path, &markdown)?;
let report = format!(
"Export\n Result wrote markdown transcript\n File {}\n Session {}\n Messages {}",
path.display(),
handle.id,
session.messages.len(),
);
match output_format {
CliOutputFormat::Text => println!("{report}"),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "export",
"message": report,
"session_id": handle.id,
"file": path.display().to_string(),
"messages": session.messages.len(),
}))?
),
}
return Ok(());
}
match output_format {
CliOutputFormat::Text => {
print!("{markdown}");
if !markdown.ends_with('\n') {
println!();
}
}
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "export",
"session_id": handle.id,
"file": handle.path.display().to_string(),
"messages": session.messages.len(),
"markdown": markdown,
}))?
),
}
Ok(())
}
fn render_session_markdown(session: &Session, session_id: &str, session_path: &Path) -> String {
let mut lines = vec![
"# Conversation Export".to_string(),
String::new(),
format!("- **Session**: `{session_id}`"),
format!("- **File**: `{}`", session_path.display()),
format!("- **Messages**: {}", session.messages.len()),
];
if let Some(workspace_root) = session.workspace_root() {
lines.push(format!("- **Workspace**: `{}`", workspace_root.display()));
}
if let Some(fork) = &session.fork {
let branch = fork.branch_name.as_deref().unwrap_or("(unnamed)");
lines.push(format!(
"- **Forked from**: `{}` (branch `{branch}`)",
fork.parent_session_id
));
}
if let Some(compaction) = &session.compaction {
lines.push(format!(
"- **Compactions**: {} (last removed {} messages)",
compaction.count, compaction.removed_message_count
));
}
lines.push(String::new());
lines.push("---".to_string());
lines.push(String::new());
for (index, message) in session.messages.iter().enumerate() {
let role = match message.role {
MessageRole::System => "System",
MessageRole::User => "User",
MessageRole::Assistant => "Assistant",
MessageRole::Tool => "Tool",
};
lines.push(format!("## {}. {role}", index + 1));
lines.push(String::new());
for block in &message.blocks {
match block {
ContentBlock::Text { text } => {
let trimmed = text.trim_end();
if !trimmed.is_empty() {
lines.push(trimmed.to_string());
lines.push(String::new());
}
}
ContentBlock::ToolUse { id, name, input } => {
lines.push(format!(
"**Tool call** `{name}` _(id `{}`)_",
short_tool_id(id)
));
let summary = summarize_tool_payload_for_markdown(input);
if !summary.is_empty() {
lines.push(format!("> {summary}"));
}
lines.push(String::new());
}
ContentBlock::ToolResult {
tool_use_id,
tool_name,
output,
is_error,
} => {
let status = if *is_error { "error" } else { "ok" };
lines.push(format!(
"**Tool result** `{tool_name}` _(id `{}`, {status})_",
short_tool_id(tool_use_id)
));
let summary = summarize_tool_payload_for_markdown(output);
if !summary.is_empty() {
lines.push(format!("> {summary}"));
}
lines.push(String::new());
}
ContentBlock::Thinking {
thinking,
signature,
} => {
lines.push(format!("**Thinking** _{signature:?}_"));
let summary = summarize_tool_payload_for_markdown(thinking);
if !summary.is_empty() {
lines.push(format!("> {summary}"));
}
lines.push(String::new());
}
}
}
if let Some(usage) = message.usage {
lines.push(format!(
"_tokens: in={} out={} cache_create={} cache_read={}_",
usage.input_tokens,
usage.output_tokens,
usage.cache_creation_input_tokens,
usage.cache_read_input_tokens,
));
lines.push(String::new());
}
}
lines.join("\n")
}
fn short_tool_id(id: &str) -> String {
let char_count = id.chars().count();
if char_count <= 12 {
return id.to_string();
}
let prefix: String = id.chars().take(12).collect();
format!("{prefix}")
}
fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
Ok(load_system_prompt(
env::current_dir()?,
DEFAULT_DATE,
env::consts::OS,
"unknown",
)?)
}
fn build_runtime_plugin_state() -> Result<RuntimePluginState, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
let runtime_config = loader.load()?;
build_runtime_plugin_state_with_loader(&cwd, &loader, &runtime_config)
}
pub(crate) fn build_runtime_plugin_state_with_loader(
cwd: &Path,
loader: &ConfigLoader,
runtime_config: &runtime::RuntimeConfig,
) -> Result<RuntimePluginState, Box<dyn std::error::Error>> {
let plugin_manager = build_plugin_manager(cwd, loader, runtime_config);
let plugin_registry = plugin_manager.plugin_registry()?;
let plugin_hook_config =
runtime_hook_config_from_plugin_hooks(plugin_registry.aggregated_hooks()?);
let feature_config = runtime_config
.feature_config()
.clone()
.with_hooks(runtime_config.hooks().merged(&plugin_hook_config));
let (mcp_state, runtime_tools) = build_runtime_mcp_state(runtime_config)?;
let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_registry.aggregated_tools()?)?
.with_runtime_tools(runtime_tools)?;
Ok(RuntimePluginState {
feature_config,
tool_registry,
plugin_registry,
mcp_state,
})
}
fn build_plugin_manager(
cwd: &Path,
loader: &ConfigLoader,
runtime_config: &runtime::RuntimeConfig,
) -> PluginManager {
let plugin_settings = runtime_config.plugins();
let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf());
plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone();
plugin_config.external_dirs = plugin_settings
.external_directories()
.iter()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path))
.collect();
plugin_config.install_root = plugin_settings
.install_root()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
plugin_config.registry_path = plugin_settings
.registry_path()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
plugin_config.bundled_root = plugin_settings
.bundled_root()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
PluginManager::new(plugin_config)
}
fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf {
let path = PathBuf::from(value);
if path.is_absolute() {
path
} else if value.starts_with('.') {
cwd.join(path)
} else {
config_home.join(path)
}
}
fn runtime_hook_config_from_plugin_hooks(hooks: PluginHooks) -> runtime::RuntimeHookConfig {
runtime::RuntimeHookConfig::new(
hooks.pre_tool_use,
hooks.post_tool_use,
hooks.post_tool_use_failure,
)
}
#[allow(clippy::needless_pass_by_value)]
#[allow(clippy::too_many_arguments)]
fn build_runtime(
session: Session,
session_id: &str,
model: String,
system_prompt: Vec<String>,
enable_tools: bool,
emit_output: bool,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
progress_reporter: Option<InternalPromptProgressReporter>,
) -> Result<BuiltRuntime, Box<dyn std::error::Error>> {
let runtime_plugin_state = build_runtime_plugin_state()?;
build_runtime_with_plugin_state(
session,
session_id,
model,
system_prompt,
enable_tools,
emit_output,
allowed_tools,
permission_mode,
progress_reporter,
runtime_plugin_state,
)
}
#[allow(clippy::needless_pass_by_value)]
#[allow(clippy::too_many_arguments)]
fn build_runtime_with_plugin_state(
mut session: Session,
session_id: &str,
model: String,
system_prompt: Vec<String>,
enable_tools: bool,
emit_output: bool,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
progress_reporter: Option<InternalPromptProgressReporter>,
runtime_plugin_state: RuntimePluginState,
) -> Result<BuiltRuntime, Box<dyn std::error::Error>> {
// Persist the model in session metadata so resumed sessions can report it.
if session.model.is_none() {
session.model = Some(model.clone());
}
let RuntimePluginState {
feature_config,
tool_registry,
plugin_registry,
mcp_state,
} = runtime_plugin_state;
plugin_registry.initialize()?;
let policy = permission_policy(permission_mode, &feature_config, &tool_registry)
.map_err(std::io::Error::other)?;
let mut runtime = ConversationRuntime::new_with_features(
session,
AnthropicRuntimeClient::new(
session_id,
model,
enable_tools,
emit_output,
allowed_tools.clone(),
tool_registry.clone(),
progress_reporter,
)?,
CliToolExecutor::new(
allowed_tools.clone(),
emit_output,
tool_registry.clone(),
mcp_state.clone(),
),
policy,
system_prompt,
&feature_config,
);
if emit_output {
runtime = runtime.with_hook_progress_reporter(Box::new(CliHookProgressReporter));
}
Ok(BuiltRuntime::new(runtime, plugin_registry, mcp_state))
}
// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct
// now holds an `ApiProviderClient` which dispatches to Anthropic, xAI,
// OpenAI, or DashScope at construction time based on
// `detect_provider_kind(&model)`. The struct name is kept to avoid
// churning `BuiltRuntime` and every Deref/DerefMut site that references
// it. See ROADMAP #29 for the provider-dispatch routing fix.
struct AnthropicRuntimeClient {
runtime: tokio::runtime::Runtime,
client: ApiProviderClient,
session_id: String,
model: String,
enable_tools: bool,
emit_output: bool,
allowed_tools: Option<AllowedToolSet>,
tool_registry: GlobalToolRegistry,
progress_reporter: Option<InternalPromptProgressReporter>,
reasoning_effort: Option<String>,
}
impl AnthropicRuntimeClient {
fn new(
session_id: &str,
model: String,
enable_tools: bool,
emit_output: bool,
allowed_tools: Option<AllowedToolSet>,
tool_registry: GlobalToolRegistry,
progress_reporter: Option<InternalPromptProgressReporter>,
) -> Result<Self, Box<dyn std::error::Error>> {
// Dispatch to the correct provider at construction time.
// `ApiProviderClient` (exposed by the api crate as
// `ProviderClient`) is an enum over Anthropic / xAI / OpenAI
// variants, where xAI and OpenAI both use the OpenAI-compat
// wire format under the hood. We consult
// `detect_provider_kind(&resolved_model)` so model-name prefix
// routing (`openai/`, `gpt-`, `grok`, `qwen/`) wins over
// env-var presence.
//
// For Anthropic we build the client directly instead of going
// through `ApiProviderClient::from_model_with_anthropic_auth`
// so we can explicitly apply `api::read_base_url()` — that
// reads `ANTHROPIC_BASE_URL` and is required for the local
// mock-server test harness
// (`crates/rusty-claude-cli/tests/compact_output.rs`) to point
// claw at its fake Anthropic endpoint. We also attach a
// session-scoped prompt cache on the Anthropic path; the
// prompt cache is Anthropic-only so non-Anthropic variants
// skip it.
let resolved_model = api::resolve_model_alias(&model);
let client = match detect_provider_kind(&resolved_model) {
ProviderKind::Anthropic => {
let auth = resolve_cli_auth_source()?;
let inner = AnthropicClient::from_auth(auth)
.with_base_url(api::read_base_url())
.with_prompt_cache(PromptCache::new(session_id));
ApiProviderClient::Anthropic(inner)
}
ProviderKind::Xai | ProviderKind::OpenAi => {
// The api crate's `ProviderClient::from_model_with_anthropic_auth`
// with `None` for the anthropic auth routes via
// `detect_provider_kind` and builds an
// `OpenAiCompatClient::from_env` with the matching
// `OpenAiCompatConfig` (openai / xai / dashscope).
// That reads the correct API-key env var and BASE_URL
// override internally, so this one call covers OpenAI,
// OpenRouter, xAI, DashScope, Ollama, and any other
// OpenAI-compat endpoint users configure via
// `OPENAI_BASE_URL` / `XAI_BASE_URL` / `DASHSCOPE_BASE_URL`.
ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)?
}
};
Ok(Self {
runtime: tokio::runtime::Runtime::new()?,
client,
session_id: session_id.to_string(),
model,
enable_tools,
emit_output,
allowed_tools,
tool_registry,
progress_reporter,
reasoning_effort: None,
})
}
fn set_reasoning_effort(&mut self, effort: Option<String>) {
self.reasoning_effort = effort;
}
}
fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
Ok(resolve_cli_auth_source_for_cwd()?)
}
fn resolve_cli_auth_source_for_cwd() -> Result<AuthSource, api::ApiError> {
resolve_startup_auth_source(|| Ok(None))
}
impl ApiClient for AnthropicRuntimeClient {
#[allow(clippy::too_many_lines)]
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
if let Some(progress_reporter) = &self.progress_reporter {
progress_reporter.mark_model_phase();
}
let is_post_tool = request_ends_with_tool_result(&request);
let message_request = MessageRequest {
model: self.model.clone(),
max_tokens: max_tokens_for_model(&self.model),
messages: convert_messages(&request.messages),
system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")),
tools: self
.enable_tools
.then(|| filter_tool_specs(&self.tool_registry, self.allowed_tools.as_ref())),
tool_choice: self.enable_tools.then_some(ToolChoice::Auto),
stream: true,
reasoning_effort: self.reasoning_effort.clone(),
..Default::default()
};
self.runtime.block_on(async {
// When resuming after tool execution, apply a stall timeout on the
// first stream event. If the model does not respond within the
// deadline we drop the stalled connection and re-send the request as
// a continuation nudge (one retry only).
let max_attempts: usize = if is_post_tool { 2 } else { 1 };
for attempt in 1..=max_attempts {
let result = self
.consume_stream(&message_request, is_post_tool && attempt == 1)
.await;
match result {
Ok(events) => return Ok(events),
Err(error)
if error.to_string().contains("post-tool stall")
&& attempt < max_attempts =>
{
// Stalled after tool completion — nudge the model by
// re-sending the same request.
}
Err(error) => return Err(error),
}
}
Err(RuntimeError::new("post-tool continuation nudge exhausted"))
})
}
}
impl AnthropicRuntimeClient {
/// Consume a single streaming response, optionally applying a stall
/// timeout on the first event for post-tool continuations.
#[allow(clippy::too_many_lines)]
async fn consume_stream(
&self,
message_request: &MessageRequest,
apply_stall_timeout: bool,
) -> Result<Vec<AssistantEvent>, RuntimeError> {
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 {
&mut stdout
} else {
&mut sink
};
let renderer = TerminalRenderer::new();
let mut markdown_stream = MarkdownStreamState::default();
let mut events = Vec::new();
let mut pending_tool: Option<(String, String, String)> = None;
let mut block_has_thinking_summary = false;
let mut saw_stop = false;
let mut received_any_event = false;
loop {
let next = if apply_stall_timeout && !received_any_event {
match tokio::time::timeout(POST_TOOL_STALL_TIMEOUT, stream.next_event()).await {
Ok(inner) => inner.map_err(|error| {
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
})?,
Err(_elapsed) => {
return Err(RuntimeError::new(
"post-tool stall: model did not respond within timeout",
));
}
}
} else {
stream.next_event().await.map_err(|error| {
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
})?
};
let Some(event) = next else {
break;
};
received_any_event = true;
match event {
ApiStreamEvent::MessageStart(start) => {
for block in start.message.content {
push_output_block(
block,
out,
&mut events,
&mut pending_tool,
true,
&mut block_has_thinking_summary,
)?;
}
}
ApiStreamEvent::ContentBlockStart(start) => {
push_output_block(
start.content_block,
out,
&mut events,
&mut pending_tool,
true,
&mut block_has_thinking_summary,
)?;
}
ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
ContentBlockDelta::TextDelta { text } => {
if !text.is_empty() {
if let Some(progress_reporter) = &self.progress_reporter {
progress_reporter.mark_text_phase(&text);
}
if let Some(rendered) = markdown_stream.push(&renderer, &text) {
write!(out, "{rendered}")
.and_then(|()| out.flush())
.map_err(|error| RuntimeError::new(error.to_string()))?;
}
events.push(AssistantEvent::TextDelta(text));
}
}
ContentBlockDelta::InputJsonDelta { partial_json } => {
if let Some((_, _, input)) = &mut pending_tool {
input.push_str(&partial_json);
}
}
ContentBlockDelta::ThinkingDelta { thinking } => {
if !block_has_thinking_summary {
render_thinking_block_summary(out, None, false)?;
block_has_thinking_summary = true;
}
events.push(AssistantEvent::ThinkingDelta(thinking));
}
ContentBlockDelta::SignatureDelta { signature } => {
events.push(AssistantEvent::SignatureDelta(signature));
}
},
ApiStreamEvent::ContentBlockStop(_) => {
block_has_thinking_summary = false;
if let Some(rendered) = markdown_stream.flush(&renderer) {
write!(out, "{rendered}")
.and_then(|()| out.flush())
.map_err(|error| RuntimeError::new(error.to_string()))?;
}
if let Some((id, name, input)) = pending_tool.take() {
if let Some(progress_reporter) = &self.progress_reporter {
progress_reporter.mark_tool_phase(&name, &input);
}
// Display tool call now that input is fully accumulated
writeln!(out, "\n{}", format_tool_call_start(&name, &input))
.and_then(|()| out.flush())
.map_err(|error| RuntimeError::new(error.to_string()))?;
events.push(AssistantEvent::ToolUse { id, name, input });
}
}
ApiStreamEvent::MessageDelta(delta) => {
events.push(AssistantEvent::Usage(delta.usage.token_usage()));
}
ApiStreamEvent::MessageStop(_) => {
saw_stop = true;
if let Some(rendered) = markdown_stream.flush(&renderer) {
write!(out, "{rendered}")
.and_then(|()| out.flush())
.map_err(|error| RuntimeError::new(error.to_string()))?;
}
events.push(AssistantEvent::MessageStop);
}
}
}
push_prompt_cache_record(&self.client, &mut events);
if !saw_stop
&& events.iter().any(|event| {
matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty())
|| matches!(event, AssistantEvent::ToolUse { .. })
})
{
events.push(AssistantEvent::MessageStop);
}
if events
.iter()
.any(|event| matches!(event, AssistantEvent::MessageStop))
{
return Ok(events);
}
let response = self
.client
.send_message(&MessageRequest {
stream: false,
..message_request.clone()
})
.await
.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)
}
}
/// Returns `true` when the conversation ends with a tool-result message,
/// meaning the model is expected to continue after tool execution.
fn request_ends_with_tool_result(request: &ApiRequest) -> bool {
request
.messages
.last()
.is_some_and(|message| message.role == MessageRole::Tool)
}
fn format_user_visible_api_error(session_id: &str, error: &api::ApiError) -> String {
if error.is_context_window_failure() {
format_context_window_blocked_error(session_id, error)
} else if error.is_generic_fatal_wrapper() {
let mut qualifiers = vec![format!("session {session_id}")];
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 format_context_window_blocked_error(session_id: &str, error: &api::ApiError) -> String {
let mut lines = vec![
"Context window blocked".to_string(),
" Failure class context_window_blocked".to_string(),
format!(" Session {session_id}"),
];
if let Some(request_id) = error.request_id() {
lines.push(format!(" Trace {request_id}"));
}
match error {
api::ApiError::ContextWindowExceeded {
model,
estimated_input_tokens,
requested_output_tokens,
estimated_total_tokens,
context_window_tokens,
} => {
lines.push(format!(" Model {model}"));
lines.push(format!(
" Input estimate ~{estimated_input_tokens} tokens (heuristic)"
));
lines.push(format!(
" Requested output {requested_output_tokens} tokens"
));
lines.push(format!(
" Total estimate ~{estimated_total_tokens} tokens (heuristic)"
));
lines.push(format!(" Context window {context_window_tokens} tokens"));
}
api::ApiError::Api { message, body, .. } => {
let detail = message.as_deref().unwrap_or(body).trim();
if !detail.is_empty() {
lines.push(format!(
" Detail {}",
truncate_for_summary(detail, 120)
));
}
}
api::ApiError::RetriesExhausted { last_error, .. } => {
let detail = match last_error.as_ref() {
api::ApiError::Api { message, body, .. } => message.as_deref().unwrap_or(body),
other => return format_context_window_blocked_error(session_id, other),
}
.trim();
if !detail.is_empty() {
lines.push(format!(
" Detail {}",
truncate_for_summary(detail, 120)
));
}
}
_ => {}
}
lines.push(String::new());
lines.push("Recovery".to_string());
lines.push(" Compact /compact".to_string());
lines.push(format!(
" Resume compact claw --resume {session_id} /compact"
));
lines.push(" Fresh session /clear --confirm".to_string());
lines.push(
" Reduce scope remove large pasted context/files or ask for a smaller slice"
.to_string(),
);
lines.push(" Retry rerun after compacting or reducing the request".to_string());
lines.join("\n")
}
fn final_assistant_text(summary: &runtime::TurnSummary) -> String {
summary
.assistant_messages
.last()
.map(|message| {
message
.blocks
.iter()
.filter_map(|block| match block {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default()
}
fn collect_tool_uses(summary: &runtime::TurnSummary) -> Vec<serde_json::Value> {
summary
.assistant_messages
.iter()
.flat_map(|message| message.blocks.iter())
.filter_map(|block| match block {
ContentBlock::ToolUse { id, name, input } => Some(json!({
"id": id,
"name": name,
"input": input,
})),
_ => None,
})
.collect()
}
fn collect_tool_results(summary: &runtime::TurnSummary) -> Vec<serde_json::Value> {
summary
.tool_results
.iter()
.flat_map(|message| message.blocks.iter())
.filter_map(|block| match block {
ContentBlock::ToolResult {
tool_use_id,
tool_name,
output,
is_error,
} => Some(json!({
"tool_use_id": tool_use_id,
"tool_name": tool_name,
"output": output,
"is_error": is_error,
})),
_ => None,
})
.collect()
}
fn collect_prompt_cache_events(summary: &runtime::TurnSummary) -> Vec<serde_json::Value> {
summary
.prompt_cache_events
.iter()
.map(|event| {
json!({
"unexpected": event.unexpected,
"reason": event.reason,
"previous_cache_read_input_tokens": event.previous_cache_read_input_tokens,
"current_cache_read_input_tokens": event.current_cache_read_input_tokens,
"token_drop": event.token_drop,
})
})
.collect()
}
/// Slash commands that are registered in the spec list but not yet implemented
fn slash_command_completion_candidates_with_sessions(
model: &str,
active_session_id: Option<&str>,
recent_session_ids: Vec<String>,
) -> Vec<String> {
let mut completions = BTreeSet::new();
for spec in slash_command_specs() {
if STUB_COMMANDS.contains(&spec.name) {
continue;
}
completions.insert(format!("/{}", spec.name));
for alias in spec.aliases {
if !STUB_COMMANDS.contains(alias) {
completions.insert(format!("/{alias}"));
}
}
}
for candidate in [
"/bughunter ",
"/clear --confirm",
"/config ",
"/config env",
"/config hooks",
"/config model",
"/config plugins",
"/mcp ",
"/mcp list",
"/mcp show ",
"/export ",
"/issue ",
"/model ",
"/model opus",
"/model sonnet",
"/model haiku",
"/permissions ",
"/permissions read-only",
"/permissions workspace-write",
"/permissions danger-full-access",
"/plugin list",
"/plugin install ",
"/plugin enable ",
"/plugin disable ",
"/plugin uninstall ",
"/plugin update ",
"/plugins list",
"/pr ",
"/resume ",
"/session list",
"/session switch ",
"/session fork ",
"/teleport ",
"/ultraplan ",
"/agents help",
"/mcp help",
"/skills help",
] {
completions.insert(candidate.to_string());
}
if !model.trim().is_empty() {
completions.insert(format!("/model {}", resolve_model_alias(model)));
completions.insert(format!("/model {model}"));
}
if let Some(active_session_id) = active_session_id.filter(|value| !value.trim().is_empty()) {
completions.insert(format!("/resume {active_session_id}"));
completions.insert(format!("/session switch {active_session_id}"));
}
for session_id in recent_session_ids
.into_iter()
.filter(|value| !value.trim().is_empty())
.take(10)
{
completions.insert(format!("/resume {session_id}"));
completions.insert(format!("/session switch {session_id}"));
}
completions.into_iter().collect()
}
fn format_tool_call_start(name: &str, input: &str) -> String {
let parsed: serde_json::Value =
serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string()));
let detail = match name {
"bash" | "Bash" => format_bash_call(&parsed),
"read_file" | "Read" => {
let path = extract_tool_path(&parsed);
format!("\x1b[2m📄 Reading {path}\x1b[0m")
}
"write_file" | "Write" => {
let path = extract_tool_path(&parsed);
let lines = parsed
.get("content")
.and_then(|value| value.as_str())
.map_or(0, |content| content.lines().count());
format!("\x1b[1;32m✏ Writing {path}\x1b[0m \x1b[2m({lines} lines)\x1b[0m")
}
"edit_file" | "Edit" => {
let path = extract_tool_path(&parsed);
let old_value = parsed
.get("old_string")
.or_else(|| parsed.get("oldString"))
.and_then(|value| value.as_str())
.unwrap_or_default();
let new_value = parsed
.get("new_string")
.or_else(|| parsed.get("newString"))
.and_then(|value| value.as_str())
.unwrap_or_default();
format!(
"\x1b[1;33m📝 Editing {path}\x1b[0m{}",
format_patch_preview(old_value, new_value)
.map(|preview| format!("\n{preview}"))
.unwrap_or_default()
)
}
"glob_search" | "Glob" => format_search_start("🔎 Glob", &parsed),
"grep_search" | "Grep" => format_search_start("🔎 Grep", &parsed),
"web_search" | "WebSearch" => parsed
.get("query")
.and_then(|value| value.as_str())
.unwrap_or("?")
.to_string(),
_ => summarize_tool_payload(input),
};
let border = "".repeat(name.len() + 8);
format!(
"\x1b[38;5;245m╭─ \x1b[1;36m{name}\x1b[0;38;5;245m ─╮\x1b[0m\n\x1b[38;5;245m│\x1b[0m {detail}\n\x1b[38;5;245m╰{border}\x1b[0m"
)
}
fn format_tool_result(name: &str, output: &str, is_error: bool) -> String {
let icon = if is_error {
"\x1b[1;31m✗\x1b[0m"
} else {
"\x1b[1;32m✓\x1b[0m"
};
if is_error {
let summary = truncate_for_summary(output.trim(), 160);
return if summary.is_empty() {
format!("{icon} \x1b[38;5;245m{name}\x1b[0m")
} else {
format!("{icon} \x1b[38;5;245m{name}\x1b[0m\n\x1b[38;5;203m{summary}\x1b[0m")
};
}
let parsed: serde_json::Value =
serde_json::from_str(output).unwrap_or(serde_json::Value::String(output.to_string()));
match name {
"bash" | "Bash" => format_bash_result(icon, &parsed),
"read_file" | "Read" => format_read_result(icon, &parsed),
"write_file" | "Write" => format_write_result(icon, &parsed),
"edit_file" | "Edit" => format_edit_result(icon, &parsed),
"glob_search" | "Glob" => format_glob_result(icon, &parsed),
"grep_search" | "Grep" => format_grep_result(icon, &parsed),
_ => format_generic_tool_result(icon, name, &parsed),
}
}
const DISPLAY_TRUNCATION_NOTICE: &str =
"\x1b[2m… output truncated for display; full result preserved in session.\x1b[0m";
const READ_DISPLAY_MAX_LINES: usize = 80;
const READ_DISPLAY_MAX_CHARS: usize = 6_000;
const TOOL_OUTPUT_DISPLAY_MAX_LINES: usize = 60;
const TOOL_OUTPUT_DISPLAY_MAX_CHARS: usize = 4_000;
fn extract_tool_path(parsed: &serde_json::Value) -> String {
parsed
.get("file_path")
.or_else(|| parsed.get("filePath"))
.or_else(|| parsed.get("path"))
.and_then(|value| value.as_str())
.unwrap_or("?")
.to_string()
}
fn format_search_start(label: &str, parsed: &serde_json::Value) -> String {
let pattern = parsed
.get("pattern")
.and_then(|value| value.as_str())
.unwrap_or("?");
let scope = parsed
.get("path")
.and_then(|value| value.as_str())
.unwrap_or(".");
format!("{label} {pattern}\n\x1b[2min {scope}\x1b[0m")
}
fn format_patch_preview(old_value: &str, new_value: &str) -> Option<String> {
if old_value.is_empty() && new_value.is_empty() {
return None;
}
Some(format!(
"\x1b[38;5;203m- {}\x1b[0m\n\x1b[38;5;70m+ {}\x1b[0m",
truncate_for_summary(first_visible_line(old_value), 72),
truncate_for_summary(first_visible_line(new_value), 72)
))
}
fn format_bash_call(parsed: &serde_json::Value) -> String {
let command = parsed
.get("command")
.and_then(|value| value.as_str())
.unwrap_or_default();
if command.is_empty() {
String::new()
} else {
format!(
"\x1b[48;5;236;38;5;255m $ {} \x1b[0m",
truncate_for_summary(command, 160)
)
}
}
fn first_visible_line(text: &str) -> &str {
text.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or(text)
}
fn format_bash_result(icon: &str, parsed: &serde_json::Value) -> String {
use std::fmt::Write as _;
let mut lines = vec![format!("{icon} \x1b[38;5;245mbash\x1b[0m")];
if let Some(task_id) = parsed
.get("backgroundTaskId")
.and_then(|value| value.as_str())
{
write!(&mut lines[0], " backgrounded ({task_id})").expect("write to string");
} else if let Some(status) = parsed
.get("returnCodeInterpretation")
.and_then(|value| value.as_str())
.filter(|status| !status.is_empty())
{
write!(&mut lines[0], " {status}").expect("write to string");
}
if let Some(stdout) = parsed.get("stdout").and_then(|value| value.as_str()) {
if !stdout.trim().is_empty() {
lines.push(truncate_output_for_display(
stdout,
TOOL_OUTPUT_DISPLAY_MAX_LINES,
TOOL_OUTPUT_DISPLAY_MAX_CHARS,
));
}
}
if let Some(stderr) = parsed.get("stderr").and_then(|value| value.as_str()) {
if !stderr.trim().is_empty() {
lines.push(format!(
"\x1b[38;5;203m{}\x1b[0m",
truncate_output_for_display(
stderr,
TOOL_OUTPUT_DISPLAY_MAX_LINES,
TOOL_OUTPUT_DISPLAY_MAX_CHARS,
)
));
}
}
lines.join("\n\n")
}
fn format_read_result(icon: &str, parsed: &serde_json::Value) -> String {
let file = parsed.get("file").unwrap_or(parsed);
let path = extract_tool_path(file);
let start_line = file
.get("startLine")
.and_then(serde_json::Value::as_u64)
.unwrap_or(1);
let num_lines = file
.get("numLines")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let total_lines = file
.get("totalLines")
.and_then(serde_json::Value::as_u64)
.unwrap_or(num_lines);
let content = file
.get("content")
.and_then(|value| value.as_str())
.unwrap_or_default();
let end_line = start_line.saturating_add(num_lines.saturating_sub(1));
format!(
"{icon} \x1b[2m📄 Read {path} (lines {}-{} of {})\x1b[0m\n{}",
start_line,
end_line.max(start_line),
total_lines,
truncate_output_for_display(content, READ_DISPLAY_MAX_LINES, READ_DISPLAY_MAX_CHARS)
)
}
fn format_write_result(icon: &str, parsed: &serde_json::Value) -> String {
let path = extract_tool_path(parsed);
let kind = parsed
.get("type")
.and_then(|value| value.as_str())
.unwrap_or("write");
let line_count = parsed
.get("content")
.and_then(|value| value.as_str())
.map_or(0, |content| content.lines().count());
format!(
"{icon} \x1b[1;32m✏ {} {path}\x1b[0m \x1b[2m({line_count} lines)\x1b[0m",
if kind == "create" { "Wrote" } else { "Updated" },
)
}
fn format_structured_patch_preview(parsed: &serde_json::Value) -> Option<String> {
let hunks = parsed.get("structuredPatch")?.as_array()?;
let mut preview = Vec::new();
for hunk in hunks.iter().take(2) {
let lines = hunk.get("lines")?.as_array()?;
for line in lines.iter().filter_map(|value| value.as_str()).take(6) {
match line.chars().next() {
Some('+') => preview.push(format!("\x1b[38;5;70m{line}\x1b[0m")),
Some('-') => preview.push(format!("\x1b[38;5;203m{line}\x1b[0m")),
_ => preview.push(line.to_string()),
}
}
}
if preview.is_empty() {
None
} else {
Some(preview.join("\n"))
}
}
fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String {
let path = extract_tool_path(parsed);
let suffix = if parsed
.get("replaceAll")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
{
" (replace all)"
} else {
""
};
let preview = format_structured_patch_preview(parsed).or_else(|| {
let old_value = parsed
.get("oldString")
.and_then(|value| value.as_str())
.unwrap_or_default();
let new_value = parsed
.get("newString")
.and_then(|value| value.as_str())
.unwrap_or_default();
format_patch_preview(old_value, new_value)
});
match preview {
Some(preview) => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m\n{preview}"),
None => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m"),
}
}
fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String {
let num_files = parsed
.get("numFiles")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let filenames = parsed
.get("filenames")
.and_then(|value| value.as_array())
.map(|files| {
files
.iter()
.filter_map(|value| value.as_str())
.take(8)
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default();
if filenames.is_empty() {
format!("{icon} \x1b[38;5;245mglob_search\x1b[0m matched {num_files} files")
} else {
format!("{icon} \x1b[38;5;245mglob_search\x1b[0m matched {num_files} files\n{filenames}")
}
}
fn format_grep_result(icon: &str, parsed: &serde_json::Value) -> String {
let num_matches = parsed
.get("numMatches")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let num_files = parsed
.get("numFiles")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let content = parsed
.get("content")
.and_then(|value| value.as_str())
.unwrap_or_default();
let filenames = parsed
.get("filenames")
.and_then(|value| value.as_array())
.map(|files| {
files
.iter()
.filter_map(|value| value.as_str())
.take(8)
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default();
let summary = format!(
"{icon} \x1b[38;5;245mgrep_search\x1b[0m {num_matches} matches across {num_files} files"
);
if !content.trim().is_empty() {
format!(
"{summary}\n{}",
truncate_output_for_display(
content,
TOOL_OUTPUT_DISPLAY_MAX_LINES,
TOOL_OUTPUT_DISPLAY_MAX_CHARS,
)
)
} else if !filenames.is_empty() {
format!("{summary}\n{filenames}")
} else {
summary
}
}
fn format_generic_tool_result(icon: &str, name: &str, parsed: &serde_json::Value) -> String {
let rendered_output = match parsed {
serde_json::Value::String(text) => text.clone(),
serde_json::Value::Null => String::new(),
serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
serde_json::to_string_pretty(parsed).unwrap_or_else(|_| parsed.to_string())
}
_ => parsed.to_string(),
};
let preview = truncate_output_for_display(
&rendered_output,
TOOL_OUTPUT_DISPLAY_MAX_LINES,
TOOL_OUTPUT_DISPLAY_MAX_CHARS,
);
if preview.is_empty() {
format!("{icon} \x1b[38;5;245m{name}\x1b[0m")
} else if preview.contains('\n') {
format!("{icon} \x1b[38;5;245m{name}\x1b[0m\n{preview}")
} else {
format!("{icon} \x1b[38;5;245m{name}:\x1b[0m {preview}")
}
}
fn summarize_tool_payload(payload: &str) -> String {
let compact = match serde_json::from_str::<serde_json::Value>(payload) {
Ok(value) => value.to_string(),
Err(_) => payload.trim().to_string(),
};
truncate_for_summary(&compact, 96)
}
fn truncate_for_summary(value: &str, limit: usize) -> String {
let mut chars = value.chars();
let truncated = chars.by_ref().take(limit).collect::<String>();
if chars.next().is_some() {
format!("{truncated}")
} else {
truncated
}
}
fn truncate_output_for_display(content: &str, max_lines: usize, max_chars: usize) -> String {
let original = content.trim_end_matches('\n');
if original.is_empty() {
return String::new();
}
let mut preview_lines = Vec::new();
let mut used_chars = 0usize;
let mut truncated = false;
for (index, line) in original.lines().enumerate() {
if index >= max_lines {
truncated = true;
break;
}
let newline_cost = usize::from(!preview_lines.is_empty());
let available = max_chars.saturating_sub(used_chars + newline_cost);
if available == 0 {
truncated = true;
break;
}
let line_chars = line.chars().count();
if line_chars > available {
preview_lines.push(line.chars().take(available).collect::<String>());
truncated = true;
break;
}
preview_lines.push(line.to_string());
used_chars += newline_cost + line_chars;
}
let mut preview = preview_lines.join("\n");
if truncated {
if !preview.is_empty() {
preview.push('\n');
}
preview.push_str(DISPLAY_TRUNCATION_NOTICE);
}
preview
}
fn render_thinking_block_summary(
out: &mut (impl Write + ?Sized),
char_count: Option<usize>,
redacted: bool,
) -> Result<(), RuntimeError> {
let summary = if redacted {
"\n▶ Thinking block hidden by provider\n".to_string()
} else if let Some(char_count) = char_count {
format!("\n▶ Thinking ({char_count} chars hidden)\n")
} else {
"\n▶ Thinking hidden\n".to_string()
};
write!(out, "{summary}")
.and_then(|()| out.flush())
.map_err(|error| RuntimeError::new(error.to_string()))
}
fn push_output_block(
block: OutputContentBlock,
out: &mut (impl Write + ?Sized),
events: &mut Vec<AssistantEvent>,
pending_tool: &mut Option<(String, String, String)>,
streaming_tool_input: bool,
block_has_thinking_summary: &mut bool,
) -> Result<(), RuntimeError> {
match block {
OutputContentBlock::Text { text } => {
if !text.is_empty() {
let rendered = TerminalRenderer::new().markdown_to_ansi(&text);
write!(out, "{rendered}")
.and_then(|()| out.flush())
.map_err(|error| RuntimeError::new(error.to_string()))?;
events.push(AssistantEvent::TextDelta(text));
}
}
OutputContentBlock::ToolUse { id, name, input } => {
// During streaming, the initial content_block_start has an empty input ({}).
// The real input arrives via input_json_delta events. In
// non-streaming responses, preserve a legitimate empty object.
let initial_input = if streaming_tool_input
&& input.is_object()
&& input.as_object().is_some_and(serde_json::Map::is_empty)
{
String::new()
} else {
input.to_string()
};
*pending_tool = Some((id, name, initial_input));
}
OutputContentBlock::Thinking { thinking, .. } => {
render_thinking_block_summary(out, Some(thinking.chars().count()), false)?;
*block_has_thinking_summary = true;
}
OutputContentBlock::RedactedThinking { .. } => {
render_thinking_block_summary(out, None, true)?;
*block_has_thinking_summary = true;
}
}
Ok(())
}
fn response_to_events(
response: MessageResponse,
out: &mut (impl Write + ?Sized),
) -> Result<Vec<AssistantEvent>, RuntimeError> {
let mut events = Vec::new();
let mut pending_tool = None;
for block in response.content {
let mut block_has_thinking_summary = false;
push_output_block(
block,
out,
&mut events,
&mut pending_tool,
false,
&mut block_has_thinking_summary,
)?;
if let Some((id, name, input)) = pending_tool.take() {
events.push(AssistantEvent::ToolUse { id, name, input });
}
}
events.push(AssistantEvent::Usage(response.usage.token_usage()));
events.push(AssistantEvent::MessageStop);
Ok(events)
}
fn push_prompt_cache_record(client: &ApiProviderClient, events: &mut Vec<AssistantEvent>) {
// `ApiProviderClient::take_last_prompt_cache_record` is a pass-through
// to the Anthropic variant and returns `None` for OpenAI-compat /
// xAI variants, which do not have a prompt cache. So this helper
// remains a no-op on non-Anthropic providers without any extra
// branching here.
if let Some(record) = client.take_last_prompt_cache_record() {
if let Some(event) = prompt_cache_record_to_runtime_event(record) {
events.push(AssistantEvent::PromptCache(event));
}
}
}
fn prompt_cache_record_to_runtime_event(
record: api::PromptCacheRecord,
) -> Option<PromptCacheEvent> {
let cache_break = record.cache_break?;
Some(PromptCacheEvent {
unexpected: cache_break.unexpected,
reason: cache_break.reason,
previous_cache_read_input_tokens: cache_break.previous_cache_read_input_tokens,
current_cache_read_input_tokens: cache_break.current_cache_read_input_tokens,
token_drop: cache_break.token_drop,
})
}
struct CliToolExecutor {
renderer: TerminalRenderer,
emit_output: bool,
allowed_tools: Option<AllowedToolSet>,
tool_registry: GlobalToolRegistry,
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
}
impl CliToolExecutor {
fn new(
allowed_tools: Option<AllowedToolSet>,
emit_output: bool,
tool_registry: GlobalToolRegistry,
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
) -> Self {
Self {
renderer: TerminalRenderer::new(),
emit_output,
allowed_tools,
tool_registry,
mcp_state,
}
}
fn execute_search_tool(&self, value: serde_json::Value) -> Result<String, ToolError> {
let input: ToolSearchRequest = serde_json::from_value(value)
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
let (pending_mcp_servers, mcp_degraded) =
self.mcp_state.as_ref().map_or((None, None), |state| {
let state = state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
(state.pending_servers(), state.degraded_report())
});
serde_json::to_string_pretty(&self.tool_registry.search(
&input.query,
input.max_results.unwrap_or(5),
pending_mcp_servers,
mcp_degraded,
))
.map_err(|error| ToolError::new(error.to_string()))
}
fn execute_runtime_tool(
&self,
tool_name: &str,
value: serde_json::Value,
) -> Result<String, ToolError> {
let Some(mcp_state) = &self.mcp_state else {
return Err(ToolError::new(format!(
"runtime tool `{tool_name}` is unavailable without configured MCP servers"
)));
};
let mut mcp_state = mcp_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
match tool_name {
"MCPTool" => {
let input: McpToolRequest = serde_json::from_value(value)
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
let qualified_name = input
.qualified_name
.or(input.tool)
.ok_or_else(|| ToolError::new("missing required field `qualifiedName`"))?;
mcp_state.call_tool(&qualified_name, input.arguments)
}
"ListMcpResourcesTool" => {
let input: ListMcpResourcesRequest = serde_json::from_value(value)
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
match input.server {
Some(server_name) => mcp_state.list_resources_for_server(&server_name),
None => mcp_state.list_resources_for_all_servers(),
}
}
"ReadMcpResourceTool" => {
let input: ReadMcpResourceRequest = serde_json::from_value(value)
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
mcp_state.read_resource(&input.server, &input.uri)
}
_ => mcp_state.call_tool(tool_name, Some(value)),
}
}
}
impl ToolExecutor for CliToolExecutor {
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
if self
.allowed_tools
.as_ref()
.is_some_and(|allowed| !allowed.contains(tool_name))
{
return Err(ToolError::new(format!(
"tool `{tool_name}` is not enabled by the current --allowedTools setting"
)));
}
let value = serde_json::from_str(input)
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
let result = if tool_name == "ToolSearch" {
self.execute_search_tool(value)
} else if self.tool_registry.has_runtime_tool(tool_name) {
self.execute_runtime_tool(tool_name, value)
} else {
self.tool_registry
.execute(tool_name, &value)
.map_err(ToolError::new)
};
match result {
Ok(output) => {
if self.emit_output {
let markdown = format_tool_result(tool_name, &output, false);
self.renderer
.stream_markdown(&markdown, &mut io::stdout())
.map_err(|error| ToolError::new(error.to_string()))?;
}
Ok(output)
}
Err(error) => {
if self.emit_output {
let markdown = format_tool_result(tool_name, &error.to_string(), true);
self.renderer
.stream_markdown(&markdown, &mut io::stdout())
.map_err(|stream_error| ToolError::new(stream_error.to_string()))?;
}
Err(error)
}
}
}
}
fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
messages
.iter()
.filter_map(|message| {
let role = match message.role {
MessageRole::System | MessageRole::User | MessageRole::Tool => "user",
MessageRole::Assistant => "assistant",
};
let content = message
.blocks
.iter()
.map(|block| match block {
ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
ContentBlock::Thinking {
thinking,
signature,
} => InputContentBlock::Thinking {
thinking: thinking.clone(),
signature: signature.clone(),
},
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
input: serde_json::from_str(input)
.unwrap_or_else(|_| serde_json::json!({ "raw": input })),
},
ContentBlock::ToolResult {
tool_use_id,
output,
is_error,
..
} => InputContentBlock::ToolResult {
tool_use_id: tool_use_id.clone(),
content: vec![ToolResultContentBlock::Text {
text: output.clone(),
}],
is_error: *is_error,
},
})
.collect::<Vec<_>>();
(!content.is_empty()).then(|| InputMessage {
role: role.to_string(),
content,
})
})
.collect()
}
#[allow(clippy::too_many_lines)]
fn print_help_to(out: &mut impl Write) -> io::Result<()> {
writeln!(out, "claw v{VERSION}")?;
writeln!(out)?;
writeln!(out, "Usage:")?;
writeln!(
out,
" claw [--model MODEL] [--allowedTools TOOL[,TOOL...]]"
)?;
writeln!(out, " Start the interactive REPL")?;
writeln!(
out,
" claw [--model MODEL] [--output-format text|json] prompt TEXT"
)?;
writeln!(out, " Send one prompt and exit")?;
writeln!(
out,
" claw [--model MODEL] [--output-format text|json] TEXT"
)?;
writeln!(out, " Shorthand non-interactive prompt mode")?;
writeln!(
out,
" claw --resume [SESSION.jsonl|session-id|latest] [/status] [/compact] [...]"
)?;
writeln!(
out,
" Inspect or maintain a saved session without entering the REPL"
)?;
writeln!(out, " claw help")?;
writeln!(out, " Alias for --help")?;
writeln!(out, " claw version")?;
writeln!(out, " Alias for --version")?;
writeln!(out, " claw status")?;
writeln!(
out,
" Show the current local workspace status snapshot"
)?;
writeln!(out, " claw sandbox")?;
writeln!(out, " Show the current sandbox isolation snapshot")?;
writeln!(out, " claw doctor")?;
writeln!(
out,
" Diagnose local auth, config, workspace, and sandbox health"
)?;
writeln!(out, " claw acp [serve]")?;
writeln!(
out,
" Show ACP/Zed editor integration status (currently unsupported; aliases: --acp, -acp)"
)?;
writeln!(out, " Source of truth: {OFFICIAL_REPO_SLUG}")?;
writeln!(
out,
" Warning: do not `{DEPRECATED_INSTALL_COMMAND}` (deprecated stub)"
)?;
writeln!(out, " claw dump-manifests [--manifests-dir PATH]")?;
writeln!(out, " claw bootstrap-plan")?;
writeln!(out, " claw agents")?;
writeln!(out, " claw mcp")?;
writeln!(out, " claw skills")?;
writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?;
writeln!(out, " claw init")?;
writeln!(
out,
" claw export [PATH] [--session SESSION] [--output PATH]"
)?;
writeln!(
out,
" Dump the latest (or named) session as markdown; writes to PATH or stdout"
)?;
writeln!(out)?;
writeln!(out, "Flags:")?;
writeln!(
out,
" --model MODEL Override the active model"
)?;
writeln!(
out,
" --output-format FORMAT Non-interactive output format: text or json"
)?;
writeln!(
out,
" --compact Strip tool call details; print only the final assistant text (text mode only; useful for piping)"
)?;
writeln!(
out,
" --permission-mode MODE Set read-only, workspace-write, or danger-full-access"
)?;
writeln!(
out,
" --dangerously-skip-permissions Skip all permission checks"
)?;
writeln!(out, " --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)")?;
writeln!(
out,
" --version, -V Print version and build information locally"
)?;
writeln!(out)?;
writeln!(out, "Interactive slash commands:")?;
writeln!(out, "{}", render_slash_command_help_filtered(STUB_COMMANDS))?;
writeln!(out)?;
let resume_commands = resume_supported_slash_commands()
.into_iter()
.map(|spec| match spec.argument_hint {
Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
None => format!("/{}", spec.name),
})
.collect::<Vec<_>>()
.join(", ");
writeln!(out, "Resume-safe commands: {resume_commands}")?;
writeln!(out)?;
writeln!(out, "Session shortcuts:")?;
writeln!(
out,
" REPL turns auto-save to .claw/sessions/<session-id>.{PRIMARY_SESSION_EXTENSION}"
)?;
writeln!(
out,
" Use `{LATEST_SESSION_REFERENCE}` with --resume, /resume, or /session switch to target the newest saved session"
)?;
writeln!(
out,
" Use /session list in the REPL to browse managed sessions"
)?;
writeln!(out, "Examples:")?;
writeln!(out, " claw --model claude-opus \"summarize this repo\"")?;
writeln!(
out,
" claw --output-format json prompt \"explain src/main.rs\""
)?;
writeln!(out, " claw --compact \"summarize Cargo.toml\" | wc -l")?;
writeln!(
out,
" claw --allowedTools read,glob \"summarize Cargo.toml\""
)?;
writeln!(out, " claw --resume {LATEST_SESSION_REFERENCE}")?;
writeln!(
out,
" claw --resume {LATEST_SESSION_REFERENCE} /status /diff /export notes.txt"
)?;
writeln!(out, " claw agents")?;
writeln!(out, " claw mcp show my-server")?;
writeln!(out, " claw /skills")?;
writeln!(out, " claw doctor")?;
writeln!(out, " source of truth: {OFFICIAL_REPO_URL}")?;
writeln!(
out,
" do not run `{DEPRECATED_INSTALL_COMMAND}` — it installs a deprecated stub"
)?;
writeln!(out, " claw init")?;
writeln!(out, " claw export")?;
writeln!(out, " claw export conversation.md")?;
Ok(())
}
fn print_help(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let mut buffer = Vec::new();
print_help_to(&mut buffer)?;
let message = String::from_utf8(buffer)?;
match output_format {
CliOutputFormat::Text => print!("{message}"),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "help",
"message": message,
}))?
),
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
build_runtime_plugin_state_with_loader, build_runtime_with_plugin_state,
classify_error_kind, collect_session_prompt_history, create_managed_session_handle,
describe_tool_progress, filter_tool_specs, format_bughunter_report,
format_commit_preflight_report, format_commit_skipped_report, format_compact_report,
format_connected_line, format_cost_report, format_history_timestamp,
format_internal_prompt_progress_line, format_issue_report, format_model_report,
format_model_switch_report, 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, format_user_visible_api_error,
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_history_count, permission_policy, print_help_to, push_output_block,
render_config_report, render_diff_report, render_diff_report_for, render_help_topic,
render_memory_report, render_prompt_history_report, render_repl_help, render_resume_usage,
render_session_markdown, resolve_model_alias, resolve_model_alias_with_config,
resolve_repl_model, resolve_session_reference, response_to_events,
resume_supported_slash_commands, run_resume_command, short_tool_id,
slash_command_completion_candidates_with_sessions, split_error_hint, status_context,
summarize_tool_payload_for_markdown, try_resolve_bare_skill_prompt, validate_no_args,
write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary,
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic,
PromptHistoryEntry, SessionLifecycleKind, SessionLifecycleSummary, SlashCommand, StatusUsage, DEFAULT_MODEL, LATEST_SESSION_REFERENCE,
STUB_COMMANDS,
};
use api::{ApiError, MessageResponse, OutputContentBlock, Usage};
use plugins::{
PluginManager, PluginManagerConfig, PluginTool, PluginToolDefinition, PluginToolPermission,
};
use runtime::{
load_oauth_credentials, save_oauth_credentials, AssistantEvent, ConfigLoader, ContentBlock,
ConversationMessage, MessageRole, OAuthConfig, PermissionMode, Session, ToolExecutor,
};
use serde_json::json;
use std::fs;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Mutex, MutexGuard, OnceLock};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tools::GlobalToolRegistry;
fn registry_with_plugin_tool() -> GlobalToolRegistry {
GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
"plugin-demo@external",
"plugin-demo",
PluginToolDefinition {
name: "plugin_echo".to_string(),
description: Some("Echo plugin payload".to_string()),
input_schema: json!({
"type": "object",
"properties": {
"message": { "type": "string" }
},
"required": ["message"],
"additionalProperties": false
}),
},
"echo".to_string(),
Vec::new(),
PluginToolPermission::WorkspaceWrite,
None,
)])
.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,
suggested_action: None,
};
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,
suggested_action: None,
}),
};
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"));
}
#[test]
fn context_window_preflight_errors_render_recovery_steps() {
let error = ApiError::ContextWindowExceeded {
model: "claude-sonnet-4-6".to_string(),
estimated_input_tokens: 182_000,
requested_output_tokens: 64_000,
estimated_total_tokens: 246_000,
context_window_tokens: 200_000,
};
let rendered = format_user_visible_api_error("session-issue-32", &error);
assert!(rendered.contains("Context window blocked"), "{rendered}");
assert!(rendered.contains("context_window_blocked"), "{rendered}");
assert!(
rendered.contains("Session session-issue-32"),
"{rendered}"
);
assert!(
rendered.contains("Model claude-sonnet-4-6"),
"{rendered}"
);
assert!(
rendered.contains("Input estimate ~182000 tokens (heuristic)"),
"{rendered}"
);
assert!(
rendered.contains("Total estimate ~246000 tokens (heuristic)"),
"{rendered}"
);
assert!(rendered.contains("Compact /compact"), "{rendered}");
assert!(
rendered.contains("Resume compact claw --resume session-issue-32 /compact"),
"{rendered}"
);
assert!(
rendered.contains("Fresh session /clear --confirm"),
"{rendered}"
);
assert!(rendered.contains("Reduce scope"), "{rendered}");
assert!(rendered.contains("Retry rerun"), "{rendered}");
}
#[test]
fn provider_context_window_errors_are_reframed_with_same_guidance() {
let error = ApiError::Api {
status: "400".parse().expect("status"),
error_type: Some("invalid_request_error".to_string()),
message: Some(
"This model's maximum context length is 200000 tokens, but your request used 230000 tokens."
.to_string(),
),
request_id: Some("req_ctx_456".to_string()),
body: String::new(),
retryable: false,
suggested_action: None,
};
let rendered = format_user_visible_api_error("session-issue-32", &error);
assert!(rendered.contains("context_window_blocked"), "{rendered}");
assert!(
rendered.contains("Trace req_ctx_456"),
"{rendered}"
);
assert!(
rendered
.contains("Detail This model's maximum context length is 200000 tokens"),
"{rendered}"
);
assert!(rendered.contains("Compact /compact"), "{rendered}");
assert!(
rendered.contains("Fresh session /clear --confirm"),
"{rendered}"
);
}
#[test]
fn retry_wrapped_context_window_errors_keep_recovery_guidance() {
let error = ApiError::RetriesExhausted {
attempts: 2,
last_error: Box::new(ApiError::Api {
status: "413".parse().expect("status"),
error_type: Some("invalid_request_error".to_string()),
message: Some("Request is too large for this model's context window.".to_string()),
request_id: Some("req_ctx_retry_789".to_string()),
body: String::new(),
retryable: false,
suggested_action: None,
}),
};
let rendered = format_user_visible_api_error("session-issue-32", &error);
assert!(rendered.contains("Context window blocked"), "{rendered}");
assert!(rendered.contains("context_window_blocked"), "{rendered}");
assert!(
rendered.contains("Trace req_ctx_retry_789"),
"{rendered}"
);
assert!(
rendered
.contains("Detail Request is too large for this model's context window."),
"{rendered}"
);
assert!(rendered.contains("Compact /compact"), "{rendered}");
assert!(
rendered.contains("Resume compact claw --resume session-issue-32 /compact"),
"{rendered}"
);
}
fn temp_dir() -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_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) {
let status = Command::new("git")
.args(args)
.current_dir(cwd)
.status()
.expect("git command should run");
assert!(
status.success(),
"git command failed: git {}",
args.join(" ")
);
}
fn env_lock() -> MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn with_current_dir<T>(cwd: &Path, f: impl FnOnce() -> T) -> T {
let _guard = cwd_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let previous = std::env::current_dir().expect("cwd should load");
std::env::set_current_dir(cwd).expect("cwd should change");
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
std::env::set_current_dir(previous).expect("cwd should restore");
match result {
Ok(value) => value,
Err(payload) => std::panic::resume_unwind(payload),
}
}
fn write_skill_fixture(root: &Path, name: &str, description: &str) {
let skill_dir = root.join(name);
fs::create_dir_all(&skill_dir).expect("skill dir should exist");
fs::write(
skill_dir.join("SKILL.md"),
format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
)
.expect("skill file should write");
}
fn write_plugin_fixture(root: &Path, name: &str, include_hooks: bool, include_lifecycle: bool) {
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
if include_hooks {
fs::create_dir_all(root.join("hooks")).expect("hooks dir");
fs::write(
root.join("hooks").join("pre.sh"),
"#!/bin/sh\nprintf 'plugin pre hook'\n",
)
.expect("write hook");
}
if include_lifecycle {
fs::create_dir_all(root.join("lifecycle")).expect("lifecycle dir");
fs::write(
root.join("lifecycle").join("init.sh"),
"#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n",
)
.expect("write init lifecycle");
fs::write(
root.join("lifecycle").join("shutdown.sh"),
"#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n",
)
.expect("write shutdown lifecycle");
}
let hooks = if include_hooks {
",\n \"hooks\": {\n \"PreToolUse\": [\"./hooks/pre.sh\"]\n }"
} else {
""
};
let lifecycle = if include_lifecycle {
",\n \"lifecycle\": {\n \"Init\": [\"./lifecycle/init.sh\"],\n \"Shutdown\": [\"./lifecycle/shutdown.sh\"]\n }"
} else {
""
};
fs::write(
root.join(".claude-plugin").join("plugin.json"),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"runtime plugin fixture\"{hooks}{lifecycle}\n}}"
),
)
.expect("write plugin manifest");
}
#[test]
fn defaults_to_repl_when_no_args() {
let _guard = env_lock();
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
assert_eq!(
parse_args(&[]).expect("args should parse"),
CliAction::Repl {
model: DEFAULT_MODEL.to_string(),
allowed_tools: None,
permission_mode: PermissionMode::DangerFullAccess,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
}
#[test]
fn default_permission_mode_uses_project_config_when_env_is_unset() {
let _guard = env_lock();
let root = temp_dir();
let cwd = root.join("project");
let config_home = root.join("config-home");
std::fs::create_dir_all(cwd.join(".claw")).expect("project config dir should exist");
std::fs::create_dir_all(&config_home).expect("config home should exist");
std::fs::write(
cwd.join(".claw").join("settings.json"),
r#"{"permissionMode":"acceptEdits"}"#,
)
.expect("project config should write");
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
let original_permission_mode = std::env::var("RUSTY_CLAUDE_PERMISSION_MODE").ok();
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
let resolved = with_current_dir(&cwd, super::default_permission_mode);
match original_config_home {
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
None => std::env::remove_var("CLAW_CONFIG_HOME"),
}
match original_permission_mode {
Some(value) => std::env::set_var("RUSTY_CLAUDE_PERMISSION_MODE", value),
None => std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"),
}
std::fs::remove_dir_all(root).expect("temp config root should clean up");
assert_eq!(resolved, PermissionMode::WorkspaceWrite);
}
#[test]
fn env_permission_mode_overrides_project_config_default() {
let _guard = env_lock();
let root = temp_dir();
let cwd = root.join("project");
let config_home = root.join("config-home");
std::fs::create_dir_all(cwd.join(".claw")).expect("project config dir should exist");
std::fs::create_dir_all(&config_home).expect("config home should exist");
std::fs::write(
cwd.join(".claw").join("settings.json"),
r#"{"permissionMode":"acceptEdits"}"#,
)
.expect("project config should write");
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
let original_permission_mode = std::env::var("RUSTY_CLAUDE_PERMISSION_MODE").ok();
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
std::env::set_var("RUSTY_CLAUDE_PERMISSION_MODE", "read-only");
let resolved = with_current_dir(&cwd, super::default_permission_mode);
match original_config_home {
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
None => std::env::remove_var("CLAW_CONFIG_HOME"),
}
match original_permission_mode {
Some(value) => std::env::set_var("RUSTY_CLAUDE_PERMISSION_MODE", value),
None => std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"),
}
std::fs::remove_dir_all(root).expect("temp config root should clean up");
assert_eq!(resolved, PermissionMode::ReadOnly);
}
#[test]
fn resolve_cli_auth_source_ignores_saved_oauth_credentials() {
let _guard = env_lock();
let config_home = temp_dir();
std::fs::create_dir_all(&config_home).expect("config home should exist");
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
let original_api_key = std::env::var("ANTHROPIC_API_KEY").ok();
let original_auth_token = std::env::var("ANTHROPIC_AUTH_TOKEN").ok();
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_API_KEY");
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
save_oauth_credentials(&runtime::OAuthTokenSet {
access_token: "expired-access-token".to_string(),
refresh_token: Some("refresh-token".to_string()),
expires_at: Some(0),
scopes: vec!["org:create_api_key".to_string(), "user:profile".to_string()],
})
.expect("save expired oauth credentials");
let error = super::resolve_cli_auth_source_for_cwd()
.expect_err("saved oauth should be ignored without env auth");
match original_config_home {
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
None => std::env::remove_var("CLAW_CONFIG_HOME"),
}
match original_api_key {
Some(value) => std::env::set_var("ANTHROPIC_API_KEY", value),
None => std::env::remove_var("ANTHROPIC_API_KEY"),
}
match original_auth_token {
Some(value) => std::env::set_var("ANTHROPIC_AUTH_TOKEN", value),
None => std::env::remove_var("ANTHROPIC_AUTH_TOKEN"),
}
std::fs::remove_dir_all(config_home).expect("temp config home should clean up");
assert!(error.to_string().contains("ANTHROPIC_API_KEY"));
}
#[test]
fn parses_prompt_subcommand() {
let _guard = env_lock();
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
let args = vec![
"prompt".to_string(),
"hello".to_string(),
"world".to_string(),
];
assert_eq!(
parse_args(&args).expect("args should parse"),
CliAction::Prompt {
prompt: "hello world".to_string(),
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::DangerFullAccess,
compact: false,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
}
#[test]
fn merge_prompt_with_stdin_returns_prompt_unchanged_when_no_pipe() {
// given
let prompt = "Review this";
// when
let merged = merge_prompt_with_stdin(prompt, None);
// then
assert_eq!(merged, "Review this");
}
#[test]
fn merge_prompt_with_stdin_ignores_whitespace_only_pipe() {
// given
let prompt = "Review this";
let piped = " \n\t\n ";
// when
let merged = merge_prompt_with_stdin(prompt, Some(piped));
// then
assert_eq!(merged, "Review this");
}
#[test]
fn merge_prompt_with_stdin_appends_piped_content_as_context() {
// given
let prompt = "Review this";
let piped = "fn main() { println!(\"hi\"); }\n";
// when
let merged = merge_prompt_with_stdin(prompt, Some(piped));
// then
assert_eq!(merged, "Review this\n\nfn main() { println!(\"hi\"); }");
}
#[test]
fn merge_prompt_with_stdin_trims_surrounding_whitespace_on_pipe() {
// given
let prompt = "Summarize";
let piped = "\n\n some notes \n\n";
// when
let merged = merge_prompt_with_stdin(prompt, Some(piped));
// then
assert_eq!(merged, "Summarize\n\nsome notes");
}
#[test]
fn merge_prompt_with_stdin_returns_pipe_when_prompt_is_empty() {
// given
let prompt = "";
let piped = "standalone body";
// when
let merged = merge_prompt_with_stdin(prompt, Some(piped));
// then
assert_eq!(merged, "standalone body");
}
#[test]
fn parses_bare_prompt_and_json_output_flag() {
let _guard = env_lock();
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
let args = vec![
"--output-format=json".to_string(),
"--model".to_string(),
"opus".to_string(),
"explain".to_string(),
"this".to_string(),
];
assert_eq!(
parse_args(&args).expect("args should parse"),
CliAction::Prompt {
prompt: "explain this".to_string(),
model: "claude-opus-4-6".to_string(),
output_format: CliOutputFormat::Json,
allowed_tools: None,
permission_mode: PermissionMode::DangerFullAccess,
compact: false,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
}
#[test]
fn parses_compact_flag_for_prompt_mode() {
// given a bare prompt invocation that includes the --compact flag
let _guard = env_lock();
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
let args = vec![
"--compact".to_string(),
"summarize".to_string(),
"this".to_string(),
];
// when parse_args interprets the flag
let parsed = parse_args(&args).expect("args should parse");
// then compact mode is propagated and other defaults stay unchanged
assert_eq!(
parsed,
CliAction::Prompt {
prompt: "summarize this".to_string(),
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::DangerFullAccess,
compact: true,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
}
#[test]
fn prompt_subcommand_defaults_compact_to_false() {
// given a `prompt` subcommand invocation without --compact
let _guard = env_lock();
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
let args = vec!["prompt".to_string(), "hello".to_string()];
// when parse_args runs
let parsed = parse_args(&args).expect("args should parse");
// then compact stays false (opt-in flag)
match parsed {
CliAction::Prompt { compact, .. } => assert!(!compact),
other => panic!("expected Prompt action, got {other:?}"),
}
}
#[test]
fn resolves_model_aliases_in_args() {
let _guard = env_lock();
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
let args = vec![
"--model".to_string(),
"opus".to_string(),
"explain".to_string(),
"this".to_string(),
];
assert_eq!(
parse_args(&args).expect("args should parse"),
CliAction::Prompt {
prompt: "explain this".to_string(),
model: "claude-opus-4-6".to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::DangerFullAccess,
compact: false,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
}
#[test]
fn resolves_known_model_aliases() {
assert_eq!(resolve_model_alias("opus"), "claude-opus-4-6");
assert_eq!(resolve_model_alias("sonnet"), "claude-sonnet-4-6");
assert_eq!(resolve_model_alias("haiku"), "claude-haiku-4-5-20251213");
assert_eq!(resolve_model_alias("claude-opus"), "claude-opus");
}
#[test]
fn user_defined_aliases_resolve_before_provider_dispatch() {
// given
let _guard = env_lock();
let root = temp_dir();
let cwd = root.join("project");
let config_home = root.join("config-home");
std::fs::create_dir_all(cwd.join(".claw")).expect("project config dir should exist");
std::fs::create_dir_all(&config_home).expect("config home should exist");
std::fs::write(
cwd.join(".claw").join("settings.json"),
r#"{"aliases":{"fast":"claude-haiku-4-5-20251213","smart":"opus","cheap":"grok-3-mini"}}"#,
)
.expect("project config should write");
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
// when
let direct = with_current_dir(&cwd, || resolve_model_alias_with_config("fast"));
let chained = with_current_dir(&cwd, || resolve_model_alias_with_config("smart"));
let cross_provider = with_current_dir(&cwd, || resolve_model_alias_with_config("cheap"));
let unknown = with_current_dir(&cwd, || resolve_model_alias_with_config("unknown-model"));
let builtin = with_current_dir(&cwd, || resolve_model_alias_with_config("haiku"));
match original_config_home {
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
None => std::env::remove_var("CLAW_CONFIG_HOME"),
}
std::fs::remove_dir_all(root).expect("temp config root should clean up");
// then
assert_eq!(direct, "claude-haiku-4-5-20251213");
assert_eq!(chained, "claude-opus-4-6");
assert_eq!(cross_provider, "grok-3-mini");
assert_eq!(unknown, "unknown-model");
assert_eq!(builtin, "claude-haiku-4-5-20251213");
}
#[test]
fn parses_version_flags_without_initializing_prompt_mode() {
assert_eq!(
parse_args(&["--version".to_string()]).expect("args should parse"),
CliAction::Version {
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["-V".to_string()]).expect("args should parse"),
CliAction::Version {
output_format: CliOutputFormat::Text,
}
);
}
#[test]
fn parses_permission_mode_flag() {
let args = vec!["--permission-mode=read-only".to_string()];
assert_eq!(
parse_args(&args).expect("args should parse"),
CliAction::Repl {
model: DEFAULT_MODEL.to_string(),
allowed_tools: None,
permission_mode: PermissionMode::ReadOnly,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
}
#[test]
fn dangerously_skip_permissions_flag_forces_danger_full_access_in_repl() {
let _guard = env_lock();
std::env::set_var("RUSTY_CLAUDE_PERMISSION_MODE", "read-only");
let args = vec!["--dangerously-skip-permissions".to_string()];
let parsed = parse_args(&args).expect("args should parse");
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
assert_eq!(
parsed,
CliAction::Repl {
model: DEFAULT_MODEL.to_string(),
allowed_tools: None,
permission_mode: PermissionMode::DangerFullAccess,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
}
#[test]
fn dangerously_skip_permissions_flag_applies_to_prompt_subcommand() {
let _guard = env_lock();
std::env::set_var("RUSTY_CLAUDE_PERMISSION_MODE", "read-only");
let args = vec![
"--dangerously-skip-permissions".to_string(),
"prompt".to_string(),
"do".to_string(),
"the".to_string(),
"thing".to_string(),
];
let parsed = parse_args(&args).expect("args should parse");
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
assert_eq!(
parsed,
CliAction::Prompt {
prompt: "do the thing".to_string(),
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::DangerFullAccess,
compact: false,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
}
#[test]
fn parses_allowed_tools_flags_with_aliases_and_lists() {
let _guard = env_lock();
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
let args = vec![
"--allowedTools".to_string(),
"read,glob".to_string(),
"--allowed-tools=write_file".to_string(),
];
assert_eq!(
parse_args(&args).expect("args should parse"),
CliAction::Repl {
model: DEFAULT_MODEL.to_string(),
allowed_tools: Some(
["glob_search", "read_file", "write_file"]
.into_iter()
.map(str::to_string)
.collect()
),
permission_mode: PermissionMode::DangerFullAccess,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
}
#[test]
fn rejects_unknown_allowed_tools() {
let error = parse_args(&["--allowedTools".to_string(), "teleport".to_string()])
.expect_err("tool should be rejected");
assert!(error.contains("unsupported tool in --allowedTools: teleport"));
}
#[test]
fn rejects_empty_allowed_tools_flag() {
for raw in ["", ",,"] {
let error = parse_args(&["--allowedTools".to_string(), raw.to_string()])
.expect_err("empty allowedTools should be rejected");
assert!(
error.contains("--allowedTools was provided with no usable tool names"),
"unexpected error for {raw:?}: {error}"
);
}
}
#[test]
fn parses_system_prompt_options() {
let args = vec![
"system-prompt".to_string(),
"--cwd".to_string(),
"/tmp/project".to_string(),
"--date".to_string(),
"2026-04-01".to_string(),
];
assert_eq!(
parse_args(&args).expect("args should parse"),
CliAction::PrintSystemPrompt {
cwd: PathBuf::from("/tmp/project"),
date: "2026-04-01".to_string(),
output_format: CliOutputFormat::Text,
}
);
}
#[test]
fn removed_login_and_logout_subcommands_error_helpfully() {
let login = parse_args(&["login".to_string()]).expect_err("login should be removed");
assert!(login.contains("ANTHROPIC_API_KEY"));
let logout = parse_args(&["logout".to_string()]).expect_err("logout should be removed");
assert!(logout.contains("ANTHROPIC_AUTH_TOKEN"));
assert_eq!(
parse_args(&["doctor".to_string()]).expect("doctor should parse"),
CliAction::Doctor {
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["state".to_string()]).expect("state should parse"),
CliAction::State {
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&[
"state".to_string(),
"--output-format".to_string(),
"json".to_string()
])
.expect("state --output-format json should parse"),
CliAction::State {
output_format: CliOutputFormat::Json,
}
);
assert_eq!(
parse_args(&["init".to_string()]).expect("init should parse"),
CliAction::Init {
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["agents".to_string()]).expect("agents should parse"),
CliAction::Agents {
args: None,
output_format: CliOutputFormat::Text
}
);
assert_eq!(
parse_args(&["mcp".to_string()]).expect("mcp should parse"),
CliAction::Mcp {
args: None,
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["skills".to_string()]).expect("skills should parse"),
CliAction::Skills {
args: None,
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&[
"skills".to_string(),
"help".to_string(),
"overview".to_string()
])
.expect("skills help overview should invoke"),
CliAction::Prompt {
prompt: "$help overview".to_string(),
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: crate::default_permission_mode(),
compact: false,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
assert_eq!(
parse_args(&["agents".to_string(), "--help".to_string()])
.expect("agents help should parse"),
CliAction::Agents {
args: Some("--help".to_string()),
output_format: CliOutputFormat::Text,
}
);
// #145: `plugins` must parse as CliAction::Plugins (not fall through
// to the prompt path, which would hit the Anthropic API for a purely
// local introspection command).
assert_eq!(
parse_args(&["plugins".to_string()]).expect("plugins should parse"),
CliAction::Plugins {
action: None,
target: None,
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["plugins".to_string(), "list".to_string()])
.expect("plugins list should parse"),
CliAction::Plugins {
action: Some("list".to_string()),
target: None,
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&[
"plugins".to_string(),
"enable".to_string(),
"example-bundled".to_string(),
])
.expect("plugins enable <target> should parse"),
CliAction::Plugins {
action: Some("enable".to_string()),
target: Some("example-bundled".to_string()),
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&[
"plugins".to_string(),
"--output-format".to_string(),
"json".to_string(),
])
.expect("plugins --output-format json should parse"),
CliAction::Plugins {
action: None,
target: None,
output_format: CliOutputFormat::Json,
}
);
// #146: `config` and `diff` must parse as standalone CLI actions,
// not fall through to the "is a slash command" error. Both are
// pure-local read-only introspection.
assert_eq!(
parse_args(&["config".to_string()]).expect("config should parse"),
CliAction::Config {
section: None,
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["config".to_string(), "env".to_string()])
.expect("config env should parse"),
CliAction::Config {
section: Some("env".to_string()),
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&[
"config".to_string(),
"--output-format".to_string(),
"json".to_string(),
])
.expect("config --output-format json should parse"),
CliAction::Config {
section: None,
output_format: CliOutputFormat::Json,
}
);
assert_eq!(
parse_args(&["diff".to_string()]).expect("diff should parse"),
CliAction::Diff {
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&[
"diff".to_string(),
"--output-format".to_string(),
"json".to_string(),
])
.expect("diff --output-format json should parse"),
CliAction::Diff {
output_format: CliOutputFormat::Json,
}
);
// #147: empty / whitespace-only positional args must be rejected
// with a specific error instead of falling through to the prompt
// path (where they surface a misleading "missing Anthropic
// credentials" error or burn API tokens on an empty prompt).
let empty_err =
parse_args(&["".to_string()]).expect_err("empty positional arg should be rejected");
assert!(
empty_err.starts_with("empty prompt:"),
"empty-arg error should be specific, got: {empty_err}"
);
let whitespace_err = parse_args(&[" ".to_string()])
.expect_err("whitespace-only positional arg should be rejected");
assert!(
whitespace_err.starts_with("empty prompt:"),
"whitespace-only error should be specific, got: {whitespace_err}"
);
let multi_empty_err = parse_args(&["".to_string(), "".to_string()])
.expect_err("multiple empty positional args should be rejected");
assert!(
multi_empty_err.starts_with("empty prompt:"),
"multi-empty error should be specific, got: {multi_empty_err}"
);
// Typo guard from #108 must still take precedence for non-empty
// single-word non-prompt-looking inputs.
let typo_err = parse_args(&["sttaus".to_string()])
.expect_err("typo'd subcommand should be caught by #108 guard");
assert!(
typo_err.starts_with("unknown subcommand:"),
"typo guard should fire for 'sttaus', got: {typo_err}"
);
// #148: `--model` flag must be captured as model_flag_raw so status
// JSON can report provenance (source: flag, raw: <user-input>).
match parse_args(&[
"--model".to_string(),
"sonnet".to_string(),
"status".to_string(),
])
.expect("--model sonnet status should parse")
{
CliAction::Status {
model,
model_flag_raw,
..
} => {
assert_eq!(model, "claude-sonnet-4-6", "sonnet alias should resolve");
assert_eq!(
model_flag_raw.as_deref(),
Some("sonnet"),
"raw flag input should be preserved"
);
}
other => panic!("expected CliAction::Status, got: {other:?}"),
}
// --model= form should also capture raw.
match parse_args(&[
"--model=anthropic/claude-opus-4-6".to_string(),
"status".to_string(),
])
.expect("--model=... status should parse")
{
CliAction::Status {
model,
model_flag_raw,
..
} => {
assert_eq!(model, "anthropic/claude-opus-4-6");
assert_eq!(
model_flag_raw.as_deref(),
Some("anthropic/claude-opus-4-6"),
"--model= form should also preserve raw input"
);
}
other => panic!("expected CliAction::Status, got: {other:?}"),
}
}
#[test]
fn dump_manifests_subcommand_accepts_explicit_manifest_dir() {
assert_eq!(
parse_args(&[
"dump-manifests".to_string(),
"--manifests-dir".to_string(),
"/tmp/upstream".to_string(),
])
.expect("dump-manifests should parse"),
CliAction::DumpManifests {
output_format: CliOutputFormat::Text,
manifests_dir: Some(PathBuf::from("/tmp/upstream")),
}
);
assert_eq!(
parse_args(&[
"dump-manifests".to_string(),
"--manifests-dir=/tmp/upstream".to_string()
])
.expect("inline dump-manifests flag should parse"),
CliAction::DumpManifests {
output_format: CliOutputFormat::Text,
manifests_dir: Some(PathBuf::from("/tmp/upstream")),
}
);
}
#[test]
fn parses_acp_command_surfaces() {
assert_eq!(
parse_args(&["acp".to_string()]).expect("acp should parse"),
CliAction::Acp {
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["acp".to_string(), "serve".to_string()]).expect("acp serve should parse"),
CliAction::Acp {
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["--acp".to_string()]).expect("--acp should parse"),
CliAction::Acp {
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["-acp".to_string()]).expect("-acp should parse"),
CliAction::Acp {
output_format: CliOutputFormat::Text,
}
);
}
#[test]
fn local_command_help_flags_stay_on_the_local_parser_path() {
assert_eq!(
parse_args(&["status".to_string(), "--help".to_string()])
.expect("status help should parse"),
CliAction::HelpTopic(LocalHelpTopic::Status)
);
assert_eq!(
parse_args(&["sandbox".to_string(), "-h".to_string()])
.expect("sandbox help should parse"),
CliAction::HelpTopic(LocalHelpTopic::Sandbox)
);
assert_eq!(
parse_args(&["doctor".to_string(), "--help".to_string()])
.expect("doctor help should parse"),
CliAction::HelpTopic(LocalHelpTopic::Doctor)
);
assert_eq!(
parse_args(&["acp".to_string(), "--help".to_string()]).expect("acp help should parse"),
CliAction::HelpTopic(LocalHelpTopic::Acp)
);
}
#[test]
fn subcommand_help_flag_has_one_contract_across_all_subcommands_141() {
// #141: every documented subcommand must resolve `<subcommand> --help`
// to a subcommand-specific help topic, never to global help, never to
// an "unknown option" error, never to the subcommand's primary output.
let cases: &[(&str, LocalHelpTopic)] = &[
("status", LocalHelpTopic::Status),
("sandbox", LocalHelpTopic::Sandbox),
("doctor", LocalHelpTopic::Doctor),
("acp", LocalHelpTopic::Acp),
("init", LocalHelpTopic::Init),
("state", LocalHelpTopic::State),
("export", LocalHelpTopic::Export),
("version", LocalHelpTopic::Version),
("system-prompt", LocalHelpTopic::SystemPrompt),
("dump-manifests", LocalHelpTopic::DumpManifests),
("bootstrap-plan", LocalHelpTopic::BootstrapPlan),
];
for (subcommand, expected_topic) in cases {
for flag in ["--help", "-h"] {
let parsed = parse_args(&[subcommand.to_string(), flag.to_string()])
.unwrap_or_else(|error| {
panic!("`{subcommand} {flag}` should parse as help but errored: {error}")
});
assert_eq!(
parsed,
CliAction::HelpTopic(*expected_topic),
"`{subcommand} {flag}` should resolve to HelpTopic({expected_topic:?})"
);
}
// And the rendered help must actually mention the subcommand name
// (or its canonical title) so users know they got the right help.
let rendered = render_help_topic(*expected_topic);
assert!(
!rendered.is_empty(),
"{subcommand} help text should not be empty"
);
assert!(
rendered.contains("Usage"),
"{subcommand} help text should contain a Usage line"
);
}
}
#[test]
fn status_degrades_gracefully_on_malformed_mcp_config_143() {
// #143: previously `claw status` hard-failed on any config parse error,
// taking down the entire health surface for one malformed MCP entry.
// `claw doctor` already degrades gracefully; this test locks `status`
// to the same contract.
let _guard = env_lock();
let root = temp_dir();
let cwd = root.join("project-with-malformed-mcp");
std::fs::create_dir_all(&cwd).expect("project dir should exist");
// One valid server + one malformed entry missing `command`.
std::fs::write(
cwd.join(".claw.json"),
r#"{
"mcpServers": {
"everything": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-everything"]},
"missing-command": {"args": ["arg-only-no-command"]}
}
}
"#,
)
.expect("write malformed .claw.json");
let context = with_current_dir(&cwd, || {
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.
let err = context
.config_load_error
.as_ref()
.expect("config_load_error should be Some when config parse fails");
assert!(
err.contains("mcpServers.missing-command"),
"config_load_error should name the malformed field path: {err}"
);
assert!(
err.contains("missing string field command"),
"config_load_error should carry the underlying parse error: {err}"
);
// Phase 1 contract: workspace/git/sandbox fields are still populated
// (independent of config parse). Sandbox falls back to defaults.
assert_eq!(context.cwd, cwd.canonicalize().unwrap_or(cwd.clone()));
assert_eq!(
context.loaded_config_files, 0,
"loaded_config_files should be 0 when config parse fails"
);
assert!(
context.discovered_config_files > 0,
"discovered_config_files should still count the file that failed to parse"
);
// JSON output contract: top-level `status: "degraded"` + config_load_error field.
let usage = super::StatusUsage {
message_count: 0,
turns: 0,
latest: runtime::TokenUsage::default(),
cumulative: runtime::TokenUsage::default(),
estimated_tokens: 0,
};
let json = super::status_json_value(
Some("test-model"),
usage,
"workspace-write",
&context,
None,
None,
);
assert_eq!(
json.get("status").and_then(|v| v.as_str()),
Some("degraded"),
"top-level status marker should be 'degraded' when config parse failed: {json}"
);
assert!(
json.get("config_load_error")
.and_then(|v| v.as_str())
.is_some_and(|s| s.contains("mcpServers.missing-command")),
"config_load_error should surface in JSON output: {json}"
);
// Independent fields still populated.
assert_eq!(
json.get("model").and_then(|v| v.as_str()),
Some("test-model")
);
assert!(
json.get("workspace").is_some(),
"workspace field still reported"
);
assert!(
json.get("sandbox").is_some(),
"sandbox field still reported"
);
assert_eq!(
json.pointer("/allowed_tools/source")
.and_then(|v| v.as_str()),
Some("default"),
"default status should expose unrestricted tool source: {json}"
);
assert_eq!(
json.pointer("/allowed_tools/restricted")
.and_then(|v| v.as_bool()),
Some(false),
"default status should expose unrestricted tool state: {json}"
);
let allowed: super::AllowedToolSet = ["read_file", "grep_search"]
.into_iter()
.map(str::to_string)
.collect();
let restricted_json = super::status_json_value(
Some("test-model"),
usage,
"workspace-write",
&context,
None,
Some(&allowed),
);
assert_eq!(
restricted_json
.pointer("/allowed_tools/source")
.and_then(|v| v.as_str()),
Some("flag"),
"flag status should expose allow-list source: {restricted_json}"
);
assert_eq!(
restricted_json
.pointer("/allowed_tools/entries")
.and_then(|v| v.as_array())
.map(Vec::len),
Some(2),
"flag status should expose allow-list entries: {restricted_json}"
);
// Clean path: no config error → status: "ok", config_load_error: null.
let clean_cwd = root.join("project-with-clean-config");
std::fs::create_dir_all(&clean_cwd).expect("clean project dir");
let clean_context = with_current_dir(&clean_cwd, || {
super::status_context(None).expect("clean status_context should succeed")
});
assert!(clean_context.config_load_error.is_none());
let clean_json = super::status_json_value(
Some("test-model"),
usage,
"workspace-write",
&clean_context,
None,
None,
);
assert_eq!(
clean_json.get("status").and_then(|v| v.as_str()),
Some("ok"),
"clean run should report status: 'ok'"
);
}
#[test]
fn state_error_surfaces_actionable_worker_commands_139() {
// #139: the error for missing `.claw/worker-state.json` must name
// the concrete commands that produce worker state, otherwise claws
// have no discoverable path from the error to a fix.
let _guard = env_lock();
let root = temp_dir();
let cwd = root.join("project-with-no-state");
std::fs::create_dir_all(&cwd).expect("project dir should exist");
let error = with_current_dir(&cwd, || {
super::run_worker_state(CliOutputFormat::Text).expect_err("missing state should error")
});
let message = error.to_string();
// Keep the original locator so scripts grepping for it still work.
assert!(
message.contains("no worker state file found at"),
"error should keep the canonical prefix: {message}"
);
// New actionable hints — this is what #139 is fixing.
assert!(
message.contains("claw prompt"),
"error should name `claw prompt <text>` as a producer: {message}"
);
assert!(
message.contains("REPL"),
"error should mention the interactive REPL as a producer: {message}"
);
assert!(
message.contains("claw state"),
"error should tell the user what to rerun once state exists: {message}"
);
// And the State --help topic must document the worker relationship
// so claws can discover the contract without hitting the error first.
let state_help = render_help_topic(LocalHelpTopic::State);
assert!(
state_help.contains("Produces state"),
"state help must document how state is produced: {state_help}"
);
assert!(
state_help.contains("claw prompt"),
"state help must name `claw prompt <text>` as a producer: {state_help}"
);
}
#[test]
fn parses_single_word_command_aliases_without_falling_back_to_prompt_mode() {
let _guard = env_lock();
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
assert_eq!(
parse_args(&["help".to_string()]).expect("help should parse"),
CliAction::Help {
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["version".to_string()]).expect("version should parse"),
CliAction::Version {
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["status".to_string()]).expect("status should parse"),
CliAction::Status {
model: DEFAULT_MODEL.to_string(),
model_flag_raw: None, // #148: no --model flag passed
permission_mode: PermissionMode::DangerFullAccess,
output_format: CliOutputFormat::Text,
allowed_tools: None,
}
);
assert_eq!(
parse_args(&["sandbox".to_string()]).expect("sandbox should parse"),
CliAction::Sandbox {
output_format: CliOutputFormat::Text,
}
);
// #152: `--json` on diagnostic verbs should hint the correct flag.
let err = parse_args(&["doctor".to_string(), "--json".to_string()])
.expect_err("`doctor --json` should fail with hint");
assert!(
err.contains("unrecognized argument `--json` for subcommand `doctor`"),
"error should name the verb: {err}"
);
assert!(
err.contains("Did you mean `--output-format json`?"),
"error should hint the correct flag: {err}"
);
// Other unrecognized args should NOT trigger the --json hint.
let err_other = parse_args(&["doctor".to_string(), "garbage".to_string()])
.expect_err("`doctor garbage` should fail without --json hint");
assert!(
!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
let err_gpt = parse_args(&[
"prompt".to_string(),
"test".to_string(),
"--model".to_string(),
"gpt-4".to_string(),
])
.expect_err("`--model gpt-4` should fail with OpenAI hint");
assert!(
err_gpt.contains("Did you mean `openai/gpt-4`?"),
"GPT model error should hint openai/ prefix: {err_gpt}"
);
assert!(
err_gpt.contains("OPENAI_API_KEY"),
"GPT model error should mention env var: {err_gpt}"
);
// 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(),
])
.expect_err("`--model asdfgh` should fail");
assert!(
!err_garbage.contains("Did you mean"),
"Unrelated model errors should not get a hint: {err_garbage}"
);
}
#[test]
fn classify_error_kind_returns_correct_discriminants() {
// #77: error kind classification for JSON error payloads
assert_eq!(
classify_error_kind("missing Anthropic credentials; export ..."),
"missing_credentials"
);
assert_eq!(
classify_error_kind("no worker state file found at /tmp/..."),
"missing_worker_state"
);
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]
fn split_error_hint_separates_reason_from_runbook() {
// #77: short reason / hint separation for JSON error payloads
let (short, hint) = split_error_hint("missing credentials\nHint: export ANTHROPIC_API_KEY");
assert_eq!(short, "missing credentials");
assert_eq!(hint, Some("Hint: export ANTHROPIC_API_KEY".to_string()));
let (short, hint) = split_error_hint("simple error with no hint");
assert_eq!(short, "simple error with no hint");
assert_eq!(hint, None);
}
#[test]
fn parses_bare_export_subcommand_targeting_latest_session() {
// given
let _guard = env_lock();
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
let args = vec!["export".to_string()];
// when
let parsed = parse_args(&args).expect("bare export should parse");
// then
assert_eq!(
parsed,
CliAction::Export {
session_reference: LATEST_SESSION_REFERENCE.to_string(),
output_path: None,
output_format: CliOutputFormat::Text,
}
);
}
#[test]
fn parses_export_subcommand_with_positional_output_path() {
// given
let args = vec!["export".to_string(), "conversation.md".to_string()];
// when
let parsed = parse_args(&args).expect("export with path should parse");
// then
assert_eq!(
parsed,
CliAction::Export {
session_reference: LATEST_SESSION_REFERENCE.to_string(),
output_path: Some(PathBuf::from("conversation.md")),
output_format: CliOutputFormat::Text,
}
);
}
#[test]
fn parses_export_subcommand_with_session_and_output_flags() {
// given
let args = vec![
"export".to_string(),
"--session".to_string(),
"session-alpha".to_string(),
"--output".to_string(),
"/tmp/share.md".to_string(),
];
// when
let parsed = parse_args(&args).expect("export flags should parse");
// then
assert_eq!(
parsed,
CliAction::Export {
session_reference: "session-alpha".to_string(),
output_path: Some(PathBuf::from("/tmp/share.md")),
output_format: CliOutputFormat::Text,
}
);
}
#[test]
fn parses_export_subcommand_with_inline_flag_values() {
// given
let args = vec![
"export".to_string(),
"--session=session-beta".to_string(),
"--output=/tmp/beta.md".to_string(),
];
// when
let parsed = parse_args(&args).expect("export inline flags should parse");
// then
assert_eq!(
parsed,
CliAction::Export {
session_reference: "session-beta".to_string(),
output_path: Some(PathBuf::from("/tmp/beta.md")),
output_format: CliOutputFormat::Text,
}
);
}
#[test]
fn parses_export_subcommand_with_json_output_format() {
// given
let args = vec![
"--output-format=json".to_string(),
"export".to_string(),
"/tmp/notes.md".to_string(),
];
// when
let parsed = parse_args(&args).expect("json export should parse");
// then
assert_eq!(
parsed,
CliAction::Export {
session_reference: LATEST_SESSION_REFERENCE.to_string(),
output_path: Some(PathBuf::from("/tmp/notes.md")),
output_format: CliOutputFormat::Json,
}
);
}
#[test]
fn rejects_unknown_export_options_with_helpful_message() {
// given
let args = vec!["export".to_string(), "--bogus".to_string()];
// when
let error = parse_args(&args).expect_err("unknown export option should fail");
// then
assert!(error.contains("unknown export option: --bogus"));
}
#[test]
fn rejects_export_with_extra_positional_after_path() {
// given
let args = vec![
"export".to_string(),
"first.md".to_string(),
"second.md".to_string(),
];
// when
let error = parse_args(&args).expect_err("multiple positionals should fail");
// then
assert!(error.contains("unexpected export argument: second.md"));
}
#[test]
fn parse_export_args_helper_defaults_to_latest_reference_and_no_output() {
// given
let args: Vec<String> = vec![];
// when
let parsed = parse_export_args(&args, CliOutputFormat::Text)
.expect("empty export args should parse");
// then
assert_eq!(
parsed,
CliAction::Export {
session_reference: LATEST_SESSION_REFERENCE.to_string(),
output_path: None,
output_format: CliOutputFormat::Text,
}
);
}
#[test]
fn render_session_markdown_includes_header_and_summarized_tool_calls() {
// given
let mut session = Session::new();
session.session_id = "session-export-test".to_string();
session.messages = vec![
ConversationMessage::user_text("How do I list files?"),
ConversationMessage::assistant(vec![
ContentBlock::Text {
text: "I'll run a tool.".to_string(),
},
ContentBlock::ToolUse {
id: "toolu_abcdefghijklmnop".to_string(),
name: "bash".to_string(),
input: r#"{"command":"ls -la"}"#.to_string(),
},
]),
ConversationMessage {
role: MessageRole::Tool,
blocks: vec![ContentBlock::ToolResult {
tool_use_id: "toolu_abcdefghijklmnop".to_string(),
tool_name: "bash".to_string(),
output: "total 8\ndrwxr-xr-x 2 user staff 64 Apr 7 12:00 .".to_string(),
is_error: false,
}],
usage: None,
},
];
// when
let markdown = render_session_markdown(
&session,
"session-export-test",
std::path::Path::new("/tmp/sessions/session-export-test.jsonl"),
);
// then
assert!(markdown.starts_with("# Conversation Export"));
assert!(markdown.contains("- **Session**: `session-export-test`"));
assert!(markdown.contains("- **Messages**: 3"));
assert!(markdown.contains("## 1. User"));
assert!(markdown.contains("How do I list files?"));
assert!(markdown.contains("## 2. Assistant"));
assert!(markdown.contains("**Tool call** `bash`"));
assert!(markdown.contains("toolu_abcdef…"));
assert!(markdown.contains("ls -la"));
assert!(markdown.contains("## 3. Tool"));
assert!(markdown.contains("**Tool result** `bash`"));
assert!(markdown.contains("ok"));
assert!(markdown.contains("total 8"));
}
#[test]
fn render_session_markdown_marks_tool_errors_and_skips_empty_summaries() {
// given
let mut session = Session::new();
session.session_id = "errs".to_string();
session.messages = vec![ConversationMessage {
role: MessageRole::Tool,
blocks: vec![ContentBlock::ToolResult {
tool_use_id: "short".to_string(),
tool_name: "read_file".to_string(),
output: " ".to_string(),
is_error: true,
}],
usage: None,
}];
// when
let markdown =
render_session_markdown(&session, "errs", std::path::Path::new("errs.jsonl"));
// then
assert!(markdown.contains("**Tool result** `read_file` _(id `short`, error)_"));
// an empty summary should not produce a stray blockquote line
assert!(!markdown.contains("> \n"));
}
#[test]
fn summarize_tool_payload_for_markdown_compacts_json_and_truncates_overflow() {
// given
let json_payload = r#"{
"command": "ls -la",
"cwd": "/tmp"
}"#;
let long_payload = "a".repeat(600);
// when
let compacted = summarize_tool_payload_for_markdown(json_payload);
let truncated = summarize_tool_payload_for_markdown(&long_payload);
// then
assert_eq!(compacted, r#"{"command":"ls -la","cwd":"/tmp"}"#);
assert!(truncated.ends_with('…'));
assert!(truncated.chars().count() <= 281);
}
#[test]
fn short_tool_id_truncates_long_identifiers_with_ellipsis() {
// given
let long = "toolu_01ABCDEFGHIJKLMN";
let short = "tool_1";
// when
let trimmed_long = short_tool_id(long);
let trimmed_short = short_tool_id(short);
// then
assert_eq!(trimmed_long, "toolu_01ABCD…");
assert_eq!(trimmed_short, "tool_1");
}
#[test]
fn parses_json_output_for_mcp_and_skills_commands() {
assert_eq!(
parse_args(&["--output-format=json".to_string(), "mcp".to_string()])
.expect("json mcp should parse"),
CliAction::Mcp {
args: None,
output_format: CliOutputFormat::Json,
}
);
assert_eq!(
parse_args(&[
"--output-format=json".to_string(),
"/skills".to_string(),
"help".to_string(),
])
.expect("json /skills help should parse"),
CliAction::Skills {
args: Some("help".to_string()),
output_format: CliOutputFormat::Json,
}
);
}
#[test]
fn single_word_slash_command_names_return_guidance_instead_of_hitting_prompt_mode() {
let error = parse_args(&["cost".to_string()]).expect_err("cost should return guidance");
assert!(error.contains("slash command"));
assert!(error.contains("/cost"));
}
#[test]
fn multi_word_prompt_still_uses_shorthand_prompt_mode() {
let _guard = env_lock();
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
// Input is ["--model", "opus", "please", "debug", "this"] so the joined
// prompt shorthand must stay a normal multi-word prompt while still
// honoring alias validation at parse time.
assert_eq!(
parse_args(&[
"--model".to_string(),
"opus".to_string(),
"please".to_string(),
"debug".to_string(),
"this".to_string(),
])
.expect("prompt shorthand should still work"),
CliAction::Prompt {
prompt: "please debug this".to_string(),
model: "claude-opus-4-6".to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: crate::default_permission_mode(),
compact: false,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
}
#[test]
fn parses_direct_agents_mcp_and_skills_slash_commands() {
assert_eq!(
parse_args(&["/agents".to_string()]).expect("/agents should parse"),
CliAction::Agents {
args: None,
output_format: CliOutputFormat::Text
}
);
assert_eq!(
parse_args(&["/mcp".to_string(), "show".to_string(), "demo".to_string()])
.expect("/mcp show demo should parse"),
CliAction::Mcp {
args: Some("show demo".to_string()),
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["/skills".to_string()]).expect("/skills should parse"),
CliAction::Skills {
args: None,
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["/skill".to_string()]).expect("/skill should parse"),
CliAction::Skills {
args: None,
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["/skills".to_string(), "help".to_string()])
.expect("/skills help should parse"),
CliAction::Skills {
args: Some("help".to_string()),
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["/skill".to_string(), "list".to_string()])
.expect("/skill list should parse"),
CliAction::Skills {
args: Some("list".to_string()),
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&[
"/skills".to_string(),
"help".to_string(),
"overview".to_string()
])
.expect("/skills help overview should invoke"),
CliAction::Prompt {
prompt: "$help overview".to_string(),
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: crate::default_permission_mode(),
compact: false,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
assert_eq!(
parse_args(&[
"/skills".to_string(),
"install".to_string(),
"./fixtures/help-skill".to_string(),
])
.expect("/skills install should parse"),
CliAction::Skills {
args: Some("install ./fixtures/help-skill".to_string()),
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["/skills".to_string(), "/test".to_string()])
.expect("/skills /test should normalize to a single skill prompt prefix"),
CliAction::Prompt {
prompt: "$test".to_string(),
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: crate::default_permission_mode(),
compact: false,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
let error = parse_args(&["/status".to_string()])
.expect_err("/status should remain REPL-only when invoked directly");
assert!(error.contains("interactive-only"));
assert!(error.contains("claw --resume SESSION.jsonl /status"));
}
#[test]
fn direct_slash_commands_surface_shared_validation_errors() {
let compact_error = parse_args(&["/compact".to_string(), "now".to_string()])
.expect_err("invalid /compact shape should be rejected");
assert!(compact_error.contains("Unexpected arguments for /compact."));
assert!(compact_error.contains("Usage /compact"));
let plugins_error = parse_args(&[
"/plugins".to_string(),
"list".to_string(),
"extra".to_string(),
])
.expect_err("invalid /plugins list shape should be rejected");
assert!(plugins_error.contains("Usage: /plugin list"));
assert!(plugins_error.contains("Aliases /plugins, /marketplace"));
}
#[test]
fn formats_unknown_slash_command_with_suggestions() {
let report = format_unknown_slash_command_message("statsu");
assert!(report.contains("unknown slash command: /statsu"));
assert!(report.contains("Did you mean"));
assert!(report.contains("Use /help"));
}
#[test]
fn typoed_doctor_subcommand_returns_did_you_mean_error() {
let error = parse_args(&["doctorr".to_string()]).expect_err("doctorr should error");
assert!(error.contains("unknown subcommand: doctorr."));
assert!(error.contains("Did you mean"));
assert!(error.contains("doctor"));
}
#[test]
fn typoed_skills_subcommand_returns_did_you_mean_error() {
let error = parse_args(&["skilsl".to_string()]).expect_err("skilsl should error");
assert!(error.contains("unknown subcommand: skilsl."));
assert!(error.contains("skills"));
}
#[test]
fn typoed_status_subcommand_returns_did_you_mean_error() {
let error = parse_args(&["statuss".to_string()]).expect_err("statuss should error");
assert!(error.contains("unknown subcommand: statuss."));
assert!(error.contains("status"));
}
#[test]
fn typoed_export_subcommand_returns_did_you_mean_error() {
let error = parse_args(&["exporrt".to_string()]).expect_err("exporrt should error");
assert!(error.contains("unknown subcommand: exporrt."));
assert!(error.contains("Did you mean"));
assert!(error.contains("export"));
}
#[test]
fn typoed_mcp_subcommand_returns_did_you_mean_error() {
let error = parse_args(&["mcpp".to_string()]).expect_err("mcpp should error");
assert!(error.contains("unknown subcommand: mcpp."));
assert!(error.contains("mcp"));
}
#[test]
fn multi_word_prompt_still_bypasses_subcommand_typo_guard() {
assert_eq!(
parse_args(&[
"hello".to_string(),
"world".to_string(),
"this".to_string(),
"is".to_string(),
"a".to_string(),
"prompt".to_string(),
])
.expect("multi-word prompt should still parse"),
CliAction::Prompt {
prompt: "hello world this is a prompt".to_string(),
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: crate::default_permission_mode(),
compact: false,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
}
#[test]
fn prompt_subcommand_allows_literal_typo_word() {
assert_eq!(
parse_args(&["prompt".to_string(), "doctorr".to_string()])
.expect("explicit prompt subcommand should allow literal typo word"),
CliAction::Prompt {
prompt: "doctorr".to_string(),
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::DangerFullAccess,
compact: false,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
}
#[test]
fn punctuation_bearing_single_token_still_dispatches_to_prompt() {
// #140: Guard against test pollution — isolate cwd + env so this test
// doesn't pick up a stale .claw/settings.json from other tests that
// may have set `permissionMode: acceptEdits` in a shared cwd.
let _guard = env_lock();
let root = temp_dir();
let cwd = root.join("project");
std::fs::create_dir_all(&cwd).expect("project dir should exist");
let result = with_current_dir(&cwd, || {
parse_args(&["PARITY_SCENARIO:bash_permission_prompt_approved".to_string()])
.expect("scenario token should still dispatch to prompt")
});
assert_eq!(
result,
CliAction::Prompt {
prompt: "PARITY_SCENARIO:bash_permission_prompt_approved".to_string(),
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::DangerFullAccess,
compact: false,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
}
#[test]
fn formats_namespaced_omc_slash_command_with_contract_guidance() {
let report = format_unknown_slash_command_message("oh-my-claudecode:hud");
assert!(report.contains("unknown slash command: /oh-my-claudecode:hud"));
assert!(report.contains("Claude Code/OMC plugin command"));
assert!(report.contains("plugin slash commands"));
assert!(report.contains("statusline"));
assert!(report.contains("session hooks"));
}
#[test]
fn parses_resume_flag_with_slash_command() {
let args = vec![
"--resume".to_string(),
"session.jsonl".to_string(),
"/compact".to_string(),
];
assert_eq!(
parse_args(&args).expect("args should parse"),
CliAction::ResumeSession {
session_path: PathBuf::from("session.jsonl"),
commands: vec!["/compact".to_string()],
output_format: CliOutputFormat::Text,
}
);
}
#[test]
fn parses_resume_flag_without_path_as_latest_session() {
assert_eq!(
parse_args(&["--resume".to_string()]).expect("args should parse"),
CliAction::ResumeSession {
session_path: PathBuf::from("latest"),
commands: vec![],
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["--resume".to_string(), "/status".to_string()])
.expect("resume shortcut should parse"),
CliAction::ResumeSession {
session_path: PathBuf::from("latest"),
commands: vec!["/status".to_string()],
output_format: CliOutputFormat::Text,
}
);
}
#[test]
fn parses_resume_flag_with_multiple_slash_commands() {
let args = vec![
"--resume".to_string(),
"session.jsonl".to_string(),
"/status".to_string(),
"/compact".to_string(),
"/cost".to_string(),
];
assert_eq!(
parse_args(&args).expect("args should parse"),
CliAction::ResumeSession {
session_path: PathBuf::from("session.jsonl"),
commands: vec![
"/status".to_string(),
"/compact".to_string(),
"/cost".to_string(),
],
output_format: CliOutputFormat::Text,
}
);
}
#[test]
fn rejects_unknown_options_with_helpful_guidance() {
let error = parse_args(&["--resum".to_string()]).expect_err("unknown option should fail");
assert!(error.contains("unknown option: --resum"));
assert!(error.contains("Did you mean --resume?"));
assert!(error.contains("claw --help"));
}
#[test]
fn parses_resume_flag_with_slash_command_arguments() {
let args = vec![
"--resume".to_string(),
"session.jsonl".to_string(),
"/export".to_string(),
"notes.txt".to_string(),
"/clear".to_string(),
"--confirm".to_string(),
];
assert_eq!(
parse_args(&args).expect("args should parse"),
CliAction::ResumeSession {
session_path: PathBuf::from("session.jsonl"),
commands: vec![
"/export notes.txt".to_string(),
"/clear --confirm".to_string(),
],
output_format: CliOutputFormat::Text,
}
);
}
#[test]
fn parses_resume_flag_with_absolute_export_path() {
let args = vec![
"--resume".to_string(),
"session.jsonl".to_string(),
"/export".to_string(),
"/tmp/notes.txt".to_string(),
"/status".to_string(),
];
assert_eq!(
parse_args(&args).expect("args should parse"),
CliAction::ResumeSession {
session_path: PathBuf::from("session.jsonl"),
commands: vec!["/export /tmp/notes.txt".to_string(), "/status".to_string()],
output_format: CliOutputFormat::Text,
}
);
}
#[test]
fn filtered_tool_specs_respect_allowlist() {
let allowed = ["read_file", "grep_search"]
.into_iter()
.map(str::to_string)
.collect();
let filtered = filter_tool_specs(&GlobalToolRegistry::builtin(), Some(&allowed));
let names = filtered
.into_iter()
.map(|spec| spec.name)
.collect::<Vec<_>>();
assert_eq!(names, vec!["read_file", "grep_search"]);
}
#[test]
fn filtered_tool_specs_include_plugin_tools() {
let filtered = filter_tool_specs(&registry_with_plugin_tool(), None);
let names = filtered
.into_iter()
.map(|definition| definition.name)
.collect::<Vec<_>>();
assert!(names.contains(&"bash".to_string()));
assert!(names.contains(&"plugin_echo".to_string()));
}
#[test]
fn permission_policy_uses_plugin_tool_permissions() {
let feature_config = runtime::RuntimeFeatureConfig::default();
let policy = permission_policy(
PermissionMode::ReadOnly,
&feature_config,
&registry_with_plugin_tool(),
)
.expect("permission policy should build");
let required = policy.required_mode_for("plugin_echo");
assert_eq!(required, PermissionMode::WorkspaceWrite);
}
#[test]
fn shared_help_uses_resume_annotation_copy() {
let help = commands::render_slash_command_help();
assert!(help.contains("Slash commands"));
assert!(help.contains("works with --resume SESSION.jsonl"));
}
#[test]
fn bare_skill_dispatch_resolves_known_project_skill_to_prompt() {
let _guard = env_lock();
let workspace = temp_dir();
write_skill_fixture(
&workspace.join(".codex").join("skills"),
"caveman",
"Project skill fixture",
);
let prompt = try_resolve_bare_skill_prompt(&workspace, "caveman sharpen club")
.expect("known bare skill should dispatch");
assert_eq!(prompt, "$caveman sharpen club");
fs::remove_dir_all(workspace).expect("workspace should clean up");
}
#[test]
fn bare_skill_dispatch_ignores_unknown_or_non_skill_input() {
let _guard = env_lock();
let workspace = temp_dir();
fs::create_dir_all(&workspace).expect("workspace should exist");
assert_eq!(
try_resolve_bare_skill_prompt(&workspace, "not-a-known-skill do thing"),
None
);
assert_eq!(try_resolve_bare_skill_prompt(&workspace, "/status"), None);
fs::remove_dir_all(workspace).expect("workspace should clean up");
}
#[test]
fn repl_help_includes_shared_commands_and_exit() {
let help = render_repl_help();
assert!(help.contains("REPL"));
assert!(help.contains("/help"));
assert!(help.contains("Complete commands, modes, and recent sessions"));
assert!(help.contains("/status"));
assert!(help.contains("/sandbox"));
assert!(help.contains("/model [model]"));
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
assert!(help.contains("/clear [--confirm]"));
assert!(help.contains("/cost"));
assert!(help.contains("/resume <session-path>"));
assert!(help.contains("/config [env|hooks|model|plugins]"));
assert!(help.contains("/mcp [list|show <server>|help]"));
assert!(help.contains("/memory"));
assert!(help.contains("/init"));
assert!(help.contains("/diff"));
assert!(help.contains("/version"));
assert!(help.contains("/export [file]"));
// Batch 5 added `/session delete`; match on the stable core rather than
// the trailing bracket so future additions don't re-break this.
assert!(help.contains("/session [list|switch <session-id>|fork [branch-name]"));
assert!(help.contains(
"/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
));
assert!(help.contains("aliases: /plugins, /marketplace"));
assert!(help.contains("/agents"));
assert!(help.contains("/skills"));
assert!(help.contains("/exit"));
assert!(help.contains("Auto-save .claw/sessions/<session-id>.jsonl"));
assert!(help.contains("Resume latest /resume latest"));
}
#[test]
fn completion_candidates_include_workflow_shortcuts_and_dynamic_sessions() {
let completions = slash_command_completion_candidates_with_sessions(
"sonnet",
Some("session-current"),
vec!["session-old".to_string()],
);
assert!(completions.contains(&"/model claude-sonnet-4-6".to_string()));
assert!(completions.contains(&"/permissions workspace-write".to_string()));
assert!(completions.contains(&"/session list".to_string()));
assert!(completions.contains(&"/session switch session-current".to_string()));
assert!(completions.contains(&"/resume session-old".to_string()));
assert!(completions.contains(&"/mcp list".to_string()));
assert!(completions.contains(&"/ultraplan ".to_string()));
}
#[test]
fn startup_banner_mentions_workflow_completions() {
let _guard = env_lock();
// Inject dummy credentials so LiveCli can construct without real Anthropic key
std::env::set_var("ANTHROPIC_API_KEY", "test-dummy-key-for-banner-test");
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
let banner = with_current_dir(&root, || {
LiveCli::new(
"claude-sonnet-4-6".to_string(),
true,
None,
PermissionMode::DangerFullAccess,
)
.expect("cli should initialize")
.startup_banner()
});
assert!(banner.contains("Tab"));
assert!(banner.contains("workflow completions"));
fs::remove_dir_all(root).expect("cleanup temp dir");
std::env::remove_var("ANTHROPIC_API_KEY");
}
#[test]
fn format_connected_line_renders_anthropic_provider_for_claude_model() {
let model = "claude-sonnet-4-6";
let line = format_connected_line(model);
assert_eq!(line, "Connected: claude-sonnet-4-6 via anthropic");
}
#[test]
fn format_connected_line_renders_xai_provider_for_grok_model() {
let model = "grok-3";
let line = format_connected_line(model);
assert_eq!(line, "Connected: grok-3 via xai");
}
#[test]
fn resolve_repl_model_returns_user_supplied_model_unchanged_when_explicit() {
let user_model = "claude-sonnet-4-6".to_string();
let resolved = resolve_repl_model(user_model);
assert_eq!(resolved, "claude-sonnet-4-6");
}
#[test]
fn resolve_repl_model_falls_back_to_anthropic_model_env_when_default() {
let _guard = env_lock();
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
let config_home = root.join("config");
fs::create_dir_all(&config_home).expect("config home dir");
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_MODEL");
std::env::set_var("ANTHROPIC_MODEL", "sonnet");
let resolved = with_current_dir(&root, || resolve_repl_model(DEFAULT_MODEL.to_string()));
assert_eq!(resolved, "claude-sonnet-4-6");
std::env::remove_var("ANTHROPIC_MODEL");
std::env::remove_var("CLAW_CONFIG_HOME");
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn resolve_repl_model_returns_default_when_env_unset_and_no_config() {
let _guard = env_lock();
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
let config_home = root.join("config");
fs::create_dir_all(&config_home).expect("config home dir");
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_MODEL");
let resolved = with_current_dir(&root, || resolve_repl_model(DEFAULT_MODEL.to_string()));
assert_eq!(resolved, DEFAULT_MODEL);
std::env::remove_var("CLAW_CONFIG_HOME");
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn resume_supported_command_list_matches_expected_surface() {
let names = resume_supported_slash_commands()
.into_iter()
.map(|spec| spec.name)
.collect::<Vec<_>>();
// Now with 135+ slash commands, verify minimum resume support
assert!(
names.len() >= 39,
"expected at least 39 resume-supported commands, got {}",
names.len()
);
// Verify key resume commands still exist
assert!(names.contains(&"help"));
assert!(names.contains(&"status"));
assert!(names.contains(&"compact"));
}
#[test]
fn resume_report_uses_sectioned_layout() {
let report = format_resume_report("session.jsonl", 14, 6);
assert!(report.contains("Session resumed"));
assert!(report.contains("Session file session.jsonl"));
assert!(report.contains("Messages 14"));
assert!(report.contains("Turns 6"));
}
#[test]
fn compact_report_uses_structured_output() {
let compacted = format_compact_report(8, 5, false);
assert!(compacted.contains("Compact"));
assert!(compacted.contains("Result compacted"));
assert!(compacted.contains("Messages removed 8"));
let skipped = format_compact_report(0, 3, true);
assert!(skipped.contains("Result skipped"));
}
#[test]
fn cost_report_uses_sectioned_layout() {
let report = format_cost_report(runtime::TokenUsage {
input_tokens: 20,
output_tokens: 8,
cache_creation_input_tokens: 3,
cache_read_input_tokens: 1,
});
assert!(report.contains("Cost"));
assert!(report.contains("Input tokens 20"));
assert!(report.contains("Output tokens 8"));
assert!(report.contains("Cache create 3"));
assert!(report.contains("Cache read 1"));
assert!(report.contains("Total tokens 32"));
}
#[test]
fn permissions_report_uses_sectioned_layout() {
let report = format_permissions_report("workspace-write");
assert!(report.contains("Permissions"));
assert!(report.contains("Active mode workspace-write"));
assert!(report.contains("Modes"));
assert!(report.contains("read-only ○ available Read/search tools only"));
assert!(report.contains("workspace-write ● current Edit files inside the workspace"));
assert!(report.contains("danger-full-access ○ available Unrestricted tool access"));
}
#[test]
fn permissions_switch_report_is_structured() {
let report = format_permissions_switch_report("read-only", "workspace-write");
assert!(report.contains("Permissions updated"));
assert!(report.contains("Result mode switched"));
assert!(report.contains("Previous mode read-only"));
assert!(report.contains("Active mode workspace-write"));
assert!(report.contains("Applies to subsequent tool calls"));
}
#[test]
fn init_help_mentions_direct_subcommand() {
let mut help = Vec::new();
print_help_to(&mut help).expect("help should render");
let help = String::from_utf8(help).expect("help should be utf8");
assert!(help.contains("claw help"));
assert!(help.contains("claw version"));
assert!(help.contains("claw status"));
assert!(help.contains("claw sandbox"));
assert!(help.contains("claw init"));
assert!(help.contains("claw acp [serve]"));
assert!(help.contains("claw agents"));
assert!(help.contains("claw mcp"));
assert!(help.contains("claw skills"));
assert!(help.contains("claw /skills"));
assert!(help.contains("ultraworkers/claw-code"));
assert!(help.contains("cargo install claw-code"));
assert!(!help.contains("claw login"));
assert!(!help.contains("claw logout"));
}
#[test]
fn model_report_uses_sectioned_layout() {
let report = format_model_report("claude-sonnet", 12, 4);
assert!(report.contains("Model"));
assert!(report.contains("Current model claude-sonnet"));
assert!(report.contains("Session messages 12"));
assert!(report.contains("Switch models with /model <name>"));
}
#[test]
fn model_switch_report_preserves_context_summary() {
let report = format_model_switch_report("claude-sonnet", "claude-opus", 9);
assert!(report.contains("Model updated"));
assert!(report.contains("Previous claude-sonnet"));
assert!(report.contains("Current claude-opus"));
assert!(report.contains("Preserved msgs 9"));
}
#[test]
fn status_line_reports_model_and_token_totals() {
let status = format_status_report(
"claude-sonnet",
StatusUsage {
message_count: 7,
turns: 3,
latest: runtime::TokenUsage {
input_tokens: 5,
output_tokens: 4,
cache_creation_input_tokens: 1,
cache_read_input_tokens: 0,
},
cumulative: runtime::TokenUsage {
input_tokens: 20,
output_tokens: 8,
cache_creation_input_tokens: 2,
cache_read_input_tokens: 1,
},
estimated_tokens: 128,
},
"workspace-write",
&super::StatusContext {
cwd: PathBuf::from("/tmp/project"),
session_path: Some(PathBuf::from("session.jsonl")),
loaded_config_files: 2,
discovered_config_files: 3,
memory_file_count: 4,
project_root: Some(PathBuf::from("/tmp")),
git_branch: Some("main".to_string()),
git_summary: GitWorkspaceSummary {
changed_files: 3,
staged_files: 1,
unstaged_files: 1,
untracked_files: 1,
conflicted_files: 0,
},
session_lifecycle: SessionLifecycleSummary {
kind: SessionLifecycleKind::SavedOnly,
pane_id: None,
pane_command: None,
pane_path: None,
workspace_dirty: false,
abandoned: false,
},
sandbox_status: runtime::SandboxStatus::default(),
config_load_error: None,
},
None, // #148
);
assert!(status.contains("Status"));
assert!(status.contains("Model claude-sonnet"));
assert!(status.contains("Permission mode workspace-write"));
assert!(status.contains("Messages 7"));
assert!(status.contains("Latest total 10"));
assert!(status.contains("Cumulative total 31"));
assert!(status.contains("Cwd /tmp/project"));
assert!(status.contains("Project root /tmp"));
assert!(status.contains("Git branch main"));
assert!(
status.contains("Git state dirty · 3 files · 1 staged, 1 unstaged, 1 untracked")
);
assert!(status.contains("Changed files 3"));
assert!(status.contains("Staged 1"));
assert!(status.contains("Unstaged 1"));
assert!(status.contains("Untracked 1"));
assert!(status.contains("Session session.jsonl"));
assert!(status.contains("Config files loaded 2/3"));
assert!(status.contains("Memory files 4"));
assert!(status.contains("Suggested flow /status → /diff → /commit"));
}
#[test]
fn commit_reports_surface_workspace_context() {
let summary = GitWorkspaceSummary {
changed_files: 2,
staged_files: 1,
unstaged_files: 1,
untracked_files: 0,
conflicted_files: 0,
};
let preflight = format_commit_preflight_report(Some("feature/ux"), summary);
assert!(preflight.contains("Result ready"));
assert!(preflight.contains("Branch feature/ux"));
assert!(preflight.contains("Workspace dirty · 2 files · 1 staged, 1 unstaged"));
assert!(preflight
.contains("Action create a git commit from the current workspace changes"));
}
#[test]
fn commit_skipped_report_points_to_next_steps() {
let report = format_commit_skipped_report();
assert!(report.contains("Reason no workspace changes"));
assert!(report
.contains("Action create a git commit from the current workspace changes"));
assert!(report.contains("/status to inspect context"));
assert!(report.contains("/diff to inspect repo changes"));
}
#[test]
fn runtime_slash_reports_describe_command_behavior() {
let bughunter = format_bughunter_report(Some("runtime"));
assert!(bughunter.contains("Scope runtime"));
assert!(bughunter.contains("inspect the selected code for likely bugs"));
let ultraplan = format_ultraplan_report(Some("ship the release"));
assert!(ultraplan.contains("Task ship the release"));
assert!(ultraplan.contains("break work into a multi-step execution plan"));
let pr = format_pr_report("feature/ux", Some("ready for review"));
assert!(pr.contains("Branch feature/ux"));
assert!(pr.contains("draft or create a pull request"));
let issue = format_issue_report(Some("flaky test"));
assert!(issue.contains("Context flaky test"));
assert!(issue.contains("draft or create a GitHub issue"));
}
#[test]
fn no_arg_commands_reject_unexpected_arguments() {
assert!(validate_no_args("/commit", None).is_ok());
let error = validate_no_args("/commit", Some("now"))
.expect_err("unexpected arguments should fail")
.to_string();
assert!(error.contains("/commit does not accept arguments"));
assert!(error.contains("Received: now"));
}
#[test]
fn config_report_supports_section_views() {
let report = render_config_report(Some("env")).expect("config report should render");
assert!(report.contains("Merged section: env"));
let plugins_report =
render_config_report(Some("plugins")).expect("plugins config report should render");
assert!(plugins_report.contains("Merged section: plugins"));
}
#[test]
fn memory_report_uses_sectioned_layout() {
let report = render_memory_report().expect("memory report should render");
assert!(report.contains("Memory"));
assert!(report.contains("Working directory"));
assert!(report.contains("Instruction files"));
assert!(report.contains("Discovered files"));
}
#[test]
fn config_report_uses_sectioned_layout() {
let report = render_config_report(None).expect("config report should render");
assert!(report.contains("Config"));
assert!(report.contains("Discovered files"));
assert!(report.contains("Merged JSON"));
}
#[test]
fn parses_git_status_metadata() {
let _guard = env_lock();
let temp_root = temp_dir();
fs::create_dir_all(&temp_root).expect("root dir");
let (project_root, branch) = parse_git_status_metadata_for(
&temp_root,
Some(
"## rcc/cli...origin/rcc/cli
M src/main.rs",
),
);
assert_eq!(branch.as_deref(), Some("rcc/cli"));
assert!(project_root.is_none());
fs::remove_dir_all(temp_root).expect("cleanup temp dir");
}
#[test]
fn parses_detached_head_from_status_snapshot() {
let _guard = env_lock();
assert_eq!(
parse_git_status_branch(Some(
"## HEAD (no branch)
M src/main.rs"
)),
Some("detached HEAD".to_string())
);
}
#[test]
fn parses_git_workspace_summary_counts() {
let summary = parse_git_workspace_summary(Some(
"## feature/ux
M src/main.rs
M README.md
?? notes.md
UU conflicted.rs",
));
assert_eq!(
summary,
GitWorkspaceSummary {
changed_files: 4,
staged_files: 2,
unstaged_files: 2,
untracked_files: 1,
conflicted_files: 1,
}
);
assert_eq!(
summary.headline(),
"dirty · 4 files · 2 staged, 2 unstaged, 1 untracked, 1 conflicted"
);
}
#[test]
fn render_diff_report_shows_clean_tree_for_committed_repo() {
let _guard = env_lock();
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
git(&["init", "--quiet"], &root);
git(&["config", "user.email", "tests@example.com"], &root);
git(&["config", "user.name", "Rusty Claude Tests"], &root);
fs::write(root.join("tracked.txt"), "hello\n").expect("write file");
git(&["add", "tracked.txt"], &root);
git(&["commit", "-m", "init", "--quiet"], &root);
let report = render_diff_report_for(&root).expect("diff report should render");
assert!(report.contains("clean working tree"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn render_diff_report_includes_staged_and_unstaged_sections() {
let _guard = env_lock();
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
git(&["init", "--quiet"], &root);
git(&["config", "user.email", "tests@example.com"], &root);
git(&["config", "user.name", "Rusty Claude Tests"], &root);
fs::write(root.join("tracked.txt"), "hello\n").expect("write file");
git(&["add", "tracked.txt"], &root);
git(&["commit", "-m", "init", "--quiet"], &root);
fs::write(root.join("tracked.txt"), "hello\nstaged\n").expect("update file");
git(&["add", "tracked.txt"], &root);
fs::write(root.join("tracked.txt"), "hello\nstaged\nunstaged\n")
.expect("update file twice");
let report = render_diff_report_for(&root).expect("diff report should render");
assert!(report.contains("Staged changes:"));
assert!(report.contains("Unstaged changes:"));
assert!(report.contains("tracked.txt"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn render_diff_report_omits_ignored_files() {
let _guard = env_lock();
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
git(&["init", "--quiet"], &root);
git(&["config", "user.email", "tests@example.com"], &root);
git(&["config", "user.name", "Rusty Claude Tests"], &root);
fs::write(root.join(".gitignore"), ".omx/\nignored.txt\n").expect("write gitignore");
fs::write(root.join("tracked.txt"), "hello\n").expect("write tracked");
git(&["add", ".gitignore", "tracked.txt"], &root);
git(&["commit", "-m", "init", "--quiet"], &root);
fs::create_dir_all(root.join(".omx")).expect("write omx dir");
fs::write(root.join(".omx").join("state.json"), "{}").expect("write ignored omx");
fs::write(root.join("ignored.txt"), "secret\n").expect("write ignored file");
fs::write(root.join("tracked.txt"), "hello\nworld\n").expect("write tracked change");
let report = render_diff_report_for(&root).expect("diff report should render");
assert!(report.contains("tracked.txt"));
assert!(!report.contains("+++ b/ignored.txt"));
assert!(!report.contains("+++ b/.omx/state.json"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn resume_diff_command_renders_report_for_saved_session() {
let _guard = env_lock();
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
git(&["init", "--quiet"], &root);
git(&["config", "user.email", "tests@example.com"], &root);
git(&["config", "user.name", "Rusty Claude Tests"], &root);
fs::write(root.join("tracked.txt"), "hello\n").expect("write tracked");
git(&["add", "tracked.txt"], &root);
git(&["commit", "-m", "init", "--quiet"], &root);
fs::write(root.join("tracked.txt"), "hello\nworld\n").expect("modify tracked");
let session_path = root.join("session.json");
Session::new()
.save_to_path(&session_path)
.expect("session should save");
let session = Session::load_from_path(&session_path).expect("session should load");
let outcome = with_current_dir(&root, || {
run_resume_command(&session_path, &session, &SlashCommand::Diff)
.expect("resume diff should work")
});
let message = outcome.message.expect("diff message should exist");
assert!(message.contains("Unstaged changes:"));
assert!(message.contains("tracked.txt"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn status_context_reads_real_workspace_metadata() {
let context = status_context(None).expect("status context should load");
assert!(context.cwd.is_absolute());
assert!(context.discovered_config_files >= context.loaded_config_files);
assert!(context.loaded_config_files <= context.discovered_config_files);
}
#[test]
fn normalizes_supported_permission_modes() {
assert_eq!(normalize_permission_mode("read-only"), Some("read-only"));
assert_eq!(
normalize_permission_mode("workspace-write"),
Some("workspace-write")
);
assert_eq!(
normalize_permission_mode("danger-full-access"),
Some("danger-full-access")
);
assert_eq!(normalize_permission_mode("unknown"), None);
}
#[test]
fn clear_command_requires_explicit_confirmation_flag() {
assert_eq!(
SlashCommand::parse("/clear"),
Ok(Some(SlashCommand::Clear { confirm: false }))
);
assert_eq!(
SlashCommand::parse("/clear --confirm"),
Ok(Some(SlashCommand::Clear { confirm: true }))
);
}
#[test]
fn parses_resume_and_config_slash_commands() {
assert_eq!(
SlashCommand::parse("/resume saved-session.jsonl"),
Ok(Some(SlashCommand::Resume {
session_path: Some("saved-session.jsonl".to_string())
}))
);
assert_eq!(
SlashCommand::parse("/clear --confirm"),
Ok(Some(SlashCommand::Clear { confirm: true }))
);
assert_eq!(
SlashCommand::parse("/config"),
Ok(Some(SlashCommand::Config { section: None }))
);
assert_eq!(
SlashCommand::parse("/config env"),
Ok(Some(SlashCommand::Config {
section: Some("env".to_string())
}))
);
assert_eq!(
SlashCommand::parse("/memory"),
Ok(Some(SlashCommand::Memory))
);
assert_eq!(SlashCommand::parse("/init"), Ok(Some(SlashCommand::Init)));
assert_eq!(
SlashCommand::parse("/session fork incident-review"),
Ok(Some(SlashCommand::Session {
action: Some("fork".to_string()),
target: Some("incident-review".to_string())
}))
);
}
#[test]
fn help_mentions_jsonl_resume_examples() {
let mut help = Vec::new();
print_help_to(&mut help).expect("help should render");
let help = String::from_utf8(help).expect("help should be utf8");
assert!(help.contains("claw --resume [SESSION.jsonl|session-id|latest]"));
assert!(help.contains("Use `latest` with --resume, /resume, or /session switch"));
assert!(help.contains("claw --resume latest"));
assert!(help.contains("claw --resume latest /status /diff /export notes.txt"));
}
#[test]
fn managed_sessions_default_to_jsonl_and_resolve_legacy_json() {
let _guard = cwd_guard();
let workspace = temp_workspace("session-resolution");
std::fs::create_dir_all(&workspace).expect("workspace should create");
let previous = std::env::current_dir().expect("cwd");
std::env::set_current_dir(&workspace).expect("switch cwd");
let handle = create_managed_session_handle("session-alpha").expect("jsonl handle");
assert!(handle.path.ends_with("session-alpha.jsonl"));
let legacy_path = workspace.join(".claw/sessions/legacy.json");
std::fs::create_dir_all(
legacy_path
.parent()
.expect("legacy path should have parent directory"),
)
.expect("session dir should exist");
Session::new()
.with_workspace_root(workspace.clone())
.with_persistence_path(legacy_path.clone())
.save_to_path(&legacy_path)
.expect("legacy session should save");
let resolved = resolve_session_reference("legacy").expect("legacy session should resolve");
assert_eq!(
resolved
.path
.canonicalize()
.expect("resolved path should exist"),
legacy_path
.canonicalize()
.expect("legacy path should exist")
);
std::env::set_current_dir(previous).expect("restore cwd");
std::fs::remove_dir_all(workspace).expect("workspace should clean up");
}
#[test]
fn latest_session_alias_resolves_most_recent_managed_session() {
let _guard = cwd_guard();
let workspace = temp_workspace("latest-session-alias");
std::fs::create_dir_all(&workspace).expect("workspace should create");
let previous = std::env::current_dir().expect("cwd");
std::env::set_current_dir(&workspace).expect("switch cwd");
let older = create_managed_session_handle("session-older").expect("older handle");
Session::new()
.with_persistence_path(older.path.clone())
.save_to_path(&older.path)
.expect("older session should save");
std::thread::sleep(Duration::from_millis(20));
let newer = create_managed_session_handle("session-newer").expect("newer handle");
Session::new()
.with_persistence_path(newer.path.clone())
.save_to_path(&newer.path)
.expect("newer session should save");
let resolved = resolve_session_reference("latest").expect("latest session should resolve");
assert_eq!(
resolved
.path
.canonicalize()
.expect("resolved path should exist"),
newer.path.canonicalize().expect("newer path should exist")
);
std::env::set_current_dir(previous).expect("restore cwd");
std::fs::remove_dir_all(workspace).expect("workspace should clean up");
}
#[test]
fn load_session_reference_rejects_workspace_mismatch() {
let _guard = cwd_guard();
let workspace_a = temp_workspace("session-mismatch-a");
let workspace_b = temp_workspace("session-mismatch-b");
std::fs::create_dir_all(&workspace_a).expect("workspace a should create");
std::fs::create_dir_all(&workspace_b).expect("workspace b should create");
let previous = std::env::current_dir().expect("cwd");
std::env::set_current_dir(&workspace_b).expect("switch cwd");
let session_path = workspace_a.join(".claw/sessions/legacy-cross.jsonl");
std::fs::create_dir_all(
session_path
.parent()
.expect("session path should have parent directory"),
)
.expect("session dir should exist");
Session::new()
.with_workspace_root(workspace_a.clone())
.with_persistence_path(session_path.clone())
.save_to_path(&session_path)
.expect("session should save");
let error = crate::load_session_reference(&session_path.display().to_string())
.expect_err("mismatched workspace should fail");
assert!(
error.to_string().contains("session workspace mismatch"),
"unexpected error: {error}"
);
assert!(
error
.to_string()
.contains(&workspace_b.display().to_string()),
"expected current workspace in error: {error}"
);
assert!(
error
.to_string()
.contains(&workspace_a.display().to_string()),
"expected originating workspace in error: {error}"
);
std::env::set_current_dir(previous).expect("restore cwd");
std::fs::remove_dir_all(workspace_a).expect("workspace a should clean up");
std::fs::remove_dir_all(workspace_b).expect("workspace b should clean up");
}
#[test]
fn unknown_slash_command_guidance_suggests_nearby_commands() {
let message = format_unknown_slash_command("stats");
assert!(message.contains("Unknown slash command: /stats"));
assert!(message.contains("/status"));
assert!(message.contains("/help"));
}
#[test]
fn unknown_omc_slash_command_guidance_explains_runtime_gap() {
let message = format_unknown_slash_command("oh-my-claudecode:hud");
assert!(message.contains("Unknown slash command: /oh-my-claudecode:hud"));
assert!(message.contains("Claude Code/OMC plugin command"));
assert!(message.contains("does not yet load plugin slash commands"));
}
#[test]
fn resume_usage_mentions_latest_shortcut() {
let usage = render_resume_usage();
assert!(usage.contains("/resume <session-path|session-id|latest>"));
assert!(usage.contains(".claw/sessions/<session-id>.jsonl"));
assert!(usage.contains("/session list"));
}
fn cwd_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn cwd_guard() -> MutexGuard<'static, ()> {
cwd_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[test]
fn cwd_guard_recovers_after_poisoning() {
let poisoned = std::thread::spawn(|| {
let _guard = cwd_guard();
panic!("poison cwd lock");
})
.join();
assert!(poisoned.is_err(), "poisoning thread should panic");
let _guard = cwd_guard();
}
fn temp_workspace(label: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!("claw-cli-{label}-{nanos}"))
}
#[test]
fn init_template_mentions_detected_rust_workspace() {
let _guard = cwd_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
let rendered = crate::init::render_init_claude_md(&workspace_root);
assert!(rendered.contains("# CLAUDE.md"));
assert!(rendered.contains("cargo clippy --workspace --all-targets -- -D warnings"));
}
#[test]
fn converts_tool_roundtrip_messages() {
let messages = vec![
ConversationMessage::user_text("hello"),
ConversationMessage::assistant(vec![ContentBlock::ToolUse {
id: "tool-1".to_string(),
name: "bash".to_string(),
input: "{\"command\":\"pwd\"}".to_string(),
}]),
ConversationMessage {
role: MessageRole::Tool,
blocks: vec![ContentBlock::ToolResult {
tool_use_id: "tool-1".to_string(),
tool_name: "bash".to_string(),
output: "ok".to_string(),
is_error: false,
}],
usage: None,
},
];
let converted = super::convert_messages(&messages);
assert_eq!(converted.len(), 3);
assert_eq!(converted[1].role, "assistant");
assert_eq!(converted[2].role, "user");
}
#[test]
fn repl_help_mentions_history_completion_and_multiline() {
let help = render_repl_help();
assert!(help.contains("Up/Down"));
assert!(help.contains("Tab"));
assert!(help.contains("Shift+Enter/Ctrl+J"));
assert!(help.contains("Ctrl-R"));
assert!(help.contains("Reverse-search prompt history"));
assert!(help.contains("/history [count]"));
}
#[test]
fn parse_history_count_defaults_to_twenty_when_missing() {
// given
let raw: Option<&str> = None;
// when
let parsed = parse_history_count(raw);
// then
assert_eq!(parsed, Ok(20));
}
#[test]
fn parse_history_count_accepts_positive_integers() {
// given
let raw = Some("25");
// when
let parsed = parse_history_count(raw);
// then
assert_eq!(parsed, Ok(25));
}
#[test]
fn parse_history_count_rejects_zero() {
// given
let raw = Some("0");
// when
let parsed = parse_history_count(raw);
// then
assert!(parsed.is_err());
assert!(parsed.unwrap_err().contains("greater than 0"));
}
#[test]
fn parse_history_count_rejects_non_numeric() {
// given
let raw = Some("abc");
// when
let parsed = parse_history_count(raw);
// then
assert!(parsed.is_err());
assert!(parsed.unwrap_err().contains("invalid count 'abc'"));
}
#[test]
fn format_history_timestamp_renders_iso8601_utc() {
// given
// 2023-01-15T12:34:56.789Z -> 1673786096789 ms
let timestamp_ms: u64 = 1_673_786_096_789;
// when
let formatted = format_history_timestamp(timestamp_ms);
// then
assert_eq!(formatted, "2023-01-15T12:34:56.789Z");
}
#[test]
fn format_history_timestamp_renders_unix_epoch_origin() {
// given
let timestamp_ms: u64 = 0;
// when
let formatted = format_history_timestamp(timestamp_ms);
// then
assert_eq!(formatted, "1970-01-01T00:00:00.000Z");
}
#[test]
fn render_prompt_history_report_lists_entries_with_timestamps() {
// given
let entries = vec![
PromptHistoryEntry {
timestamp_ms: 1_673_786_096_000,
text: "first prompt".to_string(),
},
PromptHistoryEntry {
timestamp_ms: 1_673_786_100_000,
text: "second prompt".to_string(),
},
];
// when
let rendered = render_prompt_history_report(&entries, 10);
// then
assert!(rendered.contains("Prompt history"));
assert!(rendered.contains("Total 2"));
assert!(rendered.contains("Showing 2 most recent"));
assert!(rendered.contains("Reverse search Ctrl-R in the REPL"));
assert!(rendered.contains("2023-01-15T12:34:56.000Z"));
assert!(rendered.contains("first prompt"));
assert!(rendered.contains("second prompt"));
}
#[test]
fn render_prompt_history_report_truncates_to_limit_from_the_tail() {
// given
let entries = vec![
PromptHistoryEntry {
timestamp_ms: 1_000,
text: "older".to_string(),
},
PromptHistoryEntry {
timestamp_ms: 2_000,
text: "middle".to_string(),
},
PromptHistoryEntry {
timestamp_ms: 3_000,
text: "latest".to_string(),
},
];
// when
let rendered = render_prompt_history_report(&entries, 2);
// then
assert!(rendered.contains("Total 3"));
assert!(rendered.contains("Showing 2 most recent"));
assert!(!rendered.contains("older"));
assert!(rendered.contains("middle"));
assert!(rendered.contains("latest"));
}
#[test]
fn render_prompt_history_report_handles_empty_history() {
// given
let entries: Vec<PromptHistoryEntry> = Vec::new();
// when
let rendered = render_prompt_history_report(&entries, 10);
// then
assert!(rendered.contains("no prompts recorded yet"));
}
#[test]
fn collect_session_prompt_history_extracts_user_text_blocks() {
// given
let mut session = Session::new();
session.push_user_text("hello").unwrap();
session.push_user_text("world").unwrap();
// when
let entries = collect_session_prompt_history(&session);
// then
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].text, "hello");
assert_eq!(entries[1].text, "world");
}
#[test]
fn tool_rendering_helpers_compact_output() {
let start = format_tool_call_start("read_file", r#"{"path":"src/main.rs"}"#);
assert!(start.contains("read_file"));
assert!(start.contains("src/main.rs"));
let done = format_tool_result(
"read_file",
r#"{"file":{"filePath":"src/main.rs","content":"hello","numLines":1,"startLine":1,"totalLines":1}}"#,
false,
);
assert!(done.contains("📄 Read src/main.rs"));
assert!(done.contains("hello"));
}
#[test]
fn tool_rendering_truncates_large_read_output_for_display_only() {
let content = (0..200)
.map(|index| format!("line {index:03}"))
.collect::<Vec<_>>()
.join("\n");
let output = json!({
"file": {
"filePath": "src/main.rs",
"content": content,
"numLines": 200,
"startLine": 1,
"totalLines": 200
}
})
.to_string();
let rendered = format_tool_result("read_file", &output, false);
assert!(rendered.contains("line 000"));
assert!(rendered.contains("line 079"));
assert!(!rendered.contains("line 199"));
assert!(rendered.contains("full result preserved in session"));
assert!(output.contains("line 199"));
}
#[test]
fn tool_rendering_truncates_large_bash_output_for_display_only() {
let stdout = (0..120)
.map(|index| format!("stdout {index:03}"))
.collect::<Vec<_>>()
.join("\n");
let output = json!({
"stdout": stdout,
"stderr": "",
"returnCodeInterpretation": "completed successfully"
})
.to_string();
let rendered = format_tool_result("bash", &output, false);
assert!(rendered.contains("stdout 000"));
assert!(rendered.contains("stdout 059"));
assert!(!rendered.contains("stdout 119"));
assert!(rendered.contains("full result preserved in session"));
assert!(output.contains("stdout 119"));
}
#[test]
fn tool_rendering_truncates_generic_long_output_for_display_only() {
let items = (0..120)
.map(|index| format!("payload {index:03}"))
.collect::<Vec<_>>();
let output = json!({
"summary": "plugin payload",
"items": items,
})
.to_string();
let rendered = format_tool_result("plugin_echo", &output, false);
assert!(rendered.contains("plugin_echo"));
assert!(rendered.contains("payload 000"));
assert!(rendered.contains("payload 040"));
assert!(!rendered.contains("payload 080"));
assert!(!rendered.contains("payload 119"));
assert!(rendered.contains("full result preserved in session"));
assert!(output.contains("payload 119"));
}
#[test]
fn tool_rendering_truncates_raw_generic_output_for_display_only() {
let output = (0..120)
.map(|index| format!("raw {index:03}"))
.collect::<Vec<_>>()
.join("\n");
let rendered = format_tool_result("plugin_echo", &output, false);
assert!(rendered.contains("plugin_echo"));
assert!(rendered.contains("raw 000"));
assert!(rendered.contains("raw 059"));
assert!(!rendered.contains("raw 119"));
assert!(rendered.contains("full result preserved in session"));
assert!(output.contains("raw 119"));
}
#[test]
fn ultraplan_progress_lines_include_phase_step_and_elapsed_status() {
let snapshot = InternalPromptProgressState {
command_label: "Ultraplan",
task_label: "ship plugin progress".to_string(),
step: 3,
phase: "running read_file".to_string(),
detail: Some("reading rust/crates/rusty-claude-cli/src/main.rs".to_string()),
saw_final_text: false,
};
let started = format_internal_prompt_progress_line(
InternalPromptProgressEvent::Started,
&snapshot,
Duration::from_secs(0),
None,
);
let heartbeat = format_internal_prompt_progress_line(
InternalPromptProgressEvent::Heartbeat,
&snapshot,
Duration::from_secs(9),
None,
);
let completed = format_internal_prompt_progress_line(
InternalPromptProgressEvent::Complete,
&snapshot,
Duration::from_secs(12),
None,
);
let failed = format_internal_prompt_progress_line(
InternalPromptProgressEvent::Failed,
&snapshot,
Duration::from_secs(12),
Some("network timeout"),
);
assert!(started.contains("planning started"));
assert!(started.contains("current step 3"));
assert!(heartbeat.contains("heartbeat"));
assert!(heartbeat.contains("9s elapsed"));
assert!(heartbeat.contains("phase running read_file"));
assert!(completed.contains("completed"));
assert!(completed.contains("3 steps total"));
assert!(failed.contains("failed"));
assert!(failed.contains("network timeout"));
}
#[test]
fn describe_tool_progress_summarizes_known_tools() {
assert_eq!(
describe_tool_progress("read_file", r#"{"path":"src/main.rs"}"#),
"reading src/main.rs"
);
assert!(
describe_tool_progress("bash", r#"{"command":"cargo test -p rusty-claude-cli"}"#)
.contains("cargo test -p rusty-claude-cli")
);
assert_eq!(
describe_tool_progress("grep_search", r#"{"pattern":"ultraplan","path":"rust"}"#),
"grep `ultraplan` in rust"
);
}
#[test]
fn push_output_block_renders_markdown_text() {
let mut out = Vec::new();
let mut events = Vec::new();
let mut pending_tool = None;
let mut block_has_thinking_summary = false;
push_output_block(
OutputContentBlock::Text {
text: "# Heading".to_string(),
},
&mut out,
&mut events,
&mut pending_tool,
false,
&mut block_has_thinking_summary,
)
.expect("text block should render");
let rendered = String::from_utf8(out).expect("utf8");
assert!(rendered.contains("Heading"));
assert!(rendered.contains('\u{1b}'));
}
#[test]
fn push_output_block_skips_empty_object_prefix_for_tool_streams() {
let mut out = Vec::new();
let mut events = Vec::new();
let mut pending_tool = None;
let mut block_has_thinking_summary = false;
push_output_block(
OutputContentBlock::ToolUse {
id: "tool-1".to_string(),
name: "read_file".to_string(),
input: json!({}),
},
&mut out,
&mut events,
&mut pending_tool,
true,
&mut block_has_thinking_summary,
)
.expect("tool block should accumulate");
assert!(events.is_empty());
assert_eq!(
pending_tool,
Some(("tool-1".to_string(), "read_file".to_string(), String::new(),))
);
}
#[test]
fn response_to_events_preserves_empty_object_json_input_outside_streaming() {
let mut out = Vec::new();
let events = response_to_events(
MessageResponse {
id: "msg-1".to_string(),
kind: "message".to_string(),
model: "claude-opus-4-6".to_string(),
role: "assistant".to_string(),
content: vec![OutputContentBlock::ToolUse {
id: "tool-1".to_string(),
name: "read_file".to_string(),
input: json!({}),
}],
stop_reason: Some("tool_use".to_string()),
stop_sequence: None,
usage: Usage {
input_tokens: 1,
output_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
request_id: None,
},
&mut out,
)
.expect("response conversion should succeed");
assert!(matches!(
&events[0],
AssistantEvent::ToolUse { name, input, .. }
if name == "read_file" && input == "{}"
));
}
#[test]
fn response_to_events_preserves_non_empty_json_input_outside_streaming() {
let mut out = Vec::new();
let events = response_to_events(
MessageResponse {
id: "msg-2".to_string(),
kind: "message".to_string(),
model: "claude-opus-4-6".to_string(),
role: "assistant".to_string(),
content: vec![OutputContentBlock::ToolUse {
id: "tool-2".to_string(),
name: "read_file".to_string(),
input: json!({ "path": "rust/Cargo.toml" }),
}],
stop_reason: Some("tool_use".to_string()),
stop_sequence: None,
usage: Usage {
input_tokens: 1,
output_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
request_id: None,
},
&mut out,
)
.expect("response conversion should succeed");
assert!(matches!(
&events[0],
AssistantEvent::ToolUse { name, input, .. }
if name == "read_file" && input == "{\"path\":\"rust/Cargo.toml\"}"
));
}
#[test]
fn response_to_events_renders_collapsed_thinking_summary() {
let mut out = Vec::new();
let events = response_to_events(
MessageResponse {
id: "msg-3".to_string(),
kind: "message".to_string(),
model: "claude-opus-4-6".to_string(),
role: "assistant".to_string(),
content: vec![
OutputContentBlock::Thinking {
thinking: "step 1".to_string(),
signature: Some("sig_123".to_string()),
},
OutputContentBlock::Text {
text: "Final answer".to_string(),
},
],
stop_reason: Some("end_turn".to_string()),
stop_sequence: None,
usage: Usage {
input_tokens: 1,
output_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
request_id: None,
},
&mut out,
)
.expect("response conversion should succeed");
assert!(matches!(
&events[0],
AssistantEvent::TextDelta(text) if text == "Final answer"
));
let rendered = String::from_utf8(out).expect("utf8");
assert!(rendered.contains("▶ Thinking (6 chars hidden)"));
assert!(!rendered.contains("step 1"));
}
#[test]
fn build_runtime_plugin_state_merges_plugin_hooks_into_runtime_features() {
let config_home = temp_dir();
let workspace = temp_dir();
let source_root = temp_dir();
fs::create_dir_all(&config_home).expect("config home");
fs::create_dir_all(&workspace).expect("workspace");
fs::create_dir_all(&source_root).expect("source root");
write_plugin_fixture(&source_root, "hook-runtime-demo", true, false);
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
manager
.install(source_root.to_str().expect("utf8 source path"))
.expect("plugin install should succeed");
let loader = ConfigLoader::new(&workspace, &config_home);
let runtime_config = loader.load().expect("runtime config should load");
let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config)
.expect("plugin state should load");
let pre_hooks = state.feature_config.hooks().pre_tool_use();
assert_eq!(pre_hooks.len(), 1);
assert!(
pre_hooks[0].ends_with("hooks/pre.sh"),
"expected installed plugin hook path, got {pre_hooks:?}"
);
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(source_root);
}
#[test]
#[allow(clippy::too_many_lines)]
fn build_runtime_plugin_state_discovers_mcp_tools_and_surfaces_pending_servers() {
let config_home = temp_dir();
let workspace = temp_dir();
fs::create_dir_all(&config_home).expect("config home");
fs::create_dir_all(&workspace).expect("workspace");
let script_path = workspace.join("fixture-mcp.py");
write_mcp_server_fixture(&script_path);
fs::write(
config_home.join("settings.json"),
format!(
r#"{{
"mcpServers": {{
"alpha": {{
"command": "python3",
"args": ["{}"]
}},
"broken": {{
"command": "python3",
"args": ["-c", "import sys; sys.exit(0)"]
}}
}}
}}"#,
script_path.to_string_lossy()
),
)
.expect("write mcp settings");
let loader = ConfigLoader::new(&workspace, &config_home);
let runtime_config = loader.load().expect("runtime config should load");
let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config)
.expect("runtime plugin state should load");
let allowed = state
.tool_registry
.normalize_allowed_tools(&["mcp__alpha__echo".to_string(), "MCPTool".to_string()])
.expect("mcp tools should be allow-listable")
.expect("allow-list should exist");
assert!(allowed.contains("mcp__alpha__echo"));
assert!(allowed.contains("MCPTool"));
let mut executor = CliToolExecutor::new(
None,
false,
state.tool_registry.clone(),
state.mcp_state.clone(),
);
let tool_output = executor
.execute("mcp__alpha__echo", r#"{"text":"hello"}"#)
.expect("discovered mcp tool should execute");
let tool_json: serde_json::Value =
serde_json::from_str(&tool_output).expect("tool output should be json");
assert_eq!(tool_json["structuredContent"]["echoed"], "hello");
let wrapped_output = executor
.execute(
"MCPTool",
r#"{"qualifiedName":"mcp__alpha__echo","arguments":{"text":"wrapped"}}"#,
)
.expect("generic mcp wrapper should execute");
let wrapped_json: serde_json::Value =
serde_json::from_str(&wrapped_output).expect("wrapped output should be json");
assert_eq!(wrapped_json["structuredContent"]["echoed"], "wrapped");
let search_output = executor
.execute("ToolSearch", r#"{"query":"alpha echo","max_results":5}"#)
.expect("tool search should execute");
let search_json: serde_json::Value =
serde_json::from_str(&search_output).expect("search output should be json");
assert_eq!(search_json["matches"][0], "mcp__alpha__echo");
assert_eq!(search_json["pending_mcp_servers"][0], "broken");
assert_eq!(
search_json["mcp_degraded"]["failed_servers"][0]["server_name"],
"broken"
);
assert_eq!(
search_json["mcp_degraded"]["failed_servers"][0]["phase"],
"tool_discovery"
);
assert_eq!(
search_json["mcp_degraded"]["available_tools"][0],
"mcp__alpha__echo"
);
let listed = executor
.execute("ListMcpResourcesTool", r#"{"server":"alpha"}"#)
.expect("resources should list");
let listed_json: serde_json::Value =
serde_json::from_str(&listed).expect("resource output should be json");
assert_eq!(listed_json["resources"][0]["uri"], "file://guide.txt");
let read = executor
.execute(
"ReadMcpResourceTool",
r#"{"server":"alpha","uri":"file://guide.txt"}"#,
)
.expect("resource should read");
let read_json: serde_json::Value =
serde_json::from_str(&read).expect("resource read output should be json");
assert_eq!(
read_json["contents"][0]["text"],
"contents for file://guide.txt"
);
if let Some(mcp_state) = state.mcp_state {
mcp_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.shutdown()
.expect("mcp shutdown should succeed");
}
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(workspace);
}
#[test]
fn build_runtime_plugin_state_surfaces_unsupported_mcp_servers_structurally() {
let config_home = temp_dir();
let workspace = temp_dir();
fs::create_dir_all(&config_home).expect("config home");
fs::create_dir_all(&workspace).expect("workspace");
fs::write(
config_home.join("settings.json"),
r#"{
"mcpServers": {
"remote": {
"url": "https://example.test/mcp"
}
}
}"#,
)
.expect("write mcp settings");
let loader = ConfigLoader::new(&workspace, &config_home);
let runtime_config = loader.load().expect("runtime config should load");
let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config)
.expect("runtime plugin state should load");
let mut executor = CliToolExecutor::new(
None,
false,
state.tool_registry.clone(),
state.mcp_state.clone(),
);
let search_output = executor
.execute("ToolSearch", r#"{"query":"remote","max_results":5}"#)
.expect("tool search should execute");
let search_json: serde_json::Value =
serde_json::from_str(&search_output).expect("search output should be json");
assert_eq!(search_json["pending_mcp_servers"][0], "remote");
assert_eq!(
search_json["mcp_degraded"]["failed_servers"][0]["server_name"],
"remote"
);
assert_eq!(
search_json["mcp_degraded"]["failed_servers"][0]["phase"],
"server_registration"
);
assert_eq!(
search_json["mcp_degraded"]["failed_servers"][0]["error"]["context"]["transport"],
"http"
);
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(workspace);
}
#[test]
fn build_runtime_runs_plugin_lifecycle_init_and_shutdown() {
// Serialize access to process-wide env vars so parallel tests that
// set/remove ANTHROPIC_API_KEY do not race with this test.
let _guard = env_lock();
let config_home = temp_dir();
// Inject a dummy API key so runtime construction succeeds without real credentials.
// This test only exercises plugin lifecycle (init/shutdown), never calls the API.
std::env::set_var("ANTHROPIC_API_KEY", "test-dummy-key-for-plugin-lifecycle");
let workspace = temp_dir();
let source_root = temp_dir();
fs::create_dir_all(&config_home).expect("config home");
fs::create_dir_all(&workspace).expect("workspace");
fs::create_dir_all(&source_root).expect("source root");
write_plugin_fixture(&source_root, "lifecycle-runtime-demo", false, true);
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
let install = manager
.install(source_root.to_str().expect("utf8 source path"))
.expect("plugin install should succeed");
let log_path = install.install_path.join("lifecycle.log");
let loader = ConfigLoader::new(&workspace, &config_home);
let runtime_config = loader.load().expect("runtime config should load");
let runtime_plugin_state =
build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config)
.expect("plugin state should load");
let mut runtime = build_runtime_with_plugin_state(
Session::new(),
"runtime-plugin-lifecycle",
DEFAULT_MODEL.to_string(),
vec!["test system prompt".to_string()],
true,
false,
None,
PermissionMode::DangerFullAccess,
None,
runtime_plugin_state,
)
.expect("runtime should build");
assert_eq!(
fs::read_to_string(&log_path).expect("init log should exist"),
"init\n"
);
runtime
.shutdown_plugins()
.expect("plugin shutdown should succeed");
assert_eq!(
fs::read_to_string(&log_path).expect("shutdown log should exist"),
"init\nshutdown\n"
);
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(source_root);
std::env::remove_var("ANTHROPIC_API_KEY");
}
#[test]
fn rejects_invalid_reasoning_effort_value() {
let err = parse_args(&[
"--reasoning-effort".to_string(),
"turbo".to_string(),
"prompt".to_string(),
"hello".to_string(),
])
.unwrap_err();
assert!(
err.contains("invalid value for --reasoning-effort"),
"unexpected error: {err}"
);
assert!(err.contains("turbo"), "unexpected error: {err}");
}
#[test]
fn accepts_valid_reasoning_effort_values() {
for value in ["low", "medium", "high"] {
let result = parse_args(&[
"--reasoning-effort".to_string(),
value.to_string(),
"prompt".to_string(),
"hello".to_string(),
]);
assert!(
result.is_ok(),
"--reasoning-effort {value} should be accepted, got: {result:?}"
);
if let Ok(CliAction::Prompt {
reasoning_effort, ..
}) = result
{
assert_eq!(reasoning_effort.as_deref(), Some(value));
}
}
}
#[test]
fn stub_commands_absent_from_repl_completions() {
let candidates =
slash_command_completion_candidates_with_sessions("claude-3-5-sonnet", None, vec![]);
for stub in STUB_COMMANDS {
let with_slash = format!("/{stub}");
assert!(
!candidates.contains(&with_slash),
"stub command {with_slash} should not appear in REPL completions"
);
}
}
}
fn write_mcp_server_fixture(script_path: &Path) {
let script = [
"#!/usr/bin/env python3",
"import json, sys",
"",
"def read_message():",
" header = b''",
r" while not header.endswith(b'\r\n\r\n'):",
" chunk = sys.stdin.buffer.read(1)",
" if not chunk:",
" return None",
" header += chunk",
" length = 0",
r" for line in header.decode().split('\r\n'):",
r" if line.lower().startswith('content-length:'):",
" length = int(line.split(':', 1)[1].strip())",
" payload = sys.stdin.buffer.read(length)",
" return json.loads(payload.decode())",
"",
"def send_message(message):",
" payload = json.dumps(message).encode()",
r" sys.stdout.buffer.write(f'Content-Length: {len(payload)}\r\n\r\n'.encode() + payload)",
" sys.stdout.buffer.flush()",
"",
"while True:",
" request = read_message()",
" if request is None:",
" break",
" method = request['method']",
" if method == 'initialize':",
" send_message({",
" 'jsonrpc': '2.0',",
" 'id': request['id'],",
" 'result': {",
" 'protocolVersion': request['params']['protocolVersion'],",
" 'capabilities': {'tools': {}, 'resources': {}},",
" 'serverInfo': {'name': 'fixture', 'version': '1.0.0'}",
" }",
" })",
" elif method == 'tools/list':",
" send_message({",
" 'jsonrpc': '2.0',",
" 'id': request['id'],",
" 'result': {",
" 'tools': [",
" {",
" 'name': 'echo',",
" 'description': 'Echo from MCP fixture',",
" 'inputSchema': {",
" 'type': 'object',",
" 'properties': {'text': {'type': 'string'}},",
" 'required': ['text'],",
" 'additionalProperties': False",
" },",
" 'annotations': {'readOnlyHint': True}",
" }",
" ]",
" }",
" })",
" elif method == 'tools/call':",
" args = request['params'].get('arguments') or {}",
" send_message({",
" 'jsonrpc': '2.0',",
" 'id': request['id'],",
" 'result': {",
" 'content': [{'type': 'text', 'text': f\"echo:{args.get('text', '')}\"}],",
" 'structuredContent': {'echoed': args.get('text', '')},",
" 'isError': False",
" }",
" })",
" elif method == 'resources/list':",
" send_message({",
" 'jsonrpc': '2.0',",
" 'id': request['id'],",
" 'result': {",
" 'resources': [{'uri': 'file://guide.txt', 'name': 'guide', 'mimeType': 'text/plain'}]",
" }",
" })",
" elif method == 'resources/read':",
" uri = request['params']['uri']",
" send_message({",
" 'jsonrpc': '2.0',",
" 'id': request['id'],",
" 'result': {",
" 'contents': [{'uri': uri, 'mimeType': 'text/plain', 'text': f'contents for {uri}'}]",
" }",
" })",
" else:",
" send_message({",
" 'jsonrpc': '2.0',",
" 'id': request['id'],",
" 'error': {'code': -32601, 'message': method}",
" })",
"",
]
.join("\n");
fs::write(script_path, script).expect("mcp fixture script should write");
}
#[cfg(test)]
mod sandbox_report_tests {
use super::{format_sandbox_report, HookAbortMonitor};
use runtime::HookAbortSignal;
use std::sync::mpsc;
use std::time::Duration;
#[test]
fn sandbox_report_renders_expected_fields() {
let report = format_sandbox_report(&runtime::SandboxStatus::default());
assert!(report.contains("Sandbox"));
assert!(report.contains("Enabled"));
assert!(report.contains("Filesystem mode"));
assert!(report.contains("Fallback reason"));
}
#[test]
fn hook_abort_monitor_stops_without_aborting() {
let abort_signal = HookAbortSignal::new();
let (ready_tx, ready_rx) = mpsc::channel();
let monitor = HookAbortMonitor::spawn_with_waiter(
abort_signal.clone(),
move |stop_rx, abort_signal| {
ready_tx.send(()).expect("ready signal");
let _ = stop_rx.recv();
assert!(!abort_signal.is_aborted());
},
);
ready_rx.recv().expect("waiter should be ready");
monitor.stop();
assert!(!abort_signal.is_aborted());
}
#[test]
fn hook_abort_monitor_propagates_interrupt() {
let abort_signal = HookAbortSignal::new();
let (done_tx, done_rx) = mpsc::channel();
let monitor = HookAbortMonitor::spawn_with_waiter(
abort_signal.clone(),
move |_stop_rx, abort_signal| {
abort_signal.abort();
done_tx.send(()).expect("done signal");
},
);
done_rx
.recv_timeout(Duration::from_secs(1))
.expect("interrupt should complete");
monitor.stop();
assert!(abort_signal.is_aborted());
}
}
#[cfg(test)]
mod dump_manifests_tests {
use super::{dump_manifests_at_path, CliOutputFormat};
use std::fs;
#[test]
fn dump_manifests_shows_helpful_error_when_manifests_missing() {
let root = std::env::temp_dir().join(format!(
"claw_test_missing_manifests_{}",
std::process::id()
));
let workspace = root.join("workspace");
std::fs::create_dir_all(&workspace).expect("failed to create temp workspace");
let result = dump_manifests_at_path(&workspace, None, CliOutputFormat::Text);
assert!(
result.is_err(),
"expected an error when manifests are missing"
);
let error_msg = result.unwrap_err().to_string();
assert!(
error_msg.contains("Manifest source files are missing"),
"error message should mention missing manifest sources: {error_msg}"
);
assert!(
error_msg.contains(&root.display().to_string()),
"error message should contain the resolved repo root path: {error_msg}"
);
assert!(
error_msg.contains("src/commands.ts"),
"error message should mention missing commands.ts: {error_msg}"
);
assert!(
error_msg.contains("CLAUDE_CODE_UPSTREAM"),
"error message should explain how to supply the upstream path: {error_msg}"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn dump_manifests_uses_explicit_manifest_dir() {
let root = std::env::temp_dir().join(format!(
"claw_test_explicit_manifest_dir_{}",
std::process::id()
));
let workspace = root.join("workspace");
let upstream = root.join("upstream");
fs::create_dir_all(workspace.join("nested")).expect("workspace should exist");
fs::create_dir_all(upstream.join("src/entrypoints"))
.expect("upstream fixture should exist");
fs::write(
upstream.join("src/commands.ts"),
"import FooCommand from './commands/foo'\n",
)
.expect("commands fixture should write");
fs::write(
upstream.join("src/tools.ts"),
"import ReadTool from './tools/read'\n",
)
.expect("tools fixture should write");
fs::write(
upstream.join("src/entrypoints/cli.tsx"),
"startupProfiler()\n",
)
.expect("cli fixture should write");
let result = dump_manifests_at_path(&workspace, Some(&upstream), CliOutputFormat::Text);
assert!(
result.is_ok(),
"explicit manifest dir should succeed: {result:?}"
);
let _ = fs::remove_dir_all(&root);
}
}