diff --git a/debug.sh b/debug.sh index 52e68527..2fd83084 100644 --- a/debug.sh +++ b/debug.sh @@ -4,6 +4,15 @@ cargo fmt --all +### 跨平台编译 +rustup target add x86_64-unknown-linux-musl +cargo build --target x86_64-unknown-linux-musl --release +cp target/x86_64-unknown-linux-musl/release/claw /usr/local/bin/claw + + + + + echo $DASHSCOPE_API_KEY echo $ANTHROPIC_MODEL 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..d5f3fa18 --- /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 + +- [x] 6.1 Create `src/args.rs` with CliAction enum, CliOutputFormat, LocalHelpTopic, `parse_args`, all parse_* helpers, format_unknown_* functions, suggest_*/levenshtein helpers, normalize_* tools, permission_mode helpers, provider_label, filter_tool_specs (main.rs lines 506-1828) +- [x] 6.2 Add `mod args;` to `main.rs` and re-export +- [x] 6.3 Run `cargo build` and `cargo test` — must pass + +## 7. Create session.rs — extract session management + +- [x] 7.1 Create `src/session.rs` with SessionHandle, ManagedSessionSummary, all session CRUD functions: `sessions_dir`, `current_session_store`, `new_cli_session`, `create_managed_session_handle`, `resolve_session_reference`, `list_managed_sessions`, `latest_managed_session`, `load_session_reference`, `delete_managed_session`, `confirm_session_deletion`, `render_session_list`, `format_session_modified_age`, `write_session_clear_backup`, `session_clear_backup_path` (main.rs lines 3678-3692, 5257-5427) +- [x] 7.2 Add `mod session;` to `main.rs` and re-export +- [x] 7.3 Run `cargo build` and `cargo test` — must pass + +## 8. Create status.rs — extract status snapshot and git parsing + +- [x] 8.1 Create `src/status.rs` with StatusContext, StatusUsage, GitWorkspaceSummary, `print_status_snapshot`, `status_json_value`, `status_context`, `format_status_report`, `format_sandbox_report`, `print_sandbox_status_snapshot`, `sandbox_json_value`, and all git parsing helpers (main.rs lines 2842-3200, 5462-5839) +- [x] 8.2 Add `mod status;` to `main.rs` and re-export +- [x] 8.3 Run `cargo build` and `cargo test` — must pass + +## 9. Create formatting.rs — extract tool result formatting and help text + +- [x] 9.1 Create `src/formatting.rs` with all format_tool_* functions, push_output_block, response_to_events, push_prompt_cache_record, prompt_cache_record_to_runtime_event, final_assistant_text, collect_tool_uses/results/cache_events, short_tool_id, extract_tool_path, first_visible_line, truncate_* helpers, and full help text functions (main.rs lines 8184-8730, 8923-9122) +- [x] 9.2 Add `mod formatting;` to `main.rs` and re-export +- [x] 9.3 Run `cargo build` and `cargo test` — must pass + +## 10. Create config.rs — extract config and memory reporting + +- [x] 10.1 Create `src/config.rs` with `render_config_report`, `render_config_json`, `render_memory_report`, `render_memory_json` (main.rs lines 5962-6143) +- [x] 10.2 Add `mod config;` to `main.rs` and re-export +- [x] 10.3 Run `cargo build` and `cargo test` — must pass + +## 11. Create diff.rs — extract git diff reporting + +- [x] 11.1 Create `src/diff.rs` with `render_diff_report`, `render_diff_report_for`, `render_diff_json_for`, `run_git_diff_command_in` (main.rs lines 6189-6270) +- [x] 11.2 Add `mod diff;` to `main.rs` and re-export +- [x] 11.3 Run `cargo build` and `cargo test` — must pass + +## 12. Create export.rs — extract session export + +- [x] 12.1 Create `src/export.rs` with `run_export`, `render_export_text`, `render_session_markdown`, `default_export_filename`, `resolve_export_path`, `summarize_tool_payload_for_markdown` (main.rs lines 6646-6914) +- [x] 12.2 Add `mod export;` to `main.rs` and re-export +- [x] 12.3 Run `cargo build` and `cargo test` — must pass + +## 13. Create api_client.rs — extract AnthropicRuntimeClient + +- [x] 13.1 Create `src/api_client.rs` with AnthropicRuntimeClient struct, its impl, ApiClient trait impl, `resolve_cli_auth_source`, `resolve_cli_auth_source_for_cwd`, `request_ends_with_tool_result`, `format_user_visible_api_error`, `format_context_window_blocked_error` (main.rs lines 7497-7912) +- [x] 13.2 Add `mod api_client;` to `main.rs` and re-export +- [x] 13.3 Run `cargo build` and `cargo test` — must pass + +## 14. Create tool_executor.rs — extract CliToolExecutor + +- [x] 14.1 Create `src/tool_executor.rs` with CliToolExecutor struct+impl, ToolExecutor trait impl, ToolSearchRequest/McpToolRequest/ListMcpResourcesRequest/ReadMcpResourceRequest structs, and `convert_messages` (main.rs lines 8731-8922) +- [x] 14.2 Add `mod tool_executor;` to `main.rs` and re-export +- [x] 14.3 Run `cargo build` and `cargo test` — must pass + +## 15. Create runtime_builder.rs — extract runtime construction + +- [x] 15.1 Create `src/runtime_builder.rs` with RuntimePluginState, RuntimeMcpState, BuiltRuntime struct+impl+Deref+DerefMut+Drop, HookAbortMonitor, all `build_runtime*` functions, `build_plugin_manager`, `resolve_plugin_path`, `runtime_hook_config_from_plugin_hooks`, `build_system_prompt`, `build_runtime_mcp_state`, `mcp_runtime_tool_definition`, `mcp_wrapper_tool_definitions`, `permission_mode_for_mcp_tool`, `mcp_annotation_flag` (main.rs lines 3710-4180, 6924-6995, 7329-7450, 4017-4125) +- [x] 15.2 Add `mod runtime_builder;` to `main.rs` and re-export +- [x] 15.3 Run `cargo build` and `cargo test` — must pass + +## 16. Create live_cli.rs — extract LiveCli core and REPL loop + +- [x] 16.1 Create `src/live_cli.rs` with LiveCli struct, PromptHistoryEntry, and core impl methods: `new`, `startup_banner`, `prepare_turn_runtime`, `replace_runtime`, `run_turn`, `run_turn_with_output`, `run_prompt_compact/json`, `repl_completion_candidates`, `persist_session`, `record_prompt_history`, `run_internal_prompt_text*`, `reload_runtime_features` (main.rs lines 3694-3710, 4181-4440, 4580-4610, 5240-5260) +- [x] 16.2 Extract free functions: `run_repl`, `run_resume_command`, `resume_session`, `detect_broad_cwd`, `enforce_broad_cwd_policy`, `run_stale_base_preflight` (main.rs lines 3520-3675, 3597-3678, 3175-3520, 3609-3610) +- [x] 16.3 Add `mod live_cli;` to `main.rs` and re-export +- [x] 16.4 Run `cargo build` and `cargo test` — must pass + +## 17. Create repl_commands.rs — extract REPL command handlers + +- [ ] 17.1 Create `src/repl_commands.rs` with free functions for all REPL command handlers: `handle_repl_command`, `handle_session_command`, `handle_plugins_command`, `set_model`, `set_permissions`, `clear_session`, `compact`, all `print_*` methods, `run_bughunter/ultraplan/teleport/debug_tool_call/commit/pr/issue`, `export_session` (main.rs lines 4440-5256) +- [ ] 17.2 Extract all standalone command functions: `render_repl_help`, `render_resume_usage`, `render_teleport_report`, `render_last_tool_debug_report`, `render_prompt_history_report`, `render_version_report`, `collect_session_prompt_history`, `recent_user_context`, `parse_history_count`, `format_history_timestamp`, `civil_from_days`, `git_output`, `git_status_ok`, `command_exists`, `write_temp_text_file`, `sanitize_generated_message`, `parse_titled_body`, `indent_block`, `validate_no_args`, `init_claude_md`, `normalize_permission_mode`, `run_init`, `init_json_value`, `print_acp_status`, `run_worker_state`, `run_mcp_serve`, `print_bootstrap_plan`, `print_system_prompt`, `print_version`, `version_json_value`, `slash_command_completion_candidates_with_sessions`, `truncate_for_prompt`, `run_export`, `render_export_text`, `render_session_markdown`, `default_export_filename`, `resolve_export_path`, `summarize_tool_payload_for_markdown` +- [ ] 17.3 Extract all format_*_report/notice functions: `format_bughunter_report`, `format_ultraplan_report`, `format_pr_report`, `format_issue_report`, `format_commit_preflight_report`, `format_commit_skipped_report`, `format_auto_compaction_notice`, `format_compact_report`, `format_unknown_slash_command_message`, `format_model_report`, `format_model_switch_report`, `format_permissions_report`, `format_permissions_switch_report`, `format_cost_report`, `format_resume_report`, `render_resume_usage` +- [ ] 17.4 Add `mod repl_commands;` to `main.rs` and re-export +- [ ] 17.5 Run `cargo build` and `cargo test` — must pass + +## 18. Slim main.rs and verify + +- [ ] 18.1 Remove all moved code from `main.rs`; keep only `main()`, `run()`, `classify_error_kind`, `split_error_hint`, `read_piped_stdin`, `merge_prompt_with_stdin`, `write_mcp_server_fixture`, module declarations, re-exports, and the test module +- [ ] 18.2 Move `#[allow(...)]` directives to individual modules where needed +- [ ] 18.3 Run `cargo build` — must compile cleanly +- [ ] 18.4 Run full test suite: `cargo test` — all tests pass +- [ ] 18.5 Verify `wc -l src/main.rs` ≤ 2000 lines +- [ ] 18.6 Verify no new file exceeds 2000 lines 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/api_client.rs b/rust/crates/rusty-claude-cli/src/api_client.rs new file mode 100644 index 00000000..a7a1a58e --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/api_client.rs @@ -0,0 +1,404 @@ +use std::io::{self, Write}; + +use api::{ + detect_provider_kind, resolve_startup_auth_source, AnthropicClient, AuthSource, + ContentBlockDelta, MessageRequest, PromptCache, ProviderClient as ApiProviderClient, + ProviderKind, StreamEvent as ApiStreamEvent, ToolChoice, +}; +use crate::render::{MarkdownStreamState, TerminalRenderer}; +use runtime::{ApiClient, ApiRequest, AssistantEvent, MessageRole, RuntimeError}; +use tools::GlobalToolRegistry; + +use crate::constants::*; +use crate::model::max_tokens_for_model; +use crate::{ + format_tool_call_start, push_output_block, + render_thinking_block_summary, +}; + +use crate::InternalPromptProgressReporter; + +// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct +// now holds an `ApiProviderClient` which dispatches to Anthropic, xAI, +// OpenAI, or DashScope at construction time based on +// `detect_provider_kind(&model)`. The struct name is kept to avoid +// churning `BuiltRuntime` and every Deref/DerefMut site that references +// it. See ROADMAP #29 for the provider-dispatch routing fix. +pub(crate) struct AnthropicRuntimeClient { + runtime: tokio::runtime::Runtime, + client: ApiProviderClient, + session_id: String, + model: String, + enable_tools: bool, + emit_output: bool, + allowed_tools: Option, + tool_registry: GlobalToolRegistry, + progress_reporter: Option, + reasoning_effort: Option, +} + +impl AnthropicRuntimeClient { + pub(crate) fn new( + session_id: &str, + model: String, + enable_tools: bool, + emit_output: bool, + allowed_tools: Option, + tool_registry: GlobalToolRegistry, + progress_reporter: Option, + ) -> Result> { + let resolved_model = api::resolve_model_alias(&model); + let client = match detect_provider_kind(&resolved_model) { + ProviderKind::Anthropic => { + let auth = resolve_cli_auth_source()?; + let inner = AnthropicClient::from_auth(auth) + .with_base_url(api::read_base_url()) + .with_prompt_cache(PromptCache::new(session_id)); + ApiProviderClient::Anthropic(inner) + } + ProviderKind::Xai | ProviderKind::OpenAi => { + ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)? + } + }; + Ok(Self { + runtime: tokio::runtime::Runtime::new()?, + client, + session_id: session_id.to_string(), + model, + enable_tools, + emit_output, + allowed_tools, + tool_registry, + progress_reporter, + reasoning_effort: None, + }) + } + + pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { + self.reasoning_effort = effort; + } +} + +impl ApiClient for AnthropicRuntimeClient { + #[allow(clippy::too_many_lines)] + fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_model_phase(); + } + let is_post_tool = request_ends_with_tool_result(&request); + let message_request = MessageRequest { + model: self.model.clone(), + max_tokens: max_tokens_for_model(&self.model), + messages: crate::convert_messages(&request.messages), + system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")), + tools: self + .enable_tools + .then(|| crate::filter_tool_specs(&self.tool_registry, self.allowed_tools.as_ref())), + tool_choice: self.enable_tools.then_some(ToolChoice::Auto), + stream: true, + reasoning_effort: self.reasoning_effort.clone(), + ..Default::default() + }; + + self.runtime.block_on(async { + let max_attempts: usize = if is_post_tool { 2 } else { 1 }; + + for attempt in 1..=max_attempts { + let result = self + .consume_stream(&message_request, is_post_tool && attempt == 1) + .await; + match result { + Ok(events) => return Ok(events), + Err(error) + if error.to_string().contains("post-tool stall") + && attempt < max_attempts => + { + } + Err(error) => return Err(error), + } + } + + Err(RuntimeError::new("post-tool continuation nudge exhausted")) + }) + } +} + +impl AnthropicRuntimeClient { + #[allow(clippy::too_many_lines)] + async fn consume_stream( + &self, + message_request: &MessageRequest, + apply_stall_timeout: bool, + ) -> Result, RuntimeError> { + let mut stream = self + .client + .stream_message(message_request) + .await + .map_err(|error| { + RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) + })?; + let mut stdout = io::stdout(); + let mut sink = io::sink(); + let out: &mut dyn Write = if self.emit_output { + &mut stdout + } else { + &mut sink + }; + let renderer = TerminalRenderer::new(); + let mut markdown_stream = MarkdownStreamState::default(); + let mut events = Vec::new(); + let mut pending_tool: Option<(String, String, String)> = None; + let mut block_has_thinking_summary = false; + let mut saw_stop = false; + let mut received_any_event = false; + + loop { + let next = if apply_stall_timeout && !received_any_event { + match tokio::time::timeout(POST_TOOL_STALL_TIMEOUT, stream.next_event()).await { + Ok(inner) => inner.map_err(|error| { + RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) + })?, + Err(_elapsed) => { + return Err(RuntimeError::new( + "post-tool stall: model did not respond within timeout", + )); + } + } + } else { + stream.next_event().await.map_err(|error| { + RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) + })? + }; + + let Some(event) = next else { + break; + }; + received_any_event = true; + + match event { + ApiStreamEvent::MessageStart(start) => { + for block in start.message.content { + push_output_block( + block, + out, + &mut events, + &mut pending_tool, + true, + &mut block_has_thinking_summary, + )?; + } + } + ApiStreamEvent::ContentBlockStart(start) => { + push_output_block( + start.content_block, + out, + &mut events, + &mut pending_tool, + true, + &mut block_has_thinking_summary, + )?; + } + ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta { + ContentBlockDelta::TextDelta { text } => { + if !text.is_empty() { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_text_phase(&text); + } + if let Some(rendered) = markdown_stream.push(&renderer, &text) { + write!(out, "{rendered}") + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string()))?; + } + events.push(AssistantEvent::TextDelta(text)); + } + } + ContentBlockDelta::InputJsonDelta { partial_json } => { + if let Some((_, _, input)) = &mut pending_tool { + input.push_str(&partial_json); + } + } + ContentBlockDelta::ThinkingDelta { thinking } => { + if !block_has_thinking_summary { + render_thinking_block_summary(out, None, false)?; + block_has_thinking_summary = true; + } + events.push(AssistantEvent::ThinkingDelta(thinking)); + } + ContentBlockDelta::SignatureDelta { signature } => { + events.push(AssistantEvent::SignatureDelta(signature)); + } + }, + ApiStreamEvent::ContentBlockStop(_) => { + block_has_thinking_summary = false; + if let Some(rendered) = markdown_stream.flush(&renderer) { + write!(out, "{rendered}") + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string()))?; + } + if let Some((id, name, input)) = pending_tool.take() { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_tool_phase(&name, &input); + } + writeln!(out, "\n{}", format_tool_call_start(&name, &input)) + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string()))?; + events.push(AssistantEvent::ToolUse { id, name, input }); + } + } + ApiStreamEvent::MessageDelta(delta) => { + events.push(AssistantEvent::Usage(delta.usage.token_usage())); + } + ApiStreamEvent::MessageStop(_) => { + saw_stop = true; + if let Some(rendered) = markdown_stream.flush(&renderer) { + write!(out, "{rendered}") + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string()))?; + } + events.push(AssistantEvent::MessageStop); + } + } + } + + crate::push_prompt_cache_record(&self.client, &mut events); + + if !saw_stop + && events.iter().any(|event| { + matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty()) + || matches!(event, AssistantEvent::ToolUse { .. }) + }) + { + events.push(AssistantEvent::MessageStop); + } + + if events + .iter() + .any(|event| matches!(event, AssistantEvent::MessageStop)) + { + return Ok(events); + } + + let response = self + .client + .send_message(&MessageRequest { + stream: false, + ..message_request.clone() + }) + .await + .map_err(|error| { + RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) + })?; + let mut events = crate::response_to_events(response, out)?; + crate::push_prompt_cache_record(&self.client, &mut events); + Ok(events) + } +} + +pub(crate) fn resolve_cli_auth_source() -> Result> { + Ok(resolve_cli_auth_source_for_cwd()?) +} + +pub(crate) fn resolve_cli_auth_source_for_cwd() -> Result { + resolve_startup_auth_source(|| Ok(None)) +} + +pub(crate) fn request_ends_with_tool_result(request: &ApiRequest) -> bool { + request + .messages + .last() + .is_some_and(|message| message.role == MessageRole::Tool) +} + +pub(crate) fn format_user_visible_api_error(session_id: &str, error: &api::ApiError) -> String { + if error.is_context_window_failure() { + format_context_window_blocked_error(session_id, error) + } else if error.is_generic_fatal_wrapper() { + let mut qualifiers = vec![format!("session {session_id}")]; + if let Some(request_id) = error.request_id() { + qualifiers.push(format!("trace {request_id}")); + } + format!( + "{} ({}): {}", + error.safe_failure_class(), + qualifiers.join(", "), + error + ) + } else { + error.to_string() + } +} + +pub(crate) fn format_context_window_blocked_error( + session_id: &str, + error: &api::ApiError, +) -> String { + let mut lines = vec![ + "Context window blocked".to_string(), + " Failure class context_window_blocked".to_string(), + format!(" Session {session_id}"), + ]; + + if let Some(request_id) = error.request_id() { + lines.push(format!(" Trace {request_id}")); + } + + match error { + api::ApiError::ContextWindowExceeded { + model, + estimated_input_tokens, + requested_output_tokens, + estimated_total_tokens, + context_window_tokens, + } => { + lines.push(format!(" Model {model}")); + lines.push(format!( + " Input estimate ~{estimated_input_tokens} tokens (heuristic)" + )); + lines.push(format!( + " Requested output {requested_output_tokens} tokens" + )); + lines.push(format!( + " Total estimate ~{estimated_total_tokens} tokens (heuristic)" + )); + lines.push(format!(" Context window {context_window_tokens} tokens")); + } + api::ApiError::Api { message, body, .. } => { + let detail = message.as_deref().unwrap_or(body).trim(); + if !detail.is_empty() { + lines.push(format!( + " Detail {}", + crate::formatting::truncate_for_summary(detail, 120) + )); + } + } + api::ApiError::RetriesExhausted { last_error, .. } => { + let detail = match last_error.as_ref() { + api::ApiError::Api { message, body, .. } => message.as_deref().unwrap_or(body), + other => return format_context_window_blocked_error(session_id, other), + } + .trim(); + if !detail.is_empty() { + lines.push(format!( + " Detail {}", + crate::formatting::truncate_for_summary(detail, 120) + )); + } + } + _ => {} + } + + lines.push(String::new()); + lines.push("Recovery".to_string()); + lines.push(" Compact /compact".to_string()); + lines.push(format!( + " Resume compact claw --resume {session_id} /compact" + )); + lines.push(" Fresh session /clear --confirm".to_string()); + lines.push( + " Reduce scope remove large pasted context/files or ask for smaller slice" + .to_string(), + ); + lines.push(" Retry rerun after compacting or reducing request".to_string()); + + lines.join("\n") +} \ No newline at end of file diff --git a/rust/crates/rusty-claude-cli/src/args.rs b/rust/crates/rusty-claude-cli/src/args.rs new file mode 100644 index 00000000..2a41fbaa --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/args.rs @@ -0,0 +1,1605 @@ +use std::env; +use std::io::IsTerminal; +use std::path::{Path, PathBuf}; + +use api::ToolDefinition; +use commands::{ + classify_skills_slash_command, resolve_skill_invocation, slash_command_specs, + SkillSlashDispatch, SlashCommand, +}; +use runtime::{ConfigLoader, PermissionMode, ResolvedPermissionMode}; +use serde_json::{json, Value}; +use tools::GlobalToolRegistry; + +use crate::build_runtime_plugin_state_with_loader; +use crate::constants::*; +use crate::model::*; +use crate::runtime_builder::build_system_prompt; +use crate::formatting::{render_help_topic, render_version_report, full_help_text}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CliAction { + DumpManifests { + output_format: CliOutputFormat, + manifests_dir: Option, + }, + BootstrapPlan { + output_format: CliOutputFormat, + }, + Agents { + args: Option, + output_format: CliOutputFormat, + }, + Mcp { + args: Option, + output_format: CliOutputFormat, + }, + Skills { + args: Option, + output_format: CliOutputFormat, + }, + Plugins { + action: Option, + target: Option, + output_format: CliOutputFormat, + }, + PrintSystemPrompt { + cwd: PathBuf, + date: String, + output_format: CliOutputFormat, + }, + Version { + output_format: CliOutputFormat, + }, + ResumeSession { + session_path: PathBuf, + commands: Vec, + output_format: CliOutputFormat, + }, + Status { + model: String, + // #148: raw `--model` flag input (pre-alias-resolution), if any. + // None means no flag was supplied; env/config/default fallback is + // resolved inside `print_status_snapshot`. + model_flag_raw: Option, + permission_mode: PermissionMode, + output_format: CliOutputFormat, + allowed_tools: Option, + }, + Sandbox { + output_format: CliOutputFormat, + }, + Prompt { + prompt: String, + model: String, + output_format: CliOutputFormat, + allowed_tools: Option, + permission_mode: PermissionMode, + compact: bool, + base_commit: Option, + reasoning_effort: Option, + allow_broad_cwd: bool, + }, + Doctor { + output_format: CliOutputFormat, + }, + Acp { + output_format: CliOutputFormat, + }, + State { + output_format: CliOutputFormat, + }, + Init { + output_format: CliOutputFormat, + }, + // #146: `claw config` and `claw diff` are pure-local read-only + // introspection commands; wire them as standalone CLI subcommands. + Config { + section: Option, + output_format: CliOutputFormat, + }, + Diff { + output_format: CliOutputFormat, + }, + Export { + session_reference: String, + output_path: Option, + output_format: CliOutputFormat, + }, + Repl { + model: String, + allowed_tools: Option, + permission_mode: PermissionMode, + base_commit: Option, + reasoning_effort: Option, + allow_broad_cwd: bool, + }, + HelpTopic(LocalHelpTopic), + // prompt-mode formatting is only supported for non-interactive runs + Help { + output_format: CliOutputFormat, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LocalHelpTopic { + Status, + Sandbox, + Doctor, + Acp, + // #141: extend the local-help pattern to every subcommand so + // `claw --help` has one consistent contract. + Init, + State, + Export, + Version, + SystemPrompt, + DumpManifests, + BootstrapPlan, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CliOutputFormat { + Text, + Json, +} + +impl CliOutputFormat { + pub(crate) fn parse(value: &str) -> Result { + match value { + "text" => Ok(Self::Text), + "json" => Ok(Self::Json), + other => Err(format!( + "unsupported value for --output-format: {other} (expected text or json)" + )), + } + } +} + +#[allow(clippy::too_many_lines)] +pub(crate) 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; + let mut wants_version = false; + let mut allowed_tool_values = Vec::new(); + let mut compact = false; + let mut base_commit: Option = None; + let mut reasoning_effort: Option = None; + let mut allow_broad_cwd = false; + let mut rest: Vec = Vec::new(); + let mut index = 0; + + while index < args.len() { + match args[index].as_str() { + "--help" | "-h" if rest.is_empty() => { + wants_help = true; + index += 1; + } + "--help" | "-h" + if !rest.is_empty() + && matches!(rest[0].as_str(), "prompt" | "commit" | "pr" | "issue") => + { + // `--help` following a subcommand that would otherwise forward + // the arg to the API (e.g. `claw prompt --help`) should show + // top-level help instead. Subcommands that consume their own + // args (agents, mcp, plugins, skills) and local help-topic + // subcommands (status, sandbox, doctor, init, state, export, + // version, system-prompt, dump-manifests, bootstrap-plan) must + // NOT be intercepted here — they handle --help in their own + // dispatch paths via parse_local_help_action(). See #141. + wants_help = true; + index += 1; + } + "--version" | "-V" => { + wants_version = true; + index += 1; + } + "--model" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --model".to_string())?; + validate_model_syntax(value)?; + model = resolve_model_alias_with_config(value); + model_flag_raw = Some(value.clone()); // #148 + index += 2; + } + flag if flag.starts_with("--model=") => { + let value = &flag[8..]; + validate_model_syntax(value)?; + model = resolve_model_alias_with_config(value); + model_flag_raw = Some(value.to_string()); // #148 + index += 1; + } + "--output-format" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --output-format".to_string())?; + output_format = CliOutputFormat::parse(value)?; + index += 2; + } + "--permission-mode" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --permission-mode".to_string())?; + permission_mode_override = Some(parse_permission_mode_arg(value)?); + index += 2; + } + flag if flag.starts_with("--output-format=") => { + output_format = CliOutputFormat::parse(&flag[16..])?; + index += 1; + } + flag if flag.starts_with("--permission-mode=") => { + permission_mode_override = Some(parse_permission_mode_arg(&flag[18..])?); + index += 1; + } + "--dangerously-skip-permissions" => { + permission_mode_override = Some(PermissionMode::DangerFullAccess); + index += 1; + } + "--compact" => { + compact = true; + index += 1; + } + "--base-commit" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --base-commit".to_string())?; + base_commit = Some(value.clone()); + index += 2; + } + flag if flag.starts_with("--base-commit=") => { + base_commit = Some(flag[14..].to_string()); + index += 1; + } + "--reasoning-effort" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --reasoning-effort".to_string())?; + if !matches!(value.as_str(), "low" | "medium" | "high") { + return Err(format!( + "invalid value for --reasoning-effort: '{value}'; must be low, medium, or high" + )); + } + reasoning_effort = Some(value.clone()); + index += 2; + } + flag if flag.starts_with("--reasoning-effort=") => { + let value = &flag[19..]; + if !matches!(value, "low" | "medium" | "high") { + return Err(format!( + "invalid value for --reasoning-effort: '{value}'; must be low, medium, or high" + )); + } + reasoning_effort = Some(value.to_string()); + index += 1; + } + "--allow-broad-cwd" => { + allow_broad_cwd = true; + index += 1; + } + "-p" => { + // Claw Code compat: -p "prompt" = one-shot prompt + let prompt = args[index + 1..].join(" "); + if prompt.trim().is_empty() { + return Err("-p requires a prompt string".to_string()); + } + return Ok(CliAction::Prompt { + prompt, + model: resolve_model_alias_with_config(&model), + output_format, + allowed_tools: normalize_allowed_tools(&allowed_tool_values)?, + permission_mode: permission_mode_override + .unwrap_or_else(default_permission_mode), + compact, + base_commit: base_commit.clone(), + reasoning_effort: reasoning_effort.clone(), + allow_broad_cwd, + }); + } + "--print" => { + // Claw Code compat: --print makes output non-interactive + output_format = CliOutputFormat::Text; + index += 1; + } + "--resume" if rest.is_empty() => { + rest.push("--resume".to_string()); + index += 1; + } + flag if rest.is_empty() && flag.starts_with("--resume=") => { + rest.push("--resume".to_string()); + rest.push(flag[9..].to_string()); + index += 1; + } + "--acp" | "-acp" => { + rest.push("acp".to_string()); + index += 1; + } + "--allowedTools" | "--allowed-tools" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --allowedTools".to_string())?; + allowed_tool_values.push(value.clone()); + index += 2; + } + flag if flag.starts_with("--allowedTools=") => { + allowed_tool_values.push(flag[15..].to_string()); + index += 1; + } + flag if flag.starts_with("--allowed-tools=") => { + allowed_tool_values.push(flag[16..].to_string()); + index += 1; + } + other if rest.is_empty() && other.starts_with('-') => { + return Err(format_unknown_option(other)) + } + other => { + rest.push(other.to_string()); + index += 1; + } + } + } + + if wants_help { + return Ok(CliAction::Help { output_format }); + } + + if wants_version { + return Ok(CliAction::Version { output_format }); + } + + let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?; + + if rest.is_empty() { + let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode); + // When stdin is not a terminal (pipe/redirect) and no prompt is given on the + // command line, read stdin as the prompt and dispatch as a one-shot Prompt + // rather than starting the interactive REPL (which would consume the pipe and + // print the startup banner, then exit without sending anything to the API). + if !std::io::stdin().is_terminal() { + let mut buf = String::new(); + let _ = std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf); + let piped = buf.trim().to_string(); + if !piped.is_empty() { + return Ok(CliAction::Prompt { + model, + prompt: piped, + allowed_tools, + permission_mode, + output_format, + compact: false, + base_commit, + reasoning_effort, + allow_broad_cwd, + }); + } + } + return Ok(CliAction::Repl { + model, + allowed_tools, + permission_mode, + base_commit, + reasoning_effort: reasoning_effort.clone(), + allow_broad_cwd, + }); + } + if rest.first().map(String::as_str) == Some("--resume") { + return parse_resume_args(&rest[1..], output_format); + } + if let Some(action) = parse_local_help_action(&rest) { + return action; + } + if let Some(action) = parse_single_word_command_alias( + &rest, + &model, + model_flag_raw.as_deref(), + permission_mode_override, + output_format, + allowed_tools.clone(), + ) { + return action; + } + + let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode); + + match rest[0].as_str() { + "dump-manifests" => parse_dump_manifests_args(&rest[1..], output_format), + "bootstrap-plan" => Ok(CliAction::BootstrapPlan { output_format }), + "agents" => Ok(CliAction::Agents { + args: join_optional_args(&rest[1..]), + output_format, + }), + "mcp" => Ok(CliAction::Mcp { + args: join_optional_args(&rest[1..]), + output_format, + }), + // #145: `plugins` was routed through the prompt fallback because no + // top-level parser arm produced CliAction::Plugins. That made `claw + // plugins` (and `claw plugins --help`, `claw plugins list`, ...) + // attempt an Anthropic network call, surfacing the misleading error + // `missing Anthropic credentials` even though the command is purely + // local introspection. Mirror `agents`/`mcp`/`skills`: action is the + // first positional arg, target is the second. + "plugins" => { + let tail = &rest[1..]; + let action = tail.first().cloned(); + let target = tail.get(1).cloned(); + if tail.len() > 2 { + return Err(format!( + "unexpected extra arguments after `claw plugins {}`: {}", + tail[..2].join(" "), + tail[2..].join(" ") + )); + } + Ok(CliAction::Plugins { + action, + target, + output_format, + }) + } + // #146: `config` is pure-local read-only introspection (merges + // `.claw.json` + `.claw/settings.json` from disk, no network, no + // state mutation). Previously callers had to spin up a session with + // `claw --resume SESSION.jsonl /config` to see their own config, + // which is synthetic friction. Accepts an optional section name + // (env|hooks|model|plugins) matching the slash command shape. + "config" => { + let tail = &rest[1..]; + let section = tail.first().cloned(); + if tail.len() > 1 { + return Err(format!( + "unexpected extra arguments after `claw config {}`: {}", + tail[0], + tail[1..].join(" ") + )); + } + Ok(CliAction::Config { + section, + output_format, + }) + } + // #146: `diff` is pure-local (shells out to `git diff --cached` + + // `git diff`). No session needed to inspect the working tree. + "diff" => { + if rest.len() > 1 { + return Err(format!( + "unexpected extra arguments after `claw diff`: {}", + rest[1..].join(" ") + )); + } + Ok(CliAction::Diff { output_format }) + } + "skills" => { + let args = join_optional_args(&rest[1..]); + match classify_skills_slash_command(args.as_deref()) { + SkillSlashDispatch::Invoke(prompt) => Ok(CliAction::Prompt { + prompt, + model, + output_format, + allowed_tools, + permission_mode, + compact, + base_commit, + reasoning_effort: reasoning_effort.clone(), + allow_broad_cwd, + }), + SkillSlashDispatch::Local => Ok(CliAction::Skills { + args, + output_format, + }), + } + } + "system-prompt" => parse_system_prompt_args(&rest[1..], output_format), + "acp" => parse_acp_args(&rest[1..], output_format), + "login" | "logout" => Err(removed_auth_surface_error(rest[0].as_str())), + "init" => Ok(CliAction::Init { output_format }), + "export" => parse_export_args(&rest[1..], output_format), + "prompt" => { + let prompt = rest[1..].join(" "); + if prompt.trim().is_empty() { + return Err("prompt subcommand requires a prompt string".to_string()); + } + Ok(CliAction::Prompt { + prompt, + model, + output_format, + allowed_tools, + permission_mode, + compact, + base_commit: base_commit.clone(), + reasoning_effort: reasoning_effort.clone(), + allow_broad_cwd, + }) + } + other if other.starts_with('/') => parse_direct_slash_cli_action( + &rest, + model, + output_format, + allowed_tools, + permission_mode, + compact, + base_commit, + reasoning_effort, + allow_broad_cwd, + ), + other => { + if rest.len() == 1 && looks_like_subcommand_typo(other) { + if let Some(suggestions) = suggest_similar_subcommand(other) { + let mut message = format!("unknown subcommand: {other}."); + if let Some(line) = render_suggestion_line("Did you mean", &suggestions) { + message.push('\n'); + message.push_str(&line); + } + message.push_str( + "\nRun `claw --help` for the full list. If you meant to send a prompt literally, use `claw prompt `.", + ); + return Err(message); + } + } + // #147: guard empty/whitespace-only prompts at the fallthrough + // path the same way `"prompt"` arm above does. Without this, + // `claw ""`, `claw " "`, and `claw "" ""` silently route to + // the Anthropic call and surface a misleading + // `missing Anthropic credentials` error (or burn API tokens on + // an empty prompt when credentials are present). + let joined = rest.join(" "); + if joined.trim().is_empty() { + return Err( + "empty prompt: provide a subcommand (run `claw --help`) or a non-empty prompt string" + .to_string(), + ); + } + Ok(CliAction::Prompt { + prompt: joined, + model, + output_format, + allowed_tools, + permission_mode, + compact, + base_commit, + reasoning_effort: reasoning_effort.clone(), + allow_broad_cwd, + }) + } + } +} + +pub(crate) fn parse_local_help_action(rest: &[String]) -> Option> { + if rest.len() != 2 || !is_help_flag(&rest[1]) { + return None; + } + + let topic = match rest[0].as_str() { + "status" => LocalHelpTopic::Status, + "sandbox" => LocalHelpTopic::Sandbox, + "doctor" => LocalHelpTopic::Doctor, + "acp" => LocalHelpTopic::Acp, + // #141: add the subcommands that were previously falling back + // to global help (init/state/export/version) or erroring out + // (system-prompt/dump-manifests) or printing their primary + // output instead of help text (bootstrap-plan). + "init" => LocalHelpTopic::Init, + "state" => LocalHelpTopic::State, + "export" => LocalHelpTopic::Export, + "version" => LocalHelpTopic::Version, + "system-prompt" => LocalHelpTopic::SystemPrompt, + "dump-manifests" => LocalHelpTopic::DumpManifests, + "bootstrap-plan" => LocalHelpTopic::BootstrapPlan, + _ => return None, + }; + Some(Ok(CliAction::HelpTopic(topic))) +} + +pub(crate) fn is_help_flag(value: &str) -> bool { + matches!(value, "--help" | "-h") +} + +pub(crate) fn parse_single_word_command_alias( + rest: &[String], + model: &str, + // #148: raw --model flag input for status provenance. None = no flag. + model_flag_raw: Option<&str>, + permission_mode_override: Option, + output_format: CliOutputFormat, + allowed_tools: Option, +) -> Option> { + if rest.is_empty() { + return None; + } + + // Diagnostic verbs (help, version, status, sandbox, doctor, state) accept only the verb itself + // or --help / -h as a suffix. Any other suffix args are unrecognized. + let verb = &rest[0]; + let is_diagnostic = matches!( + verb.as_str(), + "help" | "version" | "status" | "sandbox" | "doctor" | "state" + ); + + if is_diagnostic && rest.len() > 1 { + // Diagnostic verb with trailing args: reject unrecognized suffix + if is_help_flag(&rest[1]) && rest.len() == 2 { + // "doctor --help" is valid, routed to parse_local_help_action() instead + return None; + } + // Unrecognized suffix like "--json" + let mut msg = format!( + "unrecognized argument `{}` for subcommand `{}`", + rest[1], verb + ); + // #152: common mistake — users type `--json` expecting JSON output. + // Hint at the correct flag so they don't have to re-read --help. + if rest[1] == "--json" { + msg.push_str("\nDid you mean `--output-format json`?"); + } + return Some(Err(msg)); + } + + if rest.len() != 1 { + return None; + } + + match rest[0].as_str() { + "help" => Some(Ok(CliAction::Help { output_format })), + "version" => Some(Ok(CliAction::Version { output_format })), + "status" => Some(Ok(CliAction::Status { + model: model.to_string(), + model_flag_raw: model_flag_raw.map(str::to_string), // #148 + permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode), + output_format, + allowed_tools, + })), + "sandbox" => Some(Ok(CliAction::Sandbox { output_format })), + "doctor" => Some(Ok(CliAction::Doctor { output_format })), + "state" => Some(Ok(CliAction::State { output_format })), + // #146: let `config` and `diff` fall through to parse_subcommand + // where they are wired as pure-local introspection, instead of + // producing the "is a slash command" guidance. Zero-arg cases + // reach parse_subcommand too via this None. + "config" | "diff" => None, + other => bare_slash_command_guidance(other).map(Err), + } +} + +pub(crate) fn bare_slash_command_guidance(command_name: &str) -> Option { + if matches!( + command_name, + "dump-manifests" + | "bootstrap-plan" + | "agents" + | "mcp" + | "skills" + | "system-prompt" + | "init" + | "prompt" + | "export" + ) { + return None; + } + let slash_command = slash_command_specs() + .iter() + .find(|spec| spec.name == command_name)?; + let guidance = if slash_command.resume_supported { + format!( + "`claw {command_name}` is a slash command. Use `claw --resume SESSION.jsonl /{command_name}` or start `claw` and run `/{command_name}`." + ) + } else { + format!( + "`claw {command_name}` is a slash command. Start `claw` and run `/{command_name}` inside the REPL." + ) + }; + Some(guidance) +} + +pub(crate) fn removed_auth_surface_error(command_name: &str) -> String { + format!( + "`claw {command_name}` has been removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead." + ) +} + +pub(crate) fn parse_acp_args(args: &[String], output_format: CliOutputFormat) -> Result { + match args { + [] => Ok(CliAction::Acp { output_format }), + [subcommand] if subcommand == "serve" => Ok(CliAction::Acp { output_format }), + _ => Err(String::from( + "unsupported ACP invocation. Use `claw acp`, `claw acp serve`, `claw --acp`, or `claw -acp`.", + )), + } +} + +pub(crate) fn try_resolve_bare_skill_prompt(cwd: &Path, trimmed: &str) -> Option { + let bare_first_token = trimmed.split_whitespace().next().unwrap_or_default(); + let looks_like_skill_name = !bare_first_token.is_empty() + && !bare_first_token.starts_with('/') + && bare_first_token + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_'); + if !looks_like_skill_name { + return None; + } + match resolve_skill_invocation(cwd, Some(trimmed)) { + Ok(SkillSlashDispatch::Invoke(prompt)) => Some(prompt), + _ => None, + } +} + +pub(crate) fn join_optional_args(args: &[String]) -> Option { + let joined = args.join(" "); + let trimmed = joined.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +#[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] +pub(crate) fn parse_direct_slash_cli_action( + rest: &[String], + model: String, + output_format: CliOutputFormat, + allowed_tools: Option, + permission_mode: PermissionMode, + compact: bool, + base_commit: Option, + reasoning_effort: Option, + allow_broad_cwd: bool, +) -> Result { + let raw = rest.join(" "); + match SlashCommand::parse(&raw) { + Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help { output_format }), + Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents { + args, + output_format, + }), + Ok(Some(SlashCommand::Mcp { action, target })) => Ok(CliAction::Mcp { + args: match (action, target) { + (None, None) => None, + (Some(action), None) => Some(action), + (Some(action), Some(target)) => Some(format!("{action} {target}")), + (None, Some(target)) => Some(target), + }, + output_format, + }), + Ok(Some(SlashCommand::Skills { args })) => { + match classify_skills_slash_command(args.as_deref()) { + SkillSlashDispatch::Invoke(prompt) => Ok(CliAction::Prompt { + prompt, + model, + output_format, + allowed_tools, + permission_mode, + compact, + base_commit, + reasoning_effort: reasoning_effort.clone(), + allow_broad_cwd, + }), + SkillSlashDispatch::Local => Ok(CliAction::Skills { + args, + output_format, + }), + } + } + Ok(Some(SlashCommand::Unknown(name))) => Err(format_unknown_slash_command_message(&name)), + Ok(Some(command)) => Err({ + let _ = command; + format!( + "slash command {command_name} is interactive-only. Start `claw` and run it there, or use `claw --resume SESSION.jsonl {command_name}` / `claw --resume {latest} {command_name}` when the command is marked [resume] in /help.", + command_name = rest[0], + latest = LATEST_SESSION_REFERENCE, + ) + }), + Ok(None) => Err(format!("unknown subcommand: {}", rest[0])), + Err(error) => Err(error.to_string()), + } +} + +pub(crate) fn format_unknown_option(option: &str) -> String { + let mut message = format!("unknown option: {option}"); + if let Some(suggestion) = suggest_closest_term(option, CLI_OPTION_SUGGESTIONS) { + message.push_str("\nDid you mean "); + message.push_str(suggestion); + message.push('?'); + } + message.push_str("\nRun `claw --help` for usage."); + message +} + +pub(crate) fn format_unknown_slash_command_message(name: &str) -> String { + let mut message = format!("unknown slash command: /{name}"); + if let Some(suggestions) = render_suggestion_line("Did you mean", &suggest_slash_commands(name)) + { + message.push('\n'); + message.push_str(&suggestions); + } + if let Some(note) = omc_compatibility_note_for_unknown_slash_command(name) { + message.push('\n'); + message.push_str(note); + } + message.push_str("\nRun `claw --help` for CLI usage. Use /help in the REPL."); + message +} + +pub(crate) fn format_unknown_slash_command(name: &str) -> String { + let mut message = format!("Unknown slash command: /{name}"); + if let Some(suggestions) = render_suggestion_line("Did you mean", &suggest_slash_commands(name)) + { + message.push('\n'); + message.push_str(&suggestions); + } + if let Some(note) = omc_compatibility_note_for_unknown_slash_command(name) { + message.push('\n'); + message.push_str(note); + } + message.push_str("\n Help /help lists available slash commands"); + message +} + +pub(crate) fn omc_compatibility_note_for_unknown_slash_command(name: &str) -> Option<&'static str> { + name.starts_with("oh-my-claudecode:") + .then_some( + "Compatibility note: `/oh-my-claudecode:*` is a Claude Code/OMC plugin command. `claw` does not yet load plugin slash commands, Claude statusline stdin, or OMC session hooks.", + ) +} + +pub(crate) fn render_suggestion_line(label: &str, suggestions: &[String]) -> Option { + (!suggestions.is_empty()).then(|| format!(" {label:<16} {}", suggestions.join(", "),)) +} + +pub(crate) fn suggest_slash_commands(input: &str) -> Vec { + let mut candidates = slash_command_specs() + .iter() + .flat_map(|spec| { + std::iter::once(spec.name) + .chain(spec.aliases.iter().copied()) + .map(|name| format!("/{name}")) + .collect::>() + }) + .collect::>(); + candidates.sort(); + candidates.dedup(); + let candidate_refs = candidates.iter().map(String::as_str).collect::>(); + ranked_suggestions(input.trim_start_matches('/'), &candidate_refs) + .into_iter() + .map(str::to_string) + .collect() +} + +pub(crate) fn suggest_closest_term<'a>(input: &str, candidates: &'a [&'a str]) -> Option<&'a str> { + ranked_suggestions(input, candidates).into_iter().next() +} + +pub(crate) fn suggest_similar_subcommand(input: &str) -> Option> { + const KNOWN_SUBCOMMANDS: &[&str] = &[ + "help", + "version", + "status", + "sandbox", + "doctor", + "state", + "dump-manifests", + "bootstrap-plan", + "agents", + "mcp", + "skills", + "system-prompt", + "acp", + "init", + "export", + "prompt", + ]; + + let normalized_input = input.to_ascii_lowercase(); + let mut ranked = KNOWN_SUBCOMMANDS + .iter() + .filter_map(|candidate| { + let normalized_candidate = candidate.to_ascii_lowercase(); + let distance = levenshtein_distance(&normalized_input, &normalized_candidate); + let prefix_match = common_prefix_len(&normalized_input, &normalized_candidate) >= 4; + let substring_match = normalized_candidate.contains(&normalized_input) + || normalized_input.contains(&normalized_candidate); + ((distance <= 2) || prefix_match || substring_match).then_some((distance, *candidate)) + }) + .collect::>(); + ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1))); + ranked.dedup_by(|left, right| left.1 == right.1); + let suggestions = ranked + .into_iter() + .map(|(_, candidate)| candidate.to_string()) + .take(3) + .collect::>(); + (!suggestions.is_empty()).then_some(suggestions) +} + +pub(crate) fn common_prefix_len(left: &str, right: &str) -> usize { + left.chars() + .zip(right.chars()) + .take_while(|(l, r)| l == r) + .count() +} + +pub(crate) fn looks_like_subcommand_typo(input: &str) -> bool { + !input.is_empty() + && input + .chars() + .all(|ch| ch.is_ascii_alphabetic() || ch == '-') +} + +pub(crate) fn ranked_suggestions<'a>(input: &str, candidates: &'a [&'a str]) -> Vec<&'a str> { + let normalized_input = input.trim_start_matches('/').to_ascii_lowercase(); + let mut ranked = candidates + .iter() + .filter_map(|candidate| { + let normalized_candidate = candidate.trim_start_matches('/').to_ascii_lowercase(); + let distance = levenshtein_distance(&normalized_input, &normalized_candidate); + let prefix_bonus = usize::from( + !(normalized_candidate.starts_with(&normalized_input) + || normalized_input.starts_with(&normalized_candidate)), + ); + let score = distance + prefix_bonus; + (score <= 4).then_some((score, *candidate)) + }) + .collect::>(); + ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1))); + ranked + .into_iter() + .map(|(_, candidate)| candidate) + .take(3) + .collect() +} + +pub(crate) fn levenshtein_distance(left: &str, right: &str) -> usize { + if left.is_empty() { + return right.chars().count(); + } + if right.is_empty() { + return left.chars().count(); + } + + let right_chars = right.chars().collect::>(); + let mut previous = (0..=right_chars.len()).collect::>(); + let mut current = vec![0; right_chars.len() + 1]; + + for (left_index, left_char) in left.chars().enumerate() { + current[0] = left_index + 1; + for (right_index, right_char) in right_chars.iter().enumerate() { + let substitution_cost = usize::from(left_char != *right_char); + current[right_index + 1] = (previous[right_index + 1] + 1) + .min(current[right_index] + 1) + .min(previous[right_index] + substitution_cost); + } + previous.clone_from(¤t); + } + + previous[right_chars.len()] +} + + + +pub(crate) fn normalize_allowed_tools(values: &[String]) -> Result, String> { + if values.is_empty() { + return Ok(None); + } + current_tool_registry()?.normalize_allowed_tools(values) +} + +pub(crate) fn current_tool_registry() -> Result { + let cwd = env::current_dir().map_err(|error| error.to_string())?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load().map_err(|error| error.to_string())?; + let state = build_runtime_plugin_state_with_loader(&cwd, &loader, &runtime_config) + .map_err(|error| error.to_string())?; + let registry = state.tool_registry.clone(); + if let Some(mcp_state) = state.mcp_state { + mcp_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .shutdown() + .map_err(|error| error.to_string())?; + } + Ok(registry) +} + +pub(crate) fn parse_permission_mode_arg(value: &str) -> Result { + normalize_permission_mode(value) + .ok_or_else(|| { + format!( + "unsupported permission mode '{value}'. Use read-only, workspace-write, or danger-full-access." + ) + }) + .map(permission_mode_from_label) +} + +pub(crate) fn permission_mode_from_label(mode: &str) -> PermissionMode { + match mode { + "read-only" => PermissionMode::ReadOnly, + "workspace-write" => PermissionMode::WorkspaceWrite, + "danger-full-access" => PermissionMode::DangerFullAccess, + other => panic!("unsupported permission mode label: {other}"), + } +} + +pub(crate) fn permission_mode_from_resolved(mode: ResolvedPermissionMode) -> PermissionMode { + match mode { + ResolvedPermissionMode::ReadOnly => PermissionMode::ReadOnly, + ResolvedPermissionMode::WorkspaceWrite => PermissionMode::WorkspaceWrite, + ResolvedPermissionMode::DangerFullAccess => PermissionMode::DangerFullAccess, + } +} + +pub(crate) fn default_permission_mode() -> PermissionMode { + env::var("RUSTY_CLAUDE_PERMISSION_MODE") + .ok() + .as_deref() + .and_then(normalize_permission_mode) + .map(permission_mode_from_label) + .or_else(config_permission_mode_for_current_dir) + .unwrap_or(PermissionMode::DangerFullAccess) +} + +pub(crate) fn config_permission_mode_for_current_dir() -> Option { + let cwd = env::current_dir().ok()?; + let loader = ConfigLoader::default_for(&cwd); + loader + .load() + .ok()? + .permission_mode() + .map(permission_mode_from_resolved) +} + + + +pub(crate) fn filter_tool_specs( + tool_registry: &GlobalToolRegistry, + allowed_tools: Option<&AllowedToolSet>, +) -> Vec { + tool_registry.definitions(allowed_tools) +} + +pub(crate) fn parse_system_prompt_args( + args: &[String], + output_format: CliOutputFormat, +) -> Result { + let mut cwd = env::current_dir().map_err(|error| error.to_string())?; + let mut date = DEFAULT_DATE.to_string(); + let mut index = 0; + + while index < args.len() { + match args[index].as_str() { + "--cwd" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --cwd".to_string())?; + cwd = PathBuf::from(value); + index += 2; + } + "--date" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --date".to_string())?; + date.clone_from(value); + index += 2; + } + other => { + // #152: hint `--output-format json` when user types `--json`. + let mut msg = format!("unknown system-prompt option: {other}"); + if other == "--json" { + msg.push_str("\nDid you mean `--output-format json`?"); + } + return Err(msg); + } + } + } + + Ok(CliAction::PrintSystemPrompt { + cwd, + date, + output_format, + }) +} + +pub(crate) fn parse_export_args(args: &[String], output_format: CliOutputFormat) -> Result { + let mut session_reference = LATEST_SESSION_REFERENCE.to_string(); + let mut output_path: Option = None; + let mut index = 0; + + while index < args.len() { + match args[index].as_str() { + "--session" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --session".to_string())?; + session_reference.clone_from(value); + index += 2; + } + flag if flag.starts_with("--session=") => { + session_reference = flag[10..].to_string(); + index += 1; + } + "--output" | "-o" => { + let value = args + .get(index + 1) + .ok_or_else(|| format!("missing value for {}", args[index]))?; + output_path = Some(PathBuf::from(value)); + index += 2; + } + flag if flag.starts_with("--output=") => { + output_path = Some(PathBuf::from(&flag[9..])); + index += 1; + } + other if other.starts_with('-') => { + return Err(format!("unknown export option: {other}")); + } + other if output_path.is_none() => { + output_path = Some(PathBuf::from(other)); + index += 1; + } + other => { + return Err(format!("unexpected export argument: {other}")); + } + } + } + + Ok(CliAction::Export { + session_reference, + output_path, + output_format, + }) +} + +pub(crate) fn parse_dump_manifests_args( + args: &[String], + output_format: CliOutputFormat, +) -> Result { + let mut manifests_dir: Option = None; + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + if arg == "--manifests-dir" { + let value = args + .get(index + 1) + .ok_or_else(|| String::from("--manifests-dir requires a path"))?; + manifests_dir = Some(PathBuf::from(value)); + index += 2; + continue; + } + if let Some(value) = arg.strip_prefix("--manifests-dir=") { + if value.is_empty() { + return Err(String::from("--manifests-dir requires a path")); + } + manifests_dir = Some(PathBuf::from(value)); + index += 1; + continue; + } + return Err(format!("unknown dump-manifests option: {arg}")); + } + + Ok(CliAction::DumpManifests { + output_format, + manifests_dir, + }) +} + +pub(crate) fn parse_resume_args(args: &[String], output_format: CliOutputFormat) -> Result { + let (session_path, command_tokens): (PathBuf, &[String]) = match args.first() { + None => (PathBuf::from(LATEST_SESSION_REFERENCE), &[]), + Some(first) if looks_like_slash_command_token(first) => { + (PathBuf::from(LATEST_SESSION_REFERENCE), args) + } + Some(first) => (PathBuf::from(first), &args[1..]), + }; + let mut commands = Vec::new(); + let mut current_command = String::new(); + + for token in command_tokens { + if token.trim_start().starts_with('/') { + if resume_command_can_absorb_token(¤t_command, token) { + current_command.push(' '); + current_command.push_str(token); + continue; + } + if !current_command.is_empty() { + commands.push(current_command); + } + current_command = String::from(token.as_str()); + continue; + } + + if current_command.is_empty() { + return Err("--resume trailing arguments must be slash commands".to_string()); + } + + current_command.push(' '); + current_command.push_str(token); + } + + if !current_command.is_empty() { + commands.push(current_command); + } + + Ok(CliAction::ResumeSession { + session_path, + commands, + output_format, + }) +} + +pub(crate) fn parse_history_count(raw: Option<&str>) -> Result { + let Some(raw) = raw else { + return Ok(DEFAULT_HISTORY_LIMIT); + }; + let parsed: usize = raw + .parse() + .map_err(|_| format!("history: invalid count '{raw}'. Expected a positive integer."))?; + if parsed == 0 { + return Err("history: count must be greater than 0.".to_string()); + } + Ok(parsed) +} + +pub(crate) fn normalize_permission_mode(mode: &str) -> Option<&'static str> { + match mode.trim() { + "read-only" => Some("read-only"), + "workspace-write" => Some("workspace-write"), + "danger-full-access" => Some("danger-full-access"), + _ => None, + } +} + +pub(crate) fn validate_no_args( + command_name: &str, + args: Option<&str>, +) -> Result<(), Box> { + if let Some(args) = args.map(str::trim).filter(|value| !value.is_empty()) { + return Err(format!( + "{command_name} does not accept arguments. Received: {args}\nUsage: {command_name}" + ) + .into()); + } + Ok(()) +} + +pub(crate) fn print_help_topic(topic: LocalHelpTopic) { + println!("{}", render_help_topic(topic)); +} + +pub(crate) fn print_acp_status( + output_format: CliOutputFormat, +) -> Result<(), Box> { + let message = "ACP/Zed editor integration is not implemented in claw-code yet. `claw acp serve` is only a discoverability alias today; it does not launch a daemon or Zed-specific protocol endpoint. Use the normal terminal surfaces for now and track ROADMAP #76 for real ACP support."; + match output_format { + CliOutputFormat::Text => { + println!( + "ACP / Zed\n Status discoverability only\n Launch `claw acp serve` / `claw --acp` / `claw -acp` report status only; no editor daemon is available yet\n Today use `claw prompt`, the REPL, or `claw doctor` for local verification\n Tracking ROADMAP #76\n Message {message}" + ); + } + CliOutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "kind": "acp", + "status": "discoverability_only", + "supported": false, + "serve_alias_only": true, + "message": message, + "launch_command": serde_json::Value::Null, + "aliases": ["acp", "--acp", "-acp"], + "discoverability_tracking": "ROADMAP #64a", + "tracking": "ROADMAP #76", + "recommended_workflows": [ + "claw prompt TEXT", + "claw", + "claw doctor" + ], + }))? + ); + } + } + Ok(()) +} + +pub(crate) fn slash_command_completion_candidates_with_sessions( + model: &str, + active_session_id: Option<&str>, + recent_session_ids: Vec, +) -> Vec { + use std::collections::BTreeSet; + let mut completions = BTreeSet::new(); + + for spec in commands::slash_command_specs() { + if STUB_COMMANDS.contains(&spec.name) { + continue; + } + completions.insert(format!("/{}", spec.name)); + for alias in spec.aliases { + if !STUB_COMMANDS.contains(alias) { + completions.insert(format!("/{alias}")); + } + } + } + + for candidate in [ + "/bughunter ", + "/clear --confirm", + "/config ", + "/config env", + "/config hooks", + "/config model", + "/config plugins", + "/mcp ", + "/mcp list", + "/mcp show ", + "/export ", + "/issue ", + "/model ", + "/model opus", + "/model sonnet", + "/model haiku", + "/permissions ", + "/permissions read-only", + "/permissions workspace-write", + "/permissions danger-full-access", + "/plugin list", + "/plugin install ", + "/plugin enable ", + "/plugin disable ", + "/plugin uninstall ", + "/plugin update ", + "/plugins list", + "/pr ", + "/resume ", + "/session list", + "/session switch ", + "/session fork ", + "/teleport ", + "/ultraplan ", + "/agents help", + "/mcp help", + "/skills help", + ] { + completions.insert(candidate.to_string()); + } + + if !model.trim().is_empty() { + completions.insert(format!("/model {}", resolve_model_alias(model))); + completions.insert(format!("/model {model}")); + } + + if let Some(active_session_id) = active_session_id.filter(|value| !value.trim().is_empty()) { + completions.insert(format!("/resume {active_session_id}")); + completions.insert(format!("/session switch {active_session_id}")); + } + + for session_id in recent_session_ids + .into_iter() + .filter(|value| !value.trim().is_empty()) + .take(10) + { + completions.insert(format!("/resume {session_id}")); + completions.insert(format!("/session switch {session_id}")); + } + + completions.into_iter().collect() +} + +pub(crate) fn run_worker_state(output_format: CliOutputFormat) -> Result<(), Box> { + let cwd = env::current_dir()?; + let state_path = cwd.join(".claw").join("worker-state.json"); + if !state_path.exists() { + return Err(format!( + "no worker state file found at {path}\n Hint: worker state is written by the interactive REPL or a non-interactive prompt.\n Run: claw # start the REPL (writes state on first turn)\n Or: claw prompt # run one non-interactive turn\n Then rerun: claw state [--output-format json]", + path = state_path.display() + ) + .into()); + } + let raw = std::fs::read_to_string(&state_path)?; + match output_format { + CliOutputFormat::Text => println!("{raw}"), + CliOutputFormat::Json => { + let _: serde_json::Value = serde_json::from_str(&raw)?; + println!("{raw}"); + } + } + Ok(()) +} + +pub(crate) fn resume_command_can_absorb_token(current_command: &str, token: &str) -> bool { + matches!( + commands::SlashCommand::parse(current_command), + Ok(Some(commands::SlashCommand::Export { path: None })) + ) && !looks_like_slash_command_token(token) +} + +pub(crate) fn looks_like_slash_command_token(token: &str) -> bool { + let trimmed = token.trim_start(); + let Some(name) = trimmed.strip_prefix('/').and_then(|value| { + value + .split_whitespace() + .next() + .map(str::trim) + .filter(|value| !value.is_empty()) + }) else { + return false; + }; + + commands::slash_command_specs() + .iter() + .any(|spec| spec.name == name || spec.aliases.contains(&name)) +} + +pub(crate) fn dump_manifests( + manifests_dir: Option<&Path>, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + dump_manifests_at_path(&workspace_dir, manifests_dir, output_format) +} + +const DUMP_MANIFESTS_OVERRIDE_HINT: &str = + "Hint: set CLAUDE_CODE_UPSTREAM=/path/to/upstream or pass `claw dump-manifests --manifests-dir /path/to/upstream`."; + +fn dump_manifests_at_path( + workspace_dir: &std::path::Path, + manifests_dir: Option<&Path>, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let paths = if let Some(dir) = manifests_dir { + let resolved = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()); + compat_harness::UpstreamPaths::from_repo_root(resolved) + } else { + let resolved = workspace_dir + .canonicalize() + .unwrap_or_else(|_| workspace_dir.to_path_buf()); + compat_harness::UpstreamPaths::from_workspace_dir(&resolved) + }; + + let source_root = paths.repo_root(); + if !source_root.exists() { + return Err(format!( + "Manifest source directory does not exist.\n looked in: {}\n {DUMP_MANIFESTS_OVERRIDE_HINT}", + source_root.display(), + ) + .into()); + } + + let required_paths = [ + ("src/commands.ts", paths.commands_path()), + ("src/tools.ts", paths.tools_path()), + ("src/entrypoints/cli.tsx", paths.cli_path()), + ]; + let missing = required_paths + .iter() + .filter_map(|(label, path)| (!path.is_file()).then_some(*label)) + .collect::>(); + if !missing.is_empty() { + return Err(format!( + "Manifest source files are missing.\n repo root: {}\n missing: {}\n {DUMP_MANIFESTS_OVERRIDE_HINT}", + source_root.display(), + missing.join(", "), + ) + .into()); + } + + match compat_harness::extract_manifest(&paths) { + Ok(manifest) => { + match output_format { + CliOutputFormat::Text => { + println!("commands: {}", manifest.commands.entries().len()); + println!("tools: {}", manifest.tools.entries().len()); + println!("bootstrap phases: {}", manifest.bootstrap.phases().len()); + } + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "kind": "dump-manifests", + "commands": manifest.commands.entries().len(), + "tools": manifest.tools.entries().len(), + "bootstrap_phases": manifest.bootstrap.phases().len(), + }))? + ), + } + Ok(()) + } + Err(error) => Err(format!("failed to extract manifests: {error}").into()), + } +} + +pub(crate) fn print_bootstrap_plan( + output_format: CliOutputFormat, +) -> Result<(), Box> { + match output_format { + CliOutputFormat::Text => { + println!("Bootstrap Plan\n Status not implemented\n Message the bootstrap plan surface is not implemented in this build yet."); + } + CliOutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&bootstrap_plan_json_value())? + ); + } + } + Ok(()) +} + +fn bootstrap_plan_json_value() -> serde_json::Value { + serde_json::json!({ + "kind": "bootstrap_plan", + "phases": [], + "message": "the bootstrap plan surface is not implemented in this build yet.", + }) +} + +pub(crate) fn print_system_prompt( + _cwd: Option, + _date: Option, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let prompt = build_system_prompt()?; + match output_format { + CliOutputFormat::Text => { + println!("{}", prompt.join("\n\n")); + } + CliOutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "kind": "system_prompt", + "prompt": prompt, + }))? + ); + } + } + Ok(()) +} + +pub(crate) fn print_version(output_format: CliOutputFormat) -> Result<(), Box> { + match output_format { + CliOutputFormat::Text => { + println!("{}", render_version_report()); + } + CliOutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&version_json_value())?); + } + } + Ok(()) +} + +pub(crate) fn version_json_value() -> serde_json::Value { + serde_json::json!({ + "kind": "version", + "version": VERSION, + "git_sha": GIT_SHA, + "build_target": BUILD_TARGET, + "build_date": DEFAULT_DATE, + }) +} + +pub(crate) fn print_help(output_format: CliOutputFormat) -> Result<(), Box> { + match output_format { + CliOutputFormat::Text => { + println!("{}", full_help_text()); + } + CliOutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "kind": "help", + "text": full_help_text(), + }))? + ); + } + } + Ok(()) +} + +pub(crate) fn print_connected_line(model: &str) -> String { + let provider = if model.to_lowercase().contains("grok") { + "xAI" + } else { + "Anthropic" + }; + format!("\x1b[2mConnected to {provider} ({model})\x1b[0m") +} + diff --git a/rust/crates/rusty-claude-cli/src/config.rs b/rust/crates/rusty-claude-cli/src/config.rs new file mode 100644 index 00000000..36471484 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/config.rs @@ -0,0 +1,175 @@ +use crate::*; +use runtime::{ConfigLoader, ConfigSource, ProjectContext}; +use serde_json; +use std::env; + +pub(crate) fn render_config_report(section: Option<&str>) -> Result> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let discovered = loader.discover(); + let runtime_config = loader.load()?; + + let mut lines = vec![ + format!( + "Config + Working directory {} + Loaded files {} + Merged keys {}", + cwd.display(), + runtime_config.loaded_entries().len(), + runtime_config.merged().len() + ), + "Discovered files".to_string(), + ]; + for entry in discovered { + let source = match entry.source { + ConfigSource::User => "user", + ConfigSource::Project => "project", + ConfigSource::Local => "local", + }; + let status = if runtime_config + .loaded_entries() + .iter() + .any(|loaded_entry| loaded_entry.path == entry.path) + { + "loaded" + } else { + "missing" + }; + lines.push(format!( + " {source:<7} {status:<7} {}", + entry.path.display() + )); + } + + if let Some(section) = section { + lines.push(format!("Merged section: {section}")); + let value = match section { + "env" => runtime_config.get("env"), + "hooks" => runtime_config.get("hooks"), + "model" => runtime_config.get("model"), + "plugins" => runtime_config + .get("plugins") + .or_else(|| runtime_config.get("enabledPlugins")), + other => { + lines.push(format!( + " Unsupported config section '{other}'. Use env, hooks, model, or plugins." + )); + return Ok(lines.join("\n")); + } + }; + lines.push(format!( + " {}", + match value { + Some(value) => value.render(), + None => "".to_string(), + } + )); + return Ok(lines.join("\n")); + } + + lines.push("Merged JSON".to_string()); + lines.push(format!(" {}", runtime_config.as_json().render())); + Ok(lines.join("\n")) +} + +pub(crate) fn render_config_json( + _section: Option<&str>, +) -> Result> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let discovered = loader.discover(); + let runtime_config = loader.load()?; + + let loaded_paths: Vec<_> = runtime_config + .loaded_entries() + .iter() + .map(|e| e.path.display().to_string()) + .collect(); + + let files: Vec<_> = discovered + .iter() + .map(|e| { + let source = match e.source { + ConfigSource::User => "user", + ConfigSource::Project => "project", + ConfigSource::Local => "local", + }; + let is_loaded = runtime_config + .loaded_entries() + .iter() + .any(|le| le.path == e.path); + serde_json::json!({ + "path": e.path.display().to_string(), + "source": source, + "loaded": is_loaded, + }) + }) + .collect(); + + Ok(serde_json::json!({ + "kind": "config", + "cwd": cwd.display().to_string(), + "loaded_files": loaded_paths.len(), + "merged_keys": runtime_config.merged().len(), + "files": files, + })) +} + +pub(crate) fn render_memory_report() -> Result> { + let cwd = env::current_dir()?; + let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?; + let mut lines = vec![format!( + "Memory + Working directory {} + Instruction files {}", + cwd.display(), + project_context.instruction_files.len() + )]; + if project_context.instruction_files.is_empty() { + lines.push("Discovered files".to_string()); + lines.push( + " No CLAUDE instruction files discovered in the current directory ancestry." + .to_string(), + ); + } else { + lines.push("Discovered files".to_string()); + for (index, file) in project_context.instruction_files.iter().enumerate() { + let preview = file.content.lines().next().unwrap_or("").trim(); + let preview = if preview.is_empty() { + "" + } else { + preview + }; + lines.push(format!(" {}. {}", index + 1, file.path.display(),)); + lines.push(format!( + " lines={} preview={}", + file.content.lines().count(), + preview + )); + } + } + Ok(lines.join("\n")) +} + +pub(crate) fn render_memory_json() -> Result> { + let cwd = env::current_dir()?; + let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?; + let files: Vec<_> = project_context + .instruction_files + .iter() + .map(|f| { + serde_json::json!({ + "path": f.path.display().to_string(), + "lines": f.content.lines().count(), + "preview": f.content.lines().next().unwrap_or("").trim(), + }) + }) + .collect(); + Ok(serde_json::json!({ + "kind": "memory", + "cwd": cwd.display().to_string(), + "instruction_files": files.len(), + "files": files, + })) +} \ No newline at end of file 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..378d7a0a --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/constants.rs @@ -0,0 +1,156 @@ +use std::collections::BTreeSet; +use std::time::Duration; + +pub(crate) const DEFAULT_MODEL: &str = "claude-opus-4-6"; +pub(crate) const DEFAULT_DATE: &str = match option_env!("BUILD_DATE") { + Some(d) => d, + None => "unknown", +}; +pub(crate) const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545; +pub(crate) const VERSION: &str = env!("CARGO_PKG_VERSION"); +pub(crate) const BUILD_TARGET: Option<&str> = option_env!("TARGET"); +pub(crate) const GIT_SHA: Option<&str> = option_env!("GIT_SHA"); +pub(crate) const INTERNAL_PROGRESS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(3); +pub(crate) const POST_TOOL_STALL_TIMEOUT: Duration = Duration::from_secs(10); +pub(crate) const PRIMARY_SESSION_EXTENSION: &str = "jsonl"; +pub(crate) const LEGACY_SESSION_EXTENSION: &str = "json"; +pub(crate) const OFFICIAL_REPO_URL: &str = "https://github.com/ultraworkers/claw-code"; +pub(crate) const OFFICIAL_REPO_SLUG: &str = "ultraworkers/claw-code"; +pub(crate) const DEPRECATED_INSTALL_COMMAND: &str = "cargo install claw-code"; +pub(crate) const LATEST_SESSION_REFERENCE: &str = "latest"; +pub(crate) const SESSION_REFERENCE_ALIASES: &[&str] = &[ + LATEST_SESSION_REFERENCE, + "last", + "recent", +]; +pub(crate) const CLI_OPTION_SUGGESTIONS: &[&str] = &[ + "--help", + "-h", + "--version", + "-V", + "--model", + "--output-format", + "--permission-mode", + "--dangerously-skip-permissions", + "--allowedTools", + "--allowed-tools", + "--resume", + "--acp", + "-acp", + "--print", + "--compact", + "--base-commit", + "-p", +]; + +pub(crate) const DEFAULT_HISTORY_LIMIT: usize = 20; +pub(crate) type AllowedToolSet = BTreeSet; + +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/diff.rs b/rust/crates/rusty-claude-cli/src/diff.rs new file mode 100644 index 00000000..8e383adf --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/diff.rs @@ -0,0 +1,83 @@ +use crate::*; +use std::env; +use std::path::Path; +use std::process::Command; + +pub(crate) fn render_diff_report() -> Result> { + render_diff_report_for(&env::current_dir()?) +} + +pub(crate) fn render_diff_report_for(cwd: &Path) -> Result> { + // Verify we are inside a git repository before calling `git diff`. + // Running `git diff --cached` outside a git tree produces a misleading + // "unknown option `cached`" error because git falls back to --no-index mode. + let in_git_repo = Command::new("git") + .args(["rev-parse", "--is-inside-work-tree"]) + .current_dir(cwd) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if !in_git_repo { + return Ok(format!( + "Diff\n Result no git repository\n Detail {} is not inside a git project", + cwd.display() + )); + } + let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?; + let unstaged = run_git_diff_command_in(cwd, &["diff"])?; + if staged.trim().is_empty() && unstaged.trim().is_empty() { + return Ok( + "Diff\n Result clean working tree\n Detail no current changes" + .to_string(), + ); + } + + let mut sections = Vec::new(); + if !staged.trim().is_empty() { + sections.push(format!("Staged changes:\n{}", staged.trim_end())); + } + if !unstaged.trim().is_empty() { + sections.push(format!("Unstaged changes:\n{}", unstaged.trim_end())); + } + + Ok(format!("Diff\n\n{}", sections.join("\n\n"))) +} + +pub(crate) fn render_diff_json_for(cwd: &Path) -> Result> { + let in_git_repo = Command::new("git") + .args(["rev-parse", "--is-inside-work-tree"]) + .current_dir(cwd) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if !in_git_repo { + return Ok(serde_json::json!({ + "kind": "diff", + "result": "no_git_repo", + "detail": format!("{} is not inside a git project", cwd.display()), + })); + } + let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?; + let unstaged = run_git_diff_command_in(cwd, &["diff"])?; + Ok(serde_json::json!({ + "kind": "diff", + "result": if staged.trim().is_empty() && unstaged.trim().is_empty() { "clean" } else { "changes" }, + "staged": staged.trim(), + "unstaged": unstaged.trim(), + })) +} + +pub(crate) fn run_git_diff_command_in( + cwd: &Path, + args: &[&str], +) -> Result> { + let output = Command::new("git") + .args(args) + .current_dir(cwd) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!("git {} failed: {stderr}", args.join(" ")).into()); + } + Ok(String::from_utf8(output.stdout)?) +} 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..0321ad82 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/doctor.rs @@ -0,0 +1,398 @@ +use std::env; +use std::path::Path; + +use runtime::{ConfigLoader, ProjectContext}; +use serde_json::{json, Map, Value}; + +use crate::constants::{BUILD_TARGET, DEFAULT_DATE, DEPRECATED_INSTALL_COMMAND, GIT_SHA, OFFICIAL_REPO_SLUG, OFFICIAL_REPO_URL, VERSION}; +use crate::{parse_git_status_metadata, parse_git_workspace_summary, resolve_sandbox_status, CliOutputFormat, StatusContext}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DiagnosticLevel { + Ok, + Warn, + Fail, +} + +impl DiagnosticLevel { + pub(crate) fn label(self) -> &'static str { + match self { + Self::Ok => "ok", + Self::Warn => "warn", + Self::Fail => "fail", + } + } + + pub(crate) fn is_failure(self) -> bool { + matches!(self, Self::Fail) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DiagnosticCheck { + pub(crate) name: &'static str, + pub(crate) level: DiagnosticLevel, + pub(crate) summary: String, + pub(crate) details: Vec, + 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(); + serde_json::json!({ + "kind": "doctor", + "message": report, + "report": report, + "has_failures": self.has_failures(), + "summary": { + "total": self.checks.len(), + "ok": ok_count, + "warnings": warn_count, + "failures": fail_count, + }, + "checks": self.checks.iter().map(DiagnosticCheck::json_value).collect::>(), + }) + } +} + +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, + session_lifecycle: crate::classify_session_lifecycle_for(&cwd), + sandbox_status: resolve_sandbox_status(sandbox_config.sandbox(), &cwd), + config_load_error: config.as_ref().err().map(ToString::to_string), + }; + Ok(DoctorReport { + checks: vec![ + check_auth_health(), + check_config_health(&config_loader, config.as_ref()), + check_install_source_health(), + check_workspace_health(&context), + check_sandbox_health(&context.sandbox_status), + check_system_health(&cwd, config.as_ref().ok()), + ], + }) +} + +pub(crate) fn run_doctor(output_format: CliOutputFormat) -> Result<(), Box> { + 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(),serde_json::json!(api_key_present)), + ("auth_token_present".to_string(),serde_json::json!(auth_token_present)), + ("legacy_saved_oauth_present".to_string(),serde_json::json!(true)), + ("legacy_saved_oauth_expires_at".to_string(),serde_json::json!(token_set.expires_at)), + ("legacy_refresh_token_present".to_string(),serde_json::json!(token_set.refresh_token.is_some())), + ("legacy_scopes".to_string(),serde_json::json!(token_set.scopes)), + ])), + Ok(None) => DiagnosticCheck::new( + "Auth", + if api_key_present || auth_token_present { DiagnosticLevel::Ok } else { DiagnosticLevel::Warn }, + if api_key_present || auth_token_present { + "supported auth env vars are configured" + } else { + "no supported auth env vars were found" + }, + ) + .with_details(vec![env_details]) + .with_data(Map::from_iter([ + ("api_key_present".to_string(),serde_json::json!(api_key_present)), + ("auth_token_present".to_string(),serde_json::json!(auth_token_present)), + ("legacy_saved_oauth_present".to_string(),serde_json::json!(false)), + ("legacy_saved_oauth_expires_at".to_string(), Value::Null), + ("legacy_refresh_token_present".to_string(),serde_json::json!(false)), + ("legacy_scopes".to_string(),serde_json::json!(Vec::::new())), + ])), + Err(error) => DiagnosticCheck::new( + "Auth", + DiagnosticLevel::Fail, + format!("failed to inspect legacy saved credentials: {error}"), + ) + .with_data(Map::from_iter([ + ("api_key_present".to_string(),serde_json::json!(api_key_present)), + ("auth_token_present".to_string(),serde_json::json!(auth_token_present)), + ("legacy_saved_oauth_present".to_string(), Value::Null), + ("legacy_saved_oauth_expires_at".to_string(), Value::Null), + ("legacy_refresh_token_present".to_string(), Value::Null), + ("legacy_scopes".to_string(), Value::Null), + ("legacy_saved_oauth_error".to_string(),serde_json::json!(error.to_string())), + ])), + } +} + +fn check_config_health( + config_loader: &ConfigLoader, + config: Result<&runtime::RuntimeConfig, &runtime::ConfigError>, +) -> DiagnosticCheck { + let discovered = config_loader.discover(); + let present_paths: Vec = 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(),serde_json::json!(present_paths)), + ("discovered_files_count".to_string(),serde_json::json!(present_paths.len())), + ("loaded_config_files".to_string(),serde_json::json!(loaded_entries.len())), + ("resolved_model".to_string(),serde_json::json!(runtime_config.model())), + ("mcp_servers".to_string(),serde_json::json!(runtime_config.mcp().servers().len())), + ])) + } + Err(error) => DiagnosticCheck::new("Config", DiagnosticLevel::Fail, format!("runtime config failed to load: {error}")) + .with_details(if discovered_paths.is_empty() { vec!["Discovered files ".to_string()] } else { discovered_paths.iter().map(|p| format!("Discovered file {p}")).collect() }) + .with_data(Map::from_iter([ + ("discovered_files".to_string(),serde_json::json!(discovered_paths)), + ("discovered_files_count".to_string(),serde_json::json!(discovered_paths.len())), + ("loaded_config_files".to_string(),serde_json::json!(0)), + ("resolved_model".to_string(), Value::Null), + ("mcp_servers".to_string(), Value::Null), + ("load_error".to_string(),serde_json::json!(error.to_string())), + ])), + } +} + +fn check_install_source_health() -> DiagnosticCheck { + DiagnosticCheck::new("Install source", DiagnosticLevel::Ok, format!("official source of truth is {OFFICIAL_REPO_SLUG}; avoid `{DEPRECATED_INSTALL_COMMAND}`")) + .with_details(vec![ + format!("Official repo {OFFICIAL_REPO_URL}"), + "Recommended path build from this repo or use the upstream binary documented in README.md".to_string(), + format!("Deprecated crate `{DEPRECATED_INSTALL_COMMAND}` installs a deprecated stub and does not provide the `claw` binary"), + ]) + .with_data(Map::from_iter([ + ("official_repo".to_string(),serde_json::json!(OFFICIAL_REPO_URL)), + ("deprecated_install".to_string(),serde_json::json!(DEPRECATED_INSTALL_COMMAND)), + ("recommended_install".to_string(),serde_json::json!("build from source or follow the upstream binary instructions in README.md")), + ])) +} + +fn check_workspace_health(context: &StatusContext) -> DiagnosticCheck { + let in_repo = context.project_root.is_some(); + DiagnosticCheck::new("Workspace", if in_repo { DiagnosticLevel::Ok } else { DiagnosticLevel::Warn }, + if in_repo { format!("project root detected on branch {}", context.git_branch.as_deref().unwrap_or("unknown")) } else { "current directory is not inside a git project".to_string() }, + ) + .with_details(vec![ + format!("Cwd {}", context.cwd.display()), + format!("Project root {}", context.project_root.as_ref().map_or_else(|| "".to_string(), |p| p.display().to_string())), + format!("Git branch {}", context.git_branch.as_deref().unwrap_or("unknown")), + format!("Git state {}", context.git_summary.headline()), + format!("Changed files {}", context.git_summary.changed_files), + format!("Memory files {} · config files loaded {}/{}", context.memory_file_count, context.loaded_config_files, context.discovered_config_files), + ]) + .with_data(Map::from_iter([ + ("cwd".to_string(),serde_json::json!(context.cwd.display().to_string())), + ("project_root".to_string(),serde_json::json!(context.project_root.as_ref().map(|p| p.display().to_string()))), + ("in_git_repo".to_string(),serde_json::json!(in_repo)), + ("git_branch".to_string(),serde_json::json!(context.git_branch)), + ("git_state".to_string(),serde_json::json!(context.git_summary.headline())), + ("changed_files".to_string(),serde_json::json!(context.git_summary.changed_files)), + ("memory_file_count".to_string(),serde_json::json!(context.memory_file_count)), + ("loaded_config_files".to_string(),serde_json::json!(context.loaded_config_files)), + ("discovered_config_files".to_string(),serde_json::json!(context.discovered_config_files)), + ])) +} + +fn check_sandbox_health(status: &runtime::SandboxStatus) -> DiagnosticCheck { + let degraded = status.enabled && !status.active; + let mut details = vec![ + format!("Enabled {}", status.enabled), + format!("Active {}", status.active), + format!("Supported {}", status.supported), + format!("Filesystem mode {}", status.filesystem_mode.as_str()), + format!("Filesystem live {}", status.filesystem_active), + ]; + if let Some(reason) = &status.fallback_reason { + details.push(format!("Fallback reason {reason}")); + } + DiagnosticCheck::new("Sandbox", + if degraded { DiagnosticLevel::Warn } else { DiagnosticLevel::Ok }, + if degraded { "sandbox was requested but is not currently active" } else if status.active { "sandbox protections are active" } else { "sandbox is not active for this session" }, + ) + .with_details(details) + .with_data(Map::from_iter([ + ("enabled".to_string(),serde_json::json!(status.enabled)), + ("active".to_string(),serde_json::json!(status.active)), + ("supported".to_string(),serde_json::json!(status.supported)), + ("namespace_supported".to_string(),serde_json::json!(status.namespace_supported)), + ("namespace_active".to_string(),serde_json::json!(status.namespace_active)), + ("network_supported".to_string(),serde_json::json!(status.network_supported)), + ("network_active".to_string(),serde_json::json!(status.network_active)), + ("filesystem_mode".to_string(),serde_json::json!(status.filesystem_mode.as_str())), + ("filesystem_active".to_string(),serde_json::json!(status.filesystem_active)), + ("allowed_mounts".to_string(),serde_json::json!(status.allowed_mounts)), + ("in_container".to_string(),serde_json::json!(status.in_container)), + ("container_markers".to_string(),serde_json::json!(status.container_markers)), + ("fallback_reason".to_string(),serde_json::json!(status.fallback_reason)), + ])) +} + +fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> DiagnosticCheck { + let default_model = config.and_then(runtime::RuntimeConfig::model); + let mut details = vec![ + format!("OS {} {}", env::consts::OS, env::consts::ARCH), + format!("Working dir {}", cwd.display()), + format!("Version {}", VERSION), + format!("Build target {}", BUILD_TARGET.unwrap_or("")), + 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(),serde_json::json!(env::consts::OS)), + ("arch".to_string(),serde_json::json!(env::consts::ARCH)), + ("working_dir".to_string(),serde_json::json!(cwd.display().to_string())), + ("version".to_string(),serde_json::json!(VERSION)), + ("build_target".to_string(),serde_json::json!(BUILD_TARGET)), + ("git_sha".to_string(),serde_json::json!(GIT_SHA)), + ("default_model".to_string(),serde_json::json!(default_model)), + ])) +} diff --git a/rust/crates/rusty-claude-cli/src/export.rs b/rust/crates/rusty-claude-cli/src/export.rs new file mode 100644 index 00000000..1b012c1c --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/export.rs @@ -0,0 +1,110 @@ +use crate::*; +use std::path::{Path, PathBuf}; + + +pub(crate) fn run_export( + session_reference: &str, + output_path: Option<&Path>, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let (handle, session) = load_session_reference(session_reference)?; + let markdown = render_session_markdown(&session, &handle.id, &handle.path); + + if let Some(path) = output_path { + fs::write(path, &markdown)?; + let report = format!( + "Export\n Result wrote markdown transcript\n File {}\n Session {}\n Messages {}", + path.display(), + handle.id, + session.messages.len(), + ); + match output_format { + CliOutputFormat::Text => println!("{report}"), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "kind": "export", + "message": report, + "session_id": handle.id, + "file": path.display().to_string(), + "messages": session.messages.len(), + }))? + ), + } + return Ok(()); + } + + match output_format { + CliOutputFormat::Text => { + print!("{markdown}"); + if !markdown.ends_with('\n') { + println!(); + } + } + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "kind": "export", + "session_id": handle.id, + "file": handle.path.display().to_string(), + "messages": session.messages.len(), + "markdown": markdown, + }))? + ), + } + Ok(()) +} + +pub(crate) fn default_export_filename(session: &Session) -> String { + let stem = session + .messages + .iter() + .find_map(|message| match message.role { + MessageRole::User => message.blocks.iter().find_map(|block| match block { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }), + _ => None, + }) + .map_or("conversation", |text| { + text.lines().next().unwrap_or("conversation") + }) + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::() + .split('-') + .filter(|part| !part.is_empty()) + .take(8) + .collect::>() + .join("-"); + let fallback = if stem.is_empty() { + "conversation" + } else { + &stem + }; + format!("{fallback}.txt") +} + +pub(crate) fn resolve_export_path( + requested_path: Option<&str>, + session: &Session, +) -> Result> { + let cwd = std::env::current_dir()?; + let file_name = + requested_path.map_or_else(|| default_export_filename(session), ToOwned::to_owned); + let final_name = if Path::new(&file_name) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("txt")) + { + file_name + } else { + format!("{file_name}.txt") + }; + Ok(cwd.join(final_name)) +} diff --git a/rust/crates/rusty-claude-cli/src/formatting/helpers.rs b/rust/crates/rusty-claude-cli/src/formatting/helpers.rs new file mode 100644 index 00000000..74285016 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/formatting/helpers.rs @@ -0,0 +1,199 @@ +use serde_json::Value; + +pub(crate) fn civil_from_days(days: i64) -> (i32, u32, u32) { + let z = days + 719_468; + let era = if z >= 0 { + z / 146_097 + } else { + (z - 146_096) / 146_097 + }; + let doe = (z - era * 146_097) as u64; // [0, 146_096] + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31] + let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] + let y = y + i64::from(m <= 2); + (y as i32, m as u32, d as u32) +} + +pub(crate) fn indent_block(value: &str, spaces: usize) -> String { + let indent = " ".repeat(spaces); + value + .lines() + .map(|line| format!("{indent}{line}")) + .collect::>() + .join("\n") +} + +pub(crate) fn truncate_for_prompt(value: &str, limit: usize) -> String { + if value.chars().count() <= limit { + value.trim().to_string() + } else { + let truncated = value.chars().take(limit).collect::(); + format!("{}\n…[truncated]", truncated.trim_end()) + } +} + +pub(crate) fn extract_tool_path(parsed: &Value) -> String { + parsed + .get("file_path") + .or_else(|| parsed.get("filePath")) + .or_else(|| parsed.get("path")) + .and_then(|value| value.as_str()) + .unwrap_or("?") + .to_string() +} + +pub(crate) fn first_visible_line(text: &str) -> &str { + text.lines().find(|l| !l.trim().is_empty()).unwrap_or("") +} + +pub(crate) fn final_assistant_text(summary: &runtime::TurnSummary) -> String { + summary + .assistant_messages + .last() + .map(|message| { + message + .blocks + .iter() + .filter_map(|block| match block { + runtime::ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("") + }) + .unwrap_or_default() +} + +pub(crate) fn collect_tool_uses(summary: &runtime::TurnSummary) -> Vec { + summary + .assistant_messages + .iter() + .flat_map(|message| message.blocks.iter()) + .filter_map(|block| match block { + runtime::ContentBlock::ToolUse { id, name, input } => Some(serde_json::json!({ + "id": id, + "name": name, + "input": input, + })), + _ => None, + }) + .collect() +} + +pub(crate) fn collect_tool_results(summary: &runtime::TurnSummary) -> Vec { + summary + .tool_results + .iter() + .flat_map(|message| message.blocks.iter()) + .filter_map(|block| match block { + runtime::ContentBlock::ToolResult { + tool_use_id, + tool_name, + output, + is_error, + } => Some(serde_json::json!({ + "tool_use_id": tool_use_id, + "tool_name": tool_name, + "output": output, + "is_error": is_error, + })), + _ => None, + }) + .collect() +} + +pub(crate) fn collect_prompt_cache_events(summary: &runtime::TurnSummary) -> Vec { + summary + .prompt_cache_events + .iter() + .map(|event| { + serde_json::json!({ + "unexpected": event.unexpected, + "reason": event.reason, + "previous_cache_read_input_tokens": event.previous_cache_read_input_tokens, + "current_cache_read_input_tokens": event.current_cache_read_input_tokens, + "token_drop": event.token_drop, + }) + }) + .collect() +} + +pub(crate) fn sanitize_generated_message(value: &str) -> String { + value.trim().trim_matches('`').trim().replace("\r\n", "\n") +} + +pub(crate) fn parse_titled_body(value: &str) -> Option<(String, String)> { + let normalized = sanitize_generated_message(value); + let title = normalized + .lines() + .find_map(|line| line.strip_prefix("TITLE:").map(str::trim))?; + let body_start = normalized.find("BODY:")?; + let body = normalized[body_start + "BODY:".len()..].trim(); + Some((title.to_string(), body.to_string())) +} + +pub(crate) fn truncate_output_for_display(content: &str, max_lines: usize, max_chars: usize) -> String { + let original = content.trim_end_matches('\n'); + if original.is_empty() { + return String::new(); + } + + let mut preview_lines = Vec::new(); + let mut used_chars = 0usize; + let mut truncated = false; + + for (index, line) in original.lines().enumerate() { + if index >= max_lines { + truncated = true; + break; + } + + let newline_cost = usize::from(!preview_lines.is_empty()); + let available = max_chars.saturating_sub(used_chars + newline_cost); + if available == 0 { + truncated = true; + break; + } + + let line_chars = line.chars().count(); + if line_chars > available { + preview_lines.push(line.chars().take(available).collect::()); + truncated = true; + break; + } + + preview_lines.push(line.to_string()); + used_chars += newline_cost + line_chars; + } + + let mut preview = preview_lines.join("\n"); + if truncated { + if !preview.is_empty() { + preview.push('\n'); + } + preview.push_str(super::DISPLAY_TRUNCATION_NOTICE); + } + preview +} + +pub(crate) fn summarize_tool_payload(payload: &str) -> String { + let compact = match serde_json::from_str::(payload) { + Ok(value) => value.to_string(), + Err(_) => payload.trim().to_string(), + }; + truncate_for_summary(&compact, 96) +} + +pub(crate) fn truncate_for_summary(value: &str, limit: usize) -> String { + let mut chars = value.chars(); + let truncated = chars.by_ref().take(limit).collect::(); + if chars.next().is_some() { + format!("{truncated}…") + } else { + truncated + } +} diff --git a/rust/crates/rusty-claude-cli/src/formatting/mod.rs b/rust/crates/rusty-claude-cli/src/formatting/mod.rs new file mode 100644 index 00000000..14b6f53d --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/formatting/mod.rs @@ -0,0 +1,19 @@ +pub(crate) mod helpers; +pub(crate) mod reports; +pub(crate) mod tools; + +pub(crate) use helpers::*; +pub(crate) use reports::*; +pub(crate) use tools::*; + +pub(crate) fn full_help_text() -> String { + reports::full_help_text() +} + +pub(crate) const DISPLAY_TRUNCATION_NOTICE: &str = + "\x1b[2m… output truncated for display; full result preserved in session.\x1b[0m"; +pub(crate) const READ_DISPLAY_MAX_LINES: usize = 80; +pub(crate) const READ_DISPLAY_MAX_CHARS: usize = 6_000; +pub(crate) const TOOL_OUTPUT_DISPLAY_MAX_LINES: usize = 60; +pub(crate) const TOOL_OUTPUT_DISPLAY_MAX_CHARS: usize = 4_000; +pub(crate) const SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT: usize = 280; diff --git a/rust/crates/rusty-claude-cli/src/formatting/reports.rs b/rust/crates/rusty-claude-cli/src/formatting/reports.rs new file mode 100644 index 00000000..50e6ab09 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/formatting/reports.rs @@ -0,0 +1,755 @@ +use std::path::Path; +use std::process::Command; +use std::env; +use std::time::UNIX_EPOCH; +use runtime::{Session, TokenUsage}; +use commands::render_slash_command_help_filtered; +use crate::constants::*; +use crate::models::*; +use crate::live_cli::PromptHistoryEntry; +use crate::args::LocalHelpTopic; +use crate::omc_compatibility_note_for_unknown_slash_command; +use crate::render::TerminalRenderer; +use super::helpers::*; +use super::tools::format_tool_result; + + + +pub(crate) fn format_model_report(model: &str, message_count: usize, turns: u32) -> String { + format!( + "Model + Current model {model} + Session messages {message_count} + Session turns {turns} + +Usage + Inspect current model with /model + Switch models with /model " + ) +} + +pub(crate) fn format_model_switch_report(previous: &str, next: &str, message_count: usize) -> String { + format!( + "Model updated + Previous {previous} + Current {next} + Preserved msgs {message_count}" + ) +} + +pub(crate) fn format_permissions_report(mode: &str) -> String { + let modes = [ + ("read-only", "Read/search tools only", mode == "read-only"), + ( + "workspace-write", + "Edit files inside the workspace", + mode == "workspace-write", + ), + ( + "danger-full-access", + "Unrestricted tool access", + mode == "danger-full-access", + ), + ] + .into_iter() + .map(|(name, description, is_current)| { + let marker = if is_current { + "● current" + } else { + "○ available" + }; + format!(" {name:<18} {marker:<11} {description}") + }) + .collect::>() + .join("\n"); + + format!( + "Permissions + Active mode {mode} + Mode status live session default + +Modes +{modes} + +Usage + Inspect current mode with /permissions + Switch modes with /permissions " + ) +} + +pub(crate) fn format_permissions_switch_report(previous: &str, next: &str) -> String { + format!( + "Permissions updated + Result mode switched + Previous mode {previous} + Active mode {next} + Applies to subsequent tool calls + Usage /permissions to inspect current mode" + ) +} + +pub(crate) fn format_cost_report(usage: TokenUsage) -> String { + format!( + "Cost + Input tokens {} + Output tokens {} + Cache create {} + Cache read {} + Total tokens {}", + usage.input_tokens, + usage.output_tokens, + usage.cache_creation_input_tokens, + usage.cache_read_input_tokens, + usage.total_tokens(), + ) +} + +pub(crate) fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String { + format!( + "Session resumed + Session file {session_path} + Messages {message_count} + Turns {turns}" + ) +} + +pub(crate) fn render_resume_usage() -> String { + format!( + "Resume + Usage /resume + Auto-save .claw/sessions/.{PRIMARY_SESSION_EXTENSION} + Tip use /session list to inspect saved sessions" + ) +} + +pub(crate) fn render_repl_help() -> String { + [ + "REPL".to_string(), + " /exit Quit the REPL".to_string(), + " /quit Quit the REPL".to_string(), + " Up/Down Navigate prompt history".to_string(), + " Ctrl-R Reverse-search prompt history".to_string(), + " Tab Complete commands, modes, and recent sessions".to_string(), + " Ctrl-C Clear input (or exit on empty prompt)".to_string(), + " Shift+Enter/Ctrl+J Insert a newline".to_string(), + " Auto-save .claw/sessions/.jsonl".to_string(), + " Resume latest /resume latest".to_string(), + " Browse sessions /session list".to_string(), + " Show prompt history /history [count]".to_string(), + String::new(), + render_slash_command_help_filtered(STUB_COMMANDS), + ] + .join("\n") +} + +pub(crate) fn full_help_text() -> String { + let mut out = Vec::new(); + let _ = print_help_to(&mut out); + String::from_utf8_lossy(&out).to_string() +} + +pub(crate) fn print_help_to(out: &mut impl std::io::Write) -> std::io::Result<()> { + writeln!(out, "claw v{VERSION}")?; + writeln!(out)?; + writeln!(out, "Usage:")?; + writeln!( + out, + " claw [--model MODEL] [--allowedTools TOOL[,TOOL...]]" + )?; + writeln!(out, " Start the interactive REPL")?; + writeln!( + out, + " claw [--model MODEL] [--output-format text|json] prompt TEXT" + )?; + writeln!(out, " Send one prompt and exit")?; + writeln!( + out, + " claw [--model MODEL] [--output-format text|json] TEXT" + )?; + writeln!(out, " Shorthand non-interactive prompt mode")?; + writeln!( + out, + " claw --resume [SESSION.jsonl|session-id|latest] [/status] [/compact] [...]" + )?; + writeln!( + out, + " Inspect or maintain a saved session without entering the REPL" + )?; + writeln!(out, " claw help")?; + writeln!(out, " Alias for --help")?; + writeln!(out, " claw version")?; + writeln!(out, " Alias for --version")?; + writeln!(out, " claw status")?; + writeln!( + out, + " Show the current local workspace status snapshot" + )?; + writeln!(out, " claw sandbox")?; + writeln!(out, " Show the current sandbox isolation snapshot")?; + writeln!(out, " claw doctor")?; + writeln!( + out, + " Diagnose local auth, config, workspace, and sandbox health" + )?; + writeln!(out, " claw acp [serve]")?; + writeln!( + out, + " Show ACP/Zed editor integration status (currently unsupported; aliases: --acp, -acp)" + )?; + writeln!(out, " Source of truth: {OFFICIAL_REPO_SLUG}")?; + writeln!( + out, + " Warning: do not `{DEPRECATED_INSTALL_COMMAND}` (deprecated stub)" + )?; + writeln!(out, " claw dump-manifests [--manifests-dir PATH]")?; + writeln!(out, " claw bootstrap-plan")?; + writeln!(out, " claw agents")?; + writeln!(out, " claw mcp")?; + writeln!(out, " claw skills")?; + writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?; + writeln!(out, " claw init")?; + writeln!( + out, + " claw export [PATH] [--session SESSION] [--output PATH]" + )?; + writeln!( + out, + " Dump the latest (or named) session as markdown; writes to PATH or stdout" + )?; + writeln!(out)?; + writeln!(out, "Flags:")?; + writeln!( + out, + " --model MODEL Override the active model" + )?; + writeln!( + out, + " --output-format FORMAT Non-interactive output format: text or json" + )?; + writeln!( + out, + " --compact Strip tool call details; print only the final assistant text (text mode only; useful for piping)" + )?; + writeln!( + out, + " --permission-mode MODE Set read-only, workspace-write, or danger-full-access" + )?; + writeln!( + out, + " --dangerously-skip-permissions Skip all permission checks" + )?; + writeln!(out, " --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)")?; + writeln!( + out, + " --version, -V Print version and build information locally" + )?; + writeln!(out)?; + writeln!(out, "Interactive slash commands:")?; + writeln!(out, "{}", render_slash_command_help_filtered(STUB_COMMANDS))?; + writeln!(out)?; + let resume_commands = commands::resume_supported_slash_commands() + .into_iter() + .map(|spec| match spec.argument_hint { + Some(argument_hint) => format!("/{} {}", spec.name, argument_hint), + None => format!("/{}", spec.name), + }) + .collect::>() + .join(", "); + writeln!(out, "Resume-safe commands: {resume_commands}")?; + writeln!(out)?; + writeln!(out, "Session shortcuts:")?; + writeln!( + out, + " REPL turns auto-save to .claw/sessions/.{PRIMARY_SESSION_EXTENSION}" + )?; + writeln!( + out, + " Use `{LATEST_SESSION_REFERENCE}` with --resume, /resume, or /session switch to target the newest saved session" + )?; + writeln!( + out, + " Use /session list in the REPL to browse managed sessions" + )?; + writeln!(out, "Examples:")?; + writeln!(out, " claw --model claude-opus \"summarize this repo\"")?; + writeln!( + out, + " claw --output-format json prompt \"explain src/main.rs\"" + )?; + writeln!(out, " claw --compact \"summarize Cargo.toml\" | wc -l")?; + writeln!( + out, + " claw --allowedTools read,glob \"summarize Cargo.toml\"" + )?; + writeln!(out, " claw --resume {LATEST_SESSION_REFERENCE}")?; + writeln!( + out, + " claw --resume {LATEST_SESSION_REFERENCE} /status /diff /export notes.txt" + )?; + writeln!(out, " claw agents")?; + writeln!(out, " claw mcp show my-server")?; + writeln!(out, " claw /skills")?; + writeln!(out, " claw doctor")?; + writeln!(out, " source of truth: {OFFICIAL_REPO_URL}")?; + writeln!( + out, + " do not run `{DEPRECATED_INSTALL_COMMAND}` — it installs a deprecated stub" + )?; + writeln!(out, " claw init")?; + writeln!(out, " claw export")?; + writeln!(out, " claw export conversation.md")?; + Ok(()) +} + + +pub(crate) fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String { + if skipped { + format!( + "Compact + Result skipped + Reason session below compaction threshold + Messages kept {resulting_messages}" + ) + } else { + format!( + "Compact + Result compacted + Messages removed {removed} + Messages kept {resulting_messages}" + ) + } +} + +pub(crate) fn format_auto_compaction_notice(removed: usize) -> String { + format!("[auto-compacted: removed {removed} messages]") +} + +pub(crate) fn render_help_topic(topic: LocalHelpTopic) -> String { + match topic { + LocalHelpTopic::Status => "Status + Usage claw status [--output-format ] + Purpose show local workspace snapshot without entering REPL + Output model, permissions, git state, config files, and sandbox status + Formats text (default), json + Related /status · claw --resume latest /status" + .to_string(), + LocalHelpTopic::Sandbox => "Sandbox + Usage claw sandbox [--output-format ] + Purpose inspect resolved sandbox and isolation state for current directory + Output namespace, network, filesystem, and fallback details + Formats text (default), json + Related /sandbox · claw status" + .to_string(), + LocalHelpTopic::Doctor => "Doctor + Usage claw doctor [--output-format ] + Purpose diagnose local auth, config, workspace, sandbox, and build metadata + Output local-only health report; no provider request or session resume required + Formats text (default), json + Related /doctor · claw --resume latest /doctor" + .to_string(), + LocalHelpTopic::Acp => "ACP / Zed + Usage claw acp [serve] [--output-format ] + Aliases claw --acp · claw -acp + Purpose explain current editor-facing ACP/Zed launch contract without starting runtime + Status discoverability only; `serve` is a status alias and does not launch daemon yet + Formats text (default), json + Related ROADMAP #64a (discoverability) · ROADMAP #76 (real ACP support) · claw --help" + .to_string(), + LocalHelpTopic::Init => "Init + Usage claw init [--output-format ] + Purpose create .claw/, .claw.json, .gitignore, and CLAUDE.md in current project + Output list of created vs skipped files (idempotent: safe to re-run) + Formats text (default), json + Related claw status · claw doctor" + .to_string(), + LocalHelpTopic::State => "State + Usage claw state [--output-format ] + Purpose read .claw/worker-state.json written by interactive REPL or one-shot prompt + Output worker id, model, permissions, session reference (text or json) + Formats text (default), json + Produces state `claw` (interactive REPL) or `claw prompt ` (one non-interactive turn) + Observes state `claw state` reads; clawhip/CI may poll this file without HTTP + Exit codes 0 if state file exists and parses; 1 with actionable hint otherwise + Related claw status · ROADMAP #139 (this worker-concept contract)" + .to_string(), + LocalHelpTopic::Export => "Export + Usage claw export [--session ] [--output ] [--output-format ] + Purpose serialize managed session to JSON for review, transfer, or archival + Defaults --session latest (most recent managed session in .claw/sessions/) + Formats text (default), json + Related /session list · claw --resume latest" + .to_string(), + LocalHelpTopic::Version => "Version + Usage claw version [--output-format ] + Aliases claw --version · claw -V + Purpose print claw CLI version and build metadata + Formats text (default), json + Related claw doctor (full build/auth/config diagnostic)" + .to_string(), + LocalHelpTopic::SystemPrompt => "System Prompt + Usage claw system-prompt [--cwd ] [--date YYYY-MM-DD] [--output-format ] + Purpose render resolved system prompt that `claw` would send for given cwd + date + Options --cwd overrides workspace dir · --date injects deterministic date stamp + Formats text (default), json + Related claw doctor · claw dump-manifests" + .to_string(), + LocalHelpTopic::DumpManifests => "Dump Manifests + Usage claw dump-manifests [--manifests-dir ] [--output-format ] + Purpose emit every skill/agent/tool manifest resolver would load for current cwd + Options --manifests-dir scopes discovery to specific directory + Formats text (default), json + Related claw skills · claw agents · claw doctor" + .to_string(), + LocalHelpTopic::BootstrapPlan => "Bootstrap Plan + Usage claw bootstrap-plan [--output-format ] + Purpose list ordered startup phases CLI would execute before dispatch + Output phase names (text) or structured phase list (json) — primary output is plan itself + Formats text (default), json + Related claw doctor · claw status" + .to_string(), + } +} + +pub(crate) fn render_teleport_report(target: &str) -> Result> { + let cwd = env::current_dir()?; + + let file_list = Command::new("rg") + .args(["--files"]) + .current_dir(&cwd) + .output()?; + let file_matches = if file_list.status.success() { + String::from_utf8(file_list.stdout)? + .lines() + .filter(|line| line.contains(target)) + .take(10) + .map(ToOwned::to_owned) + .collect::>() + } else { + Vec::new() + }; + + let content_output = Command::new("rg") + .args(["-n", "-S", "--color", "never", target, "."]) + .current_dir(&cwd) + .output()?; + + let mut lines = vec![ + "Teleport".to_string(), + format!(" Target {target}"), + " Action search workspace files and content for target".to_string(), + ]; + if !file_matches.is_empty() { + lines.push(String::new()); + lines.push("File matches".to_string()); + lines.extend(file_matches.into_iter().map(|path| format!(" {path}"))); + } + + if content_output.status.success() { + let matches = String::from_utf8(content_output.stdout)?; + if !matches.trim().is_empty() { + lines.push(String::new()); + lines.push("Content matches".to_string()); + lines.push(truncate_for_prompt(&matches, 4_000)); + } + } + + if lines.len() == 1 { + lines.push(" Result no matches found".to_string()); + } + + Ok(lines.join("\n")) +} + +pub(crate) fn render_last_tool_debug_report(session: &Session) -> Result> { + use runtime::ContentBlock; + let last_tool_use = session + .messages + .iter() + .rev() + .find_map(|message| { + message.blocks.iter().rev().find_map(|block| match block { + ContentBlock::ToolUse { id, name, input } => { + Some((id.clone(), name.clone(), input.clone())) + } + _ => None, + }) + }) + .ok_or_else(|| "no prior tool call found in session".to_string())?; + + let tool_result = session.messages.iter().rev().find_map(|message| { + message.blocks.iter().rev().find_map(|block| match block { + ContentBlock::ToolResult { + tool_use_id, + tool_name, + output, + is_error, + } if tool_use_id == &last_tool_use.0 => { + Some((tool_name.clone(), output.clone(), *is_error)) + } + _ => None, + }) + }); + + let mut lines = vec![ + "Debug tool call".to_string(), + " Action inspect last recorded tool call and its result".to_string(), + format!(" Tool id {}", last_tool_use.0), + format!(" Tool name {}", last_tool_use.1), + " Input".to_string(), + indent_block(&last_tool_use.2, 4), + ]; + + match tool_result { + Some((tool_name, output, is_error)) => { + lines.push(" Result".to_string()); + lines.push(format!(" name {tool_name}")); + lines.push(format!( + " status {}", + if is_error { "error" } else { "ok" } + )); + lines.push(indent_block(&output, 4)); + } + None => lines.push(" Result missing tool result".to_string()), + } + + Ok(lines.join("\n")) +} + +pub(crate) fn format_bughunter_report(scope: Option<&str>) -> String { + format!( + "Bughunter + Scope {} + Action inspect the selected code for likely bugs and correctness issues + Output findings should include file paths, severity, and suggested fixes", + scope.unwrap_or("current repository") + ) +} + +pub(crate) fn format_ultraplan_report(task: Option<&str>) -> String { + format!( + "Ultraplan + Task {} + Action break work into a multi-step execution plan + Output plan should cover goals, risks, sequencing, verification, and rollback", + task.unwrap_or("current repo work") + ) +} + +pub(crate) fn format_pr_report(branch: &str, context: Option<&str>) -> String { + format!( + "PR + Branch {branch} + Context {} + Action draft or create a pull request for current branch + Output title and markdown body suitable for GitHub", + context.unwrap_or("none") + ) +} + +pub(crate) fn format_issue_report(context: Option<&str>) -> String { + format!( + "Issue + Context {} + Action draft or create a GitHub issue from current context + Output title and markdown body suitable for GitHub", + context.unwrap_or("none") + ) +} + +pub(crate) fn format_history_timestamp(timestamp_ms: u64) -> String { + let secs = timestamp_ms / 1_000; + let subsec_ms = timestamp_ms % 1_000; + let days_since_epoch = secs / 86_400; + let seconds_of_day = secs % 86_400; + let hours = seconds_of_day / 3_600; + let minutes = (seconds_of_day % 3_600) / 60; + let seconds = seconds_of_day % 60; + + let (year, month, day) = civil_from_days(i64::try_from(days_since_epoch).unwrap_or(0)); + format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}.{subsec_ms:03}Z") +} + +pub(crate) fn render_prompt_history_report(entries: &[PromptHistoryEntry], limit: usize) -> String { + if entries.is_empty() { + return "Prompt history\n Result no prompts recorded yet".to_string(); + } + + let total = entries.len(); + let start = total.saturating_sub(limit); + let shown = &entries[start..]; + let mut lines = vec![ + "Prompt history".to_string(), + format!(" Total {total}"), + format!(" Showing {} most recent", shown.len()), + format!(" Reverse search Ctrl-R in the REPL"), + String::new(), + ]; + for (offset, entry) in shown.iter().enumerate() { + let absolute_index = start + offset + 1; + let timestamp = format_history_timestamp(entry.timestamp_ms); + let first_line = entry.text.lines().next().unwrap_or("").trim(); + let display = if first_line.chars().count() > 80 { + let truncated: String = first_line.chars().take(77).collect(); + format!("{truncated}...") + } else { + first_line.to_string() + }; + lines.push(format!(" {absolute_index:>3}. [{timestamp}] {display}")); + } + lines.join("\n") +} + +pub(crate) fn render_export_text(session: &Session) -> String { + use runtime::MessageRole; + use runtime::ContentBlock; + + let mut lines = vec!["# Conversation Export".to_string(), String::new()]; + for (index, message) in session.messages.iter().enumerate() { + let role = match message.role { + MessageRole::System => "system", + MessageRole::User => "user", + MessageRole::Assistant => "assistant", + MessageRole::Tool => "tool", + }; + lines.push(format!("## {}. {role}", index + 1)); + for block in &message.blocks { + match block { + ContentBlock::Text { text } => lines.push(text.clone()), + ContentBlock::ToolUse { id, name, input } => { + lines.push(format!("[tool_use id={id} name={name}] {input}")); + } + ContentBlock::ToolResult { + tool_use_id, + tool_name, + output, + is_error, + } => { + lines.push(format!( + "[tool_result id={tool_use_id} name={tool_name} error={is_error}] {output}" + )); + } + ContentBlock::Thinking { + thinking, + signature, + } => { + lines.push(format!("[thinking signature={signature:?}] {thinking}")); + } + } + } + lines.push(String::new()); + } + lines.join("\n") +} + +pub(crate) fn render_version_report() -> String { + let git_sha = GIT_SHA.unwrap_or("unknown"); + let target = BUILD_TARGET.unwrap_or("unknown"); + format!( + "Claw Code\n Version {VERSION}\n Git SHA {git_sha}\n Target {target}\n Build date {DEFAULT_DATE}" + ) +} + +pub(crate) fn render_session_markdown(session: &Session, session_id: &str, session_path: &Path) -> String { + use runtime::{MessageRole, ContentBlock}; + use super::tools::short_tool_id; + use super::tools::summarize_tool_payload_for_markdown; + + let mut lines = vec![ + "# Conversation Export".to_string(), + String::new(), + format!("- **Session**: `{session_id}`"), + format!("- **File**: `{}`", session_path.display()), + format!("- **Messages**: {}", session.messages.len()), + ]; + if let Some(workspace_root) = session.workspace_root() { + lines.push(format!("- **Workspace**: `{}`", workspace_root.display())); + } + if let Some(fork) = &session.fork { + let branch = fork.branch_name.as_deref().unwrap_or("(unnamed)"); + lines.push(format!( + "- **Forked from**: `{}` (branch `{branch}`)", + fork.parent_session_id + )); + } + if let Some(compaction) = &session.compaction { + lines.push(format!( + "- **Compactions**: {} (last removed {} messages)", + compaction.count, compaction.removed_message_count + )); + } + lines.push(String::new()); + lines.push("---".to_string()); + lines.push(String::new()); + + for (index, message) in session.messages.iter().enumerate() { + let role = match message.role { + MessageRole::System => "System", + MessageRole::User => "User", + MessageRole::Assistant => "Assistant", + MessageRole::Tool => "Tool", + }; + lines.push(format!("## {}. {role}", index + 1)); + lines.push(String::new()); + for block in &message.blocks { + match block { + ContentBlock::Text { text } => { + let trimmed = text.trim_end(); + if !trimmed.is_empty() { + lines.push(trimmed.to_string()); + lines.push(String::new()); + } + } + ContentBlock::ToolUse { id, name, input } => { + lines.push(format!( + "**Tool call** `{name}` _(id `{}`)_", + short_tool_id(id) + )); + let summary = summarize_tool_payload_for_markdown(input); + if !summary.is_empty() { + lines.push(format!("> {summary}")); + } + lines.push(String::new()); + } + ContentBlock::ToolResult { + tool_use_id, + tool_name, + output, + is_error, + } => { + let status = if *is_error { "error" } else { "ok" }; + lines.push(format!( + "**Tool result** `{tool_name}` _(id `{}`, {status})_", + short_tool_id(tool_use_id) + )); + let summary = summarize_tool_payload_for_markdown(output); + if !summary.is_empty() { + lines.push(format!("> {summary}")); + } + lines.push(String::new()); + } + ContentBlock::Thinking { + thinking, + signature: _, + } => { + lines.push(format!("**Thinking**")); + let summary = summarize_tool_payload_for_markdown(thinking); + if !summary.is_empty() { + lines.push(format!("> {summary}")); + } + lines.push(String::new()); + } + } + } + if let Some(usage) = message.usage { + lines.push(format!( + "_tokens: in={} out={} cache_create={} cache_read={}_", + usage.input_tokens, + usage.output_tokens, + usage.cache_creation_input_tokens, + usage.cache_read_input_tokens, + )); + lines.push(String::new()); + } + } + lines.join("\n") +} diff --git a/rust/crates/rusty-claude-cli/src/formatting/tools.rs b/rust/crates/rusty-claude-cli/src/formatting/tools.rs new file mode 100644 index 00000000..14e2e6ba --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/formatting/tools.rs @@ -0,0 +1,492 @@ +use std::io::{self, Write}; +use serde_json::json; +use runtime::{RuntimeError, AssistantEvent}; +use api::OutputContentBlock; +use crate::render::TerminalRenderer; +use super::helpers::*; +use super::{ + DISPLAY_TRUNCATION_NOTICE, READ_DISPLAY_MAX_CHARS, READ_DISPLAY_MAX_LINES, + SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT, TOOL_OUTPUT_DISPLAY_MAX_CHARS, + TOOL_OUTPUT_DISPLAY_MAX_LINES, +}; + +pub(crate) fn short_tool_id(id: &str) -> String { + let char_count = id.chars().count(); + if char_count <= 12 { + return id.to_string(); + } + let prefix: String = id.chars().take(12).collect(); + format!("{prefix}…") +} + +pub(crate) fn summarize_tool_payload_for_markdown(payload: &str) -> String { + let compact = match serde_json::from_str::(payload) { + Ok(value) => value.to_string(), + Err(_) => payload.split_whitespace().collect::>().join(" "), + }; + if compact.is_empty() { + return String::new(); + } + truncate_for_summary(&compact, SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT) +} + +pub(crate) fn format_tool_call_start(name: &str, input: &str) -> String { + let parsed: serde_json::Value = + serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string())); + + let detail = match name { + "bash" | "Bash" => format_bash_call(&parsed), + "read_file" | "Read" => { + let path = extract_tool_path(&parsed); + format!("\x1b[2m📄 Reading {path}…\x1b[0m") + } + "write_file" | "Write" => { + let path = extract_tool_path(&parsed); + let lines = parsed + .get("content") + .and_then(|value| value.as_str()) + .map_or(0, |content| content.lines().count()); + format!("\x1b[1;32m✏️ Writing {path}\x1b[0m \x1b[2m({lines} lines)\x1b[0m") + } + "edit_file" | "Edit" => { + let path = extract_tool_path(&parsed); + let old_value = parsed + .get("old_string") + .or_else(|| parsed.get("oldString")) + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let new_value = parsed + .get("new_string") + .or_else(|| parsed.get("newString")) + .and_then(|value| value.as_str()) + .unwrap_or_default(); + format!( + "\x1b[1;33m📝 Editing {path}\x1b[0m{}", + format_patch_preview(old_value, new_value) + .map(|preview| format!("\n{preview}")) + .unwrap_or_default() + ) + } + "glob_search" | "Glob" => format_search_start("🔎 Glob", &parsed), + "grep_search" | "Grep" => format_search_start("🔎 Grep", &parsed), + "web_search" | "WebSearch" => parsed + .get("query") + .and_then(|value| value.as_str()) + .unwrap_or("?") + .to_string(), + _ => summarize_tool_payload(input), + }; + + let border = "─".repeat(name.len() + 8); + format!( + "\x1b[38;5;245m╭─ \x1b[1;36m{name}\x1b[0;38;5;245m ─╮\x1b[0m\n\x1b[38;5;245m│\x1b[0m {detail}\n\x1b[38;5;245m╰{border}╯\x1b[0m" + ) +} + +pub(crate) fn format_tool_result(name: &str, output: &str, is_error: bool) -> String { + let icon = if is_error { + "\x1b[1;31m✗\x1b[0m" + } else { + "\x1b[1;32m✓\x1b[0m" + }; + if is_error { + let summary = truncate_for_summary(output.trim(), 160); + return if summary.is_empty() { + format!("{icon} \x1b[38;5;245m{name}\x1b[0m") + } else { + format!("{icon} \x1b[38;5;245m{name}\x1b[0m\n\x1b[38;5;203m{summary}\x1b[0m") + }; + } + + let parsed: serde_json::Value = + serde_json::from_str(output).unwrap_or(serde_json::Value::String(output.to_string())); + match name { + "bash" | "Bash" => format_bash_result(icon, &parsed), + "read_file" | "Read" => format_read_result(icon, &parsed), + "write_file" | "Write" => format_write_result(icon, &parsed), + "edit_file" | "Edit" => format_edit_result(icon, &parsed), + "glob_search" | "Glob" => format_glob_result(icon, &parsed), + "grep_search" | "Grep" => format_grep_result(icon, &parsed), + _ => format_generic_tool_result(icon, name, &parsed), + } +} + +pub(crate) fn format_search_start(label: &str, parsed: &serde_json::Value) -> String { + let pattern = parsed + .get("pattern") + .and_then(|value| value.as_str()) + .unwrap_or("?"); + let scope = parsed + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or("."); + format!("{label} {pattern}\n\x1b[2min {scope}\x1b[0m") +} + +pub(crate) fn format_patch_preview(old_value: &str, new_value: &str) -> Option { + if old_value.is_empty() && new_value.is_empty() { + return None; + } + Some(format!( + "\x1b[38;5;203m- {}\x1b[0m\n\x1b[38;5;70m+ {}\x1b[0m", + truncate_for_summary(first_visible_line(old_value), 72), + truncate_for_summary(first_visible_line(new_value), 72) + )) +} + +pub(crate) fn format_bash_call(parsed: &serde_json::Value) -> String { + let command = parsed + .get("command") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if command.is_empty() { + String::new() + } else { + format!( + "\x1b[48;5;236;38;5;255m $ {} \x1b[0m", + truncate_for_summary(command, 160) + ) + } +} + +pub(crate) fn format_bash_result(icon: &str, parsed: &serde_json::Value) -> String { + use std::fmt::Write as _; + + let mut lines = vec![format!("{icon} \x1b[38;5;245mbash\x1b[0m")]; + if let Some(task_id) = parsed + .get("backgroundTaskId") + .and_then(|value| value.as_str()) + { + write!(&mut lines[0], " backgrounded ({task_id})").expect("write to string"); + } else if let Some(status) = parsed + .get("returnCodeInterpretation") + .and_then(|value| value.as_str()) + .filter(|status| !status.is_empty()) + { + write!(&mut lines[0], " {status}").expect("write to string"); + } + + if let Some(stdout) = parsed.get("stdout").and_then(|value| value.as_str()) { + if !stdout.trim().is_empty() { + lines.push(truncate_output_for_display( + stdout, + TOOL_OUTPUT_DISPLAY_MAX_LINES, + TOOL_OUTPUT_DISPLAY_MAX_CHARS, + )); + } + } + if let Some(stderr) = parsed.get("stderr").and_then(|value| value.as_str()) { + if !stderr.trim().is_empty() { + lines.push(format!( + "\x1b[38;5;203m{}\x1b[0m", + truncate_output_for_display( + stderr, + TOOL_OUTPUT_DISPLAY_MAX_LINES, + TOOL_OUTPUT_DISPLAY_MAX_CHARS, + ) + )); + } + } + + lines.join("\n\n") +} + +pub(crate) fn format_read_result(icon: &str, parsed: &serde_json::Value) -> String { + let file = parsed.get("file").unwrap_or(parsed); + let path = extract_tool_path(file); + let start_line = file + .get("startLine") + .and_then(serde_json::Value::as_u64) + .unwrap_or(1); + let num_lines = file + .get("numLines") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let total_lines = file + .get("totalLines") + .and_then(serde_json::Value::as_u64) + .unwrap_or(num_lines); + let content = file + .get("content") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let end_line = start_line.saturating_add(num_lines.saturating_sub(1)); + + format!( + "{icon} \x1b[2m📄 Read {path} (lines {}-{} of {})\x1b[0m\n{}", + start_line, + end_line.max(start_line), + total_lines, + truncate_output_for_display(content, READ_DISPLAY_MAX_LINES, READ_DISPLAY_MAX_CHARS) + ) +} + +pub(crate) fn format_write_result(icon: &str, parsed: &serde_json::Value) -> String { + let path = extract_tool_path(parsed); + let kind = parsed + .get("type") + .and_then(|value| value.as_str()) + .unwrap_or("write"); + let line_count = parsed + .get("content") + .and_then(|value| value.as_str()) + .map_or(0, |content| content.lines().count()); + format!( + "{icon} \x1b[1;32m✏️ {} {path}\x1b[0m \x1b[2m({line_count} lines)\x1b[0m", + if kind == "create" { "Wrote" } else { "Updated" }, + ) +} + +pub(crate) fn format_structured_patch_preview(parsed: &serde_json::Value) -> Option { + let hunks = parsed.get("structuredPatch")?.as_array()?; + let mut preview = Vec::new(); + for hunk in hunks.iter().take(2) { + let lines = hunk.get("lines")?.as_array()?; + for line in lines.iter().filter_map(|value| value.as_str()).take(6) { + match line.chars().next() { + Some('+') => preview.push(format!("\x1b[38;5;70m{line}\x1b[0m")), + Some('-') => preview.push(format!("\x1b[38;5;203m{line}\x1b[0m")), + _ => preview.push(line.to_string()), + } + } + } + if preview.is_empty() { + None + } else { + Some(preview.join("\n")) + } +} + +pub(crate) fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String { + let path = extract_tool_path(parsed); + let suffix = if parsed + .get("replaceAll") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + " (replace all)" + } else { + "" + }; + let preview = format_structured_patch_preview(parsed).or_else(|| { + let old_value = parsed + .get("oldString") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let new_value = parsed + .get("newString") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + format_patch_preview(old_value, new_value) + }); + + match preview { + Some(preview) => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m\n{preview}"), + None => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m"), + } +} + +pub(crate) fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String { + let num_files = parsed + .get("numFiles") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let filenames = parsed + .get("filenames") + .and_then(|value| value.as_array()) + .map(|files| { + files + .iter() + .filter_map(|value| value.as_str()) + .take(8) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + if filenames.is_empty() { + format!("{icon} \x1b[38;5;245mglob_search\x1b[0m matched {num_files} files") + } else { + format!("{icon} \x1b[38;5;245mglob_search\x1b[0m matched {num_files} files\n{filenames}") + } +} + +pub(crate) fn format_grep_result(icon: &str, parsed: &serde_json::Value) -> String { + let num_matches = parsed + .get("numMatches") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let num_files = parsed + .get("numFiles") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let content = parsed + .get("content") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let filenames = parsed + .get("filenames") + .and_then(|value| value.as_array()) + .map(|files| { + files + .iter() + .filter_map(|value| value.as_str()) + .take(8) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + let summary = format!( + "{icon} \x1b[38;5;245mgrep_search\x1b[0m {num_matches} matches across {num_files} files" + ); + if !content.trim().is_empty() { + format!( + "{summary}\n{}", + truncate_output_for_display( + content, + TOOL_OUTPUT_DISPLAY_MAX_LINES, + TOOL_OUTPUT_DISPLAY_MAX_CHARS, + ) + ) + } else if !filenames.is_empty() { + format!("{summary}\n{filenames}") + } else { + summary + } +} + +pub(crate) fn format_generic_tool_result(icon: &str, name: &str, parsed: &serde_json::Value) -> String { + let rendered_output = match parsed { + serde_json::Value::String(text) => text.clone(), + serde_json::Value::Null => String::new(), + serde_json::Value::Object(_) | serde_json::Value::Array(_) => { + serde_json::to_string_pretty(parsed).unwrap_or_else(|_| parsed.to_string()) + } + _ => parsed.to_string(), + }; + let preview = truncate_output_for_display( + &rendered_output, + TOOL_OUTPUT_DISPLAY_MAX_LINES, + TOOL_OUTPUT_DISPLAY_MAX_CHARS, + ); + + if preview.is_empty() { + format!("{icon} \x1b[38;5;245m{name}\x1b[0m") + } else if preview.contains('\n') { + format!("{icon} \x1b[38;5;245m{name}\x1b[0m\n{preview}") + } else { + format!("{icon} \x1b[38;5;245m{name}:\x1b[0m {preview}") + } +} + +pub(crate) fn render_thinking_block_summary( + out: &mut (impl Write + ?Sized), + char_count: Option, + redacted: bool, +) -> Result<(), RuntimeError> { + let summary = if redacted { + "\n▶ Thinking block hidden by provider\n".to_string() + } else if let Some(char_count) = char_count { + format!("\n▶ Thinking ({char_count} chars hidden)\n") + } else { + "\n▶ Thinking hidden\n".to_string() + }; + write!(out, "{summary}") + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string())) +} + +pub(crate) fn push_output_block( + block: OutputContentBlock, + out: &mut (impl Write + ?Sized), + events: &mut Vec, + pending_tool: &mut Option<(String, String, String)>, + streaming_tool_input: bool, + block_has_thinking_summary: &mut bool, +) -> Result<(), RuntimeError> { + match block { + OutputContentBlock::Text { text } => { + if !text.is_empty() { + let rendered = TerminalRenderer::new().markdown_to_ansi(&text); + write!(out, "{rendered}") + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string()))?; + events.push(AssistantEvent::TextDelta(text)); + } + } + OutputContentBlock::ToolUse { id, name, input } => { + // During streaming, the initial content_block_start has an empty input ({}). + // The real input arrives via input_json_delta events. In + // non-streaming responses, preserve a legitimate empty object. + let initial_input = if streaming_tool_input + && input.is_object() + && input.as_object().is_some_and(serde_json::Map::is_empty) + { + String::new() + } else { + input.to_string() + }; + *pending_tool = Some((id, name, initial_input)); + } + OutputContentBlock::Thinking { thinking, .. } => { + render_thinking_block_summary(out, Some(thinking.chars().count()), false)?; + *block_has_thinking_summary = true; + } + OutputContentBlock::RedactedThinking { .. } => { + render_thinking_block_summary(out, None, true)?; + *block_has_thinking_summary = true; + } + } + Ok(()) +} + +pub(crate) fn response_to_events( + response: api::MessageResponse, + out: &mut (impl Write + ?Sized), +) -> Result, RuntimeError> { + let mut events = Vec::new(); + let mut pending_tool = None; + + for block in response.content { + let mut block_has_thinking_summary = false; + push_output_block( + block, + out, + &mut events, + &mut pending_tool, + false, + &mut block_has_thinking_summary, + )?; + if let Some((id, name, input)) = pending_tool.take() { + events.push(AssistantEvent::ToolUse { id, name, input }); + } + } + + events.push(AssistantEvent::Usage(response.usage.token_usage())); + events.push(AssistantEvent::MessageStop); + Ok(events) +} + +pub(crate) fn push_prompt_cache_record( + client: &api::ProviderClient, + events: &mut Vec, +) { + if let Some(record) = client.take_last_prompt_cache_record() { + if let Some(event) = prompt_cache_record_to_runtime_event(record) { + events.push(AssistantEvent::PromptCache(event)); + } + } +} + +pub(crate) fn prompt_cache_record_to_runtime_event( + record: api::PromptCacheRecord, +) -> Option { + let cache_break = record.cache_break?; + Some(runtime::PromptCacheEvent { + unexpected: cache_break.unexpected, + reason: cache_break.reason, + previous_cache_read_input_tokens: cache_break.previous_cache_read_input_tokens, + current_cache_read_input_tokens: cache_break.current_cache_read_input_tokens, + token_drop: cache_break.token_drop, + }) +} + diff --git a/rust/crates/rusty-claude-cli/src/init.rs b/rust/crates/rusty-claude-cli/src/init.rs index 39d5a556..7aeff9fe 100644 --- a/rust/crates/rusty-claude-cli/src/init.rs +++ b/rust/crates/rusty-claude-cli/src/init.rs @@ -1,6 +1,8 @@ use std::fs; use std::path::{Path, PathBuf}; +use crate::args::CliOutputFormat; + const STARTER_CLAW_JSON: &str = concat!( "{\n", " \"permissions\": {\n", @@ -162,6 +164,38 @@ pub(crate) fn initialize_repo(cwd: &Path) -> Result Result> { + let cwd = std::env::current_dir()?; + Ok(initialize_repo(&cwd)?.render()) +} + +pub(crate) fn run_init(output_format: CliOutputFormat) -> Result<(), Box> { + let cwd = std::env::current_dir()?; + let report = initialize_repo(&cwd)?; + let message = report.render(); + match output_format { + CliOutputFormat::Text => println!("{message}"), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&init_json_value(&report, &message))? + ), + } + Ok(()) +} + +pub(crate) fn init_json_value(report: &InitReport, message: &str) -> serde_json::Value { + serde_json::json!({ + "kind": "init", + "project_root": report.project_root.display().to_string(), + "created": report.artifacts_with_status(InitStatus::Created), + "updated": report.artifacts_with_status(InitStatus::Updated), + "skipped": report.artifacts_with_status(InitStatus::Skipped), + "artifacts": report.artifact_json_entries(), + "next_step": InitReport::NEXT_STEP, + "message": message, + }) +} + fn ensure_dir(path: &Path) -> Result { if path.is_dir() { return Ok(InitStatus::Skipped); diff --git a/rust/crates/rusty-claude-cli/src/live_cli.rs b/rust/crates/rusty-claude-cli/src/live_cli.rs new file mode 100644 index 00000000..a5245672 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/live_cli.rs @@ -0,0 +1,518 @@ +use crate::*; +use std::env; +use std::io::{self, IsTerminal, Write}; +use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; +use serde_json::json; +use crate::render::*; + +#[derive(Debug, Clone)] +pub(crate) struct PromptHistoryEntry { + pub(crate) timestamp_ms: u64, + pub(crate) text: String, +} + +#[allow(clippy::needless_pass_by_value)] +pub(crate) struct LiveCli { + pub(crate) model: String, + pub(crate) allowed_tools: Option, + pub(crate) permission_mode: PermissionMode, + pub(crate) system_prompt: Vec, + pub(crate) runtime: BuiltRuntime, + pub(crate) session: SessionHandle, + pub(crate) prompt_history: Vec, +} + +impl LiveCli { + pub(crate) fn new( + model: String, + enable_tools: bool, + allowed_tools: Option, + permission_mode: PermissionMode, + ) -> Result> { + let system_prompt = build_system_prompt()?; + let session_state = new_cli_session()?; + let session = create_managed_session_handle(&session_state.session_id)?; + let runtime = build_runtime( + session_state.with_persistence_path(session.path.clone()), + &session.id, + model.clone(), + system_prompt.clone(), + enable_tools, + true, + allowed_tools.clone(), + permission_mode, + None, + )?; + let cli = Self { + model, + allowed_tools, + permission_mode, + system_prompt, + runtime, + session, + prompt_history: Vec::new(), + }; + cli.persist_session()?; + Ok(cli) + } + + pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { + if let Some(rt) = self.runtime.runtime.as_mut() { + rt.api_client_mut().set_reasoning_effort(effort); + } + } + + pub(crate) fn startup_banner(&self) -> String { + let cwd = env::current_dir().map_or_else( + |_| "".to_string(), + |path| path.display().to_string(), + ); + let status = status_context(None).ok(); + let git_branch = status + .as_ref() + .and_then(|context| context.git_branch.as_deref()) + .unwrap_or("unknown"); + let workspace = status.as_ref().map_or_else( + || "unknown".to_string(), + |context| context.git_summary.headline(), + ); + let session_path = self.session.path.strip_prefix(Path::new(&cwd)).map_or_else( + |_| self.session.path.display().to_string(), + |path| path.display().to_string(), + ); + format!( + "\x1b[38;5;196m\ + ██████╗██╗ █████╗ ██╗ ██╗\n\ +██╔════╝██║ ██╔══██╗██║ ██║\n\ +██║ ██║ ███████║██║ █╗ ██║\n\ +██║ ██║ ██╔══██║██║███╗██║\n\ +╚██████╗███████╗██║ ██║╚███╔███╔╝\n\ + ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\x1b[0m \x1b[38;5;208mCode\x1b[0m 🦞\n\n\ + \x1b[2mModel\x1b[0m {}\n\ + \x1b[2mPermissions\x1b[0m {}\n\ + \x1b[2mBranch\x1b[0m {}\n\ + \x1b[2mWorkspace\x1b[0m {}\n\ + \x1b[2mDirectory\x1b[0m {}\n\ + \x1b[2mSession\x1b[0m {}\n\ + \x1b[2mAuto-save\x1b[0m {}\n\n\ + Type \x1b[1m/help\x1b[0m for commands · \x1b[1m/status\x1b[0m for live context · \x1b[2m/resume latest\x1b[0m jumps back to the newest session · \x1b[1m/diff\x1b[0m then \x1b[1m/commit\x1b[0m to ship · \x1b[2mTab\x1b[0m for workflow completions · \x1b[2mShift+Enter\x1b[0m for newline", + self.model, + self.permission_mode.as_str(), + git_branch, + workspace, + cwd, + self.session.id, + session_path, + ) + } + + pub(crate) fn repl_completion_candidates( + &self, + ) -> Result, Box> { + Ok(slash_command_completion_candidates_with_sessions( + &self.model, + Some(&self.session.id), + list_managed_sessions()? + .into_iter() + .map(|session| session.id) + .collect(), + )) + } + + pub(crate) fn prepare_turn_runtime( + &self, + emit_output: bool, + ) -> Result<(BuiltRuntime, HookAbortMonitor), Box> { + let hook_abort_signal = runtime::HookAbortSignal::new(); + let runtime = build_runtime( + self.runtime.session().clone(), + &self.session.id, + self.model.clone(), + self.system_prompt.clone(), + true, + emit_output, + self.allowed_tools.clone(), + self.permission_mode, + None, + )? + .with_hook_abort_signal(hook_abort_signal.clone()); + let hook_abort_monitor = HookAbortMonitor::spawn(hook_abort_signal); + + Ok((runtime, hook_abort_monitor)) + } + + pub(crate) fn replace_runtime( + &mut self, + runtime: BuiltRuntime, + ) -> Result<(), Box> { + self.runtime.shutdown_plugins()?; + self.runtime = runtime; + Ok(()) + } + + pub(crate) fn run_turn(&mut self, input: &str) -> Result<(), Box> { + let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?; + let mut spinner = Spinner::new(); + let mut stdout = io::stdout(); + spinner.tick( + "🦀 Thinking...", + TerminalRenderer::new().color_theme(), + &mut stdout, + )?; + let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); + let result = runtime.run_turn(input, Some(&mut permission_prompter)); + hook_abort_monitor.stop(); + match result { + Ok(summary) => { + self.replace_runtime(runtime)?; + spinner.finish( + "✨ Done", + TerminalRenderer::new().color_theme(), + &mut stdout, + )?; + println!(); + if let Some(event) = summary.auto_compaction { + println!( + "{}", + format_auto_compaction_notice(event.removed_message_count) + ); + } + self.persist_session()?; + Ok(()) + } + Err(error) => { + runtime.shutdown_plugins()?; + spinner.fail( + "❌ Request failed", + TerminalRenderer::new().color_theme(), + &mut stdout, + )?; + Err(Box::new(error)) + } + } + } + + pub(crate) fn run_turn_with_output( + &mut self, + input: &str, + output_format: CliOutputFormat, + compact: bool, + ) -> Result<(), Box> { + match output_format { + CliOutputFormat::Json if compact => self.run_prompt_compact_json(input), + CliOutputFormat::Text if compact => self.run_prompt_compact(input), + CliOutputFormat::Text => self.run_turn(input), + CliOutputFormat::Json => self.run_prompt_json(input), + } + } + + pub(crate) fn run_prompt_compact( + &mut self, + input: &str, + ) -> Result<(), Box> { + let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?; + let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); + let result = runtime.run_turn(input, Some(&mut permission_prompter)); + hook_abort_monitor.stop(); + let summary = result?; + self.replace_runtime(runtime)?; + self.persist_session()?; + let final_text = final_assistant_text(&summary); + println!("{final_text}"); + Ok(()) + } + + pub(crate) fn run_prompt_compact_json( + &mut self, + input: &str, + ) -> Result<(), Box> { + let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?; + let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); + let result = runtime.run_turn(input, Some(&mut permission_prompter)); + hook_abort_monitor.stop(); + let summary = result?; + self.replace_runtime(runtime)?; + self.persist_session()?; + println!( + "{}", + serde_json::json!({ + "message": final_assistant_text(&summary), + "compact": true, + "model": self.model, + "usage": { + "input_tokens": summary.usage.input_tokens, + "output_tokens": summary.usage.output_tokens, + "cache_creation_input_tokens": summary.usage.cache_creation_input_tokens, + "cache_read_input_tokens": summary.usage.cache_read_input_tokens, + }, + }) + ); + Ok(()) + } + + pub(crate) fn run_prompt_json( + &mut self, + input: &str, + ) -> Result<(), Box> { + let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?; + let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); + let result = runtime.run_turn(input, Some(&mut permission_prompter)); + hook_abort_monitor.stop(); + let summary = result?; + self.replace_runtime(runtime)?; + self.persist_session()?; + println!( + "{}", + serde_json::json!({ + "message": final_assistant_text(&summary), + "model": self.model, + "iterations": summary.iterations, + "auto_compaction": summary.auto_compaction.map(|event| serde_json::json!({ + "removed_messages": event.removed_message_count, + "notice": format_auto_compaction_notice(event.removed_message_count), + })), + "tool_uses": collect_tool_uses(&summary), + "tool_results": collect_tool_results(&summary), + "prompt_cache_events": collect_prompt_cache_events(&summary), + "usage": { + "input_tokens": summary.usage.input_tokens, + "output_tokens": summary.usage.output_tokens, + "cache_creation_input_tokens": summary.usage.cache_creation_input_tokens, + "cache_read_input_tokens": summary.usage.cache_read_input_tokens, + }, + "estimated_cost": format_usd( + summary.usage.estimate_cost_usd_with_pricing( + pricing_for_model(&self.model) + .unwrap_or_else(runtime::ModelPricing::default_sonnet_tier) + ).total_cost_usd() + ) + }) + ); + Ok(()) + } + + pub(crate) fn persist_session(&self) -> Result<(), Box> { + self.runtime.session().save_to_path(&self.session.path)?; + Ok(()) + } + + pub(crate) fn record_prompt_history(&mut self, prompt: &str) { + let timestamp_ms = std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .map_or(self.runtime.session().updated_at_ms, |duration| { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) + }); + let entry = PromptHistoryEntry { + timestamp_ms, + text: prompt.to_string(), + }; + self.prompt_history.push(entry); + if let Err(error) = self.runtime.session_mut().push_prompt_entry(prompt) { + eprintln!("warning: failed to persist prompt history: {error}"); + } + } + + pub(crate) fn reload_runtime_features(&mut self) -> Result<(), Box> { + let runtime = build_runtime( + self.runtime.session().clone(), + &self.session.id, + self.model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + None, + )?; + self.replace_runtime(runtime)?; + self.persist_session() + } + + pub(crate) fn run_internal_prompt_text_with_progress( + &self, + prompt: &str, + enable_tools: bool, + progress: Option, + ) -> Result> { + let session = self.runtime.session().clone(); + let mut runtime = build_runtime( + session, + &self.session.id, + self.model.clone(), + self.system_prompt.clone(), + enable_tools, + false, + self.allowed_tools.clone(), + self.permission_mode, + progress, + )?; + let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); + let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?; + let text = final_assistant_text(&summary).trim().to_string(); + runtime.shutdown_plugins()?; + Ok(text) + } + + pub(crate) fn run_internal_prompt_text( + &self, + prompt: &str, + enable_tools: bool, + ) -> Result> { + self.run_internal_prompt_text_with_progress(prompt, enable_tools, None) + } + + pub(crate) fn run_stale_base_preflight(flag_value: Option<&str>) { + let Ok(cwd) = env::current_dir() else { + return; + }; + let source = resolve_expected_base(flag_value, &cwd); + let state = check_base_commit(&cwd, source.as_ref()); + if let Some(warning) = format_stale_base_warning(&state) { + eprintln!("{warning}"); + } + } +} + +pub(crate) fn run_repl( + model: String, + allowed_tools: Option, + permission_mode: PermissionMode, + base_commit: Option, + reasoning_effort: Option, + allow_broad_cwd: bool, +) -> Result<(), Box> { + enforce_broad_cwd_policy(allow_broad_cwd, CliOutputFormat::Text)?; + run_stale_base_preflight(base_commit.as_deref()); + let resolved_model = resolve_repl_model(model); + let mut cli = LiveCli::new(resolved_model, true, allowed_tools, permission_mode)?; + cli.set_reasoning_effort(reasoning_effort); + let mut editor = + input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default()); + println!("{}", cli.startup_banner()); + println!("{}", format_connected_line(&cli.model)); + + loop { + editor.set_completions(cli.repl_completion_candidates().unwrap_or_default()); + match editor.read_line()? { + input::ReadOutcome::Submit(input) => { + let trimmed = input.trim().to_string(); + if trimmed.is_empty() { + continue; + } + if matches!(trimmed.as_str(), "/exit" | "/quit") { + cli.persist_session()?; + break; + } + match SlashCommand::parse(&trimmed) { + Ok(Some(command)) => { + if cli.handle_repl_command(command)? { + cli.persist_session()?; + } + continue; + } + Ok(None) => {} + Err(error) => { + eprintln!("{error}"); + continue; + } + } + // Bare-word skill dispatch: if the first token of the input + // matches a known skill name, invoke it as `/skills ` + // rather than forwarding raw text to the LLM (ROADMAP #36). + let cwd = std::env::current_dir().unwrap_or_default(); + if let Some(prompt) = try_resolve_bare_skill_prompt(&cwd, &trimmed) { + editor.push_history(input); + cli.record_prompt_history(&trimmed); + cli.run_turn(&prompt)?; + continue; + } + editor.push_history(input); + cli.record_prompt_history(&trimmed); + cli.run_turn(&trimmed)?; + } + input::ReadOutcome::Cancel => {} + input::ReadOutcome::Exit => { + cli.persist_session()?; + break; + } + } + } + + Ok(()) +} + +pub(crate) fn detect_broad_cwd() -> Option { + let Ok(cwd) = env::current_dir() else { + return None; + }; + let is_home = env::var_os("HOME") + .or_else(|| env::var_os("USERPROFILE")) + .is_some_and(|h| Path::new(&h) == cwd); + let is_root = cwd.parent().is_none(); + if is_home || is_root { + Some(cwd) + } else { + None + } +} + +pub(crate) fn enforce_broad_cwd_policy( + allow_broad_cwd: bool, + output_format: CliOutputFormat, +) -> Result<(), Box> { + if allow_broad_cwd { + return Ok(()); + } + let Some(cwd) = detect_broad_cwd() else { + return Ok(()); + }; + + let is_interactive = io::stdin().is_terminal(); + + if is_interactive { + // Interactive mode: print warning and ask for confirmation + eprintln!( + "Warning: claw is running from a very broad directory ({}).\n\ + The agent can read and search everything under this path.\n\ + Consider running from inside your project: cd /path/to/project && claw", + cwd.display() + ); + eprint!("Continue anyway? [y/N]: "); + io::stderr().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + let trimmed = input.trim().to_lowercase(); + if trimmed != "y" && trimmed != "yes" { + eprintln!("Aborted."); + std::process::exit(0); + } + Ok(()) + } else { + // Non-interactive mode: exit with error (JSON or text) + let message = format!( + "claw is running from a very broad directory ({}). \ + The agent can read and search everything under this path. \ + Use --allow-broad-cwd to proceed anyway, \ + or run from inside your project: cd /path/to/project && claw", + cwd.display() + ); + match output_format { + CliOutputFormat::Json => { + eprintln!( + "{}", + serde_json::json!({ + "type": "error", + "error": message, + }) + ); + } + CliOutputFormat::Text => { + eprintln!("error: {message}"); + } + } + std::process::exit(1); + } +} diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 81e1e6c7..aadacc42 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -9,132 +9,60 @@ clippy::doc_markdown, clippy::result_large_err )] +mod args; +mod config; +mod constants; +mod diff; +mod doctor; +mod error; +mod export; +mod formatting; +mod status; +mod session; mod init; -pub(crate) use crate::permissions::prompter::*; -pub(crate) use crate::ui::progress::*; -pub(crate) use crate::cli::suggestions::*; -pub(crate) use crate::config::models::*; -use crate::diagnostics::{render_doctor_report, run_doctor, run_worker_state}; mod input; +mod live_cli; +mod model; +mod models; +mod permission; +mod api_client; +mod progress; +mod render; +mod tool_executor; +mod runtime_builder; +mod repl_commands; -pub(crate) mod cli; -pub(crate) mod config; -pub(crate) mod diagnostics; -pub(crate) mod execution; -pub(crate) mod permissions; -pub(crate) mod ui; - -use crate::execution::client::*; -use crate::execution::stream::*; -use crate::execution::executor::*; - -use std::collections::BTreeSet; -pub(crate) mod models; -pub(crate) use models::*; -pub(crate) mod error; +pub(crate) use api_client::*; +pub(crate) use args::*; +pub(crate) use config::*; +pub(crate) use constants::*; +pub(crate) use diff::*; +pub(crate) use doctor::*; pub(crate) use error::*; -pub(crate) mod render; +pub(crate) use export::*; +pub(crate) use live_cli::*; +pub(crate) use repl_commands::*; +pub(crate) use formatting::*; +pub(crate) use status::*; +pub(crate) use session::*; +pub(crate) use init::*; +pub(crate) use model::*; +pub(crate) use models::*; +pub(crate) use permission::*; +pub(crate) use progress::*; +pub(crate) use tool_executor::*; +pub(crate) use runtime_builder::*; pub(crate) use render::*; -pub(crate) mod repl; -pub(crate) use repl::*; -#[cfg(test)] -mod main_tests; +pub(crate) use commands::*; +pub(crate) use runtime::*; use std::env; use std::fs; use std::io::{self, IsTerminal, Read, Write}; -use std::ops::{Deref, DerefMut}; use std::path::{Path, PathBuf}; -use std::process::Command; -use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; -use std::sync::{Arc, Mutex}; -use std::thread::{self, JoinHandle}; -use std::time::{Duration, Instant, UNIX_EPOCH}; -use api::{ - detect_provider_kind, resolve_startup_auth_source, AnthropicClient, AuthSource, - ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, MessageResponse, - OutputContentBlock, PromptCache, ProviderClient as ApiProviderClient, ProviderKind, - StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, -}; - -use commands::{ - classify_skills_slash_command, handle_agents_slash_command, handle_agents_slash_command_json, - handle_mcp_slash_command, handle_mcp_slash_command_json, handle_plugins_slash_command, - handle_skills_slash_command, handle_skills_slash_command_json, - render_slash_command_help_filtered, resolve_skill_invocation, resume_supported_slash_commands, - slash_command_specs, SkillSlashDispatch, SlashCommand, -}; -use compat_harness::{extract_manifest, UpstreamPaths}; -use init::initialize_repo; -use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry}; -use render::{MarkdownStreamState, Spinner, TerminalRenderer}; -use runtime::{ - check_base_commit, format_stale_base_warning, format_usd, load_oauth_credentials, - load_system_prompt, pricing_for_model, resolve_expected_base, resolve_sandbox_status, - ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, - ContentBlock, ConversationMessage, ConversationRuntime, McpServer, McpServerManager, - McpServerSpec, McpTool, MessageRole, PermissionMode, PermissionPolicy, ProjectContext, - PromptCacheEvent, ResolvedPermissionMode, RuntimeError, Session, TokenUsage, ToolError, - ToolExecutor, UsageTracker, -}; -use serde::Deserialize; -use serde_json::{json, Map, Value}; -use tools::{execute_tool, mvp_tool_specs, GlobalToolRegistry, RuntimeToolDefinition}; - -const DEFAULT_MODEL: &str = "claude-opus-4-6"; - -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, -); +use commands::SlashCommand; +use runtime::{PermissionMode, Session}; fn main() { if let Err(error) = run() { @@ -143,8 +71,6 @@ fn main() { error, std::backtrace::Backtrace::force_capture() ); - // When --output-format json is active, emit errors as JSON so downstream - // tools can parse failures the same way they parse successes (ROADMAP #42). let argv: Vec = std::env::args().collect(); let json_output = argv .windows(2) @@ -152,8 +78,6 @@ fn main() { || argv.iter().any(|a| a == "--output-format=json"); if json_output { - // #77: classify error by prefix so downstream claws can route without - // regex-scraping the prose. Split short-reason from hint-runbook. let kind = classify_error_kind(&message); let (short_reason, hint) = split_error_hint(&message); eprintln!( @@ -166,27 +90,21 @@ fn main() { }) ); } else { - // #156: Add machine-readable error kind to text output so stderr observers - // don't need to regex-scrape the prose. let kind = classify_error_kind(&message); if message.contains("`claw --help`") { eprintln!( - "[error-kind: {kind}] -error: {message}" + "[error-kind: {kind}]\nerror: {message}" ); } else { - eprintln!("[error-kind: {kind}]\nerror: {message}\n\nRun `claw --help` for usage."); + eprintln!( + "[error-kind: {kind}]\nerror: {message}\nRun `claw --help` for usage." + ); } } std::process::exit(1); } } -/// Read piped stdin content when stdin is not a terminal. -/// -/// Returns `None` when stdin is attached to a terminal (interactive REPL use), -/// when reading fails, or when the piped content is empty after trimming. -/// Returns `Some(raw_content)` when a pipe delivered non-empty content. fn read_piped_stdin() -> Option { if io::stdin().is_terminal() { return None; @@ -201,12 +119,6 @@ fn read_piped_stdin() -> Option { Some(buffer) } -/// Merge a piped stdin payload into a prompt argument. -/// -/// When `stdin_content` is `None` or empty after trimming, the prompt is -/// returned unchanged. Otherwise the trimmed stdin content is appended to the -/// prompt separated by a blank line so the model sees the prompt first and the -/// piped context immediately after it. fn merge_prompt_with_stdin(prompt: &str, stdin_content: Option<&str>) -> String { let Some(raw) = stdin_content else { return prompt.to_string(); @@ -229,6 +141,7 @@ fn run() -> Result<(), Box> { output_format, manifests_dir, } => dump_manifests(manifests_dir.as_deref(), output_format)?, + CliAction::BootstrapPlan { output_format } => print_bootstrap_plan(output_format)?, CliAction::Agents { args, @@ -251,7 +164,7 @@ fn run() -> Result<(), Box> { cwd, date, output_format, - } => print_system_prompt(cwd, date, output_format)?, + } => print_system_prompt(Some(cwd), Some(date), output_format)?, CliAction::Version { output_format } => print_version(output_format)?, CliAction::ResumeSession { session_path, @@ -285,11 +198,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 - // must remain available for interactive approval; otherwise the - // prompter's read_line() would hit EOF and deny every request. + let stdin_context = if matches!(permission_mode, PermissionMode::DangerFullAccess) { read_piped_stdin() } else { @@ -305,9 +214,6 @@ fn run() -> Result<(), Box> { CliAction::Acp { output_format } => print_acp_status(output_format)?, CliAction::State { output_format } => run_worker_state(output_format)?, 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. CliAction::Config { section, output_format, @@ -361,720 +267,12 @@ fn run() -> Result<(), Box> { Ok(()) } -#[derive(Debug, Clone, PartialEq, Eq)] -enum CliAction { - DumpManifests { - output_format: CliOutputFormat, - manifests_dir: Option, - }, - BootstrapPlan { - output_format: CliOutputFormat, - }, - Agents { - args: Option, - output_format: CliOutputFormat, - }, - Mcp { - args: Option, - output_format: CliOutputFormat, - }, - Skills { - args: Option, - output_format: CliOutputFormat, - }, - Plugins { - action: Option, - target: Option, - output_format: CliOutputFormat, - }, - PrintSystemPrompt { - cwd: PathBuf, - date: String, - output_format: CliOutputFormat, - }, - Version { - output_format: CliOutputFormat, - }, - ResumeSession { - session_path: PathBuf, - commands: Vec, - output_format: CliOutputFormat, - }, - Status { - model: String, - // #148: raw `--model` flag input (pre-alias-resolution), if any. - // None means no flag was supplied; env/config/default fallback is - // resolved inside `print_status_snapshot`. - model_flag_raw: Option, - permission_mode: PermissionMode, - output_format: CliOutputFormat, - allowed_tools: Option, - }, - Sandbox { - output_format: CliOutputFormat, - }, - Prompt { - prompt: String, - model: String, - output_format: CliOutputFormat, - allowed_tools: Option, - permission_mode: PermissionMode, - compact: bool, - base_commit: Option, - reasoning_effort: Option, - allow_broad_cwd: bool, - }, - Doctor { - output_format: CliOutputFormat, - }, - Acp { - output_format: CliOutputFormat, - }, - State { - output_format: CliOutputFormat, - }, - Init { - output_format: CliOutputFormat, - }, - // #146: `claw config` and `claw diff` are pure-local read-only - // introspection commands; wire them as standalone CLI subcommands. - Config { - section: Option, - output_format: CliOutputFormat, - }, - Diff { - output_format: CliOutputFormat, - }, - Export { - session_reference: String, - output_path: Option, - output_format: CliOutputFormat, - }, - Repl { - model: String, - allowed_tools: Option, - permission_mode: PermissionMode, - base_commit: Option, - reasoning_effort: Option, - allow_broad_cwd: bool, - }, - HelpTopic(LocalHelpTopic), - // prompt-mode formatting is only supported for non-interactive runs - Help { - output_format: CliOutputFormat, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum LocalHelpTopic { - Status, - Sandbox, - Doctor, - Acp, - // #141: extend the local-help pattern to every subcommand so - // `claw --help` has one consistent contract. - Init, - State, - Export, - Version, - SystemPrompt, - DumpManifests, - BootstrapPlan, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CliOutputFormat { - Text, - Json, -} - -impl CliOutputFormat { - fn parse(value: &str) -> Result { - match value { - "text" => Ok(Self::Text), - "json" => Ok(Self::Json), - other => Err(format!( - "unsupported value for --output-format: {other} (expected text or json)" - )), - } - } -} - -fn parse_local_help_action(rest: &[String]) -> Option> { - if rest.len() != 2 || !is_help_flag(&rest[1]) { - return None; - } - - let topic = match rest[0].as_str() { - "status" => LocalHelpTopic::Status, - "sandbox" => LocalHelpTopic::Sandbox, - "doctor" => LocalHelpTopic::Doctor, - "acp" => LocalHelpTopic::Acp, - // #141: add the subcommands that were previously falling back - // to global help (init/state/export/version) or erroring out - // (system-prompt/dump-manifests) or printing their primary - // output instead of help text (bootstrap-plan). - "init" => LocalHelpTopic::Init, - "state" => LocalHelpTopic::State, - "export" => LocalHelpTopic::Export, - "version" => LocalHelpTopic::Version, - "system-prompt" => LocalHelpTopic::SystemPrompt, - "dump-manifests" => LocalHelpTopic::DumpManifests, - "bootstrap-plan" => LocalHelpTopic::BootstrapPlan, - _ => return None, - }; - Some(Ok(CliAction::HelpTopic(topic))) -} - -fn is_help_flag(value: &str) -> bool { - matches!(value, "--help" | "-h") -} - -fn parse_single_word_command_alias( - rest: &[String], - model: &str, - // #148: raw --model flag input for status provenance. None = no flag. - model_flag_raw: Option<&str>, - permission_mode_override: Option, - output_format: CliOutputFormat, - allowed_tools: Option, -) -> Option> { - if rest.is_empty() { - return None; - } - - // Diagnostic verbs (help, version, status, sandbox, doctor, state) accept only the verb itself - // or --help / -h as a suffix. Any other suffix args are unrecognized. - let verb = &rest[0]; - let is_diagnostic = matches!( - verb.as_str(), - "help" | "version" | "status" | "sandbox" | "doctor" | "state" - ); - - if is_diagnostic && rest.len() > 1 { - // Diagnostic verb with trailing args: reject unrecognized suffix - if is_help_flag(&rest[1]) && rest.len() == 2 { - // "doctor --help" is valid, routed to parse_local_help_action() instead - return None; - } - // Unrecognized suffix like "--json" - let mut msg = format!( - "unrecognized argument `{}` for subcommand `{}`", - rest[1], verb - ); - // #152: common mistake — users type `--json` expecting JSON output. - // Hint at the correct flag so they don't have to re-read --help. - if rest[1] == "--json" { - msg.push_str("\nDid you mean `--output-format json`?"); - } - return Some(Err(msg)); - } - - if rest.len() != 1 { - return None; - } - - match rest[0].as_str() { - "help" => Some(Ok(CliAction::Help { output_format })), - "version" => Some(Ok(CliAction::Version { output_format })), - "status" => Some(Ok(CliAction::Status { - model: model.to_string(), - model_flag_raw: model_flag_raw.map(str::to_string), // #148 - permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode), - output_format, - allowed_tools, - })), - "sandbox" => Some(Ok(CliAction::Sandbox { output_format })), - "doctor" => Some(Ok(CliAction::Doctor { output_format })), - "state" => Some(Ok(CliAction::State { output_format })), - // #146: let `config` and `diff` fall through to parse_subcommand - // where they are wired as pure-local introspection, instead of - // producing the "is a slash command" guidance. Zero-arg cases - // reach parse_subcommand too via this None. - "config" | "diff" => None, - other => bare_slash_command_guidance(other).map(Err), - } -} - -fn bare_slash_command_guidance(command_name: &str) -> Option { - if matches!( - command_name, - "dump-manifests" - | "bootstrap-plan" - | "agents" - | "mcp" - | "skills" - | "system-prompt" - | "init" - | "prompt" - | "export" - ) { - return None; - } - let slash_command = slash_command_specs() - .iter() - .find(|spec| spec.name == command_name)?; - let guidance = if slash_command.resume_supported { - format!( - "`claw {command_name}` is a slash command. Use `claw --resume SESSION.jsonl /{command_name}` or start `claw` and run `/{command_name}`." - ) - } else { - format!( - "`claw {command_name}` is a slash command. Start `claw` and run `/{command_name}` inside the REPL." - ) - }; - Some(guidance) -} - -fn removed_auth_surface_error(command_name: &str) -> String { - format!( - "`claw {command_name}` has been removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead." - ) -} - -fn parse_acp_args(args: &[String], output_format: CliOutputFormat) -> Result { - match args { - [] => Ok(CliAction::Acp { output_format }), - [subcommand] if subcommand == "serve" => Ok(CliAction::Acp { output_format }), - _ => Err(String::from( - "unsupported ACP invocation. Use `claw acp`, `claw acp serve`, `claw --acp`, or `claw -acp`.", - )), - } -} - -fn try_resolve_bare_skill_prompt(cwd: &Path, trimmed: &str) -> Option { - let bare_first_token = trimmed.split_whitespace().next().unwrap_or_default(); - let looks_like_skill_name = !bare_first_token.is_empty() - && !bare_first_token.starts_with('/') - && bare_first_token - .chars() - .all(|c| c.is_alphanumeric() || c == '-' || c == '_'); - if !looks_like_skill_name { - return None; - } - match resolve_skill_invocation(cwd, Some(trimmed)) { - Ok(SkillSlashDispatch::Invoke(prompt)) => Some(prompt), - _ => None, - } -} - -fn join_optional_args(args: &[String]) -> Option { - let joined = args.join(" "); - let trimmed = joined.trim(); - (!trimmed.is_empty()).then(|| trimmed.to_string()) -} - -#[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] -fn parse_direct_slash_cli_action( - rest: &[String], - model: String, - output_format: CliOutputFormat, - allowed_tools: Option, - permission_mode: PermissionMode, - compact: bool, - base_commit: Option, - reasoning_effort: Option, - allow_broad_cwd: bool, -) -> Result { - let raw = rest.join(" "); - match SlashCommand::parse(&raw) { - Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help { output_format }), - Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents { - args, - output_format, - }), - Ok(Some(SlashCommand::Mcp { action, target })) => Ok(CliAction::Mcp { - args: match (action, target) { - (None, None) => None, - (Some(action), None) => Some(action), - (Some(action), Some(target)) => Some(format!("{action} {target}")), - (None, Some(target)) => Some(target), - }, - output_format, - }), - Ok(Some(SlashCommand::Skills { args })) => { - match classify_skills_slash_command(args.as_deref()) { - SkillSlashDispatch::Invoke(prompt) => Ok(CliAction::Prompt { - prompt, - model, - output_format, - allowed_tools, - permission_mode, - compact, - base_commit, - reasoning_effort: reasoning_effort.clone(), - allow_broad_cwd, - }), - SkillSlashDispatch::Local => Ok(CliAction::Skills { - args, - output_format, - }), - } - } - Ok(Some(SlashCommand::Unknown(name))) => Err(format_unknown_direct_slash_command(&name)), - Ok(Some(command)) => Err({ - let _ = command; - format!( - "slash command {command_name} is interactive-only. Start `claw` and run it there, or use `claw --resume SESSION.jsonl {command_name}` / `claw --resume {latest} {command_name}` when the command is marked [resume] in /help.", - command_name = rest[0], - latest = LATEST_SESSION_REFERENCE, - ) - }), - Ok(None) => Err(format!("unknown subcommand: {}", rest[0])), - Err(error) => Err(error.to_string()), - } -} - -fn omc_compatibility_note_for_unknown_slash_command(name: &str) -> Option<&'static str> { - name.starts_with("oh-my-claudecode:") - .then_some( - "Compatibility note: `/oh-my-claudecode:*` is a Claude Code/OMC plugin command. `claw` does not yet load plugin slash commands, Claude statusline stdin, or OMC session hooks.", - ) -} - -fn normalize_allowed_tools(values: &[String]) -> Result, String> { - if values.is_empty() { - return Ok(None); - } - current_tool_registry()?.normalize_allowed_tools(values) -} - -fn current_tool_registry() -> Result { - let cwd = env::current_dir().map_err(|error| error.to_string())?; - let loader = ConfigLoader::default_for(&cwd); - let runtime_config = loader.load().map_err(|error| error.to_string())?; - let state = build_runtime_plugin_state_with_loader(&cwd, &loader, &runtime_config) - .map_err(|error| error.to_string())?; - let registry = state.tool_registry.clone(); - if let Some(mcp_state) = state.mcp_state { - mcp_state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .shutdown() - .map_err(|error| error.to_string())?; - } - Ok(registry) -} - - -fn provider_label(kind: ProviderKind) -> &'static str { - match kind { - ProviderKind::Anthropic => "anthropic", - ProviderKind::Xai => "xai", - ProviderKind::OpenAi => "openai", - } -} - -fn filter_tool_specs( - tool_registry: &GlobalToolRegistry, - allowed_tools: Option<&AllowedToolSet>, -) -> Vec { - tool_registry.definitions(allowed_tools) -} - -fn parse_system_prompt_args( - args: &[String], - output_format: CliOutputFormat, -) -> Result { - let mut cwd = env::current_dir().map_err(|error| error.to_string())?; - let mut date = DEFAULT_DATE.to_string(); - let mut index = 0; - - while index < args.len() { - match args[index].as_str() { - "--cwd" => { - let value = args - .get(index + 1) - .ok_or_else(|| "missing value for --cwd".to_string())?; - cwd = PathBuf::from(value); - index += 2; - } - "--date" => { - let value = args - .get(index + 1) - .ok_or_else(|| "missing value for --date".to_string())?; - date.clone_from(value); - index += 2; - } - other => { - // #152: hint `--output-format json` when user types `--json`. - let mut msg = format!("unknown system-prompt option: {other}"); - if other == "--json" { - msg.push_str("\nDid you mean `--output-format json`?"); - } - return Err(msg); - } - } - } - - Ok(CliAction::PrintSystemPrompt { - cwd, - date, - output_format, - }) -} - -fn parse_dump_manifests_args( - args: &[String], - output_format: CliOutputFormat, -) -> Result { - let mut manifests_dir: Option = None; - let mut index = 0; - while index < args.len() { - let arg = &args[index]; - if arg == "--manifests-dir" { - let value = args - .get(index + 1) - .ok_or_else(|| String::from("--manifests-dir requires a path"))?; - manifests_dir = Some(PathBuf::from(value)); - index += 2; - continue; - } - if let Some(value) = arg.strip_prefix("--manifests-dir=") { - if value.is_empty() { - return Err(String::from("--manifests-dir requires a path")); - } - manifests_dir = Some(PathBuf::from(value)); - index += 1; - continue; - } - return Err(format!("unknown dump-manifests option: {arg}")); - } - - Ok(CliAction::DumpManifests { - output_format, - manifests_dir, - }) -} - -fn parse_resume_args(args: &[String], output_format: CliOutputFormat) -> Result { - let (session_path, command_tokens): (PathBuf, &[String]) = match args.first() { - None => (PathBuf::from(LATEST_SESSION_REFERENCE), &[]), - Some(first) if looks_like_slash_command_token(first) => { - (PathBuf::from(LATEST_SESSION_REFERENCE), args) - } - Some(first) => (PathBuf::from(first), &args[1..]), - }; - let mut commands = Vec::new(); - let mut current_command = String::new(); - - for token in command_tokens { - if token.trim_start().starts_with('/') { - if resume_command_can_absorb_token(¤t_command, token) { - current_command.push(' '); - current_command.push_str(token); - continue; - } - if !current_command.is_empty() { - commands.push(current_command); - } - current_command = String::from(token.as_str()); - continue; - } - - if current_command.is_empty() { - return Err("--resume trailing arguments must be slash commands".to_string()); - } - - current_command.push(' '); - current_command.push_str(token); - } - - if !current_command.is_empty() { - commands.push(current_command); - } - - Ok(CliAction::ResumeSession { - session_path, - commands, - output_format, - }) -} - -fn resume_command_can_absorb_token(current_command: &str, token: &str) -> bool { - matches!( - SlashCommand::parse(current_command), - Ok(Some(SlashCommand::Export { path: None })) - ) && !looks_like_slash_command_token(token) -} - -fn looks_like_slash_command_token(token: &str) -> bool { - let trimmed = token.trim_start(); - let Some(name) = trimmed.strip_prefix('/').and_then(|value| { - value - .split_whitespace() - .next() - .map(str::trim) - .filter(|value| !value.is_empty()) - }) else { - return false; - }; - - slash_command_specs() - .iter() - .any(|spec| spec.name == name || spec.aliases.contains(&name)) -} - -fn dump_manifests( - manifests_dir: Option<&Path>, - output_format: CliOutputFormat, -) -> Result<(), Box> { - let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); - dump_manifests_at_path(&workspace_dir, manifests_dir, output_format) -} - -const DUMP_MANIFESTS_OVERRIDE_HINT: &str = - "Hint: set CLAUDE_CODE_UPSTREAM=/path/to/upstream or pass `claw dump-manifests --manifests-dir /path/to/upstream`."; - -// Internal function for testing that accepts a workspace directory path. -fn dump_manifests_at_path( - workspace_dir: &std::path::Path, - manifests_dir: Option<&Path>, - output_format: CliOutputFormat, -) -> Result<(), Box> { - let paths = if let Some(dir) = manifests_dir { - let resolved = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()); - UpstreamPaths::from_repo_root(resolved) - } else { - // Surface the resolved path in the error so users can diagnose missing - // manifest files without guessing what path the binary expected. - let resolved = workspace_dir - .canonicalize() - .unwrap_or_else(|_| workspace_dir.to_path_buf()); - UpstreamPaths::from_workspace_dir(&resolved) - }; - - let source_root = paths.repo_root(); - if !source_root.exists() { - return Err(format!( - "Manifest source directory does not exist.\n looked in: {}\n {DUMP_MANIFESTS_OVERRIDE_HINT}", - source_root.display(), - ) - .into()); - } - - let required_paths = [ - ("src/commands.ts", paths.commands_path()), - ("src/tools.ts", paths.tools_path()), - ("src/entrypoints/cli.tsx", paths.cli_path()), - ]; - let missing = required_paths - .iter() - .filter_map(|(label, path)| (!path.is_file()).then_some(*label)) - .collect::>(); - if !missing.is_empty() { - return Err(format!( - "Manifest source files are missing.\n repo root: {}\n missing: {}\n {DUMP_MANIFESTS_OVERRIDE_HINT}", - source_root.display(), - missing.join(", "), - ) - .into()); - } - - match extract_manifest(&paths) { - Ok(manifest) => { - match output_format { - CliOutputFormat::Text => { - println!("commands: {}", manifest.commands.entries().len()); - println!("tools: {}", manifest.tools.entries().len()); - println!("bootstrap phases: {}", manifest.bootstrap.phases().len()); - } - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "dump-manifests", - "commands": manifest.commands.entries().len(), - "tools": manifest.tools.entries().len(), - "bootstrap_phases": manifest.bootstrap.phases().len(), - }))? - ), - } - Ok(()) - } - Err(error) => Err(format!( - "failed to extract manifests: {error}\n looked in: {path}\n {DUMP_MANIFESTS_OVERRIDE_HINT}", - path = paths.repo_root().display() - ) - .into()), - } -} - -fn print_bootstrap_plan(output_format: CliOutputFormat) -> Result<(), Box> { - let phases = runtime::BootstrapPlan::claude_code_default() - .phases() - .iter() - .map(|phase| format!("{phase:?}")) - .collect::>(); - match output_format { - CliOutputFormat::Text => { - for phase in &phases { - println!("- {phase}"); - } - } - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "bootstrap-plan", - "phases": phases, - }))? - ), - } - Ok(()) -} - -fn print_system_prompt( - cwd: PathBuf, - date: String, - output_format: CliOutputFormat, -) -> Result<(), Box> { - let sections = load_system_prompt(cwd, date, env::consts::OS, "unknown")?; - let message = sections.join( - " - -", - ); - match output_format { - CliOutputFormat::Text => println!("{message}"), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "system-prompt", - "message": message, - "sections": sections, - }))? - ), - } - Ok(()) -} - -fn print_version(output_format: CliOutputFormat) -> Result<(), Box> { - match output_format { - CliOutputFormat::Text => println!("{}", render_version_report()), - CliOutputFormat::Json => { - println!("{}", serde_json::to_string_pretty(&version_json_value())?); - } - } - Ok(()) -} - -fn version_json_value() -> serde_json::Value { - json!({ - "kind": "version", - "message": render_version_report(), - "version": VERSION, - "git_sha": GIT_SHA, - "target": BUILD_TARGET, - }) -} - -#[allow(clippy::too_many_lines)] fn resume_session(session_path: &Path, commands: &[String], output_format: CliOutputFormat) { let session_reference = session_path.display().to_string(); let (handle, session) = match load_session_reference(&session_reference) { Ok(loaded) => loaded, Err(error) => { if output_format == CliOutputFormat::Json { - // #77: classify session load errors for downstream consumers let full_message = format!("failed to restore session: {error}"); let kind = classify_error_kind(&full_message); let (short_reason, hint) = split_error_hint(&full_message); @@ -1118,11 +316,6 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu let mut session = session; for raw_command in commands { - // Intercept spec commands that have no parse arm before calling - // SlashCommand::parse — they return Err(SlashCommandParseError) which - // formats as the confusing circular "Did you mean /X?" message. - // STUB_COMMANDS covers both completions-filtered stubs and parse-less - // spec entries; treat both as unsupported in resume mode. { let cmd_root = raw_command .trim_start_matches('/') @@ -1220,2559 +413,7 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu } } -#[derive(Debug, Clone)] -struct ResumeCommandOutcome { - session: Session, - message: Option, - json: Option, -} - -#[derive(Debug, Clone)] -struct StatusContext { - cwd: PathBuf, - session_path: Option, - loaded_config_files: usize, - discovered_config_files: usize, - memory_file_count: usize, - project_root: Option, - git_branch: Option, - git_summary: GitWorkspaceSummary, - session_lifecycle: SessionLifecycleSummary, - sandbox_status: runtime::SandboxStatus, - /// #143: when `.claw.json` (or another loaded config file) fails to parse, - /// we capture the parse error here and still populate every field that - /// doesn't depend on runtime config (workspace, git, sandbox defaults, - /// discovery counts). Top-level JSON output then reports - /// `status: "degraded"` so claws can distinguish "status ran but config - /// is broken" from "status ran cleanly". - config_load_error: Option, -} - -#[derive(Debug, Clone, Copy)] -struct StatusUsage { - message_count: usize, - turns: u32, - latest: TokenUsage, - cumulative: TokenUsage, - estimated_tokens: usize, -} - -fn classify_session_lifecycle_for(workspace: &Path) -> SessionLifecycleSummary { - classify_session_lifecycle_from_panes(workspace, discover_tmux_panes()) -} - -fn classify_session_lifecycle_from_panes( - workspace: &Path, - panes: Vec, -) -> SessionLifecycleSummary { - let workspace_dirty = git_worktree_is_dirty(workspace); - let mut idle_shell = None; - for pane in panes { - if !pane_path_matches_workspace(&pane.current_path, workspace) { - continue; - } - if is_idle_shell_command(&pane.current_command) { - idle_shell.get_or_insert(pane); - } else { - return SessionLifecycleSummary { - kind: SessionLifecycleKind::RunningProcess, - pane_id: Some(pane.pane_id), - pane_command: Some(pane.current_command), - pane_path: Some(pane.current_path), - workspace_dirty, - abandoned: false, - }; - } - } - - if let Some(pane) = idle_shell { - SessionLifecycleSummary { - kind: SessionLifecycleKind::IdleShell, - pane_id: Some(pane.pane_id), - pane_command: Some(pane.current_command), - pane_path: Some(pane.current_path), - workspace_dirty, - abandoned: workspace_dirty, - } - } else { - SessionLifecycleSummary { - kind: SessionLifecycleKind::SavedOnly, - pane_id: None, - pane_command: None, - pane_path: None, - workspace_dirty, - abandoned: workspace_dirty, - } - } -} - -fn discover_tmux_panes() -> Vec { - let output = Command::new("tmux") - .args([ - "list-panes", - "-a", - "-F", - "#{pane_id}\t#{pane_current_command}\t#{pane_current_path}", - ]) - .output(); - let Ok(output) = output else { - return Vec::new(); - }; - if !output.status.success() { - return Vec::new(); - } - let stdout = String::from_utf8_lossy(&output.stdout); - parse_tmux_pane_snapshots(&stdout) -} - -fn parse_tmux_pane_snapshots(output: &str) -> Vec { - output - .lines() - .filter_map(|line| { - let mut fields = line.splitn(3, '\t'); - let pane_id = fields.next()?.trim(); - let current_command = fields.next()?.trim(); - let current_path = fields.next()?.trim(); - if pane_id.is_empty() || current_path.is_empty() { - return None; - } - Some(TmuxPaneSnapshot { - pane_id: pane_id.to_string(), - current_command: current_command.to_string(), - current_path: PathBuf::from(current_path), - }) - }) - .collect() -} - -fn pane_path_matches_workspace(pane_path: &Path, workspace: &Path) -> bool { - let pane_path = fs::canonicalize(pane_path).unwrap_or_else(|_| pane_path.to_path_buf()); - let workspace = fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf()); - pane_path == workspace || pane_path.starts_with(&workspace) -} - -fn is_idle_shell_command(command: &str) -> bool { - let command = command.rsplit('/').next().unwrap_or(command); - matches!( - command, - "bash" | "zsh" | "sh" | "fish" | "nu" | "pwsh" | "powershell" | "cmd" - ) -} - -fn git_worktree_is_dirty(workspace: &Path) -> bool { - let output = Command::new("git") - .arg("-C") - .arg(workspace) - .args(["status", "--porcelain"]) - .output(); - output - .ok() - .filter(|output| output.status.success()) - .is_some_and(|output| !output.stdout.is_empty()) -} - -fn render_resume_usage() -> String { - format!( - "Resume - Usage /resume - Auto-save .claw/sessions/.{PRIMARY_SESSION_EXTENSION} - Tip use /session list to inspect saved sessions" - ) -} - -fn parse_git_status_metadata(status: Option<&str>) -> (Option, Option) { - parse_git_status_metadata_for( - &env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - status, - ) -} - -fn resolve_git_branch_for(cwd: &Path) -> Option { - let branch = run_git_capture_in(cwd, &["branch", "--show-current"])?; - let branch = branch.trim(); - if !branch.is_empty() { - return Some(branch.to_string()); - } - - let fallback = run_git_capture_in(cwd, &["rev-parse", "--abbrev-ref", "HEAD"])?; - let fallback = fallback.trim(); - if fallback.is_empty() { - None - } else if fallback == "HEAD" { - Some("detached HEAD".to_string()) - } else { - Some(fallback.to_string()) - } -} - -fn run_git_capture_in(cwd: &Path, args: &[&str]) -> Option { - let output = std::process::Command::new("git") - .args(args) - .current_dir(cwd) - .output() - .ok()?; - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout).ok() -} - -fn find_git_root_in(cwd: &Path) -> Result> { - let output = std::process::Command::new("git") - .args(["rev-parse", "--show-toplevel"]) - .current_dir(cwd) - .output()?; - if !output.status.success() { - return Err("not a git repository".into()); - } - let path = String::from_utf8(output.stdout)?.trim().to_string(); - if path.is_empty() { - return Err("empty git root".into()); - } - Ok(PathBuf::from(path)) -} - -#[allow(clippy::too_many_lines)] -fn run_resume_command( - session_path: &Path, - session: &Session, - command: &SlashCommand, -) -> Result> { - match command { - SlashCommand::Help => Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(render_repl_help()), - json: Some(serde_json::json!({ "kind": "help", "text": render_repl_help() })), - }), - SlashCommand::Compact => { - let result = runtime::compact_session( - session, - CompactionConfig { - max_estimated_tokens: 0, - ..CompactionConfig::default() - }, - ); - let removed = result.removed_message_count; - let kept = result.compacted_session.messages.len(); - let skipped = removed == 0; - result.compacted_session.save_to_path(session_path)?; - Ok(ResumeCommandOutcome { - session: result.compacted_session, - message: Some(format_compact_report(removed, kept, skipped)), - json: Some(serde_json::json!({ - "kind": "compact", - "skipped": skipped, - "removed_messages": removed, - "kept_messages": kept, - })), - }) - } - SlashCommand::Clear { confirm } => { - if !confirm { - return Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some( - "clear: confirmation required; rerun with /clear --confirm".to_string(), - ), - json: Some(serde_json::json!({ - "kind": "error", - "error": "confirmation required", - "hint": "rerun with /clear --confirm", - })), - }); - } - let backup_path = write_session_clear_backup(session, session_path)?; - let previous_session_id = session.session_id.clone(); - let cleared = new_cli_session()?; - let new_session_id = cleared.session_id.clone(); - cleared.save_to_path(session_path)?; - Ok(ResumeCommandOutcome { - session: cleared, - message: Some(format!( - "Session cleared\n Mode resumed session reset\n Previous session {previous_session_id}\n Backup {}\n Resume previous claw --resume {}\n New session {new_session_id}\n Session file {}", - backup_path.display(), - backup_path.display(), - session_path.display() - )), - json: Some(serde_json::json!({ - "kind": "clear", - "previous_session_id": previous_session_id, - "new_session_id": new_session_id, - "backup": backup_path.display().to_string(), - "session_file": session_path.display().to_string(), - })), - }) - } - SlashCommand::Status => { - let tracker = UsageTracker::from_session(session); - let usage = tracker.cumulative_usage(); - let context = status_context(Some(session_path))?; - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(format_status_report( - session.model.as_deref().unwrap_or("restored-session"), - StatusUsage { - message_count: session.messages.len(), - turns: tracker.turns(), - latest: tracker.current_turn_usage(), - cumulative: usage, - estimated_tokens: 0, - }, - default_permission_mode().as_str(), - &context, - None, // #148: resumed sessions don't have flag provenance - )), - json: Some(status_json_value( - session.model.as_deref(), - StatusUsage { - message_count: session.messages.len(), - turns: tracker.turns(), - latest: tracker.current_turn_usage(), - cumulative: usage, - estimated_tokens: 0, - }, - default_permission_mode().as_str(), - &context, - None, // #148: resumed sessions don't have flag provenance - None, - )), - }) - } - SlashCommand::Sandbox => { - let cwd = env::current_dir()?; - let loader = ConfigLoader::default_for(&cwd); - let runtime_config = loader.load()?; - let status = resolve_sandbox_status(runtime_config.sandbox(), &cwd); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(format_sandbox_report(&status)), - json: Some(sandbox_json_value(&status)), - }) - } - SlashCommand::Cost => { - let usage = UsageTracker::from_session(session).cumulative_usage(); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(format_cost_report(usage)), - json: Some(serde_json::json!({ - "kind": "cost", - "input_tokens": usage.input_tokens, - "output_tokens": usage.output_tokens, - "cache_creation_input_tokens": usage.cache_creation_input_tokens, - "cache_read_input_tokens": usage.cache_read_input_tokens, - "total_tokens": usage.total_tokens(), - })), - }) - } - SlashCommand::Config { section } => { - let message = render_config_report(section.as_deref())?; - let json = render_config_json(section.as_deref())?; - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(message), - json: Some(json), - }) - } - SlashCommand::Mcp { action, target } => { - let cwd = env::current_dir()?; - let args = match (action.as_deref(), target.as_deref()) { - (None, None) => None, - (Some(action), None) => Some(action.to_string()), - (Some(action), Some(target)) => Some(format!("{action} {target}")), - (None, Some(target)) => Some(target.to_string()), - }; - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(handle_mcp_slash_command(args.as_deref(), &cwd)), - json: Some(handle_mcp_slash_command_json(args.as_deref(), &cwd)), - }) - } - SlashCommand::Memory => Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(render_memory_report()?), - json: Some(render_memory_json()?), - }), - SlashCommand::Init => { - // #142: run the init once, then render both text + structured JSON - // from the same InitReport so both surfaces stay in sync. - let cwd = env::current_dir()?; - let report = crate::init::initialize_repo(&cwd)?; - let message = report.render(); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(message.clone()), - json: Some(init_json_value(&report, &message)), - }) - } - SlashCommand::Diff => { - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let message = render_diff_report_for(&cwd)?; - let json = render_diff_json_for(&cwd)?; - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(message), - json: Some(json), - }) - } - SlashCommand::Version => Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(render_version_report()), - json: Some(version_json_value()), - }), - SlashCommand::Export { path } => { - let export_path = resolve_export_path(path.as_deref(), session)?; - fs::write(&export_path, render_export_text(session))?; - let msg_count = session.messages.len(); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(format!( - "Export\n Result wrote transcript\n File {}\n Messages {}", - export_path.display(), - msg_count, - )), - json: Some(serde_json::json!({ - "kind": "export", - "file": export_path.display().to_string(), - "message_count": msg_count, - })), - }) - } - SlashCommand::Agents { args } => { - let cwd = env::current_dir()?; - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?), - json: Some(serde_json::json!({ - "kind": "agents", - "text": handle_agents_slash_command(args.as_deref(), &cwd)?, - })), - }) - } - SlashCommand::Skills { args } => { - if let SkillSlashDispatch::Invoke(_) = classify_skills_slash_command(args.as_deref()) { - return Err( - "resumed /skills invocations are interactive-only; start `claw` and run `/skills ` in the REPL".into(), - ); - } - let cwd = env::current_dir()?; - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?), - json: Some(handle_skills_slash_command_json(args.as_deref(), &cwd)?), - }) - } - SlashCommand::Doctor => { - let report = render_doctor_report()?; - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(report.render()), - json: Some(report.json_value()), - }) - } - SlashCommand::Stats => { - let usage = UsageTracker::from_session(session).cumulative_usage(); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(format_cost_report(usage)), - json: Some(serde_json::json!({ - "kind": "stats", - "input_tokens": usage.input_tokens, - "output_tokens": usage.output_tokens, - "cache_creation_input_tokens": usage.cache_creation_input_tokens, - "cache_read_input_tokens": usage.cache_read_input_tokens, - "total_tokens": usage.total_tokens(), - })), - }) - } - SlashCommand::History { count } => { - let limit = parse_history_count(count.as_deref()) - .map_err(|error| -> Box { error.into() })?; - let entries = collect_session_prompt_history(session); - let shown: Vec<_> = entries.iter().rev().take(limit).rev().collect(); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(render_prompt_history_report(&entries, limit)), - json: Some(serde_json::json!({ - "kind": "history", - "total": entries.len(), - "showing": shown.len(), - "entries": shown.iter().map(|e| serde_json::json!({ - "timestamp_ms": e.timestamp_ms, - "text": e.text, - })).collect::>(), - })), - }) - } - SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()), - // /session list can be served from the sessions directory without a live session. - SlashCommand::Session { - action: Some(ref act), - .. - } if act == "list" => { - let sessions = list_managed_sessions().unwrap_or_default(); - let session_ids: Vec = sessions.iter().map(|s| s.id.clone()).collect(); - let session_details: Vec = sessions - .iter() - .map(|session| { - serde_json::json!({ - "id": session.id, - "path": session.path.display().to_string(), - "message_count": session.message_count, - "updated_at_ms": session.updated_at_ms, - "lifecycle": session.lifecycle.json_value(), - }) - }) - .collect(); - let active_id = session.session_id.clone(); - let text = render_session_list(&active_id).unwrap_or_else(|e| format!("error: {e}")); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(text), - json: Some(serde_json::json!({ - "kind": "session_list", - "sessions": session_ids, - "session_details": session_details, - "active": active_id, - })), - }) - } - SlashCommand::Bughunter { .. } - | SlashCommand::Commit { .. } - | SlashCommand::Pr { .. } - | SlashCommand::Issue { .. } - | SlashCommand::Ultraplan { .. } - | SlashCommand::Teleport { .. } - | SlashCommand::DebugToolCall { .. } - | SlashCommand::Resume { .. } - | SlashCommand::Model { .. } - | SlashCommand::Permissions { .. } - | SlashCommand::Session { .. } - | SlashCommand::Plugins { .. } - | SlashCommand::Login - | SlashCommand::Logout - | SlashCommand::Vim - | SlashCommand::Upgrade - | SlashCommand::Share - | SlashCommand::Feedback - | SlashCommand::Files - | SlashCommand::Fast - | SlashCommand::Exit - | SlashCommand::Summary - | SlashCommand::Desktop - | SlashCommand::Brief - | SlashCommand::Advisor - | SlashCommand::Stickers - | SlashCommand::Insights - | SlashCommand::Thinkback - | SlashCommand::ReleaseNotes - | SlashCommand::SecurityReview - | SlashCommand::Keybindings - | SlashCommand::PrivacySettings - | SlashCommand::Plan { .. } - | SlashCommand::Review { .. } - | SlashCommand::Tasks { .. } - | SlashCommand::Theme { .. } - | SlashCommand::Voice { .. } - | SlashCommand::Usage { .. } - | SlashCommand::Rename { .. } - | SlashCommand::Copy { .. } - | SlashCommand::Hooks { .. } - | SlashCommand::Context { .. } - | SlashCommand::Color { .. } - | SlashCommand::Effort { .. } - | SlashCommand::Branch { .. } - | SlashCommand::Rewind { .. } - | SlashCommand::Ide { .. } - | SlashCommand::Tag { .. } - | SlashCommand::OutputStyle { .. } - | SlashCommand::AddDir { .. } => Err("unsupported resumed slash command".into()), - } -} - -/// Detect if the current working directory is "broad" (home directory or -/// filesystem root). Returns the cwd path if broad, None otherwise. -fn detect_broad_cwd() -> Option { - let Ok(cwd) = env::current_dir() else { - return None; - }; - let is_home = env::var_os("HOME") - .or_else(|| env::var_os("USERPROFILE")) - .is_some_and(|h| Path::new(&h) == cwd); - let is_root = cwd.parent().is_none(); - if is_home || is_root { - Some(cwd) - } else { - None - } -} - -/// Enforce the broad-CWD policy: when running from home or root, either -/// require the --allow-broad-cwd flag, or prompt for confirmation (interactive), -/// or exit with an error (non-interactive). -fn enforce_broad_cwd_policy( - allow_broad_cwd: bool, - output_format: CliOutputFormat, -) -> Result<(), Box> { - if allow_broad_cwd { - return Ok(()); - } - let Some(cwd) = detect_broad_cwd() else { - return Ok(()); - }; - - let is_interactive = io::stdin().is_terminal(); - - if is_interactive { - // Interactive mode: print warning and ask for confirmation - eprintln!( - "Warning: claw is running from a very broad directory ({}).\n\ - The agent can read and search everything under this path.\n\ - Consider running from inside your project: cd /path/to/project && claw", - cwd.display() - ); - eprint!("Continue anyway? [y/N]: "); - io::stderr().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - let trimmed = input.trim().to_lowercase(); - if trimmed != "y" && trimmed != "yes" { - eprintln!("Aborted."); - std::process::exit(0); - } - Ok(()) - } else { - // Non-interactive mode: exit with error (JSON or text) - let message = format!( - "claw is running from a very broad directory ({}). \ - The agent can read and search everything under this path. \ - Use --allow-broad-cwd to proceed anyway, \ - or run from inside your project: cd /path/to/project && claw", - cwd.display() - ); - match output_format { - CliOutputFormat::Json => { - eprintln!( - "{}", - serde_json::json!({ - "type": "error", - "error": message, - }) - ); - } - CliOutputFormat::Text => { - eprintln!("error: {message}"); - } - } - std::process::exit(1); - } -} - -fn run_stale_base_preflight(flag_value: Option<&str>) { - let Ok(cwd) = env::current_dir() else { - return; - }; - let source = resolve_expected_base(flag_value, &cwd); - let state = check_base_commit(&cwd, source.as_ref()); - if let Some(warning) = format_stale_base_warning(&state) { - eprintln!("{warning}"); - } -} - - - - - -fn build_runtime_mcp_state( - runtime_config: &runtime::RuntimeConfig, -) -> Result> { - let Some((mcp_state, discovery)) = RuntimeMcpState::new(runtime_config)? else { - return Ok((None, Vec::new())); - }; - - let mut runtime_tools = discovery - .tools - .iter() - .map(mcp_runtime_tool_definition) - .collect::>(); - if !mcp_state.server_names().is_empty() { - runtime_tools.extend(mcp_wrapper_tool_definitions()); - } - - Ok((Some(Arc::new(Mutex::new(mcp_state))), runtime_tools)) -} - - - - - - - - - -struct HookAbortMonitor { - stop_tx: Option>, - join_handle: Option>, -} - -impl HookAbortMonitor { - fn spawn(abort_signal: runtime::HookAbortSignal) -> Self { - Self::spawn_with_waiter(abort_signal, move |stop_rx, abort_signal| { - let Ok(runtime) = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - else { - return; - }; - - runtime.block_on(async move { - let wait_for_stop = tokio::task::spawn_blocking(move || { - let _ = stop_rx.recv(); - }); - - tokio::select! { - result = tokio::signal::ctrl_c() => { - if result.is_ok() { - abort_signal.abort(); - } - } - _ = wait_for_stop => {} - } - }); - }) - } - - fn spawn_with_waiter(abort_signal: runtime::HookAbortSignal, wait_for_interrupt: F) -> Self - where - F: FnOnce(Receiver<()>, runtime::HookAbortSignal) + Send + 'static, - { - let (stop_tx, stop_rx) = mpsc::channel(); - let join_handle = thread::spawn(move || wait_for_interrupt(stop_rx, abort_signal)); - - Self { - stop_tx: Some(stop_tx), - join_handle: Some(join_handle), - } - } - - fn stop(mut self) { - if let Some(stop_tx) = self.stop_tx.take() { - let _ = stop_tx.send(()); - } - if let Some(join_handle) = self.join_handle.take() { - let _ = join_handle.join(); - } - } -} - -fn sessions_dir() -> Result> { - Ok(current_session_store()?.sessions_dir().to_path_buf()) -} - -fn current_session_store() -> Result> { - let cwd = env::current_dir()?; - runtime::SessionStore::from_cwd(&cwd).map_err(|e| Box::new(e) as Box) -} - -fn new_cli_session() -> Result> { - Ok(Session::new().with_workspace_root(env::current_dir()?)) -} - -fn create_managed_session_handle( - session_id: &str, -) -> Result> { - let handle = current_session_store()?.create_handle(session_id); - Ok(SessionHandle { - id: handle.id, - path: handle.path, - }) -} - -fn resolve_session_reference(reference: &str) -> Result> { - let handle = current_session_store()? - .resolve_reference(reference) - .map_err(|e| Box::new(e) as Box)?; - Ok(SessionHandle { - id: handle.id, - path: handle.path, - }) -} - -fn resolve_managed_session_path(session_id: &str) -> Result> { - current_session_store()? - .resolve_managed_path(session_id) - .map_err(|e| Box::new(e) as Box) -} - -fn list_managed_sessions() -> Result, Box> { - let store = current_session_store()?; - let lifecycle = classify_session_lifecycle_for(store.workspace_root()); - Ok(store - .list_sessions() - .map_err(|e| Box::new(e) as Box)? - .into_iter() - .map(|session| ManagedSessionSummary { - id: session.id, - path: session.path, - updated_at_ms: session.updated_at_ms, - modified_epoch_millis: session.modified_epoch_millis, - message_count: session.message_count, - parent_session_id: session.parent_session_id, - branch_name: session.branch_name, - lifecycle: lifecycle.clone(), - }) - .collect()) -} - -fn latest_managed_session() -> Result> { - let store = current_session_store()?; - let lifecycle = classify_session_lifecycle_for(store.workspace_root()); - let session = store - .latest_session() - .map_err(|e| Box::new(e) as Box)?; - Ok(ManagedSessionSummary { - id: session.id, - path: session.path, - updated_at_ms: session.updated_at_ms, - modified_epoch_millis: session.modified_epoch_millis, - message_count: session.message_count, - parent_session_id: session.parent_session_id, - branch_name: session.branch_name, - lifecycle, - }) -} - -fn load_session_reference( - reference: &str, -) -> Result<(SessionHandle, Session), Box> { - let loaded = current_session_store()? - .load_session(reference) - .map_err(|e| Box::new(e) as Box)?; - Ok(( - SessionHandle { - id: loaded.handle.id, - path: loaded.handle.path, - }, - loaded.session, - )) -} - -fn delete_managed_session(path: &Path) -> Result<(), Box> { - if !path.exists() { - return Err(format!("session file does not exist: {}", path.display()).into()); - } - fs::remove_file(path)?; - Ok(()) -} - -fn confirm_session_deletion(session_id: &str) -> bool { - print!("Delete session '{session_id}'? This cannot be undone. [y/N]: "); - io::stdout().flush().unwrap_or(()); - let mut answer = String::new(); - if io::stdin().read_line(&mut answer).is_err() { - return false; - } - matches!(answer.trim(), "y" | "Y" | "yes" | "Yes" | "YES") -} - -fn render_session_list(active_session_id: &str) -> Result> { - let sessions = list_managed_sessions()?; - let mut lines = vec![ - "Sessions".to_string(), - format!(" Directory {}", sessions_dir()?.display()), - ]; - if sessions.is_empty() { - lines.push(" No managed sessions saved yet.".to_string()); - return Ok(lines.join("\n")); - } - for session in sessions { - let marker = if session.id == active_session_id { - "● current" - } else { - "○ saved" - }; - let lineage = match ( - session.branch_name.as_deref(), - session.parent_session_id.as_deref(), - ) { - (Some(branch_name), Some(parent_session_id)) => { - format!(" branch={branch_name} from={parent_session_id}") - } - (None, Some(parent_session_id)) => format!(" from={parent_session_id}"), - (Some(branch_name), None) => format!(" branch={branch_name}"), - (None, None) => String::new(), - }; - lines.push(format!( - " {id:<20} {marker:<10} lifecycle={lifecycle} msgs={msgs:<4} modified={modified}{lineage} path={path}", - id = session.id, - lifecycle = session.lifecycle.signal(), - msgs = session.message_count, - modified = format_session_modified_age(session.modified_epoch_millis), - lineage = lineage, - path = session.path.display(), - )); - } - Ok(lines.join("\n")) -} - -fn write_session_clear_backup( - session: &Session, - session_path: &Path, -) -> Result> { - let backup_path = session_clear_backup_path(session_path); - session.save_to_path(&backup_path)?; - Ok(backup_path) -} - -fn session_clear_backup_path(session_path: &Path) -> PathBuf { - let timestamp = std::time::SystemTime::now() - .duration_since(UNIX_EPOCH) - .ok() - .map_or(0, |duration| duration.as_millis()); - let file_name = session_path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("session.jsonl"); - session_path.with_file_name(format!("{file_name}.before-clear-{timestamp}.bak")) -} - -fn print_status_snapshot( - model: &str, - model_flag_raw: Option<&str>, - permission_mode: PermissionMode, - output_format: CliOutputFormat, - allowed_tools: Option<&AllowedToolSet>, -) -> Result<(), Box> { - let usage = StatusUsage { - message_count: 0, - turns: 0, - latest: TokenUsage::default(), - cumulative: TokenUsage::default(), - estimated_tokens: 0, - }; - let context = status_context(None)?; - // #148: resolve model provenance. If user passed --model, source is - // "flag" with the raw input preserved. Otherwise probe env -> config - // -> default and record the winning source. - let provenance = match model_flag_raw { - Some(raw) => ModelProvenance { - resolved: model.to_string(), - raw: Some(raw.to_string()), - source: ModelSource::Flag, - }, - None => ModelProvenance::from_env_or_config_or_default(model), - }; - match output_format { - CliOutputFormat::Text => println!( - "{}", - format_status_report( - &provenance.resolved, - usage, - permission_mode.as_str(), - &context, - Some(&provenance) - ) - ), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&status_json_value( - Some(&provenance.resolved), - usage, - permission_mode.as_str(), - &context, - Some(&provenance), - allowed_tools, - ))? - ), - } - Ok(()) -} - -fn status_json_value( - model: Option<&str>, - usage: StatusUsage, - permission_mode: &str, - context: &StatusContext, - // #148: optional provenance for `model` field. Surfaces `model_source` - // ("flag" | "env" | "config" | "default") and `model_raw` (user input - // before alias resolution, or null when source is "default"). Callers - // that don't have provenance (legacy resume paths) pass None, in which - // case both new fields are omitted. - provenance: Option<&ModelProvenance>, - allowed_tools: Option<&AllowedToolSet>, -) -> serde_json::Value { - // #143: top-level `status` marker so claws can distinguish - // a clean run from a degraded run (config parse failed but other fields - // are still populated). `config_load_error` carries the parse-error string - // when present; it's a string rather than a typed object in Phase 1 and - // will join the typed-error taxonomy in Phase 2 (ROADMAP §4.44). - let degraded = context.config_load_error.is_some(); - let model_source = provenance.map(|p| p.source.as_str()); - let model_raw = provenance.and_then(|p| p.raw.clone()); - let allowed_tool_entries = allowed_tools.map(|tools| tools.iter().cloned().collect::>()); - json!({ - "kind": "status", - "status": if degraded { "degraded" } else { "ok" }, - "config_load_error": context.config_load_error, - "model": model, - "model_source": model_source, - "model_raw": model_raw, - "permission_mode": permission_mode, - "allowed_tools": { - "source": if allowed_tools.is_some() { "flag" } else { "default" }, - "restricted": allowed_tools.is_some(), - "entries": allowed_tool_entries, - }, - "usage": { - "messages": usage.message_count, - "turns": usage.turns, - "latest_total": usage.latest.total_tokens(), - "cumulative_input": usage.cumulative.input_tokens, - "cumulative_output": usage.cumulative.output_tokens, - "cumulative_total": usage.cumulative.total_tokens(), - "estimated_tokens": usage.estimated_tokens, - }, - "workspace": { - "cwd": context.cwd, - "project_root": context.project_root, - "git_branch": context.git_branch, - "git_state": context.git_summary.headline(), - "changed_files": context.git_summary.changed_files, - "staged_files": context.git_summary.staged_files, - "unstaged_files": context.git_summary.unstaged_files, - "untracked_files": context.git_summary.untracked_files, - "session": context.session_path.as_ref().map_or_else(|| "live-repl".to_string(), |path| path.display().to_string()), - "session_id": context.session_path.as_ref().and_then(|path| { - // Session files are named .jsonl directly under - // .claw/sessions/. Extract the stem (drop the .jsonl extension). - path.file_stem().map(|n| n.to_string_lossy().into_owned()) - }), - "session_lifecycle": context.session_lifecycle.json_value(), - "loaded_config_files": context.loaded_config_files, - "discovered_config_files": context.discovered_config_files, - "memory_file_count": context.memory_file_count, - }, - "sandbox": { - "enabled": context.sandbox_status.enabled, - "active": context.sandbox_status.active, - "supported": context.sandbox_status.supported, - "in_container": context.sandbox_status.in_container, - "requested_namespace": context.sandbox_status.requested.namespace_restrictions, - "active_namespace": context.sandbox_status.namespace_active, - "requested_network": context.sandbox_status.requested.network_isolation, - "active_network": context.sandbox_status.network_active, - "filesystem_mode": context.sandbox_status.filesystem_mode.as_str(), - "filesystem_active": context.sandbox_status.filesystem_active, - "allowed_mounts": context.sandbox_status.allowed_mounts, - "markers": context.sandbox_status.container_markers, - "fallback_reason": context.sandbox_status.fallback_reason, - } - }) -} - -fn status_context( - session_path: Option<&Path>, -) -> Result> { - let cwd = env::current_dir()?; - let loader = ConfigLoader::default_for(&cwd); - let discovered_config_files = loader.discover().len(); - // #143: degrade gracefully on config parse failure rather than hard-fail. - // `claw doctor` already does this; `claw status` now matches that contract - // so that one malformed `mcpServers.*` entry doesn't take down the whole - // health surface (workspace, git, model, permission, sandbox can still be - // reported independently). - let (loaded_config_files, sandbox_status, config_load_error) = match loader.load() { - Ok(runtime_config) => ( - runtime_config.loaded_entries().len(), - resolve_sandbox_status(runtime_config.sandbox(), &cwd), - None, - ), - Err(err) => ( - 0, - // Fall back to defaults for sandbox resolution so claws still see - // a populated sandbox section instead of a missing field. Defaults - // produce the same output as a runtime config with no sandbox - // overrides, which is the right degraded-mode shape: we cannot - // report what the user *intended*, only what is actually in effect. - resolve_sandbox_status(&runtime::SandboxConfig::default(), &cwd), - Some(err.to_string()), - ), - }; - let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?; - let (project_root, git_branch) = - parse_git_status_metadata(project_context.git_status.as_deref()); - let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref()); - Ok(StatusContext { - cwd: cwd.clone(), - session_path: session_path.map(Path::to_path_buf), - loaded_config_files, - discovered_config_files, - memory_file_count: project_context.instruction_files.len(), - project_root, - git_branch, - git_summary, - session_lifecycle: classify_session_lifecycle_for(&cwd), - sandbox_status, - config_load_error, - }) -} - -fn print_sandbox_status_snapshot( - output_format: CliOutputFormat, -) -> Result<(), Box> { - let cwd = env::current_dir()?; - let loader = ConfigLoader::default_for(&cwd); - let runtime_config = loader - .load() - .unwrap_or_else(|_| runtime::RuntimeConfig::empty()); - let status = resolve_sandbox_status(runtime_config.sandbox(), &cwd); - match output_format { - CliOutputFormat::Text => println!("{}", format_sandbox_report(&status)), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&sandbox_json_value(&status))? - ), - } - Ok(()) -} - -fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value { - json!({ - "kind": "sandbox", - "enabled": status.enabled, - "active": status.active, - "supported": status.supported, - "in_container": status.in_container, - "requested_namespace": status.requested.namespace_restrictions, - "active_namespace": status.namespace_active, - "requested_network": status.requested.network_isolation, - "active_network": status.network_active, - "filesystem_mode": status.filesystem_mode.as_str(), - "filesystem_active": status.filesystem_active, - "allowed_mounts": status.allowed_mounts, - "markers": status.container_markers, - "fallback_reason": status.fallback_reason, - }) -} - -fn print_help_topic(topic: LocalHelpTopic) { - println!("{}", render_help_topic(topic)); -} - -fn print_acp_status(output_format: CliOutputFormat) -> Result<(), Box> { - let message = "ACP/Zed editor integration is not implemented in claw-code yet. `claw acp serve` is only a discoverability alias today; it does not launch a daemon or Zed-specific protocol endpoint. Use the normal terminal surfaces for now and track ROADMAP #76 for real ACP support."; - match output_format { - CliOutputFormat::Text => { - println!( - "ACP / Zed\n Status discoverability only\n Launch `claw acp serve` / `claw --acp` / `claw -acp` report status only; no editor daemon is available yet\n Today use `claw prompt`, the REPL, or `claw doctor` for local verification\n Tracking ROADMAP #76\n Message {message}" - ); - } - CliOutputFormat::Json => { - println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "acp", - "status": "discoverability_only", - "supported": false, - "serve_alias_only": true, - "message": message, - "launch_command": serde_json::Value::Null, - "aliases": ["acp", "--acp", "-acp"], - "discoverability_tracking": "ROADMAP #64a", - "tracking": "ROADMAP #76", - "recommended_workflows": [ - "claw prompt TEXT", - "claw", - "claw doctor" - ], - }))? - ); - } - } - Ok(()) -} - -fn render_config_report(section: Option<&str>) -> Result> { - let cwd = env::current_dir()?; - let loader = ConfigLoader::default_for(&cwd); - let discovered = loader.discover(); - let runtime_config = loader.load()?; - - let mut lines = vec![ - format!( - "Config - Working directory {} - Loaded files {} - Merged keys {}", - cwd.display(), - runtime_config.loaded_entries().len(), - runtime_config.merged().len() - ), - "Discovered files".to_string(), - ]; - for entry in discovered { - let source = match entry.source { - ConfigSource::User => "user", - ConfigSource::Project => "project", - ConfigSource::Local => "local", - }; - let status = if runtime_config - .loaded_entries() - .iter() - .any(|loaded_entry| loaded_entry.path == entry.path) - { - "loaded" - } else { - "missing" - }; - lines.push(format!( - " {source:<7} {status:<7} {}", - entry.path.display() - )); - } - - if let Some(section) = section { - lines.push(format!("Merged section: {section}")); - let value = match section { - "env" => runtime_config.get("env"), - "hooks" => runtime_config.get("hooks"), - "model" => runtime_config.get("model"), - "plugins" => runtime_config - .get("plugins") - .or_else(|| runtime_config.get("enabledPlugins")), - other => { - lines.push(format!( - " Unsupported config section '{other}'. Use env, hooks, model, or plugins." - )); - return Ok(lines.join( - " -", - )); - } - }; - lines.push(format!( - " {}", - match value { - Some(value) => value.render(), - None => "".to_string(), - } - )); - return Ok(lines.join( - " -", - )); - } - - lines.push("Merged JSON".to_string()); - lines.push(format!(" {}", runtime_config.as_json().render())); - Ok(lines.join( - " -", - )) -} - -fn render_config_json( - _section: Option<&str>, -) -> Result> { - let cwd = env::current_dir()?; - let loader = ConfigLoader::default_for(&cwd); - let discovered = loader.discover(); - let runtime_config = loader.load()?; - - let loaded_paths: Vec<_> = runtime_config - .loaded_entries() - .iter() - .map(|e| e.path.display().to_string()) - .collect(); - - let files: Vec<_> = discovered - .iter() - .map(|e| { - let source = match e.source { - ConfigSource::User => "user", - ConfigSource::Project => "project", - ConfigSource::Local => "local", - }; - let is_loaded = runtime_config - .loaded_entries() - .iter() - .any(|le| le.path == e.path); - serde_json::json!({ - "path": e.path.display().to_string(), - "source": source, - "loaded": is_loaded, - }) - }) - .collect(); - - Ok(serde_json::json!({ - "kind": "config", - "cwd": cwd.display().to_string(), - "loaded_files": loaded_paths.len(), - "merged_keys": runtime_config.merged().len(), - "files": files, - })) -} - -fn render_memory_report() -> Result> { - let cwd = env::current_dir()?; - let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?; - let mut lines = vec![format!( - "Memory - Working directory {} - Instruction files {}", - cwd.display(), - project_context.instruction_files.len() - )]; - if project_context.instruction_files.is_empty() { - lines.push("Discovered files".to_string()); - lines.push( - " No CLAUDE instruction files discovered in the current directory ancestry." - .to_string(), - ); - } else { - lines.push("Discovered files".to_string()); - for (index, file) in project_context.instruction_files.iter().enumerate() { - let preview = file.content.lines().next().unwrap_or("").trim(); - let preview = if preview.is_empty() { - "" - } else { - preview - }; - lines.push(format!(" {}. {}", index + 1, file.path.display(),)); - lines.push(format!( - " lines={} preview={}", - file.content.lines().count(), - preview - )); - } - } - Ok(lines.join( - " -", - )) -} - -fn render_memory_json() -> Result> { - let cwd = env::current_dir()?; - let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?; - let files: Vec<_> = project_context - .instruction_files - .iter() - .map(|f| { - json!({ - "path": f.path.display().to_string(), - "lines": f.content.lines().count(), - "preview": f.content.lines().next().unwrap_or("").trim(), - }) - }) - .collect(); - Ok(json!({ - "kind": "memory", - "cwd": cwd.display().to_string(), - "instruction_files": files.len(), - "files": files, - })) -} - -fn init_claude_md() -> Result> { - let cwd = env::current_dir()?; - Ok(initialize_repo(&cwd)?.render()) -} - -fn run_init(output_format: CliOutputFormat) -> Result<(), Box> { - let cwd = env::current_dir()?; - let report = initialize_repo(&cwd)?; - let message = report.render(); - match output_format { - CliOutputFormat::Text => println!("{message}"), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&init_json_value(&report, &message))? - ), - } - Ok(()) -} - -/// #142: emit first-class structured fields alongside the legacy `message` -/// string so claws can detect per-artifact state without substring matching. -fn init_json_value(report: &crate::init::InitReport, message: &str) -> serde_json::Value { - use crate::init::InitStatus; - json!({ - "kind": "init", - "project_path": report.project_root.display().to_string(), - "created": report.artifacts_with_status(InitStatus::Created), - "updated": report.artifacts_with_status(InitStatus::Updated), - "skipped": report.artifacts_with_status(InitStatus::Skipped), - "artifacts": report.artifact_json_entries(), - "next_step": crate::init::InitReport::NEXT_STEP, - "message": message, - }) -} - - -fn render_diff_report() -> Result> { - render_diff_report_for(&env::current_dir()?) -} - -fn render_diff_report_for(cwd: &Path) -> Result> { - // Verify we are inside a git repository before calling `git diff`. - // Running `git diff --cached` outside a git tree produces a misleading - // "unknown option `cached`" error because git falls back to --no-index mode. - let in_git_repo = std::process::Command::new("git") - .args(["rev-parse", "--is-inside-work-tree"]) - .current_dir(cwd) - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - if !in_git_repo { - return Ok(format!( - "Diff\n Result no git repository\n Detail {} is not inside a git project", - cwd.display() - )); - } - let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?; - let unstaged = run_git_diff_command_in(cwd, &["diff"])?; - if staged.trim().is_empty() && unstaged.trim().is_empty() { - return Ok( - "Diff\n Result clean working tree\n Detail no current changes" - .to_string(), - ); - } - - let mut sections = Vec::new(); - if !staged.trim().is_empty() { - sections.push(format!("Staged changes:\n{}", staged.trim_end())); - } - if !unstaged.trim().is_empty() { - sections.push(format!("Unstaged changes:\n{}", unstaged.trim_end())); - } - - Ok(format!("Diff\n\n{}", sections.join("\n\n"))) -} - -fn render_diff_json_for(cwd: &Path) -> Result> { - let in_git_repo = std::process::Command::new("git") - .args(["rev-parse", "--is-inside-work-tree"]) - .current_dir(cwd) - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - if !in_git_repo { - return Ok(serde_json::json!({ - "kind": "diff", - "result": "no_git_repo", - "detail": format!("{} is not inside a git project", cwd.display()), - })); - } - let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?; - let unstaged = run_git_diff_command_in(cwd, &["diff"])?; - Ok(serde_json::json!({ - "kind": "diff", - "result": if staged.trim().is_empty() && unstaged.trim().is_empty() { "clean" } else { "changes" }, - "staged": staged.trim(), - "unstaged": unstaged.trim(), - })) -} - -fn run_git_diff_command_in( - cwd: &Path, - args: &[&str], -) -> Result> { - let output = std::process::Command::new("git") - .args(args) - .current_dir(cwd) - .output()?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return Err(format!("git {} failed: {stderr}", args.join(" ")).into()); - } - Ok(String::from_utf8(output.stdout)?) -} - -fn render_teleport_report(target: &str) -> Result> { - let cwd = env::current_dir()?; - - let file_list = Command::new("rg") - .args(["--files"]) - .current_dir(&cwd) - .output()?; - let file_matches = if file_list.status.success() { - String::from_utf8(file_list.stdout)? - .lines() - .filter(|line| line.contains(target)) - .take(10) - .map(ToOwned::to_owned) - .collect::>() - } else { - Vec::new() - }; - - let content_output = Command::new("rg") - .args(["-n", "-S", "--color", "never", target, "."]) - .current_dir(&cwd) - .output()?; - - let mut lines = vec![ - "Teleport".to_string(), - format!(" Target {target}"), - " Action search workspace files and content for the target".to_string(), - ]; - if !file_matches.is_empty() { - lines.push(String::new()); - lines.push("File matches".to_string()); - lines.extend(file_matches.into_iter().map(|path| format!(" {path}"))); - } - - if content_output.status.success() { - let matches = String::from_utf8(content_output.stdout)?; - if !matches.trim().is_empty() { - lines.push(String::new()); - lines.push("Content matches".to_string()); - lines.push(truncate_for_prompt(&matches, 4_000)); - } - } - - if lines.len() == 1 { - lines.push(" Result no matches found".to_string()); - } - - Ok(lines.join("\n")) -} - -fn render_last_tool_debug_report(session: &Session) -> Result> { - let last_tool_use = session - .messages - .iter() - .rev() - .find_map(|message| { - message.blocks.iter().rev().find_map(|block| match block { - ContentBlock::ToolUse { id, name, input } => { - Some((id.clone(), name.clone(), input.clone())) - } - _ => None, - }) - }) - .ok_or_else(|| "no prior tool call found in session".to_string())?; - - let tool_result = session.messages.iter().rev().find_map(|message| { - message.blocks.iter().rev().find_map(|block| match block { - ContentBlock::ToolResult { - tool_use_id, - tool_name, - output, - is_error, - } if tool_use_id == &last_tool_use.0 => { - Some((tool_name.clone(), output.clone(), *is_error)) - } - _ => None, - }) - }); - - let mut lines = vec![ - "Debug tool call".to_string(), - " Action inspect the last recorded tool call and its result".to_string(), - format!(" Tool id {}", last_tool_use.0), - format!(" Tool name {}", last_tool_use.1), - " Input".to_string(), - indent_block(&last_tool_use.2, 4), - ]; - - match tool_result { - Some((tool_name, output, is_error)) => { - lines.push(" Result".to_string()); - lines.push(format!(" name {tool_name}")); - lines.push(format!( - " status {}", - if is_error { "error" } else { "ok" } - )); - lines.push(indent_block(&output, 4)); - } - None => lines.push(" Result missing tool result".to_string()), - } - - Ok(lines.join("\n")) -} - -fn indent_block(value: &str, spaces: usize) -> String { - let indent = " ".repeat(spaces); - value - .lines() - .map(|line| format!("{indent}{line}")) - .collect::>() - .join("\n") -} - -fn validate_no_args( - command_name: &str, - args: Option<&str>, -) -> Result<(), Box> { - if let Some(args) = args.map(str::trim).filter(|value| !value.is_empty()) { - return Err(format!( - "{command_name} does not accept arguments. Received: {args}\nUsage: {command_name}" - ) - .into()); - } - Ok(()) -} - -fn git_output(args: &[&str]) -> Result> { - let output = Command::new("git") - .args(args) - .current_dir(env::current_dir()?) - .output()?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return Err(format!("git {} failed: {stderr}", args.join(" ")).into()); - } - Ok(String::from_utf8(output.stdout)?) -} - -fn git_status_ok(args: &[&str]) -> Result<(), Box> { - let output = Command::new("git") - .args(args) - .current_dir(env::current_dir()?) - .output()?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return Err(format!("git {} failed: {stderr}", args.join(" ")).into()); - } - Ok(()) -} - -fn command_exists(name: &str) -> bool { - Command::new("which") - .arg(name) - .output() - .map(|output| output.status.success()) - .unwrap_or(false) -} - -fn write_temp_text_file( - filename: &str, - contents: &str, -) -> Result> { - let path = env::temp_dir().join(filename); - fs::write(&path, contents)?; - Ok(path) -} - -const DEFAULT_HISTORY_LIMIT: usize = 20; - -// Computes civil (Gregorian) year/month/day from days since the Unix epoch -// (1970-01-01) using Howard Hinnant's `civil_from_days` algorithm. -#[allow( - clippy::cast_sign_loss, - clippy::cast_possible_wrap, - clippy::cast_possible_truncation -)] -fn civil_from_days(days: i64) -> (i32, u32, u32) { - let z = days + 719_468; - let era = if z >= 0 { - z / 146_097 - } else { - (z - 146_096) / 146_097 - }; - let doe = (z - era * 146_097) as u64; // [0, 146_096] - let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] - let mp = (5 * doy + 2) / 153; // [0, 11] - let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31] - let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] - let y = y + i64::from(m <= 2); - (y as i32, m as u32, d as u32) -} - -fn collect_session_prompt_history(session: &Session) -> Vec { - if !session.prompt_history.is_empty() { - return session - .prompt_history - .iter() - .map(|entry| PromptHistoryEntry { - timestamp_ms: entry.timestamp_ms, - text: entry.text.clone(), - }) - .collect(); - } - let timestamp_ms = session.updated_at_ms; - session - .messages - .iter() - .filter(|message| message.role == MessageRole::User) - .filter_map(|message| { - message.blocks.iter().find_map(|block| match block { - ContentBlock::Text { text } => Some(PromptHistoryEntry { - timestamp_ms, - text: text.clone(), - }), - _ => None, - }) - }) - .collect() -} - -fn recent_user_context(session: &Session, limit: usize) -> String { - let requests = session - .messages - .iter() - .filter(|message| message.role == MessageRole::User) - .filter_map(|message| { - message.blocks.iter().find_map(|block| match block { - ContentBlock::Text { text } => Some(text.trim().to_string()), - _ => None, - }) - }) - .rev() - .take(limit) - .collect::>(); - - if requests.is_empty() { - "".to_string() - } else { - requests - .into_iter() - .rev() - .enumerate() - .map(|(index, text)| format!("{}. {}", index + 1, text)) - .collect::>() - .join("\n") - } -} - -fn truncate_for_prompt(value: &str, limit: usize) -> String { - if value.chars().count() <= limit { - value.trim().to_string() - } else { - let truncated = value.chars().take(limit).collect::(); - format!("{}\n…[truncated]", truncated.trim_end()) - } -} - -fn sanitize_generated_message(value: &str) -> String { - value.trim().trim_matches('`').trim().replace("\r\n", "\n") -} - -fn parse_titled_body(value: &str) -> Option<(String, String)> { - let normalized = sanitize_generated_message(value); - let title = normalized - .lines() - .find_map(|line| line.strip_prefix("TITLE:").map(str::trim))?; - let body_start = normalized.find("BODY:")?; - let body = normalized[body_start + "BODY:".len()..].trim(); - Some((title.to_string(), body.to_string())) -} - -fn default_export_filename(session: &Session) -> String { - let stem = session - .messages - .iter() - .find_map(|message| match message.role { - MessageRole::User => message.blocks.iter().find_map(|block| match block { - ContentBlock::Text { text } => Some(text.as_str()), - _ => None, - }), - _ => None, - }) - .map_or("conversation", |text| { - text.lines().next().unwrap_or("conversation") - }) - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() { - ch.to_ascii_lowercase() - } else { - '-' - } - }) - .collect::() - .split('-') - .filter(|part| !part.is_empty()) - .take(8) - .collect::>() - .join("-"); - let fallback = if stem.is_empty() { - "conversation" - } else { - &stem - }; - format!("{fallback}.txt") -} - -fn resolve_export_path( - requested_path: Option<&str>, - session: &Session, -) -> Result> { - let cwd = env::current_dir()?; - let file_name = - requested_path.map_or_else(|| default_export_filename(session), ToOwned::to_owned); - let final_name = if Path::new(&file_name) - .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("txt")) - { - file_name - } else { - format!("{file_name}.txt") - }; - Ok(cwd.join(final_name)) -} - -const SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT: usize = 280; - -fn run_export( - session_reference: &str, - output_path: Option<&Path>, - output_format: CliOutputFormat, -) -> Result<(), Box> { - let (handle, session) = load_session_reference(session_reference)?; - let markdown = render_session_markdown(&session, &handle.id, &handle.path); - - if let Some(path) = output_path { - fs::write(path, &markdown)?; - let report = format!( - "Export\n Result wrote markdown transcript\n File {}\n Session {}\n Messages {}", - path.display(), - handle.id, - session.messages.len(), - ); - match output_format { - CliOutputFormat::Text => println!("{report}"), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "export", - "message": report, - "session_id": handle.id, - "file": path.display().to_string(), - "messages": session.messages.len(), - }))? - ), - } - return Ok(()); - } - - match output_format { - CliOutputFormat::Text => { - print!("{markdown}"); - if !markdown.ends_with('\n') { - println!(); - } - } - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "export", - "session_id": handle.id, - "file": handle.path.display().to_string(), - "messages": session.messages.len(), - "markdown": markdown, - }))? - ), - } - Ok(()) -} - -fn build_system_prompt() -> Result, Box> { - Ok(load_system_prompt( - env::current_dir()?, - DEFAULT_DATE, - env::consts::OS, - "unknown", - )?) -} - - - - - -fn build_plugin_manager( - cwd: &Path, - loader: &ConfigLoader, - runtime_config: &runtime::RuntimeConfig, -) -> PluginManager { - let plugin_settings = runtime_config.plugins(); - let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf()); - plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone(); - plugin_config.external_dirs = plugin_settings - .external_directories() - .iter() - .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)) - .collect(); - plugin_config.install_root = plugin_settings - .install_root() - .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); - plugin_config.registry_path = plugin_settings - .registry_path() - .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); - plugin_config.bundled_root = plugin_settings - .bundled_root() - .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); - PluginManager::new(plugin_config) -} - -fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf { - let path = PathBuf::from(value); - if path.is_absolute() { - path - } else if value.starts_with('.') { - cwd.join(path) - } else { - config_home.join(path) - } -} - -fn runtime_hook_config_from_plugin_hooks(hooks: PluginHooks) -> runtime::RuntimeHookConfig { - runtime::RuntimeHookConfig::new( - hooks.pre_tool_use, - hooks.post_tool_use, - hooks.post_tool_use_failure, - ) -} - -#[allow(clippy::needless_pass_by_value)] -#[allow(clippy::too_many_arguments)] -fn build_runtime( - session: Session, - session_id: &str, - model: String, - system_prompt: Vec, - enable_tools: bool, - emit_output: bool, - allowed_tools: Option, - permission_mode: PermissionMode, - progress_reporter: Option, -) -> Result> { - let runtime_plugin_state = build_runtime_plugin_state()?; - build_runtime_with_plugin_state( - session, - session_id, - model, - system_prompt, - enable_tools, - emit_output, - allowed_tools, - permission_mode, - progress_reporter, - runtime_plugin_state, - ) -} - -#[allow(clippy::needless_pass_by_value)] -#[allow(clippy::too_many_arguments)] -fn build_runtime_with_plugin_state( - mut session: Session, - session_id: &str, - model: String, - system_prompt: Vec, - enable_tools: bool, - emit_output: bool, - allowed_tools: Option, - permission_mode: PermissionMode, - progress_reporter: Option, - runtime_plugin_state: RuntimePluginState, -) -> Result> { - // Persist the model in session metadata so resumed sessions can report it. - if session.model.is_none() { - session.model = Some(model.clone()); - } - let RuntimePluginState { - feature_config, - tool_registry, - plugin_registry, - mcp_state, - } = runtime_plugin_state; - plugin_registry.initialize()?; - let policy = permission_policy(permission_mode, &feature_config, &tool_registry) - .map_err(std::io::Error::other)?; - let mut runtime = ConversationRuntime::new_with_features( - session, - AnthropicRuntimeClient::new( - session_id, - model, - enable_tools, - emit_output, - allowed_tools.clone(), - tool_registry.clone(), - progress_reporter, - )?, - CliToolExecutor::new( - allowed_tools.clone(), - emit_output, - tool_registry.clone(), - mcp_state.clone(), - ), - policy, - system_prompt, - &feature_config, - ); - if emit_output { - runtime = runtime.with_hook_progress_reporter(Box::new(CliHookProgressReporter)); - } - Ok(BuiltRuntime::new(runtime, plugin_registry, mcp_state)) -} - - - -// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct -// now holds an `ApiProviderClient` which dispatches to Anthropic, xAI, -// OpenAI, or DashScope at construction time based on -// `detect_provider_kind(&model)`. The struct name is kept to avoid -// churning `BuiltRuntime` and every Deref/DerefMut site that references - - -fn final_assistant_text(summary: &runtime::TurnSummary) -> String { - summary - .assistant_messages - .last() - .map(|message| { - message - .blocks - .iter() - .filter_map(|block| match block { - ContentBlock::Text { text } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join("") - }) - .unwrap_or_default() -} - -fn collect_tool_uses(summary: &runtime::TurnSummary) -> Vec { - summary - .assistant_messages - .iter() - .flat_map(|message| message.blocks.iter()) - .filter_map(|block| match block { - ContentBlock::ToolUse { id, name, input } => Some(json!({ - "id": id, - "name": name, - "input": input, - })), - _ => None, - }) - .collect() -} - -fn collect_tool_results(summary: &runtime::TurnSummary) -> Vec { - summary - .tool_results - .iter() - .flat_map(|message| message.blocks.iter()) - .filter_map(|block| match block { - ContentBlock::ToolResult { - tool_use_id, - tool_name, - output, - is_error, - } => Some(json!({ - "tool_use_id": tool_use_id, - "tool_name": tool_name, - "output": output, - "is_error": is_error, - })), - _ => None, - }) - .collect() -} - -fn collect_prompt_cache_events(summary: &runtime::TurnSummary) -> Vec { - summary - .prompt_cache_events - .iter() - .map(|event| { - json!({ - "unexpected": event.unexpected, - "reason": event.reason, - "previous_cache_read_input_tokens": event.previous_cache_read_input_tokens, - "current_cache_read_input_tokens": event.current_cache_read_input_tokens, - "token_drop": event.token_drop, - }) - }) - .collect() -} - -/// Slash commands that are registered in the spec list but not yet implemented -/// in this build. Used to filter both REPL completions and help output so the -/// discovery surface only shows commands that actually work (ROADMAP #39). -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", - // Spec entries with no parse arm — produce circular "Did you mean" error - // without this guard. Adding here routes them to the proper unsupported - // message and excludes them from REPL completions / help. - // NOTE: do NOT add "stats", "tokens", "cache" — they are implemented. - "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", -]; - -fn slash_command_completion_candidates_with_sessions( - model: &str, - active_session_id: Option<&str>, - recent_session_ids: Vec, -) -> Vec { - let mut completions = BTreeSet::new(); - - for spec in slash_command_specs() { - if STUB_COMMANDS.contains(&spec.name) { - continue; - } - completions.insert(format!("/{}", spec.name)); - for alias in spec.aliases { - if !STUB_COMMANDS.contains(alias) { - completions.insert(format!("/{alias}")); - } - } - } - - for candidate in [ - "/bughunter ", - "/clear --confirm", - "/config ", - "/config env", - "/config hooks", - "/config model", - "/config plugins", - "/mcp ", - "/mcp list", - "/mcp show ", - "/export ", - "/issue ", - "/model ", - "/model opus", - "/model sonnet", - "/model haiku", - "/permissions ", - "/permissions read-only", - "/permissions workspace-write", - "/permissions danger-full-access", - "/plugin list", - "/plugin install ", - "/plugin enable ", - "/plugin disable ", - "/plugin uninstall ", - "/plugin update ", - "/plugins list", - "/pr ", - "/resume ", - "/session list", - "/session switch ", - "/session fork ", - "/teleport ", - "/ultraplan ", - "/agents help", - "/mcp help", - "/skills help", - ] { - completions.insert(candidate.to_string()); - } - - if !model.trim().is_empty() { - completions.insert(format!("/model {}", resolve_model_alias(model))); - completions.insert(format!("/model {model}")); - } - - if let Some(active_session_id) = active_session_id.filter(|value| !value.trim().is_empty()) { - completions.insert(format!("/resume {active_session_id}")); - completions.insert(format!("/session switch {active_session_id}")); - } - - for session_id in recent_session_ids - .into_iter() - .filter(|value| !value.trim().is_empty()) - .take(10) - { - completions.insert(format!("/resume {session_id}")); - completions.insert(format!("/session switch {session_id}")); - } - - completions.into_iter().collect() -} - -const DISPLAY_TRUNCATION_NOTICE: &str = - "\x1b[2m… output truncated for display; full result preserved in session.\x1b[0m"; -const READ_DISPLAY_MAX_LINES: usize = 80; -const READ_DISPLAY_MAX_CHARS: usize = 6_000; -const TOOL_OUTPUT_DISPLAY_MAX_LINES: usize = 60; -const TOOL_OUTPUT_DISPLAY_MAX_CHARS: usize = 4_000; - - -fn truncate_output_for_display(content: &str, max_lines: usize, max_chars: usize) -> String { - let original = content.trim_end_matches('\n'); - if original.is_empty() { - return String::new(); - } - - let mut preview_lines = Vec::new(); - let mut used_chars = 0usize; - let mut truncated = false; - - for (index, line) in original.lines().enumerate() { - if index >= max_lines { - truncated = true; - break; - } - - let newline_cost = usize::from(!preview_lines.is_empty()); - let available = max_chars.saturating_sub(used_chars + newline_cost); - if available == 0 { - truncated = true; - break; - } - - let line_chars = line.chars().count(); - if line_chars > available { - preview_lines.push(line.chars().take(available).collect::()); - truncated = true; - break; - } - - preview_lines.push(line.to_string()); - used_chars += newline_cost + line_chars; - } - - let mut preview = preview_lines.join("\n"); - if truncated { - if !preview.is_empty() { - preview.push('\n'); - } - preview.push_str(DISPLAY_TRUNCATION_NOTICE); - } - preview -} - - - - - - - - - - -fn convert_messages(messages: &[ConversationMessage]) -> Vec { - messages - .iter() - .filter_map(|message| { - let role = match message.role { - MessageRole::System | MessageRole::User | MessageRole::Tool => "user", - MessageRole::Assistant => "assistant", - }; - let content = message - .blocks - .iter() - .map(|block| match block { - ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() }, - ContentBlock::Thinking { - thinking, - signature, - } => InputContentBlock::Thinking { - thinking: thinking.clone(), - signature: signature.clone(), - }, - ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse { - id: id.clone(), - name: name.clone(), - input: serde_json::from_str(input) - .unwrap_or_else(|_| serde_json::json!({ "raw": input })), - }, - ContentBlock::ToolResult { - tool_use_id, - output, - is_error, - .. - } => InputContentBlock::ToolResult { - tool_use_id: tool_use_id.clone(), - content: vec![ToolResultContentBlock::Text { - text: output.clone(), - }], - is_error: *is_error, - }, - }) - .collect::>(); - (!content.is_empty()).then(|| InputMessage { - role: role.to_string(), - content, - }) - }) - .collect() -} - -#[allow(clippy::too_many_lines)] -fn print_help_to(out: &mut impl Write) -> io::Result<()> { - writeln!(out, "claw v{VERSION}")?; - writeln!(out)?; - writeln!(out, "Usage:")?; - writeln!( - out, - " claw [--model MODEL] [--allowedTools TOOL[,TOOL...]]" - )?; - writeln!(out, " Start the interactive REPL")?; - writeln!( - out, - " claw [--model MODEL] [--output-format text|json] prompt TEXT" - )?; - writeln!(out, " Send one prompt and exit")?; - writeln!( - out, - " claw [--model MODEL] [--output-format text|json] TEXT" - )?; - writeln!(out, " Shorthand non-interactive prompt mode")?; - writeln!( - out, - " claw --resume [SESSION.jsonl|session-id|latest] [/status] [/compact] [...]" - )?; - writeln!( - out, - " Inspect or maintain a saved session without entering the REPL" - )?; - writeln!(out, " claw help")?; - writeln!(out, " Alias for --help")?; - writeln!(out, " claw version")?; - writeln!(out, " Alias for --version")?; - writeln!(out, " claw status")?; - writeln!( - out, - " Show the current local workspace status snapshot" - )?; - writeln!(out, " claw sandbox")?; - writeln!(out, " Show the current sandbox isolation snapshot")?; - writeln!(out, " claw doctor")?; - writeln!( - out, - " Diagnose local auth, config, workspace, and sandbox health" - )?; - writeln!(out, " claw acp [serve]")?; - writeln!( - out, - " Show ACP/Zed editor integration status (currently unsupported; aliases: --acp, -acp)" - )?; - writeln!(out, " Source of truth: {OFFICIAL_REPO_SLUG}")?; - writeln!( - out, - " Warning: do not `{DEPRECATED_INSTALL_COMMAND}` (deprecated stub)" - )?; - writeln!(out, " claw dump-manifests [--manifests-dir PATH]")?; - writeln!(out, " claw bootstrap-plan")?; - writeln!(out, " claw agents")?; - writeln!(out, " claw mcp")?; - writeln!(out, " claw skills")?; - writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?; - writeln!(out, " claw init")?; - writeln!( - out, - " claw export [PATH] [--session SESSION] [--output PATH]" - )?; - writeln!( - out, - " Dump the latest (or named) session as markdown; writes to PATH or stdout" - )?; - writeln!(out)?; - writeln!(out, "Flags:")?; - writeln!( - out, - " --model MODEL Override the active model" - )?; - writeln!( - out, - " --output-format FORMAT Non-interactive output format: text or json" - )?; - writeln!( - out, - " --compact Strip tool call details; print only the final assistant text (text mode only; useful for piping)" - )?; - writeln!( - out, - " --permission-mode MODE Set read-only, workspace-write, or danger-full-access" - )?; - writeln!( - out, - " --dangerously-skip-permissions Skip all permission checks" - )?; - writeln!(out, " --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)")?; - writeln!( - out, - " --version, -V Print version and build information locally" - )?; - writeln!(out)?; - writeln!(out, "Interactive slash commands:")?; - writeln!(out, "{}", render_slash_command_help_filtered(STUB_COMMANDS))?; - writeln!(out)?; - let resume_commands = resume_supported_slash_commands() - .into_iter() - .filter(|spec| !STUB_COMMANDS.contains(&spec.name)) - .map(|spec| match spec.argument_hint { - Some(argument_hint) => format!("/{} {}", spec.name, argument_hint), - None => format!("/{}", spec.name), - }) - .collect::>() - .join(", "); - writeln!(out, "Resume-safe commands: {resume_commands}")?; - writeln!(out)?; - writeln!(out, "Session shortcuts:")?; - writeln!( - out, - " REPL turns auto-save to .claw/sessions/.{PRIMARY_SESSION_EXTENSION}" - )?; - writeln!( - out, - " Use `{LATEST_SESSION_REFERENCE}` with --resume, /resume, or /session switch to target the newest saved session" - )?; - writeln!( - out, - " Use /session list in the REPL to browse managed sessions" - )?; - writeln!(out, "Examples:")?; - writeln!(out, " claw --model claude-opus \"summarize this repo\"")?; - writeln!( - out, - " claw --output-format json prompt \"explain src/main.rs\"" - )?; - writeln!(out, " claw --compact \"summarize Cargo.toml\" | wc -l")?; - writeln!( - out, - " claw --allowedTools read,glob \"summarize Cargo.toml\"" - )?; - writeln!(out, " claw --resume {LATEST_SESSION_REFERENCE}")?; - writeln!( - out, - " claw --resume {LATEST_SESSION_REFERENCE} /status /diff /export notes.txt" - )?; - writeln!(out, " claw agents")?; - writeln!(out, " claw mcp show my-server")?; - writeln!(out, " claw /skills")?; - writeln!(out, " claw doctor")?; - writeln!(out, " source of truth: {OFFICIAL_REPO_URL}")?; - writeln!( - out, - " do not run `{DEPRECATED_INSTALL_COMMAND}` — it installs a deprecated stub" - )?; - writeln!(out, " claw init")?; - writeln!(out, " claw export")?; - writeln!(out, " claw export conversation.md")?; - Ok(()) -} - -fn print_help(output_format: CliOutputFormat) -> Result<(), Box> { - let mut buffer = Vec::new(); - print_help_to(&mut buffer)?; - let message = String::from_utf8(buffer)?; - match output_format { - CliOutputFormat::Text => print!("{message}"), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "help", - "message": message, - }))? - ), - } - Ok(()) -} - -fn write_mcp_server_fixture(script_path: &Path) { +pub(crate) fn write_mcp_server_fixture(script_path: &Path) { let script = [ "#!/usr/bin/env python3", "import json, sys", @@ -3818,198 +459,72 @@ fn write_mcp_server_fixture(script_path: &Path) { " 'result': {", " 'tools': [", " {", - " 'name': 'echo',", - " 'description': 'Echo from MCP fixture',", + " 'name': 'fixture_tool',", + " 'description': 'A tool from the fixture server',", " 'inputSchema': {", " 'type': 'object',", - " 'properties': {'text': {'type': 'string'}},", - " 'required': ['text'],", - " 'additionalProperties': False", - " },", - " 'annotations': {'readOnlyHint': True}", + " 'properties': {", + " 'arg': {'type': 'string'}", + " },", + " 'required': ['arg']", + " }", " }", " ]", " }", " })", - " elif method == 'tools/call':", - " args = request['params'].get('arguments') or {}", - " send_message({", - " 'jsonrpc': '2.0',", - " 'id': request['id'],", - " 'result': {", - " 'content': [{'type': 'text', 'text': f\"echo:{args.get('text', '')}\"}],", - " 'structuredContent': {'echoed': args.get('text', '')},", - " 'isError': False", - " }", - " })", " elif method == 'resources/list':", " send_message({", " 'jsonrpc': '2.0',", " 'id': request['id'],", " 'result': {", - " 'resources': [{'uri': 'file://guide.txt', 'name': 'guide', 'mimeType': 'text/plain'}]", + " 'resources': [", + " {", + " 'uri': 'fixture://resource',", + " 'name': 'fixture_resource',", + " 'description': 'A resource from the fixture server',", + " 'mimeType': 'text/plain'", + " }", + " ]", " }", " })", " elif method == 'resources/read':", - " uri = request['params']['uri']", " send_message({", " 'jsonrpc': '2.0',", " 'id': request['id'],", " 'result': {", - " 'contents': [{'uri': uri, 'mimeType': 'text/plain', 'text': f'contents for {uri}'}]", + " 'contents': [", + " {", + " 'uri': request['params']['uri'],", + " 'mimeType': 'text/plain',", + " 'text': 'fixture resource content'", + " }", + " ]", " }", " })", - " else:", + " elif method == 'tools/call':", " send_message({", " 'jsonrpc': '2.0',", " 'id': request['id'],", - " 'error': {'code': -32601, 'message': method}", + " 'result': {", + " 'content': [", + " {", + " 'type': 'text',", + " 'text': f\"fixture tool result for {request['params']['arguments']['arg']}\"", + " }", + " ]", + " }", " })", - "", - ] - .join("\n"); - fs::write(script_path, script).expect("mcp fixture script should write"); -} + ]; + let content = script.join("\n"); + fs::write(script_path, content).expect("write fixture script"); -#[cfg(test)] -mod sandbox_report_tests { - use super::{format_sandbox_report, HookAbortMonitor}; - use runtime::HookAbortSignal; - use std::sync::mpsc; - use std::time::Duration; - - #[test] - fn sandbox_report_renders_expected_fields() { - let report = format_sandbox_report(&runtime::SandboxStatus::default()); - assert!(report.contains("Sandbox")); - assert!(report.contains("Enabled")); - assert!(report.contains("Filesystem mode")); - assert!(report.contains("Fallback reason")); - } - - #[test] - fn hook_abort_monitor_stops_without_aborting() { - let abort_signal = HookAbortSignal::new(); - let (ready_tx, ready_rx) = mpsc::channel(); - let monitor = HookAbortMonitor::spawn_with_waiter( - abort_signal.clone(), - move |stop_rx, abort_signal| { - ready_tx.send(()).expect("ready signal"); - let _ = stop_rx.recv(); - assert!(!abort_signal.is_aborted()); - }, - ); - - ready_rx.recv().expect("waiter should be ready"); - monitor.stop(); - - assert!(!abort_signal.is_aborted()); - } - - #[test] - fn hook_abort_monitor_propagates_interrupt() { - let abort_signal = HookAbortSignal::new(); - let (done_tx, done_rx) = mpsc::channel(); - let monitor = HookAbortMonitor::spawn_with_waiter( - abort_signal.clone(), - move |_stop_rx, abort_signal| { - abort_signal.abort(); - done_tx.send(()).expect("done signal"); - }, - ); - - done_rx - .recv_timeout(Duration::from_secs(1)) - .expect("interrupt should complete"); - monitor.stop(); - - assert!(abort_signal.is_aborted()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(script_path, fs::Permissions::from_mode(0o755)) + .expect("set fixture permissions"); } } #[cfg(test)] -mod dump_manifests_tests { - use super::{dump_manifests_at_path, CliOutputFormat}; - use std::fs; - - #[test] - fn dump_manifests_shows_helpful_error_when_manifests_missing() { - let root = std::env::temp_dir().join(format!( - "claw_test_missing_manifests_{}", - std::process::id() - )); - let workspace = root.join("workspace"); - std::fs::create_dir_all(&workspace).expect("failed to create temp workspace"); - - let result = dump_manifests_at_path(&workspace, None, CliOutputFormat::Text); - assert!( - result.is_err(), - "expected an error when manifests are missing" - ); - - let error_msg = result.unwrap_err().to_string(); - - assert!( - error_msg.contains("Manifest source files are missing"), - "error message should mention missing manifest sources: {error_msg}" - ); - assert!( - error_msg.contains(&root.display().to_string()), - "error message should contain the resolved repo root path: {error_msg}" - ); - assert!( - error_msg.contains("src/commands.ts"), - "error message should mention missing commands.ts: {error_msg}" - ); - assert!( - error_msg.contains("CLAUDE_CODE_UPSTREAM"), - "error message should explain how to supply the upstream path: {error_msg}" - ); - - let _ = std::fs::remove_dir_all(&root); - } - - #[test] - fn dump_manifests_uses_explicit_manifest_dir() { - let root = std::env::temp_dir().join(format!( - "claw_test_explicit_manifest_dir_{}", - std::process::id() - )); - let workspace = root.join("workspace"); - let upstream = root.join("upstream"); - fs::create_dir_all(workspace.join("nested")).expect("workspace should exist"); - fs::create_dir_all(upstream.join("src/entrypoints")) - .expect("upstream fixture should exist"); - fs::write( - upstream.join("src/commands.ts"), - "import FooCommand from './commands/foo'\n", - ) - .expect("commands fixture should write"); - fs::write( - upstream.join("src/tools.ts"), - "import ReadTool from './tools/read'\n", - ) - .expect("tools fixture should write"); - fs::write( - upstream.join("src/entrypoints/cli.tsx"), - "startupProfiler()\n", - ) - .expect("cli fixture should write"); - - let result = dump_manifests_at_path(&workspace, Some(&upstream), CliOutputFormat::Text); - assert!( - result.is_ok(), - "explicit manifest dir should succeed: {result:?}" - ); - - let _ = fs::remove_dir_all(&root); - } -} - -pub(crate) fn resolve_cli_auth_source() -> Result> { - Ok(resolve_cli_auth_source_for_cwd()?) -} - -pub(crate) fn resolve_cli_auth_source_for_cwd() -> Result { - api::resolve_startup_auth_source(|| Ok(None)) -} +mod main_tests; diff --git a/rust/crates/rusty-claude-cli/src/main_tests.rs b/rust/crates/rusty-claude-cli/src/main_tests.rs index 5b19d3d9..71afe256 100644 --- a/rust/crates/rusty-claude-cli/src/main_tests.rs +++ b/rust/crates/rusty-claude-cli/src/main_tests.rs @@ -53,7 +53,7 @@ mod tests { PluginToolDefinition { name: "plugin_echo".to_string(), description: Some("Echo plugin payload".to_string()), - input_schema: json!({ + input_schema:serde_json::json!({ "type": "object", "properties": { "message": { "type": "string" } @@ -638,9 +638,52 @@ mod tests { ); } - + #[test] + fn resolves_known_model_aliases() { + assert_eq!(resolve_model_alias("opus"), "claude-opus-4-6"); + assert_eq!(resolve_model_alias("sonnet"), "claude-sonnet-4-6"); + assert_eq!(resolve_model_alias("haiku"), "claude-haiku-4-5-20251213"); + assert_eq!(resolve_model_alias("claude-opus"), "claude-opus"); + } - + #[test] + fn user_defined_aliases_resolve_before_provider_dispatch() { + // given + let _guard = env_lock(); + let root = temp_dir(); + let cwd = root.join("project"); + let config_home = root.join("config-home"); + std::fs::create_dir_all(cwd.join(".claw")).expect("project config dir should exist"); + std::fs::create_dir_all(&config_home).expect("config home should exist"); + std::fs::write( + cwd.join(".claw").join("settings.json"), + r#"{"aliases":{"fast":"claude-haiku-4-5-20251213","smart":"opus","cheap":"grok-3-mini"}}"#, + ) + .expect("project config should write"); + + let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); + std::env::set_var("CLAW_CONFIG_HOME", &config_home); + + // when + let direct = with_current_dir(&cwd, || resolve_model_alias_with_config("fast")); + let chained = with_current_dir(&cwd, || resolve_model_alias_with_config("smart")); + let cross_provider = with_current_dir(&cwd, || resolve_model_alias_with_config("cheap")); + let unknown = with_current_dir(&cwd, || resolve_model_alias_with_config("unknown-model")); + let builtin = with_current_dir(&cwd, || resolve_model_alias_with_config("haiku")); + + match original_config_home { + Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), + None => std::env::remove_var("CLAW_CONFIG_HOME"), + } + std::fs::remove_dir_all(root).expect("temp config root should clean up"); + + // then + assert_eq!(direct, "claude-haiku-4-5-20251213"); + assert_eq!(chained, "claude-opus-4-6"); + assert_eq!(cross_provider, "grok-3-mini"); + assert_eq!(unknown, "unknown-model"); + assert_eq!(builtin, "claude-haiku-4-5-20251213"); + } #[test] fn parses_version_flags_without_initializing_prompt_mode() { @@ -1756,7 +1799,20 @@ mod tests { assert!(truncated.chars().count() <= 281); } - + #[test] + fn short_tool_id_truncates_long_identifiers_with_ellipsis() { + // given + let long = "toolu_01ABCDEFGHIJKLMN"; + let short = "tool_1"; + + // when + let trimmed_long = short_tool_id(long); + let trimmed_short = short_tool_id(short); + + // then + assert_eq!(trimmed_long, "toolu_01ABCD…"); + assert_eq!(trimmed_short, "tool_1"); + } #[test] fn parses_json_output_for_mcp_and_skills_commands() { @@ -2200,7 +2256,18 @@ mod tests { assert!(names.contains(&"plugin_echo".to_string())); } - + #[test] + fn permission_policy_uses_plugin_tool_permissions() { + let feature_config = runtime::RuntimeFeatureConfig::default(); + let policy = permission_policy( + PermissionMode::ReadOnly, + &feature_config, + ®istry_with_plugin_tool(), + ) + .expect("permission policy should build"); + let required = policy.required_mode_for("plugin_echo"); + assert_eq!(required, PermissionMode::WorkspaceWrite); + } #[test] fn shared_help_uses_resume_annotation_copy() { @@ -2336,11 +2403,52 @@ mod tests { assert_eq!(line, "Connected: grok-3 via xai"); } - + #[test] + fn resolve_repl_model_returns_user_supplied_model_unchanged_when_explicit() { + let user_model = "claude-sonnet-4-6".to_string(); - + let resolved = resolve_repl_model(user_model); - + assert_eq!(resolved, "claude-sonnet-4-6"); + } + + #[test] + fn resolve_repl_model_falls_back_to_anthropic_model_env_when_default() { + let _guard = env_lock(); + let root = temp_dir(); + fs::create_dir_all(&root).expect("root dir"); + let config_home = root.join("config"); + fs::create_dir_all(&config_home).expect("config home dir"); + std::env::set_var("CLAW_CONFIG_HOME", &config_home); + std::env::remove_var("ANTHROPIC_MODEL"); + std::env::set_var("ANTHROPIC_MODEL", "sonnet"); + + let resolved = with_current_dir(&root, || resolve_repl_model(DEFAULT_MODEL.to_string())); + + assert_eq!(resolved, "claude-sonnet-4-6"); + + std::env::remove_var("ANTHROPIC_MODEL"); + std::env::remove_var("CLAW_CONFIG_HOME"); + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + + #[test] + fn resolve_repl_model_returns_default_when_env_unset_and_no_config() { + let _guard = env_lock(); + let root = temp_dir(); + fs::create_dir_all(&root).expect("root dir"); + let config_home = root.join("config"); + fs::create_dir_all(&config_home).expect("config home dir"); + std::env::set_var("CLAW_CONFIG_HOME", &config_home); + std::env::remove_var("ANTHROPIC_MODEL"); + + let resolved = with_current_dir(&root, || resolve_repl_model(DEFAULT_MODEL.to_string())); + + assert_eq!(resolved, DEFAULT_MODEL); + + std::env::remove_var("CLAW_CONFIG_HOME"); + fs::remove_dir_all(root).expect("cleanup temp dir"); + } #[test] fn resume_supported_command_list_matches_expected_surface() { @@ -3363,15 +3471,107 @@ UU conflicted.rs", assert_eq!(entries[1].text, "world"); } - + #[test] + fn tool_rendering_helpers_compact_output() { + let start = format_tool_call_start("read_file", r#"{"path":"src/main.rs"}"#); + assert!(start.contains("read_file")); + assert!(start.contains("src/main.rs")); - + let done = format_tool_result( + "read_file", + r#"{"file":{"filePath":"src/main.rs","content":"hello","numLines":1,"startLine":1,"totalLines":1}}"#, + false, + ); + assert!(done.contains("📄 Read src/main.rs")); + assert!(done.contains("hello")); + } - + #[test] + fn tool_rendering_truncates_large_read_output_for_display_only() { + let content = (0..200) + .map(|index| format!("line {index:03}")) + .collect::>() + .join("\n"); + let output =serde_json::json!({ + "file": { + "filePath": "src/main.rs", + "content": content, + "numLines": 200, + "startLine": 1, + "totalLines": 200 + } + }) + .to_string(); - + let rendered = format_tool_result("read_file", &output, false); - + assert!(rendered.contains("line 000")); + assert!(rendered.contains("line 079")); + assert!(!rendered.contains("line 199")); + assert!(rendered.contains("full result preserved in session")); + assert!(output.contains("line 199")); + } + + #[test] + fn tool_rendering_truncates_large_bash_output_for_display_only() { + let stdout = (0..120) + .map(|index| format!("stdout {index:03}")) + .collect::>() + .join("\n"); + let output =serde_json::json!({ + "stdout": stdout, + "stderr": "", + "returnCodeInterpretation": "completed successfully" + }) + .to_string(); + + let rendered = format_tool_result("bash", &output, false); + + assert!(rendered.contains("stdout 000")); + assert!(rendered.contains("stdout 059")); + assert!(!rendered.contains("stdout 119")); + assert!(rendered.contains("full result preserved in session")); + assert!(output.contains("stdout 119")); + } + + #[test] + fn tool_rendering_truncates_generic_long_output_for_display_only() { + let items = (0..120) + .map(|index| format!("payload {index:03}")) + .collect::>(); + let output =serde_json::json!({ + "summary": "plugin payload", + "items": items, + }) + .to_string(); + + let rendered = format_tool_result("plugin_echo", &output, false); + + assert!(rendered.contains("plugin_echo")); + assert!(rendered.contains("payload 000")); + assert!(rendered.contains("payload 040")); + assert!(!rendered.contains("payload 080")); + assert!(!rendered.contains("payload 119")); + assert!(rendered.contains("full result preserved in session")); + assert!(output.contains("payload 119")); + } + + #[test] + fn tool_rendering_truncates_raw_generic_output_for_display_only() { + let output = (0..120) + .map(|index| format!("raw {index:03}")) + .collect::>() + .join("\n"); + + let rendered = format_tool_result("plugin_echo", &output, false); + + assert!(rendered.contains("plugin_echo")); + assert!(rendered.contains("raw 000")); + assert!(rendered.contains("raw 059")); + assert!(!rendered.contains("raw 119")); + assert!(rendered.contains("full result preserved in session")); + assert!(output.contains("raw 119")); + } #[test] fn ultraplan_progress_lines_include_phase_step_and_elapsed_status() { @@ -3420,7 +3620,21 @@ UU conflicted.rs", assert!(failed.contains("network timeout")); } - + #[test] + fn describe_tool_progress_summarizes_known_tools() { + assert_eq!( + describe_tool_progress("read_file", r#"{"path":"src/main.rs"}"#), + "reading src/main.rs" + ); + assert!( + describe_tool_progress("bash", r#"{"command":"cargo test -p rusty-claude-cli"}"#) + .contains("cargo test -p rusty-claude-cli") + ); + assert_eq!( + describe_tool_progress("grep_search", r#"{"pattern":"ultraplan","path":"rust"}"#), + "grep `ultraplan` in rust" + ); + } #[test] fn push_output_block_renders_markdown_text() { @@ -3457,7 +3671,7 @@ UU conflicted.rs", OutputContentBlock::ToolUse { id: "tool-1".to_string(), name: "read_file".to_string(), - input: json!({}), + input:serde_json::json!({}), }, &mut out, &mut events, @@ -3474,19 +3688,376 @@ UU conflicted.rs", ); } - + #[test] + fn response_to_events_preserves_empty_object_json_input_outside_streaming() { + let mut out = Vec::new(); + let events = response_to_events( + MessageResponse { + id: "msg-1".to_string(), + kind: "message".to_string(), + model: "claude-opus-4-6".to_string(), + role: "assistant".to_string(), + content: vec![OutputContentBlock::ToolUse { + id: "tool-1".to_string(), + name: "read_file".to_string(), + input:serde_json::json!({}), + }], + stop_reason: Some("tool_use".to_string()), + stop_sequence: None, + usage: Usage { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + request_id: None, + }, + &mut out, + ) + .expect("response conversion should succeed"); - + assert!(matches!( + &events[0], + AssistantEvent::ToolUse { name, input, .. } + if name == "read_file" && input == "{}" + )); + } - + #[test] + fn response_to_events_preserves_non_empty_json_input_outside_streaming() { + let mut out = Vec::new(); + let events = response_to_events( + MessageResponse { + id: "msg-2".to_string(), + kind: "message".to_string(), + model: "claude-opus-4-6".to_string(), + role: "assistant".to_string(), + content: vec![OutputContentBlock::ToolUse { + id: "tool-2".to_string(), + name: "read_file".to_string(), + input:serde_json::json!({ "path": "rust/Cargo.toml" }), + }], + stop_reason: Some("tool_use".to_string()), + stop_sequence: None, + usage: Usage { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + request_id: None, + }, + &mut out, + ) + .expect("response conversion should succeed"); - + assert!(matches!( + &events[0], + AssistantEvent::ToolUse { name, input, .. } + if name == "read_file" && input == "{\"path\":\"rust/Cargo.toml\"}" + )); + } - + #[test] + fn response_to_events_renders_collapsed_thinking_summary() { + let mut out = Vec::new(); + let events = response_to_events( + MessageResponse { + id: "msg-3".to_string(), + kind: "message".to_string(), + model: "claude-opus-4-6".to_string(), + role: "assistant".to_string(), + content: vec![ + OutputContentBlock::Thinking { + thinking: "step 1".to_string(), + signature: Some("sig_123".to_string()), + }, + OutputContentBlock::Text { + text: "Final answer".to_string(), + }, + ], + stop_reason: Some("end_turn".to_string()), + stop_sequence: None, + usage: Usage { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + request_id: None, + }, + &mut out, + ) + .expect("response conversion should succeed"); - + assert!(matches!( + &events[0], + AssistantEvent::TextDelta(text) if text == "Final answer" + )); + let rendered = String::from_utf8(out).expect("utf8"); + assert!(rendered.contains("▶ Thinking (6 chars hidden)")); + assert!(!rendered.contains("step 1")); + } - + #[test] + fn build_runtime_plugin_state_merges_plugin_hooks_into_runtime_features() { + let config_home = temp_dir(); + let workspace = temp_dir(); + let source_root = temp_dir(); + fs::create_dir_all(&config_home).expect("config home"); + fs::create_dir_all(&workspace).expect("workspace"); + fs::create_dir_all(&source_root).expect("source root"); + write_plugin_fixture(&source_root, "hook-runtime-demo", true, false); + + let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); + manager + .install(source_root.to_str().expect("utf8 source path")) + .expect("plugin install should succeed"); + let loader = ConfigLoader::new(&workspace, &config_home); + let runtime_config = loader.load().expect("runtime config should load"); + let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config) + .expect("plugin state should load"); + let pre_hooks = state.feature_config.hooks().pre_tool_use(); + assert_eq!(pre_hooks.len(), 1); + assert!( + pre_hooks[0].ends_with("hooks/pre.sh"), + "expected installed plugin hook path, got {pre_hooks:?}" + ); + + let _ = fs::remove_dir_all(config_home); + let _ = fs::remove_dir_all(workspace); + let _ = fs::remove_dir_all(source_root); + } + + #[test] + #[allow(clippy::too_many_lines)] + fn build_runtime_plugin_state_discovers_mcp_tools_and_surfaces_pending_servers() { + let config_home = temp_dir(); + let workspace = temp_dir(); + fs::create_dir_all(&config_home).expect("config home"); + fs::create_dir_all(&workspace).expect("workspace"); + let script_path = workspace.join("fixture-mcp.py"); + write_mcp_server_fixture(&script_path); + fs::write( + config_home.join("settings.json"), + format!( + r#"{{ + "mcpServers": {{ + "alpha": {{ + "command": "python3", + "args": ["{}"] + }}, + "broken": {{ + "command": "python3", + "args": ["-c", "import sys; sys.exit(0)"] + }} + }} + }}"#, + script_path.to_string_lossy() + ), + ) + .expect("write mcp settings"); + + let loader = ConfigLoader::new(&workspace, &config_home); + let runtime_config = loader.load().expect("runtime config should load"); + let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config) + .expect("runtime plugin state should load"); + + let allowed = state + .tool_registry + .normalize_allowed_tools(&["mcp__alpha__echo".to_string(), "MCPTool".to_string()]) + .expect("mcp tools should be allow-listable") + .expect("allow-list should exist"); + assert!(allowed.contains("mcp__alpha__echo")); + assert!(allowed.contains("MCPTool")); + + let mut executor = CliToolExecutor::new( + None, + false, + state.tool_registry.clone(), + state.mcp_state.clone(), + ); + + let tool_output = executor + .execute("mcp__alpha__echo", r#"{"text":"hello"}"#) + .expect("discovered mcp tool should execute"); + let tool_json: serde_json::Value = + serde_json::from_str(&tool_output).expect("tool output should be json"); + assert_eq!(tool_json["structuredContent"]["echoed"], "hello"); + + let wrapped_output = executor + .execute( + "MCPTool", + r#"{"qualifiedName":"mcp__alpha__echo","arguments":{"text":"wrapped"}}"#, + ) + .expect("generic mcp wrapper should execute"); + let wrapped_json: serde_json::Value = + serde_json::from_str(&wrapped_output).expect("wrapped output should be json"); + assert_eq!(wrapped_json["structuredContent"]["echoed"], "wrapped"); + + let search_output = executor + .execute("ToolSearch", r#"{"query":"alpha echo","max_results":5}"#) + .expect("tool search should execute"); + let search_json: serde_json::Value = + serde_json::from_str(&search_output).expect("search output should be json"); + assert_eq!(search_json["matches"][0], "mcp__alpha__echo"); + assert_eq!(search_json["pending_mcp_servers"][0], "broken"); + assert_eq!( + search_json["mcp_degraded"]["failed_servers"][0]["server_name"], + "broken" + ); + assert_eq!( + search_json["mcp_degraded"]["failed_servers"][0]["phase"], + "tool_discovery" + ); + assert_eq!( + search_json["mcp_degraded"]["available_tools"][0], + "mcp__alpha__echo" + ); + + let listed = executor + .execute("ListMcpResourcesTool", r#"{"server":"alpha"}"#) + .expect("resources should list"); + let listed_json: serde_json::Value = + serde_json::from_str(&listed).expect("resource output should be json"); + assert_eq!(listed_json["resources"][0]["uri"], "file://guide.txt"); + + let read = executor + .execute( + "ReadMcpResourceTool", + r#"{"server":"alpha","uri":"file://guide.txt"}"#, + ) + .expect("resource should read"); + let read_json: serde_json::Value = + serde_json::from_str(&read).expect("resource read output should be json"); + assert_eq!( + read_json["contents"][0]["text"], + "contents for file://guide.txt" + ); + + if let Some(mcp_state) = state.mcp_state { + mcp_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .shutdown() + .expect("mcp shutdown should succeed"); + } + + let _ = fs::remove_dir_all(config_home); + let _ = fs::remove_dir_all(workspace); + } + + #[test] + fn build_runtime_plugin_state_surfaces_unsupported_mcp_servers_structurally() { + let config_home = temp_dir(); + let workspace = temp_dir(); + fs::create_dir_all(&config_home).expect("config home"); + fs::create_dir_all(&workspace).expect("workspace"); + fs::write( + config_home.join("settings.json"), + r#"{ + "mcpServers": { + "remote": { + "url": "https://example.test/mcp" + } + } + }"#, + ) + .expect("write mcp settings"); + + let loader = ConfigLoader::new(&workspace, &config_home); + let runtime_config = loader.load().expect("runtime config should load"); + let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config) + .expect("runtime plugin state should load"); + let mut executor = CliToolExecutor::new( + None, + false, + state.tool_registry.clone(), + state.mcp_state.clone(), + ); + + let search_output = executor + .execute("ToolSearch", r#"{"query":"remote","max_results":5}"#) + .expect("tool search should execute"); + let search_json: serde_json::Value = + serde_json::from_str(&search_output).expect("search output should be json"); + assert_eq!(search_json["pending_mcp_servers"][0], "remote"); + assert_eq!( + search_json["mcp_degraded"]["failed_servers"][0]["server_name"], + "remote" + ); + assert_eq!( + search_json["mcp_degraded"]["failed_servers"][0]["phase"], + "server_registration" + ); + assert_eq!( + search_json["mcp_degraded"]["failed_servers"][0]["error"]["context"]["transport"], + "http" + ); + + let _ = fs::remove_dir_all(config_home); + let _ = fs::remove_dir_all(workspace); + } + + #[test] + fn build_runtime_runs_plugin_lifecycle_init_and_shutdown() { + // Serialize access to process-wide env vars so parallel tests that + // set/remove ANTHROPIC_API_KEY do not race with this test. + let _guard = env_lock(); + let config_home = temp_dir(); + // Inject a dummy API key so runtime construction succeeds without real credentials. + // This test only exercises plugin lifecycle (init/shutdown), never calls the API. + std::env::set_var("ANTHROPIC_API_KEY", "test-dummy-key-for-plugin-lifecycle"); + let workspace = temp_dir(); + let source_root = temp_dir(); + fs::create_dir_all(&config_home).expect("config home"); + fs::create_dir_all(&workspace).expect("workspace"); + fs::create_dir_all(&source_root).expect("source root"); + write_plugin_fixture(&source_root, "lifecycle-runtime-demo", false, true); + + let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); + let install = manager + .install(source_root.to_str().expect("utf8 source path")) + .expect("plugin install should succeed"); + let log_path = install.install_path.join("lifecycle.log"); + let loader = ConfigLoader::new(&workspace, &config_home); + let runtime_config = loader.load().expect("runtime config should load"); + let runtime_plugin_state = + build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config) + .expect("plugin state should load"); + let mut runtime = build_runtime_with_plugin_state( + Session::new(), + "runtime-plugin-lifecycle", + DEFAULT_MODEL.to_string(), + vec!["test system prompt".to_string()], + true, + false, + None, + PermissionMode::DangerFullAccess, + None, + runtime_plugin_state, + ) + .expect("runtime should build"); + + assert_eq!( + fs::read_to_string(&log_path).expect("init log should exist"), + "init\n" + ); + + runtime + .shutdown_plugins() + .expect("plugin shutdown should succeed"); + + assert_eq!( + fs::read_to_string(&log_path).expect("shutdown log should exist"), + "init\nshutdown\n" + ); + + let _ = fs::remove_dir_all(config_home); + let _ = fs::remove_dir_all(workspace); + let _ = fs::remove_dir_all(source_root); + std::env::remove_var("ANTHROPIC_API_KEY"); + } #[test] fn rejects_invalid_reasoning_effort_value() { 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/models.rs b/rust/crates/rusty-claude-cli/src/models.rs index 59b0ecab..30be6be3 100644 --- a/rust/crates/rusty-claude-cli/src/models.rs +++ b/rust/crates/rusty-claude-cli/src/models.rs @@ -1,98 +1,7 @@ -use crate::DEFAULT_MODEL; -use crate::{config_model_for_current_dir, resolve_model_alias_with_config}; use serde_json::json; use std::env; use std::path::PathBuf; -/// #148: Model provenance for `claw status` JSON/text output. Records where -/// the resolved model string came from so claws don't have to re-read argv -/// to audit whether their `--model` flag was honored vs falling back to env -/// or config or default. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum ModelSource { - /// Explicit `--model` / `--model=` CLI flag. - Flag, - /// ANTHROPIC_MODEL environment variable (when no flag was passed). - Env, - /// `model` key in `.claw.json` / `.claw/settings.json` (when neither - /// flag nor env set it). - Config, - /// Compiled-in DEFAULT_MODEL fallback. - Default, -} - -impl ModelSource { - pub(crate) fn as_str(&self) -> &'static str { - match self { - ModelSource::Flag => "flag", - ModelSource::Env => "env", - ModelSource::Config => "config", - ModelSource::Default => "default", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ModelProvenance { - /// Resolved model string (after alias expansion). - pub(crate) resolved: String, - /// Raw user input before alias resolution. None when source is Default. - pub(crate) raw: Option, - /// Where the resolved model string originated. - pub(crate) source: ModelSource, -} - -impl ModelProvenance { - pub(crate) fn default_fallback() -> Self { - Self { - resolved: DEFAULT_MODEL.to_string(), - raw: None, - source: ModelSource::Default, - } - } - - pub(crate) fn from_flag(raw: &str) -> Self { - Self { - resolved: resolve_model_alias_with_config(raw), - raw: Some(raw.to_string()), - source: ModelSource::Flag, - } - } - - pub(crate) fn from_env_or_config_or_default(cli_model: &str) -> Self { - // Only called when no --model flag was passed. Probe env first, - // then config, else fall back to default. Mirrors the logic in - // resolve_repl_model() but captures the source. - if cli_model != DEFAULT_MODEL { - // Already resolved from some prior path; treat as flag. - return Self { - resolved: cli_model.to_string(), - raw: Some(cli_model.to_string()), - source: ModelSource::Flag, - }; - } - if let Some(env_model) = env::var("ANTHROPIC_MODEL") - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - { - return Self { - resolved: resolve_model_alias_with_config(&env_model), - raw: Some(env_model), - source: ModelSource::Env, - }; - } - if let Some(config_model) = config_model_for_current_dir() { - return Self { - resolved: resolve_model_alias_with_config(&config_model), - raw: Some(config_model), - source: ModelSource::Config, - }; - } - Self::default_fallback() - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SessionLifecycleKind { RunningProcess, @@ -144,7 +53,7 @@ impl SessionLifecycleSummary { } pub(crate) fn json_value(&self) -> serde_json::Value { - json!({ + serde_json::json!({ "kind": self.kind.as_str(), "pane_id": self.pane_id, "pane_command": self.pane_command, @@ -221,8 +130,4 @@ pub(crate) struct ManagedSessionSummary { pub(crate) lifecycle: SessionLifecycleSummary, } -#[derive(Debug, Clone)] -pub(crate) struct PromptHistoryEntry { - pub(crate) timestamp_ms: u64, - pub(crate) text: String, -} + 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() + ), + } + } +} diff --git a/rust/crates/rusty-claude-cli/src/render.rs b/rust/crates/rusty-claude-cli/src/render.rs index 2970e01b..649552f6 100644 --- a/rust/crates/rusty-claude-cli/src/render.rs +++ b/rust/crates/rusty-claude-cli/src/render.rs @@ -1069,1309 +1069,3 @@ mod tests { } } -// --- Extracted from main.rs --- -use crate::TokenUsage; -use crate::*; -use api::Usage; -use serde_json::json; -use std::path::Path; - -pub(crate) fn format_unknown_option(option: &str) -> String { - let mut message = format!("unknown option: {option}"); - if let Some(suggestion) = suggest_closest_term(option, CLI_OPTION_SUGGESTIONS) { - message.push_str("\nDid you mean "); - message.push_str(suggestion); - message.push('?'); - } - message.push_str("\nRun `claw --help` for usage."); - message -} - -pub(crate) fn format_unknown_direct_slash_command(name: &str) -> String { - let mut message = format!("unknown slash command outside the REPL: /{name}"); - if let Some(suggestions) = render_suggestion_line("Did you mean", &suggest_slash_commands(name)) - { - message.push('\n'); - message.push_str(&suggestions); - } - if let Some(note) = omc_compatibility_note_for_unknown_slash_command(name) { - message.push('\n'); - message.push_str(note); - } - message.push_str("\nRun `claw --help` for CLI usage, or start `claw` and use /help."); - message -} - -pub(crate) fn format_unknown_slash_command(name: &str) -> String { - let mut message = format!("Unknown slash command: /{name}"); - if let Some(suggestions) = render_suggestion_line("Did you mean", &suggest_slash_commands(name)) - { - message.push('\n'); - message.push_str(&suggestions); - } - if let Some(note) = omc_compatibility_note_for_unknown_slash_command(name) { - message.push('\n'); - message.push_str(note); - } - message.push_str("\n Help /help lists available slash commands"); - message -} - -pub(crate) fn format_connected_line(model: &str) -> String { - let provider = provider_label(detect_provider_kind(model)); - format!("Connected: {model} via {provider}") -} - -#[cfg(test)] -pub(crate) fn format_unknown_slash_command_message(name: &str) -> String { - let suggestions = suggest_slash_commands(name); - let mut message = format!("unknown slash command: /{name}."); - if !suggestions.is_empty() { - message.push_str(" Did you mean "); - message.push_str(&suggestions.join(", ")); - message.push('?'); - } - if let Some(note) = omc_compatibility_note_for_unknown_slash_command(name) { - message.push(' '); - message.push_str(note); - } - message.push_str(" Use /help to list available commands."); - message -} - -pub(crate) fn format_model_report(model: &str, message_count: usize, turns: u32) -> String { - format!( - "Model - Current model {model} - Session messages {message_count} - Session turns {turns} - -Usage - Inspect current model with /model - Switch models with /model " - ) -} - -pub(crate) fn format_model_switch_report( - previous: &str, - next: &str, - message_count: usize, -) -> String { - format!( - "Model updated - Previous {previous} - Current {next} - Preserved msgs {message_count}" - ) -} - -pub(crate) fn format_permissions_report(mode: &str) -> String { - let modes = [ - ("read-only", "Read/search tools only", mode == "read-only"), - ( - "workspace-write", - "Edit files inside the workspace", - mode == "workspace-write", - ), - ( - "danger-full-access", - "Unrestricted tool access", - mode == "danger-full-access", - ), - ] - .into_iter() - .map(|(name, description, is_current)| { - let marker = if is_current { - "● current" - } else { - "○ available" - }; - format!(" {name:<18} {marker:<11} {description}") - }) - .collect::>() - .join( - " -", - ); - - format!( - "Permissions - Active mode {mode} - Mode status live session default - -Modes -{modes} - -Usage - Inspect current mode with /permissions - Switch modes with /permissions " - ) -} - -pub(crate) fn format_permissions_switch_report(previous: &str, next: &str) -> String { - format!( - "Permissions updated - Result mode switched - Previous mode {previous} - Active mode {next} - Applies to subsequent tool calls - Usage /permissions to inspect current mode" - ) -} - -pub(crate) fn format_cost_report(usage: TokenUsage) -> String { - format!( - "Cost - Input tokens {} - Output tokens {} - Cache create {} - Cache read {} - Total tokens {}", - usage.input_tokens, - usage.output_tokens, - usage.cache_creation_input_tokens, - usage.cache_read_input_tokens, - usage.total_tokens(), - ) -} - -pub(crate) fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String { - format!( - "Session resumed - Session file {session_path} - Messages {message_count} - Turns {turns}" - ) -} - -pub(crate) fn format_compact_report( - removed: usize, - resulting_messages: usize, - skipped: bool, -) -> String { - if skipped { - format!( - "Compact - Result skipped - Reason session below compaction threshold - Messages kept {resulting_messages}" - ) - } else { - format!( - "Compact - Result compacted - Messages removed {removed} - Messages kept {resulting_messages}" - ) - } -} - -pub(crate) fn format_auto_compaction_notice(removed: usize) -> String { - format!("[auto-compacted: removed {removed} messages]") -} - -pub(crate) fn format_session_modified_age(modified_epoch_millis: u128) -> String { - let now = std::time::SystemTime::now() - .duration_since(UNIX_EPOCH) - .ok() - .map_or(modified_epoch_millis, |duration| duration.as_millis()); - let delta_seconds = now - .saturating_sub(modified_epoch_millis) - .checked_div(1_000) - .unwrap_or_default(); - match delta_seconds { - 0..=4 => "just-now".to_string(), - 5..=59 => format!("{delta_seconds}s-ago"), - 60..=3_599 => format!("{}m-ago", delta_seconds / 60), - 3_600..=86_399 => format!("{}h-ago", delta_seconds / 3_600), - _ => format!("{}d-ago", delta_seconds / 86_400), - } -} - -pub(crate) fn format_status_report( - model: &str, - usage: StatusUsage, - permission_mode: &str, - context: &StatusContext, - // #148: optional model provenance to surface in a `Model source` line. - // Callers without provenance (legacy resume paths) pass None and the - // source line is omitted for backward compat. - provenance: Option<&ModelProvenance>, -) -> String { - // #143: if config failed to parse, surface a degraded banner at the top - // of the text report so humans see the parse error before the body, while - // the body below still reports everything that could be resolved without - // config (workspace, git, sandbox defaults, etc.). - let status_line = if context.config_load_error.is_some() { - "Status (degraded)" - } else { - "Status" - }; - let mut blocks: Vec = Vec::new(); - if let Some(err) = context.config_load_error.as_deref() { - blocks.push(format!( - "Config load error\n Status fail\n Summary runtime config failed to load; reporting partial status\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun" - )); - } - // #148: render Model source line after Model, showing where the string - // came from (flag / env / config / default) and the raw input if any. - let model_source_line = provenance - .map(|p| match &p.raw { - Some(raw) if raw != model => { - format!("\n Model source {} (raw: {raw})", p.source.as_str()) - } - _ => format!("\n Model source {}", p.source.as_str()), - }) - .unwrap_or_default(); - blocks.extend([ - format!( - "{status_line} - Model {model}{model_source_line} - Permission mode {permission_mode} - Messages {} - Turns {} - Estimated tokens {}", - usage.message_count, usage.turns, usage.estimated_tokens, - ), - format!( - "Usage - Latest total {} - Cumulative input {} - Cumulative output {} - Cumulative total {}", - usage.latest.total_tokens(), - usage.cumulative.input_tokens, - usage.cumulative.output_tokens, - usage.cumulative.total_tokens(), - ), - format!( - "Workspace - Cwd {} - Project root {} - Git branch {} - Git state {} - Changed files {} - Staged {} - Unstaged {} - Untracked {} - Session {} - Lifecycle {} - Config files loaded {}/{} - Memory files {} - Suggested flow /status → /diff → /commit", - context.cwd.display(), - context - .project_root - .as_ref() - .map_or_else(|| "unknown".to_string(), |path| path.display().to_string()), - context.git_branch.as_deref().unwrap_or("unknown"), - context.git_summary.headline(), - context.git_summary.changed_files, - context.git_summary.staged_files, - context.git_summary.unstaged_files, - context.git_summary.untracked_files, - context.session_path.as_ref().map_or_else( - || "live-repl".to_string(), - |path| path.display().to_string() - ), - context.session_lifecycle.signal(), - context.loaded_config_files, - context.discovered_config_files, - context.memory_file_count, - ), - format_sandbox_report(&context.sandbox_status), - ]); - blocks.join("\n\n") -} - -pub(crate) fn format_sandbox_report(status: &runtime::SandboxStatus) -> String { - format!( - "Sandbox - Enabled {} - Active {} - Supported {} - In container {} - Requested ns {} - Active ns {} - Requested net {} - Active net {} - Filesystem mode {} - Filesystem active {} - Allowed mounts {} - Markers {} - Fallback reason {}", - status.enabled, - status.active, - status.supported, - status.in_container, - status.requested.namespace_restrictions, - status.namespace_active, - status.requested.network_isolation, - status.network_active, - status.filesystem_mode.as_str(), - status.filesystem_active, - if status.allowed_mounts.is_empty() { - "".to_string() - } else { - status.allowed_mounts.join(", ") - }, - if status.container_markers.is_empty() { - "".to_string() - } else { - status.container_markers.join(", ") - }, - status - .fallback_reason - .clone() - .unwrap_or_else(|| "".to_string()), - ) -} - -pub(crate) fn format_commit_preflight_report( - branch: Option<&str>, - summary: GitWorkspaceSummary, -) -> String { - format!( - "Commit - Result ready - Branch {} - Workspace {} - Changed files {} - Action create a git commit from the current workspace changes", - branch.unwrap_or("unknown"), - summary.headline(), - summary.changed_files, - ) -} - -pub(crate) fn format_commit_skipped_report() -> String { - "Commit - Result skipped - Reason no workspace changes - Action create a git commit from the current workspace changes - Next /status to inspect context · /diff to inspect repo changes" - .to_string() -} - -pub(crate) fn format_bughunter_report(scope: Option<&str>) -> String { - format!( - "Bughunter - Scope {} - Action inspect the selected code for likely bugs and correctness issues - Output findings should include file paths, severity, and suggested fixes", - scope.unwrap_or("the current repository") - ) -} - -pub(crate) fn format_ultraplan_report(task: Option<&str>) -> String { - format!( - "Ultraplan - Task {} - Action break work into a multi-step execution plan - Output plan should cover goals, risks, sequencing, verification, and rollback", - task.unwrap_or("the current repo work") - ) -} - -pub(crate) fn format_pr_report(branch: &str, context: Option<&str>) -> String { - format!( - "PR - Branch {branch} - Context {} - Action draft or create a pull request for the current branch - Output title and markdown body suitable for GitHub", - context.unwrap_or("none") - ) -} - -pub(crate) fn format_issue_report(context: Option<&str>) -> String { - format!( - "Issue - Context {} - Action draft or create a GitHub issue from the current context - Output title and markdown body suitable for GitHub", - context.unwrap_or("none") - ) -} - -pub(crate) fn format_history_timestamp(timestamp_ms: u64) -> String { - let secs = timestamp_ms / 1_000; - let subsec_ms = timestamp_ms % 1_000; - let days_since_epoch = secs / 86_400; - let seconds_of_day = secs % 86_400; - let hours = seconds_of_day / 3_600; - let minutes = (seconds_of_day % 3_600) / 60; - let seconds = seconds_of_day % 60; - - let (year, month, day) = civil_from_days(i64::try_from(days_since_epoch).unwrap_or(0)); - format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}.{subsec_ms:03}Z") -} - -pub(crate) fn 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 format_user_visible_api_error(session_id: &str, error: &api::ApiError) -> String { - if error.is_context_window_failure() { - format_context_window_blocked_error(session_id, error) - } else if error.is_generic_fatal_wrapper() { - let mut qualifiers = vec![format!("session {session_id}")]; - if let Some(request_id) = error.request_id() { - qualifiers.push(format!("trace {request_id}")); - } - format!( - "{} ({}): {}", - error.safe_failure_class(), - qualifiers.join(", "), - error - ) - } else { - error.to_string() - } -} - -pub(crate) fn format_context_window_blocked_error( - session_id: &str, - error: &api::ApiError, -) -> String { - let mut lines = vec![ - "Context window blocked".to_string(), - " Failure class context_window_blocked".to_string(), - format!(" Session {session_id}"), - ]; - - if let Some(request_id) = error.request_id() { - lines.push(format!(" Trace {request_id}")); - } - - match error { - api::ApiError::ContextWindowExceeded { - model, - estimated_input_tokens, - requested_output_tokens, - estimated_total_tokens, - context_window_tokens, - } => { - lines.push(format!(" Model {model}")); - lines.push(format!( - " Input estimate ~{estimated_input_tokens} tokens (heuristic)" - )); - lines.push(format!( - " Requested output {requested_output_tokens} tokens" - )); - lines.push(format!( - " Total estimate ~{estimated_total_tokens} tokens (heuristic)" - )); - lines.push(format!(" Context window {context_window_tokens} tokens")); - } - api::ApiError::Api { message, body, .. } => { - let detail = message.as_deref().unwrap_or(body).trim(); - if !detail.is_empty() { - lines.push(format!( - " Detail {}", - truncate_for_summary(detail, 120) - )); - } - } - api::ApiError::RetriesExhausted { last_error, .. } => { - let detail = match last_error.as_ref() { - api::ApiError::Api { message, body, .. } => message.as_deref().unwrap_or(body), - other => return format_context_window_blocked_error(session_id, other), - } - .trim(); - if !detail.is_empty() { - lines.push(format!( - " Detail {}", - truncate_for_summary(detail, 120) - )); - } - } - _ => {} - } - - lines.push(String::new()); - lines.push("Recovery".to_string()); - lines.push(" Compact /compact".to_string()); - lines.push(format!( - " Resume compact claw --resume {session_id} /compact" - )); - lines.push(" Fresh session /clear --confirm".to_string()); - lines.push( - " Reduce scope remove large pasted context/files or ask for a smaller slice" - .to_string(), - ); - lines.push(" Retry rerun after compacting or reducing the request".to_string()); - - lines.join("\n") -} - -pub(crate) fn format_tool_call_start(name: &str, input: &str) -> String { - let parsed: serde_json::Value = - serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string())); - - let detail = match name { - "bash" | "Bash" => format_bash_call(&parsed), - "read_file" | "Read" => { - let path = extract_tool_path(&parsed); - format!("\x1b[2m📄 Reading {path}…\x1b[0m") - } - "write_file" | "Write" => { - let path = extract_tool_path(&parsed); - let lines = parsed - .get("content") - .and_then(|value| value.as_str()) - .map_or(0, |content| content.lines().count()); - format!("\x1b[1;32m✏️ Writing {path}\x1b[0m \x1b[2m({lines} lines)\x1b[0m") - } - "edit_file" | "Edit" => { - let path = extract_tool_path(&parsed); - let old_value = parsed - .get("old_string") - .or_else(|| parsed.get("oldString")) - .and_then(|value| value.as_str()) - .unwrap_or_default(); - let new_value = parsed - .get("new_string") - .or_else(|| parsed.get("newString")) - .and_then(|value| value.as_str()) - .unwrap_or_default(); - format!( - "\x1b[1;33m📝 Editing {path}\x1b[0m{}", - format_patch_preview(old_value, new_value) - .map(|preview| format!("\n{preview}")) - .unwrap_or_default() - ) - } - "glob_search" | "Glob" => format_search_start("🔎 Glob", &parsed), - "grep_search" | "Grep" => format_search_start("🔎 Grep", &parsed), - "web_search" | "WebSearch" => parsed - .get("query") - .and_then(|value| value.as_str()) - .unwrap_or("?") - .to_string(), - _ => summarize_tool_payload(input), - }; - - let border = "─".repeat(name.len() + 8); - format!( - "\x1b[38;5;245m╭─ \x1b[1;36m{name}\x1b[0;38;5;245m ─╮\x1b[0m\n\x1b[38;5;245m│\x1b[0m {detail}\n\x1b[38;5;245m╰{border}╯\x1b[0m" - ) -} - -pub(crate) fn format_tool_result(name: &str, output: &str, is_error: bool) -> String { - let icon = if is_error { - "\x1b[1;31m✗\x1b[0m" - } else { - "\x1b[1;32m✓\x1b[0m" - }; - if is_error { - let summary = truncate_for_summary(output.trim(), 160); - return if summary.is_empty() { - format!("{icon} \x1b[38;5;245m{name}\x1b[0m") - } else { - format!("{icon} \x1b[38;5;245m{name}\x1b[0m\n\x1b[38;5;203m{summary}\x1b[0m") - }; - } - - let parsed: serde_json::Value = - serde_json::from_str(output).unwrap_or(serde_json::Value::String(output.to_string())); - match name { - "bash" | "Bash" => format_bash_result(icon, &parsed), - "read_file" | "Read" => format_read_result(icon, &parsed), - "write_file" | "Write" => format_write_result(icon, &parsed), - "edit_file" | "Edit" => format_edit_result(icon, &parsed), - "glob_search" | "Glob" => format_glob_result(icon, &parsed), - "grep_search" | "Grep" => format_grep_result(icon, &parsed), - _ => format_generic_tool_result(icon, name, &parsed), - } -} - -pub(crate) fn format_search_start(label: &str, parsed: &serde_json::Value) -> String { - let pattern = parsed - .get("pattern") - .and_then(|value| value.as_str()) - .unwrap_or("?"); - let scope = parsed - .get("path") - .and_then(|value| value.as_str()) - .unwrap_or("."); - format!("{label} {pattern}\n\x1b[2min {scope}\x1b[0m") -} - -pub(crate) fn format_patch_preview(old_value: &str, new_value: &str) -> Option { - if old_value.is_empty() && new_value.is_empty() { - return None; - } - Some(format!( - "\x1b[38;5;203m- {}\x1b[0m\n\x1b[38;5;70m+ {}\x1b[0m", - truncate_for_summary(first_visible_line(old_value), 72), - truncate_for_summary(first_visible_line(new_value), 72) - )) -} - -pub(crate) fn format_bash_call(parsed: &serde_json::Value) -> String { - let command = parsed - .get("command") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - if command.is_empty() { - String::new() - } else { - format!( - "\x1b[48;5;236;38;5;255m $ {} \x1b[0m", - truncate_for_summary(command, 160) - ) - } -} - -pub(crate) fn format_bash_result(icon: &str, parsed: &serde_json::Value) -> String { - use std::fmt::Write as _; - - let mut lines = vec![format!("{icon} \x1b[38;5;245mbash\x1b[0m")]; - if let Some(task_id) = parsed - .get("backgroundTaskId") - .and_then(|value| value.as_str()) - { - write!(&mut lines[0], " backgrounded ({task_id})").expect("write to string"); - } else if let Some(status) = parsed - .get("returnCodeInterpretation") - .and_then(|value| value.as_str()) - .filter(|status| !status.is_empty()) - { - write!(&mut lines[0], " {status}").expect("write to string"); - } - - if let Some(stdout) = parsed.get("stdout").and_then(|value| value.as_str()) { - if !stdout.trim().is_empty() { - lines.push(truncate_output_for_display( - stdout, - TOOL_OUTPUT_DISPLAY_MAX_LINES, - TOOL_OUTPUT_DISPLAY_MAX_CHARS, - )); - } - } - if let Some(stderr) = parsed.get("stderr").and_then(|value| value.as_str()) { - if !stderr.trim().is_empty() { - lines.push(format!( - "\x1b[38;5;203m{}\x1b[0m", - truncate_output_for_display( - stderr, - TOOL_OUTPUT_DISPLAY_MAX_LINES, - TOOL_OUTPUT_DISPLAY_MAX_CHARS, - ) - )); - } - } - - lines.join("\n\n") -} - -pub(crate) fn format_read_result(icon: &str, parsed: &serde_json::Value) -> String { - let file = parsed.get("file").unwrap_or(parsed); - let path = extract_tool_path(file); - let start_line = file - .get("startLine") - .and_then(serde_json::Value::as_u64) - .unwrap_or(1); - let num_lines = file - .get("numLines") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - let total_lines = file - .get("totalLines") - .and_then(serde_json::Value::as_u64) - .unwrap_or(num_lines); - let content = file - .get("content") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - let end_line = start_line.saturating_add(num_lines.saturating_sub(1)); - - format!( - "{icon} \x1b[2m📄 Read {path} (lines {}-{} of {})\x1b[0m\n{}", - start_line, - end_line.max(start_line), - total_lines, - truncate_output_for_display(content, READ_DISPLAY_MAX_LINES, READ_DISPLAY_MAX_CHARS) - ) -} - -pub(crate) fn format_write_result(icon: &str, parsed: &serde_json::Value) -> String { - let path = extract_tool_path(parsed); - let kind = parsed - .get("type") - .and_then(|value| value.as_str()) - .unwrap_or("write"); - let line_count = parsed - .get("content") - .and_then(|value| value.as_str()) - .map_or(0, |content| content.lines().count()); - format!( - "{icon} \x1b[1;32m✏️ {} {path}\x1b[0m \x1b[2m({line_count} lines)\x1b[0m", - if kind == "create" { "Wrote" } else { "Updated" }, - ) -} - -pub(crate) fn format_structured_patch_preview(parsed: &serde_json::Value) -> Option { - let hunks = parsed.get("structuredPatch")?.as_array()?; - let mut preview = Vec::new(); - for hunk in hunks.iter().take(2) { - let lines = hunk.get("lines")?.as_array()?; - for line in lines.iter().filter_map(|value| value.as_str()).take(6) { - match line.chars().next() { - Some('+') => preview.push(format!("\x1b[38;5;70m{line}\x1b[0m")), - Some('-') => preview.push(format!("\x1b[38;5;203m{line}\x1b[0m")), - _ => preview.push(line.to_string()), - } - } - } - if preview.is_empty() { - None - } else { - Some(preview.join("\n")) - } -} - -pub(crate) fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String { - let path = extract_tool_path(parsed); - let suffix = if parsed - .get("replaceAll") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) - { - " (replace all)" - } else { - "" - }; - let preview = format_structured_patch_preview(parsed).or_else(|| { - let old_value = parsed - .get("oldString") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - let new_value = parsed - .get("newString") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - format_patch_preview(old_value, new_value) - }); - - match preview { - Some(preview) => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m\n{preview}"), - None => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m"), - } -} - -pub(crate) fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String { - let num_files = parsed - .get("numFiles") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - let filenames = parsed - .get("filenames") - .and_then(|value| value.as_array()) - .map(|files| { - files - .iter() - .filter_map(|value| value.as_str()) - .take(8) - .collect::>() - .join("\n") - }) - .unwrap_or_default(); - if filenames.is_empty() { - format!("{icon} \x1b[38;5;245mglob_search\x1b[0m matched {num_files} files") - } else { - format!("{icon} \x1b[38;5;245mglob_search\x1b[0m matched {num_files} files\n{filenames}") - } -} - -pub(crate) fn format_grep_result(icon: &str, parsed: &serde_json::Value) -> String { - let num_matches = parsed - .get("numMatches") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - let num_files = parsed - .get("numFiles") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - let content = parsed - .get("content") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - let filenames = parsed - .get("filenames") - .and_then(|value| value.as_array()) - .map(|files| { - files - .iter() - .filter_map(|value| value.as_str()) - .take(8) - .collect::>() - .join("\n") - }) - .unwrap_or_default(); - let summary = format!( - "{icon} \x1b[38;5;245mgrep_search\x1b[0m {num_matches} matches across {num_files} files" - ); - if !content.trim().is_empty() { - format!( - "{summary}\n{}", - truncate_output_for_display( - content, - TOOL_OUTPUT_DISPLAY_MAX_LINES, - TOOL_OUTPUT_DISPLAY_MAX_CHARS, - ) - ) - } else if !filenames.is_empty() { - format!("{summary}\n{filenames}") - } else { - summary - } -} - -pub(crate) fn format_generic_tool_result( - icon: &str, - name: &str, - parsed: &serde_json::Value, -) -> String { - let rendered_output = match parsed { - serde_json::Value::String(text) => text.clone(), - serde_json::Value::Null => String::new(), - serde_json::Value::Object(_) | serde_json::Value::Array(_) => { - serde_json::to_string_pretty(parsed).unwrap_or_else(|_| parsed.to_string()) - } - _ => parsed.to_string(), - }; - let preview = truncate_output_for_display( - &rendered_output, - TOOL_OUTPUT_DISPLAY_MAX_LINES, - TOOL_OUTPUT_DISPLAY_MAX_CHARS, - ); - - if preview.is_empty() { - format!("{icon} \x1b[38;5;245m{name}\x1b[0m") - } else if preview.contains('\n') { - format!("{icon} \x1b[38;5;245m{name}\x1b[0m\n{preview}") - } else { - format!("{icon} \x1b[38;5;245m{name}:\x1b[0m {preview}") - } -} - -pub(crate) fn render_suggestion_line(label: &str, suggestions: &[String]) -> Option { - (!suggestions.is_empty()).then(|| format!(" {label:<16} {}", suggestions.join(", "),)) -} - -pub(crate) fn render_repl_help() -> String { - [ - "REPL".to_string(), - " /exit Quit the REPL".to_string(), - " /quit Quit the REPL".to_string(), - " Up/Down Navigate prompt history".to_string(), - " Ctrl-R Reverse-search prompt history".to_string(), - " Tab Complete commands, modes, and recent sessions".to_string(), - " Ctrl-C Clear input (or exit on empty prompt)".to_string(), - " Shift+Enter/Ctrl+J Insert a newline".to_string(), - " Auto-save .claw/sessions/.jsonl".to_string(), - " Resume latest /resume latest".to_string(), - " Browse sessions /session list".to_string(), - " Show prompt history /history [count]".to_string(), - String::new(), - render_slash_command_help_filtered(STUB_COMMANDS), - ] - .join( - " -", - ) -} - -pub(crate) fn render_help_topic(topic: LocalHelpTopic) -> String { - match topic { - LocalHelpTopic::Status => "Status - Usage claw status [--output-format ] - Purpose show the local workspace snapshot without entering the REPL - Output model, permissions, git state, config files, and sandbox status - Formats text (default), json - Related /status · claw --resume latest /status" - .to_string(), - LocalHelpTopic::Sandbox => "Sandbox - Usage claw sandbox [--output-format ] - Purpose inspect the resolved sandbox and isolation state for the current directory - Output namespace, network, filesystem, and fallback details - Formats text (default), json - Related /sandbox · claw status" - .to_string(), - LocalHelpTopic::Doctor => "Doctor - Usage claw doctor [--output-format ] - Purpose diagnose local auth, config, workspace, sandbox, and build metadata - Output local-only health report; no provider request or session resume required - Formats text (default), json - Related /doctor · claw --resume latest /doctor" - .to_string(), - LocalHelpTopic::Acp => "ACP / Zed - Usage claw acp [serve] [--output-format ] - Aliases claw --acp · claw -acp - Purpose explain the current editor-facing ACP/Zed launch contract without starting the runtime - Status discoverability only; `serve` is a status alias and does not launch a daemon yet - Formats text (default), json - Related ROADMAP #64a (discoverability) · ROADMAP #76 (real ACP support) · claw --help" - .to_string(), - LocalHelpTopic::Init => "Init - Usage claw init [--output-format ] - Purpose create .claw/, .claw.json, .gitignore, and CLAUDE.md in the current project - Output list of created vs. skipped files (idempotent: safe to re-run) - Formats text (default), json - Related claw status · claw doctor" - .to_string(), - LocalHelpTopic::State => "State - Usage claw state [--output-format ] - Purpose read .claw/worker-state.json written by the interactive REPL or a one-shot prompt - Output worker id, model, permissions, session reference (text or json) - Formats text (default), json - Produces state `claw` (interactive REPL) or `claw prompt ` (one non-interactive turn) - Observes state `claw state` reads; clawhip/CI may poll this file without HTTP - Exit codes 0 if state file exists and parses; 1 with actionable hint otherwise - Related claw status · ROADMAP #139 (this worker-concept contract)" - .to_string(), - LocalHelpTopic::Export => "Export - Usage claw export [--session ] [--output ] [--output-format ] - Purpose serialize a managed session to JSON for review, transfer, or archival - Defaults --session latest (most recent managed session in .claw/sessions/) - Formats text (default), json - Related /session list · claw --resume latest" - .to_string(), - LocalHelpTopic::Version => "Version - Usage claw version [--output-format ] - Aliases claw --version · claw -V - Purpose print the claw CLI version and build metadata - Formats text (default), json - Related claw doctor (full build/auth/config diagnostic)" - .to_string(), - LocalHelpTopic::SystemPrompt => "System Prompt - Usage claw system-prompt [--cwd ] [--date YYYY-MM-DD] [--output-format ] - Purpose render the resolved system prompt that `claw` would send for the given cwd + date - Options --cwd overrides the workspace dir · --date injects a deterministic date stamp - Formats text (default), json - Related claw doctor · claw dump-manifests" - .to_string(), - LocalHelpTopic::DumpManifests => "Dump Manifests - Usage claw dump-manifests [--manifests-dir ] [--output-format ] - Purpose emit every skill/agent/tool manifest the resolver would load for the current cwd - Options --manifests-dir scopes discovery to a specific directory - Formats text (default), json - Related claw skills · claw agents · claw doctor" - .to_string(), - LocalHelpTopic::BootstrapPlan => "Bootstrap Plan - Usage claw bootstrap-plan [--output-format ] - Purpose list the ordered startup phases the CLI would execute before dispatch - Output phase names (text) or structured phase list (json) — primary output is the plan itself - Formats text (default), json - Related claw doctor · claw status" - .to_string(), - } -} - -pub(crate) fn render_prompt_history_report(entries: &[PromptHistoryEntry], limit: usize) -> String { - if entries.is_empty() { - return "Prompt history\n Result no prompts recorded yet".to_string(); - } - - let total = entries.len(); - let start = total.saturating_sub(limit); - let shown = &entries[start..]; - let mut lines = vec![ - "Prompt history".to_string(), - format!(" Total {total}"), - format!(" Showing {} most recent", shown.len()), - format!(" Reverse search Ctrl-R in the REPL"), - String::new(), - ]; - for (offset, entry) in shown.iter().enumerate() { - let absolute_index = start + offset + 1; - let timestamp = format_history_timestamp(entry.timestamp_ms); - let first_line = entry.text.lines().next().unwrap_or("").trim(); - let display = if first_line.chars().count() > 80 { - let truncated: String = first_line.chars().take(77).collect(); - format!("{truncated}...") - } else { - first_line.to_string() - }; - lines.push(format!(" {absolute_index:>3}. [{timestamp}] {display}")); - } - lines.join("\n") -} - -pub(crate) fn render_version_report() -> String { - let git_sha = GIT_SHA.unwrap_or("unknown"); - let target = BUILD_TARGET.unwrap_or("unknown"); - format!( - "Claw Code\n Version {VERSION}\n Git SHA {git_sha}\n Target {target}\n Build date {DEFAULT_DATE}" - ) -} - -pub(crate) fn render_export_text(session: &Session) -> String { - let mut lines = vec!["# Conversation Export".to_string(), String::new()]; - for (index, message) in session.messages.iter().enumerate() { - let role = match message.role { - MessageRole::System => "system", - MessageRole::User => "user", - MessageRole::Assistant => "assistant", - MessageRole::Tool => "tool", - }; - lines.push(format!("## {}. {role}", index + 1)); - for block in &message.blocks { - match block { - ContentBlock::Text { text } => lines.push(text.clone()), - ContentBlock::ToolUse { id, name, input } => { - lines.push(format!("[tool_use id={id} name={name}] {input}")); - } - ContentBlock::ToolResult { - tool_use_id, - tool_name, - output, - is_error, - } => { - lines.push(format!( - "[tool_result id={tool_use_id} name={tool_name} error={is_error}] {output}" - )); - } - ContentBlock::Thinking { - thinking, - signature, - } => { - lines.push(format!("[thinking signature={signature:?}] {thinking}")); - } - } - } - lines.push(String::new()); - } - lines.join("\n") -} - -pub(crate) fn render_session_markdown( - session: &Session, - session_id: &str, - session_path: &Path, -) -> String { - let mut lines = vec![ - "# Conversation Export".to_string(), - String::new(), - format!("- **Session**: `{session_id}`"), - format!("- **File**: `{}`", session_path.display()), - format!("- **Messages**: {}", session.messages.len()), - ]; - if let Some(workspace_root) = session.workspace_root() { - lines.push(format!("- **Workspace**: `{}`", workspace_root.display())); - } - if let Some(fork) = &session.fork { - let branch = fork.branch_name.as_deref().unwrap_or("(unnamed)"); - lines.push(format!( - "- **Forked from**: `{}` (branch `{branch}`)", - fork.parent_session_id - )); - } - if let Some(compaction) = &session.compaction { - lines.push(format!( - "- **Compactions**: {} (last removed {} messages)", - compaction.count, compaction.removed_message_count - )); - } - lines.push(String::new()); - lines.push("---".to_string()); - lines.push(String::new()); - - for (index, message) in session.messages.iter().enumerate() { - let role = match message.role { - MessageRole::System => "System", - MessageRole::User => "User", - MessageRole::Assistant => "Assistant", - MessageRole::Tool => "Tool", - }; - lines.push(format!("## {}. {role}", index + 1)); - lines.push(String::new()); - for block in &message.blocks { - match block { - ContentBlock::Text { text } => { - let trimmed = text.trim_end(); - if !trimmed.is_empty() { - lines.push(trimmed.to_string()); - lines.push(String::new()); - } - } - ContentBlock::ToolUse { id, name, input } => { - lines.push(format!( - "**Tool call** `{name}` _(id `{}`)_", - short_tool_id(id) - )); - let summary = summarize_tool_payload_for_markdown(input); - if !summary.is_empty() { - lines.push(format!("> {summary}")); - } - lines.push(String::new()); - } - ContentBlock::ToolResult { - tool_use_id, - tool_name, - output, - is_error, - } => { - let status = if *is_error { "error" } else { "ok" }; - lines.push(format!( - "**Tool result** `{tool_name}` _(id `{}`, {status})_", - short_tool_id(tool_use_id) - )); - let summary = summarize_tool_payload_for_markdown(output); - if !summary.is_empty() { - lines.push(format!("> {summary}")); - } - lines.push(String::new()); - } - ContentBlock::Thinking { - thinking, - signature, - } => { - lines.push(format!("**Thinking** _{signature:?}_")); - let summary = summarize_tool_payload_for_markdown(thinking); - if !summary.is_empty() { - lines.push(format!("> {summary}")); - } - lines.push(String::new()); - } - } - } - if let Some(usage) = message.usage { - lines.push(format!( - "_tokens: in={} out={} cache_create={} cache_read={}_", - usage.input_tokens, - usage.output_tokens, - usage.cache_creation_input_tokens, - usage.cache_read_input_tokens, - )); - lines.push(String::new()); - } - } - lines.join("\n") -} - -pub(crate) fn render_thinking_block_summary( - out: &mut (impl Write + ?Sized), - char_count: Option, - redacted: bool, -) -> Result<(), RuntimeError> { - let summary = if redacted { - "\n▶ Thinking block hidden by provider\n".to_string() - } else if let Some(char_count) = char_count { - format!("\n▶ Thinking ({char_count} chars hidden)\n") - } else { - "\n▶ Thinking hidden\n".to_string() - }; - write!(out, "{summary}") - .and_then(|()| out.flush()) - .map_err(|error| RuntimeError::new(error.to_string())) -} - -pub(crate) fn push_output_block( - block: OutputContentBlock, - out: &mut (impl Write + ?Sized), - events: &mut Vec, - pending_tool: &mut Option<(String, String, String)>, - streaming_tool_input: bool, - block_has_thinking_summary: &mut bool, -) -> Result<(), RuntimeError> { - match block { - OutputContentBlock::Text { text } => { - if !text.is_empty() { - let rendered = TerminalRenderer::new().markdown_to_ansi(&text); - write!(out, "{rendered}") - .and_then(|()| out.flush()) - .map_err(|error| RuntimeError::new(error.to_string()))?; - events.push(AssistantEvent::TextDelta(text)); - } - } - OutputContentBlock::ToolUse { id, name, input } => { - // During streaming, the initial content_block_start has an empty input ({}). - // The real input arrives via input_json_delta events. In - // non-streaming responses, preserve a legitimate empty object. - let initial_input = if streaming_tool_input - && input.is_object() - && input.as_object().is_some_and(serde_json::Map::is_empty) - { - String::new() - } else { - input.to_string() - }; - *pending_tool = Some((id, name, initial_input)); - } - OutputContentBlock::Thinking { thinking, .. } => { - render_thinking_block_summary(out, Some(thinking.chars().count()), false)?; - *block_has_thinking_summary = true; - } - OutputContentBlock::RedactedThinking { .. } => { - render_thinking_block_summary(out, None, true)?; - *block_has_thinking_summary = true; - } - } - Ok(()) -} - -pub(crate) fn summarize_tool_payload_for_markdown(payload: &str) -> String { - let compact = match serde_json::from_str::(payload) { - Ok(value) => value.to_string(), - Err(_) => payload.split_whitespace().collect::>().join(" "), - }; - if compact.is_empty() { - return String::new(); - } - truncate_for_summary(&compact, SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT) -} - -pub(crate) fn short_tool_id(id: &str) -> String { - let char_count = id.chars().count(); - if char_count <= 12 { - return id.to_string(); - } - let prefix: String = id.chars().take(12).collect(); - format!("{prefix}…") -} diff --git a/rust/crates/rusty-claude-cli/src/repl.rs b/rust/crates/rusty-claude-cli/src/repl.rs index b27cb87e..5372231a 100644 --- a/rust/crates/rusty-claude-cli/src/repl.rs +++ b/rust/crates/rusty-claude-cli/src/repl.rs @@ -1,1738 +1 @@ -use crate::diagnostics::{render_doctor_report, run_mcp_serve}; -use crate::TokenUsage; -use crate::*; -use api::Usage; -use std::env; -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; -use std::time::Duration; -use std::time::UNIX_EPOCH; - -pub(crate) struct LiveCli { - model: String, - allowed_tools: Option, - permission_mode: PermissionMode, - system_prompt: Vec, - runtime: BuiltRuntime, - session: SessionHandle, - prompt_history: Vec, -} - -impl LiveCli { - pub(crate) fn new( - model: String, - enable_tools: bool, - allowed_tools: Option, - permission_mode: PermissionMode, - ) -> Result> { - let system_prompt = build_system_prompt()?; - let session_state = new_cli_session()?; - let session = create_managed_session_handle(&session_state.session_id)?; - let runtime = build_runtime( - session_state.with_persistence_path(session.path.clone()), - &session.id, - model.clone(), - system_prompt.clone(), - enable_tools, - true, - allowed_tools.clone(), - permission_mode, - None, - )?; - let cli = Self { - model, - allowed_tools, - permission_mode, - system_prompt, - runtime, - session, - prompt_history: Vec::new(), - }; - cli.persist_session()?; - Ok(cli) - } - - pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { - if let Some(rt) = self.runtime.runtime.as_mut() { - rt.api_client_mut().set_reasoning_effort(effort); - } - } - - pub(crate) fn startup_banner(&self) -> String { - let cwd = env::current_dir().map_or_else( - |_| "".to_string(), - |path| path.display().to_string(), - ); - let status = status_context(None).ok(); - let git_branch = status - .as_ref() - .and_then(|context| context.git_branch.as_deref()) - .unwrap_or("unknown"); - let workspace = status.as_ref().map_or_else( - || "unknown".to_string(), - |context| context.git_summary.headline(), - ); - let session_path = self.session.path.strip_prefix(Path::new(&cwd)).map_or_else( - |_| self.session.path.display().to_string(), - |path| path.display().to_string(), - ); - format!( - "\x1b[38;5;196m\ - ██████╗██╗ █████╗ ██╗ ██╗\n\ -██╔════╝██║ ██╔══██╗██║ ██║\n\ -██║ ██║ ███████║██║ █╗ ██║\n\ -██║ ██║ ██╔══██║██║███╗██║\n\ -╚██████╗███████╗██║ ██║╚███╔███╔╝\n\ - ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\x1b[0m \x1b[38;5;208mCode\x1b[0m 🦞\n\n\ - \x1b[2mModel\x1b[0m {}\n\ - \x1b[2mPermissions\x1b[0m {}\n\ - \x1b[2mBranch\x1b[0m {}\n\ - \x1b[2mWorkspace\x1b[0m {}\n\ - \x1b[2mDirectory\x1b[0m {}\n\ - \x1b[2mSession\x1b[0m {}\n\ - \x1b[2mAuto-save\x1b[0m {}\n\n\ - Type \x1b[1m/help\x1b[0m for commands · \x1b[1m/status\x1b[0m for live context · \x1b[2m/resume latest\x1b[0m jumps back to the newest session · \x1b[1m/diff\x1b[0m then \x1b[1m/commit\x1b[0m to ship · \x1b[2mTab\x1b[0m for workflow completions · \x1b[2mShift+Enter\x1b[0m for newline", - self.model, - self.permission_mode.as_str(), - git_branch, - workspace, - cwd, - self.session.id, - session_path, - ) - } - - pub(crate) fn repl_completion_candidates( - &self, - ) -> Result, Box> { - Ok(slash_command_completion_candidates_with_sessions( - &self.model, - Some(&self.session.id), - list_managed_sessions()? - .into_iter() - .map(|session| session.id) - .collect(), - )) - } - - pub(crate) fn prepare_turn_runtime( - &self, - emit_output: bool, - ) -> Result<(BuiltRuntime, HookAbortMonitor), Box> { - let hook_abort_signal = runtime::HookAbortSignal::new(); - let runtime = build_runtime( - self.runtime.session().clone(), - &self.session.id, - self.model.clone(), - self.system_prompt.clone(), - true, - emit_output, - self.allowed_tools.clone(), - self.permission_mode, - None, - )? - .with_hook_abort_signal(hook_abort_signal.clone()); - let hook_abort_monitor = HookAbortMonitor::spawn(hook_abort_signal); - - Ok((runtime, hook_abort_monitor)) - } - - pub(crate) fn replace_runtime( - &mut self, - runtime: BuiltRuntime, - ) -> Result<(), Box> { - self.runtime.shutdown_plugins()?; - self.runtime = runtime; - Ok(()) - } - - pub(crate) fn run_turn(&mut self, input: &str) -> Result<(), Box> { - let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?; - let mut spinner = Spinner::new(); - let mut stdout = io::stdout(); - spinner.tick( - "🦀 Thinking...", - TerminalRenderer::new().color_theme(), - &mut stdout, - )?; - let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); - let result = runtime.run_turn(input, Some(&mut permission_prompter)); - hook_abort_monitor.stop(); - match result { - Ok(summary) => { - self.replace_runtime(runtime)?; - spinner.finish( - "✨ Done", - TerminalRenderer::new().color_theme(), - &mut stdout, - )?; - println!(); - if let Some(event) = summary.auto_compaction { - println!( - "{}", - format_auto_compaction_notice(event.removed_message_count) - ); - } - self.persist_session()?; - Ok(()) - } - Err(error) => { - runtime.shutdown_plugins()?; - spinner.fail( - "❌ Request failed", - TerminalRenderer::new().color_theme(), - &mut stdout, - )?; - Err(Box::new(error)) - } - } - } - - pub(crate) fn run_turn_with_output( - &mut self, - input: &str, - output_format: CliOutputFormat, - compact: bool, - ) -> Result<(), Box> { - match output_format { - CliOutputFormat::Json if compact => self.run_prompt_compact_json(input), - CliOutputFormat::Text if compact => self.run_prompt_compact(input), - CliOutputFormat::Text => self.run_turn(input), - CliOutputFormat::Json => self.run_prompt_json(input), - } - } - - pub(crate) fn run_prompt_compact( - &mut self, - input: &str, - ) -> Result<(), Box> { - let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?; - let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); - let result = runtime.run_turn(input, Some(&mut permission_prompter)); - hook_abort_monitor.stop(); - let summary = result?; - self.replace_runtime(runtime)?; - self.persist_session()?; - let final_text = final_assistant_text(&summary); - println!("{final_text}"); - Ok(()) - } - - pub(crate) fn run_prompt_compact_json( - &mut self, - input: &str, - ) -> Result<(), Box> { - let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?; - let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); - let result = runtime.run_turn(input, Some(&mut permission_prompter)); - hook_abort_monitor.stop(); - let summary = result?; - self.replace_runtime(runtime)?; - self.persist_session()?; - println!( - "{}", - json!({ - "message": final_assistant_text(&summary), - "compact": true, - "model": self.model, - "usage": { - "input_tokens": summary.usage.input_tokens, - "output_tokens": summary.usage.output_tokens, - "cache_creation_input_tokens": summary.usage.cache_creation_input_tokens, - "cache_read_input_tokens": summary.usage.cache_read_input_tokens, - }, - }) - ); - Ok(()) - } - - pub(crate) fn run_prompt_json( - &mut self, - input: &str, - ) -> Result<(), Box> { - let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?; - let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); - let result = runtime.run_turn(input, Some(&mut permission_prompter)); - hook_abort_monitor.stop(); - let summary = result?; - self.replace_runtime(runtime)?; - self.persist_session()?; - println!( - "{}", - json!({ - "message": final_assistant_text(&summary), - "model": self.model, - "iterations": summary.iterations, - "auto_compaction": summary.auto_compaction.map(|event| json!({ - "removed_messages": event.removed_message_count, - "notice": format_auto_compaction_notice(event.removed_message_count), - })), - "tool_uses": collect_tool_uses(&summary), - "tool_results": collect_tool_results(&summary), - "prompt_cache_events": collect_prompt_cache_events(&summary), - "usage": { - "input_tokens": summary.usage.input_tokens, - "output_tokens": summary.usage.output_tokens, - "cache_creation_input_tokens": summary.usage.cache_creation_input_tokens, - "cache_read_input_tokens": summary.usage.cache_read_input_tokens, - }, - "estimated_cost": format_usd( - summary.usage.estimate_cost_usd_with_pricing( - pricing_for_model(&self.model) - .unwrap_or_else(runtime::ModelPricing::default_sonnet_tier) - ).total_cost_usd() - ) - }) - ); - Ok(()) - } - - #[allow(clippy::too_many_lines)] - pub(crate) fn handle_repl_command( - &mut self, - command: SlashCommand, - ) -> Result> { - Ok(match command { - SlashCommand::Help => { - println!("{}", render_repl_help()); - false - } - SlashCommand::Status => { - self.print_status(); - false - } - SlashCommand::Bughunter { scope } => { - self.run_bughunter(scope.as_deref())?; - false - } - SlashCommand::Commit => { - self.run_commit(None)?; - false - } - SlashCommand::Pr { context } => { - self.run_pr(context.as_deref())?; - false - } - SlashCommand::Issue { context } => { - self.run_issue(context.as_deref())?; - false - } - SlashCommand::Ultraplan { task } => { - self.run_ultraplan(task.as_deref())?; - false - } - SlashCommand::Teleport { target } => { - Self::run_teleport(target.as_deref())?; - false - } - SlashCommand::DebugToolCall => { - self.run_debug_tool_call(None)?; - false - } - SlashCommand::Sandbox => { - Self::print_sandbox_status(); - false - } - SlashCommand::Compact => { - self.compact()?; - false - } - SlashCommand::Model { model } => self.set_model(model)?, - SlashCommand::Permissions { mode } => self.set_permissions(mode)?, - SlashCommand::Clear { confirm } => self.clear_session(confirm)?, - SlashCommand::Cost => { - self.print_cost(); - false - } - SlashCommand::Resume { session_path } => self.resume_session(session_path)?, - SlashCommand::Config { section } => { - Self::print_config(section.as_deref())?; - false - } - SlashCommand::Mcp { action, target } => { - let args = match (action.as_deref(), target.as_deref()) { - (None, None) => None, - (Some(action), None) => Some(action.to_string()), - (Some(action), Some(target)) => Some(format!("{action} {target}")), - (None, Some(target)) => Some(target.to_string()), - }; - Self::print_mcp(args.as_deref(), CliOutputFormat::Text)?; - false - } - SlashCommand::Memory => { - Self::print_memory()?; - false - } - SlashCommand::Init => { - run_init(CliOutputFormat::Text)?; - false - } - SlashCommand::Diff => { - Self::print_diff()?; - false - } - SlashCommand::Version => { - Self::print_version(CliOutputFormat::Text); - false - } - SlashCommand::Export { path } => { - self.export_session(path.as_deref())?; - false - } - SlashCommand::Session { action, target } => { - self.handle_session_command(action.as_deref(), target.as_deref())? - } - SlashCommand::Plugins { action, target } => { - self.handle_plugins_command(action.as_deref(), target.as_deref())? - } - SlashCommand::Agents { args } => { - Self::print_agents(args.as_deref(), CliOutputFormat::Text)?; - false - } - SlashCommand::Skills { args } => { - match classify_skills_slash_command(args.as_deref()) { - SkillSlashDispatch::Invoke(prompt) => self.run_turn(&prompt)?, - SkillSlashDispatch::Local => { - Self::print_skills(args.as_deref(), CliOutputFormat::Text)?; - } - } - false - } - SlashCommand::Doctor => { - println!("{}", render_doctor_report()?.render()); - false - } - SlashCommand::History { count } => { - self.print_prompt_history(count.as_deref()); - false - } - SlashCommand::Stats => { - let usage = UsageTracker::from_session(self.runtime.session()).cumulative_usage(); - println!("{}", format_cost_report(usage)); - false - } - SlashCommand::Login - | SlashCommand::Logout - | SlashCommand::Vim - | SlashCommand::Upgrade - | SlashCommand::Share - | SlashCommand::Feedback - | SlashCommand::Files - | SlashCommand::Fast - | SlashCommand::Exit - | SlashCommand::Summary - | SlashCommand::Desktop - | SlashCommand::Brief - | SlashCommand::Advisor - | SlashCommand::Stickers - | SlashCommand::Insights - | SlashCommand::Thinkback - | SlashCommand::ReleaseNotes - | SlashCommand::SecurityReview - | SlashCommand::Keybindings - | SlashCommand::PrivacySettings - | SlashCommand::Plan { .. } - | SlashCommand::Review { .. } - | SlashCommand::Tasks { .. } - | SlashCommand::Theme { .. } - | SlashCommand::Voice { .. } - | SlashCommand::Usage { .. } - | SlashCommand::Rename { .. } - | SlashCommand::Copy { .. } - | SlashCommand::Hooks { .. } - | SlashCommand::Context { .. } - | SlashCommand::Color { .. } - | SlashCommand::Effort { .. } - | SlashCommand::Branch { .. } - | SlashCommand::Rewind { .. } - | SlashCommand::Ide { .. } - | SlashCommand::Tag { .. } - | SlashCommand::OutputStyle { .. } - | SlashCommand::AddDir { .. } => { - let cmd_name = command.slash_name(); - eprintln!("{cmd_name} is not yet implemented in this build."); - false - } - SlashCommand::Unknown(name) => { - eprintln!("{}", format_unknown_slash_command(&name)); - false - } - }) - } - - pub(crate) fn persist_session(&self) -> Result<(), Box> { - self.runtime.session().save_to_path(&self.session.path)?; - Ok(()) - } - - pub(crate) fn print_status(&self) { - let cumulative = self.runtime.usage().cumulative_usage(); - let latest = self.runtime.usage().current_turn_usage(); - println!( - "{}", - format_status_report( - &self.model, - StatusUsage { - message_count: self.runtime.session().messages.len(), - turns: self.runtime.usage().turns(), - latest, - cumulative, - estimated_tokens: self.runtime.estimated_tokens(), - }, - self.permission_mode.as_str(), - &status_context(Some(&self.session.path)).expect("status context should load"), - None, // #148: REPL /status doesn't carry flag provenance - ) - ); - } - - pub(crate) fn record_prompt_history(&mut self, prompt: &str) { - let timestamp_ms = std::time::SystemTime::now() - .duration_since(UNIX_EPOCH) - .ok() - .map_or(self.runtime.session().updated_at_ms, |duration| { - u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) - }); - let entry = PromptHistoryEntry { - timestamp_ms, - text: prompt.to_string(), - }; - self.prompt_history.push(entry); - if let Err(error) = self.runtime.session_mut().push_prompt_entry(prompt) { - eprintln!("warning: failed to persist prompt history: {error}"); - } - } - - pub(crate) fn print_prompt_history(&self, count: Option<&str>) { - let limit = match parse_history_count(count) { - Ok(limit) => limit, - Err(message) => { - eprintln!("{message}"); - return; - } - }; - let session_entries = &self.runtime.session().prompt_history; - let entries = if session_entries.is_empty() { - if self.prompt_history.is_empty() { - collect_session_prompt_history(self.runtime.session()) - } else { - self.prompt_history - .iter() - .map(|entry| PromptHistoryEntry { - timestamp_ms: entry.timestamp_ms, - text: entry.text.clone(), - }) - .collect() - } - } else { - session_entries - .iter() - .map(|entry| PromptHistoryEntry { - timestamp_ms: entry.timestamp_ms, - text: entry.text.clone(), - }) - .collect() - }; - println!("{}", render_prompt_history_report(&entries, limit)); - } - - pub(crate) fn print_sandbox_status() { - let cwd = env::current_dir().expect("current dir"); - let loader = ConfigLoader::default_for(&cwd); - let runtime_config = loader - .load() - .unwrap_or_else(|_| runtime::RuntimeConfig::empty()); - println!( - "{}", - format_sandbox_report(&resolve_sandbox_status(runtime_config.sandbox(), &cwd)) - ); - } - - pub(crate) fn set_model( - &mut self, - model: Option, - ) -> Result> { - let Some(model) = model else { - println!( - "{}", - format_model_report( - &self.model, - self.runtime.session().messages.len(), - self.runtime.usage().turns(), - ) - ); - return Ok(false); - }; - - let model = resolve_model_alias_with_config(&model); - - if model == self.model { - println!( - "{}", - format_model_report( - &self.model, - self.runtime.session().messages.len(), - self.runtime.usage().turns(), - ) - ); - return Ok(false); - } - - let previous = self.model.clone(); - let session = self.runtime.session().clone(); - let message_count = session.messages.len(); - let runtime = build_runtime( - session, - &self.session.id, - model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - self.model.clone_from(&model); - println!( - "{}", - format_model_switch_report(&previous, &model, message_count) - ); - Ok(true) - } - - pub(crate) fn set_permissions( - &mut self, - mode: Option, - ) -> Result> { - let Some(mode) = mode else { - println!( - "{}", - format_permissions_report(self.permission_mode.as_str()) - ); - return Ok(false); - }; - - let normalized = normalize_permission_mode(&mode).ok_or_else(|| { - format!( - "unsupported permission mode '{mode}'. Use read-only, workspace-write, or danger-full-access." - ) - })?; - - if normalized == self.permission_mode.as_str() { - println!("{}", format_permissions_report(normalized)); - return Ok(false); - } - - let previous = self.permission_mode.as_str().to_string(); - let session = self.runtime.session().clone(); - self.permission_mode = permission_mode_from_label(normalized); - let runtime = build_runtime( - session, - &self.session.id, - self.model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - println!( - "{}", - format_permissions_switch_report(&previous, normalized) - ); - Ok(true) - } - - pub(crate) fn clear_session( - &mut self, - confirm: bool, - ) -> Result> { - if !confirm { - println!( - "clear: confirmation required; run /clear --confirm to start a fresh session." - ); - return Ok(false); - } - - let previous_session = self.session.clone(); - let session_state = new_cli_session()?; - self.session = create_managed_session_handle(&session_state.session_id)?; - let runtime = build_runtime( - session_state.with_persistence_path(self.session.path.clone()), - &self.session.id, - self.model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - println!( - "Session cleared\n Mode fresh session\n Previous session {}\n Resume previous /resume {}\n Preserved model {}\n Permission mode {}\n New session {}\n Session file {}", - previous_session.id, - previous_session.id, - self.model, - self.permission_mode.as_str(), - self.session.id, - self.session.path.display(), - ); - Ok(true) - } - - pub(crate) fn print_cost(&self) { - let cumulative = self.runtime.usage().cumulative_usage(); - println!("{}", format_cost_report(cumulative)); - } - - pub(crate) fn resume_session( - &mut self, - session_path: Option, - ) -> Result> { - let Some(session_ref) = session_path else { - println!("{}", render_resume_usage()); - return Ok(false); - }; - - let (handle, session) = load_session_reference(&session_ref)?; - let message_count = session.messages.len(); - let session_id = session.session_id.clone(); - let runtime = build_runtime( - session, - &handle.id, - self.model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - self.session = SessionHandle { - id: session_id, - path: handle.path, - }; - println!( - "{}", - format_resume_report( - &self.session.path.display().to_string(), - message_count, - self.runtime.usage().turns(), - ) - ); - Ok(true) - } - - pub(crate) fn print_config(section: Option<&str>) -> Result<(), Box> { - println!("{}", render_config_report(section)?); - Ok(()) - } - - pub(crate) fn print_memory() -> Result<(), Box> { - println!("{}", render_memory_report()?); - Ok(()) - } - - pub(crate) fn print_agents( - args: Option<&str>, - output_format: CliOutputFormat, - ) -> Result<(), Box> { - let cwd = env::current_dir()?; - match output_format { - CliOutputFormat::Text => println!("{}", handle_agents_slash_command(args, &cwd)?), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&handle_agents_slash_command_json(args, &cwd)?)? - ), - } - Ok(()) - } - - pub(crate) fn print_mcp( - args: Option<&str>, - output_format: CliOutputFormat, - ) -> Result<(), Box> { - // `claw mcp serve` starts a stdio MCP server exposing claw's built-in - // tools. All other `mcp` subcommands fall through to the existing - // configured-server reporter (`list`, `status`, ...). - if matches!(args.map(str::trim), Some("serve")) { - return run_mcp_serve(); - } - let cwd = env::current_dir()?; - match output_format { - CliOutputFormat::Text => println!("{}", handle_mcp_slash_command(args, &cwd)), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&handle_mcp_slash_command_json(args, &cwd))? - ), - } - Ok(()) - } - - pub(crate) fn print_skills( - args: Option<&str>, - output_format: CliOutputFormat, - ) -> Result<(), Box> { - let cwd = env::current_dir()?; - match output_format { - CliOutputFormat::Text => println!("{}", handle_skills_slash_command(args, &cwd)?), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&handle_skills_slash_command_json(args, &cwd)?)? - ), - } - Ok(()) - } - - pub(crate) fn print_plugins( - action: Option<&str>, - target: Option<&str>, - output_format: CliOutputFormat, - ) -> Result<(), Box> { - let cwd = env::current_dir()?; - let loader = ConfigLoader::default_for(&cwd); - let runtime_config = loader.load()?; - let mut manager = build_plugin_manager(&cwd, &loader, &runtime_config); - let result = handle_plugins_slash_command(action, target, &mut manager)?; - match output_format { - CliOutputFormat::Text => println!("{}", result.message), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "plugin", - "action": action.unwrap_or("list"), - "target": target, - "message": result.message, - "reload_runtime": result.reload_runtime, - }))? - ), - } - Ok(()) - } - - pub(crate) fn print_diff() -> Result<(), Box> { - println!("{}", render_diff_report()?); - Ok(()) - } - - pub(crate) fn print_version(output_format: CliOutputFormat) { - let _ = crate::print_version(output_format); - } - - pub(crate) fn export_session( - &self, - requested_path: Option<&str>, - ) -> Result<(), Box> { - let export_path = resolve_export_path(requested_path, self.runtime.session())?; - fs::write(&export_path, render_export_text(self.runtime.session()))?; - println!( - "Export\n Result wrote transcript\n File {}\n Messages {}", - export_path.display(), - self.runtime.session().messages.len(), - ); - Ok(()) - } - - #[allow(clippy::too_many_lines)] - pub(crate) fn handle_session_command( - &mut self, - action: Option<&str>, - target: Option<&str>, - ) -> Result> { - match action { - None | Some("list") => { - println!("{}", render_session_list(&self.session.id)?); - Ok(false) - } - Some("switch") => { - let Some(target) = target else { - println!("Usage: /session switch "); - return Ok(false); - }; - let (handle, session) = load_session_reference(target)?; - let message_count = session.messages.len(); - let session_id = session.session_id.clone(); - let runtime = build_runtime( - session, - &handle.id, - self.model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - self.session = SessionHandle { - id: session_id, - path: handle.path, - }; - println!( - "Session switched\n Active session {}\n File {}\n Messages {}", - self.session.id, - self.session.path.display(), - message_count, - ); - Ok(true) - } - Some("fork") => { - let forked = self.runtime.fork_session(target.map(ToOwned::to_owned)); - let parent_session_id = self.session.id.clone(); - let handle = create_managed_session_handle(&forked.session_id)?; - let branch_name = forked - .fork - .as_ref() - .and_then(|fork| fork.branch_name.clone()); - let forked = forked.with_persistence_path(handle.path.clone()); - let message_count = forked.messages.len(); - forked.save_to_path(&handle.path)?; - let runtime = build_runtime( - forked, - &handle.id, - self.model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - self.session = handle; - println!( - "Session forked\n Parent session {}\n Active session {}\n Branch {}\n File {}\n Messages {}", - parent_session_id, - self.session.id, - branch_name.as_deref().unwrap_or("(unnamed)"), - self.session.path.display(), - message_count, - ); - Ok(true) - } - Some("delete") => { - let Some(target) = target else { - println!("Usage: /session delete [--force]"); - return Ok(false); - }; - let handle = resolve_session_reference(target)?; - if handle.id == self.session.id { - println!( - "delete: refusing to delete the active session '{}'.\nSwitch to another session first with /session switch .", - handle.id - ); - return Ok(false); - } - if !confirm_session_deletion(&handle.id) { - println!("delete: cancelled."); - return Ok(false); - } - delete_managed_session(&handle.path)?; - println!( - "Session deleted\n Deleted session {}\n File {}", - handle.id, - handle.path.display(), - ); - Ok(false) - } - Some("delete-force") => { - let Some(target) = target else { - println!("Usage: /session delete [--force]"); - return Ok(false); - }; - let handle = resolve_session_reference(target)?; - if handle.id == self.session.id { - println!( - "delete: refusing to delete the active session '{}'.\nSwitch to another session first with /session switch .", - handle.id - ); - return Ok(false); - } - delete_managed_session(&handle.path)?; - println!( - "Session deleted\n Deleted session {}\n File {}", - handle.id, - handle.path.display(), - ); - Ok(false) - } - Some(other) => { - println!( - "Unknown /session action '{other}'. Use /session list, /session switch , /session fork [branch-name], or /session delete [--force]." - ); - Ok(false) - } - } - } - - pub(crate) fn handle_plugins_command( - &mut self, - action: Option<&str>, - target: Option<&str>, - ) -> Result> { - let cwd = env::current_dir()?; - let loader = ConfigLoader::default_for(&cwd); - let runtime_config = loader.load()?; - let mut manager = build_plugin_manager(&cwd, &loader, &runtime_config); - let result = handle_plugins_slash_command(action, target, &mut manager)?; - println!("{}", result.message); - if result.reload_runtime { - self.reload_runtime_features()?; - } - Ok(false) - } - - pub(crate) fn reload_runtime_features(&mut self) -> Result<(), Box> { - let runtime = build_runtime( - self.runtime.session().clone(), - &self.session.id, - self.model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - self.persist_session() - } - - pub(crate) fn compact(&mut self) -> Result<(), Box> { - let result = self.runtime.compact(CompactionConfig::default()); - let removed = result.removed_message_count; - let kept = result.compacted_session.messages.len(); - let skipped = removed == 0; - let runtime = build_runtime( - result.compacted_session, - &self.session.id, - self.model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - self.persist_session()?; - println!("{}", format_compact_report(removed, kept, skipped)); - Ok(()) - } - - pub(crate) fn run_internal_prompt_text_with_progress( - &self, - prompt: &str, - enable_tools: bool, - progress: Option, - ) -> Result> { - let session = self.runtime.session().clone(); - let mut runtime = build_runtime( - session, - &self.session.id, - self.model.clone(), - self.system_prompt.clone(), - enable_tools, - false, - self.allowed_tools.clone(), - self.permission_mode, - progress, - )?; - let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); - let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?; - let text = final_assistant_text(&summary).trim().to_string(); - runtime.shutdown_plugins()?; - Ok(text) - } - - pub(crate) fn run_internal_prompt_text( - &self, - prompt: &str, - enable_tools: bool, - ) -> Result> { - self.run_internal_prompt_text_with_progress(prompt, enable_tools, None) - } - - pub(crate) fn run_bughunter( - &self, - scope: Option<&str>, - ) -> Result<(), Box> { - println!("{}", format_bughunter_report(scope)); - Ok(()) - } - - pub(crate) fn run_ultraplan( - &self, - task: Option<&str>, - ) -> Result<(), Box> { - println!("{}", format_ultraplan_report(task)); - Ok(()) - } - - pub(crate) fn run_teleport(target: Option<&str>) -> Result<(), Box> { - let Some(target) = target.map(str::trim).filter(|value| !value.is_empty()) else { - println!("Usage: /teleport "); - return Ok(()); - }; - - println!("{}", render_teleport_report(target)?); - Ok(()) - } - - pub(crate) fn run_debug_tool_call( - &self, - args: Option<&str>, - ) -> Result<(), Box> { - validate_no_args("/debug-tool-call", args)?; - println!("{}", render_last_tool_debug_report(self.runtime.session())?); - Ok(()) - } - - pub(crate) fn run_commit( - &mut self, - args: Option<&str>, - ) -> Result<(), Box> { - validate_no_args("/commit", args)?; - let status = git_output(&["status", "--short", "--branch"])?; - let summary = parse_git_workspace_summary(Some(&status)); - let branch = parse_git_status_branch(Some(&status)); - if summary.is_clean() { - println!("{}", format_commit_skipped_report()); - return Ok(()); - } - - println!( - "{}", - format_commit_preflight_report(branch.as_deref(), summary) - ); - Ok(()) - } - - pub(crate) fn run_pr(&self, context: Option<&str>) -> Result<(), Box> { - let branch = - resolve_git_branch_for(&env::current_dir()?).unwrap_or_else(|| "unknown".to_string()); - println!("{}", format_pr_report(&branch, context)); - Ok(()) - } - - pub(crate) fn run_issue( - &self, - context: Option<&str>, - ) -> Result<(), Box> { - println!("{}", format_issue_report(context)); - Ok(()) - } -} - -#[allow(clippy::needless_pass_by_value)] -pub(crate) fn run_repl( - model: String, - allowed_tools: Option, - permission_mode: PermissionMode, - base_commit: Option, - reasoning_effort: Option, - allow_broad_cwd: bool, -) -> Result<(), Box> { - enforce_broad_cwd_policy(allow_broad_cwd, CliOutputFormat::Text)?; - run_stale_base_preflight(base_commit.as_deref()); - let resolved_model = resolve_repl_model(model); - let mut cli = LiveCli::new(resolved_model, true, allowed_tools, permission_mode)?; - cli.set_reasoning_effort(reasoning_effort); - let mut editor = - input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default()); - println!("{}", cli.startup_banner()); - println!("{}", format_connected_line(&cli.model)); - - loop { - editor.set_completions(cli.repl_completion_candidates().unwrap_or_default()); - match editor.read_line()? { - input::ReadOutcome::Submit(input) => { - let trimmed = input.trim().to_string(); - if trimmed.is_empty() { - continue; - } - if matches!(trimmed.as_str(), "/exit" | "/quit") { - cli.persist_session()?; - break; - } - match SlashCommand::parse(&trimmed) { - Ok(Some(command)) => { - if cli.handle_repl_command(command)? { - cli.persist_session()?; - } - continue; - } - Ok(None) => {} - Err(error) => { - eprintln!("{error}"); - continue; - } - } - // Bare-word skill dispatch: if the first token of the input - // matches a known skill name, invoke it as `/skills ` - // rather than forwarding raw text to the LLM (ROADMAP #36). - let cwd = std::env::current_dir().unwrap_or_default(); - if let Some(prompt) = try_resolve_bare_skill_prompt(&cwd, &trimmed) { - editor.push_history(input); - cli.record_prompt_history(&trimmed); - cli.run_turn(&prompt)?; - continue; - } - editor.push_history(input); - cli.record_prompt_history(&trimmed); - cli.run_turn(&trimmed)?; - } - input::ReadOutcome::Cancel => {} - input::ReadOutcome::Exit => { - cli.persist_session()?; - break; - } - } - } - - Ok(()) -} - -#[allow(clippy::too_many_lines)] -pub(crate) 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; - let mut wants_version = false; - let mut allowed_tool_values = Vec::new(); - let mut compact = false; - let mut base_commit: Option = None; - let mut reasoning_effort: Option = None; - let mut allow_broad_cwd = false; - let mut rest: Vec = Vec::new(); - let mut index = 0; - - while index < args.len() { - match args[index].as_str() { - "--help" | "-h" if rest.is_empty() => { - wants_help = true; - index += 1; - } - "--help" | "-h" - if !rest.is_empty() - && matches!(rest[0].as_str(), "prompt" | "commit" | "pr" | "issue") => - { - // `--help` following a subcommand that would otherwise forward - // the arg to the API (e.g. `claw prompt --help`) should show - // top-level help instead. Subcommands that consume their own - // args (agents, mcp, plugins, skills) and local help-topic - // subcommands (status, sandbox, doctor, init, state, export, - // version, system-prompt, dump-manifests, bootstrap-plan) must - // NOT be intercepted here — they handle --help in their own - // dispatch paths via parse_local_help_action(). See #141. - wants_help = true; - index += 1; - } - "--version" | "-V" => { - wants_version = true; - index += 1; - } - "--model" => { - let value = args - .get(index + 1) - .ok_or_else(|| "missing value for --model".to_string())?; - validate_model_syntax(value)?; - model = resolve_model_alias_with_config(value); - model_flag_raw = Some(value.clone()); // #148 - index += 2; - } - flag if flag.starts_with("--model=") => { - let value = &flag[8..]; - validate_model_syntax(value)?; - model = resolve_model_alias_with_config(value); - model_flag_raw = Some(value.to_string()); // #148 - index += 1; - } - "--output-format" => { - let value = args - .get(index + 1) - .ok_or_else(|| "missing value for --output-format".to_string())?; - output_format = CliOutputFormat::parse(value)?; - index += 2; - } - "--permission-mode" => { - let value = args - .get(index + 1) - .ok_or_else(|| "missing value for --permission-mode".to_string())?; - permission_mode_override = Some(parse_permission_mode_arg(value)?); - index += 2; - } - flag if flag.starts_with("--output-format=") => { - output_format = CliOutputFormat::parse(&flag[16..])?; - index += 1; - } - flag if flag.starts_with("--permission-mode=") => { - permission_mode_override = Some(parse_permission_mode_arg(&flag[18..])?); - index += 1; - } - "--dangerously-skip-permissions" => { - permission_mode_override = Some(PermissionMode::DangerFullAccess); - index += 1; - } - "--compact" => { - compact = true; - index += 1; - } - "--base-commit" => { - let value = args - .get(index + 1) - .ok_or_else(|| "missing value for --base-commit".to_string())?; - base_commit = Some(value.clone()); - index += 2; - } - flag if flag.starts_with("--base-commit=") => { - base_commit = Some(flag[14..].to_string()); - index += 1; - } - "--reasoning-effort" => { - let value = args - .get(index + 1) - .ok_or_else(|| "missing value for --reasoning-effort".to_string())?; - if !matches!(value.as_str(), "low" | "medium" | "high") { - return Err(format!( - "invalid value for --reasoning-effort: '{value}'; must be low, medium, or high" - )); - } - reasoning_effort = Some(value.clone()); - index += 2; - } - flag if flag.starts_with("--reasoning-effort=") => { - let value = &flag[19..]; - if !matches!(value, "low" | "medium" | "high") { - return Err(format!( - "invalid value for --reasoning-effort: '{value}'; must be low, medium, or high" - )); - } - reasoning_effort = Some(value.to_string()); - index += 1; - } - "--allow-broad-cwd" => { - allow_broad_cwd = true; - index += 1; - } - "-p" => { - // Claw Code compat: -p "prompt" = one-shot prompt - let prompt = args[index + 1..].join(" "); - if prompt.trim().is_empty() { - return Err("-p requires a prompt string".to_string()); - } - return Ok(CliAction::Prompt { - prompt, - model: resolve_model_alias_with_config(&model), - output_format, - allowed_tools: normalize_allowed_tools(&allowed_tool_values)?, - permission_mode: permission_mode_override - .unwrap_or_else(default_permission_mode), - compact, - base_commit: base_commit.clone(), - reasoning_effort: reasoning_effort.clone(), - allow_broad_cwd, - }); - } - "--print" => { - // Claw Code compat: --print makes output non-interactive - output_format = CliOutputFormat::Text; - index += 1; - } - "--resume" if rest.is_empty() => { - rest.push("--resume".to_string()); - index += 1; - } - flag if rest.is_empty() && flag.starts_with("--resume=") => { - rest.push("--resume".to_string()); - rest.push(flag[9..].to_string()); - index += 1; - } - "--acp" | "-acp" => { - rest.push("acp".to_string()); - index += 1; - } - "--allowedTools" | "--allowed-tools" => { - let value = args - .get(index + 1) - .ok_or_else(|| "missing value for --allowedTools".to_string())?; - allowed_tool_values.push(value.clone()); - index += 2; - } - flag if flag.starts_with("--allowedTools=") => { - allowed_tool_values.push(flag[15..].to_string()); - index += 1; - } - flag if flag.starts_with("--allowed-tools=") => { - allowed_tool_values.push(flag[16..].to_string()); - index += 1; - } - other if rest.is_empty() && other.starts_with('-') => { - return Err(format_unknown_option(other)) - } - other => { - rest.push(other.to_string()); - index += 1; - } - } - } - - if wants_help { - return Ok(CliAction::Help { output_format }); - } - - if wants_version { - return Ok(CliAction::Version { output_format }); - } - - let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?; - - if rest.is_empty() { - let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode); - // When stdin is not a terminal (pipe/redirect) and no prompt is given on the - // command line, read stdin as the prompt and dispatch as a one-shot Prompt - // rather than starting the interactive REPL (which would consume the pipe and - // print the startup banner, then exit without sending anything to the API). - if !std::io::stdin().is_terminal() { - let mut buf = String::new(); - let _ = std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf); - let piped = buf.trim().to_string(); - if !piped.is_empty() { - return Ok(CliAction::Prompt { - model, - prompt: piped, - allowed_tools, - permission_mode, - output_format, - compact: false, - base_commit, - reasoning_effort, - allow_broad_cwd, - }); - } - } - return Ok(CliAction::Repl { - model, - allowed_tools, - permission_mode, - base_commit, - reasoning_effort: reasoning_effort.clone(), - allow_broad_cwd, - }); - } - if rest.first().map(String::as_str) == Some("--resume") { - return parse_resume_args(&rest[1..], output_format); - } - if let Some(action) = parse_local_help_action(&rest) { - return action; - } - if let Some(action) = parse_single_word_command_alias( - &rest, - &model, - model_flag_raw.as_deref(), - permission_mode_override, - output_format, - allowed_tools.clone(), - ) { - return action; - } - - let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode); - - match rest[0].as_str() { - "dump-manifests" => parse_dump_manifests_args(&rest[1..], output_format), - "bootstrap-plan" => Ok(CliAction::BootstrapPlan { output_format }), - "agents" => Ok(CliAction::Agents { - args: join_optional_args(&rest[1..]), - output_format, - }), - "mcp" => Ok(CliAction::Mcp { - args: join_optional_args(&rest[1..]), - output_format, - }), - // #145: `plugins` was routed through the prompt fallback because no - // top-level parser arm produced CliAction::Plugins. That made `claw - // plugins` (and `claw plugins --help`, `claw plugins list`, ...) - // attempt an Anthropic network call, surfacing the misleading error - // `missing Anthropic credentials` even though the command is purely - // local introspection. Mirror `agents`/`mcp`/`skills`: action is the - // first positional arg, target is the second. - "plugins" => { - let tail = &rest[1..]; - let action = tail.first().cloned(); - let target = tail.get(1).cloned(); - if tail.len() > 2 { - return Err(format!( - "unexpected extra arguments after `claw plugins {}`: {}", - tail[..2].join(" "), - tail[2..].join(" ") - )); - } - Ok(CliAction::Plugins { - action, - target, - output_format, - }) - } - // #146: `config` is pure-local read-only introspection (merges - // `.claw.json` + `.claw/settings.json` from disk, no network, no - // state mutation). Previously callers had to spin up a session with - // `claw --resume SESSION.jsonl /config` to see their own config, - // which is synthetic friction. Accepts an optional section name - // (env|hooks|model|plugins) matching the slash command shape. - "config" => { - let tail = &rest[1..]; - let section = tail.first().cloned(); - if tail.len() > 1 { - return Err(format!( - "unexpected extra arguments after `claw config {}`: {}", - tail[0], - tail[1..].join(" ") - )); - } - Ok(CliAction::Config { - section, - output_format, - }) - } - // #146: `diff` is pure-local (shells out to `git diff --cached` + - // `git diff`). No session needed to inspect the working tree. - "diff" => { - if rest.len() > 1 { - return Err(format!( - "unexpected extra arguments after `claw diff`: {}", - rest[1..].join(" ") - )); - } - Ok(CliAction::Diff { output_format }) - } - "skills" => { - let args = join_optional_args(&rest[1..]); - match classify_skills_slash_command(args.as_deref()) { - SkillSlashDispatch::Invoke(prompt) => Ok(CliAction::Prompt { - prompt, - model, - output_format, - allowed_tools, - permission_mode, - compact, - base_commit, - reasoning_effort: reasoning_effort.clone(), - allow_broad_cwd, - }), - SkillSlashDispatch::Local => Ok(CliAction::Skills { - args, - output_format, - }), - } - } - "system-prompt" => parse_system_prompt_args(&rest[1..], output_format), - "acp" => parse_acp_args(&rest[1..], output_format), - "login" | "logout" => Err(removed_auth_surface_error(rest[0].as_str())), - "init" => Ok(CliAction::Init { output_format }), - "export" => parse_export_args(&rest[1..], output_format), - "prompt" => { - let prompt = rest[1..].join(" "); - if prompt.trim().is_empty() { - return Err("prompt subcommand requires a prompt string".to_string()); - } - Ok(CliAction::Prompt { - prompt, - model, - output_format, - allowed_tools, - permission_mode, - compact, - base_commit: base_commit.clone(), - reasoning_effort: reasoning_effort.clone(), - allow_broad_cwd, - }) - } - other if other.starts_with('/') => parse_direct_slash_cli_action( - &rest, - model, - output_format, - allowed_tools, - permission_mode, - compact, - base_commit, - reasoning_effort, - allow_broad_cwd, - ), - other => { - if rest.len() == 1 && looks_like_subcommand_typo(other) { - if let Some(suggestions) = suggest_similar_subcommand(other) { - let mut message = format!("unknown subcommand: {other}."); - if let Some(line) = render_suggestion_line("Did you mean", &suggestions) { - message.push('\n'); - message.push_str(&line); - } - message.push_str( - "\nRun `claw --help` for the full list. If you meant to send a prompt literally, use `claw prompt `.", - ); - return Err(message); - } - } - // #147: guard empty/whitespace-only prompts at the fallthrough - // path the same way `"prompt"` arm above does. Without this, - // `claw ""`, `claw " "`, and `claw "" ""` silently route to - // the Anthropic call and surface a misleading - // `missing Anthropic credentials` error (or burn API tokens on - // an empty prompt when credentials are present). - let joined = rest.join(" "); - if joined.trim().is_empty() { - return Err( - "empty prompt: provide a subcommand (run `claw --help`) or a non-empty prompt string" - .to_string(), - ); - } - Ok(CliAction::Prompt { - prompt: joined, - model, - output_format, - allowed_tools, - permission_mode, - compact, - base_commit, - reasoning_effort: reasoning_effort.clone(), - allow_broad_cwd, - }) - } - } -} - -pub(crate) fn parse_export_args( - args: &[String], - output_format: CliOutputFormat, -) -> Result { - let mut session_reference = LATEST_SESSION_REFERENCE.to_string(); - let mut output_path: Option = None; - let mut index = 0; - - while index < args.len() { - match args[index].as_str() { - "--session" => { - let value = args - .get(index + 1) - .ok_or_else(|| "missing value for --session".to_string())?; - session_reference.clone_from(value); - index += 2; - } - flag if flag.starts_with("--session=") => { - session_reference = flag[10..].to_string(); - index += 1; - } - "--output" | "-o" => { - let value = args - .get(index + 1) - .ok_or_else(|| format!("missing value for {}", args[index]))?; - output_path = Some(PathBuf::from(value)); - index += 2; - } - flag if flag.starts_with("--output=") => { - output_path = Some(PathBuf::from(&flag[9..])); - index += 1; - } - other if other.starts_with('-') => { - return Err(format!("unknown export option: {other}")); - } - other if output_path.is_none() => { - output_path = Some(PathBuf::from(other)); - index += 1; - } - other => { - return Err(format!("unexpected export argument: {other}")); - } - } - } - - Ok(CliAction::Export { - session_reference, - output_path, - output_format, - }) -} - -pub(crate) fn parse_history_count(raw: Option<&str>) -> Result { - let Some(raw) = raw else { - return Ok(DEFAULT_HISTORY_LIMIT); - }; - let parsed: usize = raw - .parse() - .map_err(|_| format!("history: invalid count '{raw}'. Expected a positive integer."))?; - if parsed == 0 { - return Err("history: count must be greater than 0.".to_string()); - } - Ok(parsed) -} - -pub(crate) fn parse_git_status_branch(status: Option<&str>) -> Option { - let status = status?; - let first_line = status.lines().next()?; - let line = first_line.strip_prefix("## ")?; - if line.starts_with("HEAD") { - return Some("detached HEAD".to_string()); - } - let branch = line.split(['.', ' ']).next().unwrap_or_default().trim(); - if branch.is_empty() { - None - } else { - Some(branch.to_string()) - } -} - -pub(crate) fn parse_git_status_metadata_for( - cwd: &Path, - status: Option<&str>, -) -> (Option, Option) { - let branch = resolve_git_branch_for(cwd).or_else(|| parse_git_status_branch(status)); - let project_root = find_git_root_in(cwd).ok(); - (project_root, branch) -} - -pub(crate) fn parse_git_workspace_summary(status: Option<&str>) -> GitWorkspaceSummary { - let mut summary = GitWorkspaceSummary::default(); - let Some(status) = status else { - return summary; - }; - - for line in status.lines() { - if line.starts_with("## ") || line.trim().is_empty() { - continue; - } - - summary.changed_files += 1; - let mut chars = line.chars(); - let index_status = chars.next().unwrap_or(' '); - let worktree_status = chars.next().unwrap_or(' '); - - if index_status == '?' && worktree_status == '?' { - summary.untracked_files += 1; - continue; - } - - if index_status != ' ' { - summary.staged_files += 1; - } - if worktree_status != ' ' { - summary.unstaged_files += 1; - } - if (matches!(index_status, 'U' | 'A') && matches!(worktree_status, 'U' | 'A')) - || index_status == 'U' - || worktree_status == 'U' - { - summary.conflicted_files += 1; - } - } - - summary -} +// Contents moved to live_cli.rs diff --git a/rust/crates/rusty-claude-cli/src/repl_commands.rs b/rust/crates/rusty-claude-cli/src/repl_commands.rs new file mode 100644 index 00000000..ae2e1fe6 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/repl_commands.rs @@ -0,0 +1,1165 @@ +use crate::*; +use std::env; +use std::fs; +use std::path::Path; +use serde_json::json; +use commands::SlashCommand; +use ::tools::{mvp_tool_specs, execute_tool}; + +impl LiveCli { + #[allow(clippy::too_many_lines)] + pub(crate) fn handle_repl_command( + &mut self, + command: SlashCommand, + ) -> Result> { + Ok(match command { + SlashCommand::Help => { + println!("{}", render_repl_help()); + false + } + SlashCommand::Status => { + self.print_status(); + false + } + SlashCommand::Bughunter { scope } => { + self.run_bughunter(scope.as_deref())?; + false + } + SlashCommand::Commit => { + self.run_commit(None)?; + false + } + SlashCommand::Pr { context } => { + self.run_pr(context.as_deref())?; + false + } + SlashCommand::Issue { context } => { + self.run_issue(context.as_deref())?; + false + } + SlashCommand::Ultraplan { task } => { + self.run_ultraplan(task.as_deref())?; + false + } + SlashCommand::Teleport { target } => { + Self::run_teleport(target.as_deref())?; + false + } + SlashCommand::DebugToolCall => { + self.run_debug_tool_call(None)?; + false + } + SlashCommand::Sandbox => { + Self::print_sandbox_status(); + false + } + SlashCommand::Compact => { + self.compact()?; + false + } + SlashCommand::Model { model } => self.set_model(model)?, + SlashCommand::Permissions { mode } => self.set_permissions(mode)?, + SlashCommand::Clear { confirm } => self.clear_session(confirm)?, + SlashCommand::Cost => { + self.print_cost(); + false + } + SlashCommand::Resume { session_path } => self.resume_session(session_path)?, + SlashCommand::Config { section } => { + Self::print_config(section.as_deref())?; + false + } + SlashCommand::Mcp { action, target } => { + let args = match (action.as_deref(), target.as_deref()) { + (None, None) => None, + (Some(action), None) => Some(action.to_string()), + (Some(action), Some(target)) => Some(format!("{action} {target}")), + (None, Some(target)) => Some(target.to_string()), + }; + Self::print_mcp(args.as_deref(), CliOutputFormat::Text)?; + false + } + SlashCommand::Memory => { + Self::print_memory()?; + false + } + SlashCommand::Init => { + run_init(CliOutputFormat::Text)?; + false + } + SlashCommand::Diff => { + Self::print_diff()?; + false + } + SlashCommand::Version => { + Self::print_version(CliOutputFormat::Text); + false + } + SlashCommand::Export { path } => { + self.export_session(path.as_deref())?; + false + } + SlashCommand::Session { action, target } => { + self.handle_session_command(action.as_deref(), target.as_deref())? + } + SlashCommand::Plugins { action, target } => { + self.handle_plugins_command(action.as_deref(), target.as_deref())? + } + SlashCommand::Agents { args } => { + Self::print_agents(args.as_deref(), CliOutputFormat::Text)?; + false + } + SlashCommand::Skills { args } => { + match classify_skills_slash_command(args.as_deref()) { + SkillSlashDispatch::Invoke(prompt) => self.run_turn(&prompt)?, + SkillSlashDispatch::Local => { + Self::print_skills(args.as_deref(), CliOutputFormat::Text)?; + } + } + false + } + SlashCommand::Doctor => { + println!("{}", render_doctor_report()?.render()); + false + } + SlashCommand::History { count } => { + self.print_prompt_history(count.as_deref()); + false + } + SlashCommand::Stats => { + let usage = UsageTracker::from_session(self.runtime.session()).cumulative_usage(); + println!("{}", format_cost_report(usage)); + false + } + SlashCommand::Login + | SlashCommand::Logout + | SlashCommand::Vim + | SlashCommand::Upgrade + | SlashCommand::Share + | SlashCommand::Feedback + | SlashCommand::Files + | SlashCommand::Fast + | SlashCommand::Exit + | SlashCommand::Summary + | SlashCommand::Desktop + | SlashCommand::Brief + | SlashCommand::Advisor + | SlashCommand::Stickers + | SlashCommand::Insights + | SlashCommand::Thinkback + | SlashCommand::ReleaseNotes + | SlashCommand::SecurityReview + | SlashCommand::Keybindings + | SlashCommand::PrivacySettings + | SlashCommand::Plan { .. } + | SlashCommand::Review { .. } + | SlashCommand::Tasks { .. } + | SlashCommand::Theme { .. } + | SlashCommand::Voice { .. } + | SlashCommand::Usage { .. } + | SlashCommand::Rename { .. } + | SlashCommand::Copy { .. } + | SlashCommand::Hooks { .. } + | SlashCommand::Context { .. } + | SlashCommand::Color { .. } + | SlashCommand::Effort { .. } + | SlashCommand::Branch { .. } + | SlashCommand::Rewind { .. } + | SlashCommand::Ide { .. } + | SlashCommand::Tag { .. } + | SlashCommand::OutputStyle { .. } + | SlashCommand::AddDir { .. } => { + let cmd_name = command.slash_name(); + eprintln!("{cmd_name} is not yet implemented in this build."); + false + } + SlashCommand::Unknown(name) => { + eprintln!("{}", format_unknown_slash_command(&name)); + false + } + }) + } + + pub(crate) fn print_status(&self) { + let cumulative = self.runtime.usage().cumulative_usage(); + let latest = self.runtime.usage().current_turn_usage(); + println!( + "{}", + format_status_report( + &self.model, + StatusUsage { + message_count: self.runtime.session().messages.len(), + turns: self.runtime.usage().turns(), + latest, + cumulative, + estimated_tokens: self.runtime.estimated_tokens(), + }, + self.permission_mode.as_str(), + &status_context(Some(&self.session.path)).expect("status context should load"), + None, // #148: REPL /status doesn't carry flag provenance + ) + ); + } + + pub(crate) fn print_prompt_history(&self, count: Option<&str>) { + let limit = match parse_history_count(count) { + Ok(limit) => limit, + Err(message) => { + eprintln!("{message}"); + return; + } + }; + let session_entries = &self.runtime.session().prompt_history; + let entries = if session_entries.is_empty() { + if self.prompt_history.is_empty() { + collect_session_prompt_history(self.runtime.session()) + } else { + self.prompt_history + .iter() + .map(|entry| PromptHistoryEntry { + timestamp_ms: entry.timestamp_ms, + text: entry.text.clone(), + }) + .collect() + } + } else { + session_entries + .iter() + .map(|entry| PromptHistoryEntry { + timestamp_ms: entry.timestamp_ms, + text: entry.text.clone(), + }) + .collect() + }; + println!("{}", render_prompt_history_report(&entries, limit)); + } + + pub(crate) fn print_sandbox_status() { + let cwd = env::current_dir().expect("current dir"); + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader + .load() + .unwrap_or_else(|_| runtime::RuntimeConfig::empty()); + println!( + "{}", + format_sandbox_report(&resolve_sandbox_status(runtime_config.sandbox(), &cwd)) + ); + } + + pub(crate) fn set_model( + &mut self, + model: Option, + ) -> Result> { + let Some(model) = model else { + println!( + "{}", + format_model_report( + &self.model, + self.runtime.session().messages.len(), + self.runtime.usage().turns(), + ) + ); + return Ok(false); + }; + + let model = resolve_model_alias_with_config(&model); + + if model == self.model { + println!( + "{}", + format_model_report( + &self.model, + self.runtime.session().messages.len(), + self.runtime.usage().turns(), + ) + ); + return Ok(false); + } + + let previous = self.model.clone(); + let session = self.runtime.session().clone(); + let message_count = session.messages.len(); + let runtime = build_runtime( + session, + &self.session.id, + model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + None, + )?; + self.replace_runtime(runtime)?; + self.model.clone_from(&model); + println!( + "{}", + format_model_switch_report(&previous, &model, message_count) + ); + Ok(true) + } + + pub(crate) fn set_permissions( + &mut self, + mode: Option, + ) -> Result> { + let Some(mode) = mode else { + println!( + "{}", + format_permissions_report(self.permission_mode.as_str()) + ); + return Ok(false); + }; + + let normalized = normalize_permission_mode(&mode).ok_or_else(|| { + format!( + "unsupported permission mode '{mode}'. Use read-only, workspace-write, or danger-full-access." + ) + })?; + + if normalized == self.permission_mode.as_str() { + println!("{}", format_permissions_report(normalized)); + return Ok(false); + } + + let previous = self.permission_mode.as_str().to_string(); + let session = self.runtime.session().clone(); + self.permission_mode = permission_mode_from_label(normalized); + let runtime = build_runtime( + session, + &self.session.id, + self.model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + None, + )?; + self.replace_runtime(runtime)?; + println!( + "{}", + format_permissions_switch_report(&previous, normalized) + ); + Ok(true) + } + + pub(crate) fn clear_session( + &mut self, + confirm: bool, + ) -> Result> { + if !confirm { + println!( + "clear: confirmation required; run /clear --confirm to start a fresh session." + ); + return Ok(false); + } + + let previous_session = self.session.clone(); + let session_state = new_cli_session()?; + self.session = create_managed_session_handle(&session_state.session_id)?; + let runtime = build_runtime( + session_state.with_persistence_path(self.session.path.clone()), + &self.session.id, + self.model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + None, + )?; + self.replace_runtime(runtime)?; + println!( + "Session cleared\n Mode fresh session\n Previous session {}\n Resume previous /resume {}\n Preserved model {}\n Permission mode {}\n New session {}\n Session file {}", + previous_session.id, + previous_session.id, + self.model, + self.permission_mode.as_str(), + self.session.id, + self.session.path.display(), + ); + Ok(true) + } + + pub(crate) fn print_cost(&self) { + let cumulative = self.runtime.usage().cumulative_usage(); + println!("{}", format_cost_report(cumulative)); + } + + pub(crate) fn resume_session( + &mut self, + session_path: Option, + ) -> Result> { + let Some(session_ref) = session_path else { + println!("{}", render_resume_usage()); + return Ok(false); + }; + + let (handle, session) = load_session_reference(&session_ref)?; + let message_count = session.messages.len(); + let session_id = session.session_id.clone(); + let runtime = build_runtime( + session, + &handle.id, + self.model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + None, + )?; + self.replace_runtime(runtime)?; + self.session = SessionHandle { + id: session_id, + path: handle.path, + }; + println!( + "{}", + format_resume_report( + &self.session.path.display().to_string(), + message_count, + self.runtime.usage().turns(), + ) + ); + Ok(true) + } + + pub(crate) fn print_config(section: Option<&str>) -> Result<(), Box> { + println!("{}", render_config_report(section)?); + Ok(()) + } + + pub(crate) fn print_memory() -> Result<(), Box> { + println!("{}", render_memory_report()?); + Ok(()) + } + + pub(crate) fn print_agents( + args: Option<&str>, + output_format: CliOutputFormat, + ) -> Result<(), Box> { + let cwd = env::current_dir()?; + match output_format { + CliOutputFormat::Text => println!("{}", handle_agents_slash_command(args, &cwd)?), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&handle_agents_slash_command_json(args, &cwd)?)? + ), + } + Ok(()) + } + + pub(crate) fn print_mcp( + args: Option<&str>, + output_format: CliOutputFormat, + ) -> Result<(), Box> { + // `claw mcp serve` starts a stdio MCP server exposing claw's built-in + // tools. All other `mcp` subcommands fall through to the existing + // configured-server reporter (`list`, `status`, ...). + if matches!(args.map(str::trim), Some("serve")) { + return run_mcp_serve(); + } + let cwd = env::current_dir()?; + match output_format { + CliOutputFormat::Text => println!("{}", handle_mcp_slash_command(args, &cwd)), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&handle_mcp_slash_command_json(args, &cwd))? + ), + } + Ok(()) + } + + pub(crate) fn print_skills( + args: Option<&str>, + output_format: CliOutputFormat, + ) -> Result<(), Box> { + let cwd = env::current_dir()?; + match output_format { + CliOutputFormat::Text => println!("{}", handle_skills_slash_command(args, &cwd)?), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&handle_skills_slash_command_json(args, &cwd)?)? + ), + } + Ok(()) + } + + pub(crate) fn print_plugins( + action: Option<&str>, + target: Option<&str>, + output_format: CliOutputFormat, + ) -> Result<(), Box> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load()?; + let mut manager = build_plugin_manager(&cwd, &loader, &runtime_config); + let result = handle_plugins_slash_command(action, target, &mut manager)?; + match output_format { + CliOutputFormat::Text => println!("{}", result.message), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "kind": "plugin", + "action": action.unwrap_or("list"), + "target": target, + "message": result.message, + "reload_runtime": result.reload_runtime, + }))? + ), + } + Ok(()) + } + + pub(crate) fn print_diff() -> Result<(), Box> { + println!("{}", render_diff_report()?); + Ok(()) + } + + pub(crate) fn print_version(output_format: CliOutputFormat) { + let _ = crate::print_version(output_format); + } + + pub(crate) fn export_session( + &self, + requested_path: Option<&str>, + ) -> Result<(), Box> { + let export_path = resolve_export_path(requested_path, self.runtime.session())?; + fs::write(&export_path, render_export_text(self.runtime.session()))?; + println!( + "Export\n Result wrote transcript\n File {}\n Messages {}", + export_path.display(), + self.runtime.session().messages.len(), + ); + Ok(()) + } + + #[allow(clippy::too_many_lines)] + pub(crate) fn handle_session_command( + &mut self, + action: Option<&str>, + target: Option<&str>, + ) -> Result> { + match action { + None | Some("list") => { + println!("{}", render_session_list(&self.session.id)?); + Ok(false) + } + Some("switch") => { + let Some(target) = target else { + println!("Usage: /session switch "); + return Ok(false); + }; + let (handle, session) = load_session_reference(target)?; + let message_count = session.messages.len(); + let session_id = session.session_id.clone(); + let runtime = build_runtime( + session, + &handle.id, + self.model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + None, + )?; + self.replace_runtime(runtime)?; + self.session = SessionHandle { + id: session_id, + path: handle.path, + }; + println!( + "Session switched\n Active session {}\n File {}\n Messages {}", + self.session.id, + self.session.path.display(), + message_count, + ); + Ok(true) + } + Some("fork") => { + let forked = self.runtime.fork_session(target.map(ToOwned::to_owned)); + let parent_session_id = self.session.id.clone(); + let handle = create_managed_session_handle(&forked.session_id)?; + let branch_name = forked + .fork + .as_ref() + .and_then(|fork| fork.branch_name.clone()); + let forked = forked.with_persistence_path(handle.path.clone()); + let message_count = forked.messages.len(); + forked.save_to_path(&handle.path)?; + let runtime = build_runtime( + forked, + &handle.id, + self.model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + None, + )?; + self.replace_runtime(runtime)?; + self.session = handle; + println!( + "Session forked\n Parent session {}\n Active session {}\n Branch {}\n File {}\n Messages {}", + parent_session_id, + self.session.id, + branch_name.as_deref().unwrap_or("(unnamed)"), + self.session.path.display(), + message_count, + ); + Ok(true) + } + Some("delete") => { + let Some(target) = target else { + println!("Usage: /session delete [--force]"); + return Ok(false); + }; + let handle = resolve_session_reference(target)?; + if handle.id == self.session.id { + println!( + "delete: refusing to delete the active session '{}'.\nSwitch to another session first with /session switch .", + handle.id + ); + return Ok(false); + } + if !confirm_session_deletion(&handle.id) { + println!("delete: cancelled."); + return Ok(false); + } + delete_managed_session(&handle.path)?; + println!( + "Session deleted\n Deleted session {}\n File {}", + handle.id, + handle.path.display(), + ); + Ok(false) + } + Some("delete-force") => { + let Some(target) = target else { + println!("Usage: /session delete [--force]"); + return Ok(false); + }; + let handle = resolve_session_reference(target)?; + if handle.id == self.session.id { + println!( + "delete: refusing to delete the active session '{}'.\nSwitch to another session first with /session switch .", + handle.id + ); + return Ok(false); + } + delete_managed_session(&handle.path)?; + println!( + "Session deleted\n Deleted session {}\n File {}", + handle.id, + handle.path.display(), + ); + Ok(false) + } + Some(other) => { + println!( + "Unknown /session action '{other}'. Use /session list, /session switch , /session fork [branch-name], or /session delete [--force]." + ); + Ok(false) + } + } + } + + pub(crate) fn handle_plugins_command( + &mut self, + action: Option<&str>, + target: Option<&str>, + ) -> Result> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load()?; + let mut manager = build_plugin_manager(&cwd, &loader, &runtime_config); + let result = handle_plugins_slash_command(action, target, &mut manager)?; + println!("{}", result.message); + if result.reload_runtime { + self.reload_runtime_features()?; + } + Ok(false) + } + + pub(crate) fn compact(&mut self) -> Result<(), Box> { + let result = self.runtime.compact(CompactionConfig::default()); + let removed = result.removed_message_count; + let kept = result.compacted_session.messages.len(); + let skipped = removed == 0; + let runtime = build_runtime( + result.compacted_session, + &self.session.id, + self.model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + None, + )?; + self.replace_runtime(runtime)?; + self.persist_session()?; + println!("{}", format_compact_report(removed, kept, skipped)); + Ok(()) + } + + pub(crate) fn run_bughunter( + &self, + scope: Option<&str>, + ) -> Result<(), Box> { + println!("{}", format_bughunter_report(scope)); + Ok(()) + } + + pub(crate) fn run_ultraplan( + &self, + task: Option<&str>, + ) -> Result<(), Box> { + println!("{}", format_ultraplan_report(task)); + Ok(()) + } + + pub(crate) fn run_teleport(target: Option<&str>) -> Result<(), Box> { + let Some(target) = target.map(str::trim).filter(|value| !value.is_empty()) else { + println!("Usage: /teleport "); + return Ok(()); + }; + + println!("{}", render_teleport_report(target)?); + Ok(()) + } + + pub(crate) fn run_debug_tool_call( + &self, + args: Option<&str>, + ) -> Result<(), Box> { + validate_no_args("/debug-tool-call", args)?; + println!("{}", render_last_tool_debug_report(self.runtime.session())?); + Ok(()) + } + + pub(crate) fn run_commit( + &mut self, + args: Option<&str>, + ) -> Result<(), Box> { + validate_no_args("/commit", args)?; + let status = git_output(&["status", "--short", "--branch"])?; + let summary = parse_git_workspace_summary(Some(&status)); + let branch = parse_git_status_branch(Some(&status)); + if summary.is_clean() { + println!("{}", format_commit_skipped_report()); + return Ok(()); + } + + println!( + "{}", + format_commit_preflight_report(branch.as_deref(), summary) + ); + Ok(()) + } + + pub(crate) fn run_pr(&self, context: Option<&str>) -> Result<(), Box> { + let branch = + resolve_git_branch_for(&env::current_dir()?).unwrap_or_else(|| "unknown".to_string()); + println!("{}", format_pr_report(&branch, context)); + Ok(()) + } + + pub(crate) fn run_issue(&self, context: Option<&str>) -> Result<(), Box> { + println!("{}", format_issue_report(context)); + Ok(()) + } +} + +pub(crate) fn run_stale_base_preflight(flag_value: Option<&str>) { + let Ok(cwd) = env::current_dir() else { + return; + }; + let source = resolve_expected_base(flag_value, &cwd); + let state = check_base_commit(&cwd, source.as_ref()); + if let Some(warning) = format_stale_base_warning(&state) { + eprintln!("{warning}"); + } +} + +pub(crate) fn run_mcp_serve() -> Result<(), Box> { + let tools = ::tools::mvp_tool_specs() + .into_iter() + .map(|spec| McpTool { + name: spec.name.to_string(), + description: Some(spec.description.to_string()), + input_schema: Some(spec.input_schema), + annotations: None, + meta: None, + }) + .collect(); + + let spec = McpServerSpec { + server_name: "claw".to_string(), + server_version: VERSION.to_string(), + tools, + tool_handler: Box::new(::tools::execute_tool), + }; + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + runtime.block_on(async move { + let mut server = McpServer::new(spec); + server.run().await + })?; + Ok(()) +} + +pub(crate) fn run_resume_command( + session_path: &Path, + session: &Session, + command: &SlashCommand, +) -> Result> { + match command { + SlashCommand::Help => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_repl_help()), + json: Some(serde_json::json!({ "kind": "help", "text": render_repl_help() })), + }), + SlashCommand::Compact => { + let result = runtime::compact_session( + session, + CompactionConfig { + max_estimated_tokens: 0, + ..CompactionConfig::default() + }, + ); + let removed = result.removed_message_count; + let kept = result.compacted_session.messages.len(); + let skipped = removed == 0; + result.compacted_session.save_to_path(session_path)?; + Ok(ResumeCommandOutcome { + session: result.compacted_session, + message: Some(format_compact_report(removed, kept, skipped)), + json: Some(serde_json::json!({ + "kind": "compact", + "skipped": skipped, + "removed_messages": removed, + "kept_messages": kept, + })), + }) + } + SlashCommand::Clear { confirm } => { + if !confirm { + return Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some( + "clear: confirmation required; rerun with /clear --confirm".to_string(), + ), + json: Some(serde_json::json!({ + "kind": "error", + "error": "confirmation required", + "hint": "rerun with /clear --confirm", + })), + }); + } + let backup_path = write_session_clear_backup(session, session_path)?; + let previous_session_id = session.session_id.clone(); + let cleared = new_cli_session()?; + let new_session_id = cleared.session_id.clone(); + cleared.save_to_path(session_path)?; + Ok(ResumeCommandOutcome { + session: cleared, + message: Some(format!( + "Session cleared\n Mode resumed session reset\n Previous session {previous_session_id}\n Backup {}\n Resume previous claw --resume {}\n New session {new_session_id}\n Session file {}", + backup_path.display(), + backup_path.display(), + session_path.display() + )), + json: Some(serde_json::json!({ + "kind": "clear", + "previous_session_id": previous_session_id, + "new_session_id": new_session_id, + "backup": backup_path.display().to_string(), + "session_file": session_path.display().to_string(), + })), + }) + } + SlashCommand::Status => { + let tracker = UsageTracker::from_session(session); + let usage = tracker.cumulative_usage(); + let context = status_context(Some(session_path))?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format_status_report( + session.model.as_deref().unwrap_or("restored-session"), + StatusUsage { + message_count: session.messages.len(), + turns: tracker.turns(), + latest: tracker.current_turn_usage(), + cumulative: usage, + estimated_tokens: 0, + }, + default_permission_mode().as_str(), + &context, + None, // #148: resumed sessions don't have flag provenance + )), + json: Some(status_json_value( + session.model.as_deref(), + StatusUsage { + message_count: session.messages.len(), + turns: tracker.turns(), + latest: tracker.current_turn_usage(), + cumulative: usage, + estimated_tokens: 0, + }, + default_permission_mode().as_str(), + &context, + None, // #148: resumed sessions don't have flag provenance + None, + )), + }) + } + SlashCommand::Sandbox => { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load()?; + let status = resolve_sandbox_status(runtime_config.sandbox(), &cwd); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format_sandbox_report(&status)), + json: Some(sandbox_json_value(&status)), + }) + } + SlashCommand::Cost => { + let usage = UsageTracker::from_session(session).cumulative_usage(); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format_cost_report(usage)), + json: Some(serde_json::json!({ + "kind": "cost", + "input_tokens": usage.input_tokens, + "output_tokens": usage.output_tokens, + "cache_creation_input_tokens": usage.cache_creation_input_tokens, + "cache_read_input_tokens": usage.cache_read_input_tokens, + "total_tokens": usage.total_tokens(), + })), + }) + } + SlashCommand::Config { section } => { + let message = render_config_report(section.as_deref())?; + let json = render_config_json(section.as_deref())?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(message), + json: Some(json), + }) + } + SlashCommand::Mcp { action, target } => { + let cwd = env::current_dir()?; + let args = match (action.as_deref(), target.as_deref()) { + (None, None) => None, + (Some(action), None) => Some(action.to_string()), + (Some(action), Some(target)) => Some(format!("{action} {target}")), + (None, Some(target)) => Some(target.to_string()), + }; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(handle_mcp_slash_command(args.as_deref(), &cwd)), + json: Some(handle_mcp_slash_command_json(args.as_deref(), &cwd)), + }) + } + SlashCommand::Memory => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_memory_report()?), + json: Some(render_memory_json()?), + }), + SlashCommand::Init => { + // #142: run the init once, then render both text + structured JSON + // from the same InitReport so both surfaces stay in sync. + let cwd = env::current_dir()?; + let report = crate::init::initialize_repo(&cwd)?; + let message = report.render(); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(message.clone()), + json: Some(init_json_value(&report, &message)), + }) + } + SlashCommand::Diff => { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let message = render_diff_report_for(&cwd)?; + let json = render_diff_json_for(&cwd)?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(message), + json: Some(json), + }) + } + SlashCommand::Version => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_version_report()), + json: Some(version_json_value()), + }), + SlashCommand::Export { path } => { + let export_path = resolve_export_path(path.as_deref(), session)?; + fs::write(&export_path, render_export_text(session))?; + let msg_count = session.messages.len(); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format!( + "Export\n Result wrote transcript\n File {}\n Messages {}", + export_path.display(), + msg_count, + )), + json: Some(serde_json::json!({ + "kind": "export", + "file": export_path.display().to_string(), + "message_count": msg_count, + })), + }) + } + SlashCommand::Agents { args } => { + let cwd = env::current_dir()?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?), + json: Some(serde_json::json!({ + "kind": "agents", + "text": handle_agents_slash_command(args.as_deref(), &cwd)?, + })), + }) + } + SlashCommand::Skills { args } => { + if let SkillSlashDispatch::Invoke(_) = classify_skills_slash_command(args.as_deref()) { + return Err( + "resumed /skills invocations are interactive-only; start `claw` and run `/skills ` in the REPL".into(), + ); + } + let cwd = env::current_dir()?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?), + json: Some(handle_skills_slash_command_json(args.as_deref(), &cwd)?), + }) + } + SlashCommand::Doctor => { + let report = render_doctor_report()?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(report.render()), + json: Some(report.json_value()), + }) + } + SlashCommand::Stats => { + let usage = UsageTracker::from_session(session).cumulative_usage(); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format_cost_report(usage)), + json: Some(serde_json::json!({ + "kind": "stats", + "input_tokens": usage.input_tokens, + "output_tokens": usage.output_tokens, + "cache_creation_input_tokens": usage.cache_creation_input_tokens, + "cache_read_input_tokens": usage.cache_read_input_tokens, + "total_tokens": usage.total_tokens(), + })), + }) + } + SlashCommand::History { count } => { + let limit = parse_history_count(count.as_deref()) + .map_err(|error| -> Box { error.into() })?; + let entries = collect_session_prompt_history(session); + let shown: Vec<_> = entries.iter().rev().take(limit).rev().collect(); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_prompt_history_report(&entries, limit)), + json: Some(serde_json::json!({ + "kind": "history", + "total": entries.len(), + "showing": shown.len(), + "entries": shown.iter().map(|e| serde_json::json!({ + "timestamp_ms": e.timestamp_ms, + "text": e.text, + })).collect::>(), + })), + }) + } + SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()), + // /session list can be served from the sessions directory without a live session. + SlashCommand::Session { + action: Some(ref act), + .. + } if act == "list" => { + let sessions = list_managed_sessions().unwrap_or_default(); + let session_ids: Vec = sessions.iter().map(|s| s.id.clone()).collect(); + let active_id = session.session_id.clone(); + let text = render_session_list(&active_id).unwrap_or_else(|e| format!("error: {e}")); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(text), + json: Some(serde_json::json!({ + "kind": "session_list", + "sessions": session_ids, + "active": active_id, + })), + }) + } + SlashCommand::Bughunter { .. } + | SlashCommand::Commit { .. } + | SlashCommand::Pr { .. } + | SlashCommand::Issue { .. } + | SlashCommand::Ultraplan { .. } + | SlashCommand::Teleport { .. } + | SlashCommand::DebugToolCall { .. } + | SlashCommand::Resume { .. } + | SlashCommand::Model { .. } + | SlashCommand::Permissions { .. } + | SlashCommand::Session { .. } + | SlashCommand::Plugins { .. } + | SlashCommand::Login + | SlashCommand::Logout + | SlashCommand::Vim + | SlashCommand::Upgrade + | SlashCommand::Share + | SlashCommand::Feedback + | SlashCommand::Files + | SlashCommand::Fast + | SlashCommand::Exit + | SlashCommand::Summary + | SlashCommand::Desktop + | SlashCommand::Brief + | SlashCommand::Advisor + | SlashCommand::Stickers + | SlashCommand::Insights + | SlashCommand::Thinkback + | SlashCommand::ReleaseNotes + | SlashCommand::SecurityReview + | SlashCommand::Keybindings + | SlashCommand::PrivacySettings + | SlashCommand::Plan { .. } + | SlashCommand::Review { .. } + | SlashCommand::Tasks { .. } + | SlashCommand::Theme { .. } + | SlashCommand::Voice { .. } + | SlashCommand::Usage { .. } + | SlashCommand::Rename { .. } + | SlashCommand::Copy { .. } + | SlashCommand::Hooks { .. } + | SlashCommand::Context { .. } + | SlashCommand::Color { .. } + | SlashCommand::Effort { .. } + | SlashCommand::Branch { .. } + | SlashCommand::Rewind { .. } + | SlashCommand::Ide { .. } + | SlashCommand::Tag { .. } + | SlashCommand::OutputStyle { .. } + | SlashCommand::AddDir { .. } => Err("unsupported resumed slash command".into()), + } +} + +pub(crate) struct ResumeCommandOutcome { + pub(crate) session: Session, + pub(crate) message: Option, + pub(crate) json: Option, +} diff --git a/rust/crates/rusty-claude-cli/src/runtime_builder.rs b/rust/crates/rusty-claude-cli/src/runtime_builder.rs new file mode 100644 index 00000000..1f456050 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/runtime_builder.rs @@ -0,0 +1,631 @@ +use crate::api_client::AnthropicRuntimeClient; +use crate::constants::{AllowedToolSet, DEFAULT_DATE}; +use crate::permission::permission_policy; +use crate::progress::InternalPromptProgressReporter; +use crate::tool_executor::CliToolExecutor; +use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry}; +use runtime::{ + load_system_prompt, ConfigLoader, ConversationRuntime, McpServerManager, McpTool, MessageRole, + PermissionMode, Session, ToolError, +}; +use serde_json::{json, Value}; +use std::collections::BTreeSet; +use std::env; +use std::ops::{Deref, DerefMut}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use tools::{GlobalToolRegistry, RuntimeToolDefinition}; + +pub(crate) type RuntimePluginStateBuildOutput = ( + Option>>, + Vec, +); + +pub(crate) struct RuntimePluginState { + pub(crate) feature_config: runtime::RuntimeFeatureConfig, + pub(crate) tool_registry: GlobalToolRegistry, + pub(crate) plugin_registry: PluginRegistry, + pub(crate) mcp_state: Option>>, +} + +pub(crate) struct RuntimeMcpState { + pub(crate) runtime: tokio::runtime::Runtime, + pub(crate) manager: McpServerManager, + pub(crate) pending_servers: Vec, + pub(crate) degraded_report: Option, +} + +pub(crate) struct BuiltRuntime { + pub(crate) runtime: Option>, + pub(crate) plugin_registry: PluginRegistry, + pub(crate) plugins_active: bool, + pub(crate) mcp_state: Option>>, + pub(crate) mcp_active: bool, +} + +impl BuiltRuntime { + pub(crate) fn new( + runtime: ConversationRuntime, + plugin_registry: PluginRegistry, + mcp_state: Option>>, + ) -> Self { + Self { + runtime: Some(runtime), + plugin_registry, + plugins_active: true, + mcp_state, + mcp_active: true, + } + } + + pub(crate) fn with_hook_abort_signal(mut self, hook_abort_signal: runtime::HookAbortSignal) -> Self { + let runtime = self + .runtime + .take() + .expect("runtime should exist before installing hook abort signal"); + self.runtime = Some(runtime.with_hook_abort_signal(hook_abort_signal)); + self + } + + pub(crate) fn shutdown_plugins(&mut self) -> Result<(), Box> { + if self.plugins_active { + self.plugin_registry.shutdown()?; + self.plugins_active = false; + } + Ok(()) + } + + pub(crate) fn shutdown_mcp(&mut self) -> Result<(), Box> { + if self.mcp_active { + if let Some(mcp_state) = &self.mcp_state { + mcp_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .shutdown()?; + } + self.mcp_active = false; + } + Ok(()) + } +} + +impl Deref for BuiltRuntime { + type Target = ConversationRuntime; + + fn deref(&self) -> &Self::Target { + self.runtime + .as_ref() + .expect("runtime should exist while built runtime is alive") + } +} + +impl DerefMut for BuiltRuntime { + fn deref_mut(&mut self) -> &mut Self::Target { + self.runtime + .as_mut() + .expect("runtime should exist while built runtime is alive") + } +} + +impl Drop for BuiltRuntime { + fn drop(&mut self) { + let _ = self.shutdown_mcp(); + let _ = self.shutdown_plugins(); + } +} + +impl RuntimeMcpState { + pub(crate) fn new( + runtime_config: &runtime::RuntimeConfig, + ) -> Result, Box> { + let mut manager = McpServerManager::from_runtime_config(runtime_config); + if manager.server_names().is_empty() && manager.unsupported_servers().is_empty() { + return Ok(None); + } + + let runtime = tokio::runtime::Runtime::new()?; + let discovery = runtime.block_on(manager.discover_tools_best_effort()); + let pending_servers = discovery + .failed_servers + .iter() + .map(|failure| failure.server_name.clone()) + .chain( + discovery + .unsupported_servers + .iter() + .map(|server| server.server_name.clone()), + ) + .collect::>() + .into_iter() + .collect::>(); + let available_tools = discovery + .tools + .iter() + .map(|tool| tool.qualified_name.clone()) + .collect::>(); + let failed_server_names = pending_servers.iter().cloned().collect::>(); + let working_servers = manager + .server_names() + .into_iter() + .filter(|server_name| !failed_server_names.contains(server_name)) + .collect::>(); + let failed_servers = + discovery + .failed_servers + .iter() + .map(|failure| runtime::McpFailedServer { + server_name: failure.server_name.clone(), + phase: runtime::McpLifecyclePhase::ToolDiscovery, + error: runtime::McpErrorSurface::new( + runtime::McpLifecyclePhase::ToolDiscovery, + Some(failure.server_name.clone()), + failure.error.clone(), + std::collections::BTreeMap::new(), + true, + ), + }) + .chain(discovery.unsupported_servers.iter().map(|server| { + runtime::McpFailedServer { + server_name: server.server_name.clone(), + phase: runtime::McpLifecyclePhase::ServerRegistration, + error: runtime::McpErrorSurface::new( + runtime::McpLifecyclePhase::ServerRegistration, + Some(server.server_name.clone()), + server.reason.clone(), + std::collections::BTreeMap::from([( + "transport".to_string(), + format!("{:?}", server.transport).to_ascii_lowercase(), + )]), + false, + ), + } + })) + .collect::>(); + let degraded_report = (!failed_servers.is_empty()).then(|| { + runtime::McpDegradedReport::new( + working_servers, + failed_servers, + available_tools.clone(), + available_tools, + ) + }); + + Ok(Some(( + Self { + runtime, + manager, + pending_servers, + degraded_report, + }, + discovery, + ))) + } + + pub(crate) fn shutdown(&mut self) -> Result<(), Box> { + self.runtime.block_on(self.manager.shutdown())?; + Ok(()) + } + + pub(crate) fn pending_servers(&self) -> Option> { + (!self.pending_servers.is_empty()).then(|| self.pending_servers.clone()) + } + + pub(crate) fn degraded_report(&self) -> Option { + self.degraded_report.clone() + } + + pub(crate) fn server_names(&self) -> Vec { + self.manager.server_names() + } + + pub(crate) fn call_tool( + &mut self, + qualified_tool_name: &str, + arguments: Option, + ) -> Result { + let response = self + .runtime + .block_on(self.manager.call_tool(qualified_tool_name, arguments)) + .map_err(|error| ToolError::new(error.to_string()))?; + if let Some(error) = response.error { + return Err(ToolError::new(format!( + "MCP tool `{qualified_tool_name}` returned JSON-RPC error: {} ({})", + error.message, error.code + ))); + } + + let result = response.result.ok_or_else(|| { + ToolError::new(format!( + "MCP tool `{qualified_tool_name}` returned no result payload" + )) + })?; + serde_json::to_string_pretty(&result).map_err(|error| ToolError::new(error.to_string())) + } + + pub(crate) fn list_resources_for_server(&mut self, server_name: &str) -> Result { + let result = self + .runtime + .block_on(self.manager.list_resources(server_name)) + .map_err(|error| ToolError::new(error.to_string()))?; + serde_json::to_string_pretty(&serde_json::json!({ + "server": server_name, + "resources": result.resources, + })) + .map_err(|error| ToolError::new(error.to_string())) + } + + pub(crate) fn list_resources_for_all_servers(&mut self) -> Result { + let mut resources = Vec::new(); + let mut failures = Vec::new(); + + for server_name in self.server_names() { + match self + .runtime + .block_on(self.manager.list_resources(&server_name)) + { + Ok(result) => resources.push(serde_json::json!({ + "server": server_name, + "resources": result.resources, + })), + Err(error) => failures.push(serde_json::json!({ + "server": server_name, + "error": error.to_string(), + })), + } + } + + if resources.is_empty() && !failures.is_empty() { + let message = failures + .iter() + .filter_map(|failure| failure.get("error").and_then(serde_json::Value::as_str)) + .collect::>() + .join("; "); + return Err(ToolError::new(message)); + } + + serde_json::to_string_pretty(&serde_json::json!({ + "resources": resources, + "failures": failures, + })) + .map_err(|error| ToolError::new(error.to_string())) + } + + pub(crate) fn read_resource(&mut self, server_name: &str, uri: &str) -> Result { + let result = self + .runtime + .block_on(self.manager.read_resource(server_name, uri)) + .map_err(|error| ToolError::new(error.to_string()))?; + serde_json::to_string_pretty(&serde_json::json!({ + "server": server_name, + "contents": result.contents, + })) + .map_err(|error| ToolError::new(error.to_string())) + } +} + +pub(crate) fn build_runtime_mcp_state( + runtime_config: &runtime::RuntimeConfig, +) -> Result> { + let Some((mcp_state, discovery)) = RuntimeMcpState::new(runtime_config)? else { + return Ok((None, Vec::new())); + }; + + let mut runtime_tools = discovery + .tools + .iter() + .map(mcp_runtime_tool_definition) + .collect::>(); + if !mcp_state.server_names().is_empty() { + runtime_tools.extend(mcp_wrapper_tool_definitions()); + } + + Ok((Some(Arc::new(Mutex::new(mcp_state))), runtime_tools)) +} + +pub(crate) fn mcp_runtime_tool_definition(tool: &runtime::ManagedMcpTool) -> RuntimeToolDefinition { + RuntimeToolDefinition { + name: tool.qualified_name.clone(), + description: Some( + tool.tool + .description + .clone() + .unwrap_or_else(|| format!("Invoke MCP tool `{}`.", tool.qualified_name)), + ), + input_schema: tool + .tool + .input_schema + .clone() + .unwrap_or_else(|| serde_json::json!({ "type": "object", "additionalProperties": true })), + required_permission: permission_mode_for_mcp_tool(&tool.tool), + } +} + +pub(crate) fn mcp_wrapper_tool_definitions() -> Vec { + vec![ + RuntimeToolDefinition { + name: "MCPTool".to_string(), + description: Some( + "Call a configured MCP tool by its qualified name and JSON arguments.".to_string(), + ), + input_schema:serde_json::json!({ + "type": "object", + "properties": { + "qualifiedName": { "type": "string" }, + "arguments": {} + }, + "required": ["qualifiedName"], + "additionalProperties": false + }), + required_permission: PermissionMode::DangerFullAccess, + }, + RuntimeToolDefinition { + name: "ListMcpResourcesTool".to_string(), + description: Some( + "List MCP resources from one configured server or from every connected server." + .to_string(), + ), + input_schema:serde_json::json!({ + "type": "object", + "properties": { + "server": { "type": "string" } + }, + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + RuntimeToolDefinition { + name: "ReadMcpResourceTool".to_string(), + description: Some("Read a specific MCP resource from a configured server.".to_string()), + input_schema:serde_json::json!({ + "type": "object", + "properties": { + "server": { "type": "string" }, + "uri": { "type": "string" } + }, + "required": ["server", "uri"], + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + ] +} + +pub(crate) fn permission_mode_for_mcp_tool(tool: &McpTool) -> PermissionMode { + let read_only = mcp_annotation_flag(tool, "readOnlyHint"); + let destructive = mcp_annotation_flag(tool, "destructiveHint"); + let open_world = mcp_annotation_flag(tool, "openWorldHint"); + + if read_only && !destructive && !open_world { + PermissionMode::ReadOnly + } else if destructive || open_world { + PermissionMode::DangerFullAccess + } else { + PermissionMode::WorkspaceWrite + } +} + +pub(crate) fn mcp_annotation_flag(tool: &McpTool, key: &str) -> bool { + tool.annotations + .as_ref() + .and_then(|annotations| annotations.get(key)) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) +} + +pub(crate) struct HookAbortMonitor { + pub(crate) stop_tx: Option>, + pub(crate) join_handle: Option>, +} + +impl HookAbortMonitor { + pub(crate) fn spawn(abort_signal: runtime::HookAbortSignal) -> Self { + Self::spawn_with_waiter(abort_signal, move |stop_rx, abort_signal| { + let Ok(runtime) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + else { + return; + }; + + runtime.block_on(async move { + let wait_for_stop = tokio::task::spawn_blocking(move || { + let _ = stop_rx.recv(); + }); + + tokio::select! { + result = tokio::signal::ctrl_c() => { + if result.is_ok() { + abort_signal.abort(); + } + } + _ = wait_for_stop => {} + } + }); + }) + } + + pub(crate) fn spawn_with_waiter(abort_signal: runtime::HookAbortSignal, wait_for_interrupt: F) -> Self + where + F: FnOnce(Receiver<()>, runtime::HookAbortSignal) + Send + 'static, + { + let (stop_tx, stop_rx) = mpsc::channel(); + let join_handle = thread::spawn(move || wait_for_interrupt(stop_rx, abort_signal)); + + Self { + stop_tx: Some(stop_tx), + join_handle: Some(join_handle), + } + } + + pub(crate) fn stop(mut self) { + if let Some(stop_tx) = self.stop_tx.take() { + let _ = stop_tx.send(()); + } + if let Some(join_handle) = self.join_handle.take() { + let _ = join_handle.join(); + } + } +} + +pub(crate) fn build_system_prompt() -> Result, Box> { + Ok(load_system_prompt( + env::current_dir()?, + DEFAULT_DATE, + env::consts::OS, + "unknown", + )?) +} + +pub(crate) fn build_runtime_plugin_state() -> Result> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load()?; + build_runtime_plugin_state_with_loader(&cwd, &loader, &runtime_config) +} + +pub(crate) fn build_runtime_plugin_state_with_loader( + cwd: &Path, + loader: &ConfigLoader, + runtime_config: &runtime::RuntimeConfig, +) -> Result> { + let plugin_manager = build_plugin_manager(cwd, loader, runtime_config); + let plugin_registry = plugin_manager.plugin_registry()?; + let plugin_hook_config = + runtime_hook_config_from_plugin_hooks(plugin_registry.aggregated_hooks()?); + let feature_config = runtime_config + .feature_config() + .clone() + .with_hooks(runtime_config.hooks().merged(&plugin_hook_config)); + let (mcp_state, runtime_tools) = build_runtime_mcp_state(runtime_config)?; + let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_registry.aggregated_tools()?)? + .with_runtime_tools(runtime_tools)?; + Ok(RuntimePluginState { + feature_config, + tool_registry, + plugin_registry, + mcp_state, + }) +} + +pub(crate) fn build_plugin_manager( + cwd: &Path, + loader: &ConfigLoader, + runtime_config: &runtime::RuntimeConfig, +) -> PluginManager { + let plugin_settings = runtime_config.plugins(); + let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf()); + plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone(); + plugin_config.external_dirs = plugin_settings + .external_directories() + .iter() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)) + .collect(); + plugin_config.install_root = plugin_settings + .install_root() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + plugin_config.registry_path = plugin_settings + .registry_path() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + plugin_config.bundled_root = plugin_settings + .bundled_root() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + PluginManager::new(plugin_config) +} + +pub(crate) fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf { + let path = PathBuf::from(value); + if path.is_absolute() { + path + } else if value.starts_with('.') { + cwd.join(path) + } else { + config_home.join(path) + } +} + +pub(crate) fn runtime_hook_config_from_plugin_hooks(hooks: PluginHooks) -> runtime::RuntimeHookConfig { + runtime::RuntimeHookConfig::new( + hooks.pre_tool_use, + hooks.post_tool_use, + hooks.post_tool_use_failure, + ) +} + +pub(crate) fn build_runtime( + session: Session, + session_id: &str, + model: String, + system_prompt: Vec, + enable_tools: bool, + emit_output: bool, + allowed_tools: Option, + permission_mode: PermissionMode, + progress_reporter: Option, +) -> Result> { + let runtime_plugin_state = build_runtime_plugin_state()?; + build_runtime_with_plugin_state( + session, + session_id, + model, + system_prompt, + enable_tools, + emit_output, + allowed_tools, + permission_mode, + progress_reporter, + runtime_plugin_state, + ) +} + +pub(crate) fn build_runtime_with_plugin_state( + session: Session, + session_id: &str, + model: String, + system_prompt: Vec, + enable_tools: bool, + emit_output: bool, + allowed_tools: Option, + permission_mode: PermissionMode, + progress_reporter: Option, + runtime_plugin_state: RuntimePluginState, +) -> Result> { + let RuntimePluginState { + feature_config, + tool_registry, + plugin_registry, + mcp_state, + } = runtime_plugin_state; + plugin_registry.initialize()?; + let policy = permission_policy(permission_mode, &feature_config, &tool_registry) + .map_err(std::io::Error::other)?; + + let executor = CliToolExecutor::new( + allowed_tools.clone(), + emit_output, + tool_registry.clone(), + mcp_state.clone(), + ); + + let client = AnthropicRuntimeClient::new( + session_id, + model, + enable_tools, + emit_output, + allowed_tools, + tool_registry, + progress_reporter, + )?; + + let runtime = ConversationRuntime::new_with_features( + session, + client, + executor, + policy, + system_prompt, + &feature_config, + ); + + Ok(BuiltRuntime::new(runtime, plugin_registry, mcp_state)) +} diff --git a/rust/crates/rusty-claude-cli/src/session.rs b/rust/crates/rusty-claude-cli/src/session.rs new file mode 100644 index 00000000..3bb1fc16 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/session.rs @@ -0,0 +1,417 @@ +use std::env; +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::UNIX_EPOCH; + +use commands::{render_slash_command_help_filtered, SlashCommand}; +use runtime::{ContentBlock, MessageRole, Session}; + +use crate::constants::*; +use crate::live_cli::PromptHistoryEntry; +use crate::models::*; + +pub(crate) fn collect_session_prompt_history(session: &Session) -> Vec { + if !session.prompt_history.is_empty() { + return session + .prompt_history + .iter() + .map(|entry| PromptHistoryEntry { + timestamp_ms: entry.timestamp_ms, + text: entry.text.clone(), + }) + .collect(); + } + let timestamp_ms = session.updated_at_ms; + session + .messages + .iter() + .filter(|message| message.role == MessageRole::User) + .filter_map(|message| { + message.blocks.iter().find_map(|block| match block { + ContentBlock::Text { text } => Some(PromptHistoryEntry { + timestamp_ms, + text: text.clone(), + }), + _ => None, + }) + }) + .collect() +} + +pub(crate) fn recent_user_context(session: &Session, limit: usize) -> String { + let requests = session + .messages + .iter() + .filter(|message| message.role == MessageRole::User) + .filter_map(|message| { + message.blocks.iter().find_map(|block| match block { + ContentBlock::Text { text } => Some(text.trim().to_string()), + _ => None, + }) + }) + .rev() + .take(limit) + .collect::>(); + + if requests.is_empty() { + "".to_string() + } else { + requests + .into_iter() + .rev() + .enumerate() + .map(|(index, text)| format!("{}. {}", index + 1, text)) + .collect::>() + .join("\n") + } +} + +pub(crate) fn git_output(args: &[&str]) -> Result> { + let output = Command::new("git") + .args(args) + .current_dir(env::current_dir()?) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!("git {} failed: {stderr}", args.join(" ")).into()); + } + Ok(String::from_utf8(output.stdout)?) +} + +pub(crate) fn git_status_ok(args: &[&str]) -> Result<(), Box> { + let output = Command::new("git") + .args(args) + .current_dir(env::current_dir()?) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!("git {} failed: {stderr}", args.join(" ")).into()); + } + Ok(()) +} + +pub(crate) fn command_exists(name: &str) -> bool { + Command::new("which") + .arg(name) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +pub(crate) fn write_temp_text_file( + filename: &str, + contents: &str, +) -> Result> { + let path = env::temp_dir().join(filename); + fs::write(&path, contents)?; + Ok(path) +} + +pub(crate) fn current_session_store() -> Result> { + let cwd = env::current_dir()?; + runtime::SessionStore::from_cwd(&cwd).map_err(|e| Box::new(e) as Box) +} + +pub(crate) fn sessions_dir() -> Result> { + Ok(current_session_store()?.sessions_dir().to_path_buf()) +} + +pub(crate) fn new_cli_session() -> Result> { + Ok(Session::new().with_workspace_root(env::current_dir()?)) +} + +pub(crate) fn create_managed_session_handle( + session_id: &str, +) -> Result> { + let handle = current_session_store()?.create_handle(session_id); + Ok(SessionHandle { + id: handle.id, + path: handle.path, + }) +} + +pub(crate) fn resolve_session_reference(reference: &str) -> Result> { + let handle = current_session_store()? + .resolve_reference(reference) + .map_err(|e| Box::new(e) as Box)?; + Ok(SessionHandle { + id: handle.id, + path: handle.path, + }) +} + +pub(crate) fn resolve_managed_session_path(session_id: &str) -> Result> { + current_session_store()? + .resolve_managed_path(session_id) + .map_err(|e| Box::new(e) as Box) +} + +pub(crate) fn list_managed_sessions() -> Result, Box> { + let store = current_session_store()?; + let sessions = store.list_sessions().map_err(|e| Box::new(e) as Box)?; + let panes = discover_tmux_panes(); + + Ok(sessions + .into_iter() + .map(|session| { + let lifecycle = classify_session_lifecycle_from_panes(&session.path, panes.clone()); + ManagedSessionSummary { + id: session.id, + path: session.path, + updated_at_ms: session.updated_at_ms, + modified_epoch_millis: session.modified_epoch_millis, + message_count: session.message_count, + parent_session_id: session.parent_session_id, + branch_name: session.branch_name, + lifecycle, + } + }) + .collect()) +} + +pub(crate) fn latest_managed_session() -> Result> { + let store = current_session_store()?; + let session = store.latest_session().map_err(|e| Box::new(e) as Box)?; + let lifecycle = classify_session_lifecycle_for(&session.path); + + Ok(ManagedSessionSummary { + id: session.id, + path: session.path, + updated_at_ms: session.updated_at_ms, + modified_epoch_millis: session.modified_epoch_millis, + message_count: session.message_count, + parent_session_id: session.parent_session_id, + branch_name: session.branch_name, + lifecycle, + }) +} + +pub(crate) fn load_session_reference( + reference: &str, +) -> Result<(SessionHandle, Session), Box> { + let loaded = current_session_store()? + .load_session(reference) + .map_err(|e| Box::new(e) as Box)?; + Ok(( + SessionHandle { + id: loaded.handle.id, + path: loaded.handle.path, + }, + loaded.session, + )) +} + +pub(crate) fn delete_managed_session(path: &Path) -> Result<(), Box> { + if !path.exists() { + return Err(format!("session file does not exist: {}", path.display()).into()); + } + fs::remove_file(path)?; + Ok(()) +} + +pub(crate) fn confirm_session_deletion(session_id: &str) -> bool { + print!("Delete session '{session_id}'? This cannot be undone. [y/N]: "); + io::stdout().flush().unwrap_or(()); + let mut answer = String::new(); + if io::stdin().read_line(&mut answer).is_err() { + return false; + } + matches!(answer.trim(), "y" | "Y" | "yes" | "Yes" | "YES") +} + +pub(crate) fn classify_session_lifecycle_for(workspace: &Path) -> SessionLifecycleSummary { + classify_session_lifecycle_from_panes(workspace, discover_tmux_panes()) +} + +pub(crate) fn classify_session_lifecycle_from_panes( + workspace: &Path, + panes: Vec, +) -> SessionLifecycleSummary { + let workspace_dirty = git_worktree_is_dirty(workspace); + let mut idle_shell = None; + for pane in panes { + if !pane_path_matches_workspace(&pane.current_path, workspace) { + continue; + } + if is_idle_shell_command(&pane.current_command) { + idle_shell.get_or_insert(pane); + } else { + return SessionLifecycleSummary { + kind: SessionLifecycleKind::RunningProcess, + pane_id: Some(pane.pane_id), + pane_command: Some(pane.current_command), + pane_path: Some(pane.current_path), + workspace_dirty, + abandoned: false, + }; + } + } + + if let Some(pane) = idle_shell { + SessionLifecycleSummary { + kind: SessionLifecycleKind::IdleShell, + pane_id: Some(pane.pane_id), + pane_command: Some(pane.current_command), + pane_path: Some(pane.current_path), + workspace_dirty, + abandoned: workspace_dirty, + } + } else { + SessionLifecycleSummary { + kind: SessionLifecycleKind::SavedOnly, + pane_id: None, + pane_command: None, + pane_path: None, + workspace_dirty, + abandoned: workspace_dirty, + } + } +} + +pub(crate) fn discover_tmux_panes() -> Vec { + let output = Command::new("tmux") + .args([ + "list-panes", + "-a", + "-F", + "#{pane_id}\t#{pane_current_command}\t#{pane_current_path}", + ]) + .output(); + let Ok(output) = output else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + let stdout = String::from_utf8_lossy(&output.stdout); + parse_tmux_pane_snapshots(&stdout) +} + +pub(crate) fn parse_tmux_pane_snapshots(output: &str) -> Vec { + output + .lines() + .filter_map(|line| { + let mut fields = line.splitn(3, '\t'); + let pane_id = fields.next()?.trim(); + let current_command = fields.next()?.trim(); + let current_path = fields.next()?.trim(); + if pane_id.is_empty() || current_path.is_empty() { + return None; + } + Some(TmuxPaneSnapshot { + pane_id: pane_id.to_string(), + current_command: current_command.to_string(), + current_path: PathBuf::from(current_path), + }) + }) + .collect() +} + +pub(crate) fn pane_path_matches_workspace(pane_path: &Path, workspace: &Path) -> bool { + let pane_path = fs::canonicalize(pane_path).unwrap_or_else(|_| pane_path.to_path_buf()); + let workspace = fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf()); + pane_path == workspace || pane_path.starts_with(&workspace) +} + +pub(crate) fn is_idle_shell_command(command: &str) -> bool { + let command = command.rsplit('/').next().unwrap_or(command); + matches!( + command, + "bash" | "zsh" | "sh" | "fish" | "nu" | "pwsh" | "powershell" | "cmd" + ) +} + +pub(crate) fn git_worktree_is_dirty(workspace: &Path) -> bool { + let output = Command::new("git") + .arg("-C") + .arg(workspace) + .args(["status", "--porcelain"]) + .output(); + output + .ok() + .filter(|output| output.status.success()) + .is_some_and(|output| !output.stdout.is_empty()) +} + +pub(crate) fn render_session_list(active_session_id: &str) -> Result> { + let sessions = list_managed_sessions()?; + let mut lines = vec![ + "Sessions".to_string(), + format!(" Directory {}", sessions_dir()?.display()), + ]; + if sessions.is_empty() { + lines.push(" No managed sessions saved yet.".to_string()); + return Ok(lines.join("\n")); + } + for session in sessions { + let marker = if session.id == active_session_id { + "● current" + } else { + "○ saved" + }; + let lineage = match ( + session.branch_name.as_deref(), + session.parent_session_id.as_deref(), + ) { + (Some(branch_name), Some(parent_session_id)) => { + format!(" branch={branch_name} from={parent_session_id}") + } + (None, Some(parent_session_id)) => format!(" from={parent_session_id}"), + (Some(branch_name), None) => format!(" branch={branch_name}"), + (None, None) => String::new(), + }; + lines.push(format!( + " {id:<20} {marker:<10} msgs={msgs:<4} modified={modified}{lineage} lifecycle={lifecycle} path={path}", + id = session.id, + msgs = session.message_count, + modified = format_session_modified_age(session.modified_epoch_millis), + lineage = lineage, + lifecycle = session.lifecycle.signal(), + path = session.path.display(), + )); + } + Ok(lines.join("\n")) +} + +pub(crate) fn format_session_modified_age(modified_epoch_millis: u128) -> String { + let now = std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .map_or(modified_epoch_millis, |duration| duration.as_millis()); + let delta_seconds = now + .saturating_sub(modified_epoch_millis) + .checked_div(1_000) + .unwrap_or_default(); + match delta_seconds { + 0..=4 => "just-now".to_string(), + 5..=59 => format!("{delta_seconds}s-ago"), + 60..=3_599 => format!("{}m-ago", delta_seconds / 60), + 3_600..=86_399 => format!("{}h-ago", delta_seconds / 3_600), + _ => format!("{}d-ago", delta_seconds / 86_400), + } +} + +pub(crate) fn write_session_clear_backup( + session: &Session, + session_path: &Path, +) -> Result> { + let backup_path = session_clear_backup_path(session_path); + session.save_to_path(&backup_path)?; + Ok(backup_path) +} + +pub(crate) fn session_clear_backup_path(session_path: &Path) -> PathBuf { + let timestamp = std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .map_or(0, |duration| duration.as_millis()); + let file_name = session_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("session.jsonl"); + session_path.with_file_name(format!("{file_name}.before-clear-{timestamp}.bak")) +} + + diff --git a/rust/crates/rusty-claude-cli/src/status.rs b/rust/crates/rusty-claude-cli/src/status.rs new file mode 100644 index 00000000..e7f0d80a --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/status.rs @@ -0,0 +1,532 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use serde_json::{json, Map, Value}; +use runtime::{ConfigLoader, PermissionMode, ProjectContext, SandboxConfig, SandboxStatus, TokenUsage}; +use runtime::resolve_sandbox_status; +use crate::args::*; +use crate::model::*; +use crate::*; + +#[derive(Debug, Clone)] +pub(crate) struct StatusContext { + pub(crate) cwd: PathBuf, + pub(crate) session_path: Option, + pub(crate) loaded_config_files: usize, + pub(crate) discovered_config_files: usize, + pub(crate) memory_file_count: usize, + pub(crate) project_root: Option, + pub(crate) git_branch: Option, + pub(crate) git_summary: GitWorkspaceSummary, + pub(crate) session_lifecycle: SessionLifecycleSummary, + pub(crate) sandbox_status: runtime::SandboxStatus, + pub(crate) config_load_error: Option, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct StatusUsage { + pub(crate) message_count: usize, + pub(crate) turns: u32, + pub(crate) latest: TokenUsage, + pub(crate) cumulative: TokenUsage, + pub(crate) estimated_tokens: usize, +} + +pub(crate) fn parse_git_status_metadata(status: Option<&str>) -> (Option, Option) { + parse_git_status_metadata_for( + &env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + status, + ) +} + +pub(crate) fn parse_git_status_branch(status: Option<&str>) -> Option { + let status = status?; + let first_line = status.lines().next()?; + let line = first_line.strip_prefix("## ")?; + if line.starts_with("HEAD") { + return Some("detached HEAD".to_string()); + } + let branch = line.split(['.', ' ']).next().unwrap_or_default().trim(); + if branch.is_empty() { + None + } else { + Some(branch.to_string()) + } +} + +pub(crate) fn parse_git_workspace_summary(status: Option<&str>) -> GitWorkspaceSummary { + let mut summary = GitWorkspaceSummary::default(); + let Some(status) = status else { + return summary; + }; + + for line in status.lines() { + if line.starts_with("## ") || line.trim().is_empty() { + continue; + } + + summary.changed_files += 1; + let mut chars = line.chars(); + let index_status = chars.next().unwrap_or(' '); + let worktree_status = chars.next().unwrap_or(' '); + + if index_status == '?' && worktree_status == '?' { + summary.untracked_files += 1; + continue; + } + + if index_status != ' ' { + summary.staged_files += 1; + } + if worktree_status != ' ' { + summary.unstaged_files += 1; + } + if (matches!(index_status, 'U' | 'A') && matches!(worktree_status, 'U' | 'A')) + || index_status == 'U' + || worktree_status == 'U' + { + summary.conflicted_files += 1; + } + } + + summary +} + +pub(crate) fn resolve_git_branch_for(cwd: &Path) -> Option { + let branch = run_git_capture_in(cwd, &["branch", "--show-current"])?; + let branch = branch.trim(); + if !branch.is_empty() { + return Some(branch.to_string()); + } + + let fallback = run_git_capture_in(cwd, &["rev-parse", "--abbrev-ref", "HEAD"])?; + let fallback = fallback.trim(); + if fallback.is_empty() { + None + } else if fallback == "HEAD" { + Some("detached HEAD".to_string()) + } else { + Some(fallback.to_string()) + } +} + +pub(crate) fn run_git_capture_in(cwd: &Path, args: &[&str]) -> Option { + let output = std::process::Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout).ok() +} + +pub(crate) fn find_git_root_in(cwd: &Path) -> Result> { + let output = std::process::Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(cwd) + .output()?; + if !output.status.success() { + return Err("not a git repository".into()); + } + let path = String::from_utf8(output.stdout)?.trim().to_string(); + if path.is_empty() { + return Err("empty git root".into()); + } + Ok(PathBuf::from(path)) +} + +pub(crate) fn parse_git_status_metadata_for( + cwd: &Path, + status: Option<&str>, +) -> (Option, Option) { + let branch = resolve_git_branch_for(cwd).or_else(|| parse_git_status_branch(status)); + let project_root = find_git_root_in(cwd).ok(); + (project_root, branch) +} + +#[allow(clippy::too_many_lines)] +pub(crate) fn print_status_snapshot( + model: &str, + model_flag_raw: Option<&str>, + permission_mode: PermissionMode, + output_format: CliOutputFormat, + allowed_tools: Option<&AllowedToolSet>, +) -> Result<(), Box> { + let usage = StatusUsage { + message_count: 0, + turns: 0, + latest: TokenUsage::default(), + cumulative: TokenUsage::default(), + estimated_tokens: 0, + }; + let context = status_context(None)?; + // #148: resolve model provenance. If user passed --model, source is + // "flag" with the raw input preserved. Otherwise probe env -> config + // -> default and record the winning source. + let provenance = match model_flag_raw { + Some(raw) => ModelProvenance { + resolved: model.to_string(), + raw: Some(raw.to_string()), + source: ModelSource::Flag, + }, + None => ModelProvenance::from_env_or_config_or_default(model), + }; + match output_format { + CliOutputFormat::Text => println!( + "{}", + format_status_report( + &provenance.resolved, + usage, + permission_mode.as_str(), + &context, + Some(&provenance) + ) + ), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&status_json_value( + Some(&provenance.resolved), + usage, + permission_mode.as_str(), + &context, + Some(&provenance), + allowed_tools, + ))? + ), + } + Ok(()) +} + +pub(crate) fn status_json_value( + model: Option<&str>, + usage: StatusUsage, + permission_mode: &str, + context: &StatusContext, + // #148: optional provenance for `model` field. Surfaces `model_source` + // ("flag" | "env" | "config" | "default") and `model_raw` (user input + // before alias resolution, or null when source is "default"). Callers + // that don't have provenance (legacy resume paths) pass None, in which + // case both new fields are omitted. + provenance: Option<&ModelProvenance>, + allowed_tools: Option<&AllowedToolSet>, +) -> serde_json::Value { + // #143: top-level `status` marker so claws can distinguish + // a clean run from a degraded run (config parse failed but other fields + // are still populated). `config_load_error` carries the parse-error string + // when present; it's a string rather than a typed object in Phase 1 and + // will join the typed-error taxonomy in Phase 2 (ROADMAP §4.44). + let degraded = context.config_load_error.is_some(); + let model_source = provenance.map(|p| p.source.as_str()); + let model_raw = provenance.and_then(|p| p.raw.clone()); + let allowed_tool_entries = allowed_tools.map(|tools| tools.iter().cloned().collect::>()); + serde_json::json!({ + "kind": "status", + "status": if degraded { "degraded" } else { "ok" }, + "config_load_error": context.config_load_error, + "model": model, + "model_source": model_source, + "model_raw": model_raw, + "permission_mode": permission_mode, + "allowed_tools": { + "source": if allowed_tools.is_some() { "flag" } else { "default" }, + "restricted": allowed_tools.is_some(), + "entries": allowed_tool_entries, + }, + "usage": { + "messages": usage.message_count, + "turns": usage.turns, + "latest_total": usage.latest.total_tokens(), + "cumulative_input": usage.cumulative.input_tokens, + "cumulative_output": usage.cumulative.output_tokens, + "cumulative_total": usage.cumulative.total_tokens(), + "estimated_tokens": usage.estimated_tokens, + }, + "workspace": { + "cwd": context.cwd, + "project_root": context.project_root, + "git_branch": context.git_branch, + "git_state": context.git_summary.headline(), + "changed_files": context.git_summary.changed_files, + "staged_files": context.git_summary.staged_files, + "unstaged_files": context.git_summary.unstaged_files, + "untracked_files": context.git_summary.untracked_files, + "session": context.session_path.as_ref().map_or_else(|| "live-repl".to_string(), |path| path.display().to_string()), + "session_id": context.session_path.as_ref().and_then(|path| { + // Session files are named .jsonl directly under + // .claw/sessions/. Extract the stem (drop the .jsonl extension). + path.file_stem().map(|n| n.to_string_lossy().into_owned()) + }), + "session_lifecycle": context.session_lifecycle.json_value(), + "loaded_config_files": context.loaded_config_files, + "discovered_config_files": context.discovered_config_files, + "memory_file_count": context.memory_file_count, + }, + "sandbox": { + "enabled": context.sandbox_status.enabled, + "active": context.sandbox_status.active, + "supported": context.sandbox_status.supported, + "in_container": context.sandbox_status.in_container, + "requested_namespace": context.sandbox_status.requested.namespace_restrictions, + "active_namespace": context.sandbox_status.namespace_active, + "requested_network": context.sandbox_status.requested.network_isolation, + "active_network": context.sandbox_status.network_active, + "filesystem_mode": context.sandbox_status.filesystem_mode.as_str(), + "filesystem_active": context.sandbox_status.filesystem_active, + "allowed_mounts": context.sandbox_status.allowed_mounts, + "markers": context.sandbox_status.container_markers, + "fallback_reason": context.sandbox_status.fallback_reason, + } + }) +} + +pub(crate) fn status_context( + session_path: Option<&Path>, +) -> Result> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let discovered_config_files = loader.discover().len(); + // #143: degrade gracefully on config parse failure rather than hard-fail. + // `claw doctor` already does this; `claw status` now matches that contract + // so that one malformed `mcpServers.*` entry doesn't take down the whole + // health surface (workspace, git, model, permission, sandbox can still be + // reported independently). + let (loaded_config_files, sandbox_status, config_load_error) = match loader.load() { + Ok(runtime_config) => ( + runtime_config.loaded_entries().len(), + resolve_sandbox_status(runtime_config.sandbox(), &cwd), + None, + ), + Err(err) => ( + 0, + // Fall back to defaults for sandbox resolution so claws still see + // a populated sandbox section instead of a missing field. Defaults + // produce the same output as a runtime config with no sandbox + // overrides, which is the right degraded-mode shape: we cannot + // report what the user *intended*, only what is actually in effect. + resolve_sandbox_status(&runtime::SandboxConfig::default(), &cwd), + Some(err.to_string()), + ), + }; + let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?; + let (project_root, git_branch) = + parse_git_status_metadata(project_context.git_status.as_deref()); + let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref()); + let lifecycle = classify_session_lifecycle_for(&cwd); + Ok(StatusContext { + cwd, + session_path: session_path.map(Path::to_path_buf), + loaded_config_files, + discovered_config_files, + memory_file_count: project_context.instruction_files.len(), + project_root, + git_branch, + git_summary, + session_lifecycle: lifecycle, + sandbox_status, + config_load_error, + }) +} + +pub(crate) fn format_status_report( + model: &str, + usage: StatusUsage, + permission_mode: &str, + context: &StatusContext, + // #148: optional model provenance to surface in a `Model source` line. + // Callers without provenance (legacy resume paths) pass None and the + // source line is omitted for backward compat. + provenance: Option<&ModelProvenance>, +) -> String { + // #143: if config failed to parse, surface a degraded banner at the top + // of the text report so humans see the parse error before the body, while + // the body below still reports everything that could be resolved without + // config (workspace, git, sandbox defaults, etc.). + let status_line = if context.config_load_error.is_some() { + "Status (degraded)" + } else { + "Status" + }; + let mut blocks: Vec = Vec::new(); + if let Some(err) = context.config_load_error.as_deref() { + blocks.push(format!( + "Config load error\n Status fail\n Summary runtime config failed to load; reporting partial status\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun" + )); + } + // #148: render Model source line after Model, showing where the string + // came from (flag / env / config / default) and the raw input if any. + let model_source_line = provenance + .map(|p| match &p.raw { + Some(raw) if raw != model => { + format!("\n Model source {} (raw: {raw})", p.source.as_str()) + } + _ => format!("\n Model source {}", p.source.as_str()), + }) + .unwrap_or_default(); + blocks.extend([ + format!( + "{status_line} + Model {model}{model_source_line} + Permission mode {permission_mode} + Messages {} + Turns {} + Estimated tokens {}", + usage.message_count, usage.turns, usage.estimated_tokens, + ), + format!( + "Usage + Latest total {} + Cumulative input {} + Cumulative output {} + Cumulative total {}", + usage.latest.total_tokens(), + usage.cumulative.input_tokens, + usage.cumulative.output_tokens, + usage.cumulative.total_tokens(), + ), + format!( + "Workspace + Cwd {} + Project root {} + Git branch {} + Git state {} + Changed files {} + Staged {} + Unstaged {} + Untracked {} + Session {} + Lifecycle {} + Config files loaded {}/{} + Memory files {} + Suggested flow /status → /diff → /commit", + context.cwd.display(), + context + .project_root + .as_ref() + .map_or_else(|| "unknown".to_string(), |path| path.display().to_string()), + context.git_branch.as_deref().unwrap_or("unknown"), + context.git_summary.headline(), + context.git_summary.changed_files, + context.git_summary.staged_files, + context.git_summary.unstaged_files, + context.git_summary.untracked_files, + context.session_path.as_ref().map_or_else( + || "live-repl".to_string(), + |path| path.display().to_string() + ), + context.session_lifecycle.signal(), + context.loaded_config_files, + context.discovered_config_files, + context.memory_file_count, + ), + format_sandbox_report(&context.sandbox_status), + ]); + blocks.join("\n\n") +} + +pub(crate) fn format_sandbox_report(status: &runtime::SandboxStatus) -> String { + format!( + "Sandbox + Enabled {} + Active {} + Supported {} + In container {} + Requested ns {} + Active ns {} + Requested net {} + Active net {} + Filesystem mode {} + Filesystem active {} + Allowed mounts {} + Markers {} + Fallback reason {}", + status.enabled, + status.active, + status.supported, + status.in_container, + status.requested.namespace_restrictions, + status.namespace_active, + status.requested.network_isolation, + status.network_active, + status.filesystem_mode.as_str(), + status.filesystem_active, + if status.allowed_mounts.is_empty() { + "".to_string() + } else { + status.allowed_mounts.join(", ") + }, + if status.container_markers.is_empty() { + "".to_string() + } else { + status.container_markers.join(", ") + }, + status + .fallback_reason + .clone() + .unwrap_or_else(|| "".to_string()), + ) +} + +pub(crate) fn format_commit_preflight_report(branch: Option<&str>, summary: GitWorkspaceSummary) -> String { + format!( + "Commit + Result ready + Branch {} + Workspace {} + Changed files {} + Action create a git commit from the current workspace changes", + branch.unwrap_or("unknown"), + summary.headline(), + summary.changed_files, + ) +} + +pub(crate) fn format_commit_skipped_report() -> String { + "Commit + Result skipped + Reason no workspace changes + Action create a git commit from the current workspace changes + Next /status to inspect context · /diff to inspect repo changes" + .to_string() +} + +pub(crate) fn print_sandbox_status_snapshot( + output_format: CliOutputFormat, +) -> Result<(), Box> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader + .load() + .unwrap_or_else(|_| runtime::RuntimeConfig::empty()); + let status = resolve_sandbox_status(runtime_config.sandbox(), &cwd); + match output_format { + CliOutputFormat::Text => println!("{}", format_sandbox_report(&status)), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&sandbox_json_value(&status))? + ), + } + Ok(()) +} + +pub(crate) fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value { + serde_json::json!({ + "kind": "sandbox", + "enabled": status.enabled, + "active": status.active, + "supported": status.supported, + "in_container": status.in_container, + "requested_namespace": status.requested.namespace_restrictions, + "active_namespace": status.namespace_active, + "requested_network": status.requested.network_isolation, + "active_network": status.network_active, + "filesystem_mode": status.filesystem_mode.as_str(), + "filesystem_active": status.filesystem_active, + "allowed_mounts": status.allowed_mounts, + "markers": status.container_markers, + "fallback_reason": status.fallback_reason, + }) +} + diff --git a/rust/crates/rusty-claude-cli/src/tool_executor.rs b/rust/crates/rusty-claude-cli/src/tool_executor.rs new file mode 100644 index 00000000..c0e9103b --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/tool_executor.rs @@ -0,0 +1,219 @@ +use std::io; +use std::sync::{Arc, Mutex}; + +use api::{InputContentBlock, InputMessage, ToolResultContentBlock}; +use serde::Deserialize; +use serde_json; + +use runtime::{ + ContentBlock, ConversationMessage, MessageRole, ToolError, ToolExecutor, +}; +use tools::GlobalToolRegistry; + +use crate::constants::*; +use crate::format_tool_result; +use crate::render::TerminalRenderer; +use crate::RuntimeMcpState; + +#[derive(Deserialize)] +pub(crate) struct ToolSearchRequest { + pub(crate) query: String, + pub(crate) max_results: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct McpToolRequest { + #[serde(rename = "qualifiedName")] + pub(crate) qualified_name: Option, + pub(crate) tool: Option, + pub(crate) arguments: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ListMcpResourcesRequest { + pub(crate) server: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ReadMcpResourceRequest { + pub(crate) server: String, + pub(crate) uri: String, +} + +pub(crate) struct CliToolExecutor { + renderer: TerminalRenderer, + emit_output: bool, + allowed_tools: Option, + tool_registry: GlobalToolRegistry, + mcp_state: Option>>, +} + +impl CliToolExecutor { + pub(crate) fn new( + allowed_tools: Option, + emit_output: bool, + tool_registry: GlobalToolRegistry, + mcp_state: Option>>, + ) -> Self { + Self { + renderer: TerminalRenderer::new(), + emit_output, + allowed_tools, + tool_registry, + mcp_state, + } + } + + fn execute_search_tool(&self, value: serde_json::Value) -> Result { + let input: ToolSearchRequest = serde_json::from_value(value) + .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; + let (pending_mcp_servers, mcp_degraded) = + self.mcp_state.as_ref().map_or((None, None), |state| { + let state = state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + (state.pending_servers(), state.degraded_report()) + }); + serde_json::to_string_pretty(&self.tool_registry.search( + &input.query, + input.max_results.unwrap_or(5), + pending_mcp_servers, + mcp_degraded, + )) + .map_err(|error| ToolError::new(error.to_string())) + } + + fn execute_runtime_tool( + &self, + tool_name: &str, + value: serde_json::Value, + ) -> Result { + let Some(mcp_state) = &self.mcp_state else { + return Err(ToolError::new(format!( + "runtime tool `{tool_name}` is unavailable without configured MCP servers" + ))); + }; + let mut mcp_state = mcp_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + match tool_name { + "MCPTool" => { + let input: McpToolRequest = serde_json::from_value(value) + .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; + let qualified_name = input + .qualified_name + .or(input.tool) + .ok_or_else(|| ToolError::new("missing required field `qualifiedName`"))?; + mcp_state.call_tool(&qualified_name, input.arguments) + } + "ListMcpResourcesTool" => { + let input: ListMcpResourcesRequest = serde_json::from_value(value) + .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; + match input.server { + Some(server_name) => mcp_state.list_resources_for_server(&server_name), + None => mcp_state.list_resources_for_all_servers(), + } + } + "ReadMcpResourceTool" => { + let input: ReadMcpResourceRequest = serde_json::from_value(value) + .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; + mcp_state.read_resource(&input.server, &input.uri) + } + _ => mcp_state.call_tool(tool_name, Some(value)), + } + } +} + +impl ToolExecutor for CliToolExecutor { + fn execute(&mut self, tool_name: &str, input: &str) -> Result { + if self + .allowed_tools + .as_ref() + .is_some_and(|allowed| !allowed.contains(tool_name)) + { + return Err(ToolError::new(format!( + "tool `{tool_name}` is not enabled by the current --allowedTools setting" + ))); + } + let value = serde_json::from_str(input) + .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; + let result = if tool_name == "ToolSearch" { + self.execute_search_tool(value) + } else if self.tool_registry.has_runtime_tool(tool_name) { + self.execute_runtime_tool(tool_name, value) + } else { + self.tool_registry + .execute(tool_name, &value) + .map_err(ToolError::new) + }; + match result { + Ok(output) => { + if self.emit_output { + let markdown = format_tool_result(tool_name, &output, false); + self.renderer + .stream_markdown(&markdown, &mut io::stdout()) + .map_err(|error| ToolError::new(error.to_string()))?; + } + Ok(output) + } + Err(error) => { + if self.emit_output { + let markdown = format_tool_result(tool_name, &error.to_string(), true); + self.renderer + .stream_markdown(&markdown, &mut io::stdout()) + .map_err(|stream_error| ToolError::new(stream_error.to_string()))?; + } + Err(error) + } + } + } +} + +pub(crate) fn convert_messages(messages: &[ConversationMessage]) -> Vec { + messages + .iter() + .filter_map(|message| { + let role = match message.role { + MessageRole::System | MessageRole::User | MessageRole::Tool => "user", + MessageRole::Assistant => "assistant", + }; + let content = message + .blocks + .iter() + .map(|block| match block { + ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() }, + ContentBlock::Thinking { + thinking, + signature, + } => InputContentBlock::Thinking { + thinking: thinking.clone(), + signature: signature.clone(), + }, + ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse { + id: id.clone(), + name: name.clone(), + input: serde_json::from_str(input) + .unwrap_or_else(|_| serde_json::json!({ "raw": input })), + }, + ContentBlock::ToolResult { + tool_use_id, + output, + is_error, + .. + } => InputContentBlock::ToolResult { + tool_use_id: tool_use_id.clone(), + content: vec![ToolResultContentBlock::Text { + text: output.clone(), + }], + is_error: *is_error, + }, + }) + .collect::>(); + (!content.is_empty()).then(|| InputMessage { + role: role.to_string(), + content, + }) + }) + .collect() +} \ No newline at end of file