merge: resolve conflicts with origin/main (refactor-main-rs-split)
This commit is contained in:
commit
34aaba75ae
9
debug.sh
9
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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-05-26
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
## Context
|
||||
|
||||
`rusty-claude-cli/src/main.rs` (13,292 lines) contains the entire CLI application logic. It currently imports three sub-modules (init, input, render), but everything else lives in the single file. The file has grown organically as features were added, with no enforced module boundaries.
|
||||
|
||||
**Current module layout:**
|
||||
```
|
||||
src/
|
||||
├── init.rs (~200 lines) — workspace initialization
|
||||
├── input.rs (~200 lines) — rustyline REPL editor
|
||||
├── render.rs (~200 lines) — terminal rendering, markdown, spinner
|
||||
└── main.rs (~13,292 lines) — everything else
|
||||
```
|
||||
|
||||
**Internal structure of main.rs:**
|
||||
| Region | Lines | Content |
|
||||
|--------|-------|---------|
|
||||
| Imports, consts, type aliases | 1-200 | `#![allow]`, `mod`, `use`, `const`, `type` |
|
||||
| `ModelSource/Provenance` | 60-150 | Model resolution types |
|
||||
| `main()`, `run()`, helpers | 205-535 | Entry point + CLI action dispatch |
|
||||
| `CliAction`, `CliOutputFormat` | 506-645 | Action enum definitions |
|
||||
| `parse_args()` + 50 helpers | 645-1830 | Argument parsing and validation |
|
||||
| `Doctor` system | 1831-2529 | Health check infrastructure |
|
||||
| Status/Session functions | 2835-3700 | State snapshot and git parsing |
|
||||
| `SessionHandle`, `LiveCli`, `BuiltRuntime` structs | 3678-4180 | Core data structures |
|
||||
| `LiveCli` impl + `run_repl` | 4181-5550 | REPL loop and command dispatch |
|
||||
| Formatting helpers | 5550-6900 | Report rendering (config, export, diff, etc.) |
|
||||
| Runtime construction | 6906-7600 | `build_runtime`, plugin/MCP boot |
|
||||
| `AnthropicRuntimeClient` | 7497-7910 | API client wrapper |
|
||||
| Output event formatting | 7910-8730 | tool call/result formatting |
|
||||
| `CliToolExecutor` | 8731-8923 | Tool execution adapter |
|
||||
| `print_help_to` | 8923-9122 | Help text |
|
||||
| Tests | 9123-13291 | `#[cfg(test)] mod tests { ... }` |
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Split `main.rs` into ≤ 2000 lines by moving code into responsibility-focused modules
|
||||
- Every new file must document its abstraction boundary (via its name, public API surface, and module-level structure)
|
||||
- Zero behavior change — this is a pure move-and-rename refactoring
|
||||
- All existing tests pass without modification (except import path updates)
|
||||
- Each individual migration step compiles independently (no long-lived broken state)
|
||||
|
||||
**Non-Goals:**
|
||||
- No new features or capabilities
|
||||
- No architectural pattern changes (trait extraction, Command Handler pattern, etc.) — those belong in a future Phase 2
|
||||
- No changes to `init.rs`, `input.rs`, `render.rs` — existing modules are already well-factored
|
||||
- No Cargo dependency changes
|
||||
- No performance optimization
|
||||
- No public API changes (this is an internal binary crate, not a library)
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: Module granularity — fine-grained (one concern per file)
|
||||
|
||||
**Chosen:** ~17 new files, each with a single thematic responsibility.
|
||||
|
||||
**Rationale:**
|
||||
- Each file will be 150–1200 lines, well under the 2000-line limit
|
||||
- File names directly map to the concern they contain (e.g., `doctor.rs` → diagnostic functions)
|
||||
- Easier to navigate during development — you know exactly which file to open
|
||||
- The `claw-cli` sibling crate demonstrates this pattern (app.rs, args.rs, init.rs, input.rs, main.rs, render.rs)
|
||||
|
||||
**Alternatives considered:**
|
||||
- *Fewer, larger files* (e.g., 5-6 files at 2000 lines each) — rejected because it just postpones the problem and doesn't improve navigability
|
||||
- *Single `lib.rs` with sub-modules* — the crate currently has no `lib.rs` and is a standalone binary; adding one would be a larger structural change
|
||||
|
||||
### D2: impl LiveCli — concentrate in `live_cli.rs`, REPL commands as free functions in `repl_commands.rs`
|
||||
|
||||
**Chosen:** `LiveCli` struct definition + all core methods live in `live_cli.rs`. Command handler functions (`handle_repl_command`, `handle_session_command`, `set_model`, etc.) become free functions in `repl_commands.rs` that accept `&mut LiveCli`.
|
||||
|
||||
**Rationale:**
|
||||
- Rust allows `impl` blocks in multiple files, but this requires careful coordination and makes the struct's API surface hard to discover
|
||||
- Free functions with an explicit receiver are simpler to understand and maintain
|
||||
- No orphan rule violations — `repl_commands.rs` functions simply take `cli: &mut LiveCli` as their first parameter
|
||||
|
||||
**Implementation pattern:**
|
||||
```rust
|
||||
// repl_commands.rs
|
||||
pub(crate) fn handle_repl_command(
|
||||
cli: &mut LiveCli,
|
||||
command: SlashCommand,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> { ... }
|
||||
|
||||
pub(crate) fn set_model(
|
||||
cli: &mut LiveCli,
|
||||
model: Option<String>,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> { ... }
|
||||
```
|
||||
|
||||
### D3: Visibility strategy — `pub(crate)` for cross-module access, never `pub`
|
||||
|
||||
**Chosen:** All functions that need to be called from outside their module are `pub(crate)`. Nothing is `pub` (this is a binary crate, not a library).
|
||||
|
||||
**Rationale:**
|
||||
- `main.rs` is the crate root that re-exports or `use`s sub-modules
|
||||
- Test code (in `#[cfg(test)] mod tests`) accesses items via `use super::*` — making items `pub(crate)` is sufficient since tests are inline or in `tests/` directory
|
||||
- Keeps the crate's internal API surface explicit
|
||||
|
||||
### D4: Module dependency order — low-level first, then layered
|
||||
|
||||
**Chosen:** Build modules in strict dependency order (constants → model → args → ... → live_cli → repl_commands → main).
|
||||
|
||||
**Rationale:**
|
||||
- Avoids circular dependencies between modules
|
||||
- Each module only depends on modules that are already compiled
|
||||
- Follows the principle of "stable dependencies": lower-level modules change less frequently
|
||||
|
||||
**Dependency graph:**
|
||||
```
|
||||
layer 0: constants
|
||||
layer 1: model
|
||||
layer 2: permission, progress, doctor
|
||||
layer 3: args, session
|
||||
layer 4: status, export, config, diff
|
||||
layer 5: formatting
|
||||
layer 6: api_client, tool_executor
|
||||
layer 7: runtime_builder
|
||||
layer 8: live_cli
|
||||
layer 9: repl_commands
|
||||
layer 10: main (thin dispatch)
|
||||
```
|
||||
|
||||
### D5: Test strategy — inline tests stay with source
|
||||
|
||||
**Chosen:** The `#[cfg(test)] mod tests { ... }` block at the bottom of `main.rs` stays attached to `main.rs`. As functions migrate to new modules, their test cases are NOT migrated in Phase 1 — `main.rs` tests continue to import migrated functions via `use super::*` which can reach them through module paths.
|
||||
|
||||
**Rationale:**
|
||||
- Moving tests adds risk of breaking them and increases the scope of each step
|
||||
- After Phase 1 is complete and stable, Phase 1.5 can migrate tests to their respective source modules
|
||||
- Rust's module system allows `use crate::module_name::function_name` from within `main.rs` tests
|
||||
- This minimizes per-step risk
|
||||
|
||||
### D6: `main.rs` re-export strategy
|
||||
|
||||
**Chosen:** `main.rs` will declare `pub(crate) use module_name::*;` for key modules so existing code (especially tests) can reference items without full path qualification.
|
||||
|
||||
**Rationale:**
|
||||
- Minimizes churn in test code during the migration
|
||||
- Provides a single reference point for all public crate items
|
||||
- Can be cleaned up in a follow-up pass
|
||||
|
||||
## Module API Surface Summary
|
||||
|
||||
| Module | `pub(crate)` items | Dependencies |
|
||||
|--------|-------------------|--------------|
|
||||
| `constants.rs` | all `const`, `type` aliases | none |
|
||||
| `model.rs` | `ModelSource`, `ModelProvenance`, `resolve_model_alias*`, `resolve_repl_model`, `validate_model_syntax`, `config_model_for_current_dir`, `config_alias_for_current_dir`, `max_tokens_for_model` | `constants`, `api` (external) |
|
||||
| `permission.rs` | `CliPermissionPrompter`, `permission_policy` | `constants`, `runtime` (external) |
|
||||
| `progress.rs` | `InternalPromptProgressState/Event/Shared/Reporter/Run`, `CliHookProgressReporter`, `describe_tool_progress`, `format_internal_prompt_progress_line` | `constants` |
|
||||
| `doctor.rs` | `DiagnosticLevel`, `DiagnosticCheck`, `DoctorReport`, `render_doctor_report`, `run_doctor`, `check_*_health` (6 fns), `render_diagnostic_check` | `constants`, `runtime` (external) |
|
||||
| `args.rs` | `CliAction`, `CliOutputFormat`, `LocalHelpTopic`, `parse_args`, `parse_export_args`, `parse_resume_args`, `parse_system_prompt_args`, `parse_dump_manifests_args`, `parse_local_help_action`, `parse_direct_slash_cli_action`, `parse_acp_args`, `try_resolve_bare_skill_prompt`, `join_optional_args`, `format_unknown_*`, `render_suggestion_line`, `suggest_*`, `levenshtein_distance`, `common_prefix_len`, `normalize_allowed_tools`, `current_tool_registry`, `parse_permission_mode_arg`, `permission_mode_from_label/resolved/default`, `config_permission_mode_for_current_dir`, `provider_label`, `format_connected_line`, `filter_tool_specs`, `is_help_flag`, `parse_single_word_command_alias`, `bare_slash_command_guidance`, `removed_auth_surface_error`, `looks_like_subcommand_typo`, `ranked_suggestions`, `omc_compatibility_note_for_unknown_slash_command` | `constants`, `model`, `runtime`/`tools`/`api` (external) |
|
||||
| `session.rs` | `SessionHandle`, `ManagedSessionSummary`, `sessions_dir`, `current_session_store`, `new_cli_session`, `create_managed_session_handle`, `resolve_session_reference`, `resolve_managed_session_path`, `list_managed_sessions`, `latest_managed_session`, `load_session_reference`, `delete_managed_session`, `confirm_session_deletion`, `render_session_list`, `format_session_modified_age`, `write_session_clear_backup`, `session_clear_backup_path` | `constants`, `runtime` (external) |
|
||||
| `status.rs` | `StatusContext`, `StatusUsage`, `GitWorkspaceSummary`, `print_status_snapshot`, `status_json_value`, `status_context`, `format_status_report`, `format_sandbox_report`, `print_sandbox_status_snapshot`, `sandbox_json_value`, `parse_git_status_metadata`, `parse_git_status_branch`, `parse_git_workspace_summary`, `resolve_git_branch_for`, `run_git_capture_in`, `find_git_root_in`, `parse_git_status_metadata_for` | `constants`, `session` |
|
||||
| `formatting.rs` | `format_tool_call_start`, `format_tool_result`, `format_bash_call/result`, `format_read/write/edit/glob/grep_result`, `format_search_start`, `format_patch_preview`, `format_structured_patch_preview`, `format_generic_tool_result`, `summarize_tool_payload`, `truncate_for_summary`, `truncate_output_for_display`, `render_thinking_block_summary`, `push_output_block`, `response_to_events`, `push_prompt_cache_record`, `prompt_cache_record_to_runtime_event`, `final_assistant_text`, `collect_tool_uses`, `collect_tool_results`, `collect_prompt_cache_events`, `short_tool_id`, `extract_tool_path`, `first_visible_line`, `print_help_to`, `print_help`, `render_help_topic`, `print_help_topic` | `constants` |
|
||||
| `config.rs` | `render_config_report`, `render_config_json`, `render_memory_report`, `render_memory_json` | `runtime` (external) |
|
||||
| `diff.rs` | `render_diff_report`, `render_diff_report_for`, `render_diff_json_for`, `run_git_diff_command_in` | none (std + external) |
|
||||
| `export.rs` | `run_export`, `render_export_text`, `render_session_markdown`, `default_export_filename`, `resolve_export_path`, `summarize_tool_payload_for_markdown` | `session`, `status` |
|
||||
| `api_client.rs` | `AnthropicRuntimeClient`, `resolve_cli_auth_source`, `resolve_cli_auth_source_for_cwd`, `request_ends_with_tool_result`, `format_user_visible_api_error`, `format_context_window_blocked_error` | `progress`, `api` (external) |
|
||||
| `tool_executor.rs` | `CliToolExecutor`, `ToolSearchRequest`, `McpToolRequest`, `ListMcpResourcesRequest`, `ReadMcpResourceRequest`, `permission_policy`, `convert_messages` | `formatting`, `runtime`/`tools` (external) |
|
||||
| `runtime_builder.rs` | `RuntimePluginState`, `RuntimeMcpState`, `BuiltRuntime`, `HookAbortMonitor`, `build_runtime`, `build_runtime_with_plugin_state`, `build_runtime_plugin_state`, `build_runtime_plugin_state_with_loader`, `build_plugin_manager`, `resolve_plugin_path`, `runtime_hook_config_from_plugin_hooks`, `build_system_prompt`, `build_runtime_mcp_state`, `mcp_runtime_tool_definition`, `mcp_wrapper_tool_definitions`, `permission_mode_for_mcp_tool`, `mcp_annotation_flag` | `api_client`, `tool_executor`, `progress`, `permission`, `plugins` (external) |
|
||||
| `live_cli.rs` | `LiveCli`, `PromptHistoryEntry`, `run_repl`, `run_resume_command`, `resume_session`, `detect_broad_cwd`, `enforce_broad_cwd_policy`, `run_stale_base_preflight` | `runtime_builder`, `session`, `status`, `repl_commands` |
|
||||
| `repl_commands.rs` | `handle_repl_command`, `handle_session_command`, `handle_plugins_command`, `set_model`, `set_permissions`, `clear_session`, `compact`, `reload_runtime_features`, `print_status`, `print_cost`, `print_prompt_history`, `resume_session`, `export_session`, `print_*` (config/memory/agents/mcp/skills/plugins/diff/version/sandbox), `run_bughunter/ultraplan/teleport/debug_tool_call/commit/pr/issue`, `format_*` (bughunter/ultraplan/pr/issue/commit/auto_compaction/compact/unknown_slash_command_message/model/model_switch/permissions/permissions_switch/cost/resume/compact/history), `render_repl_help`, `render_resume_usage`, `render_teleport_report`, `render_last_tool_debug_report`, `render_prompt_history_report`, `render_version_report`, `collect_session_prompt_history`, `recent_user_context`, `parse_history_count`, `format_history_timestamp`, `civil_from_days`, `git_output`, `git_status_ok`, `command_exists`, `write_temp_text_file`, `sanitize_generated_message`, `parse_titled_body`, `indent_block`, `validate_no_args`, `init_claude_md`, `normalize_permission_mode`, `run_init`, `init_json_value`, `print_acp_status`, `run_worker_state`, `run_mcp_serve`, `print_bootstrap_plan`, `print_system_prompt`, `print_version`, `version_json_value`, `slash_command_completion_candidates_with_sessions`, `truncate_for_prompt` | `live_cli`, `status`, `config`, `diff`, `export`, `doctor`, `commands` (external) |
|
||||
|
||||
### `main.rs` (post-split) — what remains
|
||||
|
||||
**Items that stay in main.rs:**
|
||||
```rust
|
||||
mod init; mod input; mod render; // existing
|
||||
mod constants; mod model; mod permission; // new
|
||||
mod progress; mod doctor; mod args;
|
||||
mod session; mod status; mod formatting;
|
||||
mod config; mod diff; mod export;
|
||||
mod api_client; mod tool_executor;
|
||||
mod runtime_builder; mod live_cli;
|
||||
mod repl_commands;
|
||||
|
||||
// Re-exports for test compatibility
|
||||
pub(crate) use constants::*;
|
||||
pub(crate) use model::*;
|
||||
pub(crate) use args::*;
|
||||
// ... etc for all modules used by tests
|
||||
|
||||
fn main() { ... } // unchanged
|
||||
fn run() -> ... { ... } // only dispatches CliAction variants
|
||||
fn classify_error_kind() // stays — used in main()
|
||||
fn split_error_hint() // stays — used in main()
|
||||
fn read_piped_stdin() // stays — used in run()
|
||||
fn merge_prompt_with_stdin() // stays — used in run()
|
||||
fn write_mcp_server_fixture() // stays — test utility
|
||||
|
||||
// Test module stays here, unchanged, referencing functions via crate:: paths
|
||||
```
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|-----------|--------|------------|
|
||||
| **Missing a function during split** | Medium | High — broken build | Each step compiles independently; CI catches missing items |
|
||||
| **Circular dependency between modules** | Low | High — redesign needed | Strict dependency ordering (low-level → high-level) prevents this |
|
||||
| **Test breakage from visibility changes** | Medium | Medium — tests fail | Keep `pub(crate) use module::*` re-exports in main.rs; tests continue to work |
|
||||
| **Merge conflicts with parallel work** | High (active project) | Medium | Execute refactoring as a focused session; each step is a small diff |
|
||||
| **Accidental behavior change during move** | Low | High — user-facing bug | Pure copy-paste moves; no logic modification; review each diff carefully |
|
||||
| **File count increase reduces discoverability** | Medium | Low — developer adaptation | Consistent naming convention; module names map 1:1 to concerns |
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Should `STUB_COMMANDS` (80+ entries) stay in `constants.rs` or be in its own file?** — Currently planned for `constants.rs` (~80 lines of data), can be extracted later if it grows.
|
||||
2. **Should `write_mcp_server_fixture` be moved to the test support module?** — It's only used in tests. Keep in `main.rs` for now; it's a short function.
|
||||
3. **How to handle the `#[allow(...)]` attributes?** — The top-level `#![allow(...)]` applies to the crate root. Some module-level allows may need to be duplicated.
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
## Why
|
||||
|
||||
`rusty-claude-cli/src/main.rs` is 13,292 lines / 473KB — a single-file God Module containing all CLI logic except three thin modules (init, input, render). This makes it extremely difficult to:
|
||||
|
||||
- **Navigate**: finding a specific function requires scrolling through thousands of lines
|
||||
- **Reason about**: no module-level boundaries document what belongs where
|
||||
- **Test**: all functions share file-private visibility, forcing brittle integration tests
|
||||
- **Maintain**: any change risks unintended side effects across unrelated subsystems
|
||||
|
||||
The project rule requires any file ≤ 2000 lines. This change brings `main.rs` under that limit by splitting its contents into focused, responsibility-driven modules.
|
||||
|
||||
## What Changes
|
||||
|
||||
**Phase 1 — Pure vertical split (zero behavior change):**
|
||||
|
||||
Move functions, structs, and impl blocks from `main.rs` into 15+ new files by thematic responsibility. Each new file declares a clear boundary. `main.rs` retains only the entry point (`main`, `run`) and the `CliAction` enum dispatch. All other code moves to new modules with `pub(crate)` visibility where cross-module access is needed.
|
||||
|
||||
- **`constants.rs`** — all `const` definitions and type aliases
|
||||
- **`model.rs`** — `ModelSource`, `ModelProvenance`, model alias resolution
|
||||
- **`args.rs`** — `CliAction` enum, `CliOutputFormat`, `parse_args()` + 50+ parse helpers
|
||||
- **`doctor.rs`** — `DiagnosticLevel`, `DiagnosticCheck`, `DoctorReport`, health checks
|
||||
- **`session.rs`** — `SessionHandle`, `ManagedSessionSummary`, session CRUD
|
||||
- **`status.rs`** — `StatusContext`, `GitWorkspaceSummary`, status/sandbox snapshots
|
||||
- **`permission.rs`** — `CliPermissionPrompter`, `PermissionPrompter` impl
|
||||
- **`progress.rs`** — `InternalPromptProgressReporter/Run`, `HookProgressReporter` impl
|
||||
- **`api_client.rs`** — `AnthropicRuntimeClient`, `ApiClient` impl
|
||||
- **`tool_executor.rs`** — `CliToolExecutor`, `ToolExecutor` impl
|
||||
- **`runtime_builder.rs`** — `BuiltRuntime`, `RuntimePluginState`, `RuntimeMcpState`, build functions
|
||||
- **`formatting.rs`** — all `format_tool_*`, `push_output_block`, `response_to_events`, help text
|
||||
- **`config.rs`** — config and memory report rendering
|
||||
- **`diff.rs`** — git diff report rendering
|
||||
- **`export.rs`** — session export (text/markdown) functions
|
||||
- **`live_cli.rs`** — `LiveCli` struct + core methods (new, run_turn, persist_session, etc.)
|
||||
- **`repl_commands.rs`** — `LiveCli` command handlers (`handle_repl_command`, `/status`, `/model`, etc.) and all standalone command functions
|
||||
|
||||
Existing files (`init.rs`, `input.rs`, `render.rs`) remain unchanged.
|
||||
|
||||
**Phase 2 — Architectural refinement (optional, future):**
|
||||
|
||||
- Extract `CliApiClient` / `CliOutputPort` / `SessionStore` traits
|
||||
- Consider Command Handler pattern for REPL commands
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
This is a **pure refactoring** with no new user-facing capabilities. The module boundaries correspond to internal architectural slices:
|
||||
|
||||
- `cli-args-parsing`: CLI argument parsing, validation, and suggestion system
|
||||
- `cli-session-management`: session lifecycle (create, list, switch, delete, fork)
|
||||
- `cli-runtime-construction`: runtime, plugin, and MCP server bootstrapping
|
||||
- `cli-output-formatting`: all terminal output formatting (tool results, reports, help)
|
||||
- `cli-repl-interaction`: REPL loop and slash command dispatch
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
None. No spec-level behavior changes — this is purely a code organization refactor.
|
||||
|
||||
## Impact
|
||||
|
||||
| Area | Impact |
|
||||
|------|--------|
|
||||
| **`rusty-claude-cli/src/`** | 3 files → ~20 files. Existing modules (init, input, render) untouched. |
|
||||
| **`main.rs`** | 13,292 → ~800 lines (only main(), run(), CliAction, re-exports) |
|
||||
| **Visibility** | Many private `fn` become `pub(crate)` for cross-module access |
|
||||
| **Testing** | Existing tests remain valid; some `use super::*` imports need updating |
|
||||
| **Compilation** | No dependency changes at the Cargo workspace level |
|
||||
| **Behavior** | Zero. Pure move-and-rename refactoring. |
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: CLI argument parsing shall be isolated in `args.rs`
|
||||
|
||||
All CLI argument parsing logic SHALL be contained in a single `args.rs` module.
|
||||
This includes `parse_args()`, all `parse_*` helper functions, the `CliAction` enum,
|
||||
`CliOutputFormat`, `LocalHelpTopic`, and all suggestion/error-reporting helpers
|
||||
(Levenshtein distance, ranked suggestions, unknown-command formatting).
|
||||
|
||||
#### Scenario: All parse functions are in args.rs
|
||||
|
||||
- **WHEN** the refactoring is complete
|
||||
- **THEN** `args.rs` contains `parse_args`, `parse_export_args`, `parse_resume_args`,
|
||||
`parse_system_prompt_args`, `parse_dump_manifests_args`, `parse_local_help_action`,
|
||||
`parse_direct_slash_cli_action`, `parse_acp_args`, and all format_unknown_* /
|
||||
suggest_* / levenshtein_distance helpers
|
||||
|
||||
#### Scenario: args.rs does not contain runtime logic
|
||||
|
||||
- **WHEN** any function in `args.rs` is reviewed
|
||||
- **THEN** it SHALL only perform argument parsing and validation, never executing
|
||||
business logic (no session CRUD, no runtime construction, no API calls)
|
||||
|
||||
### Requirement: `args.rs` shall expose `pub(crate)` parse functions
|
||||
|
||||
Argument parsing functions SHALL be `pub(crate)` so they are accessible from
|
||||
`main.rs` for dispatch but not from outside the crate.
|
||||
|
||||
#### Scenario: main.rs can call parse_args
|
||||
|
||||
- **WHEN** `main.rs` calls `args::parse_args(&args)`
|
||||
- **THEN** it returns a `Result<CliAction, String>`
|
||||
- **THEN** no other crate module can call parse functions (no `pub` visibility)
|
||||
|
||||
### Requirement: Suggestion system shall remain in args.rs
|
||||
|
||||
All fuzzy-matching and suggestion utilities (`suggest_slash_commands`,
|
||||
`suggest_closest_term`, `ranked_suggestions`, `levenshtein_distance`,
|
||||
`common_prefix_len`) SHALL reside in `args.rs`.
|
||||
|
||||
#### Scenario: Typo suggestions work after refactoring
|
||||
|
||||
- **WHEN** user types an unknown command
|
||||
- **THEN** `format_unknown_slash_command` produces a suggestion with the same
|
||||
formatting as before refactoring
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Tool result formatting shall be in `formatting.rs`
|
||||
|
||||
All `format_tool_*` functions, `response_to_events`, `push_output_block`,
|
||||
`push_prompt_cache_record`, and related helpers SHALL be in `formatting.rs`.
|
||||
|
||||
#### Scenario: format_tool_call_start is in formatting.rs
|
||||
|
||||
- **WHEN** the refactoring is complete
|
||||
- **THEN** `formatting.rs` contains `format_tool_call_start`, `format_tool_result`,
|
||||
`format_bash_call`, `format_bash_result`, `format_read_result`,
|
||||
`format_write_result`, `format_edit_result`, `format_glob_result`,
|
||||
`format_grep_result`, `format_generic_tool_result`, `format_search_start`,
|
||||
`format_patch_preview`, `format_structured_patch_preview`
|
||||
|
||||
#### Scenario: stream helpers are in formatting.rs
|
||||
|
||||
- **WHEN** the refactoring is complete
|
||||
- **THEN** `formatting.rs` contains `push_output_block`, `response_to_events`,
|
||||
`push_prompt_cache_record`, `prompt_cache_record_to_runtime_event`,
|
||||
`final_assistant_text`, `collect_tool_uses`, `collect_tool_results`,
|
||||
`collect_prompt_cache_events`, `short_tool_id`
|
||||
|
||||
### Requirement: Help text rendering shall be in `formatting.rs`
|
||||
|
||||
`print_help_to`, `print_help`, `render_help_topic`, and `print_help_topic`
|
||||
SHALL be in `formatting.rs`.
|
||||
|
||||
#### Scenario: print_help_to is in formatting.rs
|
||||
|
||||
- **WHEN** the refactoring is complete
|
||||
- **THEN** `formatting.rs` contains `print_help_to` and the full help text output
|
||||
|
||||
### Requirement: Config, diff, and export rendering shall be in dedicated modules
|
||||
|
||||
Config report rendering SHALL be in `config.rs`. Git diff rendering SHALL be
|
||||
in `diff.rs`. Session export rendering SHALL be in `export.rs`.
|
||||
|
||||
#### Scenario: config.rs exists
|
||||
|
||||
- **WHEN** the refactoring is complete
|
||||
- **THEN** `config.rs` contains `render_config_report`, `render_config_json`,
|
||||
`render_memory_report`, `render_memory_json`
|
||||
|
||||
#### Scenario: diff.rs exists
|
||||
|
||||
- **WHEN** the refactoring is complete
|
||||
- **THEN** `diff.rs` contains `render_diff_report`, `render_diff_report_for`,
|
||||
`render_diff_json_for`, `run_git_diff_command_in`
|
||||
|
||||
#### Scenario: export.rs exists
|
||||
|
||||
- **WHEN** the refactoring is complete
|
||||
- **THEN** `export.rs` contains `run_export`, `render_export_text`,
|
||||
`render_session_markdown`, `default_export_filename`, `resolve_export_path`,
|
||||
`summarize_tool_payload_for_markdown`
|
||||
|
||||
### Requirement: Internal progress reporting shall be in `progress.rs`
|
||||
|
||||
`InternalPromptProgressState`, `InternalPromptProgressEvent`,
|
||||
`InternalPromptProgressShared`, `InternalPromptProgressReporter`,
|
||||
`InternalPromptProgressRun`, `CliHookProgressReporter`,
|
||||
`format_internal_prompt_progress_line`, and `describe_tool_progress`
|
||||
SHALL be in `progress.rs`.
|
||||
|
||||
#### Scenario: progress.rs exists
|
||||
|
||||
- **WHEN** the refactoring is complete
|
||||
- **THEN** `progress.rs` contains all internal progress reporting types and impls
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: LiveCli core methods shall be in `live_cli.rs`
|
||||
|
||||
The `LiveCli` struct definition and its core lifecycle methods SHALL be in
|
||||
`live_cli.rs`. Core methods include `new`, `startup_banner`, `prepare_turn_runtime`,
|
||||
`replace_runtime`, `run_turn`, `run_turn_with_output`, `run_prompt_compact`,
|
||||
`run_prompt_compact_json`, `run_prompt_json`, `run_internal_prompt_text_with_progress`,
|
||||
`run_internal_prompt_text`, `persist_session`, `record_prompt_history`,
|
||||
`repl_completion_candidates`, and `reload_runtime_features`.
|
||||
|
||||
#### Scenario: live_cli.rs contains LiveCli struct
|
||||
|
||||
- **WHEN** the refactoring is complete
|
||||
- **THEN** `live_cli.rs` defines `LiveCli` struct and contains all core lifecycle
|
||||
methods listed above
|
||||
|
||||
#### Scenario: run_repl is in live_cli.rs
|
||||
|
||||
- **WHEN** the refactoring is complete
|
||||
- **THEN** `live_cli.rs` contains `run_repl`, `run_resume_command`,
|
||||
and `resume_session` as free functions
|
||||
|
||||
### Requirement: REPL command handlers shall be free functions in `repl_commands.rs`
|
||||
|
||||
All `handle_repl_command`, `handle_session_command`, `handle_plugins_command`,
|
||||
`set_model`, `set_permissions`, `clear_session`, `compact`, `print_*` methods,
|
||||
`run_bughunter`, `run_ultraplan`, `run_teleport`, `run_debug_tool_call`,
|
||||
`run_commit`, `run_pr`, `run_issue`, `export_session`, and all format_*_report
|
||||
helpers SHALL be free functions in `repl_commands.rs` that accept
|
||||
`&mut LiveCli` or `&LiveCli` as their first parameter.
|
||||
|
||||
#### Scenario: handle_repl_command is a free function
|
||||
|
||||
- **WHEN** the refactoring is complete
|
||||
- **THEN** `repl_commands.rs` defines `pub(crate) fn handle_repl_command(
|
||||
cli: &mut LiveCli, command: SlashCommand) -> Result<bool, Box<dyn std::error::Error>>`
|
||||
|
||||
#### Scenario: All /slash commands are handled in repl_commands.rs
|
||||
|
||||
- **WHEN** user types `/status`, `/model`, `/session`, `/config`, `/diff`
|
||||
- **THEN** the dispatch function in `repl_commands.rs` handles it via a
|
||||
free function that takes `cli: &mut LiveCli`
|
||||
|
||||
### Requirement: REPL command dispatch shall not contain runtime construction
|
||||
|
||||
No REPL command handler SHALL directly call `build_runtime`, `ConversationRuntime::new`,
|
||||
or `ApiProviderClient` constructors. Any runtime replacement SHALL go through
|
||||
`LiveCli::replace_runtime`.
|
||||
|
||||
#### Scenario: Runtime is only replaced through LiveCli::replace_runtime
|
||||
|
||||
- **WHEN** a REPL command changes model or permissions
|
||||
- **THEN** it calls `cli.replace_runtime(build_runtime(...))` from `runtime_builder.rs`
|
||||
- **THEN** it does NOT directly construct `ConversationRuntime` or manage plugin
|
||||
lifecycle
|
||||
|
||||
### Requirement: Doctor diagnostics shall be in `doctor.rs`
|
||||
|
||||
`DiagnosticLevel`, `DiagnosticCheck`, `DoctorReport`, `run_doctor`, all
|
||||
`check_*_health` functions, and `render_diagnostic_check` SHALL be in `doctor.rs`.
|
||||
|
||||
#### Scenario: doctor.rs exists
|
||||
|
||||
- **WHEN** the refactoring is complete
|
||||
- **THEN** `doctor.rs` contains the full diagnostic check system
|
||||
|
||||
### Requirement: Model resolution shall be in `model.rs`
|
||||
|
||||
`ModelSource`, `ModelProvenance`, `resolve_model_alias`,
|
||||
`resolve_model_alias_with_config`, `resolve_repl_model`, `validate_model_syntax`,
|
||||
`config_model_for_current_dir`, `config_alias_for_current_dir`, and
|
||||
`max_tokens_for_model` SHALL be in `model.rs`.
|
||||
|
||||
#### Scenario: model.rs exists
|
||||
|
||||
- **WHEN** the refactoring is complete
|
||||
- **THEN** `model.rs` contains all model-related types and functions
|
||||
|
||||
### Requirement: Status snapshot shall be in `status.rs`
|
||||
|
||||
`StatusContext`, `StatusUsage`, `GitWorkspaceSummary`, `print_status_snapshot`,
|
||||
`status_json_value`, `status_context`, `format_status_report`,
|
||||
`format_sandbox_report`, `print_sandbox_status_snapshot`, `sandbox_json_value`,
|
||||
and all git status parsing helpers SHALL be in `status.rs`.
|
||||
|
||||
#### Scenario: status.rs exists
|
||||
|
||||
- **WHEN** the refactoring is complete
|
||||
- **THEN** `status.rs` contains all status and git parsing functions
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Runtime construction shall be isolated in `runtime_builder.rs`
|
||||
|
||||
All runtime bootstrapping logic (building ConversationRuntime, plugins, MCP state)
|
||||
SHALL be in `runtime_builder.rs`. This includes `BuiltRuntime`, `RuntimePluginState`,
|
||||
`RuntimeMcpState`, `build_runtime`, `build_runtime_with_plugin_state`, all plugin
|
||||
construction helpers, and `HookAbortMonitor`.
|
||||
|
||||
#### Scenario: All build_runtime variants are in runtime_builder.rs
|
||||
|
||||
- **WHEN** the refactoring is complete
|
||||
- **THEN** `runtime_builder.rs` contains `build_runtime`,
|
||||
`build_runtime_with_plugin_state`, `build_runtime_plugin_state`,
|
||||
`build_runtime_plugin_state_with_loader`, `build_plugin_manager`,
|
||||
`build_system_prompt`, `build_runtime_mcp_state`
|
||||
|
||||
#### Scenario: runtime_builder.rs imports from api_client and tool_executor
|
||||
|
||||
- **WHEN** `runtime_builder.rs` constructs a `ConversationRuntime`
|
||||
- **THEN** it uses `AnthropicRuntimeClient` from `api_client.rs` and
|
||||
`CliToolExecutor` from `tool_executor.rs`
|
||||
|
||||
### Requirement: BuiltRuntime shall be the single runtime lifecycle manager
|
||||
|
||||
`BuiltRuntime` SHALL own the `ConversationRuntime` option, plugin registry, and
|
||||
MCP state. Its `Drop` implementation SHALL shut down plugins and MCP servers.
|
||||
|
||||
#### Scenario: BuiltRuntime::drop cleans up
|
||||
|
||||
- **WHEN** a `BuiltRuntime` is dropped
|
||||
- **THEN** `shutdown_mcp()` and `shutdown_plugins()` are called
|
||||
- **THEN** no plugin or MCP process is left running
|
||||
|
||||
### Requirement: AnthropicRuntimeClient shall be in `api_client.rs`
|
||||
|
||||
The API client adapter (`AnthropicRuntimeClient`) and its `ApiClient` trait impl
|
||||
SHALL be in `api_client.rs`. Provider detection, auth resolution, and prompt
|
||||
cache setup SHALL be contained in this module.
|
||||
|
||||
#### Scenario: api_client.rs contains AnthropicRuntimeClient
|
||||
|
||||
- **WHEN** the refactoring is complete
|
||||
- **THEN** `api_client.rs` contains `AnthropicRuntimeClient` struct, its `new()`,
|
||||
its `ApiClient` trait implementation, and auth resolution helpers
|
||||
|
||||
### Requirement: CliToolExecutor shall be in `tool_executor.rs`
|
||||
|
||||
The tool execution adapter (`CliToolExecutor`) and its `ToolExecutor` trait impl
|
||||
SHALL be in `tool_executor.rs`. This includes all MCP tool call helpers and the
|
||||
`permission_policy` / `convert_messages` functions.
|
||||
|
||||
#### Scenario: tool_executor.rs contains CliToolExecutor
|
||||
|
||||
- **WHEN** the refactoring is complete
|
||||
- **THEN** `tool_executor.rs` contains `CliToolExecutor`, `ToolSearchRequest`,
|
||||
`McpToolRequest`, `ListMcpResourcesRequest`, `ReadMcpResourceRequest`,
|
||||
and the `ToolExecutor` trait implementation
|
||||
|
||||
### Requirement: Permission prompting shall be in `permission.rs`
|
||||
|
||||
`CliPermissionPrompter` and its `PermissionPrompter` trait impl SHALL be in
|
||||
`permission.rs`.
|
||||
|
||||
#### Scenario: permission.rs contains permission prompt logic
|
||||
|
||||
- **WHEN** the refactoring is complete
|
||||
- **THEN** `permission.rs` contains `CliPermissionPrompter`, its `new()` and
|
||||
`decide()` methods, and `permission_policy` helper
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Session management shall be isolated in `session.rs`
|
||||
|
||||
All session lifecycle operations (create, resolve, list, delete, search) SHALL
|
||||
be contained in a single `session.rs` module. This includes `SessionHandle`,
|
||||
`ManagedSessionSummary`, all session CRUD functions, and session rendering helpers.
|
||||
|
||||
#### Scenario: All session CRUD is in session.rs
|
||||
|
||||
- **WHEN** the refactoring is complete
|
||||
- **THEN** `session.rs` contains `sessions_dir`, `current_session_store`,
|
||||
`new_cli_session`, `create_managed_session_handle`, `resolve_session_reference`,
|
||||
`resolve_managed_session_path`, `list_managed_sessions`, `latest_managed_session`,
|
||||
`load_session_reference`, `delete_managed_session`, `confirm_session_deletion`,
|
||||
`render_session_list`, `format_session_modified_age`,
|
||||
`write_session_clear_backup`, `session_clear_backup_path`
|
||||
|
||||
#### Scenario: session.rs does not contain runtime construction
|
||||
|
||||
- **WHEN** any function in `session.rs` is reviewed
|
||||
- **THEN** it SHALL NOT call `build_runtime` or `ConversationRuntime::new`
|
||||
— session functions only read/write session files, not construct runtimes
|
||||
|
||||
### Requirement: Session types shall be `pub(crate)`
|
||||
|
||||
`SessionHandle` and `ManagedSessionSummary` SHALL be `pub(crate)` structs
|
||||
accessible from `live_cli.rs`, `status.rs`, and `export.rs`.
|
||||
|
||||
#### Scenario: LiveCli uses SessionHandle from session.rs
|
||||
|
||||
- **WHEN** `live_cli.rs` constructs a `SessionHandle`
|
||||
- **THEN** it imports it from `crate::session::SessionHandle`
|
||||
- **THEN** the struct fields are accessible within the crate
|
||||
|
||||
### Requirement: Session store path resolution shall be centralized
|
||||
|
||||
`sessions_dir()` and `current_session_store()` SHALL be the single source of
|
||||
truth for where session files are stored on disk.
|
||||
|
||||
#### Scenario: All session file paths go through session.rs
|
||||
|
||||
- **WHEN** any module needs to know the session directory path
|
||||
- **THEN** it calls `sessions_dir()` from `session.rs`
|
||||
- **THEN** no other module duplicates path resolution logic
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
## 1. Create constants.rs — extract all const and type aliases
|
||||
|
||||
- [x] 1.1 Create `src/constants.rs` with all `const` definitions (DEFAULT_MODEL, VERSION, STUB_COMMANDS, etc.) and type aliases (AllowedToolSet, RuntimePluginStateBuildOutput) extracted from `main.rs` lines 62-200
|
||||
- [x] 1.2 Add `mod constants;` to `main.rs`; keep original items as `pub(crate) use constants::*;` re-exports so existing code continues to compile
|
||||
- [x] 1.3 Run `cargo build` and `cargo test` — must pass with zero errors
|
||||
|
||||
## 2. Create model.rs — extract model resolution types and functions
|
||||
|
||||
- [x] 2.1 Create `src/model.rs` with ModelSource enum, ModelProvenance struct + impl, and all model-related functions: `resolve_model_alias`, `resolve_model_alias_with_config`, `resolve_repl_model`, `validate_model_syntax`, `config_model_for_current_dir`, `config_alias_for_current_dir`, `max_tokens_for_model`
|
||||
- [x] 2.2 Add `mod model;` to `main.rs` and re-export via `pub(crate) use model::*;`
|
||||
- [x] 2.3 Remove original definitions from `main.rs`; add necessary imports to model.rs
|
||||
- [x] 2.4 Run `cargo build` and `cargo test` — must pass
|
||||
|
||||
## 3. Create permission.rs — extract permission prompt logic
|
||||
|
||||
- [x] 3.1 Create `src/permission.rs` with CliPermissionPrompter struct, its impl, PermissionPrompter trait impl, and `permission_policy` function (main.rs lines 7442-7508, 8861-8872)
|
||||
- [x] 3.2 Add `mod permission;` to `main.rs` and re-export
|
||||
- [x] 3.3 Run `cargo build` and `cargo test` — must pass
|
||||
|
||||
## 4. Create progress.rs — extract internal progress reporting
|
||||
|
||||
- [x] 4.1 Create `src/progress.rs` with InternalPromptProgressState/Event/Shared/Reporter/Run structs+impls, CliHookProgressReporter, `format_internal_prompt_progress_line`, and `describe_tool_progress` (main.rs lines 7000-7420)
|
||||
- [x] 4.2 Add `mod progress;` to `main.rs` and re-export
|
||||
- [x] 4.3 Run `cargo build` and `cargo test` — must pass
|
||||
|
||||
## 5. Create doctor.rs — extract diagnostic check system
|
||||
|
||||
- [x] 5.1 Create `src/doctor.rs` with DiagnosticLevel, DiagnosticCheck, DoctorReport, `render_doctor_report`, `run_doctor`, and all `check_*_health` functions (main.rs lines 1831-2525)
|
||||
- [x] 5.2 Add `mod doctor;` to `main.rs` and re-export
|
||||
- [x] 5.3 Run `cargo build` and `cargo test` — must pass
|
||||
|
||||
## 6. Create args.rs — extract all argument parsing
|
||||
|
||||
- [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
|
||||
|
|
@ -1,20 +1,23 @@
|
|||
schema: spec-driven
|
||||
|
||||
# Project context (optional)
|
||||
# This is shown to AI when creating artifacts.
|
||||
# Add your tech stack, conventions, style guides, domain knowledge, etc.
|
||||
# Example:
|
||||
# context: |
|
||||
# Tech stack: TypeScript, React, Node.js
|
||||
# We use conventional commits
|
||||
# Domain: e-commerce platform
|
||||
context: |
|
||||
Rust CLI application using crossterm, rustyline, tokio, serde, pulldown-cmark, syntect.
|
||||
Workspace layout: crates/* (Cargo workspace).
|
||||
Target crate: rusty-claude-cli (binary "claw").
|
||||
The main.rs is 13,292 lines — a God File that needs to be split into focused modules.
|
||||
User rule: any function ≤ 60 lines, any file ≤ 2000 lines (new files), file structure should document abstraction boundaries.
|
||||
|
||||
# Per-artifact rules (optional)
|
||||
# Add custom rules for specific artifacts.
|
||||
# Example:
|
||||
# rules:
|
||||
# proposal:
|
||||
# - Keep proposals under 500 words
|
||||
# - Always include a "Non-goals" section
|
||||
# tasks:
|
||||
# - Break tasks into chunks of max 2 hours
|
||||
rules:
|
||||
proposal:
|
||||
- Propose splitting strategy with clear dependency order
|
||||
- Reference specific line ranges and function names from main.rs
|
||||
- Include risk assessment per phase
|
||||
design:
|
||||
- Design module boundaries with file-level responsibility descriptions
|
||||
- Define public API surface for each new module
|
||||
- Address cross-cutting concerns: testing, visibility, re-exports
|
||||
tasks:
|
||||
- One task per file creation/migration step
|
||||
- Each task must include "compiles and tests pass" as completion criteria
|
||||
- Order tasks by dependency: low-level modules first, then higher-level ones
|
||||
- Total execution: ~18 discrete tasks, each verifiable
|
||||
|
|
|
|||
|
|
@ -0,0 +1,404 @@
|
|||
use std::io::{self, Write};
|
||||
|
||||
use api::{
|
||||
detect_provider_kind, resolve_startup_auth_source, AnthropicClient, AuthSource,
|
||||
ContentBlockDelta, MessageRequest, PromptCache, ProviderClient as ApiProviderClient,
|
||||
ProviderKind, StreamEvent as ApiStreamEvent, ToolChoice,
|
||||
};
|
||||
use crate::render::{MarkdownStreamState, TerminalRenderer};
|
||||
use runtime::{ApiClient, ApiRequest, AssistantEvent, MessageRole, RuntimeError};
|
||||
use tools::GlobalToolRegistry;
|
||||
|
||||
use crate::constants::*;
|
||||
use crate::model::max_tokens_for_model;
|
||||
use crate::{
|
||||
format_tool_call_start, push_output_block,
|
||||
render_thinking_block_summary,
|
||||
};
|
||||
|
||||
use crate::InternalPromptProgressReporter;
|
||||
|
||||
// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct
|
||||
// now holds an `ApiProviderClient` which dispatches to Anthropic, xAI,
|
||||
// OpenAI, or DashScope at construction time based on
|
||||
// `detect_provider_kind(&model)`. The struct name is kept to avoid
|
||||
// churning `BuiltRuntime` and every Deref/DerefMut site that references
|
||||
// it. See ROADMAP #29 for the provider-dispatch routing fix.
|
||||
pub(crate) struct AnthropicRuntimeClient {
|
||||
runtime: tokio::runtime::Runtime,
|
||||
client: ApiProviderClient,
|
||||
session_id: String,
|
||||
model: String,
|
||||
enable_tools: bool,
|
||||
emit_output: bool,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
tool_registry: GlobalToolRegistry,
|
||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||
reasoning_effort: Option<String>,
|
||||
}
|
||||
|
||||
impl AnthropicRuntimeClient {
|
||||
pub(crate) fn new(
|
||||
session_id: &str,
|
||||
model: String,
|
||||
enable_tools: bool,
|
||||
emit_output: bool,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
tool_registry: GlobalToolRegistry,
|
||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let resolved_model = api::resolve_model_alias(&model);
|
||||
let client = match detect_provider_kind(&resolved_model) {
|
||||
ProviderKind::Anthropic => {
|
||||
let auth = resolve_cli_auth_source()?;
|
||||
let inner = AnthropicClient::from_auth(auth)
|
||||
.with_base_url(api::read_base_url())
|
||||
.with_prompt_cache(PromptCache::new(session_id));
|
||||
ApiProviderClient::Anthropic(inner)
|
||||
}
|
||||
ProviderKind::Xai | ProviderKind::OpenAi => {
|
||||
ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)?
|
||||
}
|
||||
};
|
||||
Ok(Self {
|
||||
runtime: tokio::runtime::Runtime::new()?,
|
||||
client,
|
||||
session_id: session_id.to_string(),
|
||||
model,
|
||||
enable_tools,
|
||||
emit_output,
|
||||
allowed_tools,
|
||||
tool_registry,
|
||||
progress_reporter,
|
||||
reasoning_effort: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn set_reasoning_effort(&mut self, effort: Option<String>) {
|
||||
self.reasoning_effort = effort;
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiClient for AnthropicRuntimeClient {
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||
if let Some(progress_reporter) = &self.progress_reporter {
|
||||
progress_reporter.mark_model_phase();
|
||||
}
|
||||
let is_post_tool = request_ends_with_tool_result(&request);
|
||||
let message_request = MessageRequest {
|
||||
model: self.model.clone(),
|
||||
max_tokens: max_tokens_for_model(&self.model),
|
||||
messages: crate::convert_messages(&request.messages),
|
||||
system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")),
|
||||
tools: self
|
||||
.enable_tools
|
||||
.then(|| crate::filter_tool_specs(&self.tool_registry, self.allowed_tools.as_ref())),
|
||||
tool_choice: self.enable_tools.then_some(ToolChoice::Auto),
|
||||
stream: true,
|
||||
reasoning_effort: self.reasoning_effort.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
self.runtime.block_on(async {
|
||||
let max_attempts: usize = if is_post_tool { 2 } else { 1 };
|
||||
|
||||
for attempt in 1..=max_attempts {
|
||||
let result = self
|
||||
.consume_stream(&message_request, is_post_tool && attempt == 1)
|
||||
.await;
|
||||
match result {
|
||||
Ok(events) => return Ok(events),
|
||||
Err(error)
|
||||
if error.to_string().contains("post-tool stall")
|
||||
&& attempt < max_attempts =>
|
||||
{
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
Err(RuntimeError::new("post-tool continuation nudge exhausted"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl AnthropicRuntimeClient {
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn consume_stream(
|
||||
&self,
|
||||
message_request: &MessageRequest,
|
||||
apply_stall_timeout: bool,
|
||||
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||
let mut stream = self
|
||||
.client
|
||||
.stream_message(message_request)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
|
||||
})?;
|
||||
let mut stdout = io::stdout();
|
||||
let mut sink = io::sink();
|
||||
let out: &mut dyn Write = if self.emit_output {
|
||||
&mut stdout
|
||||
} else {
|
||||
&mut sink
|
||||
};
|
||||
let renderer = TerminalRenderer::new();
|
||||
let mut markdown_stream = MarkdownStreamState::default();
|
||||
let mut events = Vec::new();
|
||||
let mut pending_tool: Option<(String, String, String)> = None;
|
||||
let mut block_has_thinking_summary = false;
|
||||
let mut saw_stop = false;
|
||||
let mut received_any_event = false;
|
||||
|
||||
loop {
|
||||
let next = if apply_stall_timeout && !received_any_event {
|
||||
match tokio::time::timeout(POST_TOOL_STALL_TIMEOUT, stream.next_event()).await {
|
||||
Ok(inner) => inner.map_err(|error| {
|
||||
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
|
||||
})?,
|
||||
Err(_elapsed) => {
|
||||
return Err(RuntimeError::new(
|
||||
"post-tool stall: model did not respond within timeout",
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stream.next_event().await.map_err(|error| {
|
||||
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
|
||||
})?
|
||||
};
|
||||
|
||||
let Some(event) = next else {
|
||||
break;
|
||||
};
|
||||
received_any_event = true;
|
||||
|
||||
match event {
|
||||
ApiStreamEvent::MessageStart(start) => {
|
||||
for block in start.message.content {
|
||||
push_output_block(
|
||||
block,
|
||||
out,
|
||||
&mut events,
|
||||
&mut pending_tool,
|
||||
true,
|
||||
&mut block_has_thinking_summary,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
ApiStreamEvent::ContentBlockStart(start) => {
|
||||
push_output_block(
|
||||
start.content_block,
|
||||
out,
|
||||
&mut events,
|
||||
&mut pending_tool,
|
||||
true,
|
||||
&mut block_has_thinking_summary,
|
||||
)?;
|
||||
}
|
||||
ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
|
||||
ContentBlockDelta::TextDelta { text } => {
|
||||
if !text.is_empty() {
|
||||
if let Some(progress_reporter) = &self.progress_reporter {
|
||||
progress_reporter.mark_text_phase(&text);
|
||||
}
|
||||
if let Some(rendered) = markdown_stream.push(&renderer, &text) {
|
||||
write!(out, "{rendered}")
|
||||
.and_then(|()| out.flush())
|
||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||
}
|
||||
events.push(AssistantEvent::TextDelta(text));
|
||||
}
|
||||
}
|
||||
ContentBlockDelta::InputJsonDelta { partial_json } => {
|
||||
if let Some((_, _, input)) = &mut pending_tool {
|
||||
input.push_str(&partial_json);
|
||||
}
|
||||
}
|
||||
ContentBlockDelta::ThinkingDelta { thinking } => {
|
||||
if !block_has_thinking_summary {
|
||||
render_thinking_block_summary(out, None, false)?;
|
||||
block_has_thinking_summary = true;
|
||||
}
|
||||
events.push(AssistantEvent::ThinkingDelta(thinking));
|
||||
}
|
||||
ContentBlockDelta::SignatureDelta { signature } => {
|
||||
events.push(AssistantEvent::SignatureDelta(signature));
|
||||
}
|
||||
},
|
||||
ApiStreamEvent::ContentBlockStop(_) => {
|
||||
block_has_thinking_summary = false;
|
||||
if let Some(rendered) = markdown_stream.flush(&renderer) {
|
||||
write!(out, "{rendered}")
|
||||
.and_then(|()| out.flush())
|
||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||
}
|
||||
if let Some((id, name, input)) = pending_tool.take() {
|
||||
if let Some(progress_reporter) = &self.progress_reporter {
|
||||
progress_reporter.mark_tool_phase(&name, &input);
|
||||
}
|
||||
writeln!(out, "\n{}", format_tool_call_start(&name, &input))
|
||||
.and_then(|()| out.flush())
|
||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||
events.push(AssistantEvent::ToolUse { id, name, input });
|
||||
}
|
||||
}
|
||||
ApiStreamEvent::MessageDelta(delta) => {
|
||||
events.push(AssistantEvent::Usage(delta.usage.token_usage()));
|
||||
}
|
||||
ApiStreamEvent::MessageStop(_) => {
|
||||
saw_stop = true;
|
||||
if let Some(rendered) = markdown_stream.flush(&renderer) {
|
||||
write!(out, "{rendered}")
|
||||
.and_then(|()| out.flush())
|
||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||
}
|
||||
events.push(AssistantEvent::MessageStop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
crate::push_prompt_cache_record(&self.client, &mut events);
|
||||
|
||||
if !saw_stop
|
||||
&& events.iter().any(|event| {
|
||||
matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty())
|
||||
|| matches!(event, AssistantEvent::ToolUse { .. })
|
||||
})
|
||||
{
|
||||
events.push(AssistantEvent::MessageStop);
|
||||
}
|
||||
|
||||
if events
|
||||
.iter()
|
||||
.any(|event| matches!(event, AssistantEvent::MessageStop))
|
||||
{
|
||||
return Ok(events);
|
||||
}
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.send_message(&MessageRequest {
|
||||
stream: false,
|
||||
..message_request.clone()
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
|
||||
})?;
|
||||
let mut events = crate::response_to_events(response, out)?;
|
||||
crate::push_prompt_cache_record(&self.client, &mut events);
|
||||
Ok(events)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
|
||||
Ok(resolve_cli_auth_source_for_cwd()?)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_cli_auth_source_for_cwd() -> Result<AuthSource, api::ApiError> {
|
||||
resolve_startup_auth_source(|| Ok(None))
|
||||
}
|
||||
|
||||
pub(crate) fn request_ends_with_tool_result(request: &ApiRequest) -> bool {
|
||||
request
|
||||
.messages
|
||||
.last()
|
||||
.is_some_and(|message| message.role == MessageRole::Tool)
|
||||
}
|
||||
|
||||
pub(crate) fn format_user_visible_api_error(session_id: &str, error: &api::ApiError) -> String {
|
||||
if error.is_context_window_failure() {
|
||||
format_context_window_blocked_error(session_id, error)
|
||||
} else if error.is_generic_fatal_wrapper() {
|
||||
let mut qualifiers = vec![format!("session {session_id}")];
|
||||
if let Some(request_id) = error.request_id() {
|
||||
qualifiers.push(format!("trace {request_id}"));
|
||||
}
|
||||
format!(
|
||||
"{} ({}): {}",
|
||||
error.safe_failure_class(),
|
||||
qualifiers.join(", "),
|
||||
error
|
||||
)
|
||||
} else {
|
||||
error.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_context_window_blocked_error(
|
||||
session_id: &str,
|
||||
error: &api::ApiError,
|
||||
) -> String {
|
||||
let mut lines = vec![
|
||||
"Context window blocked".to_string(),
|
||||
" Failure class context_window_blocked".to_string(),
|
||||
format!(" Session {session_id}"),
|
||||
];
|
||||
|
||||
if let Some(request_id) = error.request_id() {
|
||||
lines.push(format!(" Trace {request_id}"));
|
||||
}
|
||||
|
||||
match error {
|
||||
api::ApiError::ContextWindowExceeded {
|
||||
model,
|
||||
estimated_input_tokens,
|
||||
requested_output_tokens,
|
||||
estimated_total_tokens,
|
||||
context_window_tokens,
|
||||
} => {
|
||||
lines.push(format!(" Model {model}"));
|
||||
lines.push(format!(
|
||||
" Input estimate ~{estimated_input_tokens} tokens (heuristic)"
|
||||
));
|
||||
lines.push(format!(
|
||||
" Requested output {requested_output_tokens} tokens"
|
||||
));
|
||||
lines.push(format!(
|
||||
" Total estimate ~{estimated_total_tokens} tokens (heuristic)"
|
||||
));
|
||||
lines.push(format!(" Context window {context_window_tokens} tokens"));
|
||||
}
|
||||
api::ApiError::Api { message, body, .. } => {
|
||||
let detail = message.as_deref().unwrap_or(body).trim();
|
||||
if !detail.is_empty() {
|
||||
lines.push(format!(
|
||||
" Detail {}",
|
||||
crate::formatting::truncate_for_summary(detail, 120)
|
||||
));
|
||||
}
|
||||
}
|
||||
api::ApiError::RetriesExhausted { last_error, .. } => {
|
||||
let detail = match last_error.as_ref() {
|
||||
api::ApiError::Api { message, body, .. } => message.as_deref().unwrap_or(body),
|
||||
other => return format_context_window_blocked_error(session_id, other),
|
||||
}
|
||||
.trim();
|
||||
if !detail.is_empty() {
|
||||
lines.push(format!(
|
||||
" Detail {}",
|
||||
crate::formatting::truncate_for_summary(detail, 120)
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
lines.push(String::new());
|
||||
lines.push("Recovery".to_string());
|
||||
lines.push(" Compact /compact".to_string());
|
||||
lines.push(format!(
|
||||
" Resume compact claw --resume {session_id} /compact"
|
||||
));
|
||||
lines.push(" Fresh session /clear --confirm".to_string());
|
||||
lines.push(
|
||||
" Reduce scope remove large pasted context/files or ask for smaller slice"
|
||||
.to_string(),
|
||||
);
|
||||
lines.push(" Retry rerun after compacting or reducing request".to_string());
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,175 @@
|
|||
use crate::*;
|
||||
use runtime::{ConfigLoader, ConfigSource, ProjectContext};
|
||||
use serde_json;
|
||||
use std::env;
|
||||
|
||||
pub(crate) fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
let loader = ConfigLoader::default_for(&cwd);
|
||||
let discovered = loader.discover();
|
||||
let runtime_config = loader.load()?;
|
||||
|
||||
let mut lines = vec![
|
||||
format!(
|
||||
"Config
|
||||
Working directory {}
|
||||
Loaded files {}
|
||||
Merged keys {}",
|
||||
cwd.display(),
|
||||
runtime_config.loaded_entries().len(),
|
||||
runtime_config.merged().len()
|
||||
),
|
||||
"Discovered files".to_string(),
|
||||
];
|
||||
for entry in discovered {
|
||||
let source = match entry.source {
|
||||
ConfigSource::User => "user",
|
||||
ConfigSource::Project => "project",
|
||||
ConfigSource::Local => "local",
|
||||
};
|
||||
let status = if runtime_config
|
||||
.loaded_entries()
|
||||
.iter()
|
||||
.any(|loaded_entry| loaded_entry.path == entry.path)
|
||||
{
|
||||
"loaded"
|
||||
} else {
|
||||
"missing"
|
||||
};
|
||||
lines.push(format!(
|
||||
" {source:<7} {status:<7} {}",
|
||||
entry.path.display()
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(section) = section {
|
||||
lines.push(format!("Merged section: {section}"));
|
||||
let value = match section {
|
||||
"env" => runtime_config.get("env"),
|
||||
"hooks" => runtime_config.get("hooks"),
|
||||
"model" => runtime_config.get("model"),
|
||||
"plugins" => runtime_config
|
||||
.get("plugins")
|
||||
.or_else(|| runtime_config.get("enabledPlugins")),
|
||||
other => {
|
||||
lines.push(format!(
|
||||
" Unsupported config section '{other}'. Use env, hooks, model, or plugins."
|
||||
));
|
||||
return Ok(lines.join("\n"));
|
||||
}
|
||||
};
|
||||
lines.push(format!(
|
||||
" {}",
|
||||
match value {
|
||||
Some(value) => value.render(),
|
||||
None => "<unset>".to_string(),
|
||||
}
|
||||
));
|
||||
return Ok(lines.join("\n"));
|
||||
}
|
||||
|
||||
lines.push("Merged JSON".to_string());
|
||||
lines.push(format!(" {}", runtime_config.as_json().render()));
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
pub(crate) fn render_config_json(
|
||||
_section: Option<&str>,
|
||||
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
let loader = ConfigLoader::default_for(&cwd);
|
||||
let discovered = loader.discover();
|
||||
let runtime_config = loader.load()?;
|
||||
|
||||
let loaded_paths: Vec<_> = runtime_config
|
||||
.loaded_entries()
|
||||
.iter()
|
||||
.map(|e| e.path.display().to_string())
|
||||
.collect();
|
||||
|
||||
let files: Vec<_> = discovered
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let source = match e.source {
|
||||
ConfigSource::User => "user",
|
||||
ConfigSource::Project => "project",
|
||||
ConfigSource::Local => "local",
|
||||
};
|
||||
let is_loaded = runtime_config
|
||||
.loaded_entries()
|
||||
.iter()
|
||||
.any(|le| le.path == e.path);
|
||||
serde_json::json!({
|
||||
"path": e.path.display().to_string(),
|
||||
"source": source,
|
||||
"loaded": is_loaded,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"kind": "config",
|
||||
"cwd": cwd.display().to_string(),
|
||||
"loaded_files": loaded_paths.len(),
|
||||
"merged_keys": runtime_config.merged().len(),
|
||||
"files": files,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?;
|
||||
let mut lines = vec![format!(
|
||||
"Memory
|
||||
Working directory {}
|
||||
Instruction files {}",
|
||||
cwd.display(),
|
||||
project_context.instruction_files.len()
|
||||
)];
|
||||
if project_context.instruction_files.is_empty() {
|
||||
lines.push("Discovered files".to_string());
|
||||
lines.push(
|
||||
" No CLAUDE instruction files discovered in the current directory ancestry."
|
||||
.to_string(),
|
||||
);
|
||||
} else {
|
||||
lines.push("Discovered files".to_string());
|
||||
for (index, file) in project_context.instruction_files.iter().enumerate() {
|
||||
let preview = file.content.lines().next().unwrap_or("").trim();
|
||||
let preview = if preview.is_empty() {
|
||||
"<empty>"
|
||||
} else {
|
||||
preview
|
||||
};
|
||||
lines.push(format!(" {}. {}", index + 1, file.path.display(),));
|
||||
lines.push(format!(
|
||||
" lines={} preview={}",
|
||||
file.content.lines().count(),
|
||||
preview
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
pub(crate) fn render_memory_json() -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?;
|
||||
let files: Vec<_> = project_context
|
||||
.instruction_files
|
||||
.iter()
|
||||
.map(|f| {
|
||||
serde_json::json!({
|
||||
"path": f.path.display().to_string(),
|
||||
"lines": f.content.lines().count(),
|
||||
"preview": f.content.lines().next().unwrap_or("").trim(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(serde_json::json!({
|
||||
"kind": "memory",
|
||||
"cwd": cwd.display().to_string(),
|
||||
"instruction_files": files.len(),
|
||||
"files": files,
|
||||
}))
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
use std::collections::BTreeSet;
|
||||
use std::time::Duration;
|
||||
|
||||
pub(crate) const DEFAULT_MODEL: &str = "claude-opus-4-6";
|
||||
pub(crate) const DEFAULT_DATE: &str = match option_env!("BUILD_DATE") {
|
||||
Some(d) => d,
|
||||
None => "unknown",
|
||||
};
|
||||
pub(crate) const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
|
||||
pub(crate) const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
pub(crate) const BUILD_TARGET: Option<&str> = option_env!("TARGET");
|
||||
pub(crate) const GIT_SHA: Option<&str> = option_env!("GIT_SHA");
|
||||
pub(crate) const INTERNAL_PROGRESS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(3);
|
||||
pub(crate) const POST_TOOL_STALL_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
pub(crate) const PRIMARY_SESSION_EXTENSION: &str = "jsonl";
|
||||
pub(crate) const LEGACY_SESSION_EXTENSION: &str = "json";
|
||||
pub(crate) const OFFICIAL_REPO_URL: &str = "https://github.com/ultraworkers/claw-code";
|
||||
pub(crate) const OFFICIAL_REPO_SLUG: &str = "ultraworkers/claw-code";
|
||||
pub(crate) const DEPRECATED_INSTALL_COMMAND: &str = "cargo install claw-code";
|
||||
pub(crate) const LATEST_SESSION_REFERENCE: &str = "latest";
|
||||
pub(crate) const SESSION_REFERENCE_ALIASES: &[&str] = &[
|
||||
LATEST_SESSION_REFERENCE,
|
||||
"last",
|
||||
"recent",
|
||||
];
|
||||
pub(crate) const CLI_OPTION_SUGGESTIONS: &[&str] = &[
|
||||
"--help",
|
||||
"-h",
|
||||
"--version",
|
||||
"-V",
|
||||
"--model",
|
||||
"--output-format",
|
||||
"--permission-mode",
|
||||
"--dangerously-skip-permissions",
|
||||
"--allowedTools",
|
||||
"--allowed-tools",
|
||||
"--resume",
|
||||
"--acp",
|
||||
"-acp",
|
||||
"--print",
|
||||
"--compact",
|
||||
"--base-commit",
|
||||
"-p",
|
||||
];
|
||||
|
||||
pub(crate) const DEFAULT_HISTORY_LIMIT: usize = 20;
|
||||
pub(crate) type AllowedToolSet = BTreeSet<String>;
|
||||
|
||||
pub(crate) const STUB_COMMANDS: &[&str] = &[
|
||||
"login",
|
||||
"logout",
|
||||
"vim",
|
||||
"upgrade",
|
||||
"share",
|
||||
"feedback",
|
||||
"files",
|
||||
"fast",
|
||||
"exit",
|
||||
"summary",
|
||||
"desktop",
|
||||
"brief",
|
||||
"advisor",
|
||||
"stickers",
|
||||
"insights",
|
||||
"thinkback",
|
||||
"release-notes",
|
||||
"security-review",
|
||||
"keybindings",
|
||||
"privacy-settings",
|
||||
"plan",
|
||||
"review",
|
||||
"tasks",
|
||||
"theme",
|
||||
"voice",
|
||||
"usage",
|
||||
"rename",
|
||||
"copy",
|
||||
"hooks",
|
||||
"context",
|
||||
"color",
|
||||
"effort",
|
||||
"branch",
|
||||
"rewind",
|
||||
"ide",
|
||||
"tag",
|
||||
"output-style",
|
||||
"add-dir",
|
||||
"allowed-tools",
|
||||
"bookmarks",
|
||||
"workspace",
|
||||
"reasoning",
|
||||
"budget",
|
||||
"rate-limit",
|
||||
"changelog",
|
||||
"diagnostics",
|
||||
"metrics",
|
||||
"tool-details",
|
||||
"focus",
|
||||
"unfocus",
|
||||
"pin",
|
||||
"unpin",
|
||||
"language",
|
||||
"profile",
|
||||
"max-tokens",
|
||||
"temperature",
|
||||
"system-prompt",
|
||||
"notifications",
|
||||
"telemetry",
|
||||
"env",
|
||||
"project",
|
||||
"terminal-setup",
|
||||
"api-key",
|
||||
"reset",
|
||||
"undo",
|
||||
"stop",
|
||||
"retry",
|
||||
"paste",
|
||||
"screenshot",
|
||||
"image",
|
||||
"search",
|
||||
"listen",
|
||||
"speak",
|
||||
"format",
|
||||
"test",
|
||||
"lint",
|
||||
"build",
|
||||
"run",
|
||||
"git",
|
||||
"stash",
|
||||
"blame",
|
||||
"log",
|
||||
"cron",
|
||||
"team",
|
||||
"benchmark",
|
||||
"migrate",
|
||||
"templates",
|
||||
"explain",
|
||||
"refactor",
|
||||
"docs",
|
||||
"fix",
|
||||
"perf",
|
||||
"chat",
|
||||
"web",
|
||||
"map",
|
||||
"symbols",
|
||||
"references",
|
||||
"definition",
|
||||
"hover",
|
||||
"autofix",
|
||||
"multi",
|
||||
"macro",
|
||||
"alias",
|
||||
"parallel",
|
||||
"subagent",
|
||||
"agent",
|
||||
];
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
use crate::*;
|
||||
use std::env;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
pub(crate) fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> {
|
||||
render_diff_report_for(&env::current_dir()?)
|
||||
}
|
||||
|
||||
pub(crate) fn render_diff_report_for(cwd: &Path) -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Verify we are inside a git repository before calling `git diff`.
|
||||
// Running `git diff --cached` outside a git tree produces a misleading
|
||||
// "unknown option `cached`" error because git falls back to --no-index mode.
|
||||
let in_git_repo = Command::new("git")
|
||||
.args(["rev-parse", "--is-inside-work-tree"])
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
if !in_git_repo {
|
||||
return Ok(format!(
|
||||
"Diff\n Result no git repository\n Detail {} is not inside a git project",
|
||||
cwd.display()
|
||||
));
|
||||
}
|
||||
let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?;
|
||||
let unstaged = run_git_diff_command_in(cwd, &["diff"])?;
|
||||
if staged.trim().is_empty() && unstaged.trim().is_empty() {
|
||||
return Ok(
|
||||
"Diff\n Result clean working tree\n Detail no current changes"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut sections = Vec::new();
|
||||
if !staged.trim().is_empty() {
|
||||
sections.push(format!("Staged changes:\n{}", staged.trim_end()));
|
||||
}
|
||||
if !unstaged.trim().is_empty() {
|
||||
sections.push(format!("Unstaged changes:\n{}", unstaged.trim_end()));
|
||||
}
|
||||
|
||||
Ok(format!("Diff\n\n{}", sections.join("\n\n")))
|
||||
}
|
||||
|
||||
pub(crate) fn render_diff_json_for(cwd: &Path) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
||||
let in_git_repo = Command::new("git")
|
||||
.args(["rev-parse", "--is-inside-work-tree"])
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
if !in_git_repo {
|
||||
return Ok(serde_json::json!({
|
||||
"kind": "diff",
|
||||
"result": "no_git_repo",
|
||||
"detail": format!("{} is not inside a git project", cwd.display()),
|
||||
}));
|
||||
}
|
||||
let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?;
|
||||
let unstaged = run_git_diff_command_in(cwd, &["diff"])?;
|
||||
Ok(serde_json::json!({
|
||||
"kind": "diff",
|
||||
"result": if staged.trim().is_empty() && unstaged.trim().is_empty() { "clean" } else { "changes" },
|
||||
"staged": staged.trim(),
|
||||
"unstaged": unstaged.trim(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn run_git_diff_command_in(
|
||||
cwd: &Path,
|
||||
args: &[&str],
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.output()?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(format!("git {} failed: {stderr}", args.join(" ")).into());
|
||||
}
|
||||
Ok(String::from_utf8(output.stdout)?)
|
||||
}
|
||||
|
|
@ -0,0 +1,398 @@
|
|||
use std::env;
|
||||
use std::path::Path;
|
||||
|
||||
use runtime::{ConfigLoader, ProjectContext};
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::constants::{BUILD_TARGET, DEFAULT_DATE, DEPRECATED_INSTALL_COMMAND, GIT_SHA, OFFICIAL_REPO_SLUG, OFFICIAL_REPO_URL, VERSION};
|
||||
use crate::{parse_git_status_metadata, parse_git_workspace_summary, resolve_sandbox_status, CliOutputFormat, StatusContext};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum DiagnosticLevel {
|
||||
Ok,
|
||||
Warn,
|
||||
Fail,
|
||||
}
|
||||
|
||||
impl DiagnosticLevel {
|
||||
pub(crate) fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Ok => "ok",
|
||||
Self::Warn => "warn",
|
||||
Self::Fail => "fail",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_failure(self) -> bool {
|
||||
matches!(self, Self::Fail)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct DiagnosticCheck {
|
||||
pub(crate) name: &'static str,
|
||||
pub(crate) level: DiagnosticLevel,
|
||||
pub(crate) summary: String,
|
||||
pub(crate) details: Vec<String>,
|
||||
pub(crate) data: Map<String, Value>,
|
||||
}
|
||||
|
||||
impl DiagnosticCheck {
|
||||
pub(crate) fn new(name: &'static str, level: DiagnosticLevel, summary: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name,
|
||||
level,
|
||||
summary: summary.into(),
|
||||
details: Vec::new(),
|
||||
data: Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_details(mut self, details: Vec<String>) -> Self {
|
||||
self.details = details;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_data(mut self, data: Map<String, Value>) -> Self {
|
||||
self.data = data;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn json_value(&self) -> Value {
|
||||
let mut value = Map::from_iter([
|
||||
("name".to_string(), Value::String(self.name.to_ascii_lowercase())),
|
||||
("status".to_string(), Value::String(self.level.label().to_string())),
|
||||
("summary".to_string(), Value::String(self.summary.clone())),
|
||||
("details".to_string(), Value::Array(
|
||||
self.details.iter().cloned().map(Value::String).collect(),
|
||||
)),
|
||||
]);
|
||||
value.extend(self.data.clone());
|
||||
Value::Object(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct DoctorReport {
|
||||
pub(crate) checks: Vec<DiagnosticCheck>,
|
||||
}
|
||||
|
||||
impl DoctorReport {
|
||||
pub(crate) fn counts(&self) -> (usize, usize, usize) {
|
||||
(
|
||||
self.checks.iter().filter(|c| c.level == DiagnosticLevel::Ok).count(),
|
||||
self.checks.iter().filter(|c| c.level == DiagnosticLevel::Warn).count(),
|
||||
self.checks.iter().filter(|c| c.level == DiagnosticLevel::Fail).count(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn has_failures(&self) -> bool {
|
||||
self.checks.iter().any(|c| c.level.is_failure())
|
||||
}
|
||||
|
||||
pub(crate) fn render(&self) -> String {
|
||||
let (ok_count, warn_count, fail_count) = self.counts();
|
||||
let mut lines = vec![
|
||||
"Doctor".to_string(),
|
||||
format!("Summary\n OK {ok_count}\n Warnings {warn_count}\n Failures {fail_count}"),
|
||||
];
|
||||
lines.extend(self.checks.iter().map(render_diagnostic_check));
|
||||
lines.join("\n\n")
|
||||
}
|
||||
|
||||
pub(crate) fn json_value(&self) -> Value {
|
||||
let report = self.render();
|
||||
let (ok_count, warn_count, fail_count) = self.counts();
|
||||
serde_json::json!({
|
||||
"kind": "doctor",
|
||||
"message": report,
|
||||
"report": report,
|
||||
"has_failures": self.has_failures(),
|
||||
"summary": {
|
||||
"total": self.checks.len(),
|
||||
"ok": ok_count,
|
||||
"warnings": warn_count,
|
||||
"failures": fail_count,
|
||||
},
|
||||
"checks": self.checks.iter().map(DiagnosticCheck::json_value).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn render_diagnostic_check(check: &DiagnosticCheck) -> String {
|
||||
let mut lines = vec![format!(
|
||||
"{}\n Status {}\n Summary {}",
|
||||
check.name,
|
||||
check.level.label(),
|
||||
check.summary
|
||||
)];
|
||||
if !check.details.is_empty() {
|
||||
lines.push(" Details".to_string());
|
||||
lines.extend(check.details.iter().map(|d| format!(" - {d}")));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn render_doctor_report() -> Result<DoctorReport, Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
let config_loader = ConfigLoader::default_for(&cwd);
|
||||
let config = config_loader.load();
|
||||
let discovered_config = config_loader.discover();
|
||||
let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?;
|
||||
let (project_root, git_branch) = parse_git_status_metadata(project_context.git_status.as_deref());
|
||||
let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref());
|
||||
let empty_config = runtime::RuntimeConfig::empty();
|
||||
let sandbox_config = config.as_ref().ok().unwrap_or(&empty_config);
|
||||
let context = StatusContext {
|
||||
cwd: cwd.clone(),
|
||||
session_path: None,
|
||||
loaded_config_files: config.as_ref().ok().map_or(0, |c| c.loaded_entries().len()),
|
||||
discovered_config_files: discovered_config.len(),
|
||||
memory_file_count: project_context.instruction_files.len(),
|
||||
project_root,
|
||||
git_branch,
|
||||
git_summary,
|
||||
session_lifecycle: crate::classify_session_lifecycle_for(&cwd),
|
||||
sandbox_status: resolve_sandbox_status(sandbox_config.sandbox(), &cwd),
|
||||
config_load_error: config.as_ref().err().map(ToString::to_string),
|
||||
};
|
||||
Ok(DoctorReport {
|
||||
checks: vec![
|
||||
check_auth_health(),
|
||||
check_config_health(&config_loader, config.as_ref()),
|
||||
check_install_source_health(),
|
||||
check_workspace_health(&context),
|
||||
check_sandbox_health(&context.sandbox_status),
|
||||
check_system_health(&cwd, config.as_ref().ok()),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn run_doctor(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let report = render_doctor_report()?;
|
||||
let message = report.render();
|
||||
match output_format {
|
||||
CliOutputFormat::Text => println!("{message}"),
|
||||
CliOutputFormat::Json => {
|
||||
println!("{}", serde_json::to_string_pretty(&report.json_value())?);
|
||||
}
|
||||
}
|
||||
if report.has_failures() {
|
||||
return Err("doctor found failing checks".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_auth_health() -> DiagnosticCheck {
|
||||
let api_key_present = env::var("ANTHROPIC_API_KEY").ok().is_some_and(|v| !v.trim().is_empty());
|
||||
let auth_token_present = env::var("ANTHROPIC_AUTH_TOKEN").ok().is_some_and(|v| !v.trim().is_empty());
|
||||
let env_details = format!(
|
||||
"Environment api_key={} auth_token={}",
|
||||
if api_key_present { "present" } else { "absent" },
|
||||
if auth_token_present { "present" } else { "absent" },
|
||||
);
|
||||
|
||||
match runtime::load_oauth_credentials() {
|
||||
Ok(Some(token_set)) => DiagnosticCheck::new(
|
||||
"Auth",
|
||||
if api_key_present || auth_token_present { DiagnosticLevel::Ok } else { DiagnosticLevel::Warn },
|
||||
if api_key_present || auth_token_present {
|
||||
"supported auth env vars are configured; legacy saved OAuth is ignored"
|
||||
} else {
|
||||
"legacy saved OAuth credentials are present but unsupported"
|
||||
},
|
||||
)
|
||||
.with_details(vec![
|
||||
env_details,
|
||||
format!(
|
||||
"Legacy OAuth expires_at={} refresh_token={} scopes={}",
|
||||
token_set.expires_at.map_or_else(|| "<none>".to_string(), |v| v.to_string()),
|
||||
if token_set.refresh_token.is_some() { "present" } else { "absent" },
|
||||
if token_set.scopes.is_empty() { "<none>".to_string() } else { token_set.scopes.join(",") },
|
||||
),
|
||||
"Suggested action set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN; `claw login` is removed".to_string(),
|
||||
])
|
||||
.with_data(Map::from_iter([
|
||||
("api_key_present".to_string(),serde_json::json!(api_key_present)),
|
||||
("auth_token_present".to_string(),serde_json::json!(auth_token_present)),
|
||||
("legacy_saved_oauth_present".to_string(),serde_json::json!(true)),
|
||||
("legacy_saved_oauth_expires_at".to_string(),serde_json::json!(token_set.expires_at)),
|
||||
("legacy_refresh_token_present".to_string(),serde_json::json!(token_set.refresh_token.is_some())),
|
||||
("legacy_scopes".to_string(),serde_json::json!(token_set.scopes)),
|
||||
])),
|
||||
Ok(None) => DiagnosticCheck::new(
|
||||
"Auth",
|
||||
if api_key_present || auth_token_present { DiagnosticLevel::Ok } else { DiagnosticLevel::Warn },
|
||||
if api_key_present || auth_token_present {
|
||||
"supported auth env vars are configured"
|
||||
} else {
|
||||
"no supported auth env vars were found"
|
||||
},
|
||||
)
|
||||
.with_details(vec![env_details])
|
||||
.with_data(Map::from_iter([
|
||||
("api_key_present".to_string(),serde_json::json!(api_key_present)),
|
||||
("auth_token_present".to_string(),serde_json::json!(auth_token_present)),
|
||||
("legacy_saved_oauth_present".to_string(),serde_json::json!(false)),
|
||||
("legacy_saved_oauth_expires_at".to_string(), Value::Null),
|
||||
("legacy_refresh_token_present".to_string(),serde_json::json!(false)),
|
||||
("legacy_scopes".to_string(),serde_json::json!(Vec::<String>::new())),
|
||||
])),
|
||||
Err(error) => DiagnosticCheck::new(
|
||||
"Auth",
|
||||
DiagnosticLevel::Fail,
|
||||
format!("failed to inspect legacy saved credentials: {error}"),
|
||||
)
|
||||
.with_data(Map::from_iter([
|
||||
("api_key_present".to_string(),serde_json::json!(api_key_present)),
|
||||
("auth_token_present".to_string(),serde_json::json!(auth_token_present)),
|
||||
("legacy_saved_oauth_present".to_string(), Value::Null),
|
||||
("legacy_saved_oauth_expires_at".to_string(), Value::Null),
|
||||
("legacy_refresh_token_present".to_string(), Value::Null),
|
||||
("legacy_scopes".to_string(), Value::Null),
|
||||
("legacy_saved_oauth_error".to_string(),serde_json::json!(error.to_string())),
|
||||
])),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_config_health(
|
||||
config_loader: &ConfigLoader,
|
||||
config: Result<&runtime::RuntimeConfig, &runtime::ConfigError>,
|
||||
) -> DiagnosticCheck {
|
||||
let discovered = config_loader.discover();
|
||||
let present_paths: Vec<String> = discovered.iter().filter(|e| e.path.exists()).map(|e| e.path.display().to_string()).collect();
|
||||
let discovered_paths: Vec<String> = discovered.iter().map(|e| e.path.display().to_string()).collect();
|
||||
match config {
|
||||
Ok(runtime_config) => {
|
||||
let loaded_entries = runtime_config.loaded_entries();
|
||||
let mut details = vec![format!("Config files loaded {}/{}", loaded_entries.len(), present_paths.len())];
|
||||
if let Some(model) = runtime_config.model() {
|
||||
details.push(format!("Resolved model {model}"));
|
||||
}
|
||||
details.push(format!("MCP servers {}", runtime_config.mcp().servers().len()));
|
||||
if present_paths.is_empty() {
|
||||
details.push("Discovered files <none> (defaults active)".to_string());
|
||||
} else {
|
||||
details.extend(present_paths.iter().map(|p| format!("Discovered file {p}")));
|
||||
}
|
||||
DiagnosticCheck::new("Config", DiagnosticLevel::Ok, if present_paths.is_empty() { "no config files present; defaults are active" } else { "runtime config loaded successfully" })
|
||||
.with_details(details)
|
||||
.with_data(Map::from_iter([
|
||||
("discovered_files".to_string(),serde_json::json!(present_paths)),
|
||||
("discovered_files_count".to_string(),serde_json::json!(present_paths.len())),
|
||||
("loaded_config_files".to_string(),serde_json::json!(loaded_entries.len())),
|
||||
("resolved_model".to_string(),serde_json::json!(runtime_config.model())),
|
||||
("mcp_servers".to_string(),serde_json::json!(runtime_config.mcp().servers().len())),
|
||||
]))
|
||||
}
|
||||
Err(error) => DiagnosticCheck::new("Config", DiagnosticLevel::Fail, format!("runtime config failed to load: {error}"))
|
||||
.with_details(if discovered_paths.is_empty() { vec!["Discovered files <none>".to_string()] } else { discovered_paths.iter().map(|p| format!("Discovered file {p}")).collect() })
|
||||
.with_data(Map::from_iter([
|
||||
("discovered_files".to_string(),serde_json::json!(discovered_paths)),
|
||||
("discovered_files_count".to_string(),serde_json::json!(discovered_paths.len())),
|
||||
("loaded_config_files".to_string(),serde_json::json!(0)),
|
||||
("resolved_model".to_string(), Value::Null),
|
||||
("mcp_servers".to_string(), Value::Null),
|
||||
("load_error".to_string(),serde_json::json!(error.to_string())),
|
||||
])),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_install_source_health() -> DiagnosticCheck {
|
||||
DiagnosticCheck::new("Install source", DiagnosticLevel::Ok, format!("official source of truth is {OFFICIAL_REPO_SLUG}; avoid `{DEPRECATED_INSTALL_COMMAND}`"))
|
||||
.with_details(vec![
|
||||
format!("Official repo {OFFICIAL_REPO_URL}"),
|
||||
"Recommended path build from this repo or use the upstream binary documented in README.md".to_string(),
|
||||
format!("Deprecated crate `{DEPRECATED_INSTALL_COMMAND}` installs a deprecated stub and does not provide the `claw` binary"),
|
||||
])
|
||||
.with_data(Map::from_iter([
|
||||
("official_repo".to_string(),serde_json::json!(OFFICIAL_REPO_URL)),
|
||||
("deprecated_install".to_string(),serde_json::json!(DEPRECATED_INSTALL_COMMAND)),
|
||||
("recommended_install".to_string(),serde_json::json!("build from source or follow the upstream binary instructions in README.md")),
|
||||
]))
|
||||
}
|
||||
|
||||
fn check_workspace_health(context: &StatusContext) -> DiagnosticCheck {
|
||||
let in_repo = context.project_root.is_some();
|
||||
DiagnosticCheck::new("Workspace", if in_repo { DiagnosticLevel::Ok } else { DiagnosticLevel::Warn },
|
||||
if in_repo { format!("project root detected on branch {}", context.git_branch.as_deref().unwrap_or("unknown")) } else { "current directory is not inside a git project".to_string() },
|
||||
)
|
||||
.with_details(vec![
|
||||
format!("Cwd {}", context.cwd.display()),
|
||||
format!("Project root {}", context.project_root.as_ref().map_or_else(|| "<none>".to_string(), |p| p.display().to_string())),
|
||||
format!("Git branch {}", context.git_branch.as_deref().unwrap_or("unknown")),
|
||||
format!("Git state {}", context.git_summary.headline()),
|
||||
format!("Changed files {}", context.git_summary.changed_files),
|
||||
format!("Memory files {} · config files loaded {}/{}", context.memory_file_count, context.loaded_config_files, context.discovered_config_files),
|
||||
])
|
||||
.with_data(Map::from_iter([
|
||||
("cwd".to_string(),serde_json::json!(context.cwd.display().to_string())),
|
||||
("project_root".to_string(),serde_json::json!(context.project_root.as_ref().map(|p| p.display().to_string()))),
|
||||
("in_git_repo".to_string(),serde_json::json!(in_repo)),
|
||||
("git_branch".to_string(),serde_json::json!(context.git_branch)),
|
||||
("git_state".to_string(),serde_json::json!(context.git_summary.headline())),
|
||||
("changed_files".to_string(),serde_json::json!(context.git_summary.changed_files)),
|
||||
("memory_file_count".to_string(),serde_json::json!(context.memory_file_count)),
|
||||
("loaded_config_files".to_string(),serde_json::json!(context.loaded_config_files)),
|
||||
("discovered_config_files".to_string(),serde_json::json!(context.discovered_config_files)),
|
||||
]))
|
||||
}
|
||||
|
||||
fn check_sandbox_health(status: &runtime::SandboxStatus) -> DiagnosticCheck {
|
||||
let degraded = status.enabled && !status.active;
|
||||
let mut details = vec![
|
||||
format!("Enabled {}", status.enabled),
|
||||
format!("Active {}", status.active),
|
||||
format!("Supported {}", status.supported),
|
||||
format!("Filesystem mode {}", status.filesystem_mode.as_str()),
|
||||
format!("Filesystem live {}", status.filesystem_active),
|
||||
];
|
||||
if let Some(reason) = &status.fallback_reason {
|
||||
details.push(format!("Fallback reason {reason}"));
|
||||
}
|
||||
DiagnosticCheck::new("Sandbox",
|
||||
if degraded { DiagnosticLevel::Warn } else { DiagnosticLevel::Ok },
|
||||
if degraded { "sandbox was requested but is not currently active" } else if status.active { "sandbox protections are active" } else { "sandbox is not active for this session" },
|
||||
)
|
||||
.with_details(details)
|
||||
.with_data(Map::from_iter([
|
||||
("enabled".to_string(),serde_json::json!(status.enabled)),
|
||||
("active".to_string(),serde_json::json!(status.active)),
|
||||
("supported".to_string(),serde_json::json!(status.supported)),
|
||||
("namespace_supported".to_string(),serde_json::json!(status.namespace_supported)),
|
||||
("namespace_active".to_string(),serde_json::json!(status.namespace_active)),
|
||||
("network_supported".to_string(),serde_json::json!(status.network_supported)),
|
||||
("network_active".to_string(),serde_json::json!(status.network_active)),
|
||||
("filesystem_mode".to_string(),serde_json::json!(status.filesystem_mode.as_str())),
|
||||
("filesystem_active".to_string(),serde_json::json!(status.filesystem_active)),
|
||||
("allowed_mounts".to_string(),serde_json::json!(status.allowed_mounts)),
|
||||
("in_container".to_string(),serde_json::json!(status.in_container)),
|
||||
("container_markers".to_string(),serde_json::json!(status.container_markers)),
|
||||
("fallback_reason".to_string(),serde_json::json!(status.fallback_reason)),
|
||||
]))
|
||||
}
|
||||
|
||||
fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> DiagnosticCheck {
|
||||
let default_model = config.and_then(runtime::RuntimeConfig::model);
|
||||
let mut details = vec![
|
||||
format!("OS {} {}", env::consts::OS, env::consts::ARCH),
|
||||
format!("Working dir {}", cwd.display()),
|
||||
format!("Version {}", VERSION),
|
||||
format!("Build target {}", BUILD_TARGET.unwrap_or("<unknown>")),
|
||||
format!("Git SHA {}", GIT_SHA.unwrap_or("<unknown>")),
|
||||
];
|
||||
if let Some(model) = default_model {
|
||||
details.push(format!("Default model {model}"));
|
||||
}
|
||||
DiagnosticCheck::new("System", DiagnosticLevel::Ok, "captured local runtime metadata")
|
||||
.with_details(details)
|
||||
.with_data(Map::from_iter([
|
||||
("os".to_string(),serde_json::json!(env::consts::OS)),
|
||||
("arch".to_string(),serde_json::json!(env::consts::ARCH)),
|
||||
("working_dir".to_string(),serde_json::json!(cwd.display().to_string())),
|
||||
("version".to_string(),serde_json::json!(VERSION)),
|
||||
("build_target".to_string(),serde_json::json!(BUILD_TARGET)),
|
||||
("git_sha".to_string(),serde_json::json!(GIT_SHA)),
|
||||
("default_model".to_string(),serde_json::json!(default_model)),
|
||||
]))
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
use crate::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
|
||||
pub(crate) fn run_export(
|
||||
session_reference: &str,
|
||||
output_path: Option<&Path>,
|
||||
output_format: CliOutputFormat,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (handle, session) = load_session_reference(session_reference)?;
|
||||
let markdown = render_session_markdown(&session, &handle.id, &handle.path);
|
||||
|
||||
if let Some(path) = output_path {
|
||||
fs::write(path, &markdown)?;
|
||||
let report = format!(
|
||||
"Export\n Result wrote markdown transcript\n File {}\n Session {}\n Messages {}",
|
||||
path.display(),
|
||||
handle.id,
|
||||
session.messages.len(),
|
||||
);
|
||||
match output_format {
|
||||
CliOutputFormat::Text => println!("{report}"),
|
||||
CliOutputFormat::Json => println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"kind": "export",
|
||||
"message": report,
|
||||
"session_id": handle.id,
|
||||
"file": path.display().to_string(),
|
||||
"messages": session.messages.len(),
|
||||
}))?
|
||||
),
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match output_format {
|
||||
CliOutputFormat::Text => {
|
||||
print!("{markdown}");
|
||||
if !markdown.ends_with('\n') {
|
||||
println!();
|
||||
}
|
||||
}
|
||||
CliOutputFormat::Json => println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"kind": "export",
|
||||
"session_id": handle.id,
|
||||
"file": handle.path.display().to_string(),
|
||||
"messages": session.messages.len(),
|
||||
"markdown": markdown,
|
||||
}))?
|
||||
),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn default_export_filename(session: &Session) -> String {
|
||||
let stem = session
|
||||
.messages
|
||||
.iter()
|
||||
.find_map(|message| match message.role {
|
||||
MessageRole::User => message.blocks.iter().find_map(|block| match block {
|
||||
ContentBlock::Text { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.map_or("conversation", |text| {
|
||||
text.lines().next().unwrap_or("conversation")
|
||||
})
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
ch.to_ascii_lowercase()
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.split('-')
|
||||
.filter(|part| !part.is_empty())
|
||||
.take(8)
|
||||
.collect::<Vec<_>>()
|
||||
.join("-");
|
||||
let fallback = if stem.is_empty() {
|
||||
"conversation"
|
||||
} else {
|
||||
&stem
|
||||
};
|
||||
format!("{fallback}.txt")
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_export_path(
|
||||
requested_path: Option<&str>,
|
||||
session: &Session,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let cwd = std::env::current_dir()?;
|
||||
let file_name =
|
||||
requested_path.map_or_else(|| default_export_filename(session), ToOwned::to_owned);
|
||||
let final_name = if Path::new(&file_name)
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("txt"))
|
||||
{
|
||||
file_name
|
||||
} else {
|
||||
format!("{file_name}.txt")
|
||||
};
|
||||
Ok(cwd.join(final_name))
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
use serde_json::Value;
|
||||
|
||||
pub(crate) fn civil_from_days(days: i64) -> (i32, u32, u32) {
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 {
|
||||
z / 146_097
|
||||
} else {
|
||||
(z - 146_096) / 146_097
|
||||
};
|
||||
let doe = (z - era * 146_097) as u64; // [0, 146_096]
|
||||
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
|
||||
let y = yoe as i64 + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
|
||||
let mp = (5 * doy + 2) / 153; // [0, 11]
|
||||
let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
|
||||
let y = y + i64::from(m <= 2);
|
||||
(y as i32, m as u32, d as u32)
|
||||
}
|
||||
|
||||
pub(crate) fn indent_block(value: &str, spaces: usize) -> String {
|
||||
let indent = " ".repeat(spaces);
|
||||
value
|
||||
.lines()
|
||||
.map(|line| format!("{indent}{line}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn truncate_for_prompt(value: &str, limit: usize) -> String {
|
||||
if value.chars().count() <= limit {
|
||||
value.trim().to_string()
|
||||
} else {
|
||||
let truncated = value.chars().take(limit).collect::<String>();
|
||||
format!("{}\n…[truncated]", truncated.trim_end())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn extract_tool_path(parsed: &Value) -> String {
|
||||
parsed
|
||||
.get("file_path")
|
||||
.or_else(|| parsed.get("filePath"))
|
||||
.or_else(|| parsed.get("path"))
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("?")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn first_visible_line(text: &str) -> &str {
|
||||
text.lines().find(|l| !l.trim().is_empty()).unwrap_or("")
|
||||
}
|
||||
|
||||
pub(crate) fn final_assistant_text(summary: &runtime::TurnSummary) -> String {
|
||||
summary
|
||||
.assistant_messages
|
||||
.last()
|
||||
.map(|message| {
|
||||
message
|
||||
.blocks
|
||||
.iter()
|
||||
.filter_map(|block| match block {
|
||||
runtime::ContentBlock::Text { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn collect_tool_uses(summary: &runtime::TurnSummary) -> Vec<serde_json::Value> {
|
||||
summary
|
||||
.assistant_messages
|
||||
.iter()
|
||||
.flat_map(|message| message.blocks.iter())
|
||||
.filter_map(|block| match block {
|
||||
runtime::ContentBlock::ToolUse { id, name, input } => Some(serde_json::json!({
|
||||
"id": id,
|
||||
"name": name,
|
||||
"input": input,
|
||||
})),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn collect_tool_results(summary: &runtime::TurnSummary) -> Vec<serde_json::Value> {
|
||||
summary
|
||||
.tool_results
|
||||
.iter()
|
||||
.flat_map(|message| message.blocks.iter())
|
||||
.filter_map(|block| match block {
|
||||
runtime::ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
tool_name,
|
||||
output,
|
||||
is_error,
|
||||
} => Some(serde_json::json!({
|
||||
"tool_use_id": tool_use_id,
|
||||
"tool_name": tool_name,
|
||||
"output": output,
|
||||
"is_error": is_error,
|
||||
})),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn collect_prompt_cache_events(summary: &runtime::TurnSummary) -> Vec<serde_json::Value> {
|
||||
summary
|
||||
.prompt_cache_events
|
||||
.iter()
|
||||
.map(|event| {
|
||||
serde_json::json!({
|
||||
"unexpected": event.unexpected,
|
||||
"reason": event.reason,
|
||||
"previous_cache_read_input_tokens": event.previous_cache_read_input_tokens,
|
||||
"current_cache_read_input_tokens": event.current_cache_read_input_tokens,
|
||||
"token_drop": event.token_drop,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_generated_message(value: &str) -> String {
|
||||
value.trim().trim_matches('`').trim().replace("\r\n", "\n")
|
||||
}
|
||||
|
||||
pub(crate) fn parse_titled_body(value: &str) -> Option<(String, String)> {
|
||||
let normalized = sanitize_generated_message(value);
|
||||
let title = normalized
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("TITLE:").map(str::trim))?;
|
||||
let body_start = normalized.find("BODY:")?;
|
||||
let body = normalized[body_start + "BODY:".len()..].trim();
|
||||
Some((title.to_string(), body.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn truncate_output_for_display(content: &str, max_lines: usize, max_chars: usize) -> String {
|
||||
let original = content.trim_end_matches('\n');
|
||||
if original.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut preview_lines = Vec::new();
|
||||
let mut used_chars = 0usize;
|
||||
let mut truncated = false;
|
||||
|
||||
for (index, line) in original.lines().enumerate() {
|
||||
if index >= max_lines {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
let newline_cost = usize::from(!preview_lines.is_empty());
|
||||
let available = max_chars.saturating_sub(used_chars + newline_cost);
|
||||
if available == 0 {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
let line_chars = line.chars().count();
|
||||
if line_chars > available {
|
||||
preview_lines.push(line.chars().take(available).collect::<String>());
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
preview_lines.push(line.to_string());
|
||||
used_chars += newline_cost + line_chars;
|
||||
}
|
||||
|
||||
let mut preview = preview_lines.join("\n");
|
||||
if truncated {
|
||||
if !preview.is_empty() {
|
||||
preview.push('\n');
|
||||
}
|
||||
preview.push_str(super::DISPLAY_TRUNCATION_NOTICE);
|
||||
}
|
||||
preview
|
||||
}
|
||||
|
||||
pub(crate) fn summarize_tool_payload(payload: &str) -> String {
|
||||
let compact = match serde_json::from_str::<Value>(payload) {
|
||||
Ok(value) => value.to_string(),
|
||||
Err(_) => payload.trim().to_string(),
|
||||
};
|
||||
truncate_for_summary(&compact, 96)
|
||||
}
|
||||
|
||||
pub(crate) fn truncate_for_summary(value: &str, limit: usize) -> String {
|
||||
let mut chars = value.chars();
|
||||
let truncated = chars.by_ref().take(limit).collect::<String>();
|
||||
if chars.next().is_some() {
|
||||
format!("{truncated}…")
|
||||
} else {
|
||||
truncated
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,755 @@
|
|||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::env;
|
||||
use std::time::UNIX_EPOCH;
|
||||
use runtime::{Session, TokenUsage};
|
||||
use commands::render_slash_command_help_filtered;
|
||||
use crate::constants::*;
|
||||
use crate::models::*;
|
||||
use crate::live_cli::PromptHistoryEntry;
|
||||
use crate::args::LocalHelpTopic;
|
||||
use crate::omc_compatibility_note_for_unknown_slash_command;
|
||||
use crate::render::TerminalRenderer;
|
||||
use super::helpers::*;
|
||||
use super::tools::format_tool_result;
|
||||
|
||||
|
||||
|
||||
pub(crate) fn format_model_report(model: &str, message_count: usize, turns: u32) -> String {
|
||||
format!(
|
||||
"Model
|
||||
Current model {model}
|
||||
Session messages {message_count}
|
||||
Session turns {turns}
|
||||
|
||||
Usage
|
||||
Inspect current model with /model
|
||||
Switch models with /model <name>"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_model_switch_report(previous: &str, next: &str, message_count: usize) -> String {
|
||||
format!(
|
||||
"Model updated
|
||||
Previous {previous}
|
||||
Current {next}
|
||||
Preserved msgs {message_count}"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_permissions_report(mode: &str) -> String {
|
||||
let modes = [
|
||||
("read-only", "Read/search tools only", mode == "read-only"),
|
||||
(
|
||||
"workspace-write",
|
||||
"Edit files inside the workspace",
|
||||
mode == "workspace-write",
|
||||
),
|
||||
(
|
||||
"danger-full-access",
|
||||
"Unrestricted tool access",
|
||||
mode == "danger-full-access",
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(name, description, is_current)| {
|
||||
let marker = if is_current {
|
||||
"● current"
|
||||
} else {
|
||||
"○ available"
|
||||
};
|
||||
format!(" {name:<18} {marker:<11} {description}")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
format!(
|
||||
"Permissions
|
||||
Active mode {mode}
|
||||
Mode status live session default
|
||||
|
||||
Modes
|
||||
{modes}
|
||||
|
||||
Usage
|
||||
Inspect current mode with /permissions
|
||||
Switch modes with /permissions <mode>"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_permissions_switch_report(previous: &str, next: &str) -> String {
|
||||
format!(
|
||||
"Permissions updated
|
||||
Result mode switched
|
||||
Previous mode {previous}
|
||||
Active mode {next}
|
||||
Applies to subsequent tool calls
|
||||
Usage /permissions to inspect current mode"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_cost_report(usage: TokenUsage) -> String {
|
||||
format!(
|
||||
"Cost
|
||||
Input tokens {}
|
||||
Output tokens {}
|
||||
Cache create {}
|
||||
Cache read {}
|
||||
Total tokens {}",
|
||||
usage.input_tokens,
|
||||
usage.output_tokens,
|
||||
usage.cache_creation_input_tokens,
|
||||
usage.cache_read_input_tokens,
|
||||
usage.total_tokens(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String {
|
||||
format!(
|
||||
"Session resumed
|
||||
Session file {session_path}
|
||||
Messages {message_count}
|
||||
Turns {turns}"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn render_resume_usage() -> String {
|
||||
format!(
|
||||
"Resume
|
||||
Usage /resume <session-path|session-id|{LATEST_SESSION_REFERENCE}>
|
||||
Auto-save .claw/sessions/<session-id>.{PRIMARY_SESSION_EXTENSION}
|
||||
Tip use /session list to inspect saved sessions"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn render_repl_help() -> String {
|
||||
[
|
||||
"REPL".to_string(),
|
||||
" /exit Quit the REPL".to_string(),
|
||||
" /quit Quit the REPL".to_string(),
|
||||
" Up/Down Navigate prompt history".to_string(),
|
||||
" Ctrl-R Reverse-search prompt history".to_string(),
|
||||
" Tab Complete commands, modes, and recent sessions".to_string(),
|
||||
" Ctrl-C Clear input (or exit on empty prompt)".to_string(),
|
||||
" Shift+Enter/Ctrl+J Insert a newline".to_string(),
|
||||
" Auto-save .claw/sessions/<session-id>.jsonl".to_string(),
|
||||
" Resume latest /resume latest".to_string(),
|
||||
" Browse sessions /session list".to_string(),
|
||||
" Show prompt history /history [count]".to_string(),
|
||||
String::new(),
|
||||
render_slash_command_help_filtered(STUB_COMMANDS),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn full_help_text() -> String {
|
||||
let mut out = Vec::new();
|
||||
let _ = print_help_to(&mut out);
|
||||
String::from_utf8_lossy(&out).to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn print_help_to(out: &mut impl std::io::Write) -> std::io::Result<()> {
|
||||
writeln!(out, "claw v{VERSION}")?;
|
||||
writeln!(out)?;
|
||||
writeln!(out, "Usage:")?;
|
||||
writeln!(
|
||||
out,
|
||||
" claw [--model MODEL] [--allowedTools TOOL[,TOOL...]]"
|
||||
)?;
|
||||
writeln!(out, " Start the interactive REPL")?;
|
||||
writeln!(
|
||||
out,
|
||||
" claw [--model MODEL] [--output-format text|json] prompt TEXT"
|
||||
)?;
|
||||
writeln!(out, " Send one prompt and exit")?;
|
||||
writeln!(
|
||||
out,
|
||||
" claw [--model MODEL] [--output-format text|json] TEXT"
|
||||
)?;
|
||||
writeln!(out, " Shorthand non-interactive prompt mode")?;
|
||||
writeln!(
|
||||
out,
|
||||
" claw --resume [SESSION.jsonl|session-id|latest] [/status] [/compact] [...]"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" Inspect or maintain a saved session without entering the REPL"
|
||||
)?;
|
||||
writeln!(out, " claw help")?;
|
||||
writeln!(out, " Alias for --help")?;
|
||||
writeln!(out, " claw version")?;
|
||||
writeln!(out, " Alias for --version")?;
|
||||
writeln!(out, " claw status")?;
|
||||
writeln!(
|
||||
out,
|
||||
" Show the current local workspace status snapshot"
|
||||
)?;
|
||||
writeln!(out, " claw sandbox")?;
|
||||
writeln!(out, " Show the current sandbox isolation snapshot")?;
|
||||
writeln!(out, " claw doctor")?;
|
||||
writeln!(
|
||||
out,
|
||||
" Diagnose local auth, config, workspace, and sandbox health"
|
||||
)?;
|
||||
writeln!(out, " claw acp [serve]")?;
|
||||
writeln!(
|
||||
out,
|
||||
" Show ACP/Zed editor integration status (currently unsupported; aliases: --acp, -acp)"
|
||||
)?;
|
||||
writeln!(out, " Source of truth: {OFFICIAL_REPO_SLUG}")?;
|
||||
writeln!(
|
||||
out,
|
||||
" Warning: do not `{DEPRECATED_INSTALL_COMMAND}` (deprecated stub)"
|
||||
)?;
|
||||
writeln!(out, " claw dump-manifests [--manifests-dir PATH]")?;
|
||||
writeln!(out, " claw bootstrap-plan")?;
|
||||
writeln!(out, " claw agents")?;
|
||||
writeln!(out, " claw mcp")?;
|
||||
writeln!(out, " claw skills")?;
|
||||
writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?;
|
||||
writeln!(out, " claw init")?;
|
||||
writeln!(
|
||||
out,
|
||||
" claw export [PATH] [--session SESSION] [--output PATH]"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" Dump the latest (or named) session as markdown; writes to PATH or stdout"
|
||||
)?;
|
||||
writeln!(out)?;
|
||||
writeln!(out, "Flags:")?;
|
||||
writeln!(
|
||||
out,
|
||||
" --model MODEL Override the active model"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" --output-format FORMAT Non-interactive output format: text or json"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" --compact Strip tool call details; print only the final assistant text (text mode only; useful for piping)"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" --permission-mode MODE Set read-only, workspace-write, or danger-full-access"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" --dangerously-skip-permissions Skip all permission checks"
|
||||
)?;
|
||||
writeln!(out, " --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)")?;
|
||||
writeln!(
|
||||
out,
|
||||
" --version, -V Print version and build information locally"
|
||||
)?;
|
||||
writeln!(out)?;
|
||||
writeln!(out, "Interactive slash commands:")?;
|
||||
writeln!(out, "{}", render_slash_command_help_filtered(STUB_COMMANDS))?;
|
||||
writeln!(out)?;
|
||||
let resume_commands = commands::resume_supported_slash_commands()
|
||||
.into_iter()
|
||||
.map(|spec| match spec.argument_hint {
|
||||
Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
|
||||
None => format!("/{}", spec.name),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
writeln!(out, "Resume-safe commands: {resume_commands}")?;
|
||||
writeln!(out)?;
|
||||
writeln!(out, "Session shortcuts:")?;
|
||||
writeln!(
|
||||
out,
|
||||
" REPL turns auto-save to .claw/sessions/<session-id>.{PRIMARY_SESSION_EXTENSION}"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" Use `{LATEST_SESSION_REFERENCE}` with --resume, /resume, or /session switch to target the newest saved session"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" Use /session list in the REPL to browse managed sessions"
|
||||
)?;
|
||||
writeln!(out, "Examples:")?;
|
||||
writeln!(out, " claw --model claude-opus \"summarize this repo\"")?;
|
||||
writeln!(
|
||||
out,
|
||||
" claw --output-format json prompt \"explain src/main.rs\""
|
||||
)?;
|
||||
writeln!(out, " claw --compact \"summarize Cargo.toml\" | wc -l")?;
|
||||
writeln!(
|
||||
out,
|
||||
" claw --allowedTools read,glob \"summarize Cargo.toml\""
|
||||
)?;
|
||||
writeln!(out, " claw --resume {LATEST_SESSION_REFERENCE}")?;
|
||||
writeln!(
|
||||
out,
|
||||
" claw --resume {LATEST_SESSION_REFERENCE} /status /diff /export notes.txt"
|
||||
)?;
|
||||
writeln!(out, " claw agents")?;
|
||||
writeln!(out, " claw mcp show my-server")?;
|
||||
writeln!(out, " claw /skills")?;
|
||||
writeln!(out, " claw doctor")?;
|
||||
writeln!(out, " source of truth: {OFFICIAL_REPO_URL}")?;
|
||||
writeln!(
|
||||
out,
|
||||
" do not run `{DEPRECATED_INSTALL_COMMAND}` — it installs a deprecated stub"
|
||||
)?;
|
||||
writeln!(out, " claw init")?;
|
||||
writeln!(out, " claw export")?;
|
||||
writeln!(out, " claw export conversation.md")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
pub(crate) fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String {
|
||||
if skipped {
|
||||
format!(
|
||||
"Compact
|
||||
Result skipped
|
||||
Reason session below compaction threshold
|
||||
Messages kept {resulting_messages}"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Compact
|
||||
Result compacted
|
||||
Messages removed {removed}
|
||||
Messages kept {resulting_messages}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_auto_compaction_notice(removed: usize) -> String {
|
||||
format!("[auto-compacted: removed {removed} messages]")
|
||||
}
|
||||
|
||||
pub(crate) fn render_help_topic(topic: LocalHelpTopic) -> String {
|
||||
match topic {
|
||||
LocalHelpTopic::Status => "Status
|
||||
Usage claw status [--output-format <format>]
|
||||
Purpose show local workspace snapshot without entering REPL
|
||||
Output model, permissions, git state, config files, and sandbox status
|
||||
Formats text (default), json
|
||||
Related /status · claw --resume latest /status"
|
||||
.to_string(),
|
||||
LocalHelpTopic::Sandbox => "Sandbox
|
||||
Usage claw sandbox [--output-format <format>]
|
||||
Purpose inspect resolved sandbox and isolation state for current directory
|
||||
Output namespace, network, filesystem, and fallback details
|
||||
Formats text (default), json
|
||||
Related /sandbox · claw status"
|
||||
.to_string(),
|
||||
LocalHelpTopic::Doctor => "Doctor
|
||||
Usage claw doctor [--output-format <format>]
|
||||
Purpose diagnose local auth, config, workspace, sandbox, and build metadata
|
||||
Output local-only health report; no provider request or session resume required
|
||||
Formats text (default), json
|
||||
Related /doctor · claw --resume latest /doctor"
|
||||
.to_string(),
|
||||
LocalHelpTopic::Acp => "ACP / Zed
|
||||
Usage claw acp [serve] [--output-format <format>]
|
||||
Aliases claw --acp · claw -acp
|
||||
Purpose explain current editor-facing ACP/Zed launch contract without starting runtime
|
||||
Status discoverability only; `serve` is a status alias and does not launch daemon yet
|
||||
Formats text (default), json
|
||||
Related ROADMAP #64a (discoverability) · ROADMAP #76 (real ACP support) · claw --help"
|
||||
.to_string(),
|
||||
LocalHelpTopic::Init => "Init
|
||||
Usage claw init [--output-format <format>]
|
||||
Purpose create .claw/, .claw.json, .gitignore, and CLAUDE.md in current project
|
||||
Output list of created vs skipped files (idempotent: safe to re-run)
|
||||
Formats text (default), json
|
||||
Related claw status · claw doctor"
|
||||
.to_string(),
|
||||
LocalHelpTopic::State => "State
|
||||
Usage claw state [--output-format <format>]
|
||||
Purpose read .claw/worker-state.json written by interactive REPL or one-shot prompt
|
||||
Output worker id, model, permissions, session reference (text or json)
|
||||
Formats text (default), json
|
||||
Produces state `claw` (interactive REPL) or `claw prompt <text>` (one non-interactive turn)
|
||||
Observes state `claw state` reads; clawhip/CI may poll this file without HTTP
|
||||
Exit codes 0 if state file exists and parses; 1 with actionable hint otherwise
|
||||
Related claw status · ROADMAP #139 (this worker-concept contract)"
|
||||
.to_string(),
|
||||
LocalHelpTopic::Export => "Export
|
||||
Usage claw export [--session <id|latest>] [--output <path>] [--output-format <format>]
|
||||
Purpose serialize managed session to JSON for review, transfer, or archival
|
||||
Defaults --session latest (most recent managed session in .claw/sessions/)
|
||||
Formats text (default), json
|
||||
Related /session list · claw --resume latest"
|
||||
.to_string(),
|
||||
LocalHelpTopic::Version => "Version
|
||||
Usage claw version [--output-format <format>]
|
||||
Aliases claw --version · claw -V
|
||||
Purpose print claw CLI version and build metadata
|
||||
Formats text (default), json
|
||||
Related claw doctor (full build/auth/config diagnostic)"
|
||||
.to_string(),
|
||||
LocalHelpTopic::SystemPrompt => "System Prompt
|
||||
Usage claw system-prompt [--cwd <path>] [--date YYYY-MM-DD] [--output-format <format>]
|
||||
Purpose render resolved system prompt that `claw` would send for given cwd + date
|
||||
Options --cwd overrides workspace dir · --date injects deterministic date stamp
|
||||
Formats text (default), json
|
||||
Related claw doctor · claw dump-manifests"
|
||||
.to_string(),
|
||||
LocalHelpTopic::DumpManifests => "Dump Manifests
|
||||
Usage claw dump-manifests [--manifests-dir <path>] [--output-format <format>]
|
||||
Purpose emit every skill/agent/tool manifest resolver would load for current cwd
|
||||
Options --manifests-dir scopes discovery to specific directory
|
||||
Formats text (default), json
|
||||
Related claw skills · claw agents · claw doctor"
|
||||
.to_string(),
|
||||
LocalHelpTopic::BootstrapPlan => "Bootstrap Plan
|
||||
Usage claw bootstrap-plan [--output-format <format>]
|
||||
Purpose list ordered startup phases CLI would execute before dispatch
|
||||
Output phase names (text) or structured phase list (json) — primary output is plan itself
|
||||
Formats text (default), json
|
||||
Related claw doctor · claw status"
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render_teleport_report(target: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
|
||||
let file_list = Command::new("rg")
|
||||
.args(["--files"])
|
||||
.current_dir(&cwd)
|
||||
.output()?;
|
||||
let file_matches = if file_list.status.success() {
|
||||
String::from_utf8(file_list.stdout)?
|
||||
.lines()
|
||||
.filter(|line| line.contains(target))
|
||||
.take(10)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let content_output = Command::new("rg")
|
||||
.args(["-n", "-S", "--color", "never", target, "."])
|
||||
.current_dir(&cwd)
|
||||
.output()?;
|
||||
|
||||
let mut lines = vec![
|
||||
"Teleport".to_string(),
|
||||
format!(" Target {target}"),
|
||||
" Action search workspace files and content for target".to_string(),
|
||||
];
|
||||
if !file_matches.is_empty() {
|
||||
lines.push(String::new());
|
||||
lines.push("File matches".to_string());
|
||||
lines.extend(file_matches.into_iter().map(|path| format!(" {path}")));
|
||||
}
|
||||
|
||||
if content_output.status.success() {
|
||||
let matches = String::from_utf8(content_output.stdout)?;
|
||||
if !matches.trim().is_empty() {
|
||||
lines.push(String::new());
|
||||
lines.push("Content matches".to_string());
|
||||
lines.push(truncate_for_prompt(&matches, 4_000));
|
||||
}
|
||||
}
|
||||
|
||||
if lines.len() == 1 {
|
||||
lines.push(" Result no matches found".to_string());
|
||||
}
|
||||
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
pub(crate) fn render_last_tool_debug_report(session: &Session) -> Result<String, Box<dyn std::error::Error>> {
|
||||
use runtime::ContentBlock;
|
||||
let last_tool_use = session
|
||||
.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|message| {
|
||||
message.blocks.iter().rev().find_map(|block| match block {
|
||||
ContentBlock::ToolUse { id, name, input } => {
|
||||
Some((id.clone(), name.clone(), input.clone()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| "no prior tool call found in session".to_string())?;
|
||||
|
||||
let tool_result = session.messages.iter().rev().find_map(|message| {
|
||||
message.blocks.iter().rev().find_map(|block| match block {
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
tool_name,
|
||||
output,
|
||||
is_error,
|
||||
} if tool_use_id == &last_tool_use.0 => {
|
||||
Some((tool_name.clone(), output.clone(), *is_error))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
|
||||
let mut lines = vec![
|
||||
"Debug tool call".to_string(),
|
||||
" Action inspect last recorded tool call and its result".to_string(),
|
||||
format!(" Tool id {}", last_tool_use.0),
|
||||
format!(" Tool name {}", last_tool_use.1),
|
||||
" Input".to_string(),
|
||||
indent_block(&last_tool_use.2, 4),
|
||||
];
|
||||
|
||||
match tool_result {
|
||||
Some((tool_name, output, is_error)) => {
|
||||
lines.push(" Result".to_string());
|
||||
lines.push(format!(" name {tool_name}"));
|
||||
lines.push(format!(
|
||||
" status {}",
|
||||
if is_error { "error" } else { "ok" }
|
||||
));
|
||||
lines.push(indent_block(&output, 4));
|
||||
}
|
||||
None => lines.push(" Result missing tool result".to_string()),
|
||||
}
|
||||
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
pub(crate) fn format_bughunter_report(scope: Option<&str>) -> String {
|
||||
format!(
|
||||
"Bughunter
|
||||
Scope {}
|
||||
Action inspect the selected code for likely bugs and correctness issues
|
||||
Output findings should include file paths, severity, and suggested fixes",
|
||||
scope.unwrap_or("current repository")
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_ultraplan_report(task: Option<&str>) -> String {
|
||||
format!(
|
||||
"Ultraplan
|
||||
Task {}
|
||||
Action break work into a multi-step execution plan
|
||||
Output plan should cover goals, risks, sequencing, verification, and rollback",
|
||||
task.unwrap_or("current repo work")
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_pr_report(branch: &str, context: Option<&str>) -> String {
|
||||
format!(
|
||||
"PR
|
||||
Branch {branch}
|
||||
Context {}
|
||||
Action draft or create a pull request for current branch
|
||||
Output title and markdown body suitable for GitHub",
|
||||
context.unwrap_or("none")
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_issue_report(context: Option<&str>) -> String {
|
||||
format!(
|
||||
"Issue
|
||||
Context {}
|
||||
Action draft or create a GitHub issue from current context
|
||||
Output title and markdown body suitable for GitHub",
|
||||
context.unwrap_or("none")
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_history_timestamp(timestamp_ms: u64) -> String {
|
||||
let secs = timestamp_ms / 1_000;
|
||||
let subsec_ms = timestamp_ms % 1_000;
|
||||
let days_since_epoch = secs / 86_400;
|
||||
let seconds_of_day = secs % 86_400;
|
||||
let hours = seconds_of_day / 3_600;
|
||||
let minutes = (seconds_of_day % 3_600) / 60;
|
||||
let seconds = seconds_of_day % 60;
|
||||
|
||||
let (year, month, day) = civil_from_days(i64::try_from(days_since_epoch).unwrap_or(0));
|
||||
format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}.{subsec_ms:03}Z")
|
||||
}
|
||||
|
||||
pub(crate) fn render_prompt_history_report(entries: &[PromptHistoryEntry], limit: usize) -> String {
|
||||
if entries.is_empty() {
|
||||
return "Prompt history\n Result no prompts recorded yet".to_string();
|
||||
}
|
||||
|
||||
let total = entries.len();
|
||||
let start = total.saturating_sub(limit);
|
||||
let shown = &entries[start..];
|
||||
let mut lines = vec![
|
||||
"Prompt history".to_string(),
|
||||
format!(" Total {total}"),
|
||||
format!(" Showing {} most recent", shown.len()),
|
||||
format!(" Reverse search Ctrl-R in the REPL"),
|
||||
String::new(),
|
||||
];
|
||||
for (offset, entry) in shown.iter().enumerate() {
|
||||
let absolute_index = start + offset + 1;
|
||||
let timestamp = format_history_timestamp(entry.timestamp_ms);
|
||||
let first_line = entry.text.lines().next().unwrap_or("").trim();
|
||||
let display = if first_line.chars().count() > 80 {
|
||||
let truncated: String = first_line.chars().take(77).collect();
|
||||
format!("{truncated}...")
|
||||
} else {
|
||||
first_line.to_string()
|
||||
};
|
||||
lines.push(format!(" {absolute_index:>3}. [{timestamp}] {display}"));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn render_export_text(session: &Session) -> String {
|
||||
use runtime::MessageRole;
|
||||
use runtime::ContentBlock;
|
||||
|
||||
let mut lines = vec!["# Conversation Export".to_string(), String::new()];
|
||||
for (index, message) in session.messages.iter().enumerate() {
|
||||
let role = match message.role {
|
||||
MessageRole::System => "system",
|
||||
MessageRole::User => "user",
|
||||
MessageRole::Assistant => "assistant",
|
||||
MessageRole::Tool => "tool",
|
||||
};
|
||||
lines.push(format!("## {}. {role}", index + 1));
|
||||
for block in &message.blocks {
|
||||
match block {
|
||||
ContentBlock::Text { text } => lines.push(text.clone()),
|
||||
ContentBlock::ToolUse { id, name, input } => {
|
||||
lines.push(format!("[tool_use id={id} name={name}] {input}"));
|
||||
}
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
tool_name,
|
||||
output,
|
||||
is_error,
|
||||
} => {
|
||||
lines.push(format!(
|
||||
"[tool_result id={tool_use_id} name={tool_name} error={is_error}] {output}"
|
||||
));
|
||||
}
|
||||
ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} => {
|
||||
lines.push(format!("[thinking signature={signature:?}] {thinking}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn render_version_report() -> String {
|
||||
let git_sha = GIT_SHA.unwrap_or("unknown");
|
||||
let target = BUILD_TARGET.unwrap_or("unknown");
|
||||
format!(
|
||||
"Claw Code\n Version {VERSION}\n Git SHA {git_sha}\n Target {target}\n Build date {DEFAULT_DATE}"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn render_session_markdown(session: &Session, session_id: &str, session_path: &Path) -> String {
|
||||
use runtime::{MessageRole, ContentBlock};
|
||||
use super::tools::short_tool_id;
|
||||
use super::tools::summarize_tool_payload_for_markdown;
|
||||
|
||||
let mut lines = vec![
|
||||
"# Conversation Export".to_string(),
|
||||
String::new(),
|
||||
format!("- **Session**: `{session_id}`"),
|
||||
format!("- **File**: `{}`", session_path.display()),
|
||||
format!("- **Messages**: {}", session.messages.len()),
|
||||
];
|
||||
if let Some(workspace_root) = session.workspace_root() {
|
||||
lines.push(format!("- **Workspace**: `{}`", workspace_root.display()));
|
||||
}
|
||||
if let Some(fork) = &session.fork {
|
||||
let branch = fork.branch_name.as_deref().unwrap_or("(unnamed)");
|
||||
lines.push(format!(
|
||||
"- **Forked from**: `{}` (branch `{branch}`)",
|
||||
fork.parent_session_id
|
||||
));
|
||||
}
|
||||
if let Some(compaction) = &session.compaction {
|
||||
lines.push(format!(
|
||||
"- **Compactions**: {} (last removed {} messages)",
|
||||
compaction.count, compaction.removed_message_count
|
||||
));
|
||||
}
|
||||
lines.push(String::new());
|
||||
lines.push("---".to_string());
|
||||
lines.push(String::new());
|
||||
|
||||
for (index, message) in session.messages.iter().enumerate() {
|
||||
let role = match message.role {
|
||||
MessageRole::System => "System",
|
||||
MessageRole::User => "User",
|
||||
MessageRole::Assistant => "Assistant",
|
||||
MessageRole::Tool => "Tool",
|
||||
};
|
||||
lines.push(format!("## {}. {role}", index + 1));
|
||||
lines.push(String::new());
|
||||
for block in &message.blocks {
|
||||
match block {
|
||||
ContentBlock::Text { text } => {
|
||||
let trimmed = text.trim_end();
|
||||
if !trimmed.is_empty() {
|
||||
lines.push(trimmed.to_string());
|
||||
lines.push(String::new());
|
||||
}
|
||||
}
|
||||
ContentBlock::ToolUse { id, name, input } => {
|
||||
lines.push(format!(
|
||||
"**Tool call** `{name}` _(id `{}`)_",
|
||||
short_tool_id(id)
|
||||
));
|
||||
let summary = summarize_tool_payload_for_markdown(input);
|
||||
if !summary.is_empty() {
|
||||
lines.push(format!("> {summary}"));
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
tool_name,
|
||||
output,
|
||||
is_error,
|
||||
} => {
|
||||
let status = if *is_error { "error" } else { "ok" };
|
||||
lines.push(format!(
|
||||
"**Tool result** `{tool_name}` _(id `{}`, {status})_",
|
||||
short_tool_id(tool_use_id)
|
||||
));
|
||||
let summary = summarize_tool_payload_for_markdown(output);
|
||||
if !summary.is_empty() {
|
||||
lines.push(format!("> {summary}"));
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature: _,
|
||||
} => {
|
||||
lines.push(format!("**Thinking**"));
|
||||
let summary = summarize_tool_payload_for_markdown(thinking);
|
||||
if !summary.is_empty() {
|
||||
lines.push(format!("> {summary}"));
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(usage) = message.usage {
|
||||
lines.push(format!(
|
||||
"_tokens: in={} out={} cache_create={} cache_read={}_",
|
||||
usage.input_tokens,
|
||||
usage.output_tokens,
|
||||
usage.cache_creation_input_tokens,
|
||||
usage.cache_read_input_tokens,
|
||||
));
|
||||
lines.push(String::new());
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
|
@ -0,0 +1,492 @@
|
|||
use std::io::{self, Write};
|
||||
use serde_json::json;
|
||||
use runtime::{RuntimeError, AssistantEvent};
|
||||
use api::OutputContentBlock;
|
||||
use crate::render::TerminalRenderer;
|
||||
use super::helpers::*;
|
||||
use super::{
|
||||
DISPLAY_TRUNCATION_NOTICE, READ_DISPLAY_MAX_CHARS, READ_DISPLAY_MAX_LINES,
|
||||
SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT, TOOL_OUTPUT_DISPLAY_MAX_CHARS,
|
||||
TOOL_OUTPUT_DISPLAY_MAX_LINES,
|
||||
};
|
||||
|
||||
pub(crate) fn short_tool_id(id: &str) -> String {
|
||||
let char_count = id.chars().count();
|
||||
if char_count <= 12 {
|
||||
return id.to_string();
|
||||
}
|
||||
let prefix: String = id.chars().take(12).collect();
|
||||
format!("{prefix}…")
|
||||
}
|
||||
|
||||
pub(crate) fn summarize_tool_payload_for_markdown(payload: &str) -> String {
|
||||
let compact = match serde_json::from_str::<serde_json::Value>(payload) {
|
||||
Ok(value) => value.to_string(),
|
||||
Err(_) => payload.split_whitespace().collect::<Vec<_>>().join(" "),
|
||||
};
|
||||
if compact.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
truncate_for_summary(&compact, SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT)
|
||||
}
|
||||
|
||||
pub(crate) fn format_tool_call_start(name: &str, input: &str) -> String {
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string()));
|
||||
|
||||
let detail = match name {
|
||||
"bash" | "Bash" => format_bash_call(&parsed),
|
||||
"read_file" | "Read" => {
|
||||
let path = extract_tool_path(&parsed);
|
||||
format!("\x1b[2m📄 Reading {path}…\x1b[0m")
|
||||
}
|
||||
"write_file" | "Write" => {
|
||||
let path = extract_tool_path(&parsed);
|
||||
let lines = parsed
|
||||
.get("content")
|
||||
.and_then(|value| value.as_str())
|
||||
.map_or(0, |content| content.lines().count());
|
||||
format!("\x1b[1;32m✏️ Writing {path}\x1b[0m \x1b[2m({lines} lines)\x1b[0m")
|
||||
}
|
||||
"edit_file" | "Edit" => {
|
||||
let path = extract_tool_path(&parsed);
|
||||
let old_value = parsed
|
||||
.get("old_string")
|
||||
.or_else(|| parsed.get("oldString"))
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default();
|
||||
let new_value = parsed
|
||||
.get("new_string")
|
||||
.or_else(|| parsed.get("newString"))
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"\x1b[1;33m📝 Editing {path}\x1b[0m{}",
|
||||
format_patch_preview(old_value, new_value)
|
||||
.map(|preview| format!("\n{preview}"))
|
||||
.unwrap_or_default()
|
||||
)
|
||||
}
|
||||
"glob_search" | "Glob" => format_search_start("🔎 Glob", &parsed),
|
||||
"grep_search" | "Grep" => format_search_start("🔎 Grep", &parsed),
|
||||
"web_search" | "WebSearch" => parsed
|
||||
.get("query")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("?")
|
||||
.to_string(),
|
||||
_ => summarize_tool_payload(input),
|
||||
};
|
||||
|
||||
let border = "─".repeat(name.len() + 8);
|
||||
format!(
|
||||
"\x1b[38;5;245m╭─ \x1b[1;36m{name}\x1b[0;38;5;245m ─╮\x1b[0m\n\x1b[38;5;245m│\x1b[0m {detail}\n\x1b[38;5;245m╰{border}╯\x1b[0m"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_tool_result(name: &str, output: &str, is_error: bool) -> String {
|
||||
let icon = if is_error {
|
||||
"\x1b[1;31m✗\x1b[0m"
|
||||
} else {
|
||||
"\x1b[1;32m✓\x1b[0m"
|
||||
};
|
||||
if is_error {
|
||||
let summary = truncate_for_summary(output.trim(), 160);
|
||||
return if summary.is_empty() {
|
||||
format!("{icon} \x1b[38;5;245m{name}\x1b[0m")
|
||||
} else {
|
||||
format!("{icon} \x1b[38;5;245m{name}\x1b[0m\n\x1b[38;5;203m{summary}\x1b[0m")
|
||||
};
|
||||
}
|
||||
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(output).unwrap_or(serde_json::Value::String(output.to_string()));
|
||||
match name {
|
||||
"bash" | "Bash" => format_bash_result(icon, &parsed),
|
||||
"read_file" | "Read" => format_read_result(icon, &parsed),
|
||||
"write_file" | "Write" => format_write_result(icon, &parsed),
|
||||
"edit_file" | "Edit" => format_edit_result(icon, &parsed),
|
||||
"glob_search" | "Glob" => format_glob_result(icon, &parsed),
|
||||
"grep_search" | "Grep" => format_grep_result(icon, &parsed),
|
||||
_ => format_generic_tool_result(icon, name, &parsed),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_search_start(label: &str, parsed: &serde_json::Value) -> String {
|
||||
let pattern = parsed
|
||||
.get("pattern")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("?");
|
||||
let scope = parsed
|
||||
.get("path")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or(".");
|
||||
format!("{label} {pattern}\n\x1b[2min {scope}\x1b[0m")
|
||||
}
|
||||
|
||||
pub(crate) fn format_patch_preview(old_value: &str, new_value: &str) -> Option<String> {
|
||||
if old_value.is_empty() && new_value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"\x1b[38;5;203m- {}\x1b[0m\n\x1b[38;5;70m+ {}\x1b[0m",
|
||||
truncate_for_summary(first_visible_line(old_value), 72),
|
||||
truncate_for_summary(first_visible_line(new_value), 72)
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn format_bash_call(parsed: &serde_json::Value) -> String {
|
||||
let command = parsed
|
||||
.get("command")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default();
|
||||
if command.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
"\x1b[48;5;236;38;5;255m $ {} \x1b[0m",
|
||||
truncate_for_summary(command, 160)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_bash_result(icon: &str, parsed: &serde_json::Value) -> String {
|
||||
use std::fmt::Write as _;
|
||||
|
||||
let mut lines = vec![format!("{icon} \x1b[38;5;245mbash\x1b[0m")];
|
||||
if let Some(task_id) = parsed
|
||||
.get("backgroundTaskId")
|
||||
.and_then(|value| value.as_str())
|
||||
{
|
||||
write!(&mut lines[0], " backgrounded ({task_id})").expect("write to string");
|
||||
} else if let Some(status) = parsed
|
||||
.get("returnCodeInterpretation")
|
||||
.and_then(|value| value.as_str())
|
||||
.filter(|status| !status.is_empty())
|
||||
{
|
||||
write!(&mut lines[0], " {status}").expect("write to string");
|
||||
}
|
||||
|
||||
if let Some(stdout) = parsed.get("stdout").and_then(|value| value.as_str()) {
|
||||
if !stdout.trim().is_empty() {
|
||||
lines.push(truncate_output_for_display(
|
||||
stdout,
|
||||
TOOL_OUTPUT_DISPLAY_MAX_LINES,
|
||||
TOOL_OUTPUT_DISPLAY_MAX_CHARS,
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(stderr) = parsed.get("stderr").and_then(|value| value.as_str()) {
|
||||
if !stderr.trim().is_empty() {
|
||||
lines.push(format!(
|
||||
"\x1b[38;5;203m{}\x1b[0m",
|
||||
truncate_output_for_display(
|
||||
stderr,
|
||||
TOOL_OUTPUT_DISPLAY_MAX_LINES,
|
||||
TOOL_OUTPUT_DISPLAY_MAX_CHARS,
|
||||
)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n\n")
|
||||
}
|
||||
|
||||
pub(crate) fn format_read_result(icon: &str, parsed: &serde_json::Value) -> String {
|
||||
let file = parsed.get("file").unwrap_or(parsed);
|
||||
let path = extract_tool_path(file);
|
||||
let start_line = file
|
||||
.get("startLine")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or(1);
|
||||
let num_lines = file
|
||||
.get("numLines")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let total_lines = file
|
||||
.get("totalLines")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or(num_lines);
|
||||
let content = file
|
||||
.get("content")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default();
|
||||
let end_line = start_line.saturating_add(num_lines.saturating_sub(1));
|
||||
|
||||
format!(
|
||||
"{icon} \x1b[2m📄 Read {path} (lines {}-{} of {})\x1b[0m\n{}",
|
||||
start_line,
|
||||
end_line.max(start_line),
|
||||
total_lines,
|
||||
truncate_output_for_display(content, READ_DISPLAY_MAX_LINES, READ_DISPLAY_MAX_CHARS)
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_write_result(icon: &str, parsed: &serde_json::Value) -> String {
|
||||
let path = extract_tool_path(parsed);
|
||||
let kind = parsed
|
||||
.get("type")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("write");
|
||||
let line_count = parsed
|
||||
.get("content")
|
||||
.and_then(|value| value.as_str())
|
||||
.map_or(0, |content| content.lines().count());
|
||||
format!(
|
||||
"{icon} \x1b[1;32m✏️ {} {path}\x1b[0m \x1b[2m({line_count} lines)\x1b[0m",
|
||||
if kind == "create" { "Wrote" } else { "Updated" },
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_structured_patch_preview(parsed: &serde_json::Value) -> Option<String> {
|
||||
let hunks = parsed.get("structuredPatch")?.as_array()?;
|
||||
let mut preview = Vec::new();
|
||||
for hunk in hunks.iter().take(2) {
|
||||
let lines = hunk.get("lines")?.as_array()?;
|
||||
for line in lines.iter().filter_map(|value| value.as_str()).take(6) {
|
||||
match line.chars().next() {
|
||||
Some('+') => preview.push(format!("\x1b[38;5;70m{line}\x1b[0m")),
|
||||
Some('-') => preview.push(format!("\x1b[38;5;203m{line}\x1b[0m")),
|
||||
_ => preview.push(line.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
if preview.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(preview.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String {
|
||||
let path = extract_tool_path(parsed);
|
||||
let suffix = if parsed
|
||||
.get("replaceAll")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
" (replace all)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let preview = format_structured_patch_preview(parsed).or_else(|| {
|
||||
let old_value = parsed
|
||||
.get("oldString")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default();
|
||||
let new_value = parsed
|
||||
.get("newString")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default();
|
||||
format_patch_preview(old_value, new_value)
|
||||
});
|
||||
|
||||
match preview {
|
||||
Some(preview) => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m\n{preview}"),
|
||||
None => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String {
|
||||
let num_files = parsed
|
||||
.get("numFiles")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let filenames = parsed
|
||||
.get("filenames")
|
||||
.and_then(|value| value.as_array())
|
||||
.map(|files| {
|
||||
files
|
||||
.iter()
|
||||
.filter_map(|value| value.as_str())
|
||||
.take(8)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if filenames.is_empty() {
|
||||
format!("{icon} \x1b[38;5;245mglob_search\x1b[0m matched {num_files} files")
|
||||
} else {
|
||||
format!("{icon} \x1b[38;5;245mglob_search\x1b[0m matched {num_files} files\n{filenames}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_grep_result(icon: &str, parsed: &serde_json::Value) -> String {
|
||||
let num_matches = parsed
|
||||
.get("numMatches")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let num_files = parsed
|
||||
.get("numFiles")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let content = parsed
|
||||
.get("content")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default();
|
||||
let filenames = parsed
|
||||
.get("filenames")
|
||||
.and_then(|value| value.as_array())
|
||||
.map(|files| {
|
||||
files
|
||||
.iter()
|
||||
.filter_map(|value| value.as_str())
|
||||
.take(8)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let summary = format!(
|
||||
"{icon} \x1b[38;5;245mgrep_search\x1b[0m {num_matches} matches across {num_files} files"
|
||||
);
|
||||
if !content.trim().is_empty() {
|
||||
format!(
|
||||
"{summary}\n{}",
|
||||
truncate_output_for_display(
|
||||
content,
|
||||
TOOL_OUTPUT_DISPLAY_MAX_LINES,
|
||||
TOOL_OUTPUT_DISPLAY_MAX_CHARS,
|
||||
)
|
||||
)
|
||||
} else if !filenames.is_empty() {
|
||||
format!("{summary}\n{filenames}")
|
||||
} else {
|
||||
summary
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_generic_tool_result(icon: &str, name: &str, parsed: &serde_json::Value) -> String {
|
||||
let rendered_output = match parsed {
|
||||
serde_json::Value::String(text) => text.clone(),
|
||||
serde_json::Value::Null => String::new(),
|
||||
serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
|
||||
serde_json::to_string_pretty(parsed).unwrap_or_else(|_| parsed.to_string())
|
||||
}
|
||||
_ => parsed.to_string(),
|
||||
};
|
||||
let preview = truncate_output_for_display(
|
||||
&rendered_output,
|
||||
TOOL_OUTPUT_DISPLAY_MAX_LINES,
|
||||
TOOL_OUTPUT_DISPLAY_MAX_CHARS,
|
||||
);
|
||||
|
||||
if preview.is_empty() {
|
||||
format!("{icon} \x1b[38;5;245m{name}\x1b[0m")
|
||||
} else if preview.contains('\n') {
|
||||
format!("{icon} \x1b[38;5;245m{name}\x1b[0m\n{preview}")
|
||||
} else {
|
||||
format!("{icon} \x1b[38;5;245m{name}:\x1b[0m {preview}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render_thinking_block_summary(
|
||||
out: &mut (impl Write + ?Sized),
|
||||
char_count: Option<usize>,
|
||||
redacted: bool,
|
||||
) -> Result<(), RuntimeError> {
|
||||
let summary = if redacted {
|
||||
"\n▶ Thinking block hidden by provider\n".to_string()
|
||||
} else if let Some(char_count) = char_count {
|
||||
format!("\n▶ Thinking ({char_count} chars hidden)\n")
|
||||
} else {
|
||||
"\n▶ Thinking hidden\n".to_string()
|
||||
};
|
||||
write!(out, "{summary}")
|
||||
.and_then(|()| out.flush())
|
||||
.map_err(|error| RuntimeError::new(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn push_output_block(
|
||||
block: OutputContentBlock,
|
||||
out: &mut (impl Write + ?Sized),
|
||||
events: &mut Vec<AssistantEvent>,
|
||||
pending_tool: &mut Option<(String, String, String)>,
|
||||
streaming_tool_input: bool,
|
||||
block_has_thinking_summary: &mut bool,
|
||||
) -> Result<(), RuntimeError> {
|
||||
match block {
|
||||
OutputContentBlock::Text { text } => {
|
||||
if !text.is_empty() {
|
||||
let rendered = TerminalRenderer::new().markdown_to_ansi(&text);
|
||||
write!(out, "{rendered}")
|
||||
.and_then(|()| out.flush())
|
||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||
events.push(AssistantEvent::TextDelta(text));
|
||||
}
|
||||
}
|
||||
OutputContentBlock::ToolUse { id, name, input } => {
|
||||
// During streaming, the initial content_block_start has an empty input ({}).
|
||||
// The real input arrives via input_json_delta events. In
|
||||
// non-streaming responses, preserve a legitimate empty object.
|
||||
let initial_input = if streaming_tool_input
|
||||
&& input.is_object()
|
||||
&& input.as_object().is_some_and(serde_json::Map::is_empty)
|
||||
{
|
||||
String::new()
|
||||
} else {
|
||||
input.to_string()
|
||||
};
|
||||
*pending_tool = Some((id, name, initial_input));
|
||||
}
|
||||
OutputContentBlock::Thinking { thinking, .. } => {
|
||||
render_thinking_block_summary(out, Some(thinking.chars().count()), false)?;
|
||||
*block_has_thinking_summary = true;
|
||||
}
|
||||
OutputContentBlock::RedactedThinking { .. } => {
|
||||
render_thinking_block_summary(out, None, true)?;
|
||||
*block_has_thinking_summary = true;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn response_to_events(
|
||||
response: api::MessageResponse,
|
||||
out: &mut (impl Write + ?Sized),
|
||||
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||
let mut events = Vec::new();
|
||||
let mut pending_tool = None;
|
||||
|
||||
for block in response.content {
|
||||
let mut block_has_thinking_summary = false;
|
||||
push_output_block(
|
||||
block,
|
||||
out,
|
||||
&mut events,
|
||||
&mut pending_tool,
|
||||
false,
|
||||
&mut block_has_thinking_summary,
|
||||
)?;
|
||||
if let Some((id, name, input)) = pending_tool.take() {
|
||||
events.push(AssistantEvent::ToolUse { id, name, input });
|
||||
}
|
||||
}
|
||||
|
||||
events.push(AssistantEvent::Usage(response.usage.token_usage()));
|
||||
events.push(AssistantEvent::MessageStop);
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
pub(crate) fn push_prompt_cache_record(
|
||||
client: &api::ProviderClient,
|
||||
events: &mut Vec<AssistantEvent>,
|
||||
) {
|
||||
if let Some(record) = client.take_last_prompt_cache_record() {
|
||||
if let Some(event) = prompt_cache_record_to_runtime_event(record) {
|
||||
events.push(AssistantEvent::PromptCache(event));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn prompt_cache_record_to_runtime_event(
|
||||
record: api::PromptCacheRecord,
|
||||
) -> Option<runtime::PromptCacheEvent> {
|
||||
let cache_break = record.cache_break?;
|
||||
Some(runtime::PromptCacheEvent {
|
||||
unexpected: cache_break.unexpected,
|
||||
reason: cache_break.reason,
|
||||
previous_cache_read_input_tokens: cache_break.previous_cache_read_input_tokens,
|
||||
current_cache_read_input_tokens: cache_break.current_cache_read_input_tokens,
|
||||
token_drop: cache_break.token_drop,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::args::CliOutputFormat;
|
||||
|
||||
const STARTER_CLAW_JSON: &str = concat!(
|
||||
"{\n",
|
||||
" \"permissions\": {\n",
|
||||
|
|
@ -162,6 +164,38 @@ pub(crate) fn initialize_repo(cwd: &Path) -> Result<InitReport, Box<dyn std::err
|
|||
})
|
||||
}
|
||||
|
||||
pub(crate) fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
|
||||
let cwd = std::env::current_dir()?;
|
||||
Ok(initialize_repo(&cwd)?.render())
|
||||
}
|
||||
|
||||
pub(crate) fn run_init(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cwd = std::env::current_dir()?;
|
||||
let report = initialize_repo(&cwd)?;
|
||||
let message = report.render();
|
||||
match output_format {
|
||||
CliOutputFormat::Text => println!("{message}"),
|
||||
CliOutputFormat::Json => println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&init_json_value(&report, &message))?
|
||||
),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn init_json_value(report: &InitReport, message: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"kind": "init",
|
||||
"project_root": report.project_root.display().to_string(),
|
||||
"created": report.artifacts_with_status(InitStatus::Created),
|
||||
"updated": report.artifacts_with_status(InitStatus::Updated),
|
||||
"skipped": report.artifacts_with_status(InitStatus::Skipped),
|
||||
"artifacts": report.artifact_json_entries(),
|
||||
"next_step": InitReport::NEXT_STEP,
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_dir(path: &Path) -> Result<InitStatus, std::io::Error> {
|
||||
if path.is_dir() {
|
||||
return Ok(InitStatus::Skipped);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,518 @@
|
|||
use crate::*;
|
||||
use std::env;
|
||||
use std::io::{self, IsTerminal, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
use serde_json::json;
|
||||
use crate::render::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PromptHistoryEntry {
|
||||
pub(crate) timestamp_ms: u64,
|
||||
pub(crate) text: String,
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
pub(crate) struct LiveCli {
|
||||
pub(crate) model: String,
|
||||
pub(crate) allowed_tools: Option<AllowedToolSet>,
|
||||
pub(crate) permission_mode: PermissionMode,
|
||||
pub(crate) system_prompt: Vec<String>,
|
||||
pub(crate) runtime: BuiltRuntime,
|
||||
pub(crate) session: SessionHandle,
|
||||
pub(crate) prompt_history: Vec<PromptHistoryEntry>,
|
||||
}
|
||||
|
||||
impl LiveCli {
|
||||
pub(crate) fn new(
|
||||
model: String,
|
||||
enable_tools: bool,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
permission_mode: PermissionMode,
|
||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let system_prompt = build_system_prompt()?;
|
||||
let session_state = new_cli_session()?;
|
||||
let session = create_managed_session_handle(&session_state.session_id)?;
|
||||
let runtime = build_runtime(
|
||||
session_state.with_persistence_path(session.path.clone()),
|
||||
&session.id,
|
||||
model.clone(),
|
||||
system_prompt.clone(),
|
||||
enable_tools,
|
||||
true,
|
||||
allowed_tools.clone(),
|
||||
permission_mode,
|
||||
None,
|
||||
)?;
|
||||
let cli = Self {
|
||||
model,
|
||||
allowed_tools,
|
||||
permission_mode,
|
||||
system_prompt,
|
||||
runtime,
|
||||
session,
|
||||
prompt_history: Vec::new(),
|
||||
};
|
||||
cli.persist_session()?;
|
||||
Ok(cli)
|
||||
}
|
||||
|
||||
pub(crate) fn set_reasoning_effort(&mut self, effort: Option<String>) {
|
||||
if let Some(rt) = self.runtime.runtime.as_mut() {
|
||||
rt.api_client_mut().set_reasoning_effort(effort);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn startup_banner(&self) -> String {
|
||||
let cwd = env::current_dir().map_or_else(
|
||||
|_| "<unknown>".to_string(),
|
||||
|path| path.display().to_string(),
|
||||
);
|
||||
let status = status_context(None).ok();
|
||||
let git_branch = status
|
||||
.as_ref()
|
||||
.and_then(|context| context.git_branch.as_deref())
|
||||
.unwrap_or("unknown");
|
||||
let workspace = status.as_ref().map_or_else(
|
||||
|| "unknown".to_string(),
|
||||
|context| context.git_summary.headline(),
|
||||
);
|
||||
let session_path = self.session.path.strip_prefix(Path::new(&cwd)).map_or_else(
|
||||
|_| self.session.path.display().to_string(),
|
||||
|path| path.display().to_string(),
|
||||
);
|
||||
format!(
|
||||
"\x1b[38;5;196m\
|
||||
██████╗██╗ █████╗ ██╗ ██╗\n\
|
||||
██╔════╝██║ ██╔══██╗██║ ██║\n\
|
||||
██║ ██║ ███████║██║ █╗ ██║\n\
|
||||
██║ ██║ ██╔══██║██║███╗██║\n\
|
||||
╚██████╗███████╗██║ ██║╚███╔███╔╝\n\
|
||||
╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\x1b[0m \x1b[38;5;208mCode\x1b[0m 🦞\n\n\
|
||||
\x1b[2mModel\x1b[0m {}\n\
|
||||
\x1b[2mPermissions\x1b[0m {}\n\
|
||||
\x1b[2mBranch\x1b[0m {}\n\
|
||||
\x1b[2mWorkspace\x1b[0m {}\n\
|
||||
\x1b[2mDirectory\x1b[0m {}\n\
|
||||
\x1b[2mSession\x1b[0m {}\n\
|
||||
\x1b[2mAuto-save\x1b[0m {}\n\n\
|
||||
Type \x1b[1m/help\x1b[0m for commands · \x1b[1m/status\x1b[0m for live context · \x1b[2m/resume latest\x1b[0m jumps back to the newest session · \x1b[1m/diff\x1b[0m then \x1b[1m/commit\x1b[0m to ship · \x1b[2mTab\x1b[0m for workflow completions · \x1b[2mShift+Enter\x1b[0m for newline",
|
||||
self.model,
|
||||
self.permission_mode.as_str(),
|
||||
git_branch,
|
||||
workspace,
|
||||
cwd,
|
||||
self.session.id,
|
||||
session_path,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn repl_completion_candidates(
|
||||
&self,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||
Ok(slash_command_completion_candidates_with_sessions(
|
||||
&self.model,
|
||||
Some(&self.session.id),
|
||||
list_managed_sessions()?
|
||||
.into_iter()
|
||||
.map(|session| session.id)
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_turn_runtime(
|
||||
&self,
|
||||
emit_output: bool,
|
||||
) -> Result<(BuiltRuntime, HookAbortMonitor), Box<dyn std::error::Error>> {
|
||||
let hook_abort_signal = runtime::HookAbortSignal::new();
|
||||
let runtime = build_runtime(
|
||||
self.runtime.session().clone(),
|
||||
&self.session.id,
|
||||
self.model.clone(),
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
emit_output,
|
||||
self.allowed_tools.clone(),
|
||||
self.permission_mode,
|
||||
None,
|
||||
)?
|
||||
.with_hook_abort_signal(hook_abort_signal.clone());
|
||||
let hook_abort_monitor = HookAbortMonitor::spawn(hook_abort_signal);
|
||||
|
||||
Ok((runtime, hook_abort_monitor))
|
||||
}
|
||||
|
||||
pub(crate) fn replace_runtime(
|
||||
&mut self,
|
||||
runtime: BuiltRuntime,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.runtime.shutdown_plugins()?;
|
||||
self.runtime = runtime;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?;
|
||||
let mut spinner = Spinner::new();
|
||||
let mut stdout = io::stdout();
|
||||
spinner.tick(
|
||||
"🦀 Thinking...",
|
||||
TerminalRenderer::new().color_theme(),
|
||||
&mut stdout,
|
||||
)?;
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||
hook_abort_monitor.stop();
|
||||
match result {
|
||||
Ok(summary) => {
|
||||
self.replace_runtime(runtime)?;
|
||||
spinner.finish(
|
||||
"✨ Done",
|
||||
TerminalRenderer::new().color_theme(),
|
||||
&mut stdout,
|
||||
)?;
|
||||
println!();
|
||||
if let Some(event) = summary.auto_compaction {
|
||||
println!(
|
||||
"{}",
|
||||
format_auto_compaction_notice(event.removed_message_count)
|
||||
);
|
||||
}
|
||||
self.persist_session()?;
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
runtime.shutdown_plugins()?;
|
||||
spinner.fail(
|
||||
"❌ Request failed",
|
||||
TerminalRenderer::new().color_theme(),
|
||||
&mut stdout,
|
||||
)?;
|
||||
Err(Box::new(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run_turn_with_output(
|
||||
&mut self,
|
||||
input: &str,
|
||||
output_format: CliOutputFormat,
|
||||
compact: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match output_format {
|
||||
CliOutputFormat::Json if compact => self.run_prompt_compact_json(input),
|
||||
CliOutputFormat::Text if compact => self.run_prompt_compact(input),
|
||||
CliOutputFormat::Text => self.run_turn(input),
|
||||
CliOutputFormat::Json => self.run_prompt_json(input),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run_prompt_compact(
|
||||
&mut self,
|
||||
input: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||
hook_abort_monitor.stop();
|
||||
let summary = result?;
|
||||
self.replace_runtime(runtime)?;
|
||||
self.persist_session()?;
|
||||
let final_text = final_assistant_text(&summary);
|
||||
println!("{final_text}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn run_prompt_compact_json(
|
||||
&mut self,
|
||||
input: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||
hook_abort_monitor.stop();
|
||||
let summary = result?;
|
||||
self.replace_runtime(runtime)?;
|
||||
self.persist_session()?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"message": final_assistant_text(&summary),
|
||||
"compact": true,
|
||||
"model": self.model,
|
||||
"usage": {
|
||||
"input_tokens": summary.usage.input_tokens,
|
||||
"output_tokens": summary.usage.output_tokens,
|
||||
"cache_creation_input_tokens": summary.usage.cache_creation_input_tokens,
|
||||
"cache_read_input_tokens": summary.usage.cache_read_input_tokens,
|
||||
},
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn run_prompt_json(
|
||||
&mut self,
|
||||
input: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||
hook_abort_monitor.stop();
|
||||
let summary = result?;
|
||||
self.replace_runtime(runtime)?;
|
||||
self.persist_session()?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"message": final_assistant_text(&summary),
|
||||
"model": self.model,
|
||||
"iterations": summary.iterations,
|
||||
"auto_compaction": summary.auto_compaction.map(|event| serde_json::json!({
|
||||
"removed_messages": event.removed_message_count,
|
||||
"notice": format_auto_compaction_notice(event.removed_message_count),
|
||||
})),
|
||||
"tool_uses": collect_tool_uses(&summary),
|
||||
"tool_results": collect_tool_results(&summary),
|
||||
"prompt_cache_events": collect_prompt_cache_events(&summary),
|
||||
"usage": {
|
||||
"input_tokens": summary.usage.input_tokens,
|
||||
"output_tokens": summary.usage.output_tokens,
|
||||
"cache_creation_input_tokens": summary.usage.cache_creation_input_tokens,
|
||||
"cache_read_input_tokens": summary.usage.cache_read_input_tokens,
|
||||
},
|
||||
"estimated_cost": format_usd(
|
||||
summary.usage.estimate_cost_usd_with_pricing(
|
||||
pricing_for_model(&self.model)
|
||||
.unwrap_or_else(runtime::ModelPricing::default_sonnet_tier)
|
||||
).total_cost_usd()
|
||||
)
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.runtime.session().save_to_path(&self.session.path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn record_prompt_history(&mut self, prompt: &str) {
|
||||
let timestamp_ms = std::time::SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map_or(self.runtime.session().updated_at_ms, |duration| {
|
||||
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
|
||||
});
|
||||
let entry = PromptHistoryEntry {
|
||||
timestamp_ms,
|
||||
text: prompt.to_string(),
|
||||
};
|
||||
self.prompt_history.push(entry);
|
||||
if let Err(error) = self.runtime.session_mut().push_prompt_entry(prompt) {
|
||||
eprintln!("warning: failed to persist prompt history: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reload_runtime_features(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let runtime = build_runtime(
|
||||
self.runtime.session().clone(),
|
||||
&self.session.id,
|
||||
self.model.clone(),
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
true,
|
||||
self.allowed_tools.clone(),
|
||||
self.permission_mode,
|
||||
None,
|
||||
)?;
|
||||
self.replace_runtime(runtime)?;
|
||||
self.persist_session()
|
||||
}
|
||||
|
||||
pub(crate) fn run_internal_prompt_text_with_progress(
|
||||
&self,
|
||||
prompt: &str,
|
||||
enable_tools: bool,
|
||||
progress: Option<InternalPromptProgressReporter>,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let session = self.runtime.session().clone();
|
||||
let mut runtime = build_runtime(
|
||||
session,
|
||||
&self.session.id,
|
||||
self.model.clone(),
|
||||
self.system_prompt.clone(),
|
||||
enable_tools,
|
||||
false,
|
||||
self.allowed_tools.clone(),
|
||||
self.permission_mode,
|
||||
progress,
|
||||
)?;
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?;
|
||||
let text = final_assistant_text(&summary).trim().to_string();
|
||||
runtime.shutdown_plugins()?;
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
pub(crate) fn run_internal_prompt_text(
|
||||
&self,
|
||||
prompt: &str,
|
||||
enable_tools: bool,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
self.run_internal_prompt_text_with_progress(prompt, enable_tools, None)
|
||||
}
|
||||
|
||||
pub(crate) fn run_stale_base_preflight(flag_value: Option<&str>) {
|
||||
let Ok(cwd) = env::current_dir() else {
|
||||
return;
|
||||
};
|
||||
let source = resolve_expected_base(flag_value, &cwd);
|
||||
let state = check_base_commit(&cwd, source.as_ref());
|
||||
if let Some(warning) = format_stale_base_warning(&state) {
|
||||
eprintln!("{warning}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run_repl(
|
||||
model: String,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
permission_mode: PermissionMode,
|
||||
base_commit: Option<String>,
|
||||
reasoning_effort: Option<String>,
|
||||
allow_broad_cwd: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
enforce_broad_cwd_policy(allow_broad_cwd, CliOutputFormat::Text)?;
|
||||
run_stale_base_preflight(base_commit.as_deref());
|
||||
let resolved_model = resolve_repl_model(model);
|
||||
let mut cli = LiveCli::new(resolved_model, true, allowed_tools, permission_mode)?;
|
||||
cli.set_reasoning_effort(reasoning_effort);
|
||||
let mut editor =
|
||||
input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default());
|
||||
println!("{}", cli.startup_banner());
|
||||
println!("{}", format_connected_line(&cli.model));
|
||||
|
||||
loop {
|
||||
editor.set_completions(cli.repl_completion_candidates().unwrap_or_default());
|
||||
match editor.read_line()? {
|
||||
input::ReadOutcome::Submit(input) => {
|
||||
let trimmed = input.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if matches!(trimmed.as_str(), "/exit" | "/quit") {
|
||||
cli.persist_session()?;
|
||||
break;
|
||||
}
|
||||
match SlashCommand::parse(&trimmed) {
|
||||
Ok(Some(command)) => {
|
||||
if cli.handle_repl_command(command)? {
|
||||
cli.persist_session()?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Bare-word skill dispatch: if the first token of the input
|
||||
// matches a known skill name, invoke it as `/skills <input>`
|
||||
// rather than forwarding raw text to the LLM (ROADMAP #36).
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
if let Some(prompt) = try_resolve_bare_skill_prompt(&cwd, &trimmed) {
|
||||
editor.push_history(input);
|
||||
cli.record_prompt_history(&trimmed);
|
||||
cli.run_turn(&prompt)?;
|
||||
continue;
|
||||
}
|
||||
editor.push_history(input);
|
||||
cli.record_prompt_history(&trimmed);
|
||||
cli.run_turn(&trimmed)?;
|
||||
}
|
||||
input::ReadOutcome::Cancel => {}
|
||||
input::ReadOutcome::Exit => {
|
||||
cli.persist_session()?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn detect_broad_cwd() -> Option<PathBuf> {
|
||||
let Ok(cwd) = env::current_dir() else {
|
||||
return None;
|
||||
};
|
||||
let is_home = env::var_os("HOME")
|
||||
.or_else(|| env::var_os("USERPROFILE"))
|
||||
.is_some_and(|h| Path::new(&h) == cwd);
|
||||
let is_root = cwd.parent().is_none();
|
||||
if is_home || is_root {
|
||||
Some(cwd)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn enforce_broad_cwd_policy(
|
||||
allow_broad_cwd: bool,
|
||||
output_format: CliOutputFormat,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if allow_broad_cwd {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(cwd) = detect_broad_cwd() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let is_interactive = io::stdin().is_terminal();
|
||||
|
||||
if is_interactive {
|
||||
// Interactive mode: print warning and ask for confirmation
|
||||
eprintln!(
|
||||
"Warning: claw is running from a very broad directory ({}).\n\
|
||||
The agent can read and search everything under this path.\n\
|
||||
Consider running from inside your project: cd /path/to/project && claw",
|
||||
cwd.display()
|
||||
);
|
||||
eprint!("Continue anyway? [y/N]: ");
|
||||
io::stderr().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
let trimmed = input.trim().to_lowercase();
|
||||
if trimmed != "y" && trimmed != "yes" {
|
||||
eprintln!("Aborted.");
|
||||
std::process::exit(0);
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
// Non-interactive mode: exit with error (JSON or text)
|
||||
let message = format!(
|
||||
"claw is running from a very broad directory ({}). \
|
||||
The agent can read and search everything under this path. \
|
||||
Use --allow-broad-cwd to proceed anyway, \
|
||||
or run from inside your project: cd /path/to/project && claw",
|
||||
cwd.display()
|
||||
);
|
||||
match output_format {
|
||||
CliOutputFormat::Json => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"type": "error",
|
||||
"error": message,
|
||||
})
|
||||
);
|
||||
}
|
||||
CliOutputFormat::Text => {
|
||||
eprintln!("error: {message}");
|
||||
}
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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::<Vec<_>>()
|
||||
.join("\n");
|
||||
let output =serde_json::json!({
|
||||
"file": {
|
||||
"filePath": "src/main.rs",
|
||||
"content": content,
|
||||
"numLines": 200,
|
||||
"startLine": 1,
|
||||
"totalLines": 200
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
|
||||
let rendered = format_tool_result("read_file", &output, false);
|
||||
|
||||
|
||||
assert!(rendered.contains("line 000"));
|
||||
assert!(rendered.contains("line 079"));
|
||||
assert!(!rendered.contains("line 199"));
|
||||
assert!(rendered.contains("full result preserved in session"));
|
||||
assert!(output.contains("line 199"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_rendering_truncates_large_bash_output_for_display_only() {
|
||||
let stdout = (0..120)
|
||||
.map(|index| format!("stdout {index:03}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let output =serde_json::json!({
|
||||
"stdout": stdout,
|
||||
"stderr": "",
|
||||
"returnCodeInterpretation": "completed successfully"
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let rendered = format_tool_result("bash", &output, false);
|
||||
|
||||
assert!(rendered.contains("stdout 000"));
|
||||
assert!(rendered.contains("stdout 059"));
|
||||
assert!(!rendered.contains("stdout 119"));
|
||||
assert!(rendered.contains("full result preserved in session"));
|
||||
assert!(output.contains("stdout 119"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_rendering_truncates_generic_long_output_for_display_only() {
|
||||
let items = (0..120)
|
||||
.map(|index| format!("payload {index:03}"))
|
||||
.collect::<Vec<_>>();
|
||||
let output =serde_json::json!({
|
||||
"summary": "plugin payload",
|
||||
"items": items,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let rendered = format_tool_result("plugin_echo", &output, false);
|
||||
|
||||
assert!(rendered.contains("plugin_echo"));
|
||||
assert!(rendered.contains("payload 000"));
|
||||
assert!(rendered.contains("payload 040"));
|
||||
assert!(!rendered.contains("payload 080"));
|
||||
assert!(!rendered.contains("payload 119"));
|
||||
assert!(rendered.contains("full result preserved in session"));
|
||||
assert!(output.contains("payload 119"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_rendering_truncates_raw_generic_output_for_display_only() {
|
||||
let output = (0..120)
|
||||
.map(|index| format!("raw {index:03}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let rendered = format_tool_result("plugin_echo", &output, false);
|
||||
|
||||
assert!(rendered.contains("plugin_echo"));
|
||||
assert!(rendered.contains("raw 000"));
|
||||
assert!(rendered.contains("raw 059"));
|
||||
assert!(!rendered.contains("raw 119"));
|
||||
assert!(rendered.contains("full result preserved in session"));
|
||||
assert!(output.contains("raw 119"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ultraplan_progress_lines_include_phase_step_and_elapsed_status() {
|
||||
|
|
@ -3420,7 +3620,21 @@ UU conflicted.rs",
|
|||
assert!(failed.contains("network timeout"));
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn describe_tool_progress_summarizes_known_tools() {
|
||||
assert_eq!(
|
||||
describe_tool_progress("read_file", r#"{"path":"src/main.rs"}"#),
|
||||
"reading src/main.rs"
|
||||
);
|
||||
assert!(
|
||||
describe_tool_progress("bash", r#"{"command":"cargo test -p rusty-claude-cli"}"#)
|
||||
.contains("cargo test -p rusty-claude-cli")
|
||||
);
|
||||
assert_eq!(
|
||||
describe_tool_progress("grep_search", r#"{"pattern":"ultraplan","path":"rust"}"#),
|
||||
"grep `ultraplan` in rust"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_output_block_renders_markdown_text() {
|
||||
|
|
@ -3457,7 +3671,7 @@ UU conflicted.rs",
|
|||
OutputContentBlock::ToolUse {
|
||||
id: "tool-1".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
input: json!({}),
|
||||
input:serde_json::json!({}),
|
||||
},
|
||||
&mut out,
|
||||
&mut events,
|
||||
|
|
@ -3474,19 +3688,376 @@ UU conflicted.rs",
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn response_to_events_preserves_empty_object_json_input_outside_streaming() {
|
||||
let mut out = Vec::new();
|
||||
let events = response_to_events(
|
||||
MessageResponse {
|
||||
id: "msg-1".to_string(),
|
||||
kind: "message".to_string(),
|
||||
model: "claude-opus-4-6".to_string(),
|
||||
role: "assistant".to_string(),
|
||||
content: vec![OutputContentBlock::ToolUse {
|
||||
id: "tool-1".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
input:serde_json::json!({}),
|
||||
}],
|
||||
stop_reason: Some("tool_use".to_string()),
|
||||
stop_sequence: None,
|
||||
usage: Usage {
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
},
|
||||
request_id: None,
|
||||
},
|
||||
&mut out,
|
||||
)
|
||||
.expect("response conversion should succeed");
|
||||
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AssistantEvent::ToolUse { name, input, .. }
|
||||
if name == "read_file" && input == "{}"
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn response_to_events_preserves_non_empty_json_input_outside_streaming() {
|
||||
let mut out = Vec::new();
|
||||
let events = response_to_events(
|
||||
MessageResponse {
|
||||
id: "msg-2".to_string(),
|
||||
kind: "message".to_string(),
|
||||
model: "claude-opus-4-6".to_string(),
|
||||
role: "assistant".to_string(),
|
||||
content: vec![OutputContentBlock::ToolUse {
|
||||
id: "tool-2".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
input:serde_json::json!({ "path": "rust/Cargo.toml" }),
|
||||
}],
|
||||
stop_reason: Some("tool_use".to_string()),
|
||||
stop_sequence: None,
|
||||
usage: Usage {
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
},
|
||||
request_id: None,
|
||||
},
|
||||
&mut out,
|
||||
)
|
||||
.expect("response conversion should succeed");
|
||||
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AssistantEvent::ToolUse { name, input, .. }
|
||||
if name == "read_file" && input == "{\"path\":\"rust/Cargo.toml\"}"
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn response_to_events_renders_collapsed_thinking_summary() {
|
||||
let mut out = Vec::new();
|
||||
let events = response_to_events(
|
||||
MessageResponse {
|
||||
id: "msg-3".to_string(),
|
||||
kind: "message".to_string(),
|
||||
model: "claude-opus-4-6".to_string(),
|
||||
role: "assistant".to_string(),
|
||||
content: vec![
|
||||
OutputContentBlock::Thinking {
|
||||
thinking: "step 1".to_string(),
|
||||
signature: Some("sig_123".to_string()),
|
||||
},
|
||||
OutputContentBlock::Text {
|
||||
text: "Final answer".to_string(),
|
||||
},
|
||||
],
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
stop_sequence: None,
|
||||
usage: Usage {
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
},
|
||||
request_id: None,
|
||||
},
|
||||
&mut out,
|
||||
)
|
||||
.expect("response conversion should succeed");
|
||||
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AssistantEvent::TextDelta(text) if text == "Final answer"
|
||||
));
|
||||
let rendered = String::from_utf8(out).expect("utf8");
|
||||
assert!(rendered.contains("▶ Thinking (6 chars hidden)"));
|
||||
assert!(!rendered.contains("step 1"));
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn build_runtime_plugin_state_merges_plugin_hooks_into_runtime_features() {
|
||||
let config_home = temp_dir();
|
||||
let workspace = temp_dir();
|
||||
let source_root = temp_dir();
|
||||
fs::create_dir_all(&config_home).expect("config home");
|
||||
fs::create_dir_all(&workspace).expect("workspace");
|
||||
fs::create_dir_all(&source_root).expect("source root");
|
||||
write_plugin_fixture(&source_root, "hook-runtime-demo", true, false);
|
||||
|
||||
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
||||
manager
|
||||
.install(source_root.to_str().expect("utf8 source path"))
|
||||
.expect("plugin install should succeed");
|
||||
let loader = ConfigLoader::new(&workspace, &config_home);
|
||||
let runtime_config = loader.load().expect("runtime config should load");
|
||||
let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config)
|
||||
.expect("plugin state should load");
|
||||
let pre_hooks = state.feature_config.hooks().pre_tool_use();
|
||||
assert_eq!(pre_hooks.len(), 1);
|
||||
assert!(
|
||||
pre_hooks[0].ends_with("hooks/pre.sh"),
|
||||
"expected installed plugin hook path, got {pre_hooks:?}"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(config_home);
|
||||
let _ = fs::remove_dir_all(workspace);
|
||||
let _ = fs::remove_dir_all(source_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn build_runtime_plugin_state_discovers_mcp_tools_and_surfaces_pending_servers() {
|
||||
let config_home = temp_dir();
|
||||
let workspace = temp_dir();
|
||||
fs::create_dir_all(&config_home).expect("config home");
|
||||
fs::create_dir_all(&workspace).expect("workspace");
|
||||
let script_path = workspace.join("fixture-mcp.py");
|
||||
write_mcp_server_fixture(&script_path);
|
||||
fs::write(
|
||||
config_home.join("settings.json"),
|
||||
format!(
|
||||
r#"{{
|
||||
"mcpServers": {{
|
||||
"alpha": {{
|
||||
"command": "python3",
|
||||
"args": ["{}"]
|
||||
}},
|
||||
"broken": {{
|
||||
"command": "python3",
|
||||
"args": ["-c", "import sys; sys.exit(0)"]
|
||||
}}
|
||||
}}
|
||||
}}"#,
|
||||
script_path.to_string_lossy()
|
||||
),
|
||||
)
|
||||
.expect("write mcp settings");
|
||||
|
||||
let loader = ConfigLoader::new(&workspace, &config_home);
|
||||
let runtime_config = loader.load().expect("runtime config should load");
|
||||
let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config)
|
||||
.expect("runtime plugin state should load");
|
||||
|
||||
let allowed = state
|
||||
.tool_registry
|
||||
.normalize_allowed_tools(&["mcp__alpha__echo".to_string(), "MCPTool".to_string()])
|
||||
.expect("mcp tools should be allow-listable")
|
||||
.expect("allow-list should exist");
|
||||
assert!(allowed.contains("mcp__alpha__echo"));
|
||||
assert!(allowed.contains("MCPTool"));
|
||||
|
||||
let mut executor = CliToolExecutor::new(
|
||||
None,
|
||||
false,
|
||||
state.tool_registry.clone(),
|
||||
state.mcp_state.clone(),
|
||||
);
|
||||
|
||||
let tool_output = executor
|
||||
.execute("mcp__alpha__echo", r#"{"text":"hello"}"#)
|
||||
.expect("discovered mcp tool should execute");
|
||||
let tool_json: serde_json::Value =
|
||||
serde_json::from_str(&tool_output).expect("tool output should be json");
|
||||
assert_eq!(tool_json["structuredContent"]["echoed"], "hello");
|
||||
|
||||
let wrapped_output = executor
|
||||
.execute(
|
||||
"MCPTool",
|
||||
r#"{"qualifiedName":"mcp__alpha__echo","arguments":{"text":"wrapped"}}"#,
|
||||
)
|
||||
.expect("generic mcp wrapper should execute");
|
||||
let wrapped_json: serde_json::Value =
|
||||
serde_json::from_str(&wrapped_output).expect("wrapped output should be json");
|
||||
assert_eq!(wrapped_json["structuredContent"]["echoed"], "wrapped");
|
||||
|
||||
let search_output = executor
|
||||
.execute("ToolSearch", r#"{"query":"alpha echo","max_results":5}"#)
|
||||
.expect("tool search should execute");
|
||||
let search_json: serde_json::Value =
|
||||
serde_json::from_str(&search_output).expect("search output should be json");
|
||||
assert_eq!(search_json["matches"][0], "mcp__alpha__echo");
|
||||
assert_eq!(search_json["pending_mcp_servers"][0], "broken");
|
||||
assert_eq!(
|
||||
search_json["mcp_degraded"]["failed_servers"][0]["server_name"],
|
||||
"broken"
|
||||
);
|
||||
assert_eq!(
|
||||
search_json["mcp_degraded"]["failed_servers"][0]["phase"],
|
||||
"tool_discovery"
|
||||
);
|
||||
assert_eq!(
|
||||
search_json["mcp_degraded"]["available_tools"][0],
|
||||
"mcp__alpha__echo"
|
||||
);
|
||||
|
||||
let listed = executor
|
||||
.execute("ListMcpResourcesTool", r#"{"server":"alpha"}"#)
|
||||
.expect("resources should list");
|
||||
let listed_json: serde_json::Value =
|
||||
serde_json::from_str(&listed).expect("resource output should be json");
|
||||
assert_eq!(listed_json["resources"][0]["uri"], "file://guide.txt");
|
||||
|
||||
let read = executor
|
||||
.execute(
|
||||
"ReadMcpResourceTool",
|
||||
r#"{"server":"alpha","uri":"file://guide.txt"}"#,
|
||||
)
|
||||
.expect("resource should read");
|
||||
let read_json: serde_json::Value =
|
||||
serde_json::from_str(&read).expect("resource read output should be json");
|
||||
assert_eq!(
|
||||
read_json["contents"][0]["text"],
|
||||
"contents for file://guide.txt"
|
||||
);
|
||||
|
||||
if let Some(mcp_state) = state.mcp_state {
|
||||
mcp_state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.shutdown()
|
||||
.expect("mcp shutdown should succeed");
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(config_home);
|
||||
let _ = fs::remove_dir_all(workspace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_runtime_plugin_state_surfaces_unsupported_mcp_servers_structurally() {
|
||||
let config_home = temp_dir();
|
||||
let workspace = temp_dir();
|
||||
fs::create_dir_all(&config_home).expect("config home");
|
||||
fs::create_dir_all(&workspace).expect("workspace");
|
||||
fs::write(
|
||||
config_home.join("settings.json"),
|
||||
r#"{
|
||||
"mcpServers": {
|
||||
"remote": {
|
||||
"url": "https://example.test/mcp"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write mcp settings");
|
||||
|
||||
let loader = ConfigLoader::new(&workspace, &config_home);
|
||||
let runtime_config = loader.load().expect("runtime config should load");
|
||||
let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config)
|
||||
.expect("runtime plugin state should load");
|
||||
let mut executor = CliToolExecutor::new(
|
||||
None,
|
||||
false,
|
||||
state.tool_registry.clone(),
|
||||
state.mcp_state.clone(),
|
||||
);
|
||||
|
||||
let search_output = executor
|
||||
.execute("ToolSearch", r#"{"query":"remote","max_results":5}"#)
|
||||
.expect("tool search should execute");
|
||||
let search_json: serde_json::Value =
|
||||
serde_json::from_str(&search_output).expect("search output should be json");
|
||||
assert_eq!(search_json["pending_mcp_servers"][0], "remote");
|
||||
assert_eq!(
|
||||
search_json["mcp_degraded"]["failed_servers"][0]["server_name"],
|
||||
"remote"
|
||||
);
|
||||
assert_eq!(
|
||||
search_json["mcp_degraded"]["failed_servers"][0]["phase"],
|
||||
"server_registration"
|
||||
);
|
||||
assert_eq!(
|
||||
search_json["mcp_degraded"]["failed_servers"][0]["error"]["context"]["transport"],
|
||||
"http"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(config_home);
|
||||
let _ = fs::remove_dir_all(workspace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_runtime_runs_plugin_lifecycle_init_and_shutdown() {
|
||||
// Serialize access to process-wide env vars so parallel tests that
|
||||
// set/remove ANTHROPIC_API_KEY do not race with this test.
|
||||
let _guard = env_lock();
|
||||
let config_home = temp_dir();
|
||||
// Inject a dummy API key so runtime construction succeeds without real credentials.
|
||||
// This test only exercises plugin lifecycle (init/shutdown), never calls the API.
|
||||
std::env::set_var("ANTHROPIC_API_KEY", "test-dummy-key-for-plugin-lifecycle");
|
||||
let workspace = temp_dir();
|
||||
let source_root = temp_dir();
|
||||
fs::create_dir_all(&config_home).expect("config home");
|
||||
fs::create_dir_all(&workspace).expect("workspace");
|
||||
fs::create_dir_all(&source_root).expect("source root");
|
||||
write_plugin_fixture(&source_root, "lifecycle-runtime-demo", false, true);
|
||||
|
||||
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
||||
let install = manager
|
||||
.install(source_root.to_str().expect("utf8 source path"))
|
||||
.expect("plugin install should succeed");
|
||||
let log_path = install.install_path.join("lifecycle.log");
|
||||
let loader = ConfigLoader::new(&workspace, &config_home);
|
||||
let runtime_config = loader.load().expect("runtime config should load");
|
||||
let runtime_plugin_state =
|
||||
build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config)
|
||||
.expect("plugin state should load");
|
||||
let mut runtime = build_runtime_with_plugin_state(
|
||||
Session::new(),
|
||||
"runtime-plugin-lifecycle",
|
||||
DEFAULT_MODEL.to_string(),
|
||||
vec!["test system prompt".to_string()],
|
||||
true,
|
||||
false,
|
||||
None,
|
||||
PermissionMode::DangerFullAccess,
|
||||
None,
|
||||
runtime_plugin_state,
|
||||
)
|
||||
.expect("runtime should build");
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(&log_path).expect("init log should exist"),
|
||||
"init\n"
|
||||
);
|
||||
|
||||
runtime
|
||||
.shutdown_plugins()
|
||||
.expect("plugin shutdown should succeed");
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(&log_path).expect("shutdown log should exist"),
|
||||
"init\nshutdown\n"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(config_home);
|
||||
let _ = fs::remove_dir_all(workspace);
|
||||
let _ = fs::remove_dir_all(source_root);
|
||||
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_reasoning_effort_value() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,195 @@
|
|||
use std::env;
|
||||
|
||||
use api::detect_provider_kind;
|
||||
use runtime::ConfigLoader;
|
||||
|
||||
use crate::constants::{DEFAULT_MODEL, SESSION_REFERENCE_ALIASES};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum ModelSource {
|
||||
Flag,
|
||||
Env,
|
||||
Config,
|
||||
Default,
|
||||
}
|
||||
|
||||
impl ModelSource {
|
||||
pub(crate) fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ModelSource::Flag => "flag",
|
||||
ModelSource::Env => "env",
|
||||
ModelSource::Config => "config",
|
||||
ModelSource::Default => "default",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct ModelProvenance {
|
||||
pub(crate) resolved: String,
|
||||
pub(crate) raw: Option<String>,
|
||||
pub(crate) source: ModelSource,
|
||||
}
|
||||
|
||||
impl ModelProvenance {
|
||||
pub(crate) fn default_fallback() -> Self {
|
||||
Self {
|
||||
resolved: DEFAULT_MODEL.to_string(),
|
||||
raw: None,
|
||||
source: ModelSource::Default,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_flag(raw: &str) -> Self {
|
||||
Self {
|
||||
resolved: resolve_model_alias_with_config(raw),
|
||||
raw: Some(raw.to_string()),
|
||||
source: ModelSource::Flag,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_env_or_config_or_default(cli_model: &str) -> Self {
|
||||
if cli_model != DEFAULT_MODEL {
|
||||
return Self {
|
||||
resolved: cli_model.to_string(),
|
||||
raw: Some(cli_model.to_string()),
|
||||
source: ModelSource::Flag,
|
||||
};
|
||||
}
|
||||
if let Some(env_model) = env::var("ANTHROPIC_MODEL")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Self {
|
||||
resolved: resolve_model_alias_with_config(&env_model),
|
||||
raw: Some(env_model),
|
||||
source: ModelSource::Env,
|
||||
};
|
||||
}
|
||||
if let Some(config_model) = config_model_for_current_dir() {
|
||||
return Self {
|
||||
resolved: resolve_model_alias_with_config(&config_model),
|
||||
raw: Some(config_model),
|
||||
source: ModelSource::Config,
|
||||
};
|
||||
}
|
||||
Self::default_fallback()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn max_tokens_for_model(model: &str) -> u32 {
|
||||
if model.contains("opus") {
|
||||
32_000
|
||||
} else {
|
||||
64_000
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_model_alias(model: &str) -> &str {
|
||||
match model {
|
||||
"opus" => "claude-opus-4-6",
|
||||
"sonnet" => "claude-sonnet-4-6",
|
||||
"haiku" => "claude-haiku-4-5-20251213",
|
||||
_ => model,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_model_alias_with_config(model: &str) -> String {
|
||||
let trimmed = model.trim();
|
||||
if let Some(resolved) = config_alias_for_current_dir(trimmed) {
|
||||
return resolve_model_alias(&resolved).to_string();
|
||||
}
|
||||
resolve_model_alias(trimmed).to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn validate_model_syntax(model: &str) -> Result<(), String> {
|
||||
let trimmed = model.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("model string cannot be empty".to_string());
|
||||
}
|
||||
match trimmed {
|
||||
"opus" | "sonnet" | "haiku" | "grok" | "grok-2" | "grok-3" | "grok-mini"
|
||||
| "grok-3-mini" | "kimi" | "glm-5" => return Ok(()),
|
||||
_ => {}
|
||||
}
|
||||
if trimmed.starts_with("qwen-")
|
||||
|| trimmed.starts_with("ali-")
|
||||
|| trimmed.starts_with("glm-")
|
||||
|| trimmed.starts_with("kimi-")
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if trimmed.contains(' ') {
|
||||
return Err(format!(
|
||||
"invalid model syntax: '{trimmed}' contains spaces. Use provider/model format or known alias"
|
||||
));
|
||||
}
|
||||
let parts: Vec<&str> = trimmed.split('/').collect();
|
||||
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
|
||||
let mut err_msg = format!(
|
||||
"invalid model syntax: '{trimmed}'. Expected provider/model (e.g., anthropic/claude-opus-4-6) or known alias (opus, sonnet, haiku)"
|
||||
);
|
||||
if trimmed.starts_with("gpt-") || trimmed.starts_with("gpt_") {
|
||||
err_msg.push_str("\nDid you mean `openai/");
|
||||
err_msg.push_str(trimmed);
|
||||
err_msg.push_str("`? (Requires OPENAI_API_KEY env var)");
|
||||
} else if trimmed.starts_with("qwen") {
|
||||
err_msg.push_str("\nDid you mean `qwen/");
|
||||
err_msg.push_str(trimmed);
|
||||
err_msg.push_str("`? (Requires DASHSCOPE_API_KEY env var)");
|
||||
} else if trimmed.starts_with("grok") {
|
||||
err_msg.push_str("\nDid you mean `xai/");
|
||||
err_msg.push_str(trimmed);
|
||||
err_msg.push_str("`? (Requires XAI_API_KEY env var)");
|
||||
}
|
||||
return Err(err_msg);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn config_alias_for_current_dir(alias: &str) -> Option<String> {
|
||||
if alias.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let cwd = env::current_dir().ok()?;
|
||||
let loader = ConfigLoader::default_for(&cwd);
|
||||
let config = loader.load().ok()?;
|
||||
config.aliases().get(alias).cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn config_model_for_current_dir() -> Option<String> {
|
||||
let cwd = env::current_dir().ok()?;
|
||||
let loader = ConfigLoader::default_for(&cwd);
|
||||
loader.load().ok()?.model().map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_repl_model(cli_model: String) -> String {
|
||||
if cli_model != DEFAULT_MODEL {
|
||||
return cli_model;
|
||||
}
|
||||
if let Some(env_model) = env::var("ANTHROPIC_MODEL")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return resolve_model_alias_with_config(&env_model);
|
||||
}
|
||||
if let Some(config_model) = config_model_for_current_dir() {
|
||||
return resolve_model_alias_with_config(&config_model);
|
||||
}
|
||||
cli_model
|
||||
}
|
||||
|
||||
pub(crate) fn provider_label(kind: api::ProviderKind) -> &'static str {
|
||||
match kind {
|
||||
api::ProviderKind::Anthropic => "anthropic",
|
||||
api::ProviderKind::Xai => "xai",
|
||||
api::ProviderKind::OpenAi => "openai",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_connected_line(model: &str) -> String {
|
||||
let provider = provider_label(detect_provider_kind(model));
|
||||
format!("Connected: {model} via {provider}")
|
||||
}
|
||||
|
|
@ -1,98 +1,7 @@
|
|||
use crate::DEFAULT_MODEL;
|
||||
use crate::{config_model_for_current_dir, resolve_model_alias_with_config};
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// #148: Model provenance for `claw status` JSON/text output. Records where
|
||||
/// the resolved model string came from so claws don't have to re-read argv
|
||||
/// to audit whether their `--model` flag was honored vs falling back to env
|
||||
/// or config or default.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum ModelSource {
|
||||
/// Explicit `--model` / `--model=` CLI flag.
|
||||
Flag,
|
||||
/// ANTHROPIC_MODEL environment variable (when no flag was passed).
|
||||
Env,
|
||||
/// `model` key in `.claw.json` / `.claw/settings.json` (when neither
|
||||
/// flag nor env set it).
|
||||
Config,
|
||||
/// Compiled-in DEFAULT_MODEL fallback.
|
||||
Default,
|
||||
}
|
||||
|
||||
impl ModelSource {
|
||||
pub(crate) fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ModelSource::Flag => "flag",
|
||||
ModelSource::Env => "env",
|
||||
ModelSource::Config => "config",
|
||||
ModelSource::Default => "default",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct ModelProvenance {
|
||||
/// Resolved model string (after alias expansion).
|
||||
pub(crate) resolved: String,
|
||||
/// Raw user input before alias resolution. None when source is Default.
|
||||
pub(crate) raw: Option<String>,
|
||||
/// Where the resolved model string originated.
|
||||
pub(crate) source: ModelSource,
|
||||
}
|
||||
|
||||
impl ModelProvenance {
|
||||
pub(crate) fn default_fallback() -> Self {
|
||||
Self {
|
||||
resolved: DEFAULT_MODEL.to_string(),
|
||||
raw: None,
|
||||
source: ModelSource::Default,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_flag(raw: &str) -> Self {
|
||||
Self {
|
||||
resolved: resolve_model_alias_with_config(raw),
|
||||
raw: Some(raw.to_string()),
|
||||
source: ModelSource::Flag,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_env_or_config_or_default(cli_model: &str) -> Self {
|
||||
// Only called when no --model flag was passed. Probe env first,
|
||||
// then config, else fall back to default. Mirrors the logic in
|
||||
// resolve_repl_model() but captures the source.
|
||||
if cli_model != DEFAULT_MODEL {
|
||||
// Already resolved from some prior path; treat as flag.
|
||||
return Self {
|
||||
resolved: cli_model.to_string(),
|
||||
raw: Some(cli_model.to_string()),
|
||||
source: ModelSource::Flag,
|
||||
};
|
||||
}
|
||||
if let Some(env_model) = env::var("ANTHROPIC_MODEL")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Self {
|
||||
resolved: resolve_model_alias_with_config(&env_model),
|
||||
raw: Some(env_model),
|
||||
source: ModelSource::Env,
|
||||
};
|
||||
}
|
||||
if let Some(config_model) = config_model_for_current_dir() {
|
||||
return Self {
|
||||
resolved: resolve_model_alias_with_config(&config_model),
|
||||
raw: Some(config_model),
|
||||
source: ModelSource::Config,
|
||||
};
|
||||
}
|
||||
Self::default_fallback()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum SessionLifecycleKind {
|
||||
RunningProcess,
|
||||
|
|
@ -144,7 +53,7 @@ impl SessionLifecycleSummary {
|
|||
}
|
||||
|
||||
pub(crate) fn json_value(&self) -> serde_json::Value {
|
||||
json!({
|
||||
serde_json::json!({
|
||||
"kind": self.kind.as_str(),
|
||||
"pane_id": self.pane_id,
|
||||
"pane_command": self.pane_command,
|
||||
|
|
@ -221,8 +130,4 @@ pub(crate) struct ManagedSessionSummary {
|
|||
pub(crate) lifecycle: SessionLifecycleSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PromptHistoryEntry {
|
||||
pub(crate) timestamp_ms: u64,
|
||||
pub(crate) text: String,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
use std::io::{self, Write};
|
||||
|
||||
use runtime::{PermissionMode, PermissionPolicy, PermissionPromptDecision, PermissionRequest};
|
||||
use tools::GlobalToolRegistry;
|
||||
|
||||
pub(crate) struct CliPermissionPrompter {
|
||||
current_mode: PermissionMode,
|
||||
}
|
||||
|
||||
impl CliPermissionPrompter {
|
||||
pub(crate) fn new(current_mode: PermissionMode) -> Self {
|
||||
Self { current_mode }
|
||||
}
|
||||
}
|
||||
|
||||
impl runtime::PermissionPrompter for CliPermissionPrompter {
|
||||
fn decide(
|
||||
&mut self,
|
||||
request: &PermissionRequest,
|
||||
) -> PermissionPromptDecision {
|
||||
println!();
|
||||
println!("Permission approval required");
|
||||
println!(" Tool {}", request.tool_name);
|
||||
println!(" Current mode {}", self.current_mode.as_str());
|
||||
println!(" Required mode {}", request.required_mode.as_str());
|
||||
if let Some(reason) = &request.reason {
|
||||
println!(" Reason {reason}");
|
||||
}
|
||||
println!(" Input {}", request.input);
|
||||
print!("Approve this tool call? [y/N]: ");
|
||||
let _ = io::stdout().flush();
|
||||
|
||||
let mut response = String::new();
|
||||
match io::stdin().read_line(&mut response) {
|
||||
Ok(_) => {
|
||||
let normalized = response.trim().to_ascii_lowercase();
|
||||
if matches!(normalized.as_str(), "y" | "yes") {
|
||||
PermissionPromptDecision::Allow
|
||||
} else {
|
||||
PermissionPromptDecision::Deny {
|
||||
reason: format!(
|
||||
"tool '{}' denied by user approval prompt",
|
||||
request.tool_name
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => PermissionPromptDecision::Deny {
|
||||
reason: format!("permission approval failed: {error}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn permission_policy(
|
||||
mode: PermissionMode,
|
||||
feature_config: &runtime::RuntimeFeatureConfig,
|
||||
tool_registry: &GlobalToolRegistry,
|
||||
) -> Result<PermissionPolicy, String> {
|
||||
Ok(tool_registry.permission_specs(None)?.into_iter().fold(
|
||||
PermissionPolicy::new(mode).with_permission_rules(feature_config.permission_rules()),
|
||||
|policy, (name, required_permission)| {
|
||||
policy.with_tool_requirement(name, required_permission)
|
||||
},
|
||||
))
|
||||
}
|
||||
|
|
@ -0,0 +1,368 @@
|
|||
use std::io::{self, Write};
|
||||
use std::sync::mpsc::{self, RecvTimeoutError};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::constants::INTERNAL_PROGRESS_HEARTBEAT_INTERVAL;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct InternalPromptProgressState {
|
||||
pub(crate) command_label: &'static str,
|
||||
pub(crate) task_label: String,
|
||||
pub(crate) step: usize,
|
||||
pub(crate) phase: String,
|
||||
pub(crate) detail: Option<String>,
|
||||
pub(crate) saw_final_text: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum InternalPromptProgressEvent {
|
||||
Started,
|
||||
Update,
|
||||
Heartbeat,
|
||||
Complete,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct InternalPromptProgressShared {
|
||||
pub(crate) state: Mutex<InternalPromptProgressState>,
|
||||
pub(crate) output_lock: Mutex<()>,
|
||||
pub(crate) started_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct InternalPromptProgressReporter {
|
||||
pub(crate) shared: Arc<InternalPromptProgressShared>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct InternalPromptProgressRun {
|
||||
pub(crate) reporter: InternalPromptProgressReporter,
|
||||
pub(crate) heartbeat_stop: Option<mpsc::Sender<()>>,
|
||||
pub(crate) heartbeat_handle: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl InternalPromptProgressReporter {
|
||||
pub(crate) fn ultraplan(task: &str) -> Self {
|
||||
Self {
|
||||
shared: Arc::new(InternalPromptProgressShared {
|
||||
state: Mutex::new(InternalPromptProgressState {
|
||||
command_label: "Ultraplan",
|
||||
task_label: task.to_string(),
|
||||
step: 0,
|
||||
phase: "planning started".to_string(),
|
||||
detail: Some(format!("task: {task}")),
|
||||
saw_final_text: false,
|
||||
}),
|
||||
output_lock: Mutex::new(()),
|
||||
started_at: Instant::now(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn emit(&self, event: InternalPromptProgressEvent, error: Option<&str>) {
|
||||
let snapshot = self.snapshot();
|
||||
let line = format_internal_prompt_progress_line(event, &snapshot, self.elapsed(), error);
|
||||
self.write_line(&line);
|
||||
}
|
||||
|
||||
pub(crate) fn mark_model_phase(&self) {
|
||||
let snapshot = {
|
||||
let mut state = self
|
||||
.shared
|
||||
.state
|
||||
.lock()
|
||||
.expect("internal prompt progress state poisoned");
|
||||
state.step += 1;
|
||||
state.phase = if state.step == 1 {
|
||||
"analyzing request".to_string()
|
||||
} else {
|
||||
"reviewing findings".to_string()
|
||||
};
|
||||
state.detail = Some(format!("task: {}", state.task_label));
|
||||
state.clone()
|
||||
};
|
||||
self.write_line(&format_internal_prompt_progress_line(
|
||||
InternalPromptProgressEvent::Update,
|
||||
&snapshot,
|
||||
self.elapsed(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
pub(crate) fn mark_tool_phase(&self, name: &str, input: &str) {
|
||||
let detail = describe_tool_progress(name, input);
|
||||
let snapshot = {
|
||||
let mut state = self
|
||||
.shared
|
||||
.state
|
||||
.lock()
|
||||
.expect("internal prompt progress state poisoned");
|
||||
state.step += 1;
|
||||
state.phase = format!("running {name}");
|
||||
state.detail = Some(detail);
|
||||
state.clone()
|
||||
};
|
||||
self.write_line(&format_internal_prompt_progress_line(
|
||||
InternalPromptProgressEvent::Update,
|
||||
&snapshot,
|
||||
self.elapsed(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
pub(crate) fn mark_text_phase(&self, text: &str) {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let detail = crate::truncate_for_summary(crate::first_visible_line(trimmed), 120);
|
||||
let snapshot = {
|
||||
let mut state = self
|
||||
.shared
|
||||
.state
|
||||
.lock()
|
||||
.expect("internal prompt progress state poisoned");
|
||||
if state.saw_final_text {
|
||||
return;
|
||||
}
|
||||
state.saw_final_text = true;
|
||||
state.step += 1;
|
||||
state.phase = "drafting final plan".to_string();
|
||||
state.detail = (!detail.is_empty()).then_some(detail);
|
||||
state.clone()
|
||||
};
|
||||
self.write_line(&format_internal_prompt_progress_line(
|
||||
InternalPromptProgressEvent::Update,
|
||||
&snapshot,
|
||||
self.elapsed(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
pub(crate) fn emit_heartbeat(&self) {
|
||||
let snapshot = self.snapshot();
|
||||
self.write_line(&format_internal_prompt_progress_line(
|
||||
InternalPromptProgressEvent::Heartbeat,
|
||||
&snapshot,
|
||||
self.elapsed(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
pub(crate) fn snapshot(&self) -> InternalPromptProgressState {
|
||||
self.shared
|
||||
.state
|
||||
.lock()
|
||||
.expect("internal prompt progress state poisoned")
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn elapsed(&self) -> Duration {
|
||||
self.shared.started_at.elapsed()
|
||||
}
|
||||
|
||||
pub(crate) fn write_line(&self, line: &str) {
|
||||
let _guard = self
|
||||
.shared
|
||||
.output_lock
|
||||
.lock()
|
||||
.expect("internal prompt progress output lock poisoned");
|
||||
let mut stdout = io::stdout();
|
||||
let _ = writeln!(stdout, "{line}");
|
||||
let _ = stdout.flush();
|
||||
}
|
||||
}
|
||||
|
||||
impl InternalPromptProgressRun {
|
||||
pub(crate) fn start_ultraplan(task: &str) -> Self {
|
||||
let reporter = InternalPromptProgressReporter::ultraplan(task);
|
||||
reporter.emit(InternalPromptProgressEvent::Started, None);
|
||||
|
||||
let (heartbeat_stop, heartbeat_rx) = mpsc::channel();
|
||||
let heartbeat_reporter = reporter.clone();
|
||||
let heartbeat_handle = thread::spawn(move || loop {
|
||||
match heartbeat_rx.recv_timeout(INTERNAL_PROGRESS_HEARTBEAT_INTERVAL) {
|
||||
Ok(()) | Err(RecvTimeoutError::Disconnected) => break,
|
||||
Err(RecvTimeoutError::Timeout) => heartbeat_reporter.emit_heartbeat(),
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
reporter,
|
||||
heartbeat_stop: Some(heartbeat_stop),
|
||||
heartbeat_handle: Some(heartbeat_handle),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reporter(&self) -> InternalPromptProgressReporter {
|
||||
self.reporter.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn finish_success(&mut self) {
|
||||
self.stop_heartbeat();
|
||||
self.reporter
|
||||
.emit(InternalPromptProgressEvent::Complete, None);
|
||||
}
|
||||
|
||||
pub(crate) fn finish_failure(&mut self, error: &str) {
|
||||
self.stop_heartbeat();
|
||||
self.reporter
|
||||
.emit(InternalPromptProgressEvent::Failed, Some(error));
|
||||
}
|
||||
|
||||
fn stop_heartbeat(&mut self) {
|
||||
if let Some(sender) = self.heartbeat_stop.take() {
|
||||
let _ = sender.send(());
|
||||
}
|
||||
if let Some(handle) = self.heartbeat_handle.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InternalPromptProgressRun {
|
||||
fn drop(&mut self) {
|
||||
self.stop_heartbeat();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_internal_prompt_progress_line(
|
||||
event: InternalPromptProgressEvent,
|
||||
snapshot: &InternalPromptProgressState,
|
||||
elapsed: Duration,
|
||||
error: Option<&str>,
|
||||
) -> String {
|
||||
let elapsed_seconds = elapsed.as_secs();
|
||||
let step_label = if snapshot.step == 0 {
|
||||
"current step pending".to_string()
|
||||
} else {
|
||||
format!("current step {}", snapshot.step)
|
||||
};
|
||||
let mut status_bits = vec![step_label, format!("phase {}", snapshot.phase)];
|
||||
if let Some(detail) = snapshot
|
||||
.detail
|
||||
.as_deref()
|
||||
.filter(|detail| !detail.is_empty())
|
||||
{
|
||||
status_bits.push(detail.to_string());
|
||||
}
|
||||
let status = status_bits.join(" · ");
|
||||
match event {
|
||||
InternalPromptProgressEvent::Started => {
|
||||
format!(
|
||||
"🧭 {} status · planning started · {status}",
|
||||
snapshot.command_label
|
||||
)
|
||||
}
|
||||
InternalPromptProgressEvent::Update => {
|
||||
format!("… {} status · {status}", snapshot.command_label)
|
||||
}
|
||||
InternalPromptProgressEvent::Heartbeat => format!(
|
||||
"… {} heartbeat · {elapsed_seconds}s elapsed · {status}",
|
||||
snapshot.command_label
|
||||
),
|
||||
InternalPromptProgressEvent::Complete => format!(
|
||||
"✔ {} status · completed · {elapsed_seconds}s elapsed · {} steps total",
|
||||
snapshot.command_label, snapshot.step
|
||||
),
|
||||
InternalPromptProgressEvent::Failed => format!(
|
||||
"✘ {} status · failed · {elapsed_seconds}s elapsed · {}",
|
||||
snapshot.command_label,
|
||||
error.unwrap_or("unknown error")
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn describe_tool_progress(name: &str, input: &str) -> String {
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string()));
|
||||
match name {
|
||||
"bash" | "Bash" => {
|
||||
let command = parsed
|
||||
.get("command")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default();
|
||||
if command.is_empty() {
|
||||
"running shell command".to_string()
|
||||
} else {
|
||||
format!("command {}", crate::truncate_for_summary(command.trim(), 100))
|
||||
}
|
||||
}
|
||||
"read_file" | "Read" => format!("reading {}", crate::extract_tool_path(&parsed)),
|
||||
"write_file" | "Write" => format!("writing {}", crate::extract_tool_path(&parsed)),
|
||||
"edit_file" | "Edit" => format!("editing {}", crate::extract_tool_path(&parsed)),
|
||||
"glob_search" | "Glob" => {
|
||||
let pattern = parsed
|
||||
.get("pattern")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("?");
|
||||
let scope = parsed
|
||||
.get("path")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or(".");
|
||||
format!("glob `{pattern}` in {scope}")
|
||||
}
|
||||
"grep_search" | "Grep" => {
|
||||
let pattern = parsed
|
||||
.get("pattern")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("?");
|
||||
let scope = parsed
|
||||
.get("path")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or(".");
|
||||
format!("grep `{pattern}` in {scope}")
|
||||
}
|
||||
"web_search" | "WebSearch" => parsed
|
||||
.get("query")
|
||||
.and_then(|value| value.as_str())
|
||||
.map_or_else(
|
||||
|| "running web search".to_string(),
|
||||
|query| format!("query {}", crate::truncate_for_summary(query, 100)),
|
||||
),
|
||||
_ => {
|
||||
let summary = crate::summarize_tool_payload(input);
|
||||
if summary.is_empty() {
|
||||
format!("running {name}")
|
||||
} else {
|
||||
format!("{name}: {summary}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct CliHookProgressReporter;
|
||||
|
||||
impl runtime::HookProgressReporter for CliHookProgressReporter {
|
||||
fn on_event(&mut self, event: &runtime::HookProgressEvent) {
|
||||
match event {
|
||||
runtime::HookProgressEvent::Started {
|
||||
event,
|
||||
tool_name,
|
||||
command,
|
||||
} => eprintln!(
|
||||
"[hook {event_name}] {tool_name}: {command}",
|
||||
event_name = event.as_str()
|
||||
),
|
||||
runtime::HookProgressEvent::Completed {
|
||||
event,
|
||||
tool_name,
|
||||
command,
|
||||
} => eprintln!(
|
||||
"[hook done {event_name}] {tool_name}: {command}",
|
||||
event_name = event.as_str()
|
||||
),
|
||||
runtime::HookProgressEvent::Cancelled {
|
||||
event,
|
||||
tool_name,
|
||||
command,
|
||||
} => eprintln!(
|
||||
"[hook cancelled {event_name}] {tool_name}: {command}",
|
||||
event_name = event.as_str()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,631 @@
|
|||
use crate::api_client::AnthropicRuntimeClient;
|
||||
use crate::constants::{AllowedToolSet, DEFAULT_DATE};
|
||||
use crate::permission::permission_policy;
|
||||
use crate::progress::InternalPromptProgressReporter;
|
||||
use crate::tool_executor::CliToolExecutor;
|
||||
use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry};
|
||||
use runtime::{
|
||||
load_system_prompt, ConfigLoader, ConversationRuntime, McpServerManager, McpTool, MessageRole,
|
||||
PermissionMode, Session, ToolError,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeSet;
|
||||
use std::env;
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use tools::{GlobalToolRegistry, RuntimeToolDefinition};
|
||||
|
||||
pub(crate) type RuntimePluginStateBuildOutput = (
|
||||
Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||
Vec<RuntimeToolDefinition>,
|
||||
);
|
||||
|
||||
pub(crate) struct RuntimePluginState {
|
||||
pub(crate) feature_config: runtime::RuntimeFeatureConfig,
|
||||
pub(crate) tool_registry: GlobalToolRegistry,
|
||||
pub(crate) plugin_registry: PluginRegistry,
|
||||
pub(crate) mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||
}
|
||||
|
||||
pub(crate) struct RuntimeMcpState {
|
||||
pub(crate) runtime: tokio::runtime::Runtime,
|
||||
pub(crate) manager: McpServerManager,
|
||||
pub(crate) pending_servers: Vec<String>,
|
||||
pub(crate) degraded_report: Option<runtime::McpDegradedReport>,
|
||||
}
|
||||
|
||||
pub(crate) struct BuiltRuntime {
|
||||
pub(crate) runtime: Option<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>>,
|
||||
pub(crate) plugin_registry: PluginRegistry,
|
||||
pub(crate) plugins_active: bool,
|
||||
pub(crate) mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||
pub(crate) mcp_active: bool,
|
||||
}
|
||||
|
||||
impl BuiltRuntime {
|
||||
pub(crate) fn new(
|
||||
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
|
||||
plugin_registry: PluginRegistry,
|
||||
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
runtime: Some(runtime),
|
||||
plugin_registry,
|
||||
plugins_active: true,
|
||||
mcp_state,
|
||||
mcp_active: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_hook_abort_signal(mut self, hook_abort_signal: runtime::HookAbortSignal) -> Self {
|
||||
let runtime = self
|
||||
.runtime
|
||||
.take()
|
||||
.expect("runtime should exist before installing hook abort signal");
|
||||
self.runtime = Some(runtime.with_hook_abort_signal(hook_abort_signal));
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn shutdown_plugins(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if self.plugins_active {
|
||||
self.plugin_registry.shutdown()?;
|
||||
self.plugins_active = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn shutdown_mcp(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if self.mcp_active {
|
||||
if let Some(mcp_state) = &self.mcp_state {
|
||||
mcp_state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.shutdown()?;
|
||||
}
|
||||
self.mcp_active = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for BuiltRuntime {
|
||||
type Target = ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.runtime
|
||||
.as_ref()
|
||||
.expect("runtime should exist while built runtime is alive")
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for BuiltRuntime {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
self.runtime
|
||||
.as_mut()
|
||||
.expect("runtime should exist while built runtime is alive")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BuiltRuntime {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.shutdown_mcp();
|
||||
let _ = self.shutdown_plugins();
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeMcpState {
|
||||
pub(crate) fn new(
|
||||
runtime_config: &runtime::RuntimeConfig,
|
||||
) -> Result<Option<(Self, runtime::McpToolDiscoveryReport)>, Box<dyn std::error::Error>> {
|
||||
let mut manager = McpServerManager::from_runtime_config(runtime_config);
|
||||
if manager.server_names().is_empty() && manager.unsupported_servers().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let runtime = tokio::runtime::Runtime::new()?;
|
||||
let discovery = runtime.block_on(manager.discover_tools_best_effort());
|
||||
let pending_servers = discovery
|
||||
.failed_servers
|
||||
.iter()
|
||||
.map(|failure| failure.server_name.clone())
|
||||
.chain(
|
||||
discovery
|
||||
.unsupported_servers
|
||||
.iter()
|
||||
.map(|server| server.server_name.clone()),
|
||||
)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
let available_tools = discovery
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| tool.qualified_name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let failed_server_names = pending_servers.iter().cloned().collect::<BTreeSet<_>>();
|
||||
let working_servers = manager
|
||||
.server_names()
|
||||
.into_iter()
|
||||
.filter(|server_name| !failed_server_names.contains(server_name))
|
||||
.collect::<Vec<_>>();
|
||||
let failed_servers =
|
||||
discovery
|
||||
.failed_servers
|
||||
.iter()
|
||||
.map(|failure| runtime::McpFailedServer {
|
||||
server_name: failure.server_name.clone(),
|
||||
phase: runtime::McpLifecyclePhase::ToolDiscovery,
|
||||
error: runtime::McpErrorSurface::new(
|
||||
runtime::McpLifecyclePhase::ToolDiscovery,
|
||||
Some(failure.server_name.clone()),
|
||||
failure.error.clone(),
|
||||
std::collections::BTreeMap::new(),
|
||||
true,
|
||||
),
|
||||
})
|
||||
.chain(discovery.unsupported_servers.iter().map(|server| {
|
||||
runtime::McpFailedServer {
|
||||
server_name: server.server_name.clone(),
|
||||
phase: runtime::McpLifecyclePhase::ServerRegistration,
|
||||
error: runtime::McpErrorSurface::new(
|
||||
runtime::McpLifecyclePhase::ServerRegistration,
|
||||
Some(server.server_name.clone()),
|
||||
server.reason.clone(),
|
||||
std::collections::BTreeMap::from([(
|
||||
"transport".to_string(),
|
||||
format!("{:?}", server.transport).to_ascii_lowercase(),
|
||||
)]),
|
||||
false,
|
||||
),
|
||||
}
|
||||
}))
|
||||
.collect::<Vec<_>>();
|
||||
let degraded_report = (!failed_servers.is_empty()).then(|| {
|
||||
runtime::McpDegradedReport::new(
|
||||
working_servers,
|
||||
failed_servers,
|
||||
available_tools.clone(),
|
||||
available_tools,
|
||||
)
|
||||
});
|
||||
|
||||
Ok(Some((
|
||||
Self {
|
||||
runtime,
|
||||
manager,
|
||||
pending_servers,
|
||||
degraded_report,
|
||||
},
|
||||
discovery,
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) fn shutdown(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.runtime.block_on(self.manager.shutdown())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn pending_servers(&self) -> Option<Vec<String>> {
|
||||
(!self.pending_servers.is_empty()).then(|| self.pending_servers.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn degraded_report(&self) -> Option<runtime::McpDegradedReport> {
|
||||
self.degraded_report.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn server_names(&self) -> Vec<String> {
|
||||
self.manager.server_names()
|
||||
}
|
||||
|
||||
pub(crate) fn call_tool(
|
||||
&mut self,
|
||||
qualified_tool_name: &str,
|
||||
arguments: Option<serde_json::Value>,
|
||||
) -> Result<String, ToolError> {
|
||||
let response = self
|
||||
.runtime
|
||||
.block_on(self.manager.call_tool(qualified_tool_name, arguments))
|
||||
.map_err(|error| ToolError::new(error.to_string()))?;
|
||||
if let Some(error) = response.error {
|
||||
return Err(ToolError::new(format!(
|
||||
"MCP tool `{qualified_tool_name}` returned JSON-RPC error: {} ({})",
|
||||
error.message, error.code
|
||||
)));
|
||||
}
|
||||
|
||||
let result = response.result.ok_or_else(|| {
|
||||
ToolError::new(format!(
|
||||
"MCP tool `{qualified_tool_name}` returned no result payload"
|
||||
))
|
||||
})?;
|
||||
serde_json::to_string_pretty(&result).map_err(|error| ToolError::new(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn list_resources_for_server(&mut self, server_name: &str) -> Result<String, ToolError> {
|
||||
let result = self
|
||||
.runtime
|
||||
.block_on(self.manager.list_resources(server_name))
|
||||
.map_err(|error| ToolError::new(error.to_string()))?;
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"server": server_name,
|
||||
"resources": result.resources,
|
||||
}))
|
||||
.map_err(|error| ToolError::new(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn list_resources_for_all_servers(&mut self) -> Result<String, ToolError> {
|
||||
let mut resources = Vec::new();
|
||||
let mut failures = Vec::new();
|
||||
|
||||
for server_name in self.server_names() {
|
||||
match self
|
||||
.runtime
|
||||
.block_on(self.manager.list_resources(&server_name))
|
||||
{
|
||||
Ok(result) => resources.push(serde_json::json!({
|
||||
"server": server_name,
|
||||
"resources": result.resources,
|
||||
})),
|
||||
Err(error) => failures.push(serde_json::json!({
|
||||
"server": server_name,
|
||||
"error": error.to_string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
if resources.is_empty() && !failures.is_empty() {
|
||||
let message = failures
|
||||
.iter()
|
||||
.filter_map(|failure| failure.get("error").and_then(serde_json::Value::as_str))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
return Err(ToolError::new(message));
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"resources": resources,
|
||||
"failures": failures,
|
||||
}))
|
||||
.map_err(|error| ToolError::new(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn read_resource(&mut self, server_name: &str, uri: &str) -> Result<String, ToolError> {
|
||||
let result = self
|
||||
.runtime
|
||||
.block_on(self.manager.read_resource(server_name, uri))
|
||||
.map_err(|error| ToolError::new(error.to_string()))?;
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"server": server_name,
|
||||
"contents": result.contents,
|
||||
}))
|
||||
.map_err(|error| ToolError::new(error.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_runtime_mcp_state(
|
||||
runtime_config: &runtime::RuntimeConfig,
|
||||
) -> Result<RuntimePluginStateBuildOutput, Box<dyn std::error::Error>> {
|
||||
let Some((mcp_state, discovery)) = RuntimeMcpState::new(runtime_config)? else {
|
||||
return Ok((None, Vec::new()));
|
||||
};
|
||||
|
||||
let mut runtime_tools = discovery
|
||||
.tools
|
||||
.iter()
|
||||
.map(mcp_runtime_tool_definition)
|
||||
.collect::<Vec<_>>();
|
||||
if !mcp_state.server_names().is_empty() {
|
||||
runtime_tools.extend(mcp_wrapper_tool_definitions());
|
||||
}
|
||||
|
||||
Ok((Some(Arc::new(Mutex::new(mcp_state))), runtime_tools))
|
||||
}
|
||||
|
||||
pub(crate) fn mcp_runtime_tool_definition(tool: &runtime::ManagedMcpTool) -> RuntimeToolDefinition {
|
||||
RuntimeToolDefinition {
|
||||
name: tool.qualified_name.clone(),
|
||||
description: Some(
|
||||
tool.tool
|
||||
.description
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("Invoke MCP tool `{}`.", tool.qualified_name)),
|
||||
),
|
||||
input_schema: tool
|
||||
.tool
|
||||
.input_schema
|
||||
.clone()
|
||||
.unwrap_or_else(|| serde_json::json!({ "type": "object", "additionalProperties": true })),
|
||||
required_permission: permission_mode_for_mcp_tool(&tool.tool),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mcp_wrapper_tool_definitions() -> Vec<RuntimeToolDefinition> {
|
||||
vec![
|
||||
RuntimeToolDefinition {
|
||||
name: "MCPTool".to_string(),
|
||||
description: Some(
|
||||
"Call a configured MCP tool by its qualified name and JSON arguments.".to_string(),
|
||||
),
|
||||
input_schema:serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"qualifiedName": { "type": "string" },
|
||||
"arguments": {}
|
||||
},
|
||||
"required": ["qualifiedName"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
required_permission: PermissionMode::DangerFullAccess,
|
||||
},
|
||||
RuntimeToolDefinition {
|
||||
name: "ListMcpResourcesTool".to_string(),
|
||||
description: Some(
|
||||
"List MCP resources from one configured server or from every connected server."
|
||||
.to_string(),
|
||||
),
|
||||
input_schema:serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
required_permission: PermissionMode::ReadOnly,
|
||||
},
|
||||
RuntimeToolDefinition {
|
||||
name: "ReadMcpResourceTool".to_string(),
|
||||
description: Some("Read a specific MCP resource from a configured server.".to_string()),
|
||||
input_schema:serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": { "type": "string" },
|
||||
"uri": { "type": "string" }
|
||||
},
|
||||
"required": ["server", "uri"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
required_permission: PermissionMode::ReadOnly,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn permission_mode_for_mcp_tool(tool: &McpTool) -> PermissionMode {
|
||||
let read_only = mcp_annotation_flag(tool, "readOnlyHint");
|
||||
let destructive = mcp_annotation_flag(tool, "destructiveHint");
|
||||
let open_world = mcp_annotation_flag(tool, "openWorldHint");
|
||||
|
||||
if read_only && !destructive && !open_world {
|
||||
PermissionMode::ReadOnly
|
||||
} else if destructive || open_world {
|
||||
PermissionMode::DangerFullAccess
|
||||
} else {
|
||||
PermissionMode::WorkspaceWrite
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mcp_annotation_flag(tool: &McpTool, key: &str) -> bool {
|
||||
tool.annotations
|
||||
.as_ref()
|
||||
.and_then(|annotations| annotations.get(key))
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) struct HookAbortMonitor {
|
||||
pub(crate) stop_tx: Option<Sender<()>>,
|
||||
pub(crate) join_handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl HookAbortMonitor {
|
||||
pub(crate) fn spawn(abort_signal: runtime::HookAbortSignal) -> Self {
|
||||
Self::spawn_with_waiter(abort_signal, move |stop_rx, abort_signal| {
|
||||
let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
runtime.block_on(async move {
|
||||
let wait_for_stop = tokio::task::spawn_blocking(move || {
|
||||
let _ = stop_rx.recv();
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
result = tokio::signal::ctrl_c() => {
|
||||
if result.is_ok() {
|
||||
abort_signal.abort();
|
||||
}
|
||||
}
|
||||
_ = wait_for_stop => {}
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_with_waiter<F>(abort_signal: runtime::HookAbortSignal, wait_for_interrupt: F) -> Self
|
||||
where
|
||||
F: FnOnce(Receiver<()>, runtime::HookAbortSignal) + Send + 'static,
|
||||
{
|
||||
let (stop_tx, stop_rx) = mpsc::channel();
|
||||
let join_handle = thread::spawn(move || wait_for_interrupt(stop_rx, abort_signal));
|
||||
|
||||
Self {
|
||||
stop_tx: Some(stop_tx),
|
||||
join_handle: Some(join_handle),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn stop(mut self) {
|
||||
if let Some(stop_tx) = self.stop_tx.take() {
|
||||
let _ = stop_tx.send(());
|
||||
}
|
||||
if let Some(join_handle) = self.join_handle.take() {
|
||||
let _ = join_handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||
Ok(load_system_prompt(
|
||||
env::current_dir()?,
|
||||
DEFAULT_DATE,
|
||||
env::consts::OS,
|
||||
"unknown",
|
||||
)?)
|
||||
}
|
||||
|
||||
pub(crate) fn build_runtime_plugin_state() -> Result<RuntimePluginState, Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
let loader = ConfigLoader::default_for(&cwd);
|
||||
let runtime_config = loader.load()?;
|
||||
build_runtime_plugin_state_with_loader(&cwd, &loader, &runtime_config)
|
||||
}
|
||||
|
||||
pub(crate) fn build_runtime_plugin_state_with_loader(
|
||||
cwd: &Path,
|
||||
loader: &ConfigLoader,
|
||||
runtime_config: &runtime::RuntimeConfig,
|
||||
) -> Result<RuntimePluginState, Box<dyn std::error::Error>> {
|
||||
let plugin_manager = build_plugin_manager(cwd, loader, runtime_config);
|
||||
let plugin_registry = plugin_manager.plugin_registry()?;
|
||||
let plugin_hook_config =
|
||||
runtime_hook_config_from_plugin_hooks(plugin_registry.aggregated_hooks()?);
|
||||
let feature_config = runtime_config
|
||||
.feature_config()
|
||||
.clone()
|
||||
.with_hooks(runtime_config.hooks().merged(&plugin_hook_config));
|
||||
let (mcp_state, runtime_tools) = build_runtime_mcp_state(runtime_config)?;
|
||||
let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_registry.aggregated_tools()?)?
|
||||
.with_runtime_tools(runtime_tools)?;
|
||||
Ok(RuntimePluginState {
|
||||
feature_config,
|
||||
tool_registry,
|
||||
plugin_registry,
|
||||
mcp_state,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn build_plugin_manager(
|
||||
cwd: &Path,
|
||||
loader: &ConfigLoader,
|
||||
runtime_config: &runtime::RuntimeConfig,
|
||||
) -> PluginManager {
|
||||
let plugin_settings = runtime_config.plugins();
|
||||
let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf());
|
||||
plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone();
|
||||
plugin_config.external_dirs = plugin_settings
|
||||
.external_directories()
|
||||
.iter()
|
||||
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path))
|
||||
.collect();
|
||||
plugin_config.install_root = plugin_settings
|
||||
.install_root()
|
||||
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
|
||||
plugin_config.registry_path = plugin_settings
|
||||
.registry_path()
|
||||
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
|
||||
plugin_config.bundled_root = plugin_settings
|
||||
.bundled_root()
|
||||
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
|
||||
PluginManager::new(plugin_config)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf {
|
||||
let path = PathBuf::from(value);
|
||||
if path.is_absolute() {
|
||||
path
|
||||
} else if value.starts_with('.') {
|
||||
cwd.join(path)
|
||||
} else {
|
||||
config_home.join(path)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_hook_config_from_plugin_hooks(hooks: PluginHooks) -> runtime::RuntimeHookConfig {
|
||||
runtime::RuntimeHookConfig::new(
|
||||
hooks.pre_tool_use,
|
||||
hooks.post_tool_use,
|
||||
hooks.post_tool_use_failure,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_runtime(
|
||||
session: Session,
|
||||
session_id: &str,
|
||||
model: String,
|
||||
system_prompt: Vec<String>,
|
||||
enable_tools: bool,
|
||||
emit_output: bool,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
permission_mode: PermissionMode,
|
||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||
) -> Result<BuiltRuntime, Box<dyn std::error::Error>> {
|
||||
let runtime_plugin_state = build_runtime_plugin_state()?;
|
||||
build_runtime_with_plugin_state(
|
||||
session,
|
||||
session_id,
|
||||
model,
|
||||
system_prompt,
|
||||
enable_tools,
|
||||
emit_output,
|
||||
allowed_tools,
|
||||
permission_mode,
|
||||
progress_reporter,
|
||||
runtime_plugin_state,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_runtime_with_plugin_state(
|
||||
session: Session,
|
||||
session_id: &str,
|
||||
model: String,
|
||||
system_prompt: Vec<String>,
|
||||
enable_tools: bool,
|
||||
emit_output: bool,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
permission_mode: PermissionMode,
|
||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||
runtime_plugin_state: RuntimePluginState,
|
||||
) -> Result<BuiltRuntime, Box<dyn std::error::Error>> {
|
||||
let RuntimePluginState {
|
||||
feature_config,
|
||||
tool_registry,
|
||||
plugin_registry,
|
||||
mcp_state,
|
||||
} = runtime_plugin_state;
|
||||
plugin_registry.initialize()?;
|
||||
let policy = permission_policy(permission_mode, &feature_config, &tool_registry)
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
let executor = CliToolExecutor::new(
|
||||
allowed_tools.clone(),
|
||||
emit_output,
|
||||
tool_registry.clone(),
|
||||
mcp_state.clone(),
|
||||
);
|
||||
|
||||
let client = AnthropicRuntimeClient::new(
|
||||
session_id,
|
||||
model,
|
||||
enable_tools,
|
||||
emit_output,
|
||||
allowed_tools,
|
||||
tool_registry,
|
||||
progress_reporter,
|
||||
)?;
|
||||
|
||||
let runtime = ConversationRuntime::new_with_features(
|
||||
session,
|
||||
client,
|
||||
executor,
|
||||
policy,
|
||||
system_prompt,
|
||||
&feature_config,
|
||||
);
|
||||
|
||||
Ok(BuiltRuntime::new(runtime, plugin_registry, mcp_state))
|
||||
}
|
||||
|
|
@ -0,0 +1,417 @@
|
|||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use commands::{render_slash_command_help_filtered, SlashCommand};
|
||||
use runtime::{ContentBlock, MessageRole, Session};
|
||||
|
||||
use crate::constants::*;
|
||||
use crate::live_cli::PromptHistoryEntry;
|
||||
use crate::models::*;
|
||||
|
||||
pub(crate) fn collect_session_prompt_history(session: &Session) -> Vec<PromptHistoryEntry> {
|
||||
if !session.prompt_history.is_empty() {
|
||||
return session
|
||||
.prompt_history
|
||||
.iter()
|
||||
.map(|entry| PromptHistoryEntry {
|
||||
timestamp_ms: entry.timestamp_ms,
|
||||
text: entry.text.clone(),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let timestamp_ms = session.updated_at_ms;
|
||||
session
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|message| message.role == MessageRole::User)
|
||||
.filter_map(|message| {
|
||||
message.blocks.iter().find_map(|block| match block {
|
||||
ContentBlock::Text { text } => Some(PromptHistoryEntry {
|
||||
timestamp_ms,
|
||||
text: text.clone(),
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn recent_user_context(session: &Session, limit: usize) -> String {
|
||||
let requests = session
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|message| message.role == MessageRole::User)
|
||||
.filter_map(|message| {
|
||||
message.blocks.iter().find_map(|block| match block {
|
||||
ContentBlock::Text { text } => Some(text.trim().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
.rev()
|
||||
.take(limit)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if requests.is_empty() {
|
||||
"<no prior user messages>".to_string()
|
||||
} else {
|
||||
requests
|
||||
.into_iter()
|
||||
.rev()
|
||||
.enumerate()
|
||||
.map(|(index, text)| format!("{}. {}", index + 1, text))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn git_output(args: &[&str]) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(env::current_dir()?)
|
||||
.output()?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(format!("git {} failed: {stderr}", args.join(" ")).into());
|
||||
}
|
||||
Ok(String::from_utf8(output.stdout)?)
|
||||
}
|
||||
|
||||
pub(crate) fn git_status_ok(args: &[&str]) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(env::current_dir()?)
|
||||
.output()?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(format!("git {} failed: {stderr}", args.join(" ")).into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn command_exists(name: &str) -> bool {
|
||||
Command::new("which")
|
||||
.arg(name)
|
||||
.output()
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn write_temp_text_file(
|
||||
filename: &str,
|
||||
contents: &str,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let path = env::temp_dir().join(filename);
|
||||
fs::write(&path, contents)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub(crate) fn current_session_store() -> Result<runtime::SessionStore, Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
runtime::SessionStore::from_cwd(&cwd).map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
|
||||
}
|
||||
|
||||
pub(crate) fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
Ok(current_session_store()?.sessions_dir().to_path_buf())
|
||||
}
|
||||
|
||||
pub(crate) fn new_cli_session() -> Result<Session, Box<dyn std::error::Error>> {
|
||||
Ok(Session::new().with_workspace_root(env::current_dir()?))
|
||||
}
|
||||
|
||||
pub(crate) fn create_managed_session_handle(
|
||||
session_id: &str,
|
||||
) -> Result<SessionHandle, Box<dyn std::error::Error>> {
|
||||
let handle = current_session_store()?.create_handle(session_id);
|
||||
Ok(SessionHandle {
|
||||
id: handle.id,
|
||||
path: handle.path,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> {
|
||||
let handle = current_session_store()?
|
||||
.resolve_reference(reference)
|
||||
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
||||
Ok(SessionHandle {
|
||||
id: handle.id,
|
||||
path: handle.path,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_managed_session_path(session_id: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
current_session_store()?
|
||||
.resolve_managed_path(session_id)
|
||||
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
|
||||
}
|
||||
|
||||
pub(crate) fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::error::Error>> {
|
||||
let store = current_session_store()?;
|
||||
let sessions = store.list_sessions().map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
||||
let panes = discover_tmux_panes();
|
||||
|
||||
Ok(sessions
|
||||
.into_iter()
|
||||
.map(|session| {
|
||||
let lifecycle = classify_session_lifecycle_from_panes(&session.path, panes.clone());
|
||||
ManagedSessionSummary {
|
||||
id: session.id,
|
||||
path: session.path,
|
||||
updated_at_ms: session.updated_at_ms,
|
||||
modified_epoch_millis: session.modified_epoch_millis,
|
||||
message_count: session.message_count,
|
||||
parent_session_id: session.parent_session_id,
|
||||
branch_name: session.branch_name,
|
||||
lifecycle,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn std::error::Error>> {
|
||||
let store = current_session_store()?;
|
||||
let session = store.latest_session().map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
||||
let lifecycle = classify_session_lifecycle_for(&session.path);
|
||||
|
||||
Ok(ManagedSessionSummary {
|
||||
id: session.id,
|
||||
path: session.path,
|
||||
updated_at_ms: session.updated_at_ms,
|
||||
modified_epoch_millis: session.modified_epoch_millis,
|
||||
message_count: session.message_count,
|
||||
parent_session_id: session.parent_session_id,
|
||||
branch_name: session.branch_name,
|
||||
lifecycle,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn load_session_reference(
|
||||
reference: &str,
|
||||
) -> Result<(SessionHandle, Session), Box<dyn std::error::Error>> {
|
||||
let loaded = current_session_store()?
|
||||
.load_session(reference)
|
||||
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
||||
Ok((
|
||||
SessionHandle {
|
||||
id: loaded.handle.id,
|
||||
path: loaded.handle.path,
|
||||
},
|
||||
loaded.session,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn delete_managed_session(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if !path.exists() {
|
||||
return Err(format!("session file does not exist: {}", path.display()).into());
|
||||
}
|
||||
fs::remove_file(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn confirm_session_deletion(session_id: &str) -> bool {
|
||||
print!("Delete session '{session_id}'? This cannot be undone. [y/N]: ");
|
||||
io::stdout().flush().unwrap_or(());
|
||||
let mut answer = String::new();
|
||||
if io::stdin().read_line(&mut answer).is_err() {
|
||||
return false;
|
||||
}
|
||||
matches!(answer.trim(), "y" | "Y" | "yes" | "Yes" | "YES")
|
||||
}
|
||||
|
||||
pub(crate) fn classify_session_lifecycle_for(workspace: &Path) -> SessionLifecycleSummary {
|
||||
classify_session_lifecycle_from_panes(workspace, discover_tmux_panes())
|
||||
}
|
||||
|
||||
pub(crate) fn classify_session_lifecycle_from_panes(
|
||||
workspace: &Path,
|
||||
panes: Vec<TmuxPaneSnapshot>,
|
||||
) -> SessionLifecycleSummary {
|
||||
let workspace_dirty = git_worktree_is_dirty(workspace);
|
||||
let mut idle_shell = None;
|
||||
for pane in panes {
|
||||
if !pane_path_matches_workspace(&pane.current_path, workspace) {
|
||||
continue;
|
||||
}
|
||||
if is_idle_shell_command(&pane.current_command) {
|
||||
idle_shell.get_or_insert(pane);
|
||||
} else {
|
||||
return SessionLifecycleSummary {
|
||||
kind: SessionLifecycleKind::RunningProcess,
|
||||
pane_id: Some(pane.pane_id),
|
||||
pane_command: Some(pane.current_command),
|
||||
pane_path: Some(pane.current_path),
|
||||
workspace_dirty,
|
||||
abandoned: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pane) = idle_shell {
|
||||
SessionLifecycleSummary {
|
||||
kind: SessionLifecycleKind::IdleShell,
|
||||
pane_id: Some(pane.pane_id),
|
||||
pane_command: Some(pane.current_command),
|
||||
pane_path: Some(pane.current_path),
|
||||
workspace_dirty,
|
||||
abandoned: workspace_dirty,
|
||||
}
|
||||
} else {
|
||||
SessionLifecycleSummary {
|
||||
kind: SessionLifecycleKind::SavedOnly,
|
||||
pane_id: None,
|
||||
pane_command: None,
|
||||
pane_path: None,
|
||||
workspace_dirty,
|
||||
abandoned: workspace_dirty,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn discover_tmux_panes() -> Vec<TmuxPaneSnapshot> {
|
||||
let output = Command::new("tmux")
|
||||
.args([
|
||||
"list-panes",
|
||||
"-a",
|
||||
"-F",
|
||||
"#{pane_id}\t#{pane_current_command}\t#{pane_current_path}",
|
||||
])
|
||||
.output();
|
||||
let Ok(output) = output else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !output.status.success() {
|
||||
return Vec::new();
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
parse_tmux_pane_snapshots(&stdout)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_tmux_pane_snapshots(output: &str) -> Vec<TmuxPaneSnapshot> {
|
||||
output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let mut fields = line.splitn(3, '\t');
|
||||
let pane_id = fields.next()?.trim();
|
||||
let current_command = fields.next()?.trim();
|
||||
let current_path = fields.next()?.trim();
|
||||
if pane_id.is_empty() || current_path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(TmuxPaneSnapshot {
|
||||
pane_id: pane_id.to_string(),
|
||||
current_command: current_command.to_string(),
|
||||
current_path: PathBuf::from(current_path),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn pane_path_matches_workspace(pane_path: &Path, workspace: &Path) -> bool {
|
||||
let pane_path = fs::canonicalize(pane_path).unwrap_or_else(|_| pane_path.to_path_buf());
|
||||
let workspace = fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
|
||||
pane_path == workspace || pane_path.starts_with(&workspace)
|
||||
}
|
||||
|
||||
pub(crate) fn is_idle_shell_command(command: &str) -> bool {
|
||||
let command = command.rsplit('/').next().unwrap_or(command);
|
||||
matches!(
|
||||
command,
|
||||
"bash" | "zsh" | "sh" | "fish" | "nu" | "pwsh" | "powershell" | "cmd"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn git_worktree_is_dirty(workspace: &Path) -> bool {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(workspace)
|
||||
.args(["status", "--porcelain"])
|
||||
.output();
|
||||
output
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.is_some_and(|output| !output.stdout.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let sessions = list_managed_sessions()?;
|
||||
let mut lines = vec![
|
||||
"Sessions".to_string(),
|
||||
format!(" Directory {}", sessions_dir()?.display()),
|
||||
];
|
||||
if sessions.is_empty() {
|
||||
lines.push(" No managed sessions saved yet.".to_string());
|
||||
return Ok(lines.join("\n"));
|
||||
}
|
||||
for session in sessions {
|
||||
let marker = if session.id == active_session_id {
|
||||
"● current"
|
||||
} else {
|
||||
"○ saved"
|
||||
};
|
||||
let lineage = match (
|
||||
session.branch_name.as_deref(),
|
||||
session.parent_session_id.as_deref(),
|
||||
) {
|
||||
(Some(branch_name), Some(parent_session_id)) => {
|
||||
format!(" branch={branch_name} from={parent_session_id}")
|
||||
}
|
||||
(None, Some(parent_session_id)) => format!(" from={parent_session_id}"),
|
||||
(Some(branch_name), None) => format!(" branch={branch_name}"),
|
||||
(None, None) => String::new(),
|
||||
};
|
||||
lines.push(format!(
|
||||
" {id:<20} {marker:<10} msgs={msgs:<4} modified={modified}{lineage} lifecycle={lifecycle} path={path}",
|
||||
id = session.id,
|
||||
msgs = session.message_count,
|
||||
modified = format_session_modified_age(session.modified_epoch_millis),
|
||||
lineage = lineage,
|
||||
lifecycle = session.lifecycle.signal(),
|
||||
path = session.path.display(),
|
||||
));
|
||||
}
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
pub(crate) fn format_session_modified_age(modified_epoch_millis: u128) -> String {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map_or(modified_epoch_millis, |duration| duration.as_millis());
|
||||
let delta_seconds = now
|
||||
.saturating_sub(modified_epoch_millis)
|
||||
.checked_div(1_000)
|
||||
.unwrap_or_default();
|
||||
match delta_seconds {
|
||||
0..=4 => "just-now".to_string(),
|
||||
5..=59 => format!("{delta_seconds}s-ago"),
|
||||
60..=3_599 => format!("{}m-ago", delta_seconds / 60),
|
||||
3_600..=86_399 => format!("{}h-ago", delta_seconds / 3_600),
|
||||
_ => format!("{}d-ago", delta_seconds / 86_400),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn write_session_clear_backup(
|
||||
session: &Session,
|
||||
session_path: &Path,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let backup_path = session_clear_backup_path(session_path);
|
||||
session.save_to_path(&backup_path)?;
|
||||
Ok(backup_path)
|
||||
}
|
||||
|
||||
pub(crate) fn session_clear_backup_path(session_path: &Path) -> PathBuf {
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map_or(0, |duration| duration.as_millis());
|
||||
let file_name = session_path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("session.jsonl");
|
||||
session_path.with_file_name(format!("{file_name}.before-clear-{timestamp}.bak"))
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,532 @@
|
|||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use serde_json::{json, Map, Value};
|
||||
use runtime::{ConfigLoader, PermissionMode, ProjectContext, SandboxConfig, SandboxStatus, TokenUsage};
|
||||
use runtime::resolve_sandbox_status;
|
||||
use crate::args::*;
|
||||
use crate::model::*;
|
||||
use crate::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct StatusContext {
|
||||
pub(crate) cwd: PathBuf,
|
||||
pub(crate) session_path: Option<PathBuf>,
|
||||
pub(crate) loaded_config_files: usize,
|
||||
pub(crate) discovered_config_files: usize,
|
||||
pub(crate) memory_file_count: usize,
|
||||
pub(crate) project_root: Option<PathBuf>,
|
||||
pub(crate) git_branch: Option<String>,
|
||||
pub(crate) git_summary: GitWorkspaceSummary,
|
||||
pub(crate) session_lifecycle: SessionLifecycleSummary,
|
||||
pub(crate) sandbox_status: runtime::SandboxStatus,
|
||||
pub(crate) config_load_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct StatusUsage {
|
||||
pub(crate) message_count: usize,
|
||||
pub(crate) turns: u32,
|
||||
pub(crate) latest: TokenUsage,
|
||||
pub(crate) cumulative: TokenUsage,
|
||||
pub(crate) estimated_tokens: usize,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_git_status_metadata(status: Option<&str>) -> (Option<PathBuf>, Option<String>) {
|
||||
parse_git_status_metadata_for(
|
||||
&env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
status,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_git_status_branch(status: Option<&str>) -> Option<String> {
|
||||
let status = status?;
|
||||
let first_line = status.lines().next()?;
|
||||
let line = first_line.strip_prefix("## ")?;
|
||||
if line.starts_with("HEAD") {
|
||||
return Some("detached HEAD".to_string());
|
||||
}
|
||||
let branch = line.split(['.', ' ']).next().unwrap_or_default().trim();
|
||||
if branch.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(branch.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_git_workspace_summary(status: Option<&str>) -> GitWorkspaceSummary {
|
||||
let mut summary = GitWorkspaceSummary::default();
|
||||
let Some(status) = status else {
|
||||
return summary;
|
||||
};
|
||||
|
||||
for line in status.lines() {
|
||||
if line.starts_with("## ") || line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.changed_files += 1;
|
||||
let mut chars = line.chars();
|
||||
let index_status = chars.next().unwrap_or(' ');
|
||||
let worktree_status = chars.next().unwrap_or(' ');
|
||||
|
||||
if index_status == '?' && worktree_status == '?' {
|
||||
summary.untracked_files += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if index_status != ' ' {
|
||||
summary.staged_files += 1;
|
||||
}
|
||||
if worktree_status != ' ' {
|
||||
summary.unstaged_files += 1;
|
||||
}
|
||||
if (matches!(index_status, 'U' | 'A') && matches!(worktree_status, 'U' | 'A'))
|
||||
|| index_status == 'U'
|
||||
|| worktree_status == 'U'
|
||||
{
|
||||
summary.conflicted_files += 1;
|
||||
}
|
||||
}
|
||||
|
||||
summary
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_git_branch_for(cwd: &Path) -> Option<String> {
|
||||
let branch = run_git_capture_in(cwd, &["branch", "--show-current"])?;
|
||||
let branch = branch.trim();
|
||||
if !branch.is_empty() {
|
||||
return Some(branch.to_string());
|
||||
}
|
||||
|
||||
let fallback = run_git_capture_in(cwd, &["rev-parse", "--abbrev-ref", "HEAD"])?;
|
||||
let fallback = fallback.trim();
|
||||
if fallback.is_empty() {
|
||||
None
|
||||
} else if fallback == "HEAD" {
|
||||
Some("detached HEAD".to_string())
|
||||
} else {
|
||||
Some(fallback.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run_git_capture_in(cwd: &Path, args: &[&str]) -> Option<String> {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8(output.stdout).ok()
|
||||
}
|
||||
|
||||
pub(crate) fn find_git_root_in(cwd: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["rev-parse", "--show-toplevel"])
|
||||
.current_dir(cwd)
|
||||
.output()?;
|
||||
if !output.status.success() {
|
||||
return Err("not a git repository".into());
|
||||
}
|
||||
let path = String::from_utf8(output.stdout)?.trim().to_string();
|
||||
if path.is_empty() {
|
||||
return Err("empty git root".into());
|
||||
}
|
||||
Ok(PathBuf::from(path))
|
||||
}
|
||||
|
||||
pub(crate) fn parse_git_status_metadata_for(
|
||||
cwd: &Path,
|
||||
status: Option<&str>,
|
||||
) -> (Option<PathBuf>, Option<String>) {
|
||||
let branch = resolve_git_branch_for(cwd).or_else(|| parse_git_status_branch(status));
|
||||
let project_root = find_git_root_in(cwd).ok();
|
||||
(project_root, branch)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub(crate) fn print_status_snapshot(
|
||||
model: &str,
|
||||
model_flag_raw: Option<&str>,
|
||||
permission_mode: PermissionMode,
|
||||
output_format: CliOutputFormat,
|
||||
allowed_tools: Option<&AllowedToolSet>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let usage = StatusUsage {
|
||||
message_count: 0,
|
||||
turns: 0,
|
||||
latest: TokenUsage::default(),
|
||||
cumulative: TokenUsage::default(),
|
||||
estimated_tokens: 0,
|
||||
};
|
||||
let context = status_context(None)?;
|
||||
// #148: resolve model provenance. If user passed --model, source is
|
||||
// "flag" with the raw input preserved. Otherwise probe env -> config
|
||||
// -> default and record the winning source.
|
||||
let provenance = match model_flag_raw {
|
||||
Some(raw) => ModelProvenance {
|
||||
resolved: model.to_string(),
|
||||
raw: Some(raw.to_string()),
|
||||
source: ModelSource::Flag,
|
||||
},
|
||||
None => ModelProvenance::from_env_or_config_or_default(model),
|
||||
};
|
||||
match output_format {
|
||||
CliOutputFormat::Text => println!(
|
||||
"{}",
|
||||
format_status_report(
|
||||
&provenance.resolved,
|
||||
usage,
|
||||
permission_mode.as_str(),
|
||||
&context,
|
||||
Some(&provenance)
|
||||
)
|
||||
),
|
||||
CliOutputFormat::Json => println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&status_json_value(
|
||||
Some(&provenance.resolved),
|
||||
usage,
|
||||
permission_mode.as_str(),
|
||||
&context,
|
||||
Some(&provenance),
|
||||
allowed_tools,
|
||||
))?
|
||||
),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn status_json_value(
|
||||
model: Option<&str>,
|
||||
usage: StatusUsage,
|
||||
permission_mode: &str,
|
||||
context: &StatusContext,
|
||||
// #148: optional provenance for `model` field. Surfaces `model_source`
|
||||
// ("flag" | "env" | "config" | "default") and `model_raw` (user input
|
||||
// before alias resolution, or null when source is "default"). Callers
|
||||
// that don't have provenance (legacy resume paths) pass None, in which
|
||||
// case both new fields are omitted.
|
||||
provenance: Option<&ModelProvenance>,
|
||||
allowed_tools: Option<&AllowedToolSet>,
|
||||
) -> serde_json::Value {
|
||||
// #143: top-level `status` marker so claws can distinguish
|
||||
// a clean run from a degraded run (config parse failed but other fields
|
||||
// are still populated). `config_load_error` carries the parse-error string
|
||||
// when present; it's a string rather than a typed object in Phase 1 and
|
||||
// will join the typed-error taxonomy in Phase 2 (ROADMAP §4.44).
|
||||
let degraded = context.config_load_error.is_some();
|
||||
let model_source = provenance.map(|p| p.source.as_str());
|
||||
let model_raw = provenance.and_then(|p| p.raw.clone());
|
||||
let allowed_tool_entries = allowed_tools.map(|tools| tools.iter().cloned().collect::<Vec<_>>());
|
||||
serde_json::json!({
|
||||
"kind": "status",
|
||||
"status": if degraded { "degraded" } else { "ok" },
|
||||
"config_load_error": context.config_load_error,
|
||||
"model": model,
|
||||
"model_source": model_source,
|
||||
"model_raw": model_raw,
|
||||
"permission_mode": permission_mode,
|
||||
"allowed_tools": {
|
||||
"source": if allowed_tools.is_some() { "flag" } else { "default" },
|
||||
"restricted": allowed_tools.is_some(),
|
||||
"entries": allowed_tool_entries,
|
||||
},
|
||||
"usage": {
|
||||
"messages": usage.message_count,
|
||||
"turns": usage.turns,
|
||||
"latest_total": usage.latest.total_tokens(),
|
||||
"cumulative_input": usage.cumulative.input_tokens,
|
||||
"cumulative_output": usage.cumulative.output_tokens,
|
||||
"cumulative_total": usage.cumulative.total_tokens(),
|
||||
"estimated_tokens": usage.estimated_tokens,
|
||||
},
|
||||
"workspace": {
|
||||
"cwd": context.cwd,
|
||||
"project_root": context.project_root,
|
||||
"git_branch": context.git_branch,
|
||||
"git_state": context.git_summary.headline(),
|
||||
"changed_files": context.git_summary.changed_files,
|
||||
"staged_files": context.git_summary.staged_files,
|
||||
"unstaged_files": context.git_summary.unstaged_files,
|
||||
"untracked_files": context.git_summary.untracked_files,
|
||||
"session": context.session_path.as_ref().map_or_else(|| "live-repl".to_string(), |path| path.display().to_string()),
|
||||
"session_id": context.session_path.as_ref().and_then(|path| {
|
||||
// Session files are named <session-id>.jsonl directly under
|
||||
// .claw/sessions/. Extract the stem (drop the .jsonl extension).
|
||||
path.file_stem().map(|n| n.to_string_lossy().into_owned())
|
||||
}),
|
||||
"session_lifecycle": context.session_lifecycle.json_value(),
|
||||
"loaded_config_files": context.loaded_config_files,
|
||||
"discovered_config_files": context.discovered_config_files,
|
||||
"memory_file_count": context.memory_file_count,
|
||||
},
|
||||
"sandbox": {
|
||||
"enabled": context.sandbox_status.enabled,
|
||||
"active": context.sandbox_status.active,
|
||||
"supported": context.sandbox_status.supported,
|
||||
"in_container": context.sandbox_status.in_container,
|
||||
"requested_namespace": context.sandbox_status.requested.namespace_restrictions,
|
||||
"active_namespace": context.sandbox_status.namespace_active,
|
||||
"requested_network": context.sandbox_status.requested.network_isolation,
|
||||
"active_network": context.sandbox_status.network_active,
|
||||
"filesystem_mode": context.sandbox_status.filesystem_mode.as_str(),
|
||||
"filesystem_active": context.sandbox_status.filesystem_active,
|
||||
"allowed_mounts": context.sandbox_status.allowed_mounts,
|
||||
"markers": context.sandbox_status.container_markers,
|
||||
"fallback_reason": context.sandbox_status.fallback_reason,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn status_context(
|
||||
session_path: Option<&Path>,
|
||||
) -> Result<StatusContext, Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
let loader = ConfigLoader::default_for(&cwd);
|
||||
let discovered_config_files = loader.discover().len();
|
||||
// #143: degrade gracefully on config parse failure rather than hard-fail.
|
||||
// `claw doctor` already does this; `claw status` now matches that contract
|
||||
// so that one malformed `mcpServers.*` entry doesn't take down the whole
|
||||
// health surface (workspace, git, model, permission, sandbox can still be
|
||||
// reported independently).
|
||||
let (loaded_config_files, sandbox_status, config_load_error) = match loader.load() {
|
||||
Ok(runtime_config) => (
|
||||
runtime_config.loaded_entries().len(),
|
||||
resolve_sandbox_status(runtime_config.sandbox(), &cwd),
|
||||
None,
|
||||
),
|
||||
Err(err) => (
|
||||
0,
|
||||
// Fall back to defaults for sandbox resolution so claws still see
|
||||
// a populated sandbox section instead of a missing field. Defaults
|
||||
// produce the same output as a runtime config with no sandbox
|
||||
// overrides, which is the right degraded-mode shape: we cannot
|
||||
// report what the user *intended*, only what is actually in effect.
|
||||
resolve_sandbox_status(&runtime::SandboxConfig::default(), &cwd),
|
||||
Some(err.to_string()),
|
||||
),
|
||||
};
|
||||
let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?;
|
||||
let (project_root, git_branch) =
|
||||
parse_git_status_metadata(project_context.git_status.as_deref());
|
||||
let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref());
|
||||
let lifecycle = classify_session_lifecycle_for(&cwd);
|
||||
Ok(StatusContext {
|
||||
cwd,
|
||||
session_path: session_path.map(Path::to_path_buf),
|
||||
loaded_config_files,
|
||||
discovered_config_files,
|
||||
memory_file_count: project_context.instruction_files.len(),
|
||||
project_root,
|
||||
git_branch,
|
||||
git_summary,
|
||||
session_lifecycle: lifecycle,
|
||||
sandbox_status,
|
||||
config_load_error,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn format_status_report(
|
||||
model: &str,
|
||||
usage: StatusUsage,
|
||||
permission_mode: &str,
|
||||
context: &StatusContext,
|
||||
// #148: optional model provenance to surface in a `Model source` line.
|
||||
// Callers without provenance (legacy resume paths) pass None and the
|
||||
// source line is omitted for backward compat.
|
||||
provenance: Option<&ModelProvenance>,
|
||||
) -> String {
|
||||
// #143: if config failed to parse, surface a degraded banner at the top
|
||||
// of the text report so humans see the parse error before the body, while
|
||||
// the body below still reports everything that could be resolved without
|
||||
// config (workspace, git, sandbox defaults, etc.).
|
||||
let status_line = if context.config_load_error.is_some() {
|
||||
"Status (degraded)"
|
||||
} else {
|
||||
"Status"
|
||||
};
|
||||
let mut blocks: Vec<String> = Vec::new();
|
||||
if let Some(err) = context.config_load_error.as_deref() {
|
||||
blocks.push(format!(
|
||||
"Config load error\n Status fail\n Summary runtime config failed to load; reporting partial status\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun"
|
||||
));
|
||||
}
|
||||
// #148: render Model source line after Model, showing where the string
|
||||
// came from (flag / env / config / default) and the raw input if any.
|
||||
let model_source_line = provenance
|
||||
.map(|p| match &p.raw {
|
||||
Some(raw) if raw != model => {
|
||||
format!("\n Model source {} (raw: {raw})", p.source.as_str())
|
||||
}
|
||||
_ => format!("\n Model source {}", p.source.as_str()),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
blocks.extend([
|
||||
format!(
|
||||
"{status_line}
|
||||
Model {model}{model_source_line}
|
||||
Permission mode {permission_mode}
|
||||
Messages {}
|
||||
Turns {}
|
||||
Estimated tokens {}",
|
||||
usage.message_count, usage.turns, usage.estimated_tokens,
|
||||
),
|
||||
format!(
|
||||
"Usage
|
||||
Latest total {}
|
||||
Cumulative input {}
|
||||
Cumulative output {}
|
||||
Cumulative total {}",
|
||||
usage.latest.total_tokens(),
|
||||
usage.cumulative.input_tokens,
|
||||
usage.cumulative.output_tokens,
|
||||
usage.cumulative.total_tokens(),
|
||||
),
|
||||
format!(
|
||||
"Workspace
|
||||
Cwd {}
|
||||
Project root {}
|
||||
Git branch {}
|
||||
Git state {}
|
||||
Changed files {}
|
||||
Staged {}
|
||||
Unstaged {}
|
||||
Untracked {}
|
||||
Session {}
|
||||
Lifecycle {}
|
||||
Config files loaded {}/{}
|
||||
Memory files {}
|
||||
Suggested flow /status → /diff → /commit",
|
||||
context.cwd.display(),
|
||||
context
|
||||
.project_root
|
||||
.as_ref()
|
||||
.map_or_else(|| "unknown".to_string(), |path| path.display().to_string()),
|
||||
context.git_branch.as_deref().unwrap_or("unknown"),
|
||||
context.git_summary.headline(),
|
||||
context.git_summary.changed_files,
|
||||
context.git_summary.staged_files,
|
||||
context.git_summary.unstaged_files,
|
||||
context.git_summary.untracked_files,
|
||||
context.session_path.as_ref().map_or_else(
|
||||
|| "live-repl".to_string(),
|
||||
|path| path.display().to_string()
|
||||
),
|
||||
context.session_lifecycle.signal(),
|
||||
context.loaded_config_files,
|
||||
context.discovered_config_files,
|
||||
context.memory_file_count,
|
||||
),
|
||||
format_sandbox_report(&context.sandbox_status),
|
||||
]);
|
||||
blocks.join("\n\n")
|
||||
}
|
||||
|
||||
pub(crate) fn format_sandbox_report(status: &runtime::SandboxStatus) -> String {
|
||||
format!(
|
||||
"Sandbox
|
||||
Enabled {}
|
||||
Active {}
|
||||
Supported {}
|
||||
In container {}
|
||||
Requested ns {}
|
||||
Active ns {}
|
||||
Requested net {}
|
||||
Active net {}
|
||||
Filesystem mode {}
|
||||
Filesystem active {}
|
||||
Allowed mounts {}
|
||||
Markers {}
|
||||
Fallback reason {}",
|
||||
status.enabled,
|
||||
status.active,
|
||||
status.supported,
|
||||
status.in_container,
|
||||
status.requested.namespace_restrictions,
|
||||
status.namespace_active,
|
||||
status.requested.network_isolation,
|
||||
status.network_active,
|
||||
status.filesystem_mode.as_str(),
|
||||
status.filesystem_active,
|
||||
if status.allowed_mounts.is_empty() {
|
||||
"<none>".to_string()
|
||||
} else {
|
||||
status.allowed_mounts.join(", ")
|
||||
},
|
||||
if status.container_markers.is_empty() {
|
||||
"<none>".to_string()
|
||||
} else {
|
||||
status.container_markers.join(", ")
|
||||
},
|
||||
status
|
||||
.fallback_reason
|
||||
.clone()
|
||||
.unwrap_or_else(|| "<none>".to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_commit_preflight_report(branch: Option<&str>, summary: GitWorkspaceSummary) -> String {
|
||||
format!(
|
||||
"Commit
|
||||
Result ready
|
||||
Branch {}
|
||||
Workspace {}
|
||||
Changed files {}
|
||||
Action create a git commit from the current workspace changes",
|
||||
branch.unwrap_or("unknown"),
|
||||
summary.headline(),
|
||||
summary.changed_files,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_commit_skipped_report() -> String {
|
||||
"Commit
|
||||
Result skipped
|
||||
Reason no workspace changes
|
||||
Action create a git commit from the current workspace changes
|
||||
Next /status to inspect context · /diff to inspect repo changes"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn print_sandbox_status_snapshot(
|
||||
output_format: CliOutputFormat,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
let loader = ConfigLoader::default_for(&cwd);
|
||||
let runtime_config = loader
|
||||
.load()
|
||||
.unwrap_or_else(|_| runtime::RuntimeConfig::empty());
|
||||
let status = resolve_sandbox_status(runtime_config.sandbox(), &cwd);
|
||||
match output_format {
|
||||
CliOutputFormat::Text => println!("{}", format_sandbox_report(&status)),
|
||||
CliOutputFormat::Json => println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&sandbox_json_value(&status))?
|
||||
),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"kind": "sandbox",
|
||||
"enabled": status.enabled,
|
||||
"active": status.active,
|
||||
"supported": status.supported,
|
||||
"in_container": status.in_container,
|
||||
"requested_namespace": status.requested.namespace_restrictions,
|
||||
"active_namespace": status.namespace_active,
|
||||
"requested_network": status.requested.network_isolation,
|
||||
"active_network": status.network_active,
|
||||
"filesystem_mode": status.filesystem_mode.as_str(),
|
||||
"filesystem_active": status.filesystem_active,
|
||||
"allowed_mounts": status.allowed_mounts,
|
||||
"markers": status.container_markers,
|
||||
"fallback_reason": status.fallback_reason,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
use std::io;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use api::{InputContentBlock, InputMessage, ToolResultContentBlock};
|
||||
use serde::Deserialize;
|
||||
use serde_json;
|
||||
|
||||
use runtime::{
|
||||
ContentBlock, ConversationMessage, MessageRole, ToolError, ToolExecutor,
|
||||
};
|
||||
use tools::GlobalToolRegistry;
|
||||
|
||||
use crate::constants::*;
|
||||
use crate::format_tool_result;
|
||||
use crate::render::TerminalRenderer;
|
||||
use crate::RuntimeMcpState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct ToolSearchRequest {
|
||||
pub(crate) query: String,
|
||||
pub(crate) max_results: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct McpToolRequest {
|
||||
#[serde(rename = "qualifiedName")]
|
||||
pub(crate) qualified_name: Option<String>,
|
||||
pub(crate) tool: Option<String>,
|
||||
pub(crate) arguments: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ListMcpResourcesRequest {
|
||||
pub(crate) server: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ReadMcpResourceRequest {
|
||||
pub(crate) server: String,
|
||||
pub(crate) uri: String,
|
||||
}
|
||||
|
||||
pub(crate) struct CliToolExecutor {
|
||||
renderer: TerminalRenderer,
|
||||
emit_output: bool,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
tool_registry: GlobalToolRegistry,
|
||||
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||
}
|
||||
|
||||
impl CliToolExecutor {
|
||||
pub(crate) fn new(
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
emit_output: bool,
|
||||
tool_registry: GlobalToolRegistry,
|
||||
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
renderer: TerminalRenderer::new(),
|
||||
emit_output,
|
||||
allowed_tools,
|
||||
tool_registry,
|
||||
mcp_state,
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_search_tool(&self, value: serde_json::Value) -> Result<String, ToolError> {
|
||||
let input: ToolSearchRequest = serde_json::from_value(value)
|
||||
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
|
||||
let (pending_mcp_servers, mcp_degraded) =
|
||||
self.mcp_state.as_ref().map_or((None, None), |state| {
|
||||
let state = state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
(state.pending_servers(), state.degraded_report())
|
||||
});
|
||||
serde_json::to_string_pretty(&self.tool_registry.search(
|
||||
&input.query,
|
||||
input.max_results.unwrap_or(5),
|
||||
pending_mcp_servers,
|
||||
mcp_degraded,
|
||||
))
|
||||
.map_err(|error| ToolError::new(error.to_string()))
|
||||
}
|
||||
|
||||
fn execute_runtime_tool(
|
||||
&self,
|
||||
tool_name: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<String, ToolError> {
|
||||
let Some(mcp_state) = &self.mcp_state else {
|
||||
return Err(ToolError::new(format!(
|
||||
"runtime tool `{tool_name}` is unavailable without configured MCP servers"
|
||||
)));
|
||||
};
|
||||
let mut mcp_state = mcp_state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
|
||||
match tool_name {
|
||||
"MCPTool" => {
|
||||
let input: McpToolRequest = serde_json::from_value(value)
|
||||
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
|
||||
let qualified_name = input
|
||||
.qualified_name
|
||||
.or(input.tool)
|
||||
.ok_or_else(|| ToolError::new("missing required field `qualifiedName`"))?;
|
||||
mcp_state.call_tool(&qualified_name, input.arguments)
|
||||
}
|
||||
"ListMcpResourcesTool" => {
|
||||
let input: ListMcpResourcesRequest = serde_json::from_value(value)
|
||||
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
|
||||
match input.server {
|
||||
Some(server_name) => mcp_state.list_resources_for_server(&server_name),
|
||||
None => mcp_state.list_resources_for_all_servers(),
|
||||
}
|
||||
}
|
||||
"ReadMcpResourceTool" => {
|
||||
let input: ReadMcpResourceRequest = serde_json::from_value(value)
|
||||
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
|
||||
mcp_state.read_resource(&input.server, &input.uri)
|
||||
}
|
||||
_ => mcp_state.call_tool(tool_name, Some(value)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolExecutor for CliToolExecutor {
|
||||
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
|
||||
if self
|
||||
.allowed_tools
|
||||
.as_ref()
|
||||
.is_some_and(|allowed| !allowed.contains(tool_name))
|
||||
{
|
||||
return Err(ToolError::new(format!(
|
||||
"tool `{tool_name}` is not enabled by the current --allowedTools setting"
|
||||
)));
|
||||
}
|
||||
let value = serde_json::from_str(input)
|
||||
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
|
||||
let result = if tool_name == "ToolSearch" {
|
||||
self.execute_search_tool(value)
|
||||
} else if self.tool_registry.has_runtime_tool(tool_name) {
|
||||
self.execute_runtime_tool(tool_name, value)
|
||||
} else {
|
||||
self.tool_registry
|
||||
.execute(tool_name, &value)
|
||||
.map_err(ToolError::new)
|
||||
};
|
||||
match result {
|
||||
Ok(output) => {
|
||||
if self.emit_output {
|
||||
let markdown = format_tool_result(tool_name, &output, false);
|
||||
self.renderer
|
||||
.stream_markdown(&markdown, &mut io::stdout())
|
||||
.map_err(|error| ToolError::new(error.to_string()))?;
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
Err(error) => {
|
||||
if self.emit_output {
|
||||
let markdown = format_tool_result(tool_name, &error.to_string(), true);
|
||||
self.renderer
|
||||
.stream_markdown(&markdown, &mut io::stdout())
|
||||
.map_err(|stream_error| ToolError::new(stream_error.to_string()))?;
|
||||
}
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
|
||||
messages
|
||||
.iter()
|
||||
.filter_map(|message| {
|
||||
let role = match message.role {
|
||||
MessageRole::System | MessageRole::User | MessageRole::Tool => "user",
|
||||
MessageRole::Assistant => "assistant",
|
||||
};
|
||||
let content = message
|
||||
.blocks
|
||||
.iter()
|
||||
.map(|block| match block {
|
||||
ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
|
||||
ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} => InputContentBlock::Thinking {
|
||||
thinking: thinking.clone(),
|
||||
signature: signature.clone(),
|
||||
},
|
||||
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
input: serde_json::from_str(input)
|
||||
.unwrap_or_else(|_| serde_json::json!({ "raw": input })),
|
||||
},
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
output,
|
||||
is_error,
|
||||
..
|
||||
} => InputContentBlock::ToolResult {
|
||||
tool_use_id: tool_use_id.clone(),
|
||||
content: vec![ToolResultContentBlock::Text {
|
||||
text: output.clone(),
|
||||
}],
|
||||
is_error: *is_error,
|
||||
},
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
(!content.is_empty()).then(|| InputMessage {
|
||||
role: role.to_string(),
|
||||
content,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Loading…
Reference in New Issue