merge: resolve conflicts with origin/main (refactor-main-rs-split)

This commit is contained in:
zhaoyanchao 2026-05-28 20:49:39 +08:00
commit 34aaba75ae
37 changed files with 9969 additions and 6756 deletions

View File

@ -4,6 +4,15 @@ cargo fmt --all
### 跨平台编译
rustup target add x86_64-unknown-linux-musl
cargo build --target x86_64-unknown-linux-musl --release
cp target/x86_64-unknown-linux-musl/release/claw /usr/local/bin/claw
echo $DASHSCOPE_API_KEY
echo $ANTHROPIC_MODEL

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-26

View File

@ -0,0 +1,209 @@
## Context
`rusty-claude-cli/src/main.rs` (13,292 lines) contains the entire CLI application logic. It currently imports three sub-modules (init, input, render), but everything else lives in the single file. The file has grown organically as features were added, with no enforced module boundaries.
**Current module layout:**
```
src/
├── init.rs (~200 lines) — workspace initialization
├── input.rs (~200 lines) — rustyline REPL editor
├── render.rs (~200 lines) — terminal rendering, markdown, spinner
└── main.rs (~13,292 lines) — everything else
```
**Internal structure of main.rs:**
| Region | Lines | Content |
|--------|-------|---------|
| Imports, consts, type aliases | 1-200 | `#![allow]`, `mod`, `use`, `const`, `type` |
| `ModelSource/Provenance` | 60-150 | Model resolution types |
| `main()`, `run()`, helpers | 205-535 | Entry point + CLI action dispatch |
| `CliAction`, `CliOutputFormat` | 506-645 | Action enum definitions |
| `parse_args()` + 50 helpers | 645-1830 | Argument parsing and validation |
| `Doctor` system | 1831-2529 | Health check infrastructure |
| Status/Session functions | 2835-3700 | State snapshot and git parsing |
| `SessionHandle`, `LiveCli`, `BuiltRuntime` structs | 3678-4180 | Core data structures |
| `LiveCli` impl + `run_repl` | 4181-5550 | REPL loop and command dispatch |
| Formatting helpers | 5550-6900 | Report rendering (config, export, diff, etc.) |
| Runtime construction | 6906-7600 | `build_runtime`, plugin/MCP boot |
| `AnthropicRuntimeClient` | 7497-7910 | API client wrapper |
| Output event formatting | 7910-8730 | tool call/result formatting |
| `CliToolExecutor` | 8731-8923 | Tool execution adapter |
| `print_help_to` | 8923-9122 | Help text |
| Tests | 9123-13291 | `#[cfg(test)] mod tests { ... }` |
## Goals / Non-Goals
**Goals:**
- Split `main.rs` into ≤ 2000 lines by moving code into responsibility-focused modules
- Every new file must document its abstraction boundary (via its name, public API surface, and module-level structure)
- Zero behavior change — this is a pure move-and-rename refactoring
- All existing tests pass without modification (except import path updates)
- Each individual migration step compiles independently (no long-lived broken state)
**Non-Goals:**
- No new features or capabilities
- No architectural pattern changes (trait extraction, Command Handler pattern, etc.) — those belong in a future Phase 2
- No changes to `init.rs`, `input.rs`, `render.rs` — existing modules are already well-factored
- No Cargo dependency changes
- No performance optimization
- No public API changes (this is an internal binary crate, not a library)
## Decisions
### D1: Module granularity — fine-grained (one concern per file)
**Chosen:** ~17 new files, each with a single thematic responsibility.
**Rationale:**
- Each file will be 1501200 lines, well under the 2000-line limit
- File names directly map to the concern they contain (e.g., `doctor.rs` → diagnostic functions)
- Easier to navigate during development — you know exactly which file to open
- The `claw-cli` sibling crate demonstrates this pattern (app.rs, args.rs, init.rs, input.rs, main.rs, render.rs)
**Alternatives considered:**
- *Fewer, larger files* (e.g., 5-6 files at 2000 lines each) — rejected because it just postpones the problem and doesn't improve navigability
- *Single `lib.rs` with sub-modules* — the crate currently has no `lib.rs` and is a standalone binary; adding one would be a larger structural change
### D2: impl LiveCli — concentrate in `live_cli.rs`, REPL commands as free functions in `repl_commands.rs`
**Chosen:** `LiveCli` struct definition + all core methods live in `live_cli.rs`. Command handler functions (`handle_repl_command`, `handle_session_command`, `set_model`, etc.) become free functions in `repl_commands.rs` that accept `&mut LiveCli`.
**Rationale:**
- Rust allows `impl` blocks in multiple files, but this requires careful coordination and makes the struct's API surface hard to discover
- Free functions with an explicit receiver are simpler to understand and maintain
- No orphan rule violations — `repl_commands.rs` functions simply take `cli: &mut LiveCli` as their first parameter
**Implementation pattern:**
```rust
// repl_commands.rs
pub(crate) fn handle_repl_command(
cli: &mut LiveCli,
command: SlashCommand,
) -> Result<bool, Box<dyn std::error::Error>> { ... }
pub(crate) fn set_model(
cli: &mut LiveCli,
model: Option<String>,
) -> Result<bool, Box<dyn std::error::Error>> { ... }
```
### D3: Visibility strategy — `pub(crate)` for cross-module access, never `pub`
**Chosen:** All functions that need to be called from outside their module are `pub(crate)`. Nothing is `pub` (this is a binary crate, not a library).
**Rationale:**
- `main.rs` is the crate root that re-exports or `use`s sub-modules
- Test code (in `#[cfg(test)] mod tests`) accesses items via `use super::*` — making items `pub(crate)` is sufficient since tests are inline or in `tests/` directory
- Keeps the crate's internal API surface explicit
### D4: Module dependency order — low-level first, then layered
**Chosen:** Build modules in strict dependency order (constants → model → args → ... → live_cli → repl_commands → main).
**Rationale:**
- Avoids circular dependencies between modules
- Each module only depends on modules that are already compiled
- Follows the principle of "stable dependencies": lower-level modules change less frequently
**Dependency graph:**
```
layer 0: constants
layer 1: model
layer 2: permission, progress, doctor
layer 3: args, session
layer 4: status, export, config, diff
layer 5: formatting
layer 6: api_client, tool_executor
layer 7: runtime_builder
layer 8: live_cli
layer 9: repl_commands
layer 10: main (thin dispatch)
```
### D5: Test strategy — inline tests stay with source
**Chosen:** The `#[cfg(test)] mod tests { ... }` block at the bottom of `main.rs` stays attached to `main.rs`. As functions migrate to new modules, their test cases are NOT migrated in Phase 1 — `main.rs` tests continue to import migrated functions via `use super::*` which can reach them through module paths.
**Rationale:**
- Moving tests adds risk of breaking them and increases the scope of each step
- After Phase 1 is complete and stable, Phase 1.5 can migrate tests to their respective source modules
- Rust's module system allows `use crate::module_name::function_name` from within `main.rs` tests
- This minimizes per-step risk
### D6: `main.rs` re-export strategy
**Chosen:** `main.rs` will declare `pub(crate) use module_name::*;` for key modules so existing code (especially tests) can reference items without full path qualification.
**Rationale:**
- Minimizes churn in test code during the migration
- Provides a single reference point for all public crate items
- Can be cleaned up in a follow-up pass
## Module API Surface Summary
| Module | `pub(crate)` items | Dependencies |
|--------|-------------------|--------------|
| `constants.rs` | all `const`, `type` aliases | none |
| `model.rs` | `ModelSource`, `ModelProvenance`, `resolve_model_alias*`, `resolve_repl_model`, `validate_model_syntax`, `config_model_for_current_dir`, `config_alias_for_current_dir`, `max_tokens_for_model` | `constants`, `api` (external) |
| `permission.rs` | `CliPermissionPrompter`, `permission_policy` | `constants`, `runtime` (external) |
| `progress.rs` | `InternalPromptProgressState/Event/Shared/Reporter/Run`, `CliHookProgressReporter`, `describe_tool_progress`, `format_internal_prompt_progress_line` | `constants` |
| `doctor.rs` | `DiagnosticLevel`, `DiagnosticCheck`, `DoctorReport`, `render_doctor_report`, `run_doctor`, `check_*_health` (6 fns), `render_diagnostic_check` | `constants`, `runtime` (external) |
| `args.rs` | `CliAction`, `CliOutputFormat`, `LocalHelpTopic`, `parse_args`, `parse_export_args`, `parse_resume_args`, `parse_system_prompt_args`, `parse_dump_manifests_args`, `parse_local_help_action`, `parse_direct_slash_cli_action`, `parse_acp_args`, `try_resolve_bare_skill_prompt`, `join_optional_args`, `format_unknown_*`, `render_suggestion_line`, `suggest_*`, `levenshtein_distance`, `common_prefix_len`, `normalize_allowed_tools`, `current_tool_registry`, `parse_permission_mode_arg`, `permission_mode_from_label/resolved/default`, `config_permission_mode_for_current_dir`, `provider_label`, `format_connected_line`, `filter_tool_specs`, `is_help_flag`, `parse_single_word_command_alias`, `bare_slash_command_guidance`, `removed_auth_surface_error`, `looks_like_subcommand_typo`, `ranked_suggestions`, `omc_compatibility_note_for_unknown_slash_command` | `constants`, `model`, `runtime`/`tools`/`api` (external) |
| `session.rs` | `SessionHandle`, `ManagedSessionSummary`, `sessions_dir`, `current_session_store`, `new_cli_session`, `create_managed_session_handle`, `resolve_session_reference`, `resolve_managed_session_path`, `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` | `constants`, `runtime` (external) |
| `status.rs` | `StatusContext`, `StatusUsage`, `GitWorkspaceSummary`, `print_status_snapshot`, `status_json_value`, `status_context`, `format_status_report`, `format_sandbox_report`, `print_sandbox_status_snapshot`, `sandbox_json_value`, `parse_git_status_metadata`, `parse_git_status_branch`, `parse_git_workspace_summary`, `resolve_git_branch_for`, `run_git_capture_in`, `find_git_root_in`, `parse_git_status_metadata_for` | `constants`, `session` |
| `formatting.rs` | `format_tool_call_start`, `format_tool_result`, `format_bash_call/result`, `format_read/write/edit/glob/grep_result`, `format_search_start`, `format_patch_preview`, `format_structured_patch_preview`, `format_generic_tool_result`, `summarize_tool_payload`, `truncate_for_summary`, `truncate_output_for_display`, `render_thinking_block_summary`, `push_output_block`, `response_to_events`, `push_prompt_cache_record`, `prompt_cache_record_to_runtime_event`, `final_assistant_text`, `collect_tool_uses`, `collect_tool_results`, `collect_prompt_cache_events`, `short_tool_id`, `extract_tool_path`, `first_visible_line`, `print_help_to`, `print_help`, `render_help_topic`, `print_help_topic` | `constants` |
| `config.rs` | `render_config_report`, `render_config_json`, `render_memory_report`, `render_memory_json` | `runtime` (external) |
| `diff.rs` | `render_diff_report`, `render_diff_report_for`, `render_diff_json_for`, `run_git_diff_command_in` | none (std + external) |
| `export.rs` | `run_export`, `render_export_text`, `render_session_markdown`, `default_export_filename`, `resolve_export_path`, `summarize_tool_payload_for_markdown` | `session`, `status` |
| `api_client.rs` | `AnthropicRuntimeClient`, `resolve_cli_auth_source`, `resolve_cli_auth_source_for_cwd`, `request_ends_with_tool_result`, `format_user_visible_api_error`, `format_context_window_blocked_error` | `progress`, `api` (external) |
| `tool_executor.rs` | `CliToolExecutor`, `ToolSearchRequest`, `McpToolRequest`, `ListMcpResourcesRequest`, `ReadMcpResourceRequest`, `permission_policy`, `convert_messages` | `formatting`, `runtime`/`tools` (external) |
| `runtime_builder.rs` | `RuntimePluginState`, `RuntimeMcpState`, `BuiltRuntime`, `HookAbortMonitor`, `build_runtime`, `build_runtime_with_plugin_state`, `build_runtime_plugin_state`, `build_runtime_plugin_state_with_loader`, `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` | `api_client`, `tool_executor`, `progress`, `permission`, `plugins` (external) |
| `live_cli.rs` | `LiveCli`, `PromptHistoryEntry`, `run_repl`, `run_resume_command`, `resume_session`, `detect_broad_cwd`, `enforce_broad_cwd_policy`, `run_stale_base_preflight` | `runtime_builder`, `session`, `status`, `repl_commands` |
| `repl_commands.rs` | `handle_repl_command`, `handle_session_command`, `handle_plugins_command`, `set_model`, `set_permissions`, `clear_session`, `compact`, `reload_runtime_features`, `print_status`, `print_cost`, `print_prompt_history`, `resume_session`, `export_session`, `print_*` (config/memory/agents/mcp/skills/plugins/diff/version/sandbox), `run_bughunter/ultraplan/teleport/debug_tool_call/commit/pr/issue`, `format_*` (bughunter/ultraplan/pr/issue/commit/auto_compaction/compact/unknown_slash_command_message/model/model_switch/permissions/permissions_switch/cost/resume/compact/history), `render_repl_help`, `render_resume_usage`, `render_teleport_report`, `render_last_tool_debug_report`, `render_prompt_history_report`, `render_version_report`, `collect_session_prompt_history`, `recent_user_context`, `parse_history_count`, `format_history_timestamp`, `civil_from_days`, `git_output`, `git_status_ok`, `command_exists`, `write_temp_text_file`, `sanitize_generated_message`, `parse_titled_body`, `indent_block`, `validate_no_args`, `init_claude_md`, `normalize_permission_mode`, `run_init`, `init_json_value`, `print_acp_status`, `run_worker_state`, `run_mcp_serve`, `print_bootstrap_plan`, `print_system_prompt`, `print_version`, `version_json_value`, `slash_command_completion_candidates_with_sessions`, `truncate_for_prompt` | `live_cli`, `status`, `config`, `diff`, `export`, `doctor`, `commands` (external) |
### `main.rs` (post-split) — what remains
**Items that stay in main.rs:**
```rust
mod init; mod input; mod render; // existing
mod constants; mod model; mod permission; // new
mod progress; mod doctor; mod args;
mod session; mod status; mod formatting;
mod config; mod diff; mod export;
mod api_client; mod tool_executor;
mod runtime_builder; mod live_cli;
mod repl_commands;
// Re-exports for test compatibility
pub(crate) use constants::*;
pub(crate) use model::*;
pub(crate) use args::*;
// ... etc for all modules used by tests
fn main() { ... } // unchanged
fn run() -> ... { ... } // only dispatches CliAction variants
fn classify_error_kind() // stays — used in main()
fn split_error_hint() // stays — used in main()
fn read_piped_stdin() // stays — used in run()
fn merge_prompt_with_stdin() // stays — used in run()
fn write_mcp_server_fixture() // stays — test utility
// Test module stays here, unchanged, referencing functions via crate:: paths
```
## Risks / Trade-offs
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| **Missing a function during split** | Medium | High — broken build | Each step compiles independently; CI catches missing items |
| **Circular dependency between modules** | Low | High — redesign needed | Strict dependency ordering (low-level → high-level) prevents this |
| **Test breakage from visibility changes** | Medium | Medium — tests fail | Keep `pub(crate) use module::*` re-exports in main.rs; tests continue to work |
| **Merge conflicts with parallel work** | High (active project) | Medium | Execute refactoring as a focused session; each step is a small diff |
| **Accidental behavior change during move** | Low | High — user-facing bug | Pure copy-paste moves; no logic modification; review each diff carefully |
| **File count increase reduces discoverability** | Medium | Low — developer adaptation | Consistent naming convention; module names map 1:1 to concerns |
## Open Questions
1. **Should `STUB_COMMANDS` (80+ entries) stay in `constants.rs` or be in its own file?** — Currently planned for `constants.rs` (~80 lines of data), can be extracted later if it grows.
2. **Should `write_mcp_server_fixture` be moved to the test support module?** — It's only used in tests. Keep in `main.rs` for now; it's a short function.
3. **How to handle the `#[allow(...)]` attributes?** — The top-level `#![allow(...)]` applies to the crate root. Some module-level allows may need to be duplicated.

View File

@ -0,0 +1,68 @@
## Why
`rusty-claude-cli/src/main.rs` is 13,292 lines / 473KB — a single-file God Module containing all CLI logic except three thin modules (init, input, render). This makes it extremely difficult to:
- **Navigate**: finding a specific function requires scrolling through thousands of lines
- **Reason about**: no module-level boundaries document what belongs where
- **Test**: all functions share file-private visibility, forcing brittle integration tests
- **Maintain**: any change risks unintended side effects across unrelated subsystems
The project rule requires any file ≤ 2000 lines. This change brings `main.rs` under that limit by splitting its contents into focused, responsibility-driven modules.
## What Changes
**Phase 1 — Pure vertical split (zero behavior change):**
Move functions, structs, and impl blocks from `main.rs` into 15+ new files by thematic responsibility. Each new file declares a clear boundary. `main.rs` retains only the entry point (`main`, `run`) and the `CliAction` enum dispatch. All other code moves to new modules with `pub(crate)` visibility where cross-module access is needed.
- **`constants.rs`** — all `const` definitions and type aliases
- **`model.rs`** — `ModelSource`, `ModelProvenance`, model alias resolution
- **`args.rs`** — `CliAction` enum, `CliOutputFormat`, `parse_args()` + 50+ parse helpers
- **`doctor.rs`** — `DiagnosticLevel`, `DiagnosticCheck`, `DoctorReport`, health checks
- **`session.rs`** — `SessionHandle`, `ManagedSessionSummary`, session CRUD
- **`status.rs`** — `StatusContext`, `GitWorkspaceSummary`, status/sandbox snapshots
- **`permission.rs`** — `CliPermissionPrompter`, `PermissionPrompter` impl
- **`progress.rs`** — `InternalPromptProgressReporter/Run`, `HookProgressReporter` impl
- **`api_client.rs`** — `AnthropicRuntimeClient`, `ApiClient` impl
- **`tool_executor.rs`** — `CliToolExecutor`, `ToolExecutor` impl
- **`runtime_builder.rs`** — `BuiltRuntime`, `RuntimePluginState`, `RuntimeMcpState`, build functions
- **`formatting.rs`** — all `format_tool_*`, `push_output_block`, `response_to_events`, help text
- **`config.rs`** — config and memory report rendering
- **`diff.rs`** — git diff report rendering
- **`export.rs`** — session export (text/markdown) functions
- **`live_cli.rs`** — `LiveCli` struct + core methods (new, run_turn, persist_session, etc.)
- **`repl_commands.rs`** — `LiveCli` command handlers (`handle_repl_command`, `/status`, `/model`, etc.) and all standalone command functions
Existing files (`init.rs`, `input.rs`, `render.rs`) remain unchanged.
**Phase 2 — Architectural refinement (optional, future):**
- Extract `CliApiClient` / `CliOutputPort` / `SessionStore` traits
- Consider Command Handler pattern for REPL commands
## Capabilities
### New Capabilities
This is a **pure refactoring** with no new user-facing capabilities. The module boundaries correspond to internal architectural slices:
- `cli-args-parsing`: CLI argument parsing, validation, and suggestion system
- `cli-session-management`: session lifecycle (create, list, switch, delete, fork)
- `cli-runtime-construction`: runtime, plugin, and MCP server bootstrapping
- `cli-output-formatting`: all terminal output formatting (tool results, reports, help)
- `cli-repl-interaction`: REPL loop and slash command dispatch
### Modified Capabilities
None. No spec-level behavior changes — this is purely a code organization refactor.
## Impact
| Area | Impact |
|------|--------|
| **`rusty-claude-cli/src/`** | 3 files → ~20 files. Existing modules (init, input, render) untouched. |
| **`main.rs`** | 13,292 → ~800 lines (only main(), run(), CliAction, re-exports) |
| **Visibility** | Many private `fn` become `pub(crate)` for cross-module access |
| **Testing** | Existing tests remain valid; some `use super::*` imports need updating |
| **Compilation** | No dependency changes at the Cargo workspace level |
| **Behavior** | Zero. Pure move-and-rename refactoring. |

View File

@ -0,0 +1,45 @@
## ADDED Requirements
### Requirement: CLI argument parsing shall be isolated in `args.rs`
All CLI argument parsing logic SHALL be contained in a single `args.rs` module.
This includes `parse_args()`, all `parse_*` helper functions, the `CliAction` enum,
`CliOutputFormat`, `LocalHelpTopic`, and all suggestion/error-reporting helpers
(Levenshtein distance, ranked suggestions, unknown-command formatting).
#### Scenario: All parse functions are in args.rs
- **WHEN** the refactoring is complete
- **THEN** `args.rs` contains `parse_args`, `parse_export_args`, `parse_resume_args`,
`parse_system_prompt_args`, `parse_dump_manifests_args`, `parse_local_help_action`,
`parse_direct_slash_cli_action`, `parse_acp_args`, and all format_unknown_* /
suggest_* / levenshtein_distance helpers
#### Scenario: args.rs does not contain runtime logic
- **WHEN** any function in `args.rs` is reviewed
- **THEN** it SHALL only perform argument parsing and validation, never executing
business logic (no session CRUD, no runtime construction, no API calls)
### Requirement: `args.rs` shall expose `pub(crate)` parse functions
Argument parsing functions SHALL be `pub(crate)` so they are accessible from
`main.rs` for dispatch but not from outside the crate.
#### Scenario: main.rs can call parse_args
- **WHEN** `main.rs` calls `args::parse_args(&args)`
- **THEN** it returns a `Result<CliAction, String>`
- **THEN** no other crate module can call parse functions (no `pub` visibility)
### Requirement: Suggestion system shall remain in args.rs
All fuzzy-matching and suggestion utilities (`suggest_slash_commands`,
`suggest_closest_term`, `ranked_suggestions`, `levenshtein_distance`,
`common_prefix_len`) SHALL reside in `args.rs`.
#### Scenario: Typo suggestions work after refactoring
- **WHEN** user types an unknown command
- **THEN** `format_unknown_slash_command` produces a suggestion with the same
formatting as before refactoring

View File

@ -0,0 +1,70 @@
## ADDED Requirements
### Requirement: Tool result formatting shall be in `formatting.rs`
All `format_tool_*` functions, `response_to_events`, `push_output_block`,
`push_prompt_cache_record`, and related helpers SHALL be in `formatting.rs`.
#### Scenario: format_tool_call_start is in formatting.rs
- **WHEN** the refactoring is complete
- **THEN** `formatting.rs` contains `format_tool_call_start`, `format_tool_result`,
`format_bash_call`, `format_bash_result`, `format_read_result`,
`format_write_result`, `format_edit_result`, `format_glob_result`,
`format_grep_result`, `format_generic_tool_result`, `format_search_start`,
`format_patch_preview`, `format_structured_patch_preview`
#### Scenario: stream helpers are in formatting.rs
- **WHEN** the refactoring is complete
- **THEN** `formatting.rs` contains `push_output_block`, `response_to_events`,
`push_prompt_cache_record`, `prompt_cache_record_to_runtime_event`,
`final_assistant_text`, `collect_tool_uses`, `collect_tool_results`,
`collect_prompt_cache_events`, `short_tool_id`
### Requirement: Help text rendering shall be in `formatting.rs`
`print_help_to`, `print_help`, `render_help_topic`, and `print_help_topic`
SHALL be in `formatting.rs`.
#### Scenario: print_help_to is in formatting.rs
- **WHEN** the refactoring is complete
- **THEN** `formatting.rs` contains `print_help_to` and the full help text output
### Requirement: Config, diff, and export rendering shall be in dedicated modules
Config report rendering SHALL be in `config.rs`. Git diff rendering SHALL be
in `diff.rs`. Session export rendering SHALL be in `export.rs`.
#### Scenario: config.rs exists
- **WHEN** the refactoring is complete
- **THEN** `config.rs` contains `render_config_report`, `render_config_json`,
`render_memory_report`, `render_memory_json`
#### Scenario: diff.rs exists
- **WHEN** the refactoring is complete
- **THEN** `diff.rs` contains `render_diff_report`, `render_diff_report_for`,
`render_diff_json_for`, `run_git_diff_command_in`
#### Scenario: export.rs exists
- **WHEN** the refactoring is complete
- **THEN** `export.rs` contains `run_export`, `render_export_text`,
`render_session_markdown`, `default_export_filename`, `resolve_export_path`,
`summarize_tool_payload_for_markdown`
### Requirement: Internal progress reporting shall be in `progress.rs`
`InternalPromptProgressState`, `InternalPromptProgressEvent`,
`InternalPromptProgressShared`, `InternalPromptProgressReporter`,
`InternalPromptProgressRun`, `CliHookProgressReporter`,
`format_internal_prompt_progress_line`, and `describe_tool_progress`
SHALL be in `progress.rs`.
#### Scenario: progress.rs exists
- **WHEN** the refactoring is complete
- **THEN** `progress.rs` contains all internal progress reporting types and impls

View File

@ -0,0 +1,90 @@
## ADDED Requirements
### Requirement: LiveCli core methods shall be in `live_cli.rs`
The `LiveCli` struct definition and its core lifecycle methods SHALL be in
`live_cli.rs`. Core methods include `new`, `startup_banner`, `prepare_turn_runtime`,
`replace_runtime`, `run_turn`, `run_turn_with_output`, `run_prompt_compact`,
`run_prompt_compact_json`, `run_prompt_json`, `run_internal_prompt_text_with_progress`,
`run_internal_prompt_text`, `persist_session`, `record_prompt_history`,
`repl_completion_candidates`, and `reload_runtime_features`.
#### Scenario: live_cli.rs contains LiveCli struct
- **WHEN** the refactoring is complete
- **THEN** `live_cli.rs` defines `LiveCli` struct and contains all core lifecycle
methods listed above
#### Scenario: run_repl is in live_cli.rs
- **WHEN** the refactoring is complete
- **THEN** `live_cli.rs` contains `run_repl`, `run_resume_command`,
and `resume_session` as free functions
### Requirement: REPL command handlers shall be free functions in `repl_commands.rs`
All `handle_repl_command`, `handle_session_command`, `handle_plugins_command`,
`set_model`, `set_permissions`, `clear_session`, `compact`, `print_*` methods,
`run_bughunter`, `run_ultraplan`, `run_teleport`, `run_debug_tool_call`,
`run_commit`, `run_pr`, `run_issue`, `export_session`, and all format_*_report
helpers SHALL be free functions in `repl_commands.rs` that accept
`&mut LiveCli` or `&LiveCli` as their first parameter.
#### Scenario: handle_repl_command is a free function
- **WHEN** the refactoring is complete
- **THEN** `repl_commands.rs` defines `pub(crate) fn handle_repl_command(
cli: &mut LiveCli, command: SlashCommand) -> Result<bool, Box<dyn std::error::Error>>`
#### Scenario: All /slash commands are handled in repl_commands.rs
- **WHEN** user types `/status`, `/model`, `/session`, `/config`, `/diff`
- **THEN** the dispatch function in `repl_commands.rs` handles it via a
free function that takes `cli: &mut LiveCli`
### Requirement: REPL command dispatch shall not contain runtime construction
No REPL command handler SHALL directly call `build_runtime`, `ConversationRuntime::new`,
or `ApiProviderClient` constructors. Any runtime replacement SHALL go through
`LiveCli::replace_runtime`.
#### Scenario: Runtime is only replaced through LiveCli::replace_runtime
- **WHEN** a REPL command changes model or permissions
- **THEN** it calls `cli.replace_runtime(build_runtime(...))` from `runtime_builder.rs`
- **THEN** it does NOT directly construct `ConversationRuntime` or manage plugin
lifecycle
### Requirement: Doctor diagnostics shall be in `doctor.rs`
`DiagnosticLevel`, `DiagnosticCheck`, `DoctorReport`, `run_doctor`, all
`check_*_health` functions, and `render_diagnostic_check` SHALL be in `doctor.rs`.
#### Scenario: doctor.rs exists
- **WHEN** the refactoring is complete
- **THEN** `doctor.rs` contains the full diagnostic check system
### Requirement: Model resolution shall be in `model.rs`
`ModelSource`, `ModelProvenance`, `resolve_model_alias`,
`resolve_model_alias_with_config`, `resolve_repl_model`, `validate_model_syntax`,
`config_model_for_current_dir`, `config_alias_for_current_dir`, and
`max_tokens_for_model` SHALL be in `model.rs`.
#### Scenario: model.rs exists
- **WHEN** the refactoring is complete
- **THEN** `model.rs` contains all model-related types and functions
### Requirement: Status snapshot shall be in `status.rs`
`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 status parsing helpers SHALL be in `status.rs`.
#### Scenario: status.rs exists
- **WHEN** the refactoring is complete
- **THEN** `status.rs` contains all status and git parsing functions

View File

@ -0,0 +1,69 @@
## ADDED Requirements
### Requirement: Runtime construction shall be isolated in `runtime_builder.rs`
All runtime bootstrapping logic (building ConversationRuntime, plugins, MCP state)
SHALL be in `runtime_builder.rs`. This includes `BuiltRuntime`, `RuntimePluginState`,
`RuntimeMcpState`, `build_runtime`, `build_runtime_with_plugin_state`, all plugin
construction helpers, and `HookAbortMonitor`.
#### Scenario: All build_runtime variants are in runtime_builder.rs
- **WHEN** the refactoring is complete
- **THEN** `runtime_builder.rs` contains `build_runtime`,
`build_runtime_with_plugin_state`, `build_runtime_plugin_state`,
`build_runtime_plugin_state_with_loader`, `build_plugin_manager`,
`build_system_prompt`, `build_runtime_mcp_state`
#### Scenario: runtime_builder.rs imports from api_client and tool_executor
- **WHEN** `runtime_builder.rs` constructs a `ConversationRuntime`
- **THEN** it uses `AnthropicRuntimeClient` from `api_client.rs` and
`CliToolExecutor` from `tool_executor.rs`
### Requirement: BuiltRuntime shall be the single runtime lifecycle manager
`BuiltRuntime` SHALL own the `ConversationRuntime` option, plugin registry, and
MCP state. Its `Drop` implementation SHALL shut down plugins and MCP servers.
#### Scenario: BuiltRuntime::drop cleans up
- **WHEN** a `BuiltRuntime` is dropped
- **THEN** `shutdown_mcp()` and `shutdown_plugins()` are called
- **THEN** no plugin or MCP process is left running
### Requirement: AnthropicRuntimeClient shall be in `api_client.rs`
The API client adapter (`AnthropicRuntimeClient`) and its `ApiClient` trait impl
SHALL be in `api_client.rs`. Provider detection, auth resolution, and prompt
cache setup SHALL be contained in this module.
#### Scenario: api_client.rs contains AnthropicRuntimeClient
- **WHEN** the refactoring is complete
- **THEN** `api_client.rs` contains `AnthropicRuntimeClient` struct, its `new()`,
its `ApiClient` trait implementation, and auth resolution helpers
### Requirement: CliToolExecutor shall be in `tool_executor.rs`
The tool execution adapter (`CliToolExecutor`) and its `ToolExecutor` trait impl
SHALL be in `tool_executor.rs`. This includes all MCP tool call helpers and the
`permission_policy` / `convert_messages` functions.
#### Scenario: tool_executor.rs contains CliToolExecutor
- **WHEN** the refactoring is complete
- **THEN** `tool_executor.rs` contains `CliToolExecutor`, `ToolSearchRequest`,
`McpToolRequest`, `ListMcpResourcesRequest`, `ReadMcpResourceRequest`,
and the `ToolExecutor` trait implementation
### Requirement: Permission prompting shall be in `permission.rs`
`CliPermissionPrompter` and its `PermissionPrompter` trait impl SHALL be in
`permission.rs`.
#### Scenario: permission.rs contains permission prompt logic
- **WHEN** the refactoring is complete
- **THEN** `permission.rs` contains `CliPermissionPrompter`, its `new()` and
`decide()` methods, and `permission_policy` helper

View File

@ -0,0 +1,45 @@
## ADDED Requirements
### Requirement: Session management shall be isolated in `session.rs`
All session lifecycle operations (create, resolve, list, delete, search) SHALL
be contained in a single `session.rs` module. This includes `SessionHandle`,
`ManagedSessionSummary`, all session CRUD functions, and session rendering helpers.
#### Scenario: All session CRUD is in session.rs
- **WHEN** the refactoring is complete
- **THEN** `session.rs` contains `sessions_dir`, `current_session_store`,
`new_cli_session`, `create_managed_session_handle`, `resolve_session_reference`,
`resolve_managed_session_path`, `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`
#### Scenario: session.rs does not contain runtime construction
- **WHEN** any function in `session.rs` is reviewed
- **THEN** it SHALL NOT call `build_runtime` or `ConversationRuntime::new`
— session functions only read/write session files, not construct runtimes
### Requirement: Session types shall be `pub(crate)`
`SessionHandle` and `ManagedSessionSummary` SHALL be `pub(crate)` structs
accessible from `live_cli.rs`, `status.rs`, and `export.rs`.
#### Scenario: LiveCli uses SessionHandle from session.rs
- **WHEN** `live_cli.rs` constructs a `SessionHandle`
- **THEN** it imports it from `crate::session::SessionHandle`
- **THEN** the struct fields are accessible within the crate
### Requirement: Session store path resolution shall be centralized
`sessions_dir()` and `current_session_store()` SHALL be the single source of
truth for where session files are stored on disk.
#### Scenario: All session file paths go through session.rs
- **WHEN** any module needs to know the session directory path
- **THEN** it calls `sessions_dir()` from `session.rs`
- **THEN** no other module duplicates path resolution logic

View File

@ -0,0 +1,114 @@
## 1. Create constants.rs — extract all const and type aliases
- [x] 1.1 Create `src/constants.rs` with all `const` definitions (DEFAULT_MODEL, VERSION, STUB_COMMANDS, etc.) and type aliases (AllowedToolSet, RuntimePluginStateBuildOutput) extracted from `main.rs` lines 62-200
- [x] 1.2 Add `mod constants;` to `main.rs`; keep original items as `pub(crate) use constants::*;` re-exports so existing code continues to compile
- [x] 1.3 Run `cargo build` and `cargo test` — must pass with zero errors
## 2. Create model.rs — extract model resolution types and functions
- [x] 2.1 Create `src/model.rs` with ModelSource enum, ModelProvenance struct + impl, and all model-related functions: `resolve_model_alias`, `resolve_model_alias_with_config`, `resolve_repl_model`, `validate_model_syntax`, `config_model_for_current_dir`, `config_alias_for_current_dir`, `max_tokens_for_model`
- [x] 2.2 Add `mod model;` to `main.rs` and re-export via `pub(crate) use model::*;`
- [x] 2.3 Remove original definitions from `main.rs`; add necessary imports to model.rs
- [x] 2.4 Run `cargo build` and `cargo test` — must pass
## 3. Create permission.rs — extract permission prompt logic
- [x] 3.1 Create `src/permission.rs` with CliPermissionPrompter struct, its impl, PermissionPrompter trait impl, and `permission_policy` function (main.rs lines 7442-7508, 8861-8872)
- [x] 3.2 Add `mod permission;` to `main.rs` and re-export
- [x] 3.3 Run `cargo build` and `cargo test` — must pass
## 4. Create progress.rs — extract internal progress reporting
- [x] 4.1 Create `src/progress.rs` with InternalPromptProgressState/Event/Shared/Reporter/Run structs+impls, CliHookProgressReporter, `format_internal_prompt_progress_line`, and `describe_tool_progress` (main.rs lines 7000-7420)
- [x] 4.2 Add `mod progress;` to `main.rs` and re-export
- [x] 4.3 Run `cargo build` and `cargo test` — must pass
## 5. Create doctor.rs — extract diagnostic check system
- [x] 5.1 Create `src/doctor.rs` with DiagnosticLevel, DiagnosticCheck, DoctorReport, `render_doctor_report`, `run_doctor`, and all `check_*_health` functions (main.rs lines 1831-2525)
- [x] 5.2 Add `mod doctor;` to `main.rs` and re-export
- [x] 5.3 Run `cargo build` and `cargo test` — must pass
## 6. Create args.rs — extract all argument parsing
- [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
- [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
- [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
- [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
- [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
- [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
- [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
- [x] 13.1 Create `src/api_client.rs` with AnthropicRuntimeClient struct, its impl, ApiClient trait impl, `resolve_cli_auth_source`, `resolve_cli_auth_source_for_cwd`, `request_ends_with_tool_result`, `format_user_visible_api_error`, `format_context_window_blocked_error` (main.rs lines 7497-7912)
- [x] 13.2 Add `mod api_client;` to `main.rs` and re-export
- [x] 13.3 Run `cargo build` and `cargo test` — must pass
## 14. Create tool_executor.rs — extract CliToolExecutor
- [x] 14.1 Create `src/tool_executor.rs` with CliToolExecutor struct+impl, ToolExecutor trait impl, ToolSearchRequest/McpToolRequest/ListMcpResourcesRequest/ReadMcpResourceRequest structs, and `convert_messages` (main.rs lines 8731-8922)
- [x] 14.2 Add `mod tool_executor;` to `main.rs` and re-export
- [x] 14.3 Run `cargo build` and `cargo test` — must pass
## 15. Create runtime_builder.rs — extract runtime construction
- [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
- [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
- [ ] 17.1 Create `src/repl_commands.rs` with free functions for all REPL command handlers: `handle_repl_command`, `handle_session_command`, `handle_plugins_command`, `set_model`, `set_permissions`, `clear_session`, `compact`, all `print_*` methods, `run_bughunter/ultraplan/teleport/debug_tool_call/commit/pr/issue`, `export_session` (main.rs lines 4440-5256)
- [ ] 17.2 Extract all standalone command functions: `render_repl_help`, `render_resume_usage`, `render_teleport_report`, `render_last_tool_debug_report`, `render_prompt_history_report`, `render_version_report`, `collect_session_prompt_history`, `recent_user_context`, `parse_history_count`, `format_history_timestamp`, `civil_from_days`, `git_output`, `git_status_ok`, `command_exists`, `write_temp_text_file`, `sanitize_generated_message`, `parse_titled_body`, `indent_block`, `validate_no_args`, `init_claude_md`, `normalize_permission_mode`, `run_init`, `init_json_value`, `print_acp_status`, `run_worker_state`, `run_mcp_serve`, `print_bootstrap_plan`, `print_system_prompt`, `print_version`, `version_json_value`, `slash_command_completion_candidates_with_sessions`, `truncate_for_prompt`, `run_export`, `render_export_text`, `render_session_markdown`, `default_export_filename`, `resolve_export_path`, `summarize_tool_payload_for_markdown`
- [ ] 17.3 Extract all format_*_report/notice functions: `format_bughunter_report`, `format_ultraplan_report`, `format_pr_report`, `format_issue_report`, `format_commit_preflight_report`, `format_commit_skipped_report`, `format_auto_compaction_notice`, `format_compact_report`, `format_unknown_slash_command_message`, `format_model_report`, `format_model_switch_report`, `format_permissions_report`, `format_permissions_switch_report`, `format_cost_report`, `format_resume_report`, `render_resume_usage`
- [ ] 17.4 Add `mod repl_commands;` to `main.rs` and re-export
- [ ] 17.5 Run `cargo build` and `cargo test` — must pass
## 18. Slim main.rs and verify
- [ ] 18.1 Remove all moved code from `main.rs`; keep only `main()`, `run()`, `classify_error_kind`, `split_error_hint`, `read_piped_stdin`, `merge_prompt_with_stdin`, `write_mcp_server_fixture`, module declarations, re-exports, and the test module
- [ ] 18.2 Move `#[allow(...)]` directives to individual modules where needed
- [ ] 18.3 Run `cargo build` — must compile cleanly
- [ ] 18.4 Run full test suite: `cargo test` — all tests pass
- [ ] 18.5 Verify `wc -l src/main.rs` ≤ 2000 lines
- [ ] 18.6 Verify no new file exceeds 2000 lines

View File

@ -1,20 +1,23 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
context: |
Rust CLI application using crossterm, rustyline, tokio, serde, pulldown-cmark, syntect.
Workspace layout: crates/* (Cargo workspace).
Target crate: rusty-claude-cli (binary "claw").
The main.rs is 13,292 lines — a God File that needs to be split into focused modules.
User rule: any function ≤ 60 lines, any file ≤ 2000 lines (new files), file structure should document abstraction boundaries.
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours
rules:
proposal:
- Propose splitting strategy with clear dependency order
- Reference specific line ranges and function names from main.rs
- Include risk assessment per phase
design:
- Design module boundaries with file-level responsibility descriptions
- Define public API surface for each new module
- Address cross-cutting concerns: testing, visibility, re-exports
tasks:
- One task per file creation/migration step
- Each task must include "compiles and tests pass" as completion criteria
- Order tasks by dependency: low-level modules first, then higher-level ones
- Total execution: ~18 discrete tasks, each verifiable

View File

@ -0,0 +1,404 @@
use std::io::{self, Write};
use api::{
detect_provider_kind, resolve_startup_auth_source, AnthropicClient, AuthSource,
ContentBlockDelta, MessageRequest, PromptCache, ProviderClient as ApiProviderClient,
ProviderKind, StreamEvent as ApiStreamEvent, ToolChoice,
};
use crate::render::{MarkdownStreamState, TerminalRenderer};
use runtime::{ApiClient, ApiRequest, AssistantEvent, MessageRole, RuntimeError};
use tools::GlobalToolRegistry;
use crate::constants::*;
use crate::model::max_tokens_for_model;
use crate::{
format_tool_call_start, push_output_block,
render_thinking_block_summary,
};
use crate::InternalPromptProgressReporter;
// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct
// now holds an `ApiProviderClient` which dispatches to Anthropic, xAI,
// OpenAI, or DashScope at construction time based on
// `detect_provider_kind(&model)`. The struct name is kept to avoid
// churning `BuiltRuntime` and every Deref/DerefMut site that references
// it. See ROADMAP #29 for the provider-dispatch routing fix.
pub(crate) struct AnthropicRuntimeClient {
runtime: tokio::runtime::Runtime,
client: ApiProviderClient,
session_id: String,
model: String,
enable_tools: bool,
emit_output: bool,
allowed_tools: Option<AllowedToolSet>,
tool_registry: GlobalToolRegistry,
progress_reporter: Option<InternalPromptProgressReporter>,
reasoning_effort: Option<String>,
}
impl AnthropicRuntimeClient {
pub(crate) fn new(
session_id: &str,
model: String,
enable_tools: bool,
emit_output: bool,
allowed_tools: Option<AllowedToolSet>,
tool_registry: GlobalToolRegistry,
progress_reporter: Option<InternalPromptProgressReporter>,
) -> Result<Self, Box<dyn std::error::Error>> {
let resolved_model = api::resolve_model_alias(&model);
let client = match detect_provider_kind(&resolved_model) {
ProviderKind::Anthropic => {
let auth = resolve_cli_auth_source()?;
let inner = AnthropicClient::from_auth(auth)
.with_base_url(api::read_base_url())
.with_prompt_cache(PromptCache::new(session_id));
ApiProviderClient::Anthropic(inner)
}
ProviderKind::Xai | ProviderKind::OpenAi => {
ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)?
}
};
Ok(Self {
runtime: tokio::runtime::Runtime::new()?,
client,
session_id: session_id.to_string(),
model,
enable_tools,
emit_output,
allowed_tools,
tool_registry,
progress_reporter,
reasoning_effort: None,
})
}
pub(crate) fn set_reasoning_effort(&mut self, effort: Option<String>) {
self.reasoning_effort = effort;
}
}
impl ApiClient for AnthropicRuntimeClient {
#[allow(clippy::too_many_lines)]
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
if let Some(progress_reporter) = &self.progress_reporter {
progress_reporter.mark_model_phase();
}
let is_post_tool = request_ends_with_tool_result(&request);
let message_request = MessageRequest {
model: self.model.clone(),
max_tokens: max_tokens_for_model(&self.model),
messages: crate::convert_messages(&request.messages),
system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")),
tools: self
.enable_tools
.then(|| crate::filter_tool_specs(&self.tool_registry, self.allowed_tools.as_ref())),
tool_choice: self.enable_tools.then_some(ToolChoice::Auto),
stream: true,
reasoning_effort: self.reasoning_effort.clone(),
..Default::default()
};
self.runtime.block_on(async {
let max_attempts: usize = if is_post_tool { 2 } else { 1 };
for attempt in 1..=max_attempts {
let result = self
.consume_stream(&message_request, is_post_tool && attempt == 1)
.await;
match result {
Ok(events) => return Ok(events),
Err(error)
if error.to_string().contains("post-tool stall")
&& attempt < max_attempts =>
{
}
Err(error) => return Err(error),
}
}
Err(RuntimeError::new("post-tool continuation nudge exhausted"))
})
}
}
impl AnthropicRuntimeClient {
#[allow(clippy::too_many_lines)]
async fn consume_stream(
&self,
message_request: &MessageRequest,
apply_stall_timeout: bool,
) -> Result<Vec<AssistantEvent>, RuntimeError> {
let mut stream = self
.client
.stream_message(message_request)
.await
.map_err(|error| {
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
})?;
let mut stdout = io::stdout();
let mut sink = io::sink();
let out: &mut dyn Write = if self.emit_output {
&mut stdout
} else {
&mut sink
};
let renderer = TerminalRenderer::new();
let mut markdown_stream = MarkdownStreamState::default();
let mut events = Vec::new();
let mut pending_tool: Option<(String, String, String)> = None;
let mut block_has_thinking_summary = false;
let mut saw_stop = false;
let mut received_any_event = false;
loop {
let next = if apply_stall_timeout && !received_any_event {
match tokio::time::timeout(POST_TOOL_STALL_TIMEOUT, stream.next_event()).await {
Ok(inner) => inner.map_err(|error| {
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
})?,
Err(_elapsed) => {
return Err(RuntimeError::new(
"post-tool stall: model did not respond within timeout",
));
}
}
} else {
stream.next_event().await.map_err(|error| {
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
})?
};
let Some(event) = next else {
break;
};
received_any_event = true;
match event {
ApiStreamEvent::MessageStart(start) => {
for block in start.message.content {
push_output_block(
block,
out,
&mut events,
&mut pending_tool,
true,
&mut block_has_thinking_summary,
)?;
}
}
ApiStreamEvent::ContentBlockStart(start) => {
push_output_block(
start.content_block,
out,
&mut events,
&mut pending_tool,
true,
&mut block_has_thinking_summary,
)?;
}
ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
ContentBlockDelta::TextDelta { text } => {
if !text.is_empty() {
if let Some(progress_reporter) = &self.progress_reporter {
progress_reporter.mark_text_phase(&text);
}
if let Some(rendered) = markdown_stream.push(&renderer, &text) {
write!(out, "{rendered}")
.and_then(|()| out.flush())
.map_err(|error| RuntimeError::new(error.to_string()))?;
}
events.push(AssistantEvent::TextDelta(text));
}
}
ContentBlockDelta::InputJsonDelta { partial_json } => {
if let Some((_, _, input)) = &mut pending_tool {
input.push_str(&partial_json);
}
}
ContentBlockDelta::ThinkingDelta { thinking } => {
if !block_has_thinking_summary {
render_thinking_block_summary(out, None, false)?;
block_has_thinking_summary = true;
}
events.push(AssistantEvent::ThinkingDelta(thinking));
}
ContentBlockDelta::SignatureDelta { signature } => {
events.push(AssistantEvent::SignatureDelta(signature));
}
},
ApiStreamEvent::ContentBlockStop(_) => {
block_has_thinking_summary = false;
if let Some(rendered) = markdown_stream.flush(&renderer) {
write!(out, "{rendered}")
.and_then(|()| out.flush())
.map_err(|error| RuntimeError::new(error.to_string()))?;
}
if let Some((id, name, input)) = pending_tool.take() {
if let Some(progress_reporter) = &self.progress_reporter {
progress_reporter.mark_tool_phase(&name, &input);
}
writeln!(out, "\n{}", format_tool_call_start(&name, &input))
.and_then(|()| out.flush())
.map_err(|error| RuntimeError::new(error.to_string()))?;
events.push(AssistantEvent::ToolUse { id, name, input });
}
}
ApiStreamEvent::MessageDelta(delta) => {
events.push(AssistantEvent::Usage(delta.usage.token_usage()));
}
ApiStreamEvent::MessageStop(_) => {
saw_stop = true;
if let Some(rendered) = markdown_stream.flush(&renderer) {
write!(out, "{rendered}")
.and_then(|()| out.flush())
.map_err(|error| RuntimeError::new(error.to_string()))?;
}
events.push(AssistantEvent::MessageStop);
}
}
}
crate::push_prompt_cache_record(&self.client, &mut events);
if !saw_stop
&& events.iter().any(|event| {
matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty())
|| matches!(event, AssistantEvent::ToolUse { .. })
})
{
events.push(AssistantEvent::MessageStop);
}
if events
.iter()
.any(|event| matches!(event, AssistantEvent::MessageStop))
{
return Ok(events);
}
let response = self
.client
.send_message(&MessageRequest {
stream: false,
..message_request.clone()
})
.await
.map_err(|error| {
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
})?;
let mut events = crate::response_to_events(response, out)?;
crate::push_prompt_cache_record(&self.client, &mut events);
Ok(events)
}
}
pub(crate) fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
Ok(resolve_cli_auth_source_for_cwd()?)
}
pub(crate) fn resolve_cli_auth_source_for_cwd() -> Result<AuthSource, api::ApiError> {
resolve_startup_auth_source(|| Ok(None))
}
pub(crate) fn request_ends_with_tool_result(request: &ApiRequest) -> bool {
request
.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")
}

File diff suppressed because it is too large Load Diff

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

@ -0,0 +1,156 @@
use std::collections::BTreeSet;
use std::time::Duration;
pub(crate) const DEFAULT_MODEL: &str = "claude-opus-4-6";
pub(crate) const DEFAULT_DATE: &str = match option_env!("BUILD_DATE") {
Some(d) => d,
None => "unknown",
};
pub(crate) const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
pub(crate) const VERSION: &str = env!("CARGO_PKG_VERSION");
pub(crate) const BUILD_TARGET: Option<&str> = option_env!("TARGET");
pub(crate) const GIT_SHA: Option<&str> = option_env!("GIT_SHA");
pub(crate) const INTERNAL_PROGRESS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(3);
pub(crate) const POST_TOOL_STALL_TIMEOUT: Duration = Duration::from_secs(10);
pub(crate) const PRIMARY_SESSION_EXTENSION: &str = "jsonl";
pub(crate) const LEGACY_SESSION_EXTENSION: &str = "json";
pub(crate) const OFFICIAL_REPO_URL: &str = "https://github.com/ultraworkers/claw-code";
pub(crate) const OFFICIAL_REPO_SLUG: &str = "ultraworkers/claw-code";
pub(crate) const DEPRECATED_INSTALL_COMMAND: &str = "cargo install claw-code";
pub(crate) const LATEST_SESSION_REFERENCE: &str = "latest";
pub(crate) const SESSION_REFERENCE_ALIASES: &[&str] = &[
LATEST_SESSION_REFERENCE,
"last",
"recent",
];
pub(crate) const CLI_OPTION_SUGGESTIONS: &[&str] = &[
"--help",
"-h",
"--version",
"-V",
"--model",
"--output-format",
"--permission-mode",
"--dangerously-skip-permissions",
"--allowedTools",
"--allowed-tools",
"--resume",
"--acp",
"-acp",
"--print",
"--compact",
"--base-commit",
"-p",
];
pub(crate) const DEFAULT_HISTORY_LIMIT: usize = 20;
pub(crate) type AllowedToolSet = BTreeSet<String>;
pub(crate) const STUB_COMMANDS: &[&str] = &[
"login",
"logout",
"vim",
"upgrade",
"share",
"feedback",
"files",
"fast",
"exit",
"summary",
"desktop",
"brief",
"advisor",
"stickers",
"insights",
"thinkback",
"release-notes",
"security-review",
"keybindings",
"privacy-settings",
"plan",
"review",
"tasks",
"theme",
"voice",
"usage",
"rename",
"copy",
"hooks",
"context",
"color",
"effort",
"branch",
"rewind",
"ide",
"tag",
"output-style",
"add-dir",
"allowed-tools",
"bookmarks",
"workspace",
"reasoning",
"budget",
"rate-limit",
"changelog",
"diagnostics",
"metrics",
"tool-details",
"focus",
"unfocus",
"pin",
"unpin",
"language",
"profile",
"max-tokens",
"temperature",
"system-prompt",
"notifications",
"telemetry",
"env",
"project",
"terminal-setup",
"api-key",
"reset",
"undo",
"stop",
"retry",
"paste",
"screenshot",
"image",
"search",
"listen",
"speak",
"format",
"test",
"lint",
"build",
"run",
"git",
"stash",
"blame",
"log",
"cron",
"team",
"benchmark",
"migrate",
"templates",
"explain",
"refactor",
"docs",
"fix",
"perf",
"chat",
"web",
"map",
"symbols",
"references",
"definition",
"hover",
"autofix",
"multi",
"macro",
"alias",
"parallel",
"subagent",
"agent",
];

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

@ -0,0 +1,398 @@
use std::env;
use std::path::Path;
use runtime::{ConfigLoader, ProjectContext};
use serde_json::{json, Map, Value};
use crate::constants::{BUILD_TARGET, DEFAULT_DATE, DEPRECATED_INSTALL_COMMAND, GIT_SHA, OFFICIAL_REPO_SLUG, OFFICIAL_REPO_URL, VERSION};
use crate::{parse_git_status_metadata, parse_git_workspace_summary, resolve_sandbox_status, CliOutputFormat, StatusContext};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DiagnosticLevel {
Ok,
Warn,
Fail,
}
impl DiagnosticLevel {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Ok => "ok",
Self::Warn => "warn",
Self::Fail => "fail",
}
}
pub(crate) fn is_failure(self) -> bool {
matches!(self, Self::Fail)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DiagnosticCheck {
pub(crate) name: &'static str,
pub(crate) level: DiagnosticLevel,
pub(crate) summary: String,
pub(crate) details: Vec<String>,
pub(crate) data: Map<String, Value>,
}
impl DiagnosticCheck {
pub(crate) fn new(name: &'static str, level: DiagnosticLevel, summary: impl Into<String>) -> Self {
Self {
name,
level,
summary: summary.into(),
details: Vec::new(),
data: Map::new(),
}
}
pub(crate) fn with_details(mut self, details: Vec<String>) -> Self {
self.details = details;
self
}
pub(crate) fn with_data(mut self, data: Map<String, Value>) -> Self {
self.data = data;
self
}
pub(crate) fn json_value(&self) -> Value {
let mut value = Map::from_iter([
("name".to_string(), Value::String(self.name.to_ascii_lowercase())),
("status".to_string(), Value::String(self.level.label().to_string())),
("summary".to_string(), Value::String(self.summary.clone())),
("details".to_string(), Value::Array(
self.details.iter().cloned().map(Value::String).collect(),
)),
]);
value.extend(self.data.clone());
Value::Object(value)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DoctorReport {
pub(crate) checks: Vec<DiagnosticCheck>,
}
impl DoctorReport {
pub(crate) fn counts(&self) -> (usize, usize, usize) {
(
self.checks.iter().filter(|c| c.level == DiagnosticLevel::Ok).count(),
self.checks.iter().filter(|c| c.level == DiagnosticLevel::Warn).count(),
self.checks.iter().filter(|c| c.level == DiagnosticLevel::Fail).count(),
)
}
pub(crate) fn has_failures(&self) -> bool {
self.checks.iter().any(|c| c.level.is_failure())
}
pub(crate) fn render(&self) -> String {
let (ok_count, warn_count, fail_count) = self.counts();
let mut lines = vec![
"Doctor".to_string(),
format!("Summary\n OK {ok_count}\n Warnings {warn_count}\n Failures {fail_count}"),
];
lines.extend(self.checks.iter().map(render_diagnostic_check));
lines.join("\n\n")
}
pub(crate) fn json_value(&self) -> Value {
let report = self.render();
let (ok_count, warn_count, fail_count) = self.counts();
serde_json::json!({
"kind": "doctor",
"message": report,
"report": report,
"has_failures": self.has_failures(),
"summary": {
"total": self.checks.len(),
"ok": ok_count,
"warnings": warn_count,
"failures": fail_count,
},
"checks": self.checks.iter().map(DiagnosticCheck::json_value).collect::<Vec<_>>(),
})
}
}
fn render_diagnostic_check(check: &DiagnosticCheck) -> String {
let mut lines = vec![format!(
"{}\n Status {}\n Summary {}",
check.name,
check.level.label(),
check.summary
)];
if !check.details.is_empty() {
lines.push(" Details".to_string());
lines.extend(check.details.iter().map(|d| format!(" - {d}")));
}
lines.join("\n")
}
pub(crate) fn render_doctor_report() -> Result<DoctorReport, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let config_loader = ConfigLoader::default_for(&cwd);
let config = config_loader.load();
let discovered_config = config_loader.discover();
let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?;
let (project_root, git_branch) = parse_git_status_metadata(project_context.git_status.as_deref());
let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref());
let empty_config = runtime::RuntimeConfig::empty();
let sandbox_config = config.as_ref().ok().unwrap_or(&empty_config);
let context = StatusContext {
cwd: cwd.clone(),
session_path: None,
loaded_config_files: config.as_ref().ok().map_or(0, |c| c.loaded_entries().len()),
discovered_config_files: discovered_config.len(),
memory_file_count: project_context.instruction_files.len(),
project_root,
git_branch,
git_summary,
session_lifecycle: crate::classify_session_lifecycle_for(&cwd),
sandbox_status: resolve_sandbox_status(sandbox_config.sandbox(), &cwd),
config_load_error: config.as_ref().err().map(ToString::to_string),
};
Ok(DoctorReport {
checks: vec![
check_auth_health(),
check_config_health(&config_loader, config.as_ref()),
check_install_source_health(),
check_workspace_health(&context),
check_sandbox_health(&context.sandbox_status),
check_system_health(&cwd, config.as_ref().ok()),
],
})
}
pub(crate) fn run_doctor(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let report = render_doctor_report()?;
let message = report.render();
match output_format {
CliOutputFormat::Text => println!("{message}"),
CliOutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(&report.json_value())?);
}
}
if report.has_failures() {
return Err("doctor found failing checks".into());
}
Ok(())
}
fn check_auth_health() -> DiagnosticCheck {
let api_key_present = env::var("ANTHROPIC_API_KEY").ok().is_some_and(|v| !v.trim().is_empty());
let auth_token_present = env::var("ANTHROPIC_AUTH_TOKEN").ok().is_some_and(|v| !v.trim().is_empty());
let env_details = format!(
"Environment api_key={} auth_token={}",
if api_key_present { "present" } else { "absent" },
if auth_token_present { "present" } else { "absent" },
);
match runtime::load_oauth_credentials() {
Ok(Some(token_set)) => DiagnosticCheck::new(
"Auth",
if api_key_present || auth_token_present { DiagnosticLevel::Ok } else { DiagnosticLevel::Warn },
if api_key_present || auth_token_present {
"supported auth env vars are configured; legacy saved OAuth is ignored"
} else {
"legacy saved OAuth credentials are present but unsupported"
},
)
.with_details(vec![
env_details,
format!(
"Legacy OAuth expires_at={} refresh_token={} scopes={}",
token_set.expires_at.map_or_else(|| "<none>".to_string(), |v| v.to_string()),
if token_set.refresh_token.is_some() { "present" } else { "absent" },
if token_set.scopes.is_empty() { "<none>".to_string() } else { token_set.scopes.join(",") },
),
"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(),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",
if api_key_present || auth_token_present { DiagnosticLevel::Ok } else { DiagnosticLevel::Warn },
if api_key_present || auth_token_present {
"supported auth env vars are configured"
} else {
"no supported auth env vars were found"
},
)
.with_details(vec![env_details])
.with_data(Map::from_iter([
("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(),serde_json::json!(false)),
("legacy_scopes".to_string(),serde_json::json!(Vec::<String>::new())),
])),
Err(error) => DiagnosticCheck::new(
"Auth",
DiagnosticLevel::Fail,
format!("failed to inspect legacy saved credentials: {error}"),
)
.with_data(Map::from_iter([
("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(),serde_json::json!(error.to_string())),
])),
}
}
fn check_config_health(
config_loader: &ConfigLoader,
config: Result<&runtime::RuntimeConfig, &runtime::ConfigError>,
) -> DiagnosticCheck {
let discovered = config_loader.discover();
let present_paths: Vec<String> = discovered.iter().filter(|e| e.path.exists()).map(|e| e.path.display().to_string()).collect();
let discovered_paths: Vec<String> = discovered.iter().map(|e| e.path.display().to_string()).collect();
match config {
Ok(runtime_config) => {
let loaded_entries = runtime_config.loaded_entries();
let mut details = vec![format!("Config files loaded {}/{}", loaded_entries.len(), present_paths.len())];
if let Some(model) = runtime_config.model() {
details.push(format!("Resolved model {model}"));
}
details.push(format!("MCP servers {}", runtime_config.mcp().servers().len()));
if present_paths.is_empty() {
details.push("Discovered files <none> (defaults active)".to_string());
} else {
details.extend(present_paths.iter().map(|p| format!("Discovered file {p}")));
}
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(),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(),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(),serde_json::json!(error.to_string())),
])),
}
}
fn check_install_source_health() -> DiagnosticCheck {
DiagnosticCheck::new("Install source", DiagnosticLevel::Ok, format!("official source of truth is {OFFICIAL_REPO_SLUG}; avoid `{DEPRECATED_INSTALL_COMMAND}`"))
.with_details(vec![
format!("Official repo {OFFICIAL_REPO_URL}"),
"Recommended path build from this repo or use the upstream binary documented in README.md".to_string(),
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(),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")),
]))
}
fn check_workspace_health(context: &StatusContext) -> DiagnosticCheck {
let in_repo = context.project_root.is_some();
DiagnosticCheck::new("Workspace", if in_repo { DiagnosticLevel::Ok } else { DiagnosticLevel::Warn },
if in_repo { format!("project root detected on branch {}", context.git_branch.as_deref().unwrap_or("unknown")) } else { "current directory is not inside a git project".to_string() },
)
.with_details(vec![
format!("Cwd {}", context.cwd.display()),
format!("Project root {}", context.project_root.as_ref().map_or_else(|| "<none>".to_string(), |p| p.display().to_string())),
format!("Git branch {}", context.git_branch.as_deref().unwrap_or("unknown")),
format!("Git state {}", context.git_summary.headline()),
format!("Changed files {}", context.git_summary.changed_files),
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(),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)),
]))
}
fn check_sandbox_health(status: &runtime::SandboxStatus) -> DiagnosticCheck {
let degraded = status.enabled && !status.active;
let mut details = vec![
format!("Enabled {}", status.enabled),
format!("Active {}", status.active),
format!("Supported {}", status.supported),
format!("Filesystem mode {}", status.filesystem_mode.as_str()),
format!("Filesystem live {}", status.filesystem_active),
];
if let Some(reason) = &status.fallback_reason {
details.push(format!("Fallback reason {reason}"));
}
DiagnosticCheck::new("Sandbox",
if degraded { DiagnosticLevel::Warn } else { DiagnosticLevel::Ok },
if degraded { "sandbox was requested but is not currently active" } else if status.active { "sandbox protections are active" } else { "sandbox is not active for this session" },
)
.with_details(details)
.with_data(Map::from_iter([
("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)),
]))
}
fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> DiagnosticCheck {
let default_model = config.and_then(runtime::RuntimeConfig::model);
let mut details = vec![
format!("OS {} {}", env::consts::OS, env::consts::ARCH),
format!("Working dir {}", cwd.display()),
format!("Version {}", VERSION),
format!("Build target {}", BUILD_TARGET.unwrap_or("<unknown>")),
format!("Git SHA {}", GIT_SHA.unwrap_or("<unknown>")),
];
if let Some(model) = default_model {
details.push(format!("Default model {model}"));
}
DiagnosticCheck::new("System", DiagnosticLevel::Ok, "captured local runtime metadata")
.with_details(details)
.with_data(Map::from_iter([
("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

@ -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" }
@ -638,9 +638,52 @@ mod tests {
);
}
#[test]
fn resolves_known_model_aliases() {
assert_eq!(resolve_model_alias("opus"), "claude-opus-4-6");
assert_eq!(resolve_model_alias("sonnet"), "claude-sonnet-4-6");
assert_eq!(resolve_model_alias("haiku"), "claude-haiku-4-5-20251213");
assert_eq!(resolve_model_alias("claude-opus"), "claude-opus");
}
#[test]
fn user_defined_aliases_resolve_before_provider_dispatch() {
// given
let _guard = env_lock();
let root = temp_dir();
let cwd = root.join("project");
let config_home = root.join("config-home");
std::fs::create_dir_all(cwd.join(".claw")).expect("project config dir should exist");
std::fs::create_dir_all(&config_home).expect("config home should exist");
std::fs::write(
cwd.join(".claw").join("settings.json"),
r#"{"aliases":{"fast":"claude-haiku-4-5-20251213","smart":"opus","cheap":"grok-3-mini"}}"#,
)
.expect("project config should write");
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
// when
let direct = with_current_dir(&cwd, || resolve_model_alias_with_config("fast"));
let chained = with_current_dir(&cwd, || resolve_model_alias_with_config("smart"));
let cross_provider = with_current_dir(&cwd, || resolve_model_alias_with_config("cheap"));
let unknown = with_current_dir(&cwd, || resolve_model_alias_with_config("unknown-model"));
let builtin = with_current_dir(&cwd, || resolve_model_alias_with_config("haiku"));
match original_config_home {
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
None => std::env::remove_var("CLAW_CONFIG_HOME"),
}
std::fs::remove_dir_all(root).expect("temp config root should clean up");
// then
assert_eq!(direct, "claude-haiku-4-5-20251213");
assert_eq!(chained, "claude-opus-4-6");
assert_eq!(cross_provider, "grok-3-mini");
assert_eq!(unknown, "unknown-model");
assert_eq!(builtin, "claude-haiku-4-5-20251213");
}
#[test]
fn parses_version_flags_without_initializing_prompt_mode() {
@ -1756,7 +1799,20 @@ mod tests {
assert!(truncated.chars().count() <= 281);
}
#[test]
fn short_tool_id_truncates_long_identifiers_with_ellipsis() {
// given
let long = "toolu_01ABCDEFGHIJKLMN";
let short = "tool_1";
// when
let trimmed_long = short_tool_id(long);
let trimmed_short = short_tool_id(short);
// then
assert_eq!(trimmed_long, "toolu_01ABCD…");
assert_eq!(trimmed_short, "tool_1");
}
#[test]
fn parses_json_output_for_mcp_and_skills_commands() {
@ -2200,7 +2256,18 @@ mod tests {
assert!(names.contains(&"plugin_echo".to_string()));
}
#[test]
fn permission_policy_uses_plugin_tool_permissions() {
let feature_config = runtime::RuntimeFeatureConfig::default();
let policy = permission_policy(
PermissionMode::ReadOnly,
&feature_config,
&registry_with_plugin_tool(),
)
.expect("permission policy should build");
let required = policy.required_mode_for("plugin_echo");
assert_eq!(required, PermissionMode::WorkspaceWrite);
}
#[test]
fn shared_help_uses_resume_annotation_copy() {
@ -2336,11 +2403,52 @@ mod tests {
assert_eq!(line, "Connected: grok-3 via xai");
}
#[test]
fn resolve_repl_model_returns_user_supplied_model_unchanged_when_explicit() {
let user_model = "claude-sonnet-4-6".to_string();
let resolved = resolve_repl_model(user_model);
assert_eq!(resolved, "claude-sonnet-4-6");
}
#[test]
fn resolve_repl_model_falls_back_to_anthropic_model_env_when_default() {
let _guard = env_lock();
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
let config_home = root.join("config");
fs::create_dir_all(&config_home).expect("config home dir");
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_MODEL");
std::env::set_var("ANTHROPIC_MODEL", "sonnet");
let resolved = with_current_dir(&root, || resolve_repl_model(DEFAULT_MODEL.to_string()));
assert_eq!(resolved, "claude-sonnet-4-6");
std::env::remove_var("ANTHROPIC_MODEL");
std::env::remove_var("CLAW_CONFIG_HOME");
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn resolve_repl_model_returns_default_when_env_unset_and_no_config() {
let _guard = env_lock();
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
let config_home = root.join("config");
fs::create_dir_all(&config_home).expect("config home dir");
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_MODEL");
let resolved = with_current_dir(&root, || resolve_repl_model(DEFAULT_MODEL.to_string()));
assert_eq!(resolved, DEFAULT_MODEL);
std::env::remove_var("CLAW_CONFIG_HOME");
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn resume_supported_command_list_matches_expected_surface() {
@ -3363,15 +3471,107 @@ UU conflicted.rs",
assert_eq!(entries[1].text, "world");
}
#[test]
fn tool_rendering_helpers_compact_output() {
let start = format_tool_call_start("read_file", r#"{"path":"src/main.rs"}"#);
assert!(start.contains("read_file"));
assert!(start.contains("src/main.rs"));
let done = format_tool_result(
"read_file",
r#"{"file":{"filePath":"src/main.rs","content":"hello","numLines":1,"startLine":1,"totalLines":1}}"#,
false,
);
assert!(done.contains("📄 Read src/main.rs"));
assert!(done.contains("hello"));
}
#[test]
fn tool_rendering_truncates_large_read_output_for_display_only() {
let content = (0..200)
.map(|index| format!("line {index:03}"))
.collect::<Vec<_>>()
.join("\n");
let output =serde_json::json!({
"file": {
"filePath": "src/main.rs",
"content": content,
"numLines": 200,
"startLine": 1,
"totalLines": 200
}
})
.to_string();
let rendered = format_tool_result("read_file", &output, false);
assert!(rendered.contains("line 000"));
assert!(rendered.contains("line 079"));
assert!(!rendered.contains("line 199"));
assert!(rendered.contains("full result preserved in session"));
assert!(output.contains("line 199"));
}
#[test]
fn tool_rendering_truncates_large_bash_output_for_display_only() {
let stdout = (0..120)
.map(|index| format!("stdout {index:03}"))
.collect::<Vec<_>>()
.join("\n");
let output =serde_json::json!({
"stdout": stdout,
"stderr": "",
"returnCodeInterpretation": "completed successfully"
})
.to_string();
let rendered = format_tool_result("bash", &output, false);
assert!(rendered.contains("stdout 000"));
assert!(rendered.contains("stdout 059"));
assert!(!rendered.contains("stdout 119"));
assert!(rendered.contains("full result preserved in session"));
assert!(output.contains("stdout 119"));
}
#[test]
fn tool_rendering_truncates_generic_long_output_for_display_only() {
let items = (0..120)
.map(|index| format!("payload {index:03}"))
.collect::<Vec<_>>();
let output =serde_json::json!({
"summary": "plugin payload",
"items": items,
})
.to_string();
let rendered = format_tool_result("plugin_echo", &output, false);
assert!(rendered.contains("plugin_echo"));
assert!(rendered.contains("payload 000"));
assert!(rendered.contains("payload 040"));
assert!(!rendered.contains("payload 080"));
assert!(!rendered.contains("payload 119"));
assert!(rendered.contains("full result preserved in session"));
assert!(output.contains("payload 119"));
}
#[test]
fn tool_rendering_truncates_raw_generic_output_for_display_only() {
let output = (0..120)
.map(|index| format!("raw {index:03}"))
.collect::<Vec<_>>()
.join("\n");
let rendered = format_tool_result("plugin_echo", &output, false);
assert!(rendered.contains("plugin_echo"));
assert!(rendered.contains("raw 000"));
assert!(rendered.contains("raw 059"));
assert!(!rendered.contains("raw 119"));
assert!(rendered.contains("full result preserved in session"));
assert!(output.contains("raw 119"));
}
#[test]
fn ultraplan_progress_lines_include_phase_step_and_elapsed_status() {
@ -3420,7 +3620,21 @@ UU conflicted.rs",
assert!(failed.contains("network timeout"));
}
#[test]
fn describe_tool_progress_summarizes_known_tools() {
assert_eq!(
describe_tool_progress("read_file", r#"{"path":"src/main.rs"}"#),
"reading src/main.rs"
);
assert!(
describe_tool_progress("bash", r#"{"command":"cargo test -p rusty-claude-cli"}"#)
.contains("cargo test -p rusty-claude-cli")
);
assert_eq!(
describe_tool_progress("grep_search", r#"{"pattern":"ultraplan","path":"rust"}"#),
"grep `ultraplan` in rust"
);
}
#[test]
fn push_output_block_renders_markdown_text() {
@ -3457,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,
@ -3474,19 +3688,376 @@ UU conflicted.rs",
);
}
#[test]
fn response_to_events_preserves_empty_object_json_input_outside_streaming() {
let mut out = Vec::new();
let events = response_to_events(
MessageResponse {
id: "msg-1".to_string(),
kind: "message".to_string(),
model: "claude-opus-4-6".to_string(),
role: "assistant".to_string(),
content: vec![OutputContentBlock::ToolUse {
id: "tool-1".to_string(),
name: "read_file".to_string(),
input:serde_json::json!({}),
}],
stop_reason: Some("tool_use".to_string()),
stop_sequence: None,
usage: Usage {
input_tokens: 1,
output_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
request_id: None,
},
&mut out,
)
.expect("response conversion should succeed");
assert!(matches!(
&events[0],
AssistantEvent::ToolUse { name, input, .. }
if name == "read_file" && input == "{}"
));
}
#[test]
fn response_to_events_preserves_non_empty_json_input_outside_streaming() {
let mut out = Vec::new();
let events = response_to_events(
MessageResponse {
id: "msg-2".to_string(),
kind: "message".to_string(),
model: "claude-opus-4-6".to_string(),
role: "assistant".to_string(),
content: vec![OutputContentBlock::ToolUse {
id: "tool-2".to_string(),
name: "read_file".to_string(),
input:serde_json::json!({ "path": "rust/Cargo.toml" }),
}],
stop_reason: Some("tool_use".to_string()),
stop_sequence: None,
usage: Usage {
input_tokens: 1,
output_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
request_id: None,
},
&mut out,
)
.expect("response conversion should succeed");
assert!(matches!(
&events[0],
AssistantEvent::ToolUse { name, input, .. }
if name == "read_file" && input == "{\"path\":\"rust/Cargo.toml\"}"
));
}
#[test]
fn response_to_events_renders_collapsed_thinking_summary() {
let mut out = Vec::new();
let events = response_to_events(
MessageResponse {
id: "msg-3".to_string(),
kind: "message".to_string(),
model: "claude-opus-4-6".to_string(),
role: "assistant".to_string(),
content: vec![
OutputContentBlock::Thinking {
thinking: "step 1".to_string(),
signature: Some("sig_123".to_string()),
},
OutputContentBlock::Text {
text: "Final answer".to_string(),
},
],
stop_reason: Some("end_turn".to_string()),
stop_sequence: None,
usage: Usage {
input_tokens: 1,
output_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
request_id: None,
},
&mut out,
)
.expect("response conversion should succeed");
assert!(matches!(
&events[0],
AssistantEvent::TextDelta(text) if text == "Final answer"
));
let rendered = String::from_utf8(out).expect("utf8");
assert!(rendered.contains("▶ Thinking (6 chars hidden)"));
assert!(!rendered.contains("step 1"));
}
#[test]
fn build_runtime_plugin_state_merges_plugin_hooks_into_runtime_features() {
let config_home = temp_dir();
let workspace = temp_dir();
let source_root = temp_dir();
fs::create_dir_all(&config_home).expect("config home");
fs::create_dir_all(&workspace).expect("workspace");
fs::create_dir_all(&source_root).expect("source root");
write_plugin_fixture(&source_root, "hook-runtime-demo", true, false);
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
manager
.install(source_root.to_str().expect("utf8 source path"))
.expect("plugin install should succeed");
let loader = ConfigLoader::new(&workspace, &config_home);
let runtime_config = loader.load().expect("runtime config should load");
let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config)
.expect("plugin state should load");
let pre_hooks = state.feature_config.hooks().pre_tool_use();
assert_eq!(pre_hooks.len(), 1);
assert!(
pre_hooks[0].ends_with("hooks/pre.sh"),
"expected installed plugin hook path, got {pre_hooks:?}"
);
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(source_root);
}
#[test]
#[allow(clippy::too_many_lines)]
fn build_runtime_plugin_state_discovers_mcp_tools_and_surfaces_pending_servers() {
let config_home = temp_dir();
let workspace = temp_dir();
fs::create_dir_all(&config_home).expect("config home");
fs::create_dir_all(&workspace).expect("workspace");
let script_path = workspace.join("fixture-mcp.py");
write_mcp_server_fixture(&script_path);
fs::write(
config_home.join("settings.json"),
format!(
r#"{{
"mcpServers": {{
"alpha": {{
"command": "python3",
"args": ["{}"]
}},
"broken": {{
"command": "python3",
"args": ["-c", "import sys; sys.exit(0)"]
}}
}}
}}"#,
script_path.to_string_lossy()
),
)
.expect("write mcp settings");
let loader = ConfigLoader::new(&workspace, &config_home);
let runtime_config = loader.load().expect("runtime config should load");
let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config)
.expect("runtime plugin state should load");
let allowed = state
.tool_registry
.normalize_allowed_tools(&["mcp__alpha__echo".to_string(), "MCPTool".to_string()])
.expect("mcp tools should be allow-listable")
.expect("allow-list should exist");
assert!(allowed.contains("mcp__alpha__echo"));
assert!(allowed.contains("MCPTool"));
let mut executor = CliToolExecutor::new(
None,
false,
state.tool_registry.clone(),
state.mcp_state.clone(),
);
let tool_output = executor
.execute("mcp__alpha__echo", r#"{"text":"hello"}"#)
.expect("discovered mcp tool should execute");
let tool_json: serde_json::Value =
serde_json::from_str(&tool_output).expect("tool output should be json");
assert_eq!(tool_json["structuredContent"]["echoed"], "hello");
let wrapped_output = executor
.execute(
"MCPTool",
r#"{"qualifiedName":"mcp__alpha__echo","arguments":{"text":"wrapped"}}"#,
)
.expect("generic mcp wrapper should execute");
let wrapped_json: serde_json::Value =
serde_json::from_str(&wrapped_output).expect("wrapped output should be json");
assert_eq!(wrapped_json["structuredContent"]["echoed"], "wrapped");
let search_output = executor
.execute("ToolSearch", r#"{"query":"alpha echo","max_results":5}"#)
.expect("tool search should execute");
let search_json: serde_json::Value =
serde_json::from_str(&search_output).expect("search output should be json");
assert_eq!(search_json["matches"][0], "mcp__alpha__echo");
assert_eq!(search_json["pending_mcp_servers"][0], "broken");
assert_eq!(
search_json["mcp_degraded"]["failed_servers"][0]["server_name"],
"broken"
);
assert_eq!(
search_json["mcp_degraded"]["failed_servers"][0]["phase"],
"tool_discovery"
);
assert_eq!(
search_json["mcp_degraded"]["available_tools"][0],
"mcp__alpha__echo"
);
let listed = executor
.execute("ListMcpResourcesTool", r#"{"server":"alpha"}"#)
.expect("resources should list");
let listed_json: serde_json::Value =
serde_json::from_str(&listed).expect("resource output should be json");
assert_eq!(listed_json["resources"][0]["uri"], "file://guide.txt");
let read = executor
.execute(
"ReadMcpResourceTool",
r#"{"server":"alpha","uri":"file://guide.txt"}"#,
)
.expect("resource should read");
let read_json: serde_json::Value =
serde_json::from_str(&read).expect("resource read output should be json");
assert_eq!(
read_json["contents"][0]["text"],
"contents for file://guide.txt"
);
if let Some(mcp_state) = state.mcp_state {
mcp_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.shutdown()
.expect("mcp shutdown should succeed");
}
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(workspace);
}
#[test]
fn build_runtime_plugin_state_surfaces_unsupported_mcp_servers_structurally() {
let config_home = temp_dir();
let workspace = temp_dir();
fs::create_dir_all(&config_home).expect("config home");
fs::create_dir_all(&workspace).expect("workspace");
fs::write(
config_home.join("settings.json"),
r#"{
"mcpServers": {
"remote": {
"url": "https://example.test/mcp"
}
}
}"#,
)
.expect("write mcp settings");
let loader = ConfigLoader::new(&workspace, &config_home);
let runtime_config = loader.load().expect("runtime config should load");
let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config)
.expect("runtime plugin state should load");
let mut executor = CliToolExecutor::new(
None,
false,
state.tool_registry.clone(),
state.mcp_state.clone(),
);
let search_output = executor
.execute("ToolSearch", r#"{"query":"remote","max_results":5}"#)
.expect("tool search should execute");
let search_json: serde_json::Value =
serde_json::from_str(&search_output).expect("search output should be json");
assert_eq!(search_json["pending_mcp_servers"][0], "remote");
assert_eq!(
search_json["mcp_degraded"]["failed_servers"][0]["server_name"],
"remote"
);
assert_eq!(
search_json["mcp_degraded"]["failed_servers"][0]["phase"],
"server_registration"
);
assert_eq!(
search_json["mcp_degraded"]["failed_servers"][0]["error"]["context"]["transport"],
"http"
);
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(workspace);
}
#[test]
fn build_runtime_runs_plugin_lifecycle_init_and_shutdown() {
// Serialize access to process-wide env vars so parallel tests that
// set/remove ANTHROPIC_API_KEY do not race with this test.
let _guard = env_lock();
let config_home = temp_dir();
// Inject a dummy API key so runtime construction succeeds without real credentials.
// This test only exercises plugin lifecycle (init/shutdown), never calls the API.
std::env::set_var("ANTHROPIC_API_KEY", "test-dummy-key-for-plugin-lifecycle");
let workspace = temp_dir();
let source_root = temp_dir();
fs::create_dir_all(&config_home).expect("config home");
fs::create_dir_all(&workspace).expect("workspace");
fs::create_dir_all(&source_root).expect("source root");
write_plugin_fixture(&source_root, "lifecycle-runtime-demo", false, true);
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
let install = manager
.install(source_root.to_str().expect("utf8 source path"))
.expect("plugin install should succeed");
let log_path = install.install_path.join("lifecycle.log");
let loader = ConfigLoader::new(&workspace, &config_home);
let runtime_config = loader.load().expect("runtime config should load");
let runtime_plugin_state =
build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config)
.expect("plugin state should load");
let mut runtime = build_runtime_with_plugin_state(
Session::new(),
"runtime-plugin-lifecycle",
DEFAULT_MODEL.to_string(),
vec!["test system prompt".to_string()],
true,
false,
None,
PermissionMode::DangerFullAccess,
None,
runtime_plugin_state,
)
.expect("runtime should build");
assert_eq!(
fs::read_to_string(&log_path).expect("init log should exist"),
"init\n"
);
runtime
.shutdown_plugins()
.expect("plugin shutdown should succeed");
assert_eq!(
fs::read_to_string(&log_path).expect("shutdown log should exist"),
"init\nshutdown\n"
);
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(source_root);
std::env::remove_var("ANTHROPIC_API_KEY");
}
#[test]
fn rejects_invalid_reasoning_effort_value() {

View File

@ -0,0 +1,195 @@
use std::env;
use api::detect_provider_kind;
use runtime::ConfigLoader;
use crate::constants::{DEFAULT_MODEL, SESSION_REFERENCE_ALIASES};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ModelSource {
Flag,
Env,
Config,
Default,
}
impl ModelSource {
pub(crate) fn as_str(&self) -> &'static str {
match self {
ModelSource::Flag => "flag",
ModelSource::Env => "env",
ModelSource::Config => "config",
ModelSource::Default => "default",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ModelProvenance {
pub(crate) resolved: String,
pub(crate) raw: Option<String>,
pub(crate) source: ModelSource,
}
impl ModelProvenance {
pub(crate) fn default_fallback() -> Self {
Self {
resolved: DEFAULT_MODEL.to_string(),
raw: None,
source: ModelSource::Default,
}
}
pub(crate) fn from_flag(raw: &str) -> Self {
Self {
resolved: resolve_model_alias_with_config(raw),
raw: Some(raw.to_string()),
source: ModelSource::Flag,
}
}
pub(crate) fn from_env_or_config_or_default(cli_model: &str) -> Self {
if cli_model != DEFAULT_MODEL {
return Self {
resolved: cli_model.to_string(),
raw: Some(cli_model.to_string()),
source: ModelSource::Flag,
};
}
if let Some(env_model) = env::var("ANTHROPIC_MODEL")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
return Self {
resolved: resolve_model_alias_with_config(&env_model),
raw: Some(env_model),
source: ModelSource::Env,
};
}
if let Some(config_model) = config_model_for_current_dir() {
return Self {
resolved: resolve_model_alias_with_config(&config_model),
raw: Some(config_model),
source: ModelSource::Config,
};
}
Self::default_fallback()
}
}
pub(crate) fn max_tokens_for_model(model: &str) -> u32 {
if model.contains("opus") {
32_000
} else {
64_000
}
}
pub(crate) fn resolve_model_alias(model: &str) -> &str {
match model {
"opus" => "claude-opus-4-6",
"sonnet" => "claude-sonnet-4-6",
"haiku" => "claude-haiku-4-5-20251213",
_ => model,
}
}
pub(crate) fn resolve_model_alias_with_config(model: &str) -> String {
let trimmed = model.trim();
if let Some(resolved) = config_alias_for_current_dir(trimmed) {
return resolve_model_alias(&resolved).to_string();
}
resolve_model_alias(trimmed).to_string()
}
pub(crate) fn validate_model_syntax(model: &str) -> Result<(), String> {
let trimmed = model.trim();
if trimmed.is_empty() {
return Err("model string cannot be empty".to_string());
}
match trimmed {
"opus" | "sonnet" | "haiku" | "grok" | "grok-2" | "grok-3" | "grok-mini"
| "grok-3-mini" | "kimi" | "glm-5" => return Ok(()),
_ => {}
}
if trimmed.starts_with("qwen-")
|| trimmed.starts_with("ali-")
|| trimmed.starts_with("glm-")
|| trimmed.starts_with("kimi-")
{
return Ok(());
}
if trimmed.contains(' ') {
return Err(format!(
"invalid model syntax: '{trimmed}' contains spaces. Use provider/model format or known alias"
));
}
let parts: Vec<&str> = trimmed.split('/').collect();
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
let mut err_msg = format!(
"invalid model syntax: '{trimmed}'. Expected provider/model (e.g., anthropic/claude-opus-4-6) or known alias (opus, sonnet, haiku)"
);
if trimmed.starts_with("gpt-") || trimmed.starts_with("gpt_") {
err_msg.push_str("\nDid you mean `openai/");
err_msg.push_str(trimmed);
err_msg.push_str("`? (Requires OPENAI_API_KEY env var)");
} else if trimmed.starts_with("qwen") {
err_msg.push_str("\nDid you mean `qwen/");
err_msg.push_str(trimmed);
err_msg.push_str("`? (Requires DASHSCOPE_API_KEY env var)");
} else if trimmed.starts_with("grok") {
err_msg.push_str("\nDid you mean `xai/");
err_msg.push_str(trimmed);
err_msg.push_str("`? (Requires XAI_API_KEY env var)");
}
return Err(err_msg);
}
Ok(())
}
pub(crate) fn config_alias_for_current_dir(alias: &str) -> Option<String> {
if alias.is_empty() {
return None;
}
let cwd = env::current_dir().ok()?;
let loader = ConfigLoader::default_for(&cwd);
let config = loader.load().ok()?;
config.aliases().get(alias).cloned()
}
pub(crate) fn config_model_for_current_dir() -> Option<String> {
let cwd = env::current_dir().ok()?;
let loader = ConfigLoader::default_for(&cwd);
loader.load().ok()?.model().map(ToOwned::to_owned)
}
pub(crate) fn resolve_repl_model(cli_model: String) -> String {
if cli_model != DEFAULT_MODEL {
return cli_model;
}
if let Some(env_model) = env::var("ANTHROPIC_MODEL")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
return resolve_model_alias_with_config(&env_model);
}
if let Some(config_model) = config_model_for_current_dir() {
return resolve_model_alias_with_config(&config_model);
}
cli_model
}
pub(crate) fn provider_label(kind: api::ProviderKind) -> &'static str {
match kind {
api::ProviderKind::Anthropic => "anthropic",
api::ProviderKind::Xai => "xai",
api::ProviderKind::OpenAi => "openai",
}
}
pub(crate) fn format_connected_line(model: &str) -> String {
let provider = provider_label(detect_provider_kind(model));
format!("Connected: {model} via {provider}")
}

View File

@ -1,98 +1,7 @@
use crate::DEFAULT_MODEL;
use crate::{config_model_for_current_dir, resolve_model_alias_with_config};
use serde_json::json;
use std::env;
use std::path::PathBuf;
/// #148: Model provenance for `claw status` JSON/text output. Records where
/// the resolved model string came from so claws don't have to re-read argv
/// to audit whether their `--model` flag was honored vs falling back to env
/// or config or default.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ModelSource {
/// Explicit `--model` / `--model=` CLI flag.
Flag,
/// ANTHROPIC_MODEL environment variable (when no flag was passed).
Env,
/// `model` key in `.claw.json` / `.claw/settings.json` (when neither
/// flag nor env set it).
Config,
/// Compiled-in DEFAULT_MODEL fallback.
Default,
}
impl ModelSource {
pub(crate) fn as_str(&self) -> &'static str {
match self {
ModelSource::Flag => "flag",
ModelSource::Env => "env",
ModelSource::Config => "config",
ModelSource::Default => "default",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ModelProvenance {
/// Resolved model string (after alias expansion).
pub(crate) resolved: String,
/// Raw user input before alias resolution. None when source is Default.
pub(crate) raw: Option<String>,
/// Where the resolved model string originated.
pub(crate) source: ModelSource,
}
impl ModelProvenance {
pub(crate) fn default_fallback() -> Self {
Self {
resolved: DEFAULT_MODEL.to_string(),
raw: None,
source: ModelSource::Default,
}
}
pub(crate) fn from_flag(raw: &str) -> Self {
Self {
resolved: resolve_model_alias_with_config(raw),
raw: Some(raw.to_string()),
source: ModelSource::Flag,
}
}
pub(crate) fn from_env_or_config_or_default(cli_model: &str) -> Self {
// Only called when no --model flag was passed. Probe env first,
// then config, else fall back to default. Mirrors the logic in
// resolve_repl_model() but captures the source.
if cli_model != DEFAULT_MODEL {
// Already resolved from some prior path; treat as flag.
return Self {
resolved: cli_model.to_string(),
raw: Some(cli_model.to_string()),
source: ModelSource::Flag,
};
}
if let Some(env_model) = env::var("ANTHROPIC_MODEL")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
return Self {
resolved: resolve_model_alias_with_config(&env_model),
raw: Some(env_model),
source: ModelSource::Env,
};
}
if let Some(config_model) = config_model_for_current_dir() {
return Self {
resolved: resolve_model_alias_with_config(&config_model),
raw: Some(config_model),
source: ModelSource::Config,
};
}
Self::default_fallback()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SessionLifecycleKind {
RunningProcess,
@ -144,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,
@ -221,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,
}

View File

@ -0,0 +1,66 @@
use std::io::{self, Write};
use runtime::{PermissionMode, PermissionPolicy, PermissionPromptDecision, PermissionRequest};
use tools::GlobalToolRegistry;
pub(crate) struct CliPermissionPrompter {
current_mode: PermissionMode,
}
impl CliPermissionPrompter {
pub(crate) fn new(current_mode: PermissionMode) -> Self {
Self { current_mode }
}
}
impl runtime::PermissionPrompter for CliPermissionPrompter {
fn decide(
&mut self,
request: &PermissionRequest,
) -> PermissionPromptDecision {
println!();
println!("Permission approval required");
println!(" Tool {}", request.tool_name);
println!(" Current mode {}", self.current_mode.as_str());
println!(" Required mode {}", request.required_mode.as_str());
if let Some(reason) = &request.reason {
println!(" Reason {reason}");
}
println!(" Input {}", request.input);
print!("Approve this tool call? [y/N]: ");
let _ = io::stdout().flush();
let mut response = String::new();
match io::stdin().read_line(&mut response) {
Ok(_) => {
let normalized = response.trim().to_ascii_lowercase();
if matches!(normalized.as_str(), "y" | "yes") {
PermissionPromptDecision::Allow
} else {
PermissionPromptDecision::Deny {
reason: format!(
"tool '{}' denied by user approval prompt",
request.tool_name
),
}
}
}
Err(error) => PermissionPromptDecision::Deny {
reason: format!("permission approval failed: {error}"),
},
}
}
}
pub(crate) fn permission_policy(
mode: PermissionMode,
feature_config: &runtime::RuntimeFeatureConfig,
tool_registry: &GlobalToolRegistry,
) -> Result<PermissionPolicy, String> {
Ok(tool_registry.permission_specs(None)?.into_iter().fold(
PermissionPolicy::new(mode).with_permission_rules(feature_config.permission_rules()),
|policy, (name, required_permission)| {
policy.with_tool_requirement(name, required_permission)
},
))
}

View File

@ -0,0 +1,368 @@
use std::io::{self, Write};
use std::sync::mpsc::{self, RecvTimeoutError};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use crate::constants::INTERNAL_PROGRESS_HEARTBEAT_INTERVAL;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct InternalPromptProgressState {
pub(crate) command_label: &'static str,
pub(crate) task_label: String,
pub(crate) step: usize,
pub(crate) phase: String,
pub(crate) detail: Option<String>,
pub(crate) saw_final_text: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InternalPromptProgressEvent {
Started,
Update,
Heartbeat,
Complete,
Failed,
}
#[derive(Debug)]
pub(crate) struct InternalPromptProgressShared {
pub(crate) state: Mutex<InternalPromptProgressState>,
pub(crate) output_lock: Mutex<()>,
pub(crate) started_at: Instant,
}
#[derive(Debug, Clone)]
pub(crate) struct InternalPromptProgressReporter {
pub(crate) shared: Arc<InternalPromptProgressShared>,
}
#[derive(Debug)]
pub(crate) struct InternalPromptProgressRun {
pub(crate) reporter: InternalPromptProgressReporter,
pub(crate) heartbeat_stop: Option<mpsc::Sender<()>>,
pub(crate) heartbeat_handle: Option<thread::JoinHandle<()>>,
}
impl InternalPromptProgressReporter {
pub(crate) fn ultraplan(task: &str) -> Self {
Self {
shared: Arc::new(InternalPromptProgressShared {
state: Mutex::new(InternalPromptProgressState {
command_label: "Ultraplan",
task_label: task.to_string(),
step: 0,
phase: "planning started".to_string(),
detail: Some(format!("task: {task}")),
saw_final_text: false,
}),
output_lock: Mutex::new(()),
started_at: Instant::now(),
}),
}
}
pub(crate) fn emit(&self, event: InternalPromptProgressEvent, error: Option<&str>) {
let snapshot = self.snapshot();
let line = format_internal_prompt_progress_line(event, &snapshot, self.elapsed(), error);
self.write_line(&line);
}
pub(crate) fn mark_model_phase(&self) {
let snapshot = {
let mut state = self
.shared
.state
.lock()
.expect("internal prompt progress state poisoned");
state.step += 1;
state.phase = if state.step == 1 {
"analyzing request".to_string()
} else {
"reviewing findings".to_string()
};
state.detail = Some(format!("task: {}", state.task_label));
state.clone()
};
self.write_line(&format_internal_prompt_progress_line(
InternalPromptProgressEvent::Update,
&snapshot,
self.elapsed(),
None,
));
}
pub(crate) fn mark_tool_phase(&self, name: &str, input: &str) {
let detail = describe_tool_progress(name, input);
let snapshot = {
let mut state = self
.shared
.state
.lock()
.expect("internal prompt progress state poisoned");
state.step += 1;
state.phase = format!("running {name}");
state.detail = Some(detail);
state.clone()
};
self.write_line(&format_internal_prompt_progress_line(
InternalPromptProgressEvent::Update,
&snapshot,
self.elapsed(),
None,
));
}
pub(crate) fn mark_text_phase(&self, text: &str) {
let trimmed = text.trim();
if trimmed.is_empty() {
return;
}
let detail = crate::truncate_for_summary(crate::first_visible_line(trimmed), 120);
let snapshot = {
let mut state = self
.shared
.state
.lock()
.expect("internal prompt progress state poisoned");
if state.saw_final_text {
return;
}
state.saw_final_text = true;
state.step += 1;
state.phase = "drafting final plan".to_string();
state.detail = (!detail.is_empty()).then_some(detail);
state.clone()
};
self.write_line(&format_internal_prompt_progress_line(
InternalPromptProgressEvent::Update,
&snapshot,
self.elapsed(),
None,
));
}
pub(crate) fn emit_heartbeat(&self) {
let snapshot = self.snapshot();
self.write_line(&format_internal_prompt_progress_line(
InternalPromptProgressEvent::Heartbeat,
&snapshot,
self.elapsed(),
None,
));
}
pub(crate) fn snapshot(&self) -> InternalPromptProgressState {
self.shared
.state
.lock()
.expect("internal prompt progress state poisoned")
.clone()
}
pub(crate) fn elapsed(&self) -> Duration {
self.shared.started_at.elapsed()
}
pub(crate) fn write_line(&self, line: &str) {
let _guard = self
.shared
.output_lock
.lock()
.expect("internal prompt progress output lock poisoned");
let mut stdout = io::stdout();
let _ = writeln!(stdout, "{line}");
let _ = stdout.flush();
}
}
impl InternalPromptProgressRun {
pub(crate) fn start_ultraplan(task: &str) -> Self {
let reporter = InternalPromptProgressReporter::ultraplan(task);
reporter.emit(InternalPromptProgressEvent::Started, None);
let (heartbeat_stop, heartbeat_rx) = mpsc::channel();
let heartbeat_reporter = reporter.clone();
let heartbeat_handle = thread::spawn(move || loop {
match heartbeat_rx.recv_timeout(INTERNAL_PROGRESS_HEARTBEAT_INTERVAL) {
Ok(()) | Err(RecvTimeoutError::Disconnected) => break,
Err(RecvTimeoutError::Timeout) => heartbeat_reporter.emit_heartbeat(),
}
});
Self {
reporter,
heartbeat_stop: Some(heartbeat_stop),
heartbeat_handle: Some(heartbeat_handle),
}
}
pub(crate) fn reporter(&self) -> InternalPromptProgressReporter {
self.reporter.clone()
}
pub(crate) fn finish_success(&mut self) {
self.stop_heartbeat();
self.reporter
.emit(InternalPromptProgressEvent::Complete, None);
}
pub(crate) fn finish_failure(&mut self, error: &str) {
self.stop_heartbeat();
self.reporter
.emit(InternalPromptProgressEvent::Failed, Some(error));
}
fn stop_heartbeat(&mut self) {
if let Some(sender) = self.heartbeat_stop.take() {
let _ = sender.send(());
}
if let Some(handle) = self.heartbeat_handle.take() {
let _ = handle.join();
}
}
}
impl Drop for InternalPromptProgressRun {
fn drop(&mut self) {
self.stop_heartbeat();
}
}
pub(crate) fn format_internal_prompt_progress_line(
event: InternalPromptProgressEvent,
snapshot: &InternalPromptProgressState,
elapsed: Duration,
error: Option<&str>,
) -> String {
let elapsed_seconds = elapsed.as_secs();
let step_label = if snapshot.step == 0 {
"current step pending".to_string()
} else {
format!("current step {}", snapshot.step)
};
let mut status_bits = vec![step_label, format!("phase {}", snapshot.phase)];
if let Some(detail) = snapshot
.detail
.as_deref()
.filter(|detail| !detail.is_empty())
{
status_bits.push(detail.to_string());
}
let status = status_bits.join(" · ");
match event {
InternalPromptProgressEvent::Started => {
format!(
"🧭 {} status · planning started · {status}",
snapshot.command_label
)
}
InternalPromptProgressEvent::Update => {
format!("{} status · {status}", snapshot.command_label)
}
InternalPromptProgressEvent::Heartbeat => format!(
"… {} heartbeat · {elapsed_seconds}s elapsed · {status}",
snapshot.command_label
),
InternalPromptProgressEvent::Complete => format!(
"✔ {} status · completed · {elapsed_seconds}s elapsed · {} steps total",
snapshot.command_label, snapshot.step
),
InternalPromptProgressEvent::Failed => format!(
"✘ {} status · failed · {elapsed_seconds}s elapsed · {}",
snapshot.command_label,
error.unwrap_or("unknown error")
),
}
}
pub(crate) fn describe_tool_progress(name: &str, input: &str) -> String {
let parsed: serde_json::Value =
serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string()));
match name {
"bash" | "Bash" => {
let command = parsed
.get("command")
.and_then(|value| value.as_str())
.unwrap_or_default();
if command.is_empty() {
"running shell command".to_string()
} else {
format!("command {}", crate::truncate_for_summary(command.trim(), 100))
}
}
"read_file" | "Read" => format!("reading {}", crate::extract_tool_path(&parsed)),
"write_file" | "Write" => format!("writing {}", crate::extract_tool_path(&parsed)),
"edit_file" | "Edit" => format!("editing {}", crate::extract_tool_path(&parsed)),
"glob_search" | "Glob" => {
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!("glob `{pattern}` in {scope}")
}
"grep_search" | "Grep" => {
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!("grep `{pattern}` in {scope}")
}
"web_search" | "WebSearch" => parsed
.get("query")
.and_then(|value| value.as_str())
.map_or_else(
|| "running web search".to_string(),
|query| format!("query {}", crate::truncate_for_summary(query, 100)),
),
_ => {
let summary = crate::summarize_tool_payload(input);
if summary.is_empty() {
format!("running {name}")
} else {
format!("{name}: {summary}")
}
}
}
}
pub(crate) struct CliHookProgressReporter;
impl runtime::HookProgressReporter for CliHookProgressReporter {
fn on_event(&mut self, event: &runtime::HookProgressEvent) {
match event {
runtime::HookProgressEvent::Started {
event,
tool_name,
command,
} => eprintln!(
"[hook {event_name}] {tool_name}: {command}",
event_name = event.as_str()
),
runtime::HookProgressEvent::Completed {
event,
tool_name,
command,
} => eprintln!(
"[hook done {event_name}] {tool_name}: {command}",
event_name = event.as_str()
),
runtime::HookProgressEvent::Cancelled {
event,
tool_name,
command,
} => eprintln!(
"[hook cancelled {event_name}] {tool_name}: {command}",
event_name = event.as_str()
),
}
}
}

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

@ -0,0 +1,417 @@
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::{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>)
}
pub(crate) fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
Ok(current_session_store()?.sessions_dir().to_path_buf())
}
pub(crate) fn new_cli_session() -> Result<Session, Box<dyn std::error::Error>> {
Ok(Session::new().with_workspace_root(env::current_dir()?))
}
pub(crate) fn create_managed_session_handle(
session_id: &str,
) -> Result<SessionHandle, Box<dyn std::error::Error>> {
let handle = current_session_store()?.create_handle(session_id);
Ok(SessionHandle {
id: handle.id,
path: handle.path,
})
}
pub(crate) fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> {
let handle = current_session_store()?
.resolve_reference(reference)
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
Ok(SessionHandle {
id: handle.id,
path: handle.path,
})
}
pub(crate) fn resolve_managed_session_path(session_id: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
current_session_store()?
.resolve_managed_path(session_id)
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
}
pub(crate) fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, 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| {
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 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,
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,
})
}
pub(crate) fn load_session_reference(
reference: &str,
) -> Result<(SessionHandle, Session), Box<dyn std::error::Error>> {
let loaded = current_session_store()?
.load_session(reference)
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
Ok((
SessionHandle {
id: loaded.handle.id,
path: loaded.handle.path,
},
loaded.session,
))
}
pub(crate) fn delete_managed_session(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
if !path.exists() {
return Err(format!("session file does not exist: {}", path.display()).into());
}
fs::remove_file(path)?;
Ok(())
}
pub(crate) fn confirm_session_deletion(session_id: &str) -> bool {
print!("Delete session '{session_id}'? This cannot be undone. [y/N]: ");
io::stdout().flush().unwrap_or(());
let mut answer = String::new();
if io::stdin().read_line(&mut answer).is_err() {
return false;
}
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![
"Sessions".to_string(),
format!(" Directory {}", sessions_dir()?.display()),
];
if sessions.is_empty() {
lines.push(" No managed sessions saved yet.".to_string());
return Ok(lines.join("\n"));
}
for session in sessions {
let marker = if session.id == active_session_id {
"● current"
} else {
"○ saved"
};
let lineage = match (
session.branch_name.as_deref(),
session.parent_session_id.as_deref(),
) {
(Some(branch_name), Some(parent_session_id)) => {
format!(" branch={branch_name} from={parent_session_id}")
}
(None, Some(parent_session_id)) => format!(" from={parent_session_id}"),
(Some(branch_name), None) => format!(" branch={branch_name}"),
(None, None) => String::new(),
};
lines.push(format!(
" {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(),
));
}
Ok(lines.join("\n"))
}
pub(crate) fn format_session_modified_age(modified_epoch_millis: u128) -> String {
let now = std::time::SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map_or(modified_epoch_millis, |duration| duration.as_millis());
let delta_seconds = now
.saturating_sub(modified_epoch_millis)
.checked_div(1_000)
.unwrap_or_default();
match delta_seconds {
0..=4 => "just-now".to_string(),
5..=59 => format!("{delta_seconds}s-ago"),
60..=3_599 => format!("{}m-ago", delta_seconds / 60),
3_600..=86_399 => format!("{}h-ago", delta_seconds / 3_600),
_ => format!("{}d-ago", delta_seconds / 86_400),
}
}
pub(crate) fn write_session_clear_backup(
session: &Session,
session_path: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
let backup_path = session_clear_backup_path(session_path);
session.save_to_path(&backup_path)?;
Ok(backup_path)
}
pub(crate) fn session_clear_backup_path(session_path: &Path) -> PathBuf {
let timestamp = std::time::SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map_or(0, |duration| duration.as_millis());
let file_name = session_path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("session.jsonl");
session_path.with_file_name(format!("{file_name}.before-clear-{timestamp}.bak"))
}

View File

@ -0,0 +1,532 @@
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use serde_json::{json, Map, Value};
use runtime::{ConfigLoader, PermissionMode, ProjectContext, SandboxConfig, SandboxStatus, TokenUsage};
use runtime::resolve_sandbox_status;
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(".")),
status,
)
}
pub(crate) fn parse_git_status_branch(status: Option<&str>) -> Option<String> {
let status = status?;
let first_line = status.lines().next()?;
let line = first_line.strip_prefix("## ")?;
if line.starts_with("HEAD") {
return Some("detached HEAD".to_string());
}
let branch = line.split(['.', ' ']).next().unwrap_or_default().trim();
if branch.is_empty() {
None
} else {
Some(branch.to_string())
}
}
pub(crate) fn parse_git_workspace_summary(status: Option<&str>) -> GitWorkspaceSummary {
let mut summary = GitWorkspaceSummary::default();
let Some(status) = status else {
return summary;
};
for line in status.lines() {
if line.starts_with("## ") || line.trim().is_empty() {
continue;
}
summary.changed_files += 1;
let mut chars = line.chars();
let index_status = chars.next().unwrap_or(' ');
let worktree_status = chars.next().unwrap_or(' ');
if index_status == '?' && worktree_status == '?' {
summary.untracked_files += 1;
continue;
}
if index_status != ' ' {
summary.staged_files += 1;
}
if worktree_status != ' ' {
summary.unstaged_files += 1;
}
if (matches!(index_status, 'U' | 'A') && matches!(worktree_status, 'U' | 'A'))
|| index_status == 'U'
|| worktree_status == 'U'
{
summary.conflicted_files += 1;
}
}
summary
}
pub(crate) fn resolve_git_branch_for(cwd: &Path) -> Option<String> {
let branch = run_git_capture_in(cwd, &["branch", "--show-current"])?;
let branch = branch.trim();
if !branch.is_empty() {
return Some(branch.to_string());
}
let fallback = run_git_capture_in(cwd, &["rev-parse", "--abbrev-ref", "HEAD"])?;
let fallback = fallback.trim();
if fallback.is_empty() {
None
} else if fallback == "HEAD" {
Some("detached HEAD".to_string())
} else {
Some(fallback.to_string())
}
}
pub(crate) fn run_git_capture_in(cwd: &Path, args: &[&str]) -> Option<String> {
let output = std::process::Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout).ok()
}
pub(crate) fn find_git_root_in(cwd: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
let output = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(cwd)
.output()?;
if !output.status.success() {
return Err("not a git repository".into());
}
let path = String::from_utf8(output.stdout)?.trim().to_string();
if path.is_empty() {
return Err("empty git root".into());
}
Ok(PathBuf::from(path))
}
pub(crate) fn parse_git_status_metadata_for(
cwd: &Path,
status: Option<&str>,
) -> (Option<PathBuf>, Option<String>) {
let branch = resolve_git_branch_for(cwd).or_else(|| parse_git_status_branch(status));
let project_root = find_git_root_in(cwd).ok();
(project_root, branch)
}
#[allow(clippy::too_many_lines)]
pub(crate) fn print_status_snapshot(
model: &str,
model_flag_raw: Option<&str>,
permission_mode: PermissionMode,
output_format: CliOutputFormat,
allowed_tools: Option<&AllowedToolSet>,
) -> Result<(), Box<dyn std::error::Error>> {
let usage = StatusUsage {
message_count: 0,
turns: 0,
latest: TokenUsage::default(),
cumulative: TokenUsage::default(),
estimated_tokens: 0,
};
let context = status_context(None)?;
// #148: resolve model provenance. If user passed --model, source is
// "flag" with the raw input preserved. Otherwise probe env -> config
// -> default and record the winning source.
let provenance = match model_flag_raw {
Some(raw) => ModelProvenance {
resolved: model.to_string(),
raw: Some(raw.to_string()),
source: ModelSource::Flag,
},
None => ModelProvenance::from_env_or_config_or_default(model),
};
match output_format {
CliOutputFormat::Text => println!(
"{}",
format_status_report(
&provenance.resolved,
usage,
permission_mode.as_str(),
&context,
Some(&provenance)
)
),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&status_json_value(
Some(&provenance.resolved),
usage,
permission_mode.as_str(),
&context,
Some(&provenance),
allowed_tools,
))?
),
}
Ok(())
}
pub(crate) fn status_json_value(
model: Option<&str>,
usage: StatusUsage,
permission_mode: &str,
context: &StatusContext,
// #148: optional provenance for `model` field. Surfaces `model_source`
// ("flag" | "env" | "config" | "default") and `model_raw` (user input
// before alias resolution, or null when source is "default"). Callers
// that don't have provenance (legacy resume paths) pass None, in which
// case both new fields are omitted.
provenance: Option<&ModelProvenance>,
allowed_tools: Option<&AllowedToolSet>,
) -> serde_json::Value {
// #143: top-level `status` marker so claws can distinguish
// a clean run from a degraded run (config parse failed but other fields
// are still populated). `config_load_error` carries the parse-error string
// when present; it's a string rather than a typed object in Phase 1 and
// will join the typed-error taxonomy in Phase 2 (ROADMAP §4.44).
let degraded = context.config_load_error.is_some();
let model_source = provenance.map(|p| p.source.as_str());
let model_raw = provenance.and_then(|p| p.raw.clone());
let allowed_tool_entries = allowed_tools.map(|tools| tools.iter().cloned().collect::<Vec<_>>());
serde_json::json!({
"kind": "status",
"status": if degraded { "degraded" } else { "ok" },
"config_load_error": context.config_load_error,
"model": model,
"model_source": model_source,
"model_raw": model_raw,
"permission_mode": permission_mode,
"allowed_tools": {
"source": if allowed_tools.is_some() { "flag" } else { "default" },
"restricted": allowed_tools.is_some(),
"entries": allowed_tool_entries,
},
"usage": {
"messages": usage.message_count,
"turns": usage.turns,
"latest_total": usage.latest.total_tokens(),
"cumulative_input": usage.cumulative.input_tokens,
"cumulative_output": usage.cumulative.output_tokens,
"cumulative_total": usage.cumulative.total_tokens(),
"estimated_tokens": usage.estimated_tokens,
},
"workspace": {
"cwd": context.cwd,
"project_root": context.project_root,
"git_branch": context.git_branch,
"git_state": context.git_summary.headline(),
"changed_files": context.git_summary.changed_files,
"staged_files": context.git_summary.staged_files,
"unstaged_files": context.git_summary.unstaged_files,
"untracked_files": context.git_summary.untracked_files,
"session": context.session_path.as_ref().map_or_else(|| "live-repl".to_string(), |path| path.display().to_string()),
"session_id": context.session_path.as_ref().and_then(|path| {
// Session files are named <session-id>.jsonl directly under
// .claw/sessions/. Extract the stem (drop the .jsonl extension).
path.file_stem().map(|n| n.to_string_lossy().into_owned())
}),
"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,
},
"sandbox": {
"enabled": context.sandbox_status.enabled,
"active": context.sandbox_status.active,
"supported": context.sandbox_status.supported,
"in_container": context.sandbox_status.in_container,
"requested_namespace": context.sandbox_status.requested.namespace_restrictions,
"active_namespace": context.sandbox_status.namespace_active,
"requested_network": context.sandbox_status.requested.network_isolation,
"active_network": context.sandbox_status.network_active,
"filesystem_mode": context.sandbox_status.filesystem_mode.as_str(),
"filesystem_active": context.sandbox_status.filesystem_active,
"allowed_mounts": context.sandbox_status.allowed_mounts,
"markers": context.sandbox_status.container_markers,
"fallback_reason": context.sandbox_status.fallback_reason,
}
})
}
pub(crate) fn status_context(
session_path: Option<&Path>,
) -> Result<StatusContext, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
let discovered_config_files = loader.discover().len();
// #143: degrade gracefully on config parse failure rather than hard-fail.
// `claw doctor` already does this; `claw status` now matches that contract
// so that one malformed `mcpServers.*` entry doesn't take down the whole
// health surface (workspace, git, model, permission, sandbox can still be
// reported independently).
let (loaded_config_files, sandbox_status, config_load_error) = match loader.load() {
Ok(runtime_config) => (
runtime_config.loaded_entries().len(),
resolve_sandbox_status(runtime_config.sandbox(), &cwd),
None,
),
Err(err) => (
0,
// Fall back to defaults for sandbox resolution so claws still see
// a populated sandbox section instead of a missing field. Defaults
// produce the same output as a runtime config with no sandbox
// overrides, which is the right degraded-mode shape: we cannot
// report what the user *intended*, only what is actually in effect.
resolve_sandbox_status(&runtime::SandboxConfig::default(), &cwd),
Some(err.to_string()),
),
};
let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?;
let (project_root, git_branch) =
parse_git_status_metadata(project_context.git_status.as_deref());
let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref());
let lifecycle = classify_session_lifecycle_for(&cwd);
Ok(StatusContext {
cwd,
session_path: session_path.map(Path::to_path_buf),
loaded_config_files,
discovered_config_files,
memory_file_count: project_context.instruction_files.len(),
project_root,
git_branch,
git_summary,
session_lifecycle: lifecycle,
sandbox_status,
config_load_error,
})
}
pub(crate) fn format_status_report(
model: &str,
usage: StatusUsage,
permission_mode: &str,
context: &StatusContext,
// #148: optional model provenance to surface in a `Model source` line.
// Callers without provenance (legacy resume paths) pass None and the
// source line is omitted for backward compat.
provenance: Option<&ModelProvenance>,
) -> String {
// #143: if config failed to parse, surface a degraded banner at the top
// of the text report so humans see the parse error before the body, while
// the body below still reports everything that could be resolved without
// config (workspace, git, sandbox defaults, etc.).
let status_line = if context.config_load_error.is_some() {
"Status (degraded)"
} else {
"Status"
};
let mut blocks: Vec<String> = Vec::new();
if let Some(err) = context.config_load_error.as_deref() {
blocks.push(format!(
"Config load error\n Status fail\n Summary runtime config failed to load; reporting partial status\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun"
));
}
// #148: render Model source line after Model, showing where the string
// came from (flag / env / config / default) and the raw input if any.
let model_source_line = provenance
.map(|p| match &p.raw {
Some(raw) if raw != model => {
format!("\n Model source {} (raw: {raw})", p.source.as_str())
}
_ => format!("\n Model source {}", p.source.as_str()),
})
.unwrap_or_default();
blocks.extend([
format!(
"{status_line}
Model {model}{model_source_line}
Permission mode {permission_mode}
Messages {}
Turns {}
Estimated tokens {}",
usage.message_count, usage.turns, usage.estimated_tokens,
),
format!(
"Usage
Latest total {}
Cumulative input {}
Cumulative output {}
Cumulative total {}",
usage.latest.total_tokens(),
usage.cumulative.input_tokens,
usage.cumulative.output_tokens,
usage.cumulative.total_tokens(),
),
format!(
"Workspace
Cwd {}
Project root {}
Git branch {}
Git state {}
Changed files {}
Staged {}
Unstaged {}
Untracked {}
Session {}
Lifecycle {}
Config files loaded {}/{}
Memory files {}
Suggested flow /status /diff /commit",
context.cwd.display(),
context
.project_root
.as_ref()
.map_or_else(|| "unknown".to_string(), |path| path.display().to_string()),
context.git_branch.as_deref().unwrap_or("unknown"),
context.git_summary.headline(),
context.git_summary.changed_files,
context.git_summary.staged_files,
context.git_summary.unstaged_files,
context.git_summary.untracked_files,
context.session_path.as_ref().map_or_else(
|| "live-repl".to_string(),
|path| path.display().to_string()
),
context.session_lifecycle.signal(),
context.loaded_config_files,
context.discovered_config_files,
context.memory_file_count,
),
format_sandbox_report(&context.sandbox_status),
]);
blocks.join("\n\n")
}
pub(crate) fn format_sandbox_report(status: &runtime::SandboxStatus) -> String {
format!(
"Sandbox
Enabled {}
Active {}
Supported {}
In container {}
Requested ns {}
Active ns {}
Requested net {}
Active net {}
Filesystem mode {}
Filesystem active {}
Allowed mounts {}
Markers {}
Fallback reason {}",
status.enabled,
status.active,
status.supported,
status.in_container,
status.requested.namespace_restrictions,
status.namespace_active,
status.requested.network_isolation,
status.network_active,
status.filesystem_mode.as_str(),
status.filesystem_active,
if status.allowed_mounts.is_empty() {
"<none>".to_string()
} else {
status.allowed_mounts.join(", ")
},
if status.container_markers.is_empty() {
"<none>".to_string()
} else {
status.container_markers.join(", ")
},
status
.fallback_reason
.clone()
.unwrap_or_else(|| "<none>".to_string()),
)
}
pub(crate) fn format_commit_preflight_report(branch: Option<&str>, summary: GitWorkspaceSummary) -> String {
format!(
"Commit
Result ready
Branch {}
Workspace {}
Changed files {}
Action create a git commit from the current workspace changes",
branch.unwrap_or("unknown"),
summary.headline(),
summary.changed_files,
)
}
pub(crate) fn format_commit_skipped_report() -> String {
"Commit
Result skipped
Reason no workspace changes
Action create a git commit from the current workspace changes
Next /status to inspect context · /diff to inspect repo changes"
.to_string()
}
pub(crate) fn print_sandbox_status_snapshot(
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
let runtime_config = loader
.load()
.unwrap_or_else(|_| runtime::RuntimeConfig::empty());
let status = resolve_sandbox_status(runtime_config.sandbox(), &cwd);
match output_format {
CliOutputFormat::Text => println!("{}", format_sandbox_report(&status)),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&sandbox_json_value(&status))?
),
}
Ok(())
}
pub(crate) fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value {
serde_json::json!({
"kind": "sandbox",
"enabled": status.enabled,
"active": status.active,
"supported": status.supported,
"in_container": status.in_container,
"requested_namespace": status.requested.namespace_restrictions,
"active_namespace": status.namespace_active,
"requested_network": status.requested.network_isolation,
"active_network": status.network_active,
"filesystem_mode": status.filesystem_mode.as_str(),
"filesystem_active": status.filesystem_active,
"allowed_mounts": status.allowed_mounts,
"markers": status.container_markers,
"fallback_reason": status.fallback_reason,
})
}

View File

@ -0,0 +1,219 @@
use std::io;
use std::sync::{Arc, Mutex};
use api::{InputContentBlock, InputMessage, ToolResultContentBlock};
use serde::Deserialize;
use serde_json;
use runtime::{
ContentBlock, ConversationMessage, MessageRole, ToolError, ToolExecutor,
};
use tools::GlobalToolRegistry;
use crate::constants::*;
use crate::format_tool_result;
use crate::render::TerminalRenderer;
use crate::RuntimeMcpState;
#[derive(Deserialize)]
pub(crate) struct ToolSearchRequest {
pub(crate) query: String,
pub(crate) max_results: Option<usize>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct McpToolRequest {
#[serde(rename = "qualifiedName")]
pub(crate) qualified_name: Option<String>,
pub(crate) tool: Option<String>,
pub(crate) arguments: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ListMcpResourcesRequest {
pub(crate) server: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ReadMcpResourceRequest {
pub(crate) server: String,
pub(crate) uri: String,
}
pub(crate) struct CliToolExecutor {
renderer: TerminalRenderer,
emit_output: bool,
allowed_tools: Option<AllowedToolSet>,
tool_registry: GlobalToolRegistry,
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
}
impl CliToolExecutor {
pub(crate) fn new(
allowed_tools: Option<AllowedToolSet>,
emit_output: bool,
tool_registry: GlobalToolRegistry,
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
) -> Self {
Self {
renderer: TerminalRenderer::new(),
emit_output,
allowed_tools,
tool_registry,
mcp_state,
}
}
fn execute_search_tool(&self, value: serde_json::Value) -> Result<String, ToolError> {
let input: ToolSearchRequest = serde_json::from_value(value)
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
let (pending_mcp_servers, mcp_degraded) =
self.mcp_state.as_ref().map_or((None, None), |state| {
let state = state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
(state.pending_servers(), state.degraded_report())
});
serde_json::to_string_pretty(&self.tool_registry.search(
&input.query,
input.max_results.unwrap_or(5),
pending_mcp_servers,
mcp_degraded,
))
.map_err(|error| ToolError::new(error.to_string()))
}
fn execute_runtime_tool(
&self,
tool_name: &str,
value: serde_json::Value,
) -> Result<String, ToolError> {
let Some(mcp_state) = &self.mcp_state else {
return Err(ToolError::new(format!(
"runtime tool `{tool_name}` is unavailable without configured MCP servers"
)));
};
let mut mcp_state = mcp_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
match tool_name {
"MCPTool" => {
let input: McpToolRequest = serde_json::from_value(value)
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
let qualified_name = input
.qualified_name
.or(input.tool)
.ok_or_else(|| ToolError::new("missing required field `qualifiedName`"))?;
mcp_state.call_tool(&qualified_name, input.arguments)
}
"ListMcpResourcesTool" => {
let input: ListMcpResourcesRequest = serde_json::from_value(value)
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
match input.server {
Some(server_name) => mcp_state.list_resources_for_server(&server_name),
None => mcp_state.list_resources_for_all_servers(),
}
}
"ReadMcpResourceTool" => {
let input: ReadMcpResourceRequest = serde_json::from_value(value)
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
mcp_state.read_resource(&input.server, &input.uri)
}
_ => mcp_state.call_tool(tool_name, Some(value)),
}
}
}
impl ToolExecutor for CliToolExecutor {
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
if self
.allowed_tools
.as_ref()
.is_some_and(|allowed| !allowed.contains(tool_name))
{
return Err(ToolError::new(format!(
"tool `{tool_name}` is not enabled by the current --allowedTools setting"
)));
}
let value = serde_json::from_str(input)
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
let result = if tool_name == "ToolSearch" {
self.execute_search_tool(value)
} else if self.tool_registry.has_runtime_tool(tool_name) {
self.execute_runtime_tool(tool_name, value)
} else {
self.tool_registry
.execute(tool_name, &value)
.map_err(ToolError::new)
};
match result {
Ok(output) => {
if self.emit_output {
let markdown = format_tool_result(tool_name, &output, false);
self.renderer
.stream_markdown(&markdown, &mut io::stdout())
.map_err(|error| ToolError::new(error.to_string()))?;
}
Ok(output)
}
Err(error) => {
if self.emit_output {
let markdown = format_tool_result(tool_name, &error.to_string(), true);
self.renderer
.stream_markdown(&markdown, &mut io::stdout())
.map_err(|stream_error| ToolError::new(stream_error.to_string()))?;
}
Err(error)
}
}
}
}
pub(crate) fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
messages
.iter()
.filter_map(|message| {
let role = match message.role {
MessageRole::System | MessageRole::User | MessageRole::Tool => "user",
MessageRole::Assistant => "assistant",
};
let content = message
.blocks
.iter()
.map(|block| match block {
ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
ContentBlock::Thinking {
thinking,
signature,
} => InputContentBlock::Thinking {
thinking: thinking.clone(),
signature: signature.clone(),
},
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
input: serde_json::from_str(input)
.unwrap_or_else(|_| serde_json::json!({ "raw": input })),
},
ContentBlock::ToolResult {
tool_use_id,
output,
is_error,
..
} => InputContentBlock::ToolResult {
tool_use_id: tool_use_id.clone(),
content: vec![ToolResultContentBlock::Text {
text: output.clone(),
}],
is_error: *is_error,
},
})
.collect::<Vec<_>>();
(!content.is_empty()).then(|| InputMessage {
role: role.to_string(),
content,
})
})
.collect()
}