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:
parent
3810cbb5e4
commit
cf81b904b0
9
debug.sh
9
debug.sh
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-05-26
|
||||
|
|
@ -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 150–1200 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.
|
||||
|
|
@ -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. |
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
];
|
||||
|
|
@ -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
|
|
@ -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}")
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
))
|
||||
}
|
||||
|
|
@ -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()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue