From cf81b904b0970b100e833683072ea5adfa05c9cd Mon Sep 17 00:00:00 2001 From: zhaoyanchao Date: Tue, 26 May 2026 17:08:29 +0800 Subject: [PATCH] =?UTF-8?q?refactor(rusty-claude-cli):=20=E6=8B=86?= =?UTF-8?q?=E5=88=86=E8=B6=85=E5=A4=A7main.rs=E4=B8=BA=E8=81=8C=E8=B4=A3?= =?UTF-8?q?=E5=8D=95=E4=B8=80=E7=9A=84=E5=AD=90=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 解决main.rs文件过大难以导航、维护与测试的问题,将原本13292行的单文件main.rs拆分为按职责划分的子模块,目前已完成constants.rs、model.rs、permission.rs、progress.rs的提取,符合项目单个文件≤2000行的约定。 新增本次重构的全套规范文档,包括拆分spec、设计方案与任务清单;更新openspec项目配置文件,明确重构规则;同时更新debug.sh,添加x86_64-unknown-linux-musl跨平台编译脚本。 本次重构为纯代码重组,不改变任何现有功能与用户交互逻辑。 --- debug.sh | 9 + .../refactor-main-rs-split/.openspec.yaml | 2 + .../changes/refactor-main-rs-split/design.md | 209 ++++ .../refactor-main-rs-split/proposal.md | 68 ++ .../specs/cli-args-parsing/spec.md | 45 + .../specs/cli-output-formatting/spec.md | 70 ++ .../specs/cli-repl-interaction/spec.md | 90 ++ .../specs/cli-runtime-construction/spec.md | 69 ++ .../specs/cli-session-management/spec.md | 45 + .../changes/refactor-main-rs-split/tasks.md | 114 ++ openspec/config.yaml | 37 +- rust/crates/rusty-claude-cli/src/constants.rs | 155 +++ rust/crates/rusty-claude-cli/src/doctor.rs | 397 +++++++ rust/crates/rusty-claude-cli/src/main.rs | 1042 +---------------- rust/crates/rusty-claude-cli/src/model.rs | 195 +++ .../crates/rusty-claude-cli/src/permission.rs | 66 ++ rust/crates/rusty-claude-cli/src/progress.rs | 368 ++++++ 17 files changed, 1960 insertions(+), 1021 deletions(-) create mode 100644 openspec/changes/refactor-main-rs-split/.openspec.yaml create mode 100644 openspec/changes/refactor-main-rs-split/design.md create mode 100644 openspec/changes/refactor-main-rs-split/proposal.md create mode 100644 openspec/changes/refactor-main-rs-split/specs/cli-args-parsing/spec.md create mode 100644 openspec/changes/refactor-main-rs-split/specs/cli-output-formatting/spec.md create mode 100644 openspec/changes/refactor-main-rs-split/specs/cli-repl-interaction/spec.md create mode 100644 openspec/changes/refactor-main-rs-split/specs/cli-runtime-construction/spec.md create mode 100644 openspec/changes/refactor-main-rs-split/specs/cli-session-management/spec.md create mode 100644 openspec/changes/refactor-main-rs-split/tasks.md create mode 100644 rust/crates/rusty-claude-cli/src/constants.rs create mode 100644 rust/crates/rusty-claude-cli/src/doctor.rs create mode 100644 rust/crates/rusty-claude-cli/src/model.rs create mode 100644 rust/crates/rusty-claude-cli/src/permission.rs create mode 100644 rust/crates/rusty-claude-cli/src/progress.rs diff --git a/debug.sh b/debug.sh index 3b0913bc..cb7b3720 100644 --- a/debug.sh +++ b/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 diff --git a/openspec/changes/refactor-main-rs-split/.openspec.yaml b/openspec/changes/refactor-main-rs-split/.openspec.yaml new file mode 100644 index 00000000..fd706ac4 --- /dev/null +++ b/openspec/changes/refactor-main-rs-split/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-26 diff --git a/openspec/changes/refactor-main-rs-split/design.md b/openspec/changes/refactor-main-rs-split/design.md new file mode 100644 index 00000000..6360dd70 --- /dev/null +++ b/openspec/changes/refactor-main-rs-split/design.md @@ -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> { ... } + +pub(crate) fn set_model( + cli: &mut LiveCli, + model: Option, +) -> Result> { ... } +``` + +### 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. diff --git a/openspec/changes/refactor-main-rs-split/proposal.md b/openspec/changes/refactor-main-rs-split/proposal.md new file mode 100644 index 00000000..a38affb9 --- /dev/null +++ b/openspec/changes/refactor-main-rs-split/proposal.md @@ -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. | diff --git a/openspec/changes/refactor-main-rs-split/specs/cli-args-parsing/spec.md b/openspec/changes/refactor-main-rs-split/specs/cli-args-parsing/spec.md new file mode 100644 index 00000000..603eb627 --- /dev/null +++ b/openspec/changes/refactor-main-rs-split/specs/cli-args-parsing/spec.md @@ -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` +- **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 diff --git a/openspec/changes/refactor-main-rs-split/specs/cli-output-formatting/spec.md b/openspec/changes/refactor-main-rs-split/specs/cli-output-formatting/spec.md new file mode 100644 index 00000000..0d7566ff --- /dev/null +++ b/openspec/changes/refactor-main-rs-split/specs/cli-output-formatting/spec.md @@ -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 diff --git a/openspec/changes/refactor-main-rs-split/specs/cli-repl-interaction/spec.md b/openspec/changes/refactor-main-rs-split/specs/cli-repl-interaction/spec.md new file mode 100644 index 00000000..f516366a --- /dev/null +++ b/openspec/changes/refactor-main-rs-split/specs/cli-repl-interaction/spec.md @@ -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>` + +#### 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 diff --git a/openspec/changes/refactor-main-rs-split/specs/cli-runtime-construction/spec.md b/openspec/changes/refactor-main-rs-split/specs/cli-runtime-construction/spec.md new file mode 100644 index 00000000..53fbd3cf --- /dev/null +++ b/openspec/changes/refactor-main-rs-split/specs/cli-runtime-construction/spec.md @@ -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 diff --git a/openspec/changes/refactor-main-rs-split/specs/cli-session-management/spec.md b/openspec/changes/refactor-main-rs-split/specs/cli-session-management/spec.md new file mode 100644 index 00000000..160f26f1 --- /dev/null +++ b/openspec/changes/refactor-main-rs-split/specs/cli-session-management/spec.md @@ -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 diff --git a/openspec/changes/refactor-main-rs-split/tasks.md b/openspec/changes/refactor-main-rs-split/tasks.md new file mode 100644 index 00000000..be193f05 --- /dev/null +++ b/openspec/changes/refactor-main-rs-split/tasks.md @@ -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 diff --git a/openspec/config.yaml b/openspec/config.yaml index 392946c6..90a62cee 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -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 diff --git a/rust/crates/rusty-claude-cli/src/constants.rs b/rust/crates/rusty-claude-cli/src/constants.rs new file mode 100644 index 00000000..9a4afd94 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/constants.rs @@ -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; + +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", +]; diff --git a/rust/crates/rusty-claude-cli/src/doctor.rs b/rust/crates/rusty-claude-cli/src/doctor.rs new file mode 100644 index 00000000..4e0daeff --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/doctor.rs @@ -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, + pub(crate) data: Map, +} + +impl DiagnosticCheck { + pub(crate) fn new(name: &'static str, level: DiagnosticLevel, summary: impl Into) -> Self { + Self { + name, + level, + summary: summary.into(), + details: Vec::new(), + data: Map::new(), + } + } + + pub(crate) fn with_details(mut self, details: Vec) -> Self { + self.details = details; + self + } + + pub(crate) fn with_data(mut self, data: Map) -> 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, +} + +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::>(), + }) + } +} + +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> { + 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> { + 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(|| "".to_string(), |v| v.to_string()), + if token_set.refresh_token.is_some() { "present" } else { "absent" }, + if token_set.scopes.is_empty() { "".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::::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 = discovered.iter().filter(|e| e.path.exists()).map(|e| e.path.display().to_string()).collect(); + let discovered_paths: Vec = 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 (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 ".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(|| "".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("")), + format!("Git SHA {}", GIT_SHA.unwrap_or("")), + ]; + 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)), + ])) +} diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 903cd2aa..28eecfe1 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -9,10 +9,20 @@ clippy::doc_markdown, clippy::result_large_err )] +mod constants; +mod doctor; mod init; mod input; +mod model; +mod permission; +mod progress; mod render; +use constants::*; +use doctor::*; +use model::*; +use permission::*; +use progress::*; use std::collections::BTreeSet; use std::env; use std::fs; @@ -59,144 +69,7 @@ use tools::{ execute_tool, mvp_tool_specs, GlobalToolRegistry, RuntimeToolDefinition, ToolSearchOutput, }; -const DEFAULT_MODEL: &str = "claude-opus-4-6"; -/// #148: Model provenance for `claw status` JSON/text output. Records where -/// the resolved model string came from so claws don't have to re-read argv -/// to audit whether their `--model` flag was honored vs falling back to env -/// or config or default. -#[derive(Debug, Clone, PartialEq, Eq)] -enum ModelSource { - /// Explicit `--model` / `--model=` CLI flag. - Flag, - /// ANTHROPIC_MODEL environment variable (when no flag was passed). - Env, - /// `model` key in `.claw.json` / `.claw/settings.json` (when neither - /// flag nor env set it). - Config, - /// Compiled-in DEFAULT_MODEL fallback. - Default, -} - -impl ModelSource { - 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)] -struct ModelProvenance { - /// Resolved model string (after alias expansion). - resolved: String, - /// Raw user input before alias resolution. None when source is Default. - raw: Option, - /// Where the resolved model string originated. - source: ModelSource, -} - -impl ModelProvenance { - fn default_fallback() -> Self { - Self { - resolved: DEFAULT_MODEL.to_string(), - raw: None, - source: ModelSource::Default, - } - } - - fn from_flag(raw: &str) -> Self { - Self { - resolved: resolve_model_alias_with_config(raw), - raw: Some(raw.to_string()), - source: ModelSource::Flag, - } - } - - fn from_env_or_config_or_default(cli_model: &str) -> Self { - // Only called when no --model flag was passed. Probe env first, - // then config, else fall back to default. Mirrors the logic in - // resolve_repl_model() but captures the source. - if cli_model != DEFAULT_MODEL { - // Already resolved from some prior path; treat as flag. - return Self { - resolved: cli_model.to_string(), - raw: Some(cli_model.to_string()), - source: ModelSource::Flag, - }; - } - if let Some(env_model) = env::var("ANTHROPIC_MODEL") - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - { - return Self { - resolved: resolve_model_alias_with_config(&env_model), - raw: Some(env_model), - source: ModelSource::Env, - }; - } - if let Some(config_model) = config_model_for_current_dir() { - return Self { - resolved: resolve_model_alias_with_config(&config_model), - raw: Some(config_model), - source: ModelSource::Config, - }; - } - Self::default_fallback() - } -} - -fn max_tokens_for_model(model: &str) -> u32 { - if model.contains("opus") { - 32_000 - } else { - 64_000 - } -} -// Build-time constants injected by build.rs (fall back to static values when -// build.rs hasn't run, e.g. in doc-test or unusual toolchain environments). -const DEFAULT_DATE: &str = match option_env!("BUILD_DATE") { - Some(d) => d, - None => "unknown", -}; -const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545; -const VERSION: &str = env!("CARGO_PKG_VERSION"); -const BUILD_TARGET: Option<&str> = option_env!("TARGET"); -const GIT_SHA: Option<&str> = option_env!("GIT_SHA"); -const INTERNAL_PROGRESS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(3); -const POST_TOOL_STALL_TIMEOUT: Duration = Duration::from_secs(10); -const PRIMARY_SESSION_EXTENSION: &str = "jsonl"; -const LEGACY_SESSION_EXTENSION: &str = "json"; -const OFFICIAL_REPO_URL: &str = "https://github.com/ultraworkers/claw-code"; -const OFFICIAL_REPO_SLUG: &str = "ultraworkers/claw-code"; -const DEPRECATED_INSTALL_COMMAND: &str = "cargo install claw-code"; -const LATEST_SESSION_REFERENCE: &str = "latest"; -const SESSION_REFERENCE_ALIASES: &[&str] = &[LATEST_SESSION_REFERENCE, "last", "recent"]; -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", -]; - -type AllowedToolSet = BTreeSet; type RuntimePluginStateBuildOutput = ( Option>>, Vec, @@ -204,6 +77,7 @@ type RuntimePluginStateBuildOutput = ( fn main() { if let Err(error) = run() { + // 出错逻辑 let message = format!( "{:?}\n\nStack trace:\n{}", error, @@ -244,7 +118,6 @@ error: {message}" eprintln!( "[error-kind: {kind}] error: {message} - Run `claw --help` for usage." ); } @@ -343,13 +216,19 @@ fn merge_prompt_with_stdin(prompt: &str, stdin_content: Option<&str>) -> String fn run() -> Result<(), Box> { let args: Vec = env::args().skip(1).collect(); + // 添加打印args 的日志 + println!("args: {:?}", args); match parse_args(&args)? { + // 导出所有工具/插件的 manifest 清单文件到指定目录 CliAction::DumpManifests { output_format, manifests_dir, } => dump_manifests(manifests_dir.as_deref(), output_format)?, + + // 打印启动引导计划(初始化阶段的工作流规划) CliAction::BootstrapPlan { output_format } => print_bootstrap_plan(output_format)?, + // 查看可用的 Agent 信息列表 CliAction::Agents { args, output_format, @@ -362,22 +241,27 @@ fn run() -> Result<(), Box> { args, output_format, } => LiveCli::print_skills(args.as_deref(), output_format)?, + // 查看和管理插件列表(安装/卸载/详情) CliAction::Plugins { action, target, output_format, } => LiveCli::print_plugins(action.as_deref(), target.as_deref(), output_format)?, + // 打印当前会话的系统提示词完整内容,用于调试和审查 CliAction::PrintSystemPrompt { cwd, date, output_format, } => print_system_prompt(cwd, date, output_format)?, + // 输出版本号 CliAction::Version { output_format } => print_version(output_format)?, + // 恢复一个已保存的历史会话,可追加命令继续执行 CliAction::ResumeSession { session_path, commands, output_format, } => resume_session(&session_path, &commands, output_format), + // 输出当前工作区状态快照(模型、权限、分支、工具白名单等) CliAction::Status { model, model_flag_raw, @@ -391,7 +275,9 @@ fn run() -> Result<(), Box> { output_format, allowed_tools.as_ref(), )?, + // 输出沙箱隔离环境的状态信息 CliAction::Sandbox { output_format } => print_sandbox_status_snapshot(output_format)?, + // 单次提示模式:执行一轮对话后退出,适用于脚本/管道调用 CliAction::Prompt { prompt, model, @@ -405,6 +291,7 @@ fn run() -> Result<(), Box> { } => { enforce_broad_cwd_policy(allow_broad_cwd, output_format)?; run_stale_base_preflight(base_commit.as_deref()); + // Only consume piped stdin as prompt context when the permission // mode is fully unattended. In modes where the permission // prompter may invoke CliPermissionPrompter::decide(), stdin @@ -421,13 +308,15 @@ fn run() -> Result<(), Box> { cli.set_reasoning_effort(reasoning_effort); cli.run_turn_with_output(&effective_prompt, output_format, compact)?; } + // 环境诊断检查,验证配置和依赖是否正常 CliAction::Doctor { output_format } => run_doctor(output_format)?, + // 输出 Agent Control Protocol 状态信息 CliAction::Acp { output_format } => print_acp_status(output_format)?, + // 输出后台 worker 的运行状态 CliAction::State { output_format } => run_worker_state(output_format)?, + // 初始化工作区配置(生成 .claw.json 等) CliAction::Init { output_format } => run_init(output_format)?, - // #146: dispatch pure-local introspection. Text mode uses existing - // render_config_report/render_diff_report; JSON mode uses the - // corresponding _json helpers already exposed for resume sessions. + // 查看当前生效的配置信息(可指定 section 过滤),纯本地只读操作 CliAction::Config { section, output_format, @@ -442,6 +331,7 @@ fn run() -> Result<(), Box> { ); } }, + // 查看 git 工作区变更差异摘要(未提交的改动),纯本地只读操作 CliAction::Diff { output_format } => match output_format { CliOutputFormat::Text => { println!("{}", render_diff_report()?); @@ -454,12 +344,14 @@ fn run() -> Result<(), Box> { ); } }, + // 导出指定会话记录到文件(支持 JSON/Markdown 等格式) CliAction::Export { session_reference, output_path, output_format, } => run_export(&session_reference, output_path.as_deref(), output_format)?, + // 交互式 REPL 模式:进入持续对话循环,直到用户主动退出 CliAction::Repl { model, allowed_tools, @@ -475,7 +367,9 @@ fn run() -> Result<(), Box> { reasoning_effort, allow_broad_cwd, )?, + // 打印特定子命令的帮助详情(如 claw status --help) CliAction::HelpTopic(topic) => print_help_topic(topic), + // 打印通用帮助信息 CliAction::Help { output_format } => print_help(output_format)?, } Ok(()) @@ -622,10 +516,13 @@ impl CliOutputFormat { #[allow(clippy::too_many_lines)] fn parse_args(args: &[String]) -> Result { + let mut model = DEFAULT_MODEL.to_string(); + // #148: when user passes --model/--model=, capture the raw input so we // can attribute source: "flag" later. None means no flag was supplied. let mut model_flag_raw: Option = None; + let mut output_format = CliOutputFormat::Text; let mut permission_mode_override = None; let mut wants_help = false; @@ -1437,89 +1334,7 @@ fn levenshtein_distance(left: &str, right: &str) -> usize { previous[right_chars.len()] } -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, - } -} -/// Resolve a model name through user-defined config aliases first, then fall -/// back to the built-in alias table. This is the entry point used wherever a -/// user-supplied model string is about to be dispatched to a provider. -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() -} - -/// Validate model syntax at parse time. -/// Accepts: known aliases (opus, sonnet, haiku) or provider/model pattern. -/// Rejects: empty, whitespace-only, strings with spaces, or invalid chars. -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()); - } - // Known aliases are always valid - match trimmed { - "opus" | "sonnet" | "haiku" | "grok" | "grok-2" | "grok-3" | "grok-mini" - | "grok-3-mini" | "kimi" | "glm-5" => return Ok(()), - _ => {} - } - - // Dashscope and xAI models that don't use provider/model syntax - if trimmed.starts_with("qwen-") - || trimmed.starts_with("ali-") - || trimmed.starts_with("glm-") - || trimmed.starts_with("kimi-") - { - return Ok(()); - } - // Check for spaces (malformed) - if trimmed.contains(' ') { - return Err(format!( - "invalid model syntax: '{trimmed}' contains spaces. Use provider/model format or known alias" - )); - } - // Check provider/model format: provider_id/model_id - let parts: Vec<&str> = trimmed.split('/').collect(); - if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { - // #154: hint if the model looks like it belongs to a different provider - 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(()) -} - -fn config_alias_for_current_dir(alias: &str) -> Option { - 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() -} fn normalize_allowed_tools(values: &[String]) -> Result, String> { if values.is_empty() { @@ -1592,41 +1407,7 @@ fn config_permission_mode_for_current_dir() -> Option { .map(permission_mode_from_resolved) } -fn config_model_for_current_dir() -> Option { - let cwd = env::current_dir().ok()?; - let loader = ConfigLoader::default_for(&cwd); - loader.load().ok()?.model().map(ToOwned::to_owned) -} -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 -} - -fn provider_label(kind: ProviderKind) -> &'static str { - match kind { - ProviderKind::Anthropic => "anthropic", - ProviderKind::Xai => "xai", - ProviderKind::OpenAi => "openai", - } -} - -fn format_connected_line(model: &str) -> String { - let provider = provider_label(detect_provider_kind(model)); - format!("Connected: {model} via {provider}") -} fn filter_tool_specs( tool_registry: &GlobalToolRegistry, @@ -1803,215 +1584,6 @@ fn parse_resume_args(args: &[String], output_format: CliOutputFormat) -> Result< }) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum DiagnosticLevel { - Ok, - Warn, - Fail, -} - -impl DiagnosticLevel { - fn label(self) -> &'static str { - match self { - Self::Ok => "ok", - Self::Warn => "warn", - Self::Fail => "fail", - } - } - - fn is_failure(self) -> bool { - matches!(self, Self::Fail) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct DiagnosticCheck { - name: &'static str, - level: DiagnosticLevel, - summary: String, - details: Vec, - data: Map, -} - -impl DiagnosticCheck { - fn new(name: &'static str, level: DiagnosticLevel, summary: impl Into) -> Self { - Self { - name, - level, - summary: summary.into(), - details: Vec::new(), - data: Map::new(), - } - } - - fn with_details(mut self, details: Vec) -> Self { - self.details = details; - self - } - - fn with_data(mut self, data: Map) -> Self { - self.data = data; - self - } - - 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)] -struct DoctorReport { - checks: Vec, -} - -impl DoctorReport { - fn counts(&self) -> (usize, usize, usize) { - ( - self.checks - .iter() - .filter(|check| check.level == DiagnosticLevel::Ok) - .count(), - self.checks - .iter() - .filter(|check| check.level == DiagnosticLevel::Warn) - .count(), - self.checks - .iter() - .filter(|check| check.level == DiagnosticLevel::Fail) - .count(), - ) - } - - fn has_failures(&self) -> bool { - self.checks.iter().any(|check| check.level.is_failure()) - } - - 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") - } - - 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::>(), - }) - } -} - -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(|detail| format!(" - {detail}"))); - } - lines.join("\n") -} - -fn render_doctor_report() -> Result> { - 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, |runtime_config| runtime_config.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), - // Doctor path has its own config check; StatusContext here is only - // fed into health renderers that don't read config_load_error. - 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()), - ], - }) -} - -fn run_doctor(output_format: CliOutputFormat) -> Result<(), Box> { - 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(()) -} - /// Starts a minimal Model Context Protocol server that exposes claw's /// built-in tools over stdio. /// @@ -6972,334 +6544,6 @@ fn runtime_hook_config_from_plugin_hooks(hooks: PluginHooks) -> runtime::Runtime ) } -#[derive(Debug, Clone, PartialEq, Eq)] -struct InternalPromptProgressState { - command_label: &'static str, - task_label: String, - step: usize, - phase: String, - detail: Option, - saw_final_text: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum InternalPromptProgressEvent { - Started, - Update, - Heartbeat, - Complete, - Failed, -} - -#[derive(Debug)] -struct InternalPromptProgressShared { - state: Mutex, - output_lock: Mutex<()>, - started_at: Instant, -} - -#[derive(Debug, Clone)] -struct InternalPromptProgressReporter { - shared: Arc, -} - -#[derive(Debug)] -struct InternalPromptProgressRun { - reporter: InternalPromptProgressReporter, - heartbeat_stop: Option>, - heartbeat_handle: Option>, -} - -impl InternalPromptProgressReporter { - 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(), - }), - } - } - - 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); - } - - 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, - )); - } - - 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, - )); - } - - fn mark_text_phase(&self, text: &str) { - let trimmed = text.trim(); - if trimmed.is_empty() { - return; - } - let detail = truncate_for_summary(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, - )); - } - - fn emit_heartbeat(&self) { - let snapshot = self.snapshot(); - self.write_line(&format_internal_prompt_progress_line( - InternalPromptProgressEvent::Heartbeat, - &snapshot, - self.elapsed(), - None, - )); - } - - fn snapshot(&self) -> InternalPromptProgressState { - self.shared - .state - .lock() - .expect("internal prompt progress state poisoned") - .clone() - } - - fn elapsed(&self) -> Duration { - self.shared.started_at.elapsed() - } - - 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 { - 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), - } - } - - fn reporter(&self) -> InternalPromptProgressReporter { - self.reporter.clone() - } - - fn finish_success(&mut self) { - self.stop_heartbeat(); - self.reporter - .emit(InternalPromptProgressEvent::Complete, None); - } - - 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(); - } -} - -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") - ), - } -} - -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 {}", truncate_for_summary(command.trim(), 100)) - } - } - "read_file" | "Read" => format!("reading {}", extract_tool_path(&parsed)), - "write_file" | "Write" => format!("writing {}", extract_tool_path(&parsed)), - "edit_file" | "Edit" => format!("editing {}", 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 {}", truncate_for_summary(query, 100)), - ), - _ => { - let summary = summarize_tool_payload(input); - if summary.is_empty() { - format!("running {name}") - } else { - format!("{name}: {summary}") - } - } - } -} - #[allow(clippy::needless_pass_by_value)] #[allow(clippy::too_many_arguments)] fn build_runtime( @@ -7382,88 +6626,6 @@ fn build_runtime_with_plugin_state( Ok(BuiltRuntime::new(runtime, plugin_registry, mcp_state)) } -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() - ), - } - } -} - -struct CliPermissionPrompter { - current_mode: PermissionMode, -} - -impl CliPermissionPrompter { - fn new(current_mode: PermissionMode) -> Self { - Self { current_mode } - } -} - -impl runtime::PermissionPrompter for CliPermissionPrompter { - fn decide( - &mut self, - request: &runtime::PermissionRequest, - ) -> runtime::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") { - runtime::PermissionPromptDecision::Allow - } else { - runtime::PermissionPromptDecision::Deny { - reason: format!( - "tool '{}' denied by user approval prompt", - request.tool_name - ), - } - } - } - Err(error) => runtime::PermissionPromptDecision::Deny { - reason: format!("permission approval failed: {error}"), - }, - } - } -} - // NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct // now holds an `ApiProviderClient` which dispatches to Anthropic, xAI, // OpenAI, or DashScope at construction time based on @@ -7959,121 +7121,6 @@ fn collect_prompt_cache_events(summary: &runtime::TurnSummary) -> Vec, @@ -8834,19 +7881,6 @@ impl ToolExecutor for CliToolExecutor { } } -fn permission_policy( - mode: PermissionMode, - feature_config: &runtime::RuntimeFeatureConfig, - tool_registry: &GlobalToolRegistry, -) -> Result { - 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) - }, - )) -} - fn convert_messages(messages: &[ConversationMessage]) -> Vec { messages .iter() diff --git a/rust/crates/rusty-claude-cli/src/model.rs b/rust/crates/rusty-claude-cli/src/model.rs new file mode 100644 index 00000000..3e6c0910 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/model.rs @@ -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, + 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 { + 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 { + 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}") +} diff --git a/rust/crates/rusty-claude-cli/src/permission.rs b/rust/crates/rusty-claude-cli/src/permission.rs new file mode 100644 index 00000000..9633e121 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/permission.rs @@ -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 { + 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) + }, + )) +} diff --git a/rust/crates/rusty-claude-cli/src/progress.rs b/rust/crates/rusty-claude-cli/src/progress.rs new file mode 100644 index 00000000..df019cd5 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/progress.rs @@ -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, + 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, + pub(crate) output_lock: Mutex<()>, + pub(crate) started_at: Instant, +} + +#[derive(Debug, Clone)] +pub(crate) struct InternalPromptProgressReporter { + pub(crate) shared: Arc, +} + +#[derive(Debug)] +pub(crate) struct InternalPromptProgressRun { + pub(crate) reporter: InternalPromptProgressReporter, + pub(crate) heartbeat_stop: Option>, + pub(crate) heartbeat_handle: Option>, +} + +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() + ), + } + } +}