重构main 方法

This commit is contained in:
zhaoyanchao 2026-05-28 20:42:38 +08:00
parent 00718b4c3e
commit 2380566626
25 changed files with 5089 additions and 11336 deletions

View File

@ -31,45 +31,45 @@
## 6. Create args.rs — extract all argument parsing
- [ ] 6.1 Create `src/args.rs` with CliAction enum, CliOutputFormat, LocalHelpTopic, `parse_args`, all parse_* helpers, format_unknown_* functions, suggest_*/levenshtein helpers, normalize_* tools, permission_mode helpers, provider_label, filter_tool_specs (main.rs lines 506-1828)
- [ ] 6.2 Add `mod args;` to `main.rs` and re-export
- [ ] 6.3 Run `cargo build` and `cargo test` — must pass
- [x] 6.1 Create `src/args.rs` with CliAction enum, CliOutputFormat, LocalHelpTopic, `parse_args`, all parse_* helpers, format_unknown_* functions, suggest_*/levenshtein helpers, normalize_* tools, permission_mode helpers, provider_label, filter_tool_specs (main.rs lines 506-1828)
- [x] 6.2 Add `mod args;` to `main.rs` and re-export
- [x] 6.3 Run `cargo build` and `cargo test` — must pass
## 7. Create session.rs — extract session management
- [ ] 7.1 Create `src/session.rs` with SessionHandle, ManagedSessionSummary, all session CRUD functions: `sessions_dir`, `current_session_store`, `new_cli_session`, `create_managed_session_handle`, `resolve_session_reference`, `list_managed_sessions`, `latest_managed_session`, `load_session_reference`, `delete_managed_session`, `confirm_session_deletion`, `render_session_list`, `format_session_modified_age`, `write_session_clear_backup`, `session_clear_backup_path` (main.rs lines 3678-3692, 5257-5427)
- [ ] 7.2 Add `mod session;` to `main.rs` and re-export
- [ ] 7.3 Run `cargo build` and `cargo test` — must pass
- [x] 7.1 Create `src/session.rs` with SessionHandle, ManagedSessionSummary, all session CRUD functions: `sessions_dir`, `current_session_store`, `new_cli_session`, `create_managed_session_handle`, `resolve_session_reference`, `list_managed_sessions`, `latest_managed_session`, `load_session_reference`, `delete_managed_session`, `confirm_session_deletion`, `render_session_list`, `format_session_modified_age`, `write_session_clear_backup`, `session_clear_backup_path` (main.rs lines 3678-3692, 5257-5427)
- [x] 7.2 Add `mod session;` to `main.rs` and re-export
- [x] 7.3 Run `cargo build` and `cargo test` — must pass
## 8. Create status.rs — extract status snapshot and git parsing
- [ ] 8.1 Create `src/status.rs` with StatusContext, StatusUsage, GitWorkspaceSummary, `print_status_snapshot`, `status_json_value`, `status_context`, `format_status_report`, `format_sandbox_report`, `print_sandbox_status_snapshot`, `sandbox_json_value`, and all git parsing helpers (main.rs lines 2842-3200, 5462-5839)
- [ ] 8.2 Add `mod status;` to `main.rs` and re-export
- [ ] 8.3 Run `cargo build` and `cargo test` — must pass
- [x] 8.1 Create `src/status.rs` with StatusContext, StatusUsage, GitWorkspaceSummary, `print_status_snapshot`, `status_json_value`, `status_context`, `format_status_report`, `format_sandbox_report`, `print_sandbox_status_snapshot`, `sandbox_json_value`, and all git parsing helpers (main.rs lines 2842-3200, 5462-5839)
- [x] 8.2 Add `mod status;` to `main.rs` and re-export
- [x] 8.3 Run `cargo build` and `cargo test` — must pass
## 9. Create formatting.rs — extract tool result formatting and help text
- [ ] 9.1 Create `src/formatting.rs` with all format_tool_* functions, push_output_block, response_to_events, push_prompt_cache_record, prompt_cache_record_to_runtime_event, final_assistant_text, collect_tool_uses/results/cache_events, short_tool_id, extract_tool_path, first_visible_line, truncate_* helpers, and full help text functions (main.rs lines 8184-8730, 8923-9122)
- [ ] 9.2 Add `mod formatting;` to `main.rs` and re-export
- [ ] 9.3 Run `cargo build` and `cargo test` — must pass
- [x] 9.1 Create `src/formatting.rs` with all format_tool_* functions, push_output_block, response_to_events, push_prompt_cache_record, prompt_cache_record_to_runtime_event, final_assistant_text, collect_tool_uses/results/cache_events, short_tool_id, extract_tool_path, first_visible_line, truncate_* helpers, and full help text functions (main.rs lines 8184-8730, 8923-9122)
- [x] 9.2 Add `mod formatting;` to `main.rs` and re-export
- [x] 9.3 Run `cargo build` and `cargo test` — must pass
## 10. Create config.rs — extract config and memory reporting
- [ ] 10.1 Create `src/config.rs` with `render_config_report`, `render_config_json`, `render_memory_report`, `render_memory_json` (main.rs lines 5962-6143)
- [ ] 10.2 Add `mod config;` to `main.rs` and re-export
- [ ] 10.3 Run `cargo build` and `cargo test` — must pass
- [x] 10.1 Create `src/config.rs` with `render_config_report`, `render_config_json`, `render_memory_report`, `render_memory_json` (main.rs lines 5962-6143)
- [x] 10.2 Add `mod config;` to `main.rs` and re-export
- [x] 10.3 Run `cargo build` and `cargo test` — must pass
## 11. Create diff.rs — extract git diff reporting
- [ ] 11.1 Create `src/diff.rs` with `render_diff_report`, `render_diff_report_for`, `render_diff_json_for`, `run_git_diff_command_in` (main.rs lines 6189-6270)
- [ ] 11.2 Add `mod diff;` to `main.rs` and re-export
- [ ] 11.3 Run `cargo build` and `cargo test` — must pass
- [x] 11.1 Create `src/diff.rs` with `render_diff_report`, `render_diff_report_for`, `render_diff_json_for`, `run_git_diff_command_in` (main.rs lines 6189-6270)
- [x] 11.2 Add `mod diff;` to `main.rs` and re-export
- [x] 11.3 Run `cargo build` and `cargo test` — must pass
## 12. Create export.rs — extract session export
- [ ] 12.1 Create `src/export.rs` with `run_export`, `render_export_text`, `render_session_markdown`, `default_export_filename`, `resolve_export_path`, `summarize_tool_payload_for_markdown` (main.rs lines 6646-6914)
- [ ] 12.2 Add `mod export;` to `main.rs` and re-export
- [ ] 12.3 Run `cargo build` and `cargo test` — must pass
- [x] 12.1 Create `src/export.rs` with `run_export`, `render_export_text`, `render_session_markdown`, `default_export_filename`, `resolve_export_path`, `summarize_tool_payload_for_markdown` (main.rs lines 6646-6914)
- [x] 12.2 Add `mod export;` to `main.rs` and re-export
- [x] 12.3 Run `cargo build` and `cargo test` — must pass
## 13. Create api_client.rs — extract AnthropicRuntimeClient
@ -85,16 +85,16 @@
## 15. Create runtime_builder.rs — extract runtime construction
- [ ] 15.1 Create `src/runtime_builder.rs` with RuntimePluginState, RuntimeMcpState, BuiltRuntime struct+impl+Deref+DerefMut+Drop, HookAbortMonitor, all `build_runtime*` functions, `build_plugin_manager`, `resolve_plugin_path`, `runtime_hook_config_from_plugin_hooks`, `build_system_prompt`, `build_runtime_mcp_state`, `mcp_runtime_tool_definition`, `mcp_wrapper_tool_definitions`, `permission_mode_for_mcp_tool`, `mcp_annotation_flag` (main.rs lines 3710-4180, 6924-6995, 7329-7450, 4017-4125)
- [ ] 15.2 Add `mod runtime_builder;` to `main.rs` and re-export
- [ ] 15.3 Run `cargo build` and `cargo test` — must pass
- [x] 15.1 Create `src/runtime_builder.rs` with RuntimePluginState, RuntimeMcpState, BuiltRuntime struct+impl+Deref+DerefMut+Drop, HookAbortMonitor, all `build_runtime*` functions, `build_plugin_manager`, `resolve_plugin_path`, `runtime_hook_config_from_plugin_hooks`, `build_system_prompt`, `build_runtime_mcp_state`, `mcp_runtime_tool_definition`, `mcp_wrapper_tool_definitions`, `permission_mode_for_mcp_tool`, `mcp_annotation_flag` (main.rs lines 3710-4180, 6924-6995, 7329-7450, 4017-4125)
- [x] 15.2 Add `mod runtime_builder;` to `main.rs` and re-export
- [x] 15.3 Run `cargo build` and `cargo test` — must pass
## 16. Create live_cli.rs — extract LiveCli core and REPL loop
- [ ] 16.1 Create `src/live_cli.rs` with LiveCli struct, PromptHistoryEntry, and core impl methods: `new`, `startup_banner`, `prepare_turn_runtime`, `replace_runtime`, `run_turn`, `run_turn_with_output`, `run_prompt_compact/json`, `repl_completion_candidates`, `persist_session`, `record_prompt_history`, `run_internal_prompt_text*`, `reload_runtime_features` (main.rs lines 3694-3710, 4181-4440, 4580-4610, 5240-5260)
- [ ] 16.2 Extract free functions: `run_repl`, `run_resume_command`, `resume_session`, `detect_broad_cwd`, `enforce_broad_cwd_policy`, `run_stale_base_preflight` (main.rs lines 3520-3675, 3597-3678, 3175-3520, 3609-3610)
- [ ] 16.3 Add `mod live_cli;` to `main.rs` and re-export
- [ ] 16.4 Run `cargo build` and `cargo test` — must pass
- [x] 16.1 Create `src/live_cli.rs` with LiveCli struct, PromptHistoryEntry, and core impl methods: `new`, `startup_banner`, `prepare_turn_runtime`, `replace_runtime`, `run_turn`, `run_turn_with_output`, `run_prompt_compact/json`, `repl_completion_candidates`, `persist_session`, `record_prompt_history`, `run_internal_prompt_text*`, `reload_runtime_features` (main.rs lines 3694-3710, 4181-4440, 4580-4610, 5240-5260)
- [x] 16.2 Extract free functions: `run_repl`, `run_resume_command`, `resume_session`, `detect_broad_cwd`, `enforce_broad_cwd_policy`, `run_stale_base_preflight` (main.rs lines 3520-3675, 3597-3678, 3175-3520, 3609-3610)
- [x] 16.3 Add `mod live_cli;` to `main.rs` and re-export
- [x] 16.4 Run `cargo build` and `cargo test` — must pass
## 17. Create repl_commands.rs — extract REPL command handlers

View File

@ -11,10 +11,11 @@ use tools::GlobalToolRegistry;
use crate::constants::*;
use crate::model::max_tokens_for_model;
use crate::render::{
format_tool_call_start, format_user_visible_api_error, push_output_block,
use crate::{
format_tool_call_start, push_output_block,
render_thinking_block_summary,
};
use crate::InternalPromptProgressReporter;
// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct
@ -306,4 +307,98 @@ pub(crate) fn request_ends_with_tool_result(request: &ApiRequest) -> bool {
.messages
.last()
.is_some_and(|message| message.role == MessageRole::Tool)
}
pub(crate) 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()
}
}
pub(crate) 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 {}",
crate::formatting::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 {}",
crate::formatting::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 smaller slice"
.to_string(),
);
lines.push(" Retry rerun after compacting or reducing request".to_string());
lines.join("\n")
}

View File

@ -13,10 +13,9 @@ use tools::GlobalToolRegistry;
use crate::build_runtime_plugin_state_with_loader;
use crate::constants::*;
use crate::looks_like_slash_command_token;
use crate::model::*;
use crate::normalize_permission_mode;
use crate::resume_command_can_absorb_token;
use crate::runtime_builder::build_system_prompt;
use crate::formatting::{render_help_topic, render_version_report, full_help_text};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum CliAction {
@ -783,7 +782,7 @@ pub(crate) fn parse_direct_slash_cli_action(
}),
}
}
Ok(Some(SlashCommand::Unknown(name))) => Err(format_unknown_direct_slash_command(&name)),
Ok(Some(SlashCommand::Unknown(name))) => Err(format_unknown_slash_command_message(&name)),
Ok(Some(command)) => Err({
let _ = command;
format!(
@ -808,8 +807,8 @@ pub(crate) fn format_unknown_option(option: &str) -> String {
message
}
pub(crate) fn format_unknown_direct_slash_command(name: &str) -> String {
let mut message = format!("unknown slash command outside the REPL: /{name}");
pub(crate) fn format_unknown_slash_command_message(name: &str) -> String {
let mut message = format!("unknown slash command: /{name}");
if let Some(suggestions) = render_suggestion_line("Did you mean", &suggest_slash_commands(name))
{
message.push('\n');
@ -819,7 +818,7 @@ pub(crate) fn format_unknown_direct_slash_command(name: &str) -> String {
message.push('\n');
message.push_str(note);
}
message.push_str("\nRun `claw --help` for CLI usage, or start `claw` and use /help.");
message.push_str("\nRun `claw --help` for CLI usage. Use /help in the REPL.");
message
}
@ -1226,3 +1225,381 @@ pub(crate) fn parse_resume_args(args: &[String], output_format: CliOutputFormat)
output_format,
})
}
pub(crate) 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)
}
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,
}
}
pub(crate) 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(())
}
pub(crate) fn print_help_topic(topic: LocalHelpTopic) {
println!("{}", render_help_topic(topic));
}
pub(crate) 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(&serde_json::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(())
}
pub(crate) fn slash_command_completion_candidates_with_sessions(
model: &str,
active_session_id: Option<&str>,
recent_session_ids: Vec<String>,
) -> Vec<String> {
use std::collections::BTreeSet;
let mut completions = BTreeSet::new();
for spec in commands::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()
}
pub(crate) 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() {
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 => {
let _: serde_json::Value = serde_json::from_str(&raw)?;
println!("{raw}");
}
}
Ok(())
}
pub(crate) fn resume_command_can_absorb_token(current_command: &str, token: &str) -> bool {
matches!(
commands::SlashCommand::parse(current_command),
Ok(Some(commands::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;
};
commands::slash_command_specs()
.iter()
.any(|spec| spec.name == name || spec.aliases.contains(&name))
}
pub(crate) 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`.";
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());
compat_harness::UpstreamPaths::from_repo_root(resolved)
} else {
let resolved = workspace_dir
.canonicalize()
.unwrap_or_else(|_| workspace_dir.to_path_buf());
compat_harness::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 compat_harness::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(&serde_json::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}").into()),
}
}
pub(crate) fn print_bootstrap_plan(
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match output_format {
CliOutputFormat::Text => {
println!("Bootstrap Plan\n Status not implemented\n Message the bootstrap plan surface is not implemented in this build yet.");
}
CliOutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(&bootstrap_plan_json_value())?
);
}
}
Ok(())
}
fn bootstrap_plan_json_value() -> serde_json::Value {
serde_json::json!({
"kind": "bootstrap_plan",
"phases": [],
"message": "the bootstrap plan surface is not implemented in this build yet.",
})
}
pub(crate) fn print_system_prompt(
_cwd: Option<PathBuf>,
_date: Option<String>,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let prompt = build_system_prompt()?;
match output_format {
CliOutputFormat::Text => {
println!("{}", prompt.join("\n\n"));
}
CliOutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"kind": "system_prompt",
"prompt": prompt,
}))?
);
}
}
Ok(())
}
pub(crate) 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(())
}
pub(crate) fn version_json_value() -> serde_json::Value {
serde_json::json!({
"kind": "version",
"version": VERSION,
"git_sha": GIT_SHA,
"build_target": BUILD_TARGET,
"build_date": DEFAULT_DATE,
})
}
pub(crate) fn print_help(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match output_format {
CliOutputFormat::Text => {
println!("{}", full_help_text());
}
CliOutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"kind": "help",
"text": full_help_text(),
}))?
);
}
}
Ok(())
}
pub(crate) fn print_connected_line(model: &str) -> String {
let provider = if model.to_lowercase().contains("grok") {
"xAI"
} else {
"Anthropic"
};
format!("\x1b[2mConnected to {provider} ({model})\x1b[0m")
}

View File

@ -0,0 +1,175 @@
use crate::*;
use runtime::{ConfigLoader, ConfigSource, ProjectContext};
use serde_json;
use std::env;
pub(crate) 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("\n"));
}
};
lines.push(format!(
" {}",
match value {
Some(value) => value.render(),
None => "<unset>".to_string(),
}
));
return Ok(lines.join("\n"));
}
lines.push("Merged JSON".to_string());
lines.push(format!(" {}", runtime_config.as_json().render()));
Ok(lines.join("\n"))
}
pub(crate) 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,
}))
}
pub(crate) 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("\n"))
}
pub(crate) 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| {
serde_json::json!({
"path": f.path.display().to_string(),
"lines": f.content.lines().count(),
"preview": f.content.lines().next().unwrap_or("").trim(),
})
})
.collect();
Ok(serde_json::json!({
"kind": "memory",
"cwd": cwd.display().to_string(),
"instruction_files": files.len(),
"files": files,
}))
}

View File

@ -43,6 +43,7 @@ pub(crate) const CLI_OPTION_SUGGESTIONS: &[&str] = &[
"-p",
];
pub(crate) const DEFAULT_HISTORY_LIMIT: usize = 20;
pub(crate) type AllowedToolSet = BTreeSet<String>;
pub(crate) const STUB_COMMANDS: &[&str] = &[

View File

@ -0,0 +1,83 @@
use crate::*;
use std::env;
use std::path::Path;
use std::process::Command;
pub(crate) fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> {
render_diff_report_for(&env::current_dir()?)
}
pub(crate) 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 = 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")))
}
pub(crate) fn render_diff_json_for(cwd: &Path) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let in_git_repo = 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(),
}))
}
pub(crate) fn run_git_diff_command_in(
cwd: &Path,
args: &[&str],
) -> Result<String, Box<dyn std::error::Error>> {
let output = 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)?)
}

View File

@ -103,7 +103,7 @@ impl DoctorReport {
pub(crate) fn json_value(&self) -> Value {
let report = self.render();
let (ok_count, warn_count, fail_count) = self.counts();
json!({
serde_json::json!({
"kind": "doctor",
"message": report,
"report": report,
@ -213,12 +213,12 @@ fn check_auth_health() -> DiagnosticCheck {
"Suggested action set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN; `claw login` is removed".to_string(),
])
.with_data(Map::from_iter([
("api_key_present".to_string(), json!(api_key_present)),
("auth_token_present".to_string(), json!(auth_token_present)),
("legacy_saved_oauth_present".to_string(), json!(true)),
("legacy_saved_oauth_expires_at".to_string(), json!(token_set.expires_at)),
("legacy_refresh_token_present".to_string(), json!(token_set.refresh_token.is_some())),
("legacy_scopes".to_string(), json!(token_set.scopes)),
("api_key_present".to_string(),serde_json::json!(api_key_present)),
("auth_token_present".to_string(),serde_json::json!(auth_token_present)),
("legacy_saved_oauth_present".to_string(),serde_json::json!(true)),
("legacy_saved_oauth_expires_at".to_string(),serde_json::json!(token_set.expires_at)),
("legacy_refresh_token_present".to_string(),serde_json::json!(token_set.refresh_token.is_some())),
("legacy_scopes".to_string(),serde_json::json!(token_set.scopes)),
])),
Ok(None) => DiagnosticCheck::new(
"Auth",
@ -231,12 +231,12 @@ fn check_auth_health() -> DiagnosticCheck {
)
.with_details(vec![env_details])
.with_data(Map::from_iter([
("api_key_present".to_string(), json!(api_key_present)),
("auth_token_present".to_string(), json!(auth_token_present)),
("legacy_saved_oauth_present".to_string(), json!(false)),
("api_key_present".to_string(),serde_json::json!(api_key_present)),
("auth_token_present".to_string(),serde_json::json!(auth_token_present)),
("legacy_saved_oauth_present".to_string(),serde_json::json!(false)),
("legacy_saved_oauth_expires_at".to_string(), Value::Null),
("legacy_refresh_token_present".to_string(), json!(false)),
("legacy_scopes".to_string(), json!(Vec::<String>::new())),
("legacy_refresh_token_present".to_string(),serde_json::json!(false)),
("legacy_scopes".to_string(),serde_json::json!(Vec::<String>::new())),
])),
Err(error) => DiagnosticCheck::new(
"Auth",
@ -244,13 +244,13 @@ fn check_auth_health() -> DiagnosticCheck {
format!("failed to inspect legacy saved credentials: {error}"),
)
.with_data(Map::from_iter([
("api_key_present".to_string(), json!(api_key_present)),
("auth_token_present".to_string(), json!(auth_token_present)),
("api_key_present".to_string(),serde_json::json!(api_key_present)),
("auth_token_present".to_string(),serde_json::json!(auth_token_present)),
("legacy_saved_oauth_present".to_string(), Value::Null),
("legacy_saved_oauth_expires_at".to_string(), Value::Null),
("legacy_refresh_token_present".to_string(), Value::Null),
("legacy_scopes".to_string(), Value::Null),
("legacy_saved_oauth_error".to_string(), json!(error.to_string())),
("legacy_saved_oauth_error".to_string(),serde_json::json!(error.to_string())),
])),
}
}
@ -278,22 +278,22 @@ fn check_config_health(
DiagnosticCheck::new("Config", DiagnosticLevel::Ok, if present_paths.is_empty() { "no config files present; defaults are active" } else { "runtime config loaded successfully" })
.with_details(details)
.with_data(Map::from_iter([
("discovered_files".to_string(), json!(present_paths)),
("discovered_files_count".to_string(), json!(present_paths.len())),
("loaded_config_files".to_string(), json!(loaded_entries.len())),
("resolved_model".to_string(), json!(runtime_config.model())),
("mcp_servers".to_string(), json!(runtime_config.mcp().servers().len())),
("discovered_files".to_string(),serde_json::json!(present_paths)),
("discovered_files_count".to_string(),serde_json::json!(present_paths.len())),
("loaded_config_files".to_string(),serde_json::json!(loaded_entries.len())),
("resolved_model".to_string(),serde_json::json!(runtime_config.model())),
("mcp_servers".to_string(),serde_json::json!(runtime_config.mcp().servers().len())),
]))
}
Err(error) => DiagnosticCheck::new("Config", DiagnosticLevel::Fail, format!("runtime config failed to load: {error}"))
.with_details(if discovered_paths.is_empty() { vec!["Discovered files <none>".to_string()] } else { discovered_paths.iter().map(|p| format!("Discovered file {p}")).collect() })
.with_data(Map::from_iter([
("discovered_files".to_string(), json!(discovered_paths)),
("discovered_files_count".to_string(), json!(discovered_paths.len())),
("loaded_config_files".to_string(), json!(0)),
("discovered_files".to_string(),serde_json::json!(discovered_paths)),
("discovered_files_count".to_string(),serde_json::json!(discovered_paths.len())),
("loaded_config_files".to_string(),serde_json::json!(0)),
("resolved_model".to_string(), Value::Null),
("mcp_servers".to_string(), Value::Null),
("load_error".to_string(), json!(error.to_string())),
("load_error".to_string(),serde_json::json!(error.to_string())),
])),
}
}
@ -306,9 +306,9 @@ fn check_install_source_health() -> DiagnosticCheck {
format!("Deprecated crate `{DEPRECATED_INSTALL_COMMAND}` installs a deprecated stub and does not provide the `claw` binary"),
])
.with_data(Map::from_iter([
("official_repo".to_string(), json!(OFFICIAL_REPO_URL)),
("deprecated_install".to_string(), json!(DEPRECATED_INSTALL_COMMAND)),
("recommended_install".to_string(), json!("build from source or follow the upstream binary instructions in README.md")),
("official_repo".to_string(),serde_json::json!(OFFICIAL_REPO_URL)),
("deprecated_install".to_string(),serde_json::json!(DEPRECATED_INSTALL_COMMAND)),
("recommended_install".to_string(),serde_json::json!("build from source or follow the upstream binary instructions in README.md")),
]))
}
@ -326,15 +326,15 @@ fn check_workspace_health(context: &StatusContext) -> DiagnosticCheck {
format!("Memory files {} · config files loaded {}/{}", context.memory_file_count, context.loaded_config_files, context.discovered_config_files),
])
.with_data(Map::from_iter([
("cwd".to_string(), json!(context.cwd.display().to_string())),
("project_root".to_string(), json!(context.project_root.as_ref().map(|p| p.display().to_string()))),
("in_git_repo".to_string(), json!(in_repo)),
("git_branch".to_string(), json!(context.git_branch)),
("git_state".to_string(), json!(context.git_summary.headline())),
("changed_files".to_string(), json!(context.git_summary.changed_files)),
("memory_file_count".to_string(), json!(context.memory_file_count)),
("loaded_config_files".to_string(), json!(context.loaded_config_files)),
("discovered_config_files".to_string(), json!(context.discovered_config_files)),
("cwd".to_string(),serde_json::json!(context.cwd.display().to_string())),
("project_root".to_string(),serde_json::json!(context.project_root.as_ref().map(|p| p.display().to_string()))),
("in_git_repo".to_string(),serde_json::json!(in_repo)),
("git_branch".to_string(),serde_json::json!(context.git_branch)),
("git_state".to_string(),serde_json::json!(context.git_summary.headline())),
("changed_files".to_string(),serde_json::json!(context.git_summary.changed_files)),
("memory_file_count".to_string(),serde_json::json!(context.memory_file_count)),
("loaded_config_files".to_string(),serde_json::json!(context.loaded_config_files)),
("discovered_config_files".to_string(),serde_json::json!(context.discovered_config_files)),
]))
}
@ -356,19 +356,19 @@ fn check_sandbox_health(status: &runtime::SandboxStatus) -> DiagnosticCheck {
)
.with_details(details)
.with_data(Map::from_iter([
("enabled".to_string(), json!(status.enabled)),
("active".to_string(), json!(status.active)),
("supported".to_string(), json!(status.supported)),
("namespace_supported".to_string(), json!(status.namespace_supported)),
("namespace_active".to_string(), json!(status.namespace_active)),
("network_supported".to_string(), json!(status.network_supported)),
("network_active".to_string(), json!(status.network_active)),
("filesystem_mode".to_string(), json!(status.filesystem_mode.as_str())),
("filesystem_active".to_string(), json!(status.filesystem_active)),
("allowed_mounts".to_string(), json!(status.allowed_mounts)),
("in_container".to_string(), json!(status.in_container)),
("container_markers".to_string(), json!(status.container_markers)),
("fallback_reason".to_string(), json!(status.fallback_reason)),
("enabled".to_string(),serde_json::json!(status.enabled)),
("active".to_string(),serde_json::json!(status.active)),
("supported".to_string(),serde_json::json!(status.supported)),
("namespace_supported".to_string(),serde_json::json!(status.namespace_supported)),
("namespace_active".to_string(),serde_json::json!(status.namespace_active)),
("network_supported".to_string(),serde_json::json!(status.network_supported)),
("network_active".to_string(),serde_json::json!(status.network_active)),
("filesystem_mode".to_string(),serde_json::json!(status.filesystem_mode.as_str())),
("filesystem_active".to_string(),serde_json::json!(status.filesystem_active)),
("allowed_mounts".to_string(),serde_json::json!(status.allowed_mounts)),
("in_container".to_string(),serde_json::json!(status.in_container)),
("container_markers".to_string(),serde_json::json!(status.container_markers)),
("fallback_reason".to_string(),serde_json::json!(status.fallback_reason)),
]))
}
@ -387,12 +387,12 @@ fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> D
DiagnosticCheck::new("System", DiagnosticLevel::Ok, "captured local runtime metadata")
.with_details(details)
.with_data(Map::from_iter([
("os".to_string(), json!(env::consts::OS)),
("arch".to_string(), json!(env::consts::ARCH)),
("working_dir".to_string(), json!(cwd.display().to_string())),
("version".to_string(), json!(VERSION)),
("build_target".to_string(), json!(BUILD_TARGET)),
("git_sha".to_string(), json!(GIT_SHA)),
("default_model".to_string(), json!(default_model)),
("os".to_string(),serde_json::json!(env::consts::OS)),
("arch".to_string(),serde_json::json!(env::consts::ARCH)),
("working_dir".to_string(),serde_json::json!(cwd.display().to_string())),
("version".to_string(),serde_json::json!(VERSION)),
("build_target".to_string(),serde_json::json!(BUILD_TARGET)),
("git_sha".to_string(),serde_json::json!(GIT_SHA)),
("default_model".to_string(),serde_json::json!(default_model)),
]))
}

View File

@ -0,0 +1,110 @@
use crate::*;
use std::path::{Path, PathBuf};
pub(crate) 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(&serde_json::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(&serde_json::json!({
"kind": "export",
"session_id": handle.id,
"file": handle.path.display().to_string(),
"messages": session.messages.len(),
"markdown": markdown,
}))?
),
}
Ok(())
}
pub(crate) 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")
}
pub(crate) fn resolve_export_path(
requested_path: Option<&str>,
session: &Session,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
let cwd = std::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))
}

View File

@ -1,157 +0,0 @@
use std::path::PathBuf;
use runtime::TokenUsage;
use serde_json::Value;
use crate::constants::*;
use crate::models::*;
use crate::omc_compatibility_note_for_unknown_slash_command;
use crate::suggest_slash_commands;
pub(crate) fn format_unknown_slash_command_message(name: &str) -> String {
let suggestions = suggest_slash_commands(name);
let mut message = format!("unknown slash command: /{name}.");
if !suggestions.is_empty() {
message.push_str(" Did you mean ");
message.push_str(&suggestions.join(", "));
message.push('?');
}
if let Some(note) = omc_compatibility_note_for_unknown_slash_command(name) {
message.push(' ');
message.push_str(note);
}
message.push_str(" Use /help to list available commands.");
message
}
pub(crate) fn format_model_report(model: &str, message_count: usize, turns: u32) -> String {
format!(
"Model
Current model {model}
Session messages {message_count}
Session turns {turns}
Usage
Inspect current model with /model
Switch models with /model <name>"
)
}
pub(crate) fn format_model_switch_report(previous: &str, next: &str, message_count: usize) -> String {
format!(
"Model updated
Previous {previous}
Current {next}
Preserved msgs {message_count}"
)
}
pub(crate) fn format_permissions_report(mode: &str) -> String {
let modes = [
("read-only", "Read/search tools only", mode == "read-only"),
(
"workspace-write",
"Edit files inside the workspace",
mode == "workspace-write",
),
(
"danger-full-access",
"Unrestricted tool access",
mode == "danger-full-access",
),
]
.into_iter()
.map(|(name, description, is_current)| {
let marker = if is_current {
"● current"
} else {
"○ available"
};
format!(" {name:<18} {marker:<11} {description}")
})
.collect::<Vec<_>>()
.join(
"
",
);
format!(
"Permissions
Active mode {mode}
Mode status live session default
Modes
{modes}
Usage
Inspect current mode with /permissions
Switch modes with /permissions <mode>"
)
}
pub(crate) fn format_permissions_switch_report(previous: &str, next: &str) -> String {
format!(
"Permissions updated
Result mode switched
Previous mode {previous}
Active mode {next}
Applies to subsequent tool calls
Usage /permissions to inspect current mode"
)
}
pub(crate) fn format_cost_report(usage: TokenUsage) -> String {
format!(
"Cost
Input tokens {}
Output tokens {}
Cache create {}
Cache read {}
Total tokens {}",
usage.input_tokens,
usage.output_tokens,
usage.cache_creation_input_tokens,
usage.cache_read_input_tokens,
usage.total_tokens(),
)
}
pub(crate) fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String {
format!(
"Session resumed
Session file {session_path}
Messages {message_count}
Turns {turns}"
)
}
pub(crate) fn render_resume_usage() -> String {
format!(
"Resume
Usage /resume <session-path|session-id|{LATEST_SESSION_REFERENCE}>
Auto-save .claw/sessions/<session-id>.{PRIMARY_SESSION_EXTENSION}
Tip use /session list to inspect saved sessions"
)
}
pub(crate) fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String {
if skipped {
format!(
"Compact
Result skipped
Reason session below compaction threshold
Messages kept {resulting_messages}"
)
} else {
format!(
"Compact
Result compacted
Messages removed {removed}
Messages kept {resulting_messages}"
)
}
}
pub(crate) fn format_auto_compaction_notice(removed: usize) -> String {
format!("[auto-compacted: removed {removed} messages]")
}

View File

@ -0,0 +1,199 @@
use serde_json::Value;
pub(crate) 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)
}
pub(crate) fn indent_block(value: &str, spaces: usize) -> String {
let indent = " ".repeat(spaces);
value
.lines()
.map(|line| format!("{indent}{line}"))
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) 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())
}
}
pub(crate) fn extract_tool_path(parsed: &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()
}
pub(crate) fn first_visible_line(text: &str) -> &str {
text.lines().find(|l| !l.trim().is_empty()).unwrap_or("")
}
pub(crate) fn final_assistant_text(summary: &runtime::TurnSummary) -> String {
summary
.assistant_messages
.last()
.map(|message| {
message
.blocks
.iter()
.filter_map(|block| match block {
runtime::ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default()
}
pub(crate) 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 {
runtime::ContentBlock::ToolUse { id, name, input } => Some(serde_json::json!({
"id": id,
"name": name,
"input": input,
})),
_ => None,
})
.collect()
}
pub(crate) 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 {
runtime::ContentBlock::ToolResult {
tool_use_id,
tool_name,
output,
is_error,
} => Some(serde_json::json!({
"tool_use_id": tool_use_id,
"tool_name": tool_name,
"output": output,
"is_error": is_error,
})),
_ => None,
})
.collect()
}
pub(crate) fn collect_prompt_cache_events(summary: &runtime::TurnSummary) -> Vec<serde_json::Value> {
summary
.prompt_cache_events
.iter()
.map(|event| {
serde_json::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()
}
pub(crate) fn sanitize_generated_message(value: &str) -> String {
value.trim().trim_matches('`').trim().replace("\r\n", "\n")
}
pub(crate) 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()))
}
pub(crate) 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(super::DISPLAY_TRUNCATION_NOTICE);
}
preview
}
pub(crate) fn summarize_tool_payload(payload: &str) -> String {
let compact = match serde_json::from_str::<Value>(payload) {
Ok(value) => value.to_string(),
Err(_) => payload.trim().to_string(),
};
truncate_for_summary(&compact, 96)
}
pub(crate) 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
}
}

View File

@ -0,0 +1,19 @@
pub(crate) mod helpers;
pub(crate) mod reports;
pub(crate) mod tools;
pub(crate) use helpers::*;
pub(crate) use reports::*;
pub(crate) use tools::*;
pub(crate) fn full_help_text() -> String {
reports::full_help_text()
}
pub(crate) const DISPLAY_TRUNCATION_NOTICE: &str =
"\x1b[2m… output truncated for display; full result preserved in session.\x1b[0m";
pub(crate) const READ_DISPLAY_MAX_LINES: usize = 80;
pub(crate) const READ_DISPLAY_MAX_CHARS: usize = 6_000;
pub(crate) const TOOL_OUTPUT_DISPLAY_MAX_LINES: usize = 60;
pub(crate) const TOOL_OUTPUT_DISPLAY_MAX_CHARS: usize = 4_000;
pub(crate) const SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT: usize = 280;

View File

@ -0,0 +1,755 @@
use std::path::Path;
use std::process::Command;
use std::env;
use std::time::UNIX_EPOCH;
use runtime::{Session, TokenUsage};
use commands::render_slash_command_help_filtered;
use crate::constants::*;
use crate::models::*;
use crate::live_cli::PromptHistoryEntry;
use crate::args::LocalHelpTopic;
use crate::omc_compatibility_note_for_unknown_slash_command;
use crate::render::TerminalRenderer;
use super::helpers::*;
use super::tools::format_tool_result;
pub(crate) fn format_model_report(model: &str, message_count: usize, turns: u32) -> String {
format!(
"Model
Current model {model}
Session messages {message_count}
Session turns {turns}
Usage
Inspect current model with /model
Switch models with /model <name>"
)
}
pub(crate) fn format_model_switch_report(previous: &str, next: &str, message_count: usize) -> String {
format!(
"Model updated
Previous {previous}
Current {next}
Preserved msgs {message_count}"
)
}
pub(crate) fn format_permissions_report(mode: &str) -> String {
let modes = [
("read-only", "Read/search tools only", mode == "read-only"),
(
"workspace-write",
"Edit files inside the workspace",
mode == "workspace-write",
),
(
"danger-full-access",
"Unrestricted tool access",
mode == "danger-full-access",
),
]
.into_iter()
.map(|(name, description, is_current)| {
let marker = if is_current {
"● current"
} else {
"○ available"
};
format!(" {name:<18} {marker:<11} {description}")
})
.collect::<Vec<_>>()
.join("\n");
format!(
"Permissions
Active mode {mode}
Mode status live session default
Modes
{modes}
Usage
Inspect current mode with /permissions
Switch modes with /permissions <mode>"
)
}
pub(crate) fn format_permissions_switch_report(previous: &str, next: &str) -> String {
format!(
"Permissions updated
Result mode switched
Previous mode {previous}
Active mode {next}
Applies to subsequent tool calls
Usage /permissions to inspect current mode"
)
}
pub(crate) fn format_cost_report(usage: TokenUsage) -> String {
format!(
"Cost
Input tokens {}
Output tokens {}
Cache create {}
Cache read {}
Total tokens {}",
usage.input_tokens,
usage.output_tokens,
usage.cache_creation_input_tokens,
usage.cache_read_input_tokens,
usage.total_tokens(),
)
}
pub(crate) fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String {
format!(
"Session resumed
Session file {session_path}
Messages {message_count}
Turns {turns}"
)
}
pub(crate) fn render_resume_usage() -> String {
format!(
"Resume
Usage /resume <session-path|session-id|{LATEST_SESSION_REFERENCE}>
Auto-save .claw/sessions/<session-id>.{PRIMARY_SESSION_EXTENSION}
Tip use /session list to inspect saved sessions"
)
}
pub(crate) fn render_repl_help() -> String {
[
"REPL".to_string(),
" /exit Quit the REPL".to_string(),
" /quit Quit the REPL".to_string(),
" Up/Down Navigate prompt history".to_string(),
" Ctrl-R Reverse-search prompt history".to_string(),
" Tab Complete commands, modes, and recent sessions".to_string(),
" Ctrl-C Clear input (or exit on empty prompt)".to_string(),
" Shift+Enter/Ctrl+J Insert a newline".to_string(),
" Auto-save .claw/sessions/<session-id>.jsonl".to_string(),
" Resume latest /resume latest".to_string(),
" Browse sessions /session list".to_string(),
" Show prompt history /history [count]".to_string(),
String::new(),
render_slash_command_help_filtered(STUB_COMMANDS),
]
.join("\n")
}
pub(crate) fn full_help_text() -> String {
let mut out = Vec::new();
let _ = print_help_to(&mut out);
String::from_utf8_lossy(&out).to_string()
}
pub(crate) fn print_help_to(out: &mut impl std::io::Write) -> std::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 = 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(())
}
pub(crate) fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String {
if skipped {
format!(
"Compact
Result skipped
Reason session below compaction threshold
Messages kept {resulting_messages}"
)
} else {
format!(
"Compact
Result compacted
Messages removed {removed}
Messages kept {resulting_messages}"
)
}
}
pub(crate) fn format_auto_compaction_notice(removed: usize) -> String {
format!("[auto-compacted: removed {removed} messages]")
}
pub(crate) fn render_help_topic(topic: LocalHelpTopic) -> String {
match topic {
LocalHelpTopic::Status => "Status
Usage claw status [--output-format <format>]
Purpose show local workspace snapshot without entering 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 resolved sandbox and isolation state for 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 current editor-facing ACP/Zed launch contract without starting runtime
Status discoverability only; `serve` is a status alias and does not launch 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 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 interactive REPL or 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 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 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 resolved system prompt that `claw` would send for given cwd + date
Options --cwd overrides workspace dir · --date injects 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 resolver would load for current cwd
Options --manifests-dir scopes discovery to 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 ordered startup phases CLI would execute before dispatch
Output phase names (text) or structured phase list (json) primary output is plan itself
Formats text (default), json
Related claw doctor · claw status"
.to_string(),
}
}
pub(crate) 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 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"))
}
pub(crate) fn render_last_tool_debug_report(session: &Session) -> Result<String, Box<dyn std::error::Error>> {
use runtime::ContentBlock;
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 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"))
}
pub(crate) 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("current repository")
)
}
pub(crate) 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("current repo work")
)
}
pub(crate) fn format_pr_report(branch: &str, context: Option<&str>) -> String {
format!(
"PR
Branch {branch}
Context {}
Action draft or create a pull request for current branch
Output title and markdown body suitable for GitHub",
context.unwrap_or("none")
)
}
pub(crate) fn format_issue_report(context: Option<&str>) -> String {
format!(
"Issue
Context {}
Action draft or create a GitHub issue from current context
Output title and markdown body suitable for GitHub",
context.unwrap_or("none")
)
}
pub(crate) 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")
}
pub(crate) 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")
}
pub(crate) fn render_export_text(session: &Session) -> String {
use runtime::MessageRole;
use runtime::ContentBlock;
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")
}
pub(crate) 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}"
)
}
pub(crate) fn render_session_markdown(session: &Session, session_id: &str, session_path: &Path) -> String {
use runtime::{MessageRole, ContentBlock};
use super::tools::short_tool_id;
use super::tools::summarize_tool_payload_for_markdown;
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**"));
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")
}

View File

@ -0,0 +1,492 @@
use std::io::{self, Write};
use serde_json::json;
use runtime::{RuntimeError, AssistantEvent};
use api::OutputContentBlock;
use crate::render::TerminalRenderer;
use super::helpers::*;
use super::{
DISPLAY_TRUNCATION_NOTICE, READ_DISPLAY_MAX_CHARS, READ_DISPLAY_MAX_LINES,
SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT, TOOL_OUTPUT_DISPLAY_MAX_CHARS,
TOOL_OUTPUT_DISPLAY_MAX_LINES,
};
pub(crate) 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}")
}
pub(crate) 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)
}
pub(crate) 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"
)
}
pub(crate) 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),
}
}
pub(crate) 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")
}
pub(crate) 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)
))
}
pub(crate) 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)
)
}
}
pub(crate) 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")
}
pub(crate) 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)
)
}
pub(crate) 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" },
)
}
pub(crate) 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"))
}
}
pub(crate) 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"),
}
}
pub(crate) 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}")
}
}
pub(crate) 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
}
}
pub(crate) 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}")
}
}
pub(crate) 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()))
}
pub(crate) 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(())
}
pub(crate) fn response_to_events(
response: api::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)
}
pub(crate) fn push_prompt_cache_record(
client: &api::ProviderClient,
events: &mut Vec<AssistantEvent>,
) {
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));
}
}
}
pub(crate) fn prompt_cache_record_to_runtime_event(
record: api::PromptCacheRecord,
) -> Option<runtime::PromptCacheEvent> {
let cache_break = record.cache_break?;
Some(runtime::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,
})
}

View File

@ -1,6 +1,8 @@
use std::fs;
use std::path::{Path, PathBuf};
use crate::args::CliOutputFormat;
const STARTER_CLAW_JSON: &str = concat!(
"{\n",
" \"permissions\": {\n",
@ -162,6 +164,38 @@ pub(crate) fn initialize_repo(cwd: &Path) -> Result<InitReport, Box<dyn std::err
})
}
pub(crate) fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
let cwd = std::env::current_dir()?;
Ok(initialize_repo(&cwd)?.render())
}
pub(crate) fn run_init(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let cwd = std::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(())
}
pub(crate) fn init_json_value(report: &InitReport, message: &str) -> serde_json::Value {
serde_json::json!({
"kind": "init",
"project_root": 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": InitReport::NEXT_STEP,
"message": message,
})
}
fn ensure_dir(path: &Path) -> Result<InitStatus, std::io::Error> {
if path.is_dir() {
return Ok(InitStatus::Skipped);

View File

@ -0,0 +1,518 @@
use crate::*;
use std::env;
use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use serde_json::json;
use crate::render::*;
#[derive(Debug, Clone)]
pub(crate) struct PromptHistoryEntry {
pub(crate) timestamp_ms: u64,
pub(crate) text: String,
}
#[allow(clippy::needless_pass_by_value)]
pub(crate) struct LiveCli {
pub(crate) model: String,
pub(crate) allowed_tools: Option<AllowedToolSet>,
pub(crate) permission_mode: PermissionMode,
pub(crate) system_prompt: Vec<String>,
pub(crate) runtime: BuiltRuntime,
pub(crate) session: SessionHandle,
pub(crate) prompt_history: Vec<PromptHistoryEntry>,
}
impl LiveCli {
pub(crate) fn new(
model: String,
enable_tools: bool,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
) -> Result<Self, Box<dyn std::error::Error>> {
let system_prompt = build_system_prompt()?;
let session_state = new_cli_session()?;
let session = create_managed_session_handle(&session_state.session_id)?;
let runtime = build_runtime(
session_state.with_persistence_path(session.path.clone()),
&session.id,
model.clone(),
system_prompt.clone(),
enable_tools,
true,
allowed_tools.clone(),
permission_mode,
None,
)?;
let cli = Self {
model,
allowed_tools,
permission_mode,
system_prompt,
runtime,
session,
prompt_history: Vec::new(),
};
cli.persist_session()?;
Ok(cli)
}
pub(crate) fn set_reasoning_effort(&mut self, effort: Option<String>) {
if let Some(rt) = self.runtime.runtime.as_mut() {
rt.api_client_mut().set_reasoning_effort(effort);
}
}
pub(crate) fn startup_banner(&self) -> String {
let cwd = env::current_dir().map_or_else(
|_| "<unknown>".to_string(),
|path| path.display().to_string(),
);
let status = status_context(None).ok();
let git_branch = status
.as_ref()
.and_then(|context| context.git_branch.as_deref())
.unwrap_or("unknown");
let workspace = status.as_ref().map_or_else(
|| "unknown".to_string(),
|context| context.git_summary.headline(),
);
let session_path = self.session.path.strip_prefix(Path::new(&cwd)).map_or_else(
|_| self.session.path.display().to_string(),
|path| path.display().to_string(),
);
format!(
"\x1b[38;5;196m\
\n\
\n\
\n\
\n\
\n\
\x1b[0m \x1b[38;5;208mCode\x1b[0m 🦞\n\n\
\x1b[2mModel\x1b[0m {}\n\
\x1b[2mPermissions\x1b[0m {}\n\
\x1b[2mBranch\x1b[0m {}\n\
\x1b[2mWorkspace\x1b[0m {}\n\
\x1b[2mDirectory\x1b[0m {}\n\
\x1b[2mSession\x1b[0m {}\n\
\x1b[2mAuto-save\x1b[0m {}\n\n\
Type \x1b[1m/help\x1b[0m for commands · \x1b[1m/status\x1b[0m for live context · \x1b[2m/resume latest\x1b[0m jumps back to the newest session · \x1b[1m/diff\x1b[0m then \x1b[1m/commit\x1b[0m to ship · \x1b[2mTab\x1b[0m for workflow completions · \x1b[2mShift+Enter\x1b[0m for newline",
self.model,
self.permission_mode.as_str(),
git_branch,
workspace,
cwd,
self.session.id,
session_path,
)
}
pub(crate) fn repl_completion_candidates(
&self,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
Ok(slash_command_completion_candidates_with_sessions(
&self.model,
Some(&self.session.id),
list_managed_sessions()?
.into_iter()
.map(|session| session.id)
.collect(),
))
}
pub(crate) fn prepare_turn_runtime(
&self,
emit_output: bool,
) -> Result<(BuiltRuntime, HookAbortMonitor), Box<dyn std::error::Error>> {
let hook_abort_signal = runtime::HookAbortSignal::new();
let runtime = build_runtime(
self.runtime.session().clone(),
&self.session.id,
self.model.clone(),
self.system_prompt.clone(),
true,
emit_output,
self.allowed_tools.clone(),
self.permission_mode,
None,
)?
.with_hook_abort_signal(hook_abort_signal.clone());
let hook_abort_monitor = HookAbortMonitor::spawn(hook_abort_signal);
Ok((runtime, hook_abort_monitor))
}
pub(crate) fn replace_runtime(
&mut self,
runtime: BuiltRuntime,
) -> Result<(), Box<dyn std::error::Error>> {
self.runtime.shutdown_plugins()?;
self.runtime = runtime;
Ok(())
}
pub(crate) fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?;
let mut spinner = Spinner::new();
let mut stdout = io::stdout();
spinner.tick(
"🦀 Thinking...",
TerminalRenderer::new().color_theme(),
&mut stdout,
)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let result = runtime.run_turn(input, Some(&mut permission_prompter));
hook_abort_monitor.stop();
match result {
Ok(summary) => {
self.replace_runtime(runtime)?;
spinner.finish(
"✨ Done",
TerminalRenderer::new().color_theme(),
&mut stdout,
)?;
println!();
if let Some(event) = summary.auto_compaction {
println!(
"{}",
format_auto_compaction_notice(event.removed_message_count)
);
}
self.persist_session()?;
Ok(())
}
Err(error) => {
runtime.shutdown_plugins()?;
spinner.fail(
"❌ Request failed",
TerminalRenderer::new().color_theme(),
&mut stdout,
)?;
Err(Box::new(error))
}
}
}
pub(crate) fn run_turn_with_output(
&mut self,
input: &str,
output_format: CliOutputFormat,
compact: bool,
) -> Result<(), Box<dyn std::error::Error>> {
match output_format {
CliOutputFormat::Json if compact => self.run_prompt_compact_json(input),
CliOutputFormat::Text if compact => self.run_prompt_compact(input),
CliOutputFormat::Text => self.run_turn(input),
CliOutputFormat::Json => self.run_prompt_json(input),
}
}
pub(crate) fn run_prompt_compact(
&mut self,
input: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let result = runtime.run_turn(input, Some(&mut permission_prompter));
hook_abort_monitor.stop();
let summary = result?;
self.replace_runtime(runtime)?;
self.persist_session()?;
let final_text = final_assistant_text(&summary);
println!("{final_text}");
Ok(())
}
pub(crate) fn run_prompt_compact_json(
&mut self,
input: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let result = runtime.run_turn(input, Some(&mut permission_prompter));
hook_abort_monitor.stop();
let summary = result?;
self.replace_runtime(runtime)?;
self.persist_session()?;
println!(
"{}",
serde_json::json!({
"message": final_assistant_text(&summary),
"compact": true,
"model": self.model,
"usage": {
"input_tokens": summary.usage.input_tokens,
"output_tokens": summary.usage.output_tokens,
"cache_creation_input_tokens": summary.usage.cache_creation_input_tokens,
"cache_read_input_tokens": summary.usage.cache_read_input_tokens,
},
})
);
Ok(())
}
pub(crate) fn run_prompt_json(
&mut self,
input: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let result = runtime.run_turn(input, Some(&mut permission_prompter));
hook_abort_monitor.stop();
let summary = result?;
self.replace_runtime(runtime)?;
self.persist_session()?;
println!(
"{}",
serde_json::json!({
"message": final_assistant_text(&summary),
"model": self.model,
"iterations": summary.iterations,
"auto_compaction": summary.auto_compaction.map(|event| serde_json::json!({
"removed_messages": event.removed_message_count,
"notice": format_auto_compaction_notice(event.removed_message_count),
})),
"tool_uses": collect_tool_uses(&summary),
"tool_results": collect_tool_results(&summary),
"prompt_cache_events": collect_prompt_cache_events(&summary),
"usage": {
"input_tokens": summary.usage.input_tokens,
"output_tokens": summary.usage.output_tokens,
"cache_creation_input_tokens": summary.usage.cache_creation_input_tokens,
"cache_read_input_tokens": summary.usage.cache_read_input_tokens,
},
"estimated_cost": format_usd(
summary.usage.estimate_cost_usd_with_pricing(
pricing_for_model(&self.model)
.unwrap_or_else(runtime::ModelPricing::default_sonnet_tier)
).total_cost_usd()
)
})
);
Ok(())
}
pub(crate) fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> {
self.runtime.session().save_to_path(&self.session.path)?;
Ok(())
}
pub(crate) fn record_prompt_history(&mut self, prompt: &str) {
let timestamp_ms = std::time::SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map_or(self.runtime.session().updated_at_ms, |duration| {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
});
let entry = PromptHistoryEntry {
timestamp_ms,
text: prompt.to_string(),
};
self.prompt_history.push(entry);
if let Err(error) = self.runtime.session_mut().push_prompt_entry(prompt) {
eprintln!("warning: failed to persist prompt history: {error}");
}
}
pub(crate) fn reload_runtime_features(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let runtime = build_runtime(
self.runtime.session().clone(),
&self.session.id,
self.model.clone(),
self.system_prompt.clone(),
true,
true,
self.allowed_tools.clone(),
self.permission_mode,
None,
)?;
self.replace_runtime(runtime)?;
self.persist_session()
}
pub(crate) fn run_internal_prompt_text_with_progress(
&self,
prompt: &str,
enable_tools: bool,
progress: Option<InternalPromptProgressReporter>,
) -> Result<String, Box<dyn std::error::Error>> {
let session = self.runtime.session().clone();
let mut runtime = build_runtime(
session,
&self.session.id,
self.model.clone(),
self.system_prompt.clone(),
enable_tools,
false,
self.allowed_tools.clone(),
self.permission_mode,
progress,
)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?;
let text = final_assistant_text(&summary).trim().to_string();
runtime.shutdown_plugins()?;
Ok(text)
}
pub(crate) fn run_internal_prompt_text(
&self,
prompt: &str,
enable_tools: bool,
) -> Result<String, Box<dyn std::error::Error>> {
self.run_internal_prompt_text_with_progress(prompt, enable_tools, None)
}
pub(crate) 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}");
}
}
}
pub(crate) 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(())
}
pub(crate) 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
}
}
pub(crate) 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);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -53,7 +53,7 @@ mod tests {
PluginToolDefinition {
name: "plugin_echo".to_string(),
description: Some("Echo plugin payload".to_string()),
input_schema: json!({
input_schema:serde_json::json!({
"type": "object",
"properties": {
"message": { "type": "string" }
@ -3492,7 +3492,7 @@ UU conflicted.rs",
.map(|index| format!("line {index:03}"))
.collect::<Vec<_>>()
.join("\n");
let output = json!({
let output =serde_json::json!({
"file": {
"filePath": "src/main.rs",
"content": content,
@ -3518,7 +3518,7 @@ UU conflicted.rs",
.map(|index| format!("stdout {index:03}"))
.collect::<Vec<_>>()
.join("\n");
let output = json!({
let output =serde_json::json!({
"stdout": stdout,
"stderr": "",
"returnCodeInterpretation": "completed successfully"
@ -3539,7 +3539,7 @@ UU conflicted.rs",
let items = (0..120)
.map(|index| format!("payload {index:03}"))
.collect::<Vec<_>>();
let output = json!({
let output =serde_json::json!({
"summary": "plugin payload",
"items": items,
})
@ -3671,7 +3671,7 @@ UU conflicted.rs",
OutputContentBlock::ToolUse {
id: "tool-1".to_string(),
name: "read_file".to_string(),
input: json!({}),
input:serde_json::json!({}),
},
&mut out,
&mut events,
@ -3700,7 +3700,7 @@ UU conflicted.rs",
content: vec![OutputContentBlock::ToolUse {
id: "tool-1".to_string(),
name: "read_file".to_string(),
input: json!({}),
input:serde_json::json!({}),
}],
stop_reason: Some("tool_use".to_string()),
stop_sequence: None,
@ -3735,7 +3735,7 @@ UU conflicted.rs",
content: vec![OutputContentBlock::ToolUse {
id: "tool-2".to_string(),
name: "read_file".to_string(),
input: json!({ "path": "rust/Cargo.toml" }),
input:serde_json::json!({ "path": "rust/Cargo.toml" }),
}],
stop_reason: Some("tool_use".to_string()),
stop_sequence: None,

View File

@ -53,7 +53,7 @@ impl SessionLifecycleSummary {
}
pub(crate) fn json_value(&self) -> serde_json::Value {
json!({
serde_json::json!({
"kind": self.kind.as_str(),
"pane_id": self.pane_id,
"pane_command": self.pane_command,
@ -130,8 +130,4 @@ pub(crate) struct ManagedSessionSummary {
pub(crate) lifecycle: SessionLifecycleSummary,
}
#[derive(Debug, Clone)]
pub(crate) struct PromptHistoryEntry {
pub(crate) timestamp_ms: u64,
pub(crate) text: String,
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,631 @@
use crate::api_client::AnthropicRuntimeClient;
use crate::constants::{AllowedToolSet, DEFAULT_DATE};
use crate::permission::permission_policy;
use crate::progress::InternalPromptProgressReporter;
use crate::tool_executor::CliToolExecutor;
use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry};
use runtime::{
load_system_prompt, ConfigLoader, ConversationRuntime, McpServerManager, McpTool, MessageRole,
PermissionMode, Session, ToolError,
};
use serde_json::{json, Value};
use std::collections::BTreeSet;
use std::env;
use std::ops::{Deref, DerefMut};
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use tools::{GlobalToolRegistry, RuntimeToolDefinition};
pub(crate) type RuntimePluginStateBuildOutput = (
Option<Arc<Mutex<RuntimeMcpState>>>,
Vec<RuntimeToolDefinition>,
);
pub(crate) struct RuntimePluginState {
pub(crate) feature_config: runtime::RuntimeFeatureConfig,
pub(crate) tool_registry: GlobalToolRegistry,
pub(crate) plugin_registry: PluginRegistry,
pub(crate) mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
}
pub(crate) struct RuntimeMcpState {
pub(crate) runtime: tokio::runtime::Runtime,
pub(crate) manager: McpServerManager,
pub(crate) pending_servers: Vec<String>,
pub(crate) degraded_report: Option<runtime::McpDegradedReport>,
}
pub(crate) struct BuiltRuntime {
pub(crate) runtime: Option<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>>,
pub(crate) plugin_registry: PluginRegistry,
pub(crate) plugins_active: bool,
pub(crate) mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
pub(crate) mcp_active: bool,
}
impl BuiltRuntime {
pub(crate) 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,
}
}
pub(crate) 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
}
pub(crate) fn shutdown_plugins(&mut self) -> Result<(), Box<dyn std::error::Error>> {
if self.plugins_active {
self.plugin_registry.shutdown()?;
self.plugins_active = false;
}
Ok(())
}
pub(crate) 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();
}
}
impl RuntimeMcpState {
pub(crate) 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,
)))
}
pub(crate) fn shutdown(&mut self) -> Result<(), Box<dyn std::error::Error>> {
self.runtime.block_on(self.manager.shutdown())?;
Ok(())
}
pub(crate) fn pending_servers(&self) -> Option<Vec<String>> {
(!self.pending_servers.is_empty()).then(|| self.pending_servers.clone())
}
pub(crate) fn degraded_report(&self) -> Option<runtime::McpDegradedReport> {
self.degraded_report.clone()
}
pub(crate) fn server_names(&self) -> Vec<String> {
self.manager.server_names()
}
pub(crate) 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()))
}
pub(crate) 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(&serde_json::json!({
"server": server_name,
"resources": result.resources,
}))
.map_err(|error| ToolError::new(error.to_string()))
}
pub(crate) 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(serde_json::json!({
"server": server_name,
"resources": result.resources,
})),
Err(error) => failures.push(serde_json::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(&serde_json::json!({
"resources": resources,
"failures": failures,
}))
.map_err(|error| ToolError::new(error.to_string()))
}
pub(crate) 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(&serde_json::json!({
"server": server_name,
"contents": result.contents,
}))
.map_err(|error| ToolError::new(error.to_string()))
}
}
pub(crate) 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))
}
pub(crate) 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(|| serde_json::json!({ "type": "object", "additionalProperties": true })),
required_permission: permission_mode_for_mcp_tool(&tool.tool),
}
}
pub(crate) 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:serde_json::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:serde_json::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:serde_json::json!({
"type": "object",
"properties": {
"server": { "type": "string" },
"uri": { "type": "string" }
},
"required": ["server", "uri"],
"additionalProperties": false
}),
required_permission: PermissionMode::ReadOnly,
},
]
}
pub(crate) 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
}
}
pub(crate) 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)
}
pub(crate) struct HookAbortMonitor {
pub(crate) stop_tx: Option<Sender<()>>,
pub(crate) join_handle: Option<JoinHandle<()>>,
}
impl HookAbortMonitor {
pub(crate) 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 => {}
}
});
})
}
pub(crate) 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),
}
}
pub(crate) 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();
}
}
}
pub(crate) 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",
)?)
}
pub(crate) 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,
})
}
pub(crate) 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)
}
pub(crate) 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)
}
}
pub(crate) 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,
)
}
pub(crate) 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,
)
}
pub(crate) fn build_runtime_with_plugin_state(
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>> {
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 executor = CliToolExecutor::new(
allowed_tools.clone(),
emit_output,
tool_registry.clone(),
mcp_state.clone(),
);
let client = AnthropicRuntimeClient::new(
session_id,
model,
enable_tools,
emit_output,
allowed_tools,
tool_registry,
progress_reporter,
)?;
let runtime = ConversationRuntime::new_with_features(
session,
client,
executor,
policy,
system_prompt,
&feature_config,
);
Ok(BuiltRuntime::new(runtime, plugin_registry, mcp_state))
}

View File

@ -2,14 +2,113 @@ use std::env;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::UNIX_EPOCH;
use commands::{render_slash_command_help_filtered, SlashCommand};
use runtime::Session;
use runtime::{ContentBlock, MessageRole, Session};
use crate::constants::*;
use crate::live_cli::PromptHistoryEntry;
use crate::models::*;
pub(crate) 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()
}
pub(crate) 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")
}
}
pub(crate) 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)?)
}
pub(crate) 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(())
}
pub(crate) fn command_exists(name: &str) -> bool {
Command::new("which")
.arg(name)
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
pub(crate) 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)
}
pub(crate) fn current_session_store() -> Result<runtime::SessionStore, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
runtime::SessionStore::from_cwd(&cwd).map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
@ -50,34 +149,33 @@ pub(crate) fn resolve_managed_session_path(session_id: &str) -> Result<PathBuf,
}
pub(crate) fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::error::Error>> {
Ok(current_session_store()?
.list_sessions()
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?
let store = current_session_store()?;
let sessions = store.list_sessions().map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
let panes = discover_tmux_panes();
Ok(sessions
.into_iter()
.map(|session| ManagedSessionSummary {
id: session.id,
path: session.path,
updated_at_ms: session.updated_at_ms,
modified_epoch_millis: session.modified_epoch_millis,
message_count: session.message_count,
parent_session_id: session.parent_session_id,
branch_name: session.branch_name,
lifecycle: SessionLifecycleSummary {
kind: SessionLifecycleKind::SavedOnly,
pane_id: None,
pane_command: None,
pane_path: None,
workspace_dirty: false,
abandoned: false,
},
.map(|session| {
let lifecycle = classify_session_lifecycle_from_panes(&session.path, panes.clone());
ManagedSessionSummary {
id: session.id,
path: session.path,
updated_at_ms: session.updated_at_ms,
modified_epoch_millis: session.modified_epoch_millis,
message_count: session.message_count,
parent_session_id: session.parent_session_id,
branch_name: session.branch_name,
lifecycle,
}
})
.collect())
}
pub(crate) fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn std::error::Error>> {
let session = current_session_store()?
.latest_session()
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
let store = current_session_store()?;
let session = store.latest_session().map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
let lifecycle = classify_session_lifecycle_for(&session.path);
Ok(ManagedSessionSummary {
id: session.id,
path: session.path,
@ -86,14 +184,7 @@ pub(crate) fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn
message_count: session.message_count,
parent_session_id: session.parent_session_id,
branch_name: session.branch_name,
lifecycle: SessionLifecycleSummary {
kind: SessionLifecycleKind::SavedOnly,
pane_id: None,
pane_command: None,
pane_path: None,
workspace_dirty: false,
abandoned: false,
},
lifecycle,
})
}
@ -130,6 +221,120 @@ pub(crate) fn confirm_session_deletion(session_id: &str) -> bool {
matches!(answer.trim(), "y" | "Y" | "yes" | "Yes" | "YES")
}
pub(crate) fn classify_session_lifecycle_for(workspace: &Path) -> SessionLifecycleSummary {
classify_session_lifecycle_from_panes(workspace, discover_tmux_panes())
}
pub(crate) 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,
}
}
}
pub(crate) 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)
}
pub(crate) 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()
}
pub(crate) 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)
}
pub(crate) 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"
)
}
pub(crate) 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())
}
pub(crate) fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::error::Error>> {
let sessions = list_managed_sessions()?;
let mut lines = vec![
@ -158,11 +363,12 @@ pub(crate) fn render_session_list(active_session_id: &str) -> Result<String, Box
(None, None) => String::new(),
};
lines.push(format!(
" {id:<20} {marker:<10} msgs={msgs:<4} modified={modified}{lineage} path={path}",
" {id:<20} {marker:<10} msgs={msgs:<4} modified={modified}{lineage} lifecycle={lifecycle} path={path}",
id = session.id,
msgs = session.message_count,
modified = format_session_modified_age(session.modified_epoch_millis),
lineage = lineage,
lifecycle = session.lifecycle.signal(),
path = session.path.display(),
));
}
@ -208,26 +414,4 @@ pub(crate) fn session_clear_backup_path(session_path: &Path) -> PathBuf {
session_path.with_file_name(format!("{file_name}.before-clear-{timestamp}.bak"))
}
pub(crate) fn render_repl_help() -> String {
[
"REPL".to_string(),
" /exit Quit the REPL".to_string(),
" /quit Quit the REPL".to_string(),
" Up/Down Navigate prompt history".to_string(),
" Ctrl-R Reverse-search prompt history".to_string(),
" Tab Complete commands, modes, and recent sessions".to_string(),
" Ctrl-C Clear input (or exit on empty prompt)".to_string(),
" Shift+Enter/Ctrl+J Insert a newline".to_string(),
" Auto-save .claw/sessions/<session-id>.jsonl".to_string(),
" Resume latest /resume latest".to_string(),
" Browse sessions /session list".to_string(),
" Show prompt history /history [count]".to_string(),
String::new(),
render_slash_command_help_filtered(STUB_COMMANDS),
]
.join(
"
",
)
}

View File

@ -9,6 +9,30 @@ use crate::args::*;
use crate::model::*;
use crate::*;
#[derive(Debug, Clone)]
pub(crate) struct StatusContext {
pub(crate) cwd: PathBuf,
pub(crate) session_path: Option<PathBuf>,
pub(crate) loaded_config_files: usize,
pub(crate) discovered_config_files: usize,
pub(crate) memory_file_count: usize,
pub(crate) project_root: Option<PathBuf>,
pub(crate) git_branch: Option<String>,
pub(crate) git_summary: GitWorkspaceSummary,
pub(crate) session_lifecycle: SessionLifecycleSummary,
pub(crate) sandbox_status: runtime::SandboxStatus,
pub(crate) config_load_error: Option<String>,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct StatusUsage {
pub(crate) message_count: usize,
pub(crate) turns: u32,
pub(crate) latest: TokenUsage,
pub(crate) cumulative: TokenUsage,
pub(crate) estimated_tokens: usize,
}
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(".")),
@ -198,7 +222,7 @@ pub(crate) fn status_json_value(
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!({
serde_json::json!({
"kind": "status",
"status": if degraded { "degraded" } else { "ok" },
"config_load_error": context.config_load_error,
@ -235,6 +259,7 @@ pub(crate) fn status_json_value(
// .claw/sessions/. Extract the stem (drop the .jsonl extension).
path.file_stem().map(|n| n.to_string_lossy().into_owned())
}),
"session_lifecycle": context.session_lifecycle.json_value(),
"loaded_config_files": context.loaded_config_files,
"discovered_config_files": context.discovered_config_files,
"memory_file_count": context.memory_file_count,
@ -305,120 +330,6 @@ pub(crate) fn status_context(
})
}
pub(crate) fn classify_session_lifecycle_for(workspace: &Path) -> SessionLifecycleSummary {
classify_session_lifecycle_from_panes(workspace, discover_tmux_panes())
}
pub(crate) 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,
}
}
}
pub(crate) 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)
}
pub(crate) 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()
}
pub(crate) 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)
}
pub(crate) 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"
)
}
pub(crate) 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())
}
pub(crate) fn format_status_report(
model: &str,
usage: StatusUsage,
@ -486,6 +397,7 @@ pub(crate) fn format_status_report(
Unstaged {}
Untracked {}
Session {}
Lifecycle {}
Config files loaded {}/{}
Memory files {}
Suggested flow /status /diff /commit",
@ -504,6 +416,7 @@ pub(crate) fn format_status_report(
|| "live-repl".to_string(),
|path| path.display().to_string()
),
context.session_lifecycle.signal(),
context.loaded_config_files,
context.discovered_config_files,
context.memory_file_count,
@ -599,7 +512,7 @@ pub(crate) fn print_sandbox_status_snapshot(
}
pub(crate) fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value {
json!({
serde_json::json!({
"kind": "sandbox",
"enabled": status.enabled,
"active": status.active,

View File

@ -11,7 +11,8 @@ use runtime::{
use tools::GlobalToolRegistry;
use crate::constants::*;
use crate::render::{format_tool_result, TerminalRenderer};
use crate::format_tool_result;
use crate::render::TerminalRenderer;
use crate::RuntimeMcpState;
#[derive(Deserialize)]