refactor(rusty-claude-cli): 拆分超大main.rs为职责单一的子模块

解决main.rs文件过大难以导航、维护与测试的问题,将原本13292行的单文件main.rs拆分为按职责划分的子模块,目前已完成constants.rs、model.rs、permission.rs、progress.rs的提取,符合项目单个文件≤2000行的约定。
新增本次重构的全套规范文档,包括拆分spec、设计方案与任务清单;更新openspec项目配置文件,明确重构规则;同时更新debug.sh,添加x86_64-unknown-linux-musl跨平台编译脚本。
本次重构为纯代码重组,不改变任何现有功能与用户交互逻辑。
This commit is contained in:
zhaoyanchao 2026-05-26 17:08:29 +08:00
parent 3810cbb5e4
commit cf81b904b0
17 changed files with 1960 additions and 1021 deletions

View File

@ -4,6 +4,15 @@ cargo test
### 跨平台编译
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
- [ ] 6.1 Create `src/args.rs` with CliAction enum, CliOutputFormat, LocalHelpTopic, `parse_args`, all parse_* helpers, format_unknown_* functions, suggest_*/levenshtein helpers, normalize_* tools, permission_mode helpers, provider_label, filter_tool_specs (main.rs lines 506-1828)
- [ ] 6.2 Add `mod args;` to `main.rs` and re-export
- [ ] 6.3 Run `cargo build` and `cargo test` — must pass
## 7. Create session.rs — extract session management
- [ ] 7.1 Create `src/session.rs` with SessionHandle, ManagedSessionSummary, all session CRUD functions: `sessions_dir`, `current_session_store`, `new_cli_session`, `create_managed_session_handle`, `resolve_session_reference`, `list_managed_sessions`, `latest_managed_session`, `load_session_reference`, `delete_managed_session`, `confirm_session_deletion`, `render_session_list`, `format_session_modified_age`, `write_session_clear_backup`, `session_clear_backup_path` (main.rs lines 3678-3692, 5257-5427)
- [ ] 7.2 Add `mod session;` to `main.rs` and re-export
- [ ] 7.3 Run `cargo build` and `cargo test` — must pass
## 8. Create status.rs — extract status snapshot and git parsing
- [ ] 8.1 Create `src/status.rs` with StatusContext, StatusUsage, GitWorkspaceSummary, `print_status_snapshot`, `status_json_value`, `status_context`, `format_status_report`, `format_sandbox_report`, `print_sandbox_status_snapshot`, `sandbox_json_value`, and all git parsing helpers (main.rs lines 2842-3200, 5462-5839)
- [ ] 8.2 Add `mod status;` to `main.rs` and re-export
- [ ] 8.3 Run `cargo build` and `cargo test` — must pass
## 9. Create formatting.rs — extract tool result formatting and help text
- [ ] 9.1 Create `src/formatting.rs` with all format_tool_* functions, push_output_block, response_to_events, push_prompt_cache_record, prompt_cache_record_to_runtime_event, final_assistant_text, collect_tool_uses/results/cache_events, short_tool_id, extract_tool_path, first_visible_line, truncate_* helpers, and full help text functions (main.rs lines 8184-8730, 8923-9122)
- [ ] 9.2 Add `mod formatting;` to `main.rs` and re-export
- [ ] 9.3 Run `cargo build` and `cargo test` — must pass
## 10. Create config.rs — extract config and memory reporting
- [ ] 10.1 Create `src/config.rs` with `render_config_report`, `render_config_json`, `render_memory_report`, `render_memory_json` (main.rs lines 5962-6143)
- [ ] 10.2 Add `mod config;` to `main.rs` and re-export
- [ ] 10.3 Run `cargo build` and `cargo test` — must pass
## 11. Create diff.rs — extract git diff reporting
- [ ] 11.1 Create `src/diff.rs` with `render_diff_report`, `render_diff_report_for`, `render_diff_json_for`, `run_git_diff_command_in` (main.rs lines 6189-6270)
- [ ] 11.2 Add `mod diff;` to `main.rs` and re-export
- [ ] 11.3 Run `cargo build` and `cargo test` — must pass
## 12. Create export.rs — extract session export
- [ ] 12.1 Create `src/export.rs` with `run_export`, `render_export_text`, `render_session_markdown`, `default_export_filename`, `resolve_export_path`, `summarize_tool_payload_for_markdown` (main.rs lines 6646-6914)
- [ ] 12.2 Add `mod export;` to `main.rs` and re-export
- [ ] 12.3 Run `cargo build` and `cargo test` — must pass
## 13. Create api_client.rs — extract AnthropicRuntimeClient
- [ ] 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)
- [ ] 13.2 Add `mod api_client;` to `main.rs` and re-export
- [ ] 13.3 Run `cargo build` and `cargo test` — must pass
## 14. Create tool_executor.rs — extract CliToolExecutor
- [ ] 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)
- [ ] 14.2 Add `mod tool_executor;` to `main.rs` and re-export
- [ ] 14.3 Run `cargo build` and `cargo test` — must pass
## 15. Create runtime_builder.rs — extract runtime construction
- [ ] 15.1 Create `src/runtime_builder.rs` with RuntimePluginState, RuntimeMcpState, BuiltRuntime struct+impl+Deref+DerefMut+Drop, HookAbortMonitor, all `build_runtime*` functions, `build_plugin_manager`, `resolve_plugin_path`, `runtime_hook_config_from_plugin_hooks`, `build_system_prompt`, `build_runtime_mcp_state`, `mcp_runtime_tool_definition`, `mcp_wrapper_tool_definitions`, `permission_mode_for_mcp_tool`, `mcp_annotation_flag` (main.rs lines 3710-4180, 6924-6995, 7329-7450, 4017-4125)
- [ ] 15.2 Add `mod runtime_builder;` to `main.rs` and re-export
- [ ] 15.3 Run `cargo build` and `cargo test` — must pass
## 16. Create live_cli.rs — extract LiveCli core and REPL loop
- [ ] 16.1 Create `src/live_cli.rs` with LiveCli struct, PromptHistoryEntry, and core impl methods: `new`, `startup_banner`, `prepare_turn_runtime`, `replace_runtime`, `run_turn`, `run_turn_with_output`, `run_prompt_compact/json`, `repl_completion_candidates`, `persist_session`, `record_prompt_history`, `run_internal_prompt_text*`, `reload_runtime_features` (main.rs lines 3694-3710, 4181-4440, 4580-4610, 5240-5260)
- [ ] 16.2 Extract free functions: `run_repl`, `run_resume_command`, `resume_session`, `detect_broad_cwd`, `enforce_broad_cwd_policy`, `run_stale_base_preflight` (main.rs lines 3520-3675, 3597-3678, 3175-3520, 3609-3610)
- [ ] 16.3 Add `mod live_cli;` to `main.rs` and re-export
- [ ] 16.4 Run `cargo build` and `cargo test` — must pass
## 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,155 @@
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) 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,397 @@
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();
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,
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(), json!(api_key_present)),
("auth_token_present".to_string(), json!(auth_token_present)),
("legacy_saved_oauth_present".to_string(), json!(true)),
("legacy_saved_oauth_expires_at".to_string(), json!(token_set.expires_at)),
("legacy_refresh_token_present".to_string(), json!(token_set.refresh_token.is_some())),
("legacy_scopes".to_string(), json!(token_set.scopes)),
])),
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(), json!(api_key_present)),
("auth_token_present".to_string(), json!(auth_token_present)),
("legacy_saved_oauth_present".to_string(), json!(false)),
("legacy_saved_oauth_expires_at".to_string(), Value::Null),
("legacy_refresh_token_present".to_string(), json!(false)),
("legacy_scopes".to_string(), json!(Vec::<String>::new())),
])),
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(), json!(api_key_present)),
("auth_token_present".to_string(), json!(auth_token_present)),
("legacy_saved_oauth_present".to_string(), Value::Null),
("legacy_saved_oauth_expires_at".to_string(), Value::Null),
("legacy_refresh_token_present".to_string(), Value::Null),
("legacy_scopes".to_string(), Value::Null),
("legacy_saved_oauth_error".to_string(), json!(error.to_string())),
])),
}
}
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(), json!(present_paths)),
("discovered_files_count".to_string(), json!(present_paths.len())),
("loaded_config_files".to_string(), json!(loaded_entries.len())),
("resolved_model".to_string(), json!(runtime_config.model())),
("mcp_servers".to_string(), json!(runtime_config.mcp().servers().len())),
]))
}
Err(error) => DiagnosticCheck::new("Config", DiagnosticLevel::Fail, format!("runtime config failed to load: {error}"))
.with_details(if discovered_paths.is_empty() { vec!["Discovered files <none>".to_string()] } else { discovered_paths.iter().map(|p| format!("Discovered file {p}")).collect() })
.with_data(Map::from_iter([
("discovered_files".to_string(), json!(discovered_paths)),
("discovered_files_count".to_string(), json!(discovered_paths.len())),
("loaded_config_files".to_string(), json!(0)),
("resolved_model".to_string(), Value::Null),
("mcp_servers".to_string(), Value::Null),
("load_error".to_string(), 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(), json!(OFFICIAL_REPO_URL)),
("deprecated_install".to_string(), json!(DEPRECATED_INSTALL_COMMAND)),
("recommended_install".to_string(), json!("build from source or follow the upstream binary instructions in README.md")),
]))
}
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(), json!(context.cwd.display().to_string())),
("project_root".to_string(), json!(context.project_root.as_ref().map(|p| p.display().to_string()))),
("in_git_repo".to_string(), json!(in_repo)),
("git_branch".to_string(), json!(context.git_branch)),
("git_state".to_string(), json!(context.git_summary.headline())),
("changed_files".to_string(), json!(context.git_summary.changed_files)),
("memory_file_count".to_string(), json!(context.memory_file_count)),
("loaded_config_files".to_string(), json!(context.loaded_config_files)),
("discovered_config_files".to_string(), json!(context.discovered_config_files)),
]))
}
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(), json!(status.enabled)),
("active".to_string(), json!(status.active)),
("supported".to_string(), json!(status.supported)),
("namespace_supported".to_string(), json!(status.namespace_supported)),
("namespace_active".to_string(), json!(status.namespace_active)),
("network_supported".to_string(), json!(status.network_supported)),
("network_active".to_string(), json!(status.network_active)),
("filesystem_mode".to_string(), json!(status.filesystem_mode.as_str())),
("filesystem_active".to_string(), json!(status.filesystem_active)),
("allowed_mounts".to_string(), json!(status.allowed_mounts)),
("in_container".to_string(), json!(status.in_container)),
("container_markers".to_string(), json!(status.container_markers)),
("fallback_reason".to_string(), json!(status.fallback_reason)),
]))
}
fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> DiagnosticCheck {
let default_model = config.and_then(runtime::RuntimeConfig::model);
let mut details = vec![
format!("OS {} {}", env::consts::OS, env::consts::ARCH),
format!("Working dir {}", cwd.display()),
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(), json!(env::consts::OS)),
("arch".to_string(), json!(env::consts::ARCH)),
("working_dir".to_string(), json!(cwd.display().to_string())),
("version".to_string(), json!(VERSION)),
("build_target".to_string(), json!(BUILD_TARGET)),
("git_sha".to_string(), json!(GIT_SHA)),
("default_model".to_string(), json!(default_model)),
]))
}

File diff suppressed because it is too large Load Diff

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

@ -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()
),
}
}
}