From a0618803923d41ba033613652e23d452e0c23c96 Mon Sep 17 00:00:00 2001 From: crsei Date: Tue, 21 Apr 2026 04:41:02 -0400 Subject: [PATCH] refactor(workspace): P3+P4 extract level-1 and level-2 crates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten new workspace crates land in a single commit; each builds + tests green at the boundary and the root crate keeps every `crate::{config, utils, mcp, services, computer_use, compact, sandbox, permissions, browser, session}::…` path working via `use cc_X as X;` aliases or facade `mod.rs` files that re-export the crate. Matches the existing P1 / P2 split pattern and the plan in docs/superpowers/specs/ 2026-04-20-workspace-split-design.md. Phase 3 (issue #72) — five level-1 crates ----------------------------------------- cc-config — 3,139 LOC (full extraction, one tie broken) `src/config/{claude_md,constants,features,paths,settings,validation}.rs` → `crates/cc-config/src/…`. `SettingsJson` moved from `types::app_state` into `cc-config::runtime_settings` so validation reads it without a reverse dep, and the two engine constants the validator consulted (`output_style::BUILT_IN_NAMES`, `effort::effort_to_budget_tokens`) are inlined as a small rule table — the authoritative engine values still drive runtime behaviour. cc-utils — 2,883 LOC (full, clean leaf on cc-types + cc-config) `src/utils/{abort,bash,cwd,file_state_cache,git,messages,shell, tokens}.rs` → `crates/cc-utils/src/…`. A tiny `build.rs` emits `-l dylib=advapi32` on Windows so the test binary picks up the libgit2-sys CryptoAPI imports that rust-lld otherwise leaves unresolved. cc-mcp — ~1,856 LOC (partial) Moved: `mod,channel,client,client_tests,discovery,manager, transport.rs`. Not moved: `tools.rs` (implements the root-crate `Tool` trait — unblocks in P5). The IPC-event cycle is broken the same way cc-skills broke its: cc-mcp owns a minimal `McpSubsystemEvent` enum and a `set_event_callback` hook the host wires to adapt into `SubsystemEvent`. Plugin-contributed server discovery now runs through `set_plugin_hook` for the same reason. cc-services — partial (4 of 6 modules) Moved: `lsp_lifecycle,prompt_suggestion,session_memory, tool_use_summary.rs`. Not moved: `session_analytics` (deps on `session::storage` — unblocked by this same commit's cc-session), `langfuse/` (deps on `types::tool::Tools` — unblocks in P5). cc-computer-use — partial (input + screenshot) Platform backends (`input/*.rs`, `screenshot/*.rs`) moved; the Tool wrappers (`detection,setup,tools.rs`) stay in root until P5. Phase 4 (issue #73) — five level-2 crates ----------------------------------------- cc-compact — full, clean DAG addition `src/compact/{auto_compact,compaction,messages,microcompact, pipeline,snip,tool_result_budget}.rs` → `crates/cc-compact/src/…`. Deps: cc-types + cc-utils only. cc-sandbox — full `src/sandbox/{availability,errors,filesystem,mode,network,policy, runner}.rs` → `crates/cc-sandbox/src/…`. `policy_from_app_state` signature tightened to take `&ToolPermissionContext + &SandboxSettings` instead of the whole `AppState`, so cc-sandbox doesn't need the root crate's app-state type (still tied to teams / ui / keybindings). Four callers updated to pass the two fields. cc-permissions — full, two callbacks registered `src/permissions/{bash_matcher,dangerous,decision,path_validation, rules}.rs` → `crates/cc-permissions/src/…`. The CU / browser permission-prompt lookups that previously called into `computer_use::detection` and `browser::{detection,permissions}` now go through `set_cu_message_callback` / `set_browser_message_callback`; main.rs registers adapters from the root-crate implementations. `PermissionMode`, `ToolPermissionContext`, `AdditionalWorkingDirectory`, and `ToolPermissionRulesBySource` moved into `cc-types::permissions` so both cc-sandbox and cc-permissions can consult them without reaching back into `types::tool` (where `ToolUseContext` still drags in an ipc cycle). cc-browser — partial (9 of 11 modules) Moved: `common,mcp_bridge,native_host,permissions,session,setup, state,tool_rendering,transport.rs`. Also moved the pure-string and server-registry helpers out of `detection.rs` into `cc-browser::detection`. The `Arc` registry walker (`detect_browser_servers` / `detect_browser_tools`) and the prompt-section builder stay in root until P5. cc-session — 3,828 LOC (full) `src/session/{audit_export,export,memdir,migrations,resume,storage, transcript}.rs` + `session_export/{builders,compression,mod, tests}.rs` → `crates/cc-session/src/…`. Session files still land in `~/.cc-rust/memory/` (per #73's acceptance criterion — unchanged). Same advapi32 build shim as cc-utils for the libgit2-sys test link. Post-split invariants --------------------- - `cargo build --workspace --release` succeeds; only the two pre-existing `session_id is never read` warnings in `web/handlers.rs` remain (both fields are #[serde(default)] API contract fields — not introduced by this split). - Every extracted crate's unit tests pass: cc-config 55, cc-utils 101, cc-mcp 26, cc-services 41, cc-compact n/a (no unit tests, covered via integration), cc-sandbox 55, cc-permissions 87 + 1 doctest, cc-browser n/a, cc-session 55. - Root crate: 1234 tests pass `--test-threads=1`. The handful of parallel failures are pre-existing shared-state flakes (same set as on rust-lite HEAD before this commit). - Feature-gate lookup (`cc_config::features::enabled(…)`) still routes through the same `FEATURE_*` env-var reads. - No new runtime deps — only cross-crate path deps added under `[workspace.dependencies]`. Refs #72. Refs #73. Part of #68. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 175 ++++++ Cargo.toml | 10 + crates/cc-browser/Cargo.toml | 21 + .../src/browser => cc-browser/src}/common.rs | 0 crates/cc-browser/src/detection.rs | 131 +++++ crates/cc-browser/src/lib.rs | 20 + .../browser => cc-browser/src}/mcp_bridge.rs | 2 +- .../browser => cc-browser/src}/native_host.rs | 0 .../browser => cc-browser/src}/permissions.rs | 2 +- .../src/browser => cc-browser/src}/session.rs | 0 .../src/browser => cc-browser/src}/setup.rs | 2 +- .../src/browser => cc-browser/src}/state.rs | 8 +- .../src}/tool_rendering.rs | 0 .../browser => cc-browser/src}/transport.rs | 0 crates/cc-compact/Cargo.toml | 17 + .../src}/auto_compact.rs | 0 .../compact => cc-compact/src}/compaction.rs | 8 +- crates/cc-compact/src/lib.rs | 14 + .../compact => cc-compact/src}/messages.rs | 6 +- .../src}/microcompact.rs | 8 +- .../compact => cc-compact/src}/pipeline.rs | 8 +- .../src/compact => cc-compact/src}/snip.rs | 14 +- .../src}/tool_result_budget.rs | 4 +- crates/cc-computer-use/Cargo.toml | 10 + .../src}/input/darwin.rs | 0 .../src}/input/linux.rs | 0 .../src}/input/mod.rs | 0 .../src}/input/win32.rs | 0 crates/cc-computer-use/src/lib.rs | 9 + .../src}/screenshot/darwin.rs | 0 .../src}/screenshot/linux.rs | 0 .../src}/screenshot/mod.rs | 0 .../src}/screenshot/win32.rs | 0 crates/cc-config/Cargo.toml | 18 + .../src/config => cc-config/src}/claude_md.rs | 0 .../src/config => cc-config/src}/constants.rs | 0 .../src/config => cc-config/src}/features.rs | 0 crates/cc-config/src/lib.rs | 21 + .../src/config => cc-config/src}/paths.rs | 0 crates/cc-config/src/runtime_settings.rs | 61 +++ .../src/config => cc-config/src}/settings.rs | 4 +- .../config => cc-config/src}/validation.rs | 44 +- crates/cc-mcp/Cargo.toml | 19 + .../src/mcp => cc-mcp/src}/channel.rs | 0 .../src/mcp => cc-mcp/src}/client.rs | 89 ++-- .../src/mcp => cc-mcp/src}/client_tests.rs | 8 +- .../src/mcp => cc-mcp/src}/discovery.rs | 96 ++-- crates/cc-mcp/src/lib.rs | 499 ++++++++++++++++++ .../src/mcp => cc-mcp/src}/manager.rs | 0 .../src/mcp => cc-mcp/src}/transport.rs | 0 crates/cc-permissions/Cargo.toml | 21 + .../src}/bash_matcher.rs | 4 +- .../src}/dangerous.rs | 4 +- .../src}/decision.rs | 116 ++-- .../mod.rs => cc-permissions/src/lib.rs} | 0 .../src}/path_validation.rs | 4 +- .../src}/rules.rs | 20 +- crates/cc-sandbox/Cargo.toml | 23 + .../src}/availability.rs | 0 .../src/sandbox => cc-sandbox/src}/errors.rs | 0 .../sandbox => cc-sandbox/src}/filesystem.rs | 0 .../sandbox/mod.rs => cc-sandbox/src/lib.rs} | 0 .../src/sandbox => cc-sandbox/src}/mode.rs | 0 .../src/sandbox => cc-sandbox/src}/network.rs | 2 +- .../src/sandbox => cc-sandbox/src}/policy.rs | 6 +- .../src/sandbox => cc-sandbox/src}/runner.rs | 95 ++-- crates/cc-services/Cargo.toml | 17 + crates/cc-services/src/lib.rs | 15 + .../src}/lsp_lifecycle.rs | 0 .../src}/prompt_suggestion.rs | 0 .../src}/session_memory.rs | 2 +- .../src}/tool_use_summary.rs | 0 crates/cc-session/Cargo.toml | 32 ++ crates/cc-session/build.rs | 9 + .../src}/audit_export.rs | 8 +- .../src/session => cc-session/src}/export.rs | 10 +- crates/cc-session/src/lib.rs | 17 + .../src/session => cc-session/src}/memdir.rs | 2 +- .../session => cc-session/src}/migrations.rs | 0 .../src/session => cc-session/src}/resume.rs | 2 +- .../src}/session_export/builders.rs | 12 +- .../src}/session_export/compression.rs | 2 +- .../src}/session_export/mod.rs | 6 +- .../src}/session_export/tests.rs | 2 +- .../src/session => cc-session/src}/storage.rs | 20 +- .../session => cc-session/src}/transcript.rs | 14 +- crates/cc-types/src/lib.rs | 1 + crates/cc-types/src/permissions.rs | 115 ++++ crates/cc-utils/Cargo.toml | 23 + crates/cc-utils/build.rs | 14 + .../src/utils => cc-utils/src}/abort.rs | 0 .../src/utils => cc-utils/src}/bash.rs | 2 +- .../src/utils => cc-utils/src}/cwd.rs | 0 .../src}/file_state_cache.rs | 0 .../src/utils => cc-utils/src}/git.rs | 0 crates/cc-utils/src/lib.rs | 13 + .../src/utils => cc-utils/src}/messages.rs | 4 +- .../src/utils => cc-utils/src}/shell.rs | 0 .../src/utils => cc-utils/src}/tokens.rs | 4 +- crates/claude-code-rs/Cargo.toml | 12 +- .../claude-code-rs/src/browser/detection.rs | 138 +---- crates/claude-code-rs/src/browser/mod.rs | 33 +- .../src/commands/sandbox_cmd.rs | 7 +- crates/claude-code-rs/src/compact/mod.rs | 7 - crates/claude-code-rs/src/computer_use/mod.rs | 9 +- crates/claude-code-rs/src/config/mod.rs | 12 - .../claude-code-rs/src/engine/output_style.rs | 3 - crates/claude-code-rs/src/ipc/runtime.rs | 53 +- crates/claude-code-rs/src/main.rs | 74 ++- crates/claude-code-rs/src/mcp/mod.rs | 464 +--------------- crates/claude-code-rs/src/services/mod.rs | 19 +- crates/claude-code-rs/src/session/mod.rs | 16 - crates/claude-code-rs/src/tools/exec/bash.rs | 7 +- .../src/tools/exec/powershell.rs | 7 +- crates/claude-code-rs/src/tools/web_fetch.rs | 7 +- crates/claude-code-rs/src/types/app_state.rs | 50 +- crates/claude-code-rs/src/types/tool.rs | 112 +--- crates/claude-code-rs/src/utils/mod.rs | 8 - 118 files changed, 1893 insertions(+), 1114 deletions(-) create mode 100644 crates/cc-browser/Cargo.toml rename crates/{claude-code-rs/src/browser => cc-browser/src}/common.rs (100%) create mode 100644 crates/cc-browser/src/detection.rs create mode 100644 crates/cc-browser/src/lib.rs rename crates/{claude-code-rs/src/browser => cc-browser/src}/mcp_bridge.rs (99%) rename crates/{claude-code-rs/src/browser => cc-browser/src}/native_host.rs (100%) rename crates/{claude-code-rs/src/browser => cc-browser/src}/permissions.rs (99%) rename crates/{claude-code-rs/src/browser => cc-browser/src}/session.rs (100%) rename crates/{claude-code-rs/src/browser => cc-browser/src}/setup.rs (99%) rename crates/{claude-code-rs/src/browser => cc-browser/src}/state.rs (96%) rename crates/{claude-code-rs/src/browser => cc-browser/src}/tool_rendering.rs (100%) rename crates/{claude-code-rs/src/browser => cc-browser/src}/transport.rs (100%) create mode 100644 crates/cc-compact/Cargo.toml rename crates/{claude-code-rs/src/compact => cc-compact/src}/auto_compact.rs (100%) rename crates/{claude-code-rs/src/compact => cc-compact/src}/compaction.rs (98%) create mode 100644 crates/cc-compact/src/lib.rs rename crates/{claude-code-rs/src/compact => cc-compact/src}/messages.rs (98%) rename crates/{claude-code-rs/src/compact => cc-compact/src}/microcompact.rs (97%) rename crates/{claude-code-rs/src/compact => cc-compact/src}/pipeline.rs (98%) rename crates/{claude-code-rs/src/compact => cc-compact/src}/snip.rs (93%) rename crates/{claude-code-rs/src/compact => cc-compact/src}/tool_result_budget.rs (98%) create mode 100644 crates/cc-computer-use/Cargo.toml rename crates/{claude-code-rs/src/computer_use => cc-computer-use/src}/input/darwin.rs (100%) rename crates/{claude-code-rs/src/computer_use => cc-computer-use/src}/input/linux.rs (100%) rename crates/{claude-code-rs/src/computer_use => cc-computer-use/src}/input/mod.rs (100%) rename crates/{claude-code-rs/src/computer_use => cc-computer-use/src}/input/win32.rs (100%) create mode 100644 crates/cc-computer-use/src/lib.rs rename crates/{claude-code-rs/src/computer_use => cc-computer-use/src}/screenshot/darwin.rs (100%) rename crates/{claude-code-rs/src/computer_use => cc-computer-use/src}/screenshot/linux.rs (100%) rename crates/{claude-code-rs/src/computer_use => cc-computer-use/src}/screenshot/mod.rs (100%) rename crates/{claude-code-rs/src/computer_use => cc-computer-use/src}/screenshot/win32.rs (100%) create mode 100644 crates/cc-config/Cargo.toml rename crates/{claude-code-rs/src/config => cc-config/src}/claude_md.rs (100%) rename crates/{claude-code-rs/src/config => cc-config/src}/constants.rs (100%) rename crates/{claude-code-rs/src/config => cc-config/src}/features.rs (100%) create mode 100644 crates/cc-config/src/lib.rs rename crates/{claude-code-rs/src/config => cc-config/src}/paths.rs (100%) create mode 100644 crates/cc-config/src/runtime_settings.rs rename crates/{claude-code-rs/src/config => cc-config/src}/settings.rs (99%) rename crates/{claude-code-rs/src/config => cc-config/src}/validation.rs (90%) create mode 100644 crates/cc-mcp/Cargo.toml rename crates/{claude-code-rs/src/mcp => cc-mcp/src}/channel.rs (100%) rename crates/{claude-code-rs/src/mcp => cc-mcp/src}/client.rs (89%) rename crates/{claude-code-rs/src/mcp => cc-mcp/src}/client_tests.rs (97%) rename crates/{claude-code-rs/src/mcp => cc-mcp/src}/discovery.rs (68%) create mode 100644 crates/cc-mcp/src/lib.rs rename crates/{claude-code-rs/src/mcp => cc-mcp/src}/manager.rs (100%) rename crates/{claude-code-rs/src/mcp => cc-mcp/src}/transport.rs (100%) create mode 100644 crates/cc-permissions/Cargo.toml rename crates/{claude-code-rs/src/permissions => cc-permissions/src}/bash_matcher.rs (98%) rename crates/{claude-code-rs/src/permissions => cc-permissions/src}/dangerous.rs (98%) rename crates/{claude-code-rs/src/permissions => cc-permissions/src}/decision.rs (91%) rename crates/{claude-code-rs/src/permissions/mod.rs => cc-permissions/src/lib.rs} (100%) rename crates/{claude-code-rs/src/permissions => cc-permissions/src}/path_validation.rs (98%) rename crates/{claude-code-rs/src/permissions => cc-permissions/src}/rules.rs (97%) create mode 100644 crates/cc-sandbox/Cargo.toml rename crates/{claude-code-rs/src/sandbox => cc-sandbox/src}/availability.rs (100%) rename crates/{claude-code-rs/src/sandbox => cc-sandbox/src}/errors.rs (100%) rename crates/{claude-code-rs/src/sandbox => cc-sandbox/src}/filesystem.rs (100%) rename crates/{claude-code-rs/src/sandbox/mod.rs => cc-sandbox/src/lib.rs} (100%) rename crates/{claude-code-rs/src/sandbox => cc-sandbox/src}/mode.rs (100%) rename crates/{claude-code-rs/src/sandbox => cc-sandbox/src}/network.rs (99%) rename crates/{claude-code-rs/src/sandbox => cc-sandbox/src}/policy.rs (97%) rename crates/{claude-code-rs/src/sandbox => cc-sandbox/src}/runner.rs (88%) create mode 100644 crates/cc-services/Cargo.toml create mode 100644 crates/cc-services/src/lib.rs rename crates/{claude-code-rs/src/services => cc-services/src}/lsp_lifecycle.rs (100%) rename crates/{claude-code-rs/src/services => cc-services/src}/prompt_suggestion.rs (100%) rename crates/{claude-code-rs/src/services => cc-services/src}/session_memory.rs (99%) rename crates/{claude-code-rs/src/services => cc-services/src}/tool_use_summary.rs (100%) create mode 100644 crates/cc-session/Cargo.toml create mode 100644 crates/cc-session/build.rs rename crates/{claude-code-rs/src/session => cc-session/src}/audit_export.rs (99%) rename crates/{claude-code-rs/src/session => cc-session/src}/export.rs (97%) create mode 100644 crates/cc-session/src/lib.rs rename crates/{claude-code-rs/src/session => cc-session/src}/memdir.rs (99%) rename crates/{claude-code-rs/src/session => cc-session/src}/migrations.rs (100%) rename crates/{claude-code-rs/src/session => cc-session/src}/resume.rs (97%) rename crates/{claude-code-rs/src/session => cc-session/src}/session_export/builders.rs (94%) rename crates/{claude-code-rs/src/session => cc-session/src}/session_export/compression.rs (99%) rename crates/{claude-code-rs/src/session => cc-session/src}/session_export/mod.rs (98%) rename crates/{claude-code-rs/src/session => cc-session/src}/session_export/tests.rs (99%) rename crates/{claude-code-rs/src/session => cc-session/src}/storage.rs (98%) rename crates/{claude-code-rs/src/session => cc-session/src}/transcript.rs (92%) create mode 100644 crates/cc-types/src/permissions.rs create mode 100644 crates/cc-utils/Cargo.toml create mode 100644 crates/cc-utils/build.rs rename crates/{claude-code-rs/src/utils => cc-utils/src}/abort.rs (100%) rename crates/{claude-code-rs/src/utils => cc-utils/src}/bash.rs (99%) rename crates/{claude-code-rs/src/utils => cc-utils/src}/cwd.rs (100%) rename crates/{claude-code-rs/src/utils => cc-utils/src}/file_state_cache.rs (100%) rename crates/{claude-code-rs/src/utils => cc-utils/src}/git.rs (100%) create mode 100644 crates/cc-utils/src/lib.rs rename crates/{claude-code-rs/src/utils => cc-utils/src}/messages.rs (99%) rename crates/{claude-code-rs/src/utils => cc-utils/src}/shell.rs (100%) rename crates/{claude-code-rs/src/utils => cc-utils/src}/tokens.rs (97%) delete mode 100644 crates/claude-code-rs/src/compact/mod.rs delete mode 100644 crates/claude-code-rs/src/config/mod.rs delete mode 100644 crates/claude-code-rs/src/session/mod.rs delete mode 100644 crates/claude-code-rs/src/utils/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 5a7adacc..2d8f0070 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -511,6 +511,62 @@ dependencies = [ "uuid", ] +[[package]] +name = "cc-browser" +version = "0.1.0" +dependencies = [ + "anyhow", + "cc-config", + "cc-mcp", + "chrono", + "dirs", + "parking_lot", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", +] + +[[package]] +name = "cc-compact" +version = "0.1.0" +dependencies = [ + "anyhow", + "cc-types", + "cc-utils", + "chrono", + "serde", + "serde_json", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "cc-computer-use" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "tokio", +] + +[[package]] +name = "cc-config" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "dirs", + "serde", + "serde_json", + "serial_test", + "tempfile", + "tracing", + "uuid", +] + [[package]] name = "cc-keybindings" version = "0.1.0" @@ -523,6 +579,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "cc-mcp" +version = "0.1.0" +dependencies = [ + "anyhow", + "cc-config", + "parking_lot", + "serde", + "serde_json", + "serial_test", + "tempfile", + "tokio", + "tracing", +] + [[package]] name = "cc-observability" version = "0.1.0" @@ -536,6 +607,82 @@ dependencies = [ "uuid", ] +[[package]] +name = "cc-permissions" +version = "0.1.0" +dependencies = [ + "anyhow", + "cc-bootstrap", + "cc-types", + "cc-utils", + "parking_lot", + "regex", + "serde", + "serde_json", + "tempfile", + "tracing", + "url", +] + +[[package]] +name = "cc-sandbox" +version = "0.1.0" +dependencies = [ + "anyhow", + "cc-config", + "cc-types", + "cc-utils", + "dirs", + "libc", + "regex", + "serde", + "serde_json", + "tokio", + "tracing", + "url", + "which", +] + +[[package]] +name = "cc-services" +version = "0.1.0" +dependencies = [ + "anyhow", + "cc-config", + "chrono", + "serde", + "serde_json", + "tempfile", + "tracing", +] + +[[package]] +name = "cc-session" +version = "0.1.0" +dependencies = [ + "anyhow", + "cc-bootstrap", + "cc-compact", + "cc-config", + "cc-types", + "cc-utils", + "chrono", + "dirs", + "git2", + "hex", + "parking_lot", + "regex", + "serde", + "serde_json", + "serial_test", + "sha2", + "tempfile", + "tokio", + "tracing", + "uuid", + "walkdir", +] + [[package]] name = "cc-skills" version = "0.1.0" @@ -554,6 +701,24 @@ dependencies = [ "uuid", ] +[[package]] +name = "cc-utils" +version = "0.1.0" +dependencies = [ + "anyhow", + "cc-config", + "cc-types", + "chrono", + "git2", + "lru", + "parking_lot", + "regex", + "serde_json", + "shell-words", + "tokio", + "uuid", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -634,10 +799,20 @@ dependencies = [ "bytes", "cc-auth", "cc-bootstrap", + "cc-browser", + "cc-compact", + "cc-computer-use", + "cc-config", "cc-keybindings", + "cc-mcp", "cc-observability", + "cc-permissions", + "cc-sandbox", + "cc-services", + "cc-session", "cc-skills", "cc-types", + "cc-utils", "chrono", "clap", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index b3218de9..c1b29382 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -126,6 +126,16 @@ cc-types = { path = "crates/cc-types" } cc-bootstrap = { path = "crates/cc-bootstrap" } cc-auth = { path = "crates/cc-auth" } cc-skills = { path = "crates/cc-skills" } +cc-config = { path = "crates/cc-config" } +cc-utils = { path = "crates/cc-utils" } +cc-mcp = { path = "crates/cc-mcp" } +cc-services = { path = "crates/cc-services" } +cc-computer-use = { path = "crates/cc-computer-use" } +cc-compact = { path = "crates/cc-compact" } +cc-sandbox = { path = "crates/cc-sandbox" } +cc-permissions = { path = "crates/cc-permissions" } +cc-browser = { path = "crates/cc-browser" } +cc-session = { path = "crates/cc-session" } # Daemon HTTP server axum = { version = "0.8", features = ["ws"] } diff --git a/crates/cc-browser/Cargo.toml b/crates/cc-browser/Cargo.toml new file mode 100644 index 00000000..09932608 --- /dev/null +++ b/crates/cc-browser/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "cc-browser" +version = "0.1.0" +edition = "2021" +description = "Chrome / browser MCP bridge for cc-rust" + +[dependencies] +anyhow = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +parking_lot = { workspace = true } +chrono = { workspace = true } +dirs = { workspace = true } + +cc-mcp = { workspace = true } +cc-config = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/claude-code-rs/src/browser/common.rs b/crates/cc-browser/src/common.rs similarity index 100% rename from crates/claude-code-rs/src/browser/common.rs rename to crates/cc-browser/src/common.rs diff --git a/crates/cc-browser/src/detection.rs b/crates/cc-browser/src/detection.rs new file mode 100644 index 00000000..4019eb90 --- /dev/null +++ b/crates/cc-browser/src/detection.rs @@ -0,0 +1,131 @@ +//! Pure browser-MCP detection primitives. +//! +//! Split out of the root crate's `browser::detection` in Phase 4 (issue #73) +//! so cc-browser sub-modules (`permissions`, `tool_rendering`) can call +//! these helpers without pulling in `Arc` — the registry walker +//! (`detect_browser_servers` / `detect_browser_tools`) stays in the root +//! crate until `Tool` moves out in Phase 5. + +use std::collections::HashSet; + +/// MCP tool-name prefix for all MCP-wrapped tools. +pub const MCP_PREFIX: &str = "mcp__"; + +/// Known browser-automation tool basenames. +/// +/// Intentionally generous — different servers use different names for +/// essentially the same actions (`click` vs `browser_click`, `navigate` +/// vs `goto`). We match on basename only, so the leading `mcp__{server}__` +/// has already been stripped. +pub const BROWSER_TOOL_BASENAMES: &[&str] = &[ + // Navigation / tabs + "navigate", + "navigate_page", + "goto", + "tabs_create", + "tabs_create_mcp", + "tabs_close", + "tabs_close_mcp", + "tabs_context", + "tabs_context_mcp", + "new_page", + "close_page", + "switch_browser", + "select_page", + "list_pages", + // Page reading + "read_page", + "get_page_text", + "take_snapshot", + "snapshot", + "get_page", + // DOM / element interaction + "click", + "browser_click", + "double_click", + "hover", + "drag", + "press_key", + "type_text", + "fill", + "fill_form", + "form_input", + "select", + // File upload + "upload_file", + "file_upload", + // JavaScript execution + "evaluate_script", + "javascript_tool", + "evaluate", + // Console / network observability + "get_console_message", + "list_console_messages", + "read_console_messages", + "get_network_request", + "list_network_requests", + "read_network_requests", + // Screenshots / visual + "take_screenshot", + "screenshot", + // Misc + "wait_for", + "find", + "resize_page", + "resize_window", + "emulate", + "handle_dialog", +]; + +/// Parse an MCP tool name into `(server, action)` if the action matches a +/// recognized browser basename. +pub fn extract_browser_action(tool_name: &str) -> Option<(&str, &str)> { + let rest = tool_name.strip_prefix(MCP_PREFIX)?; + let (server, action) = rest.split_once("__")?; + if BROWSER_TOOL_BASENAMES.contains(&action) { + Some((server, action)) + } else { + None + } +} + +// --------------------------------------------------------------------------- +// Process-wide registry of browser server names +// --------------------------------------------------------------------------- +// +// Populated once at startup after MCP discovery + tool registration, then +// consulted by the system-prompt assembler, the permission decision path, +// and `/mcp list`. + +static BROWSER_SERVERS: parking_lot::RwLock>> = + parking_lot::RwLock::new(None); + +/// Install the set of browser MCP server names for the rest of the process. +/// +/// Call this once after MCP discovery + tool registration. Subsequent calls +/// overwrite the registry. +pub fn install_browser_servers(servers: HashSet) { + *BROWSER_SERVERS.write() = Some(servers); +} + +/// Snapshot the current set of browser server names (empty if not installed). +pub fn browser_servers_snapshot() -> HashSet { + BROWSER_SERVERS.read().clone().unwrap_or_default() +} + +/// Check whether a server name is registered as a browser server. Consults the +/// global registry and falls back to `false` if the registry was never installed. +pub fn is_browser_server(name: &str) -> bool { + match BROWSER_SERVERS.read().as_ref() { + Some(set) => set.contains(name), + None => false, + } +} + +/// Clear the browser-server registry — tests only. `#[doc(hidden)]` keeps +/// it out of rustdoc; the `pub` visibility lets tests in the root crate +/// exercise detection flows with a clean registry. +#[doc(hidden)] +pub fn clear_browser_servers_for_tests() { + *BROWSER_SERVERS.write() = None; +} diff --git a/crates/cc-browser/src/lib.rs b/crates/cc-browser/src/lib.rs new file mode 100644 index 00000000..ca38c503 --- /dev/null +++ b/crates/cc-browser/src/lib.rs @@ -0,0 +1,20 @@ +//! Chrome / browser MCP bridge — extracted in Phase 4 (issue #73). +//! +//! **Partial extraction** — 9 of the 11 submodules moved cleanly; two keep +//! a hard dep on the root crate's `Tool` trait and stay behind until the +//! hub-cycle break in Phase 5: +//! +//! - `detection` — uses `Arc` to categorize the live tool list. +//! - `prompt` — inspects registered tools to decide whether the prompt +//! preamble should include browser-automation instructions. + +pub mod common; +pub mod detection; +pub mod mcp_bridge; +pub mod native_host; +pub mod permissions; +pub mod session; +pub mod setup; +pub mod state; +pub mod tool_rendering; +pub mod transport; diff --git a/crates/claude-code-rs/src/browser/mcp_bridge.rs b/crates/cc-browser/src/mcp_bridge.rs similarity index 99% rename from crates/claude-code-rs/src/browser/mcp_bridge.rs rename to crates/cc-browser/src/mcp_bridge.rs index 9a428f80..32fc6ef2 100644 --- a/crates/claude-code-rs/src/browser/mcp_bridge.rs +++ b/crates/cc-browser/src/mcp_bridge.rs @@ -473,7 +473,7 @@ pub async fn run() -> Result<()> { "initialize" => rpc_ok( id, json!({ - "protocolVersion": crate::mcp::PROTOCOL_VERSION, + "protocolVersion": cc_mcp::PROTOCOL_VERSION, "capabilities": { "tools": {} }, "serverInfo": { "name": "claude-in-chrome", diff --git a/crates/claude-code-rs/src/browser/native_host.rs b/crates/cc-browser/src/native_host.rs similarity index 100% rename from crates/claude-code-rs/src/browser/native_host.rs rename to crates/cc-browser/src/native_host.rs diff --git a/crates/claude-code-rs/src/browser/permissions.rs b/crates/cc-browser/src/permissions.rs similarity index 99% rename from crates/claude-code-rs/src/browser/permissions.rs rename to crates/cc-browser/src/permissions.rs index 6d434dbe..84cfd078 100644 --- a/crates/claude-code-rs/src/browser/permissions.rs +++ b/crates/cc-browser/src/permissions.rs @@ -10,7 +10,7 @@ //! > navigation, page reading, form writing, file upload, JS execution, //! > console/network reading. -use super::detection::extract_browser_action; +use crate::detection::extract_browser_action; /// Coarse-grained browser action category used for permission UX and audit logs. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/claude-code-rs/src/browser/session.rs b/crates/cc-browser/src/session.rs similarity index 100% rename from crates/claude-code-rs/src/browser/session.rs rename to crates/cc-browser/src/session.rs diff --git a/crates/claude-code-rs/src/browser/setup.rs b/crates/cc-browser/src/setup.rs similarity index 99% rename from crates/claude-code-rs/src/browser/setup.rs rename to crates/cc-browser/src/setup.rs index 0a03a62b..54daeca6 100644 --- a/crates/claude-code-rs/src/browser/setup.rs +++ b/crates/cc-browser/src/setup.rs @@ -159,7 +159,7 @@ pub fn create_wrapper_script(command: &str) -> Result { } fn cc_rust_chrome_dir() -> Result { - Ok(crate::config::settings::global_claude_dir()?.join("chrome")) + Ok(cc_config::settings::global_claude_dir()?.join("chrome")) } // --------------------------------------------------------------------------- diff --git a/crates/claude-code-rs/src/browser/state.rs b/crates/cc-browser/src/state.rs similarity index 96% rename from crates/claude-code-rs/src/browser/state.rs rename to crates/cc-browser/src/state.rs index 2ff41f38..403de247 100644 --- a/crates/claude-code-rs/src/browser/state.rs +++ b/crates/cc-browser/src/state.rs @@ -144,9 +144,11 @@ pub fn is_enabled() -> bool { state().read().connection.is_active() } -/// Reset state — tests only. -#[cfg(test)] -pub(crate) fn reset_for_tests() { +/// Reset state — tests only. Public across crates so tests in the root +/// crate (chrome_cmd etc.) can exercise the command flow with a clean +/// Chrome subsystem snapshot; `#[doc(hidden)]` keeps it out of rustdoc. +#[doc(hidden)] +pub fn reset_for_tests() { let mut s = state().write(); *s = ChromeState::default(); } diff --git a/crates/claude-code-rs/src/browser/tool_rendering.rs b/crates/cc-browser/src/tool_rendering.rs similarity index 100% rename from crates/claude-code-rs/src/browser/tool_rendering.rs rename to crates/cc-browser/src/tool_rendering.rs diff --git a/crates/claude-code-rs/src/browser/transport.rs b/crates/cc-browser/src/transport.rs similarity index 100% rename from crates/claude-code-rs/src/browser/transport.rs rename to crates/cc-browser/src/transport.rs diff --git a/crates/cc-compact/Cargo.toml b/crates/cc-compact/Cargo.toml new file mode 100644 index 00000000..1bc06e32 --- /dev/null +++ b/crates/cc-compact/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "cc-compact" +version = "0.1.0" +edition = "2021" +description = "Conversation-history compaction pipeline for cc-rust" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } +tokio = { workspace = true } + +cc-types = { workspace = true } +cc-utils = { workspace = true } diff --git a/crates/claude-code-rs/src/compact/auto_compact.rs b/crates/cc-compact/src/auto_compact.rs similarity index 100% rename from crates/claude-code-rs/src/compact/auto_compact.rs rename to crates/cc-compact/src/auto_compact.rs diff --git a/crates/claude-code-rs/src/compact/compaction.rs b/crates/cc-compact/src/compaction.rs similarity index 98% rename from crates/claude-code-rs/src/compact/compaction.rs rename to crates/cc-compact/src/compaction.rs index 7c2ac583..51149d05 100644 --- a/crates/claude-code-rs/src/compact/compaction.rs +++ b/crates/cc-compact/src/compaction.rs @@ -24,12 +24,12 @@ use anyhow::Result; use tracing::{debug, info, warn}; use uuid::Uuid; -use crate::types::message::{ +use cc_types::message::{ CompactMetadata, ContentBlock, Message, MessageContent, SystemMessage, SystemSubtype, UserMessage, }; -use crate::types::state::AutoCompactTracking; -use crate::utils::tokens; +use cc_types::state::AutoCompactTracking; +use cc_utils::tokens; use super::auto_compact; use super::messages as compact_messages; @@ -304,7 +304,7 @@ pub fn build_compaction_prompt() -> String { #[cfg(test)] mod tests { use super::*; - use crate::types::message::AssistantMessage; + use cc_types::message::AssistantMessage; fn make_user(text: &str) -> Message { compact_messages::create_user_message(text, false) diff --git a/crates/cc-compact/src/lib.rs b/crates/cc-compact/src/lib.rs new file mode 100644 index 00000000..b43c51de --- /dev/null +++ b/crates/cc-compact/src/lib.rs @@ -0,0 +1,14 @@ +//! Conversation-history compaction pipeline — extracted as a workspace +//! crate in Phase 4 (issue #73). +//! +//! Depends only on `cc-types` (message/state types) and `cc-utils` (token +//! counting), so the extraction is a clean DAG addition with no reverse +//! deps into the root crate. + +pub mod auto_compact; +pub mod compaction; +pub mod messages; +pub mod microcompact; +pub mod pipeline; +pub mod snip; +pub mod tool_result_budget; diff --git a/crates/claude-code-rs/src/compact/messages.rs b/crates/cc-compact/src/messages.rs similarity index 98% rename from crates/claude-code-rs/src/compact/messages.rs rename to crates/cc-compact/src/messages.rs index ad3f09bf..f0c92049 100644 --- a/crates/claude-code-rs/src/compact/messages.rs +++ b/crates/cc-compact/src/messages.rs @@ -3,7 +3,7 @@ use chrono::Utc; use uuid::Uuid; -use crate::types::message::{ +use cc_types::message::{ AssistantMessage, Attachment, ContentBlock, InfoLevel, Message, MessageContent, SystemMessage, SystemSubtype, ToolResultContent, UserMessage, }; @@ -258,7 +258,7 @@ mod tests { fn test_normalize_filters_progress() { let messages = vec![ create_user_message("hi", false), - Message::Progress(crate::types::message::ProgressMessage { + Message::Progress(cc_types::message::ProgressMessage { uuid: Uuid::new_v4(), timestamp: 0, tool_use_id: "x".into(), @@ -274,7 +274,7 @@ mod tests { #[test] fn test_get_messages_after_compact_boundary() { - use crate::types::message::CompactMetadata; + use cc_types::message::CompactMetadata; let messages = vec![ create_user_message("old message", false), diff --git a/crates/claude-code-rs/src/compact/microcompact.rs b/crates/cc-compact/src/microcompact.rs similarity index 97% rename from crates/claude-code-rs/src/compact/microcompact.rs rename to crates/cc-compact/src/microcompact.rs index 2ec36e14..7d242cc0 100644 --- a/crates/claude-code-rs/src/compact/microcompact.rs +++ b/crates/cc-compact/src/microcompact.rs @@ -1,6 +1,6 @@ #![allow(unused)] -use crate::types::message::{ +use cc_types::message::{ ContentBlock, Message, MessageContent, ToolResultContent, UserMessage, }; @@ -194,9 +194,9 @@ fn make_tool_result_summary(content: &ToolResultContent, original_len: usize) -> #[cfg(test)] mod tests { use super::*; - use crate::compact::messages::create_tool_result_message; - use crate::compact::messages::create_user_message; - use crate::types::message::AssistantMessage; + use crate::messages::create_tool_result_message; + use crate::messages::create_user_message; + use cc_types::message::AssistantMessage; use chrono::Utc; use uuid::Uuid; diff --git a/crates/claude-code-rs/src/compact/pipeline.rs b/crates/cc-compact/src/pipeline.rs similarity index 98% rename from crates/claude-code-rs/src/compact/pipeline.rs rename to crates/cc-compact/src/pipeline.rs index bb76f6b6..4a0608cf 100644 --- a/crates/claude-code-rs/src/compact/pipeline.rs +++ b/crates/cc-compact/src/pipeline.rs @@ -12,9 +12,9 @@ use anyhow::Result; use tracing::{debug, info, warn}; -use crate::types::message::Message; -use crate::types::state::AutoCompactTracking; -use crate::utils::tokens; +use cc_types::message::Message; +use cc_types::state::AutoCompactTracking; +use cc_utils::tokens; use super::auto_compact; use super::microcompact; @@ -232,7 +232,7 @@ pub async fn try_reactive_compact( #[cfg(test)] mod tests { use super::*; - use crate::types::message::{AssistantMessage, ContentBlock, MessageContent, UserMessage}; + use cc_types::message::{AssistantMessage, ContentBlock, MessageContent, UserMessage}; use uuid::Uuid; fn make_user(text: &str) -> Message { diff --git a/crates/claude-code-rs/src/compact/snip.rs b/crates/cc-compact/src/snip.rs similarity index 93% rename from crates/claude-code-rs/src/compact/snip.rs rename to crates/cc-compact/src/snip.rs index 76ef77da..28a4303d 100644 --- a/crates/claude-code-rs/src/compact/snip.rs +++ b/crates/cc-compact/src/snip.rs @@ -3,7 +3,7 @@ use chrono::Utc; use uuid::Uuid; -use crate::types::message::{CompactMetadata, ContentBlock, Message, SystemMessage, SystemSubtype}; +use cc_types::message::{CompactMetadata, ContentBlock, Message, SystemMessage, SystemSubtype}; /// Result of history snipping. #[derive(Debug)] @@ -130,8 +130,8 @@ fn estimate_tokens_for_messages(messages: &[Message]) -> u64 { fn estimate_message_chars(msg: &Message) -> usize { match msg { Message::User(u) => match &u.content { - crate::types::message::MessageContent::Text(t) => t.len(), - crate::types::message::MessageContent::Blocks(blocks) => { + cc_types::message::MessageContent::Text(t) => t.len(), + cc_types::message::MessageContent::Blocks(blocks) => { blocks.iter().map(|b| content_block_chars(b)).sum() } }, @@ -148,8 +148,8 @@ fn content_block_chars(block: &ContentBlock) -> usize { ContentBlock::Text { text } => text.len(), ContentBlock::ToolUse { input, .. } => input.to_string().len() + 50, ContentBlock::ToolResult { content, .. } => match content { - crate::types::message::ToolResultContent::Text(t) => t.len(), - crate::types::message::ToolResultContent::Blocks(bs) => { + cc_types::message::ToolResultContent::Text(t) => t.len(), + cc_types::message::ToolResultContent::Blocks(bs) => { bs.iter().map(|b| content_block_chars(b)).sum() } }, @@ -162,8 +162,8 @@ fn content_block_chars(block: &ContentBlock) -> usize { #[cfg(test)] mod tests { use super::*; - use crate::compact::messages::{create_tool_result_message, create_user_message}; - use crate::types::message::AssistantMessage; + use crate::messages::{create_tool_result_message, create_user_message}; + use cc_types::message::AssistantMessage; fn make_assistant_text(text: &str) -> Message { Message::Assistant(AssistantMessage { diff --git a/crates/claude-code-rs/src/compact/tool_result_budget.rs b/crates/cc-compact/src/tool_result_budget.rs similarity index 98% rename from crates/claude-code-rs/src/compact/tool_result_budget.rs rename to crates/cc-compact/src/tool_result_budget.rs index e99345ff..a70d4e5a 100644 --- a/crates/claude-code-rs/src/compact/tool_result_budget.rs +++ b/crates/cc-compact/src/tool_result_budget.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use std::path::PathBuf; -use crate::types::message::{ContentBlock, Message, MessageContent, ToolResultContent}; +use cc_types::message::{ContentBlock, Message, MessageContent, ToolResultContent}; /// A record of a tool result that was replaced with a truncated preview. #[derive(Debug)] @@ -188,7 +188,7 @@ fn truncate_in_place(text: &str, max_size: usize) -> String { #[cfg(test)] mod tests { use super::*; - use crate::compact::messages::create_tool_result_message; + use crate::messages::create_tool_result_message; #[tokio::test] async fn test_small_results_unchanged() { diff --git a/crates/cc-computer-use/Cargo.toml b/crates/cc-computer-use/Cargo.toml new file mode 100644 index 00000000..070d609b --- /dev/null +++ b/crates/cc-computer-use/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "cc-computer-use" +version = "0.1.0" +edition = "2021" +description = "Desktop control primitives (screenshot, input) for cc-rust" + +[dependencies] +anyhow = { workspace = true } +base64 = { workspace = true } +tokio = { workspace = true } diff --git a/crates/claude-code-rs/src/computer_use/input/darwin.rs b/crates/cc-computer-use/src/input/darwin.rs similarity index 100% rename from crates/claude-code-rs/src/computer_use/input/darwin.rs rename to crates/cc-computer-use/src/input/darwin.rs diff --git a/crates/claude-code-rs/src/computer_use/input/linux.rs b/crates/cc-computer-use/src/input/linux.rs similarity index 100% rename from crates/claude-code-rs/src/computer_use/input/linux.rs rename to crates/cc-computer-use/src/input/linux.rs diff --git a/crates/claude-code-rs/src/computer_use/input/mod.rs b/crates/cc-computer-use/src/input/mod.rs similarity index 100% rename from crates/claude-code-rs/src/computer_use/input/mod.rs rename to crates/cc-computer-use/src/input/mod.rs diff --git a/crates/claude-code-rs/src/computer_use/input/win32.rs b/crates/cc-computer-use/src/input/win32.rs similarity index 100% rename from crates/claude-code-rs/src/computer_use/input/win32.rs rename to crates/cc-computer-use/src/input/win32.rs diff --git a/crates/cc-computer-use/src/lib.rs b/crates/cc-computer-use/src/lib.rs new file mode 100644 index 00000000..0c3b64d7 --- /dev/null +++ b/crates/cc-computer-use/src/lib.rs @@ -0,0 +1,9 @@ +//! Desktop-control primitives used by the Computer Use tools. +//! +//! **Partial extraction** — Phase 3 (issue #72) moved the platform-specific +//! screenshot and input submodules here. The `detection`, `setup`, and +//! `tools` wrappers stay in the root crate because they implement the `Tool` +//! trait, which still lives there (unblocked by Phase 5 cycle-break). + +pub mod input; +pub mod screenshot; diff --git a/crates/claude-code-rs/src/computer_use/screenshot/darwin.rs b/crates/cc-computer-use/src/screenshot/darwin.rs similarity index 100% rename from crates/claude-code-rs/src/computer_use/screenshot/darwin.rs rename to crates/cc-computer-use/src/screenshot/darwin.rs diff --git a/crates/claude-code-rs/src/computer_use/screenshot/linux.rs b/crates/cc-computer-use/src/screenshot/linux.rs similarity index 100% rename from crates/claude-code-rs/src/computer_use/screenshot/linux.rs rename to crates/cc-computer-use/src/screenshot/linux.rs diff --git a/crates/claude-code-rs/src/computer_use/screenshot/mod.rs b/crates/cc-computer-use/src/screenshot/mod.rs similarity index 100% rename from crates/claude-code-rs/src/computer_use/screenshot/mod.rs rename to crates/cc-computer-use/src/screenshot/mod.rs diff --git a/crates/claude-code-rs/src/computer_use/screenshot/win32.rs b/crates/cc-computer-use/src/screenshot/win32.rs similarity index 100% rename from crates/claude-code-rs/src/computer_use/screenshot/win32.rs rename to crates/cc-computer-use/src/screenshot/win32.rs diff --git a/crates/cc-config/Cargo.toml b/crates/cc-config/Cargo.toml new file mode 100644 index 00000000..02423cea --- /dev/null +++ b/crates/cc-config/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "cc-config" +version = "0.1.0" +edition = "2021" +description = "Configuration, feature gates, settings, and runtime paths for cc-rust" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +chrono = { workspace = true } +dirs = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +serial_test = { workspace = true } diff --git a/crates/claude-code-rs/src/config/claude_md.rs b/crates/cc-config/src/claude_md.rs similarity index 100% rename from crates/claude-code-rs/src/config/claude_md.rs rename to crates/cc-config/src/claude_md.rs diff --git a/crates/claude-code-rs/src/config/constants.rs b/crates/cc-config/src/constants.rs similarity index 100% rename from crates/claude-code-rs/src/config/constants.rs rename to crates/cc-config/src/constants.rs diff --git a/crates/claude-code-rs/src/config/features.rs b/crates/cc-config/src/features.rs similarity index 100% rename from crates/claude-code-rs/src/config/features.rs rename to crates/cc-config/src/features.rs diff --git a/crates/cc-config/src/lib.rs b/crates/cc-config/src/lib.rs new file mode 100644 index 00000000..87a991d9 --- /dev/null +++ b/crates/cc-config/src/lib.rs @@ -0,0 +1,21 @@ +//! Configuration management — extracted as a workspace crate in Phase 3 +//! (issue #72). +//! +//! Owns: +//! - `settings.json` loader + effective-settings merge layer +//! - `CLAUDE.md` discovery + injection +//! - Data-root path helpers (`~/.cc-rust/` or `$CC_RUST_HOME`) +//! - Feature-gate system (`FEATURE_*` env vars) +//! - Config validation warnings +//! - `runtime_settings::SettingsJson` — the runtime projection of effective +//! settings previously in `types::app_state::SettingsJson` (moved here to +//! let `config::validation` read it without a reverse dep back into the +//! root crate). + +pub mod claude_md; +pub mod constants; +pub mod features; +pub mod paths; +pub mod runtime_settings; +pub mod settings; +pub mod validation; diff --git a/crates/claude-code-rs/src/config/paths.rs b/crates/cc-config/src/paths.rs similarity index 100% rename from crates/claude-code-rs/src/config/paths.rs rename to crates/cc-config/src/paths.rs diff --git a/crates/cc-config/src/runtime_settings.rs b/crates/cc-config/src/runtime_settings.rs new file mode 100644 index 00000000..b0904c34 --- /dev/null +++ b/crates/cc-config/src/runtime_settings.rs @@ -0,0 +1,61 @@ +//! Runtime projection of effective settings. +//! +//! This type used to live in `types::app_state` in the root crate. It was +//! moved here in Phase 3 (issue #72) because: +//! +//! 1. Its fields already reference concrete types from [`crate::settings`] +//! (`PermissionsSettings`, `SandboxSettings`, `StatusLineSettings`, +//! `SpinnerTipsSettings`, `SourceMap`), so cc-config is the natural +//! home. +//! 2. `cc-config::validation` reads `SettingsJson` directly; keeping +//! `SettingsJson` in the root crate would force a reverse dep +//! cc-config → claude-code-rs. +//! +//! The root crate keeps `types::app_state::SettingsJson` as a re-export of +//! this type so existing call sites compile unchanged. + +use crate::settings::{ + PermissionsSettings, SandboxSettings, SourceMap, SpinnerTipsSettings, StatusLineSettings, +}; + +/// Runtime projection of [`crate::settings::EffectiveSettings`] — +/// start-up merges raw settings into this, `/config set` writes back here, +/// and serialization converts it to [`crate::settings::RawSettings`]. +#[derive(Debug, Clone, Default)] +pub struct SettingsJson { + // -- Core identity -------------------------------------------------- + pub model: Option, + pub backend: Option, + pub theme: Option, + pub verbose: Option, + + // -- Permissions / sandbox ----------------------------------------- + pub permission_mode: Option, + pub permissions: PermissionsSettings, + pub sandbox: SandboxSettings, + + // -- UI / UX -------------------------------------------------------- + pub status_line: StatusLineSettings, + pub spinner_tips: SpinnerTipsSettings, + pub output_style: Option, + pub language: Option, + pub voice_enabled: Option, + pub editor_mode: Option, + pub view_mode: Option, + pub terminal_progress_bar_enabled: Option, + + // -- Models / effort ----------------------------------------------- + pub available_models: Vec, + pub effort_level: Option, + pub fast_mode: Option, + pub fast_mode_per_session_opt_in: Option, + + // -- Modes / integrations ------------------------------------------ + pub teammate_mode: Option, + pub claude_in_chrome_default_enabled: Option, + + // -- Per-key source (provenance) ----------------------------------- + /// 来源映射: key -> 哪个 layer 提供了该值。由启动路径 + `/config set` + /// 在写入对应键时一并更新。`/config show` 读取此 map 显示来源信息。 + pub sources: SourceMap, +} diff --git a/crates/claude-code-rs/src/config/settings.rs b/crates/cc-config/src/settings.rs similarity index 99% rename from crates/claude-code-rs/src/config/settings.rs rename to crates/cc-config/src/settings.rs index adc59a16..dad1b342 100644 --- a/crates/claude-code-rs/src/config/settings.rs +++ b/crates/cc-config/src/settings.rs @@ -784,12 +784,12 @@ impl LoadedSettings { /// Global cc-rust data directory. Never fails — falls back to a temp dir. pub fn global_claude_dir() -> Result { - Ok(crate::config::paths::data_root()) + Ok(crate::paths::data_root()) } /// Path to the user-level settings file. pub fn user_settings_path() -> PathBuf { - crate::config::paths::data_root().join("settings.json") + crate::paths::data_root().join("settings.json") } /// Path to the effective project-level settings file for `cwd`. diff --git a/crates/claude-code-rs/src/config/validation.rs b/crates/cc-config/src/validation.rs similarity index 90% rename from crates/claude-code-rs/src/config/validation.rs rename to crates/cc-config/src/validation.rs index 0e075639..3a8c747e 100644 --- a/crates/claude-code-rs/src/config/validation.rs +++ b/crates/cc-config/src/validation.rs @@ -6,10 +6,40 @@ use anyhow::{bail, Result}; -use crate::types::app_state::SettingsJson; +use crate::runtime_settings::SettingsJson; const VALID_BACKENDS: &[&str] = &["native", "codex"]; +// --------------------------------------------------------------------------- +// Engine-layer constants duplicated here +// --------------------------------------------------------------------------- +// +// These were `crate::engine::output_style::BUILT_IN_NAMES` and +// `crate::engine::effort::effort_to_budget_tokens` before cc-config was +// split off in Phase 3 (issue #72). Moving `engine::output_style` and +// `engine::effort` into cc-config would drag the full engine graph in; +// validation just needs the name/budget lookup, so we duplicate the +// small amount of data here. If these lists drift, either source can +// update independently — the authoritative engine values remain the +// ones used at runtime. +const BUILT_IN_STYLE_NAMES: &[&str] = &["default", "explanatory", "learning"]; + +/// Accept any label the engine's effort resolver would accept. +/// Mirrors `engine::effort::effort_to_budget_tokens` — kept in sync by hand. +fn effort_label_is_known(effort: &str) -> bool { + let trimmed = effort.trim(); + if trimmed.is_empty() { + return false; + } + if let Ok(n) = trimmed.parse::() { + return n > 0; + } + matches!( + trimmed.to_ascii_lowercase().as_str(), + "low" | "medium" | "med" | "high" | "auto" | "max" + ) +} + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -257,16 +287,13 @@ pub fn validate_settings(settings: &SettingsJson) -> Vec { }); } else { let lower = trimmed.to_ascii_lowercase(); - if !crate::engine::output_style::BUILT_IN_NAMES - .iter() - .any(|n| *n == lower) - { + if !BUILT_IN_STYLE_NAMES.iter().any(|n| *n == lower) { warnings.push(ValidationWarning { field: "outputStyle".to_string(), message: format!( "Unknown built-in style '{}'. Expected one of {}, or a custom file in .cc-rust/output-styles/.", trimmed, - crate::engine::output_style::BUILT_IN_NAMES.join(", "), + BUILT_IN_STYLE_NAMES.join(", "), ), severity: WarningSeverity::Info, }); @@ -279,8 +306,7 @@ pub fn validate_settings(settings: &SettingsJson) -> Vec { // default thinking budget at runtime. if let Some(effort) = &settings.effort_level { let trimmed = effort.trim(); - if !trimmed.is_empty() && crate::engine::effort::effort_to_budget_tokens(trimmed).is_none() - { + if !trimmed.is_empty() && !effort_label_is_known(trimmed) { warnings.push(ValidationWarning { field: "effortLevel".to_string(), message: format!( @@ -453,7 +479,7 @@ mod tests { fn test_validate_settings_accepts_new_permission_modes() { let settings = SettingsJson { permission_mode: Some("acceptEdits".into()), - permissions: crate::config::settings::PermissionsSettings { + permissions: crate::settings::PermissionsSettings { default_mode: Some("dontAsk".into()), ..Default::default() }, diff --git a/crates/cc-mcp/Cargo.toml b/crates/cc-mcp/Cargo.toml new file mode 100644 index 00000000..17af8ae2 --- /dev/null +++ b/crates/cc-mcp/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "cc-mcp" +version = "0.1.0" +edition = "2021" +description = "Model Context Protocol client, transport, and manager for cc-rust" + +[dependencies] +tokio = { workspace = true } +parking_lot = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } + +cc-config = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +serial_test = { workspace = true } diff --git a/crates/claude-code-rs/src/mcp/channel.rs b/crates/cc-mcp/src/channel.rs similarity index 100% rename from crates/claude-code-rs/src/mcp/channel.rs rename to crates/cc-mcp/src/channel.rs diff --git a/crates/claude-code-rs/src/mcp/client.rs b/crates/cc-mcp/src/client.rs similarity index 89% rename from crates/claude-code-rs/src/mcp/client.rs rename to crates/cc-mcp/src/client.rs index 0889b037..79db093f 100644 --- a/crates/claude-code-rs/src/mcp/client.rs +++ b/crates/cc-mcp/src/client.rs @@ -103,13 +103,11 @@ impl McpClient { }; if let Err(ref e) = result { - super::emit_event(crate::ipc::subsystem_events::SubsystemEvent::Mcp( - crate::ipc::subsystem_events::McpEvent::ServerStateChanged { - server_name: self.config.name.clone(), - state: "error".to_string(), - error: Some(e.to_string()), - }, - )); + super::emit_event(super::McpSubsystemEvent::ServerStateChanged { + server_name: self.config.name.clone(), + state: "error".to_string(), + error: Some(e.to_string()), + }); } result @@ -184,13 +182,11 @@ impl McpClient { self.child = Some(child); self.state = McpConnectionState::Connected; - super::emit_event(crate::ipc::subsystem_events::SubsystemEvent::Mcp( - crate::ipc::subsystem_events::McpEvent::ServerStateChanged { - server_name: self.config.name.clone(), - state: "connected".to_string(), - error: None, - }, - )); + super::emit_event(super::McpSubsystemEvent::ServerStateChanged { + server_name: self.config.name.clone(), + state: "connected".to_string(), + error: None, + }); debug!(server = %self.config.name, "MCP: stdio server connected"); Ok(()) @@ -262,13 +258,11 @@ impl McpClient { self.state = McpConnectionState::Disconnected; - super::emit_event(crate::ipc::subsystem_events::SubsystemEvent::Mcp( - crate::ipc::subsystem_events::McpEvent::ServerStateChanged { - server_name: self.config.name.clone(), - state: "disconnected".to_string(), - error: None, - }, - )); + super::emit_event(super::McpSubsystemEvent::ServerStateChanged { + server_name: self.config.name.clone(), + state: "disconnected".to_string(), + error: None, + }); } // ----------------------------------------------------------------------- @@ -302,19 +296,18 @@ impl McpClient { self.tools = tools.clone(); - super::emit_event(crate::ipc::subsystem_events::SubsystemEvent::Mcp( - crate::ipc::subsystem_events::McpEvent::ToolsDiscovered { - server_name: self.config.name.clone(), - tools: self - .tools - .iter() - .map(|t| crate::ipc::subsystem_types::McpToolInfo { - name: t.name.clone(), - description: Some(t.description.clone()), - }) - .collect(), - }, - )); + super::emit_event(super::McpSubsystemEvent::ToolsDiscovered { + server_name: self.config.name.clone(), + tools: self + .tools + .iter() + .map(|t| super::McpToolInfo { + server_name: self.config.name.clone(), + tool_name: t.name.clone(), + description: t.description.clone(), + }) + .collect(), + }); Ok(tools) } @@ -385,20 +378,20 @@ impl McpClient { self.resources = result.resources.clone(); - super::emit_event(crate::ipc::subsystem_events::SubsystemEvent::Mcp( - crate::ipc::subsystem_events::McpEvent::ResourcesDiscovered { - server_name: self.config.name.clone(), - resources: self - .resources - .iter() - .map(|r| crate::ipc::subsystem_types::McpResourceInfo { - uri: r.uri.clone(), - name: Some(r.name.clone()), - mime_type: r.mime_type.clone(), - }) - .collect(), - }, - )); + super::emit_event(super::McpSubsystemEvent::ResourcesDiscovered { + server_name: self.config.name.clone(), + resources: self + .resources + .iter() + .map(|r| super::McpResourceInfo { + server_name: self.config.name.clone(), + uri: r.uri.clone(), + name: r.name.clone(), + description: None, + mime_type: r.mime_type.clone(), + }) + .collect(), + }); Ok(result.resources) } diff --git a/crates/claude-code-rs/src/mcp/client_tests.rs b/crates/cc-mcp/src/client_tests.rs similarity index 97% rename from crates/claude-code-rs/src/mcp/client_tests.rs rename to crates/cc-mcp/src/client_tests.rs index 932e44a8..28ac18b4 100644 --- a/crates/claude-code-rs/src/mcp/client_tests.rs +++ b/crates/cc-mcp/src/client_tests.rs @@ -1,7 +1,7 @@ -use super::super::{JsonRpcError, JsonRpcResponse, McpConnectionState, McpServerConfig}; -use super::*; -use crate::mcp::manager::McpManager; -use crate::mcp::transport::dispatch_response; +use crate::client::McpClient; +use crate::manager::McpManager; +use crate::transport::dispatch_response; +use crate::{JsonRpcError, JsonRpcResponse, McpConnectionState, McpServerConfig}; use std::collections::HashMap; use std::sync::atomic::Ordering; diff --git a/crates/claude-code-rs/src/mcp/discovery.rs b/crates/cc-mcp/src/discovery.rs similarity index 68% rename from crates/claude-code-rs/src/mcp/discovery.rs rename to crates/cc-mcp/src/discovery.rs index de8395f4..6356a1a1 100644 --- a/crates/claude-code-rs/src/mcp/discovery.rs +++ b/crates/cc-mcp/src/discovery.rs @@ -2,7 +2,39 @@ use super::McpServerConfig; use anyhow::Result; +use parking_lot::Mutex; use std::path::Path; +use std::sync::LazyLock; + +// --------------------------------------------------------------------------- +// Plugin-contributed server hook +// --------------------------------------------------------------------------- +// +// `discover_mcp_servers` used to call `crate::plugins::discover_plugin_mcp_servers()` +// directly. Once cc-mcp moved into its own crate (issue #72), reaching back +// into the root crate's `plugins` module would have been a cycle. The host +// registers a callback that returns plugin-contributed server configs. + +type PluginHook = Box Vec + Send + Sync>; + +static PLUGIN_HOOK: LazyLock>> = LazyLock::new(|| Mutex::new(None)); + +/// Register a callback the host can use to contribute plugin-sourced MCP +/// server configs into discovery. Replaces any previous hook. +pub fn set_plugin_hook(cb: F) +where + F: Fn() -> Vec + Send + Sync + 'static, +{ + *PLUGIN_HOOK.lock() = Some(Box::new(cb)); +} + +fn plugin_servers() -> Vec { + PLUGIN_HOOK + .lock() + .as_ref() + .map(|cb| cb()) + .unwrap_or_default() +} /// Discover MCP server configurations from all supported sources. /// @@ -14,10 +46,10 @@ pub fn discover_mcp_servers(cwd: &Path) -> Result> { let mut servers = Vec::new(); // Lowest precedence: plugin-contributed servers from installed plugins. - merge_server_configs(&mut servers, crate::plugins::discover_plugin_mcp_servers()); + merge_server_configs(&mut servers, plugin_servers()); // Global config: {data_root}/settings.json - let global_settings = crate::config::paths::data_root().join("settings.json"); + let global_settings = cc_config::paths::data_root().join("settings.json"); if let Ok(configs) = load_mcp_from_settings(&global_settings) { merge_server_configs(&mut servers, configs); } @@ -66,9 +98,7 @@ fn merge_server_configs(into: &mut Vec, incoming: Vec` directly. +// Once mcp moved into its own crate (issue #72), reaching back into the root +// crate's `ipc` module would have been a cycle. The host now registers a +// simple callback that receives cc-mcp's own minimal event enum and is +// responsible for adapting it into the broader `SubsystemEvent` wrapper. +// Same pattern as `cc-skills::set_event_callback`. + +use parking_lot::Mutex as SyncMutex; +use std::sync::LazyLock; + +/// Tool information surfaced to the host when `ToolsDiscovered` fires. +#[derive(Debug, Clone)] +pub struct McpToolInfo { + pub server_name: String, + pub tool_name: String, + pub description: String, +} + +/// Resource information surfaced to the host when `ResourcesDiscovered` fires. +#[derive(Debug, Clone)] +pub struct McpResourceInfo { + pub server_name: String, + pub uri: String, + pub name: String, + pub description: Option, + pub mime_type: Option, +} + +/// Minimal event set emitted by the MCP subsystem. The host adapts these +/// into its own subsystem-event wrapper. +#[derive(Debug, Clone)] +pub enum McpSubsystemEvent { + ServerStateChanged { + server_name: String, + state: String, + error: Option, + }, + ToolsDiscovered { + server_name: String, + tools: Vec, + }, + ResourcesDiscovered { + server_name: String, + resources: Vec, + }, +} + +type EventCallback = Box; + +static EVENT_CALLBACK: LazyLock>> = + LazyLock::new(|| SyncMutex::new(None)); + +/// Register the host's event adapter. Replaces any previous callback. +pub fn set_event_callback(cb: F) +where + F: Fn(McpSubsystemEvent) + Send + Sync + 'static, +{ + *EVENT_CALLBACK.lock() = Some(Box::new(cb)); +} + +/// Emit an event through the registered callback (no-op if unset). +pub(crate) fn emit_event(event: McpSubsystemEvent) { + if let Some(cb) = EVENT_CALLBACK.lock().as_ref() { + cb(event); + } +} + +// --------------------------------------------------------------------------- +// Connection state +// --------------------------------------------------------------------------- + +/// MCP server connection state. +#[derive(Debug, Clone, PartialEq)] +pub enum McpConnectionState { + /// Not yet connected. + Pending, + /// Connection established and initialized. + Connected, + /// Disconnected (graceful or after error). + Disconnected, + /// Connection failed with an error. + #[allow(dead_code)] + Error(String), +} + +// --------------------------------------------------------------------------- +// Server configuration (from settings.json) +// --------------------------------------------------------------------------- + +/// MCP server configuration (from settings.json `mcpServers` key). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpServerConfig { + /// Server name (the key in the mcpServers map). + #[serde(default)] + pub name: String, + /// Transport type: "stdio" (default) or "sse". + #[serde(rename = "type", default = "default_transport")] + pub transport: String, + /// Command to launch (for stdio transport). + pub command: Option, + /// Command arguments (for stdio transport). + #[serde(default)] + pub args: Option>, + /// URL (for SSE transport). + pub url: Option, + /// Additional HTTP headers (for SSE transport). + pub headers: Option>, + /// Environment variables to set for the subprocess. + pub env: Option>, + /// Opt-in flag: treat every tool from this server as a browser MCP tool + /// (enables the `# Browser Automation` system-prompt section, category-aware + /// permission prompts, and browser result rendering). When absent, the + /// engine falls back to a tool-name heuristic. See `src/browser/detection.rs`. + #[serde(default, rename = "browserMcp")] + pub browser_mcp: Option, +} + +fn default_transport() -> String { + "stdio".to_string() +} + +// --------------------------------------------------------------------------- +// MCP tool / resource definitions (received from server) +// --------------------------------------------------------------------------- + +/// Tool definition received from an MCP server via `tools/list`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpToolDef { + /// Tool name. + pub name: String, + /// Human-readable description. + #[serde(default)] + pub description: String, + /// JSON Schema for the tool's input parameters. + #[serde(default = "default_schema", rename = "inputSchema")] + pub input_schema: Value, + /// Name of the server that provides this tool (set client-side). + #[serde(default)] + pub server_name: String, +} + +fn default_schema() -> Value { + serde_json::json!({"type": "object", "properties": {}}) +} + +/// Resource definition received from an MCP server via `resources/list`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpResource { + /// Resource URI. + pub uri: String, + /// Human-readable name. + #[serde(default)] + pub name: String, + /// Description. + pub description: Option, + /// MIME type. + #[serde(rename = "mimeType")] + pub mime_type: Option, +} + +/// Content returned from `resources/read`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpResourceContent { + pub uri: String, + #[serde(rename = "mimeType")] + pub mime_type: Option, + /// Text content (mutually exclusive with blob). + pub text: Option, + /// Base64-encoded binary content. + pub blob: Option, +} + +// --------------------------------------------------------------------------- +// JSON-RPC 2.0 protocol types +// --------------------------------------------------------------------------- + +/// JSON-RPC 2.0 request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcRequest { + pub jsonrpc: String, + pub id: Value, + pub method: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +/// JSON-RPC 2.0 notification (no id, no response expected). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcNotification { + pub jsonrpc: String, + pub method: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +/// JSON-RPC 2.0 response (success or error). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcResponse { + pub jsonrpc: String, + pub id: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// JSON-RPC 2.0 error object. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcError { + pub code: i64, + pub message: String, + pub data: Option, +} + +impl JsonRpcRequest { + /// Create a new JSON-RPC request. + pub fn new(id: u64, method: &str, params: Option) -> Self { + Self { + jsonrpc: "2.0".to_string(), + id: Value::Number(id.into()), + method: method.to_string(), + params, + } + } +} + +impl JsonRpcNotification { + /// Create a new JSON-RPC notification. + pub fn new(method: &str, params: Option) -> Self { + Self { + jsonrpc: "2.0".to_string(), + method: method.to_string(), + params, + } + } +} + +// --------------------------------------------------------------------------- +// MCP-specific request/response payloads +// --------------------------------------------------------------------------- + +/// Server capabilities received during initialization. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ServerCapabilities { + /// Whether the server supports tools. + pub tools: Option, + /// Whether the server supports resources. + pub resources: Option, + /// Whether the server supports prompts. + pub prompts: Option, +} + +/// Server info received during initialization. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ServerInfo { + pub name: String, + #[serde(default)] + pub version: String, +} + +/// Result of the `initialize` handshake. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InitializeResult { + pub protocol_version: String, + #[serde(default)] + pub capabilities: ServerCapabilities, + #[serde(default)] + pub server_info: ServerInfo, + pub instructions: Option, +} + +/// Result of `tools/list`. +#[derive(Debug, Clone, Deserialize)] +pub struct ListToolsResult { + pub tools: Vec, +} + +/// Result of `tools/call`. +#[derive(Debug, Clone, Deserialize)] +pub struct CallToolResult { + pub content: Vec, + #[serde(default, rename = "isError")] + pub is_error: bool, +} + +/// Content block in a tool call result. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ToolCallContent { + #[serde(rename = "text")] + Text { text: String }, + #[serde(rename = "image")] + Image { + data: String, + #[serde(rename = "mimeType")] + mime_type: String, + }, + #[serde(rename = "resource")] + Resource { resource: McpResourceContent }, +} + +/// Result of `resources/list`. +#[derive(Debug, Clone, Deserialize)] +pub struct ListResourcesResult { + pub resources: Vec, +} + +/// Result of `resources/read`. +#[allow(dead_code)] +#[derive(Debug, Clone, Deserialize)] +pub struct ReadResourceResult { + pub contents: Vec, +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_jsonrpc_request_serialization() { + let req = JsonRpcRequest::new(1, "tools/list", None); + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["jsonrpc"], "2.0"); + assert_eq!(json["id"], 1); + assert_eq!(json["method"], "tools/list"); + assert!(json.get("params").is_none()); + } + + #[test] + fn test_jsonrpc_request_with_params() { + let req = JsonRpcRequest::new( + 2, + "tools/call", + Some(json!({"name": "search", "arguments": {"query": "test"}})), + ); + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["method"], "tools/call"); + assert_eq!(json["params"]["name"], "search"); + } + + #[test] + fn test_jsonrpc_notification_serialization() { + let notif = JsonRpcNotification::new("notifications/initialized", None); + let json = serde_json::to_value(¬if).unwrap(); + assert_eq!(json["jsonrpc"], "2.0"); + assert_eq!(json["method"], "notifications/initialized"); + assert!(json.get("id").is_none()); + } + + #[test] + fn test_jsonrpc_response_deserialization_success() { + let json = json!({ + "jsonrpc": "2.0", + "id": 1, + "result": {"tools": []} + }); + let resp: JsonRpcResponse = serde_json::from_value(json).unwrap(); + assert!(resp.result.is_some()); + assert!(resp.error.is_none()); + } + + #[test] + fn test_jsonrpc_response_deserialization_error() { + let json = json!({ + "jsonrpc": "2.0", + "id": 1, + "error": {"code": -32600, "message": "Invalid Request"} + }); + let resp: JsonRpcResponse = serde_json::from_value(json).unwrap(); + assert!(resp.result.is_none()); + let err = resp.error.unwrap(); + assert_eq!(err.code, -32600); + assert_eq!(err.message, "Invalid Request"); + } + + #[test] + fn test_server_config_deserialization() { + let json = json!({ + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem"], + "env": {"HOME": "/tmp"} + }); + let config: McpServerConfig = serde_json::from_value(json).unwrap(); + assert_eq!(config.transport, "stdio"); + assert_eq!(config.command.unwrap(), "npx"); + assert_eq!( + config.args.unwrap(), + vec!["-y", "@modelcontextprotocol/server-filesystem"] + ); + } + + #[test] + fn test_tool_def_deserialization() { + let json = json!({ + "name": "read_file", + "description": "Read a file", + "inputSchema": { + "type": "object", + "properties": { + "path": {"type": "string"} + }, + "required": ["path"] + } + }); + let tool: McpToolDef = serde_json::from_value(json).unwrap(); + assert_eq!(tool.name, "read_file"); + assert_eq!(tool.description, "Read a file"); + } + + #[test] + fn test_call_tool_result_deserialization() { + let json = json!({ + "content": [ + {"type": "text", "text": "file contents here"} + ], + "isError": false + }); + let result: CallToolResult = serde_json::from_value(json).unwrap(); + assert!(!result.is_error); + assert_eq!(result.content.len(), 1); + match &result.content[0] { + ToolCallContent::Text { text } => assert_eq!(text, "file contents here"), + _ => panic!("expected text content"), + } + } + + #[test] + fn test_initialize_result_deserialization() { + let json = json!({ + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}, "resources": {}}, + "serverInfo": {"name": "test-server", "version": "1.0"} + }); + let result: InitializeResult = serde_json::from_value(json).unwrap(); + assert_eq!(result.protocol_version, "2024-11-05"); + assert_eq!(result.server_info.name, "test-server"); + } + + #[test] + fn test_resource_content_deserialization() { + let json = json!({ + "uri": "file:///tmp/test.txt", + "mimeType": "text/plain", + "text": "hello world" + }); + let content: McpResourceContent = serde_json::from_value(json).unwrap(); + assert_eq!(content.uri, "file:///tmp/test.txt"); + assert_eq!(content.text.unwrap(), "hello world"); + } +} diff --git a/crates/claude-code-rs/src/mcp/manager.rs b/crates/cc-mcp/src/manager.rs similarity index 100% rename from crates/claude-code-rs/src/mcp/manager.rs rename to crates/cc-mcp/src/manager.rs diff --git a/crates/claude-code-rs/src/mcp/transport.rs b/crates/cc-mcp/src/transport.rs similarity index 100% rename from crates/claude-code-rs/src/mcp/transport.rs rename to crates/cc-mcp/src/transport.rs diff --git a/crates/cc-permissions/Cargo.toml b/crates/cc-permissions/Cargo.toml new file mode 100644 index 00000000..8a475512 --- /dev/null +++ b/crates/cc-permissions/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "cc-permissions" +version = "0.1.0" +edition = "2021" +description = "Per-tool allow/deny model for cc-rust" + +[dependencies] +anyhow = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +regex = { workspace = true } +parking_lot = { workspace = true } +url = { workspace = true } + +cc-types = { workspace = true } +cc-utils = { workspace = true } +cc-bootstrap = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/claude-code-rs/src/permissions/bash_matcher.rs b/crates/cc-permissions/src/bash_matcher.rs similarity index 98% rename from crates/claude-code-rs/src/permissions/bash_matcher.rs rename to crates/cc-permissions/src/bash_matcher.rs index e3bc82c2..1015c0cf 100644 --- a/crates/claude-code-rs/src/permissions/bash_matcher.rs +++ b/crates/cc-permissions/src/bash_matcher.rs @@ -17,8 +17,8 @@ //! Living in a dedicated module keeps the BashTool free of permission //! concerns and lets us unit-test the matcher in isolation. -use crate::permissions::rules::glob_match_public; -use crate::utils::bash::{extract_command_name, parse_command, split_compound_command}; +use crate::rules::glob_match_public; +use cc_utils::bash::{extract_command_name, parse_command, split_compound_command}; /// Wrapper executables whose first non-flag argument is the command we /// actually want to permission-check. diff --git a/crates/claude-code-rs/src/permissions/dangerous.rs b/crates/cc-permissions/src/dangerous.rs similarity index 98% rename from crates/claude-code-rs/src/permissions/dangerous.rs rename to crates/cc-permissions/src/dangerous.rs index d5e25a0b..ea55508e 100644 --- a/crates/claude-code-rs/src/permissions/dangerous.rs +++ b/crates/cc-permissions/src/dangerous.rs @@ -6,7 +6,7 @@ use regex::Regex; use std::sync::LazyLock; -use crate::utils::bash::{contains_multiline_string, has_unterminated_quotes}; +use cc_utils::bash::{contains_multiline_string, has_unterminated_quotes}; /// A single danger pattern: compiled regex + human-readable reason. struct DangerPattern { @@ -114,7 +114,7 @@ static DANGER_PATTERNS: LazyLock> = LazyLock::new(|| { /// # Examples /// /// ``` -/// use claude_code_rs::permissions::dangerous::is_dangerous_command; +/// use cc_permissions::dangerous::is_dangerous_command; /// /// assert!(is_dangerous_command("rm -rf /").is_some()); /// assert!(is_dangerous_command("ls -la").is_none()); diff --git a/crates/claude-code-rs/src/permissions/decision.rs b/crates/cc-permissions/src/decision.rs similarity index 91% rename from crates/claude-code-rs/src/permissions/decision.rs rename to crates/cc-permissions/src/decision.rs index a7943148..129f8a15 100644 --- a/crates/claude-code-rs/src/permissions/decision.rs +++ b/crates/cc-permissions/src/decision.rs @@ -26,7 +26,7 @@ use serde_json::Value; use super::rules; -use crate::types::tool::{PermissionMode, ToolPermissionContext, ToolPermissionRulesBySource}; +use cc_types::permissions::{PermissionMode, ToolPermissionContext, ToolPermissionRulesBySource}; // --------------------------------------------------------------------------- // Core types @@ -137,60 +137,51 @@ impl DenialTracker { // --------------------------------------------------------------------------- // Descriptive permission messages (Computer Use + Browser MCP) // --------------------------------------------------------------------------- +// +// In Phase 4 (issue #73) cc-permissions moved out of the root crate; the two +// message lookups that previously called into `computer_use::detection` and +// `browser::{detection,permissions}` now go through host-registered +// callbacks to avoid a reverse dep. The host wires them from the root +// crate at startup. + +use parking_lot::Mutex; +use std::sync::LazyLock; + +type MessageCallback = Box Option + Send + Sync>; + +static CU_MESSAGE_CB: LazyLock>> = + LazyLock::new(|| Mutex::new(None)); +static BROWSER_MESSAGE_CB: LazyLock>> = + LazyLock::new(|| Mutex::new(None)); + +/// Register the callback that turns a Computer Use tool name into a +/// user-facing permission prompt (or `None` for non-CU tools). +pub fn set_cu_message_callback(cb: F) +where + F: Fn(&str) -> Option + Send + Sync + 'static, +{ + *CU_MESSAGE_CB.lock() = Some(Box::new(cb)); +} -/// Generate a human-readable permission message for Computer Use tools. -fn cu_permission_message(tool_name: &str) -> Option { - use crate::computer_use::detection::{classify_risk, extract_cu_action, CuRiskLevel}; - - let action = extract_cu_action(tool_name)?; - let risk = classify_risk(action); - let risk_tag = match risk { - CuRiskLevel::Medium => "[medium risk]", - CuRiskLevel::High => "[HIGH RISK]", - }; - - let description = match action { - "screenshot" => "read the screen (take a screenshot)", - "cursor_position" => "read the current cursor position", - "left_click" => "click the left mouse button on your screen", - "right_click" => "click the right mouse button on your screen", - "middle_click" => "click the middle mouse button on your screen", - "double_click" => "double-click the mouse on your screen", - "type_text" | "type" => "type text using the keyboard", - "key" => "press a keyboard shortcut", - "scroll" => "scroll the mouse wheel", - "mouse_move" => "move the mouse cursor", - _ => { - return Some(format!( - "Allow desktop control action '{}' {}?", - action, risk_tag - )) - } - }; - - Some(format!("Allow {} {}?", description, risk_tag)) +/// Register the callback that turns a browser tool name into a +/// user-facing permission prompt (or `None` for non-browser tools). +pub fn set_browser_message_callback(cb: F) +where + F: Fn(&str) -> Option + Send + Sync + 'static, +{ + *BROWSER_MESSAGE_CB.lock() = Some(Box::new(cb)); } fn descriptive_permission_message(tool_name: &str) -> Option { - if let Some(m) = cu_permission_message(tool_name) { - return Some(m); - } - - if let Some(m) = crate::browser::permissions::browser_permission_message(tool_name) { - return Some(m); + if let Some(cb) = CU_MESSAGE_CB.lock().as_ref() { + if let Some(m) = cb(tool_name) { + return Some(m); + } } - if let Some(rest) = tool_name.strip_prefix("mcp__") { - if let Some((server, action)) = rest.split_once("__") { - if crate::browser::detection::is_browser_server(server) { - let cat = crate::browser::permissions::classify_browser_action(action); - return Some(format!( - "Allow browser action '{}' via MCP server '{}' {}?", - action, - server, - cat.risk_tag() - )); - } + if let Some(cb) = BROWSER_MESSAGE_CB.lock().as_ref() { + if let Some(m) = cb(tool_name) { + return Some(m); } } @@ -531,6 +522,28 @@ mod tests { use super::*; use std::collections::HashMap; + /// Register the CU message callback used by tests. + /// + /// The host normally wires this from the root crate at startup, but + /// cc-permissions' standalone tests need their own copy of the + /// Computer Use lookup logic to exercise the `medium risk` / `HIGH + /// RISK` / `keyboard` code paths. Idempotent — later callers overwrite + /// an earlier registration, which is fine in this test suite. + fn install_test_cu_callback() { + set_cu_message_callback(|tool_name: &str| { + let rest = tool_name.strip_prefix("mcp__computer-use__")?; + let (risk, verb) = match rest { + "screenshot" => ("medium risk", "take a screenshot"), + "left_click" | "right_click" | "middle_click" | "double_click" => { + ("HIGH RISK", "click the mouse on your screen") + } + "type_text" | "type" => ("HIGH RISK", "type text using the keyboard"), + _ => ("medium risk", rest), + }; + Some(format!("Allow {} [{}]?", verb, risk)) + }); + } + fn default_ctx() -> ToolPermissionContext { ToolPermissionContext { mode: PermissionMode::Default, @@ -635,7 +648,7 @@ mod tests { fn test_accept_edits_mode_allows_workspace_bash_commands() { let dir = tempfile::tempdir().unwrap(); { - let mut ps = crate::bootstrap::PROCESS_STATE.write(); + let mut ps = cc_bootstrap::PROCESS_STATE.write(); ps.original_cwd = dir.path().to_path_buf(); } @@ -823,6 +836,7 @@ mod tests { #[test] fn test_cu_screenshot_permission_message() { + install_test_cu_callback(); let ctx = default_ctx(); let decision = has_permissions_to_use_tool("mcp__computer-use__screenshot", &Value::Null, &ctx, None); @@ -834,6 +848,7 @@ mod tests { #[test] fn test_cu_click_permission_message() { + install_test_cu_callback(); let ctx = default_ctx(); let decision = has_permissions_to_use_tool( "mcp__computer-use__left_click", @@ -849,6 +864,7 @@ mod tests { #[test] fn test_cu_type_text_permission_message() { + install_test_cu_callback(); let ctx = default_ctx(); let decision = has_permissions_to_use_tool( "mcp__computer-use__type_text", diff --git a/crates/claude-code-rs/src/permissions/mod.rs b/crates/cc-permissions/src/lib.rs similarity index 100% rename from crates/claude-code-rs/src/permissions/mod.rs rename to crates/cc-permissions/src/lib.rs diff --git a/crates/claude-code-rs/src/permissions/path_validation.rs b/crates/cc-permissions/src/path_validation.rs similarity index 98% rename from crates/claude-code-rs/src/permissions/path_validation.rs rename to crates/cc-permissions/src/path_validation.rs index 0bd1c855..d9f9a806 100644 --- a/crates/claude-code-rs/src/permissions/path_validation.rs +++ b/crates/cc-permissions/src/path_validation.rs @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; -use crate::types::tool::ToolPermissionContext; +use cc_types::permissions::ToolPermissionContext; /// Check if a path is within the current working directory or any of the /// allowed additional directories. @@ -180,7 +180,7 @@ fn normalize_path(path: &Path) -> PathBuf { #[cfg(test)] mod tests { use super::*; - use crate::types::tool::{AdditionalWorkingDirectory, PermissionMode, ToolPermissionContext}; + use cc_types::permissions::{AdditionalWorkingDirectory, PermissionMode, ToolPermissionContext}; use std::collections::HashMap; fn default_ctx() -> ToolPermissionContext { diff --git a/crates/claude-code-rs/src/permissions/rules.rs b/crates/cc-permissions/src/rules.rs similarity index 97% rename from crates/claude-code-rs/src/permissions/rules.rs rename to crates/cc-permissions/src/rules.rs index 4b63208a..016b992c 100644 --- a/crates/claude-code-rs/src/permissions/rules.rs +++ b/crates/cc-permissions/src/rules.rs @@ -8,13 +8,13 @@ //! 3. **Allow** rules (pre-approve) //! 4. Permission-mode fallback (Default → Ask, Auto → Allow, ...) //! -//! `Bash` patterns are dispatched through [`crate::permissions::bash_matcher`] +//! `Bash` patterns are dispatched through [`crate::bash_matcher`] //! so compound commands and process wrappers participate in matching. -use crate::permissions::bash_matcher; -use crate::permissions::path_validation; -use crate::types::tool::{PermissionMode, ToolPermissionContext, ToolPermissionRulesBySource}; -use crate::utils::bash::{parse_command, split_compound_command}; +use crate::bash_matcher; +use crate::path_validation; +use cc_types::permissions::{PermissionMode, ToolPermissionContext, ToolPermissionRulesBySource}; +use cc_utils::bash::{parse_command, split_compound_command}; use serde_json::Value; use std::path::{Path, PathBuf}; @@ -22,7 +22,7 @@ use std::path::{Path, PathBuf}; /// /// Retained as a stable internal API for tests and for callers that want /// the rule-engine result without the full hook + mode flow (which lives -/// in [`crate::permissions::decision`]). +/// in [`crate::decision`]). #[allow(dead_code)] #[derive(Debug, Clone)] pub enum PermissionCheckResult { @@ -89,14 +89,14 @@ pub fn is_accept_edits_tool_call( return false; } - let cwd = crate::bootstrap::PROCESS_STATE.read().original_cwd.clone(); + let cwd = cc_bootstrap::PROCESS_STATE.read().original_cwd.clone(); accept_edits_bash_command_is_safe(command, &cwd, ctx) } /// Check whether a tool invocation is permitted given the current context. /// /// Returns the rule-based decision only — caller is responsible for the -/// hook overlay and mode fallback (see [`crate::permissions::decision`]). +/// hook overlay and mode fallback (see [`crate::decision`]). #[allow(dead_code)] pub fn check_tool_permission( tool_name: &str, @@ -367,7 +367,7 @@ fn text_specifier_matches(pattern: &str, value: &str) -> bool { /// of characters. This is intentionally simple — we only need to support /// the patterns used in Claude Code's permission rules. /// -/// Also exposed as `glob_match_public` for use in [`crate::permissions::bash_matcher`]. +/// Also exposed as `glob_match_public` for use in [`crate::bash_matcher`]. fn glob_match(text: &str, pattern: &str) -> bool { let segments: Vec<&str> = pattern.split('*').collect(); @@ -707,7 +707,7 @@ mod tests { let mut ctx = default_ctx(); ctx.additional_working_directories.insert( "extra".into(), - crate::types::tool::AdditionalWorkingDirectory { + cc_types::permissions::AdditionalWorkingDirectory { path: extra.path().to_string_lossy().to_string(), read_only: false, }, diff --git a/crates/cc-sandbox/Cargo.toml b/crates/cc-sandbox/Cargo.toml new file mode 100644 index 00000000..3eba4b65 --- /dev/null +++ b/crates/cc-sandbox/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "cc-sandbox" +version = "0.1.0" +edition = "2021" +description = "OS-level isolation + policy checks for shell/WebFetch subprocesses" + +[dependencies] +anyhow = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +which = { workspace = true } +regex = { workspace = true } +url = { workspace = true } +dirs = { workspace = true } + +cc-types = { workspace = true } +cc-config = { workspace = true } +cc-utils = { workspace = true } + +[target.'cfg(unix)'.dependencies] +libc = { workspace = true } diff --git a/crates/claude-code-rs/src/sandbox/availability.rs b/crates/cc-sandbox/src/availability.rs similarity index 100% rename from crates/claude-code-rs/src/sandbox/availability.rs rename to crates/cc-sandbox/src/availability.rs diff --git a/crates/claude-code-rs/src/sandbox/errors.rs b/crates/cc-sandbox/src/errors.rs similarity index 100% rename from crates/claude-code-rs/src/sandbox/errors.rs rename to crates/cc-sandbox/src/errors.rs diff --git a/crates/claude-code-rs/src/sandbox/filesystem.rs b/crates/cc-sandbox/src/filesystem.rs similarity index 100% rename from crates/claude-code-rs/src/sandbox/filesystem.rs rename to crates/cc-sandbox/src/filesystem.rs diff --git a/crates/claude-code-rs/src/sandbox/mod.rs b/crates/cc-sandbox/src/lib.rs similarity index 100% rename from crates/claude-code-rs/src/sandbox/mod.rs rename to crates/cc-sandbox/src/lib.rs diff --git a/crates/claude-code-rs/src/sandbox/mode.rs b/crates/cc-sandbox/src/mode.rs similarity index 100% rename from crates/claude-code-rs/src/sandbox/mode.rs rename to crates/cc-sandbox/src/mode.rs diff --git a/crates/claude-code-rs/src/sandbox/network.rs b/crates/cc-sandbox/src/network.rs similarity index 99% rename from crates/claude-code-rs/src/sandbox/network.rs rename to crates/cc-sandbox/src/network.rs index caf16412..02c0b6ac 100644 --- a/crates/claude-code-rs/src/sandbox/network.rs +++ b/crates/cc-sandbox/src/network.rs @@ -3,7 +3,7 @@ //! Shared by [`crate::tools::web_fetch`] and the shell sandboxes. use super::errors::SandboxError; -use crate::utils::bash::{parse_command, split_compound_command}; +use cc_utils::bash::{parse_command, split_compound_command}; use regex::Regex; use std::sync::LazyLock; diff --git a/crates/claude-code-rs/src/sandbox/policy.rs b/crates/cc-sandbox/src/policy.rs similarity index 97% rename from crates/claude-code-rs/src/sandbox/policy.rs rename to crates/cc-sandbox/src/policy.rs index 9fea4402..83aab308 100644 --- a/crates/claude-code-rs/src/sandbox/policy.rs +++ b/crates/cc-sandbox/src/policy.rs @@ -1,4 +1,4 @@ -//! Effective sandbox policy — merged from [`crate::config::settings::SandboxSettings`] +//! Effective sandbox policy — merged from [`cc_config::settings::SandboxSettings`] //! plus permission rules, then cached on [`crate::types::app_state::AppState`]. //! //! Policy assembly is deliberately decoupled from settings parsing so @@ -8,7 +8,7 @@ use std::path::PathBuf; -use crate::config::settings::SandboxSettings; +use cc_config::settings::SandboxSettings; use super::availability::{detect_availability, Availability}; use super::filesystem::PathResolver; @@ -229,7 +229,7 @@ impl SandboxPolicyBuilder { #[cfg(test)] mod tests { use super::*; - use crate::config::settings::{SandboxFilesystemSettings, SandboxNetworkSettings}; + use cc_config::settings::{SandboxFilesystemSettings, SandboxNetworkSettings}; #[test] fn default_is_inactive() { diff --git a/crates/claude-code-rs/src/sandbox/runner.rs b/crates/cc-sandbox/src/runner.rs similarity index 88% rename from crates/claude-code-rs/src/sandbox/runner.rs rename to crates/cc-sandbox/src/runner.rs index d8d6e578..70c08e1a 100644 --- a/crates/claude-code-rs/src/sandbox/runner.rs +++ b/crates/cc-sandbox/src/runner.rs @@ -17,7 +17,7 @@ use super::availability::Mechanism; use super::errors::SandboxError; use super::network::NetworkDecision; use super::policy::SandboxPolicy; -use crate::types::tool::ToolPermissionRulesBySource; +use cc_types::permissions::{ToolPermissionContext, ToolPermissionRulesBySource}; /// A command pre-assembled for sandboxed execution. /// @@ -369,51 +369,48 @@ fn current_platform() -> &'static str { } } -/// Helper: build a [`SandboxPolicy`] from a snapshot of -/// [`crate::types::app_state::AppState`] plus the session workspace. +/// Helper: build a [`SandboxPolicy`] from a permission context snapshot + +/// sandbox settings + session workspace. /// /// Exposed so tools can re-derive the policy per call (permission rules -/// may change mid-session). +/// may change mid-session). The caller — which still holds a full +/// `AppState` inside the root crate — peels off the two fields this +/// function actually reads; that's how cc-sandbox stays free of the root +/// crate's `AppState` type, which still drags in teams / ui / keybindings +/// subsystems that haven't moved out yet. pub fn policy_from_app_state( - app_state: &crate::types::app_state::AppState, + permission_context: &ToolPermissionContext, + sandbox_settings: &cc_config::settings::SandboxSettings, workspace: PathBuf, force_no_network: bool, ) -> SandboxPolicy { use super::policy::SandboxPolicyBuilder; // Extract deny rules from the permission system for filesystem merging. - let deny_reads = collect_permission_paths( - &app_state.tool_permission_context.always_deny_rules, - &["Read"], - ); + let deny_reads = + collect_permission_paths(&permission_context.always_deny_rules, &["Read"]); let deny_writes = collect_permission_paths( - &app_state.tool_permission_context.always_deny_rules, + &permission_context.always_deny_rules, &["Edit", "Write", "MultiEdit", "NotebookEdit"], ); - let mut allow_reads = collect_permission_paths( - &app_state.tool_permission_context.always_allow_rules, - &["Read"], - ); + let mut allow_reads = + collect_permission_paths(&permission_context.always_allow_rules, &["Read"]); let mut allow_writes = collect_permission_paths( - &app_state.tool_permission_context.always_allow_rules, + &permission_context.always_allow_rules, &["Edit", "Write", "MultiEdit", "NotebookEdit"], ); allow_reads.extend(collect_permission_paths( - &app_state.tool_permission_context.session_allow_rules, + &permission_context.session_allow_rules, &["Read"], )); allow_writes.extend(collect_permission_paths( - &app_state.tool_permission_context.session_allow_rules, + &permission_context.session_allow_rules, &["Edit", "Write", "MultiEdit", "NotebookEdit"], )); // Additional working directories from permissions are extra workspaces. let mut extra_workspaces: Vec = Vec::new(); - for dir in app_state - .tool_permission_context - .additional_working_directories - .values() - { + for dir in permission_context.additional_working_directories.values() { allow_reads.push(dir.path.clone()); if !dir.read_only { allow_writes.push(dir.path.clone()); @@ -422,7 +419,7 @@ pub fn policy_from_app_state( } SandboxPolicyBuilder::new(workspace) - .settings(app_state.settings.sandbox.clone()) + .settings(sandbox_settings.clone()) .no_network(force_no_network) .extra_workspaces(extra_workspaces) .permission_allow_reads(allow_reads) @@ -513,7 +510,7 @@ mod tests { #[test] fn unsupported_runner_hard_fails_when_configured() { use super::super::policy::SandboxPolicyBuilder; - use crate::config::settings::SandboxSettings; + use cc_config::settings::SandboxSettings; let policy = SandboxPolicyBuilder::new(std::path::PathBuf::from("/tmp")) .settings(SandboxSettings { enabled: Some(true), @@ -532,7 +529,7 @@ mod tests { #[test] fn seatbelt_profile_includes_workdir() { use super::super::policy::SandboxPolicyBuilder; - use crate::config::settings::SandboxSettings; + use cc_config::settings::SandboxSettings; let policy = SandboxPolicyBuilder::new(std::path::PathBuf::from("/proj")) .settings(SandboxSettings { enabled: Some(true), @@ -547,7 +544,7 @@ mod tests { #[test] fn seatbelt_profile_denies_network_when_disabled() { use super::super::policy::SandboxPolicyBuilder; - use crate::config::settings::{SandboxNetworkSettings, SandboxSettings}; + use cc_config::settings::{SandboxNetworkSettings, SandboxSettings}; let policy = SandboxPolicyBuilder::new(std::path::PathBuf::from("/proj")) .settings(SandboxSettings { enabled: Some(true), @@ -562,20 +559,39 @@ mod tests { assert!(profile.contains("(deny network*)")); } + fn default_permission_ctx() -> ToolPermissionContext { + ToolPermissionContext { + mode: cc_types::permissions::PermissionMode::Default, + additional_working_directories: std::collections::HashMap::new(), + always_allow_rules: std::collections::HashMap::new(), + always_deny_rules: std::collections::HashMap::new(), + always_ask_rules: std::collections::HashMap::new(), + session_allow_rules: std::collections::HashMap::new(), + is_bypass_permissions_mode_available: false, + is_auto_mode_available: None, + pre_plan_mode: None, + } + } + #[test] fn policy_from_app_state_picks_up_deny_rules() { - use crate::types::app_state::AppState; use std::collections::HashMap; - let mut app_state = AppState::default(); + let mut ctx = default_permission_ctx(); let mut rules = HashMap::new(); rules.insert( "Edit".to_string(), vec!["Edit(/etc/passwd)".to_string(), "Read(~/.ssh)".to_string()], ); - app_state.tool_permission_context.always_deny_rules = rules; - - let policy = policy_from_app_state(&app_state, std::path::PathBuf::from("/proj"), false); + ctx.always_deny_rules = rules; + + let settings = cc_config::settings::SandboxSettings::default(); + let policy = policy_from_app_state( + &ctx, + &settings, + std::path::PathBuf::from("/proj"), + false, + ); // Deny-write should have /etc/passwd let writes = policy.paths.deny_write_paths(); assert!(writes.iter().any(|p| p.ends_with("passwd"))); @@ -584,7 +600,7 @@ mod tests { #[test] fn preflight_shell_command_checks_network_policy() { use super::super::policy::SandboxPolicyBuilder; - use crate::config::settings::{SandboxNetworkSettings, SandboxSettings}; + use cc_config::settings::{SandboxNetworkSettings, SandboxSettings}; let policy = SandboxPolicyBuilder::new(std::path::PathBuf::from("/proj")) .settings(SandboxSettings { @@ -604,10 +620,9 @@ mod tests { #[test] fn policy_from_app_state_picks_up_allow_rules() { - use crate::types::app_state::AppState; use std::collections::HashMap; - let mut app_state = AppState::default(); + let mut ctx = default_permission_ctx(); let mut rules = HashMap::new(); rules.insert( "project".to_string(), @@ -616,9 +631,15 @@ mod tests { "Edit(/tmp/work/*)".to_string(), ], ); - app_state.tool_permission_context.always_allow_rules = rules; - - let policy = policy_from_app_state(&app_state, std::path::PathBuf::from("/proj"), false); + ctx.always_allow_rules = rules; + + let settings = cc_config::settings::SandboxSettings::default(); + let policy = policy_from_app_state( + &ctx, + &settings, + std::path::PathBuf::from("/proj"), + false, + ); assert!(policy .paths .allow_read_paths() diff --git a/crates/cc-services/Cargo.toml b/crates/cc-services/Cargo.toml new file mode 100644 index 00000000..66f4ff5c --- /dev/null +++ b/crates/cc-services/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "cc-services" +version = "0.1.0" +edition = "2021" +description = "Background services for cc-rust: LSP lifecycle, prompt suggestions, session memory, tool-use summaries" + +[dependencies] +anyhow = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +chrono = { workspace = true } + +cc-config = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/cc-services/src/lib.rs b/crates/cc-services/src/lib.rs new file mode 100644 index 00000000..423ffb12 --- /dev/null +++ b/crates/cc-services/src/lib.rs @@ -0,0 +1,15 @@ +//! Background / utility services extracted from the root crate in Phase 3 +//! (issue #72). +//! +//! **Partial extraction** — 4 of the 6 services moved cleanly; the two that +//! reach into subsystems still in the root crate stay behind: +//! +//! - `session_analytics` — depends on `session::storage` (`cc-session` is +//! Phase 4, issue #73). +//! - `langfuse` — depends on `types::tool::Tools`, which can only move once +//! the tool trait leaves the root crate (Phase 5 hub-cycle break). + +pub mod lsp_lifecycle; +pub mod prompt_suggestion; +pub mod session_memory; +pub mod tool_use_summary; diff --git a/crates/claude-code-rs/src/services/lsp_lifecycle.rs b/crates/cc-services/src/lsp_lifecycle.rs similarity index 100% rename from crates/claude-code-rs/src/services/lsp_lifecycle.rs rename to crates/cc-services/src/lsp_lifecycle.rs diff --git a/crates/claude-code-rs/src/services/prompt_suggestion.rs b/crates/cc-services/src/prompt_suggestion.rs similarity index 100% rename from crates/claude-code-rs/src/services/prompt_suggestion.rs rename to crates/cc-services/src/prompt_suggestion.rs diff --git a/crates/claude-code-rs/src/services/session_memory.rs b/crates/cc-services/src/session_memory.rs similarity index 99% rename from crates/claude-code-rs/src/services/session_memory.rs rename to crates/cc-services/src/session_memory.rs index 7c70da0a..84f55f09 100644 --- a/crates/claude-code-rs/src/services/session_memory.rs +++ b/crates/cc-services/src/session_memory.rs @@ -34,7 +34,7 @@ impl Default for SessionMemoryConfig { fn default() -> Self { SessionMemoryConfig { enabled: true, - memory_dir: crate::config::paths::session_insights_dir(), + memory_dir: cc_config::paths::session_insights_dir(), max_entries: 50, min_messages_before_extract: 5, } diff --git a/crates/claude-code-rs/src/services/tool_use_summary.rs b/crates/cc-services/src/tool_use_summary.rs similarity index 100% rename from crates/claude-code-rs/src/services/tool_use_summary.rs rename to crates/cc-services/src/tool_use_summary.rs diff --git a/crates/cc-session/Cargo.toml b/crates/cc-session/Cargo.toml new file mode 100644 index 00000000..3f091ee8 --- /dev/null +++ b/crates/cc-session/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "cc-session" +version = "0.1.0" +edition = "2021" +description = "Session persistence for cc-rust — conversation state, transcripts, memdir, exports" +build = "build.rs" + +[dependencies] +anyhow = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +parking_lot = { workspace = true } +walkdir = { workspace = true } +regex = { workspace = true } +dirs = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } +git2 = { workspace = true } + +cc-types = { workspace = true } +cc-config = { workspace = true } +cc-utils = { workspace = true } +cc-bootstrap = { workspace = true } +cc-compact = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +serial_test = { workspace = true } diff --git a/crates/cc-session/build.rs b/crates/cc-session/build.rs new file mode 100644 index 00000000..7295c580 --- /dev/null +++ b/crates/cc-session/build.rs @@ -0,0 +1,9 @@ +//! Build script — link Windows advapi32 for libgit2-sys CryptoAPI. Mirrors +//! the shim added to cc-utils (both crates pull in `git2`, and rust-lld on +//! MSVC only picks up libgit2-sys's link directive for final binaries). + +fn main() { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { + println!("cargo:rustc-link-lib=dylib=advapi32"); + } +} diff --git a/crates/claude-code-rs/src/session/audit_export.rs b/crates/cc-session/src/audit_export.rs similarity index 99% rename from crates/claude-code-rs/src/session/audit_export.rs rename to crates/cc-session/src/audit_export.rs index bf34266a..268dcb39 100644 --- a/crates/claude-code-rs/src/session/audit_export.rs +++ b/crates/cc-session/src/audit_export.rs @@ -23,9 +23,9 @@ use chrono::{TimeZone, Utc}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use crate::bootstrap::PROCESS_STATE; -use crate::session::storage::{self, SessionFile}; -use crate::types::message::{Message, MessageContent}; +use cc_bootstrap::PROCESS_STATE; +use crate::storage::{self, SessionFile}; +use cc_types::message::{Message, MessageContent}; // --------------------------------------------------------------------------- // Public types @@ -502,7 +502,7 @@ fn sha256_str(s: &str) -> String { // --------------------------------------------------------------------------- fn get_audit_dir() -> PathBuf { - crate::config::paths::audits_dir() + cc_config::paths::audits_dir() } fn write_audit_record( diff --git a/crates/claude-code-rs/src/session/export.rs b/crates/cc-session/src/export.rs similarity index 97% rename from crates/claude-code-rs/src/session/export.rs rename to crates/cc-session/src/export.rs index f3b1697c..1af755d5 100644 --- a/crates/claude-code-rs/src/session/export.rs +++ b/crates/cc-session/src/export.rs @@ -12,8 +12,8 @@ use anyhow::{Context, Result}; use chrono::{DateTime, TimeZone, Utc}; use tracing::{debug, info}; -use crate::session::storage::{self, SerializableMessage, SessionFile}; -use crate::types::message::{ContentBlock, Message, MessageContent}; +use crate::storage::{self, SerializableMessage, SessionFile}; +use cc_types::message::{ContentBlock, Message, MessageContent}; // --------------------------------------------------------------------------- // Public API @@ -95,7 +95,7 @@ pub fn list_exports() -> Result> { // --------------------------------------------------------------------------- fn get_export_dir() -> PathBuf { - crate::config::paths::exports_dir() + cc_config::paths::exports_dir() } // --------------------------------------------------------------------------- @@ -295,8 +295,8 @@ fn render_content_block(block: &ContentBlock, md: &mut String) { }; md.push_str(&format!("**{}**:\n\n", label)); let text = match content { - crate::types::message::ToolResultContent::Text(t) => t.clone(), - crate::types::message::ToolResultContent::Blocks(blocks) => blocks + cc_types::message::ToolResultContent::Text(t) => t.clone(), + cc_types::message::ToolResultContent::Blocks(blocks) => blocks .iter() .filter_map(|b| { if let ContentBlock::Text { text } = b { diff --git a/crates/cc-session/src/lib.rs b/crates/cc-session/src/lib.rs new file mode 100644 index 00000000..94c2a916 --- /dev/null +++ b/crates/cc-session/src/lib.rs @@ -0,0 +1,17 @@ +//! Session persistence — extracted as a workspace crate in Phase 4 +//! (issue #73). +//! +//! Writes conversation state to `~/.cc-rust/memory/` (path unchanged by +//! the split — the acceptance test in the issue explicitly calls that +//! out). Depends on cc-bootstrap (process state), cc-compact (for the +//! export pipeline's context-window helpers), cc-types (message types), +//! cc-utils (token estimates). + +pub mod audit_export; +pub mod export; +pub mod memdir; +pub mod migrations; +pub mod resume; +pub mod session_export; +pub mod storage; +pub mod transcript; diff --git a/crates/claude-code-rs/src/session/memdir.rs b/crates/cc-session/src/memdir.rs similarity index 99% rename from crates/claude-code-rs/src/session/memdir.rs rename to crates/cc-session/src/memdir.rs index 8f4c0f11..90fd9b6d 100644 --- a/crates/claude-code-rs/src/session/memdir.rs +++ b/crates/cc-session/src/memdir.rs @@ -51,7 +51,7 @@ pub enum MemoryScope { /// Get the memory directory for a given scope. pub fn memory_dir(scope: MemoryScope, cwd: &Path) -> Result { match scope { - MemoryScope::Global => Ok(crate::config::paths::memory_dir_global()), + MemoryScope::Global => Ok(cc_config::paths::memory_dir_global()), MemoryScope::Project => Ok(cwd.join(".cc-rust").join("memory")), } } diff --git a/crates/claude-code-rs/src/session/migrations.rs b/crates/cc-session/src/migrations.rs similarity index 100% rename from crates/claude-code-rs/src/session/migrations.rs rename to crates/cc-session/src/migrations.rs diff --git a/crates/claude-code-rs/src/session/resume.rs b/crates/cc-session/src/resume.rs similarity index 97% rename from crates/claude-code-rs/src/session/resume.rs rename to crates/cc-session/src/resume.rs index 4107b6a9..091a0c64 100644 --- a/crates/claude-code-rs/src/session/resume.rs +++ b/crates/cc-session/src/resume.rs @@ -9,7 +9,7 @@ use anyhow::Result; use tracing::{debug, info}; use super::storage::{self, SessionInfo}; -use crate::types::message::Message; +use cc_types::message::Message; /// Find the most recently modified session in the same workspace/repository as `cwd`. /// diff --git a/crates/claude-code-rs/src/session/session_export/builders.rs b/crates/cc-session/src/session_export/builders.rs similarity index 94% rename from crates/claude-code-rs/src/session/session_export/builders.rs rename to crates/cc-session/src/session_export/builders.rs index 35d3fb0e..a28deeca 100644 --- a/crates/claude-code-rs/src/session/session_export/builders.rs +++ b/crates/cc-session/src/session_export/builders.rs @@ -3,10 +3,10 @@ use std::collections::HashSet; use std::path::Path; -use crate::bootstrap::PROCESS_STATE; -use crate::compact::auto_compact::get_context_window_size; -use crate::types::message::{ContentBlock, Message, MessageContent, ToolResultContent}; -use crate::utils::tokens::estimate_messages_tokens; +use cc_bootstrap::PROCESS_STATE; +use cc_compact::auto_compact::get_context_window_size; +use cc_types::message::{ContentBlock, Message, MessageContent, ToolResultContent}; +use cc_utils::tokens::estimate_messages_tokens; use super::{format_ts_millis, ContextSnapshot, SessionMeta, TranscriptData}; @@ -17,8 +17,8 @@ use super::{format_ts_millis, ContextSnapshot, SessionMeta, TranscriptData}; pub(super) fn build_session_meta(session_id: &str, messages: &[Message], cwd: &str) -> SessionMeta { let cwd_path = Path::new(cwd); - let git_branch = crate::utils::git::current_branch(cwd_path).ok(); - let git_head_sha = crate::utils::git::head_sha(cwd_path).ok(); + let git_branch = cc_utils::git::current_branch(cwd_path).ok(); + let git_head_sha = cc_utils::git::head_sha(cwd_path).ok(); let model = PROCESS_STATE .read() diff --git a/crates/claude-code-rs/src/session/session_export/compression.rs b/crates/cc-session/src/session_export/compression.rs similarity index 99% rename from crates/claude-code-rs/src/session/session_export/compression.rs rename to crates/cc-session/src/session_export/compression.rs index f73e0bad..d557566f 100644 --- a/crates/claude-code-rs/src/session/session_export/compression.rs +++ b/crates/cc-session/src/session_export/compression.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use regex::Regex; -use crate::types::message::{ +use cc_types::message::{ ContentBlock, Message, MessageContent, SystemSubtype, ToolResultContent, }; diff --git a/crates/claude-code-rs/src/session/session_export/mod.rs b/crates/cc-session/src/session_export/mod.rs similarity index 98% rename from crates/claude-code-rs/src/session/session_export/mod.rs rename to crates/cc-session/src/session_export/mod.rs index 0dd397a7..6d2d99bb 100644 --- a/crates/claude-code-rs/src/session/session_export/mod.rs +++ b/crates/cc-session/src/session_export/mod.rs @@ -24,8 +24,8 @@ use anyhow::{Context, Result}; use chrono::{TimeZone, Utc}; use serde::{Deserialize, Serialize}; -use crate::session::storage::{self, SessionFile}; -use crate::types::message::Message; +use crate::storage::{self, SessionFile}; +use cc_types::message::Message; #[allow(unused_imports)] // Used by commands/session_export.rs pub use builders::build_context_snapshot; @@ -219,7 +219,7 @@ pub fn build_session_export(session_id: &str, messages: &[Message], cwd: &str) - // --------------------------------------------------------------------------- pub(crate) fn get_export_dir() -> PathBuf { - crate::config::paths::exports_dir() + cc_config::paths::exports_dir() } fn write_session_export( diff --git a/crates/claude-code-rs/src/session/session_export/tests.rs b/crates/cc-session/src/session_export/tests.rs similarity index 99% rename from crates/claude-code-rs/src/session/session_export/tests.rs rename to crates/cc-session/src/session_export/tests.rs index ebd4c0f1..2deab15d 100644 --- a/crates/claude-code-rs/src/session/session_export/tests.rs +++ b/crates/cc-session/src/session_export/tests.rs @@ -2,7 +2,7 @@ use super::builders; use super::compression; use super::compression::detect_microcompact; use super::*; -use crate::types::message::*; +use cc_types::message::*; use uuid::Uuid; fn make_user_msg(text: &str) -> Message { diff --git a/crates/claude-code-rs/src/session/storage.rs b/crates/cc-session/src/storage.rs similarity index 98% rename from crates/claude-code-rs/src/session/storage.rs rename to crates/cc-session/src/storage.rs index dcdc713c..2660b6c7 100644 --- a/crates/claude-code-rs/src/session/storage.rs +++ b/crates/cc-session/src/storage.rs @@ -12,7 +12,7 @@ use git2::Repository; use serde::{Deserialize, Serialize}; use tracing::debug; -use crate::types::message::Message; +use cc_types::message::Message; // --------------------------------------------------------------------------- // Types @@ -86,9 +86,9 @@ pub struct SerializableMessage { // --------------------------------------------------------------------------- /// Return the base directory for session storage. Resolves through -/// [`crate::config::paths::sessions_dir`]. +/// [`cc_config::paths::sessions_dir`]. pub fn get_session_dir() -> PathBuf { - crate::config::paths::sessions_dir() + cc_config::paths::sessions_dir() } /// Return the file path for a specific session. @@ -104,7 +104,7 @@ fn normalize_display_path(path: &Path) -> String { } fn stable_workspace_path(path: &Path) -> PathBuf { - crate::utils::git::find_git_root(path).unwrap_or_else(|| path.to_path_buf()) + cc_utils::git::find_git_root(path).unwrap_or_else(|| path.to_path_buf()) } fn normalize_match_key(path: &Path) -> String { @@ -514,10 +514,10 @@ fn messages_to_serializable(messages: &[Message]) -> Vec { let (msg_type, data) = match msg { Message::User(u) => { let content_value = match &u.content { - crate::types::message::MessageContent::Text(t) => { + cc_types::message::MessageContent::Text(t) => { serde_json::json!(t) } - crate::types::message::MessageContent::Blocks(blocks) => { + cc_types::message::MessageContent::Blocks(blocks) => { serde_json::json!(blocks) } }; @@ -574,7 +574,7 @@ fn messages_to_serializable(messages: &[Message]) -> Vec { /// the simplified serialization are set to defaults. A production /// implementation would store the full typed data. fn serializable_to_messages(msgs: &[SerializableMessage]) -> Vec { - use crate::types::message::*; + use cc_types::message::*; use uuid::Uuid; msgs.iter() @@ -589,7 +589,7 @@ fn serializable_to_messages(msgs: &[SerializableMessage]) -> Vec { content: match sm.data.get("content") { Some(serde_json::Value::String(s)) => MessageContent::Text(s.clone()), Some(serde_json::Value::Array(blocks)) => { - match serde_json::from_value::>( + match serde_json::from_value::>( serde_json::Value::Array(blocks.clone()), ) { Ok(cb) => MessageContent::Blocks(cb), @@ -622,10 +622,10 @@ fn serializable_to_messages(msgs: &[SerializableMessage]) -> Vec { timestamp: sm.timestamp, role: "assistant".into(), content: sm.data.get("content") - .and_then(|v| serde_json::from_value::>(v.clone()).ok()) + .and_then(|v| serde_json::from_value::>(v.clone()).ok()) .unwrap_or_default(), usage: sm.data.get("usage") - .and_then(|v| serde_json::from_value::(v.clone()).ok()), + .and_then(|v| serde_json::from_value::(v.clone()).ok()), stop_reason: sm .data .get("stop_reason") diff --git a/crates/claude-code-rs/src/session/transcript.rs b/crates/cc-session/src/transcript.rs similarity index 92% rename from crates/claude-code-rs/src/session/transcript.rs rename to crates/cc-session/src/transcript.rs index 0e1d5a15..73554253 100644 --- a/crates/claude-code-rs/src/session/transcript.rs +++ b/crates/cc-session/src/transcript.rs @@ -12,7 +12,7 @@ use anyhow::{Context, Result}; use chrono::Utc; use serde::Serialize; -use crate::types::message::Message; +use cc_types::message::Message; // --------------------------------------------------------------------------- // Types @@ -38,9 +38,9 @@ struct TranscriptEntry { // --------------------------------------------------------------------------- /// Return the directory for transcript files. Resolves through -/// [`crate::config::paths::transcripts_dir`]. +/// [`cc_config::paths::transcripts_dir`]. fn get_transcript_dir() -> PathBuf { - crate::config::paths::transcripts_dir() + cc_config::paths::transcripts_dir() } /// Return the transcript file path for a specific session. @@ -120,8 +120,8 @@ fn message_to_payload(msg: &Message) -> (String, serde_json::Value) { match msg { Message::User(u) => { let text = match &u.content { - crate::types::message::MessageContent::Text(t) => t.clone(), - crate::types::message::MessageContent::Blocks(blocks) => { + cc_types::message::MessageContent::Text(t) => t.clone(), + cc_types::message::MessageContent::Blocks(blocks) => { format!("[{} content blocks]", blocks.len()) } }; @@ -132,8 +132,8 @@ fn message_to_payload(msg: &Message) -> (String, serde_json::Value) { .content .iter() .filter_map(|block| match block { - crate::types::message::ContentBlock::Text { text } => Some(text.clone()), - crate::types::message::ContentBlock::ToolUse { name, .. } => { + cc_types::message::ContentBlock::Text { text } => Some(text.clone()), + cc_types::message::ContentBlock::ToolUse { name, .. } => { Some(format!("[tool_use: {}]", name)) } _ => None, diff --git a/crates/cc-types/src/lib.rs b/crates/cc-types/src/lib.rs index 37f5acf0..e67e9324 100644 --- a/crates/cc-types/src/lib.rs +++ b/crates/cc-types/src/lib.rs @@ -8,5 +8,6 @@ //! See issue #70 (`[workspace-split] Phase 1`) for the rationale behind this //! partial split. pub mod message; +pub mod permissions; pub mod state; pub mod transitions; diff --git a/crates/cc-types/src/permissions.rs b/crates/cc-types/src/permissions.rs new file mode 100644 index 00000000..68f22b77 --- /dev/null +++ b/crates/cc-types/src/permissions.rs @@ -0,0 +1,115 @@ +//! Permission-context types used by both the tool trait (still in the +//! root crate) and subsystems that need to consult permission rules without +//! pulling in the full `ToolUseContext` (which still depends on +//! `ipc::agent_channel::AgentSender`). +//! +//! Moved into cc-types in Phase 4 (issue #73) so downstream crates like +//! `cc-sandbox` and `cc-permissions` can be workspace leaves without a +//! cycle through the root crate. + +use std::collections::HashMap; + +/// Permission mode. +/// +/// Mirrors the six modes described in +/// `docs/claude-code-configuration/permissions.md`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PermissionMode { + /// Default / ask mode: require user confirmation. + Default, + /// Auto: auto-approve (with a safety classifier). + Auto, + /// Bypass: skip all permission checks. + Bypass, + /// Plan: read-only, no writes. + Plan, + /// AcceptEdits: Write/Edit/MultiEdit and common workspace filesystem + /// commands are allowed by default; other tools still follow the normal + /// ask flow. + AcceptEdits, + /// DontAsk: silently deny any request that would otherwise prompt. + /// Useful for headless / CI to avoid blocking. `deny` rules still win. + DontAsk, +} + +impl PermissionMode { + /// Parse a mode string. Accepts both kebab-case and camelCase tokens + /// from the permissions docs as well as the legacy lower-case forms. + /// Unknown / empty values fall back to [`PermissionMode::Default`]. + pub fn parse(value: &str) -> PermissionMode { + match value.trim().to_ascii_lowercase().as_str() { + "auto" => PermissionMode::Auto, + "bypass" | "bypasspermissions" | "bypass-permissions" => PermissionMode::Bypass, + "plan" | "readonly" | "read-only" => PermissionMode::Plan, + "acceptedits" | "accept-edits" | "accept_edits" => PermissionMode::AcceptEdits, + "dontask" | "dont-ask" | "dont_ask" | "no-ask" => PermissionMode::DontAsk, + _ => PermissionMode::Default, + } + } + + /// Stable lower-case identifier (camelCase) used for source-map tagging + /// and `/permissions show` output. + pub fn as_str(&self) -> &'static str { + match self { + PermissionMode::Default => "default", + PermissionMode::Auto => "auto", + PermissionMode::Bypass => "bypass", + PermissionMode::Plan => "plan", + PermissionMode::AcceptEdits => "acceptEdits", + PermissionMode::DontAsk => "dontAsk", + } + } +} + +/// Tool permission context — the slice of runtime state that permission / +/// sandbox code consults. +#[derive(Debug, Clone)] +pub struct ToolPermissionContext { + pub mode: PermissionMode, + pub additional_working_directories: HashMap, + pub always_allow_rules: ToolPermissionRulesBySource, + pub always_deny_rules: ToolPermissionRulesBySource, + pub always_ask_rules: ToolPermissionRulesBySource, + /// Session-level allow grants (cleared on session end). + /// + /// Checked between `always_allow_rules` and mode fallback. + /// Used for Computer Use "always allow" to avoid permanent rules + /// for high-risk desktop control tools. + pub session_allow_rules: ToolPermissionRulesBySource, + pub is_bypass_permissions_mode_available: bool, + pub is_auto_mode_available: Option, + /// The permission mode in effect before plan mode was entered + /// (used to restore on exit). + pub pre_plan_mode: Option, +} + +impl ToolPermissionContext { + /// Add a session-level allow grant for a tool. + pub fn grant_session_allow(&mut self, tool_name: &str) { + self.session_allow_rules + .entry("session".into()) + .or_default() + .push(tool_name.to_string()); + } + + /// Check if a tool has a session-level allow grant. + pub fn has_session_grant(&self, tool_name: &str) -> bool { + self.session_allow_rules + .values() + .any(|rules| rules.iter().any(|r| r == tool_name)) + } + + /// Clear all session-level grants (called on session end). + pub fn clear_session_grants(&mut self) { + self.session_allow_rules.clear(); + } +} + +#[derive(Debug, Clone, Default)] +pub struct AdditionalWorkingDirectory { + pub path: String, + pub read_only: bool, +} + +/// Permission rules grouped by origin (settings layer, session, etc.). +pub type ToolPermissionRulesBySource = HashMap>; diff --git a/crates/cc-utils/Cargo.toml b/crates/cc-utils/Cargo.toml new file mode 100644 index 00000000..3d6ce8cd --- /dev/null +++ b/crates/cc-utils/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "cc-utils" +version = "0.1.0" +edition = "2021" +description = "Workspace-shared utilities: bash exec, file state, git ops, cwd, messages, tokens" +build = "build.rs" + +[dependencies] +anyhow = { workspace = true } +tokio = { workspace = true } +parking_lot = { workspace = true } +regex = { workspace = true } +lru = { workspace = true } +git2 = { workspace = true } +shell-words = { workspace = true } +serde_json = { workspace = true } + +cc-types = { workspace = true } +cc-config = { workspace = true } + +[dev-dependencies] +chrono = { workspace = true } +uuid = { workspace = true } diff --git a/crates/cc-utils/build.rs b/crates/cc-utils/build.rs new file mode 100644 index 00000000..f1bfc01a --- /dev/null +++ b/crates/cc-utils/build.rs @@ -0,0 +1,14 @@ +//! Build script — link platform libs needed by transitive deps. +//! +//! libgit2-sys (via our `git2` dep) uses Windows CryptoAPI hash functions +//! that live in `advapi32.dll`. The libgit2-sys build script emits the +//! link directive, but rust-lld on MSVC only picks it up for the final +//! binary, not for test binaries built from leaf crates. Emitting the +//! directive here guarantees cc-utils' own test binary links cleanly +//! without depending on a heavier parent crate to pull Advapi32 in. + +fn main() { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { + println!("cargo:rustc-link-lib=dylib=advapi32"); + } +} diff --git a/crates/claude-code-rs/src/utils/abort.rs b/crates/cc-utils/src/abort.rs similarity index 100% rename from crates/claude-code-rs/src/utils/abort.rs rename to crates/cc-utils/src/abort.rs diff --git a/crates/claude-code-rs/src/utils/bash.rs b/crates/cc-utils/src/bash.rs similarity index 99% rename from crates/claude-code-rs/src/utils/bash.rs rename to crates/cc-utils/src/bash.rs index 259590d2..8938666d 100644 --- a/crates/claude-code-rs/src/utils/bash.rs +++ b/crates/cc-utils/src/bash.rs @@ -13,7 +13,7 @@ use anyhow::{Context, Result}; use regex::Regex; use std::sync::LazyLock; -use crate::config::constants::bash::{default_timeout, max_timeout, MAX_COMMAND_LENGTH}; +use cc_config::constants::bash::{default_timeout, max_timeout, MAX_COMMAND_LENGTH}; // ============================================================================= // Command parsing diff --git a/crates/claude-code-rs/src/utils/cwd.rs b/crates/cc-utils/src/cwd.rs similarity index 100% rename from crates/claude-code-rs/src/utils/cwd.rs rename to crates/cc-utils/src/cwd.rs diff --git a/crates/claude-code-rs/src/utils/file_state_cache.rs b/crates/cc-utils/src/file_state_cache.rs similarity index 100% rename from crates/claude-code-rs/src/utils/file_state_cache.rs rename to crates/cc-utils/src/file_state_cache.rs diff --git a/crates/claude-code-rs/src/utils/git.rs b/crates/cc-utils/src/git.rs similarity index 100% rename from crates/claude-code-rs/src/utils/git.rs rename to crates/cc-utils/src/git.rs diff --git a/crates/cc-utils/src/lib.rs b/crates/cc-utils/src/lib.rs new file mode 100644 index 00000000..a8935337 --- /dev/null +++ b/crates/cc-utils/src/lib.rs @@ -0,0 +1,13 @@ +//! Shared utilities extracted from the root crate in Phase 3 (issue #72). +//! +//! These modules are used throughout the codebase but have narrow deps: +//! only `cc-types` (message types) and `cc-config` (bash constants). + +pub mod abort; +pub mod bash; +pub mod cwd; +pub mod file_state_cache; +pub mod git; +pub mod messages; +pub mod shell; +pub mod tokens; diff --git a/crates/claude-code-rs/src/utils/messages.rs b/crates/cc-utils/src/messages.rs similarity index 99% rename from crates/claude-code-rs/src/utils/messages.rs rename to crates/cc-utils/src/messages.rs index 596ff47d..092fc4dc 100644 --- a/crates/claude-code-rs/src/utils/messages.rs +++ b/crates/cc-utils/src/messages.rs @@ -3,7 +3,7 @@ /// 消息工具函数 /// /// 对应 TypeScript 中散布在多处的消息处理辅助函数 -use crate::types::message::{ContentBlock, Message, MessageContent}; +use cc_types::message::{ContentBlock, Message, MessageContent}; /// 从消息中提取纯文本内容 pub fn get_text_content(message: &Message) -> String { @@ -277,7 +277,7 @@ pub fn conversation_summary(messages: &[Message]) -> String { #[cfg(test)] mod tests { use super::*; - use crate::types::message::{AssistantMessage, MessageContent, UserMessage}; + use cc_types::message::{AssistantMessage, MessageContent, UserMessage}; use chrono::Utc; use uuid::Uuid; diff --git a/crates/claude-code-rs/src/utils/shell.rs b/crates/cc-utils/src/shell.rs similarity index 100% rename from crates/claude-code-rs/src/utils/shell.rs rename to crates/cc-utils/src/shell.rs diff --git a/crates/claude-code-rs/src/utils/tokens.rs b/crates/cc-utils/src/tokens.rs similarity index 97% rename from crates/claude-code-rs/src/utils/tokens.rs rename to crates/cc-utils/src/tokens.rs index 9f37b605..7b17939f 100644 --- a/crates/claude-code-rs/src/utils/tokens.rs +++ b/crates/cc-utils/src/tokens.rs @@ -1,6 +1,6 @@ #![allow(unused)] -use crate::types::message::{ContentBlock, Message, MessageContent, ToolResultContent}; +use cc_types::message::{ContentBlock, Message, MessageContent, ToolResultContent}; /// Get the context window size for a given model. /// Returns token count for the model's context window. @@ -117,7 +117,7 @@ pub fn is_over_token_limit(messages: &[Message], model: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::types::message::{Message, MessageContent, UserMessage}; + use cc_types::message::{Message, MessageContent, UserMessage}; use uuid::Uuid; fn make_user_message(text: &str, is_meta: bool) -> Message { diff --git a/crates/claude-code-rs/Cargo.toml b/crates/claude-code-rs/Cargo.toml index 30211aa0..1cbbd50e 100644 --- a/crates/claude-code-rs/Cargo.toml +++ b/crates/claude-code-rs/Cargo.toml @@ -131,13 +131,23 @@ dotenvy = { workspace = true } # 同步原语 parking_lot = { workspace = true } -# 内部 workspace crates (P1/P2) +# 内部 workspace crates (P1/P2/P3) cc-keybindings = { workspace = true } cc-observability = { workspace = true } cc-types = { workspace = true } cc-bootstrap = { workspace = true } cc-auth = { workspace = true } cc-skills = { workspace = true } +cc-config = { workspace = true } +cc-utils = { workspace = true } +cc-mcp = { workspace = true } +cc-services = { workspace = true } +cc-computer-use = { workspace = true } +cc-compact = { workspace = true } +cc-sandbox = { workspace = true } +cc-permissions = { workspace = true } +cc-browser = { workspace = true } +cc-session = { workspace = true } # Daemon axum = { workspace = true } diff --git a/crates/claude-code-rs/src/browser/detection.rs b/crates/claude-code-rs/src/browser/detection.rs index 760467ed..61e5b69f 100644 --- a/crates/claude-code-rs/src/browser/detection.rs +++ b/crates/claude-code-rs/src/browser/detection.rs @@ -20,115 +20,18 @@ use std::sync::Arc; use crate::mcp::McpServerConfig; use crate::types::tool::Tool; -/// MCP tool-name prefix for all MCP-wrapped tools. -pub(crate) const MCP_PREFIX: &str = "mcp__"; - -/// Known browser-automation tool basenames. -/// -/// Intentionally generous — different servers use different names for -/// essentially the same actions (`click` vs `browser_click`, `navigate` -/// vs `goto`). We match on basename only, so the leading `mcp__{server}__` -/// has already been stripped. -pub(crate) const BROWSER_TOOL_BASENAMES: &[&str] = &[ - // Navigation / tabs - "navigate", - "navigate_page", - "goto", - "tabs_create", - "tabs_create_mcp", - "tabs_close", - "tabs_close_mcp", - "tabs_context", - "tabs_context_mcp", - "new_page", - "close_page", - "switch_browser", - "select_page", - "list_pages", - // Page reading - "read_page", - "get_page_text", - "take_snapshot", - "snapshot", - "get_page", - // DOM / element interaction - "click", - "browser_click", - "double_click", - "hover", - "drag", - "press_key", - "type_text", - "fill", - "fill_form", - "form_input", - "select", - // File upload - "upload_file", - "file_upload", - // JavaScript execution - "evaluate_script", - "javascript_tool", - "evaluate", - // Console / network observability - "get_console_message", - "list_console_messages", - "read_console_messages", - "get_network_request", - "list_network_requests", - "read_network_requests", - // Screenshots / visual - "take_screenshot", - "screenshot", - // Misc - "wait_for", - "find", - "resize_page", - "resize_window", - "emulate", - "handle_dialog", -]; - -// --------------------------------------------------------------------------- -// Process-wide registry of browser server names -// --------------------------------------------------------------------------- -// -// Populated once at startup after MCP discovery + tool registration, then -// consulted by: -// - the system-prompt assembler (to decide whether to inject the browser -// section and which servers to mention) -// - the permission decision path (so "Allow clicking on the page?" can -// replace "Allow tool 'mcp__chrome__click'?" even for servers whose -// action basename isn't in `BROWSER_TOOL_BASENAMES`). -// - `/mcp list` (to tag browser servers distinctly). -// -// A global is justified here because the permission layer is called deep in -// the tool dispatch with no natural way to thread server metadata through. - -static BROWSER_SERVERS: parking_lot::RwLock>> = - parking_lot::RwLock::new(None); - -/// Install the set of browser MCP server names for the rest of the process. -/// -/// Call this once after MCP discovery + tool registration. Subsequent calls -/// overwrite the registry. -pub fn install_browser_servers(servers: HashSet) { - *BROWSER_SERVERS.write() = Some(servers); -} - -/// Snapshot the current set of browser server names (empty if not installed). -pub fn browser_servers_snapshot() -> HashSet { - BROWSER_SERVERS.read().clone().unwrap_or_default() -} - -/// Check whether a server name is registered as a browser server. Consults the -/// global registry and falls back to `false` if the registry was never installed. -pub fn is_browser_server(name: &str) -> bool { - match BROWSER_SERVERS.read().as_ref() { - Some(set) => set.contains(name), - None => false, - } -} +// Phase 4 (issue #73) moved the pure-parsing and server-registry helpers +// into `cc_browser::detection`. Re-export them here so every +// `crate::browser::detection::{MCP_PREFIX, BROWSER_TOOL_BASENAMES, +// extract_browser_action, install_browser_servers, +// browser_servers_snapshot, is_browser_server}` call site keeps +// resolving unchanged. +pub use cc_browser::detection::{ + browser_servers_snapshot, extract_browser_action, install_browser_servers, is_browser_server, + BROWSER_TOOL_BASENAMES, MCP_PREFIX, +}; +#[cfg(test)] +use cc_browser::detection::clear_browser_servers_for_tests; /// Metadata for a tool that was classified as a browser MCP tool. #[derive(Debug, Clone)] @@ -141,18 +44,6 @@ pub struct BrowserToolInfo { pub action: String, } -/// Parse an MCP tool name into `(server, action)` if the action matches a -/// recognized browser basename. -pub fn extract_browser_action(tool_name: &str) -> Option<(&str, &str)> { - let rest = tool_name.strip_prefix(MCP_PREFIX)?; - let (server, action) = rest.split_once("__")?; - if BROWSER_TOOL_BASENAMES.contains(&action) { - Some((server, action)) - } else { - None - } -} - /// Detect the set of browser MCP server names given registered tools and /// server configs. /// @@ -219,11 +110,6 @@ pub fn detect_browser_tools( // Tests // --------------------------------------------------------------------------- -#[cfg(test)] -pub(crate) fn clear_browser_servers_for_tests() { - *BROWSER_SERVERS.write() = None; -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/claude-code-rs/src/browser/mod.rs b/crates/claude-code-rs/src/browser/mod.rs index 9b9b410b..fc695b03 100644 --- a/crates/claude-code-rs/src/browser/mod.rs +++ b/crates/claude-code-rs/src/browser/mod.rs @@ -14,25 +14,20 @@ //! Both paths feed into the same downstream UX (prompt, permissions, //! rendering), so they're grouped here rather than in separate crates. //! -//! Module layout: -//! -//! - `detection` — heuristics for recognizing browser MCP tools (both paths). -//! - `permissions` — category / risk classification for permission prompts. -//! - `prompt` — `# Browser Automation` system-prompt section. -//! - `tool_rendering` — one-line previews for browser tool results. -//! - `common` — cross-platform Chromium browser paths + constants (#4+#5). -//! - `state` — runtime state for the first-party Chrome subsystem (#4+#5). -//! - `setup` — extension detection + native host manifest install (#4+#5). -//! - `session` — `ChromeSession` lifecycle (#4; transport lives in #5). +//! Phase 4 (issue #73) moved the parts of this module that did not touch +//! the `Tool` trait into the `cc-browser` workspace crate and re-exports +//! them here. `detection` and `prompt` still live locally because they +//! accept `Arc` — unblocked once the Tool trait leaves the root +//! crate (Phase 5 hub-cycle break). + +pub use cc_browser::{ + common, mcp_bridge, native_host, permissions, session, state, tool_rendering, +}; +// `setup` and `transport` are consumed by the CLI + integration tests via the +// full path `cc_browser::{setup,transport}::…`. Re-export them under the +// legacy `crate::browser::` names so any lingering call sites keep compiling. +#[allow(unused_imports)] +pub use cc_browser::{setup, transport}; -pub mod common; pub mod detection; -pub mod mcp_bridge; -pub mod native_host; -pub mod permissions; pub mod prompt; -pub mod session; -pub mod setup; -pub mod state; -pub mod tool_rendering; -pub mod transport; diff --git a/crates/claude-code-rs/src/commands/sandbox_cmd.rs b/crates/claude-code-rs/src/commands/sandbox_cmd.rs index b8079da4..3f48c04a 100644 --- a/crates/claude-code-rs/src/commands/sandbox_cmd.rs +++ b/crates/claude-code-rs/src/commands/sandbox_cmd.rs @@ -123,7 +123,12 @@ impl CommandHandler for SandboxHandler { /// Render a multi-line status block for display in the REPL. fn render_status(ctx: &CommandContext) -> String { - let policy = policy_from_app_state(&ctx.app_state, ctx.cwd.clone(), false); + let policy = policy_from_app_state( + &ctx.app_state.tool_permission_context, + &ctx.app_state.settings.sandbox, + ctx.cwd.clone(), + false, + ); let mut out = String::new(); out.push_str("Sandbox status\n"); out.push_str("──────────────\n"); diff --git a/crates/claude-code-rs/src/compact/mod.rs b/crates/claude-code-rs/src/compact/mod.rs deleted file mode 100644 index c0d9d8e0..00000000 --- a/crates/claude-code-rs/src/compact/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod auto_compact; -pub mod compaction; -pub mod messages; -pub mod microcompact; -pub mod pipeline; -pub mod snip; -pub mod tool_result_budget; diff --git a/crates/claude-code-rs/src/computer_use/mod.rs b/crates/claude-code-rs/src/computer_use/mod.rs index 7aed741c..bd63b8ce 100644 --- a/crates/claude-code-rs/src/computer_use/mod.rs +++ b/crates/claude-code-rs/src/computer_use/mod.rs @@ -5,9 +5,14 @@ //! (`tools`), and CLI registration (`setup`). //! //! Reserved tool name prefix: `mcp__computer-use__*` +//! +//! Phase 3 (issue #72) moved the `input` and `screenshot` platform +//! submodules into the `cc-computer-use` workspace crate. Re-exporting them +//! here keeps every `crate::computer_use::{input,screenshot}::…` path +//! resolving for call sites in `detection`, `setup`, and `tools`. + +pub use cc_computer_use::{input, screenshot}; pub mod detection; -pub mod input; -pub mod screenshot; pub mod setup; pub mod tools; diff --git a/crates/claude-code-rs/src/config/mod.rs b/crates/claude-code-rs/src/config/mod.rs deleted file mode 100644 index 6f9e1da0..00000000 --- a/crates/claude-code-rs/src/config/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -// Phase 3: Configuration management -// -// Settings loading (local JSON, ~/.cc-rust/settings.json, project .cc-rust/settings.json) -// CLAUDE.md discovery and injection -// Environment variable overrides - -pub mod claude_md; -pub mod constants; -pub mod features; -pub mod paths; -pub mod settings; -pub mod validation; diff --git a/crates/claude-code-rs/src/engine/output_style.rs b/crates/claude-code-rs/src/engine/output_style.rs index 7dd8bee8..e6633b9e 100644 --- a/crates/claude-code-rs/src/engine/output_style.rs +++ b/crates/claude-code-rs/src/engine/output_style.rs @@ -42,9 +42,6 @@ impl OutputStyle { } } -/// Names of the built-in styles, exposed for `/config show` and tests. -pub const BUILT_IN_NAMES: &[&str] = &["default", "explanatory", "learning"]; - /// Resolve the named output style. /// /// Built-in names always win over custom files of the same name (so diff --git a/crates/claude-code-rs/src/ipc/runtime.rs b/crates/claude-code-rs/src/ipc/runtime.rs index 3e550aca..ee411b11 100644 --- a/crates/claude-code-rs/src/ipc/runtime.rs +++ b/crates/claude-code-rs/src/ipc/runtime.rs @@ -72,7 +72,6 @@ impl HeadlessRuntime { let event_bus = super::subsystem_events::SubsystemEventBus::new(); let mut event_rx = event_bus.subscribe(); crate::lsp_service::set_event_sender(event_bus.sender()); - crate::mcp::set_event_sender(event_bus.sender()); crate::plugins::set_event_sender(event_bus.sender()); // cc-skills lives in its own crate and no longer knows about // `SubsystemEvent`. Adapt its minimal event enum into ours here. @@ -87,6 +86,58 @@ impl HeadlessRuntime { }; let _ = skills_tx.send(adapted); }); + // cc-mcp is the same: adapt its minimal event enum into ours here. + let mcp_tx = event_bus.sender(); + cc_mcp::set_event_callback(move |e| { + let adapted = match e { + cc_mcp::McpSubsystemEvent::ServerStateChanged { + server_name, + state, + error, + } => super::subsystem_events::SubsystemEvent::Mcp( + super::subsystem_events::McpEvent::ServerStateChanged { + server_name, + state, + error, + }, + ), + cc_mcp::McpSubsystemEvent::ToolsDiscovered { server_name, tools } => { + super::subsystem_events::SubsystemEvent::Mcp( + super::subsystem_events::McpEvent::ToolsDiscovered { + server_name, + tools: tools + .into_iter() + .map(|t| super::subsystem_types::McpToolInfo { + name: t.tool_name, + description: Some(t.description), + }) + .collect(), + }, + ) + } + cc_mcp::McpSubsystemEvent::ResourcesDiscovered { + server_name, + resources, + } => super::subsystem_events::SubsystemEvent::Mcp( + super::subsystem_events::McpEvent::ResourcesDiscovered { + server_name, + resources: resources + .into_iter() + .map(|r| super::subsystem_types::McpResourceInfo { + uri: r.uri, + name: Some(r.name), + mime_type: r.mime_type, + }) + .collect(), + }, + ), + }; + let _ = mcp_tx.send(adapted); + }); + // Wire the plugin-contributed MCP discovery hook. Plugins return + // `crate::mcp::McpServerConfig`, which is re-exported from + // `cc_mcp::McpServerConfig`, so they are the same type. + cc_mcp::discovery::set_plugin_hook(|| crate::plugins::discover_plugin_mcp_servers()); // ── 2. Send Ready ──────────────────────────────────────────── let app_state = self.engine.app_state(); diff --git a/crates/claude-code-rs/src/main.rs b/crates/claude-code-rs/src/main.rs index 7c07a095..93fbc5a1 100644 --- a/crates/claude-code-rs/src/main.rs +++ b/crates/claude-code-rs/src/main.rs @@ -24,24 +24,34 @@ use cc_bootstrap as bootstrap; mod cli; mod commands; mod computer_use; -mod config; +// `config` lives in its own crate (`cc-config`). Re-alias at the crate root so +// existing `crate::config::...` paths continue to resolve. +use cc_config as config; mod engine; // `keybindings` lives in its own crate (`cc-keybindings`). Re-alias at the // crate root so existing `crate::keybindings::...` paths continue to resolve. use cc_keybindings as keybindings; -mod permissions; +// `permissions` lives in its own crate (`cc-permissions`). +use cc_permissions as permissions; mod query; -mod sandbox; -mod session; +// `sandbox` lives in its own crate (`cc-sandbox`). Re-alias at the crate +// root so existing `crate::sandbox::...` paths continue to resolve. +use cc_sandbox as sandbox; +// `session` lives in its own crate (`cc-session`). +use cc_session as session; mod startup; mod tools; mod types; mod ui; -mod utils; +// `utils` lives in its own crate (`cc-utils`). Re-alias at the crate root so +// existing `crate::utils::...` paths continue to resolve. +use cc_utils as utils; mod voice; -// Context compaction pipeline -mod compact; +// Context compaction pipeline — lives in its own crate (`cc-compact`). +// Re-alias at the crate root so existing `crate::compact::...` paths continue +// to resolve. +use cc_compact as compact; // Network / API / auth. `auth` lives in its own crate (`cc-auth`); re-alias // so existing `crate::auth::...` paths continue to resolve. @@ -142,6 +152,56 @@ fn main() -> ExitCode { // directly. Register once, before any fast path might hit OAuth resolution. cc_auth::set_credentials_path(crate::config::paths::credentials_path()); + // Wire cc-permissions' descriptive-prompt callbacks. cc-permissions moved + // out of the root crate in Phase 4 (issue #73); the Computer Use and + // browser prompt strings still live here, so we register look-ups. + cc_permissions::decision::set_cu_message_callback(|tool_name: &str| { + let action = crate::computer_use::detection::extract_cu_action(tool_name)?; + let risk = crate::computer_use::detection::classify_risk(action); + let risk_tag = match risk { + crate::computer_use::detection::CuRiskLevel::Medium => "[medium risk]", + crate::computer_use::detection::CuRiskLevel::High => "[HIGH RISK]", + }; + let description = match action { + "screenshot" => "read the screen (take a screenshot)", + "cursor_position" => "read the current cursor position", + "left_click" => "click the left mouse button on your screen", + "right_click" => "click the right mouse button on your screen", + "middle_click" => "click the middle mouse button on your screen", + "double_click" => "double-click the mouse on your screen", + "type_text" | "type" => "type text using the keyboard", + "key" => "press a keyboard shortcut", + "scroll" => "scroll the mouse wheel", + "mouse_move" => "move the mouse cursor", + _ => { + return Some(format!( + "Allow desktop control action '{}' {}?", + action, risk_tag + )) + } + }; + Some(format!("Allow {} {}?", description, risk_tag)) + }); + cc_permissions::decision::set_browser_message_callback(|tool_name: &str| { + if let Some(m) = crate::browser::permissions::browser_permission_message(tool_name) { + return Some(m); + } + if let Some(rest) = tool_name.strip_prefix("mcp__") { + if let Some((server, action)) = rest.split_once("__") { + if crate::browser::detection::is_browser_server(server) { + let cat = crate::browser::permissions::classify_browser_action(action); + return Some(format!( + "Allow browser action '{}' via MCP server '{}' {}?", + action, + server, + cat.risk_tag() + )); + } + } + } + None + }); + // Phase A: parse args first so fast paths can exit immediately let cli = Cli::parse(); diff --git a/crates/claude-code-rs/src/mcp/mod.rs b/crates/claude-code-rs/src/mcp/mod.rs index 0ff36d55..81b76af1 100644 --- a/crates/claude-code-rs/src/mcp/mod.rs +++ b/crates/claude-code-rs/src/mcp/mod.rs @@ -1,455 +1,17 @@ -//! MCP (Model Context Protocol) — JSON-RPC 2.0 based protocol for external -//! tool servers. +//! MCP (Model Context Protocol) — thin facade over `cc-mcp`. //! -//! MCP allows Claude Code to communicate with external tool servers via: -//! - **stdio**: spawn a subprocess, communicate via stdin/stdout (primary) -//! - **sse**: HTTP Server-Sent Events (requires network feature) +//! Phase 3 (issue #72) moved all protocol / client / transport / discovery / +//! manager code into the `cc-mcp` workspace crate. The only piece that +//! remains here is [`tools`] — the adapter that exposes MCP tools through +//! the root crate's `Tool` trait — because `Tool` (and its +//! `ToolUseContext`) still lives in the root crate and will only move out +//! once Phase 5 breaks the hub cycles. //! -//! Protocol specification: https://modelcontextprotocol.io/specification/2025-03-26/ +//! The `pub use cc_mcp::*;` re-export preserves every historical +//! `crate::mcp::...` path: `crate::mcp::client::McpClient`, +//! `crate::mcp::discovery::discover_mcp_servers`, etc. continue to resolve +//! unchanged. -pub mod channel; -pub mod client; -pub mod discovery; -pub mod manager; -pub mod tools; -pub mod transport; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::HashMap; - -// --------------------------------------------------------------------------- -// Protocol constants -// --------------------------------------------------------------------------- - -/// MCP protocol version we advertise during initialization. -pub const PROTOCOL_VERSION: &str = "2024-11-05"; - -/// Client name sent during initialization. -pub const CLIENT_NAME: &str = "claude-code-rs"; - -/// Client version sent during initialization. -pub const CLIENT_VERSION: &str = "0.1.0"; - -/// Default connection/initialize timeout (seconds). -pub const CONNECT_TIMEOUT_SECS: u64 = 30; - -/// Default tool call timeout (seconds). Very generous — MCP tools can be slow. -pub const TOOL_CALL_TIMEOUT_SECS: u64 = 300; - -// --------------------------------------------------------------------------- -// Subsystem event emission -// --------------------------------------------------------------------------- - -use parking_lot::Mutex as SyncMutex; -use std::sync::LazyLock; - -/// Event sender for subsystem events. -static EVENT_TX: LazyLock< - SyncMutex>>, -> = LazyLock::new(|| SyncMutex::new(None)); - -/// Inject the event sender from the headless event loop. -#[allow(dead_code)] // Called by headless event loop wiring (Task 12). -pub fn set_event_sender( - tx: tokio::sync::broadcast::Sender, -) { - *EVENT_TX.lock() = Some(tx); -} - -/// Emit a subsystem event. -pub(crate) fn emit_event(event: crate::ipc::subsystem_events::SubsystemEvent) { - if let Some(tx) = EVENT_TX.lock().as_ref() { - let _ = tx.send(event); - } -} - -// --------------------------------------------------------------------------- -// Connection state -// --------------------------------------------------------------------------- - -/// MCP server connection state. -#[derive(Debug, Clone, PartialEq)] -pub enum McpConnectionState { - /// Not yet connected. - Pending, - /// Connection established and initialized. - Connected, - /// Disconnected (graceful or after error). - Disconnected, - /// Connection failed with an error. - #[allow(dead_code)] - Error(String), -} - -// --------------------------------------------------------------------------- -// Server configuration (from settings.json) -// --------------------------------------------------------------------------- - -/// MCP server configuration (from settings.json `mcpServers` key). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct McpServerConfig { - /// Server name (the key in the mcpServers map). - #[serde(default)] - pub name: String, - /// Transport type: "stdio" (default) or "sse". - #[serde(rename = "type", default = "default_transport")] - pub transport: String, - /// Command to launch (for stdio transport). - pub command: Option, - /// Command arguments (for stdio transport). - #[serde(default)] - pub args: Option>, - /// URL (for SSE transport). - pub url: Option, - /// Additional HTTP headers (for SSE transport). - pub headers: Option>, - /// Environment variables to set for the subprocess. - pub env: Option>, - /// Opt-in flag: treat every tool from this server as a browser MCP tool - /// (enables the `# Browser Automation` system-prompt section, category-aware - /// permission prompts, and browser result rendering). When absent, the - /// engine falls back to a tool-name heuristic. See `src/browser/detection.rs`. - #[serde(default, rename = "browserMcp")] - pub browser_mcp: Option, -} - -fn default_transport() -> String { - "stdio".to_string() -} - -// --------------------------------------------------------------------------- -// MCP tool / resource definitions (received from server) -// --------------------------------------------------------------------------- - -/// Tool definition received from an MCP server via `tools/list`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct McpToolDef { - /// Tool name. - pub name: String, - /// Human-readable description. - #[serde(default)] - pub description: String, - /// JSON Schema for the tool's input parameters. - #[serde(default = "default_schema", rename = "inputSchema")] - pub input_schema: Value, - /// Name of the server that provides this tool (set client-side). - #[serde(default)] - pub server_name: String, -} - -fn default_schema() -> Value { - serde_json::json!({"type": "object", "properties": {}}) -} - -/// Resource definition received from an MCP server via `resources/list`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct McpResource { - /// Resource URI. - pub uri: String, - /// Human-readable name. - #[serde(default)] - pub name: String, - /// Description. - pub description: Option, - /// MIME type. - #[serde(rename = "mimeType")] - pub mime_type: Option, -} - -/// Content returned from `resources/read`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct McpResourceContent { - pub uri: String, - #[serde(rename = "mimeType")] - pub mime_type: Option, - /// Text content (mutually exclusive with blob). - pub text: Option, - /// Base64-encoded binary content. - pub blob: Option, -} - -// --------------------------------------------------------------------------- -// JSON-RPC 2.0 protocol types -// --------------------------------------------------------------------------- - -/// JSON-RPC 2.0 request. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JsonRpcRequest { - pub jsonrpc: String, - pub id: Value, - pub method: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub params: Option, -} - -/// JSON-RPC 2.0 notification (no id, no response expected). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JsonRpcNotification { - pub jsonrpc: String, - pub method: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub params: Option, -} +pub use cc_mcp::*; -/// JSON-RPC 2.0 response (success or error). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JsonRpcResponse { - pub jsonrpc: String, - pub id: Value, - #[serde(skip_serializing_if = "Option::is_none")] - pub result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -/// JSON-RPC 2.0 error object. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JsonRpcError { - pub code: i64, - pub message: String, - pub data: Option, -} - -impl JsonRpcRequest { - /// Create a new JSON-RPC request. - pub fn new(id: u64, method: &str, params: Option) -> Self { - Self { - jsonrpc: "2.0".to_string(), - id: Value::Number(id.into()), - method: method.to_string(), - params, - } - } -} - -impl JsonRpcNotification { - /// Create a new JSON-RPC notification. - pub fn new(method: &str, params: Option) -> Self { - Self { - jsonrpc: "2.0".to_string(), - method: method.to_string(), - params, - } - } -} - -// --------------------------------------------------------------------------- -// MCP-specific request/response payloads -// --------------------------------------------------------------------------- - -/// Server capabilities received during initialization. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct ServerCapabilities { - /// Whether the server supports tools. - pub tools: Option, - /// Whether the server supports resources. - pub resources: Option, - /// Whether the server supports prompts. - pub prompts: Option, -} - -/// Server info received during initialization. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct ServerInfo { - pub name: String, - #[serde(default)] - pub version: String, -} - -/// Result of the `initialize` handshake. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InitializeResult { - pub protocol_version: String, - #[serde(default)] - pub capabilities: ServerCapabilities, - #[serde(default)] - pub server_info: ServerInfo, - pub instructions: Option, -} - -/// Result of `tools/list`. -#[derive(Debug, Clone, Deserialize)] -pub struct ListToolsResult { - pub tools: Vec, -} - -/// Result of `tools/call`. -#[derive(Debug, Clone, Deserialize)] -pub struct CallToolResult { - pub content: Vec, - #[serde(default, rename = "isError")] - pub is_error: bool, -} - -/// Content block in a tool call result. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum ToolCallContent { - #[serde(rename = "text")] - Text { text: String }, - #[serde(rename = "image")] - Image { - data: String, - #[serde(rename = "mimeType")] - mime_type: String, - }, - #[serde(rename = "resource")] - Resource { resource: McpResourceContent }, -} - -/// Result of `resources/list`. -#[derive(Debug, Clone, Deserialize)] -pub struct ListResourcesResult { - pub resources: Vec, -} - -/// Result of `resources/read`. -#[allow(dead_code)] -#[derive(Debug, Clone, Deserialize)] -pub struct ReadResourceResult { - pub contents: Vec, -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn test_jsonrpc_request_serialization() { - let req = JsonRpcRequest::new(1, "tools/list", None); - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["jsonrpc"], "2.0"); - assert_eq!(json["id"], 1); - assert_eq!(json["method"], "tools/list"); - assert!(json.get("params").is_none()); - } - - #[test] - fn test_jsonrpc_request_with_params() { - let req = JsonRpcRequest::new( - 2, - "tools/call", - Some(json!({"name": "search", "arguments": {"query": "test"}})), - ); - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["method"], "tools/call"); - assert_eq!(json["params"]["name"], "search"); - } - - #[test] - fn test_jsonrpc_notification_serialization() { - let notif = JsonRpcNotification::new("notifications/initialized", None); - let json = serde_json::to_value(¬if).unwrap(); - assert_eq!(json["jsonrpc"], "2.0"); - assert_eq!(json["method"], "notifications/initialized"); - assert!(json.get("id").is_none()); - } - - #[test] - fn test_jsonrpc_response_deserialization_success() { - let json = json!({ - "jsonrpc": "2.0", - "id": 1, - "result": {"tools": []} - }); - let resp: JsonRpcResponse = serde_json::from_value(json).unwrap(); - assert!(resp.result.is_some()); - assert!(resp.error.is_none()); - } - - #[test] - fn test_jsonrpc_response_deserialization_error() { - let json = json!({ - "jsonrpc": "2.0", - "id": 1, - "error": {"code": -32600, "message": "Invalid Request"} - }); - let resp: JsonRpcResponse = serde_json::from_value(json).unwrap(); - assert!(resp.result.is_none()); - let err = resp.error.unwrap(); - assert_eq!(err.code, -32600); - assert_eq!(err.message, "Invalid Request"); - } - - #[test] - fn test_server_config_deserialization() { - let json = json!({ - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem"], - "env": {"HOME": "/tmp"} - }); - let config: McpServerConfig = serde_json::from_value(json).unwrap(); - assert_eq!(config.transport, "stdio"); - assert_eq!(config.command.unwrap(), "npx"); - assert_eq!( - config.args.unwrap(), - vec!["-y", "@modelcontextprotocol/server-filesystem"] - ); - } - - #[test] - fn test_tool_def_deserialization() { - let json = json!({ - "name": "read_file", - "description": "Read a file", - "inputSchema": { - "type": "object", - "properties": { - "path": {"type": "string"} - }, - "required": ["path"] - } - }); - let tool: McpToolDef = serde_json::from_value(json).unwrap(); - assert_eq!(tool.name, "read_file"); - assert_eq!(tool.description, "Read a file"); - } - - #[test] - fn test_call_tool_result_deserialization() { - let json = json!({ - "content": [ - {"type": "text", "text": "file contents here"} - ], - "isError": false - }); - let result: CallToolResult = serde_json::from_value(json).unwrap(); - assert!(!result.is_error); - assert_eq!(result.content.len(), 1); - match &result.content[0] { - ToolCallContent::Text { text } => assert_eq!(text, "file contents here"), - _ => panic!("expected text content"), - } - } - - #[test] - fn test_initialize_result_deserialization() { - let json = json!({ - "protocolVersion": "2024-11-05", - "capabilities": {"tools": {}, "resources": {}}, - "serverInfo": {"name": "test-server", "version": "1.0"} - }); - let result: InitializeResult = serde_json::from_value(json).unwrap(); - assert_eq!(result.protocol_version, "2024-11-05"); - assert_eq!(result.server_info.name, "test-server"); - } - - #[test] - fn test_resource_content_deserialization() { - let json = json!({ - "uri": "file:///tmp/test.txt", - "mimeType": "text/plain", - "text": "hello world" - }); - let content: McpResourceContent = serde_json::from_value(json).unwrap(); - assert_eq!(content.uri, "file:///tmp/test.txt"); - assert_eq!(content.text.unwrap(), "hello world"); - } -} +pub mod tools; diff --git a/crates/claude-code-rs/src/services/mod.rs b/crates/claude-code-rs/src/services/mod.rs index 926074cf..1555095f 100644 --- a/crates/claude-code-rs/src/services/mod.rs +++ b/crates/claude-code-rs/src/services/mod.rs @@ -1,11 +1,18 @@ //! Services module — background and utility services for cc-rust. //! -//! Provides tool-use summarization, session memory extraction, -//! prompt suggestions, and LSP server lifecycle management. +//! Most services have been moved into the `cc-services` workspace crate +//! (Phase 3, issue #72). The two exceptions reach into subsystems still in +//! the root crate and stay here until those move: +//! +//! - [`session_analytics`] — depends on `session::storage` +//! (unblocked by Phase 4, issue #73). +//! - [`langfuse`] — depends on `types::tool::Tools` +//! (unblocked by Phase 5 hub-cycle break). +//! +//! The `pub use cc_services::*;` re-export keeps every historical +//! `crate::services::...` path working. + +pub use cc_services::*; pub mod langfuse; -pub mod lsp_lifecycle; -pub mod prompt_suggestion; pub mod session_analytics; -pub mod session_memory; -pub mod tool_use_summary; diff --git a/crates/claude-code-rs/src/session/mod.rs b/crates/claude-code-rs/src/session/mod.rs deleted file mode 100644 index 97388c57..00000000 --- a/crates/claude-code-rs/src/session/mod.rs +++ /dev/null @@ -1,16 +0,0 @@ -// Phase 6: Session persistence -// -// Session storage: save/load conversation state to ~/.cc-rust/sessions/ -// Transcript recording: append-friendly write for audit trail -// Session resume: find and restore the most recent session -// Migrations: session data format versioning and migration -// Memdir: CLAUDE.md-based memory system - -pub mod audit_export; -pub mod export; -pub mod memdir; -pub mod migrations; -pub mod resume; -pub mod session_export; -pub mod storage; -pub mod transcript; diff --git a/crates/claude-code-rs/src/tools/exec/bash.rs b/crates/claude-code-rs/src/tools/exec/bash.rs index 8b315d4a..159a3be4 100644 --- a/crates/claude-code-rs/src/tools/exec/bash.rs +++ b/crates/claude-code-rs/src/tools/exec/bash.rs @@ -290,7 +290,12 @@ impl Tool for BashTool { .unwrap_or(false); let app_state_arc = (ctx.get_app_state)(); let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); - let policy = policy_from_app_state(&app_state_arc, cwd.clone(), false); + let policy = policy_from_app_state( + &app_state_arc.tool_permission_context, + &app_state_arc.settings.sandbox, + cwd.clone(), + false, + ); if let Err(err) = preflight_shell_command(&policy, &command) { return Ok(ToolResult { diff --git a/crates/claude-code-rs/src/tools/exec/powershell.rs b/crates/claude-code-rs/src/tools/exec/powershell.rs index da77c724..d1107418 100644 --- a/crates/claude-code-rs/src/tools/exec/powershell.rs +++ b/crates/claude-code-rs/src/tools/exec/powershell.rs @@ -179,7 +179,12 @@ impl Tool for PowerShellTool { .unwrap_or(false); let app_state_arc = (ctx.get_app_state)(); let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); - let policy = policy_from_app_state(&app_state_arc, cwd.clone(), false); + let policy = policy_from_app_state( + &app_state_arc.tool_permission_context, + &app_state_arc.settings.sandbox, + cwd.clone(), + false, + ); if let Err(err) = preflight_shell_command(&policy, &command) { return Ok(ToolResult { diff --git a/crates/claude-code-rs/src/tools/web_fetch.rs b/crates/claude-code-rs/src/tools/web_fetch.rs index 6dc410f1..02d7859e 100644 --- a/crates/claude-code-rs/src/tools/web_fetch.rs +++ b/crates/claude-code-rs/src/tools/web_fetch.rs @@ -291,7 +291,12 @@ impl Tool for WebFetchTool { // stale-but-allowed content). let app_state = (ctx.get_app_state)(); let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); - let policy = policy_from_app_state(&app_state, cwd, false); + let policy = policy_from_app_state( + &app_state.tool_permission_context, + &app_state.settings.sandbox, + cwd, + false, + ); if let NetworkDecision::Denied(err) = policy.network.check_url(&url) { return Ok(ToolResult { data: json!({ diff --git a/crates/claude-code-rs/src/types/app_state.rs b/crates/claude-code-rs/src/types/app_state.rs index c36d8d3b..548abe0c 100644 --- a/crates/claude-code-rs/src/types/app_state.rs +++ b/crates/claude-code-rs/src/types/app_state.rs @@ -1,6 +1,12 @@ use super::tool::{PermissionMode, ToolPermissionContext}; use std::collections::HashMap; +/// Runtime settings projection — moved to `cc-config` in Phase 3 (issue #72). +/// +/// Re-exported here so existing `crate::types::app_state::SettingsJson` +/// call sites keep compiling. +pub use cc_config::runtime_settings::SettingsJson; + /// 应用全局状态 (简化版) /// /// 对应 TypeScript: state/AppState.ts @@ -51,50 +57,6 @@ pub struct AppState { pub status_line_runner: crate::ui::status_line::StatusLineRunner, } -/// 设置 JSON (运行时投影) -/// -/// 这是 [`crate::config::settings::EffectiveSettings`] 的运行时镜像 —— -/// 启动路径在 `main.rs` 用合并后的 effective settings 填充本结构, -/// 命令(如 `/config set`) 写回这里, 序列化时再回到 RawSettings。 -#[derive(Debug, Clone, Default)] -pub struct SettingsJson { - // -- Core identity -------------------------------------------------- - pub model: Option, - pub backend: Option, - pub theme: Option, - pub verbose: Option, - - // -- Permissions / sandbox ----------------------------------------- - pub permission_mode: Option, - pub permissions: crate::config::settings::PermissionsSettings, - pub sandbox: crate::config::settings::SandboxSettings, - - // -- UI / UX -------------------------------------------------------- - pub status_line: crate::config::settings::StatusLineSettings, - pub spinner_tips: crate::config::settings::SpinnerTipsSettings, - pub output_style: Option, - pub language: Option, - pub voice_enabled: Option, - pub editor_mode: Option, - pub view_mode: Option, - pub terminal_progress_bar_enabled: Option, - - // -- Models / effort ----------------------------------------------- - pub available_models: Vec, - pub effort_level: Option, - pub fast_mode: Option, - pub fast_mode_per_session_opt_in: Option, - - // -- Modes / integrations ------------------------------------------ - pub teammate_mode: Option, - pub claude_in_chrome_default_enabled: Option, - - // -- Per-key source (provenance) ----------------------------------- - /// 来源映射: key -> 哪个 layer 提供了该值。由启动路径 + `/config set` - /// 在写入对应键时一并更新。`/config show` 读取此 map 显示来源信息。 - pub sources: crate::config::settings::SourceMap, -} - impl Default for AppState { fn default() -> Self { Self { diff --git a/crates/claude-code-rs/src/types/tool.rs b/crates/claude-code-rs/src/types/tool.rs index f2ccd279..9dbf1204 100644 --- a/crates/claude-code-rs/src/types/tool.rs +++ b/crates/claude-code-rs/src/types/tool.rs @@ -84,106 +84,18 @@ pub struct ToolProgress { pub data: Value, } -/// 权限模式 -/// -/// 对齐 docs/claude-code-configuration/permissions.md 中描述的 6 种模式。 -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PermissionMode { - /// 默认/询问模式: 需要用户确认 - Default, - /// 自动模式: 自动批准 (带安全分类器) - Auto, - /// 绕过模式: 跳过所有权限检查 - Bypass, - /// 计划模式: 只读, 不执行写入 - Plan, - /// AcceptEdits: 文件编辑工具 (Write/Edit/MultiEdit) 与常用工作区 - /// 文件系统命令默认允许; 其它工具仍走默认询问流程。 - AcceptEdits, - /// DontAsk: 对所有需要询问的请求静默 deny。常用于 headless / CI - /// 场景, 避免阻塞。`deny` 规则仍优先生效。 - DontAsk, -} - -impl PermissionMode { - /// Parse a mode string. Accepts both kebab-case and camelCase tokens - /// from the permissions docs as well as the legacy lower-case forms. - /// Unknown / empty values fall back to [`PermissionMode::Default`]. - pub fn parse(value: &str) -> PermissionMode { - match value.trim().to_ascii_lowercase().as_str() { - "auto" => PermissionMode::Auto, - "bypass" | "bypasspermissions" | "bypass-permissions" => PermissionMode::Bypass, - "plan" | "readonly" | "read-only" => PermissionMode::Plan, - "acceptedits" | "accept-edits" | "accept_edits" => PermissionMode::AcceptEdits, - "dontask" | "dont-ask" | "dont_ask" | "no-ask" => PermissionMode::DontAsk, - _ => PermissionMode::Default, - } - } - - /// Stable lower-case identifier (camelCase) used for source-map tagging - /// and `/permissions show` output. - pub fn as_str(&self) -> &'static str { - match self { - PermissionMode::Default => "default", - PermissionMode::Auto => "auto", - PermissionMode::Bypass => "bypass", - PermissionMode::Plan => "plan", - PermissionMode::AcceptEdits => "acceptEdits", - PermissionMode::DontAsk => "dontAsk", - } - } -} - -/// 工具权限上下文 -#[derive(Debug, Clone)] -pub struct ToolPermissionContext { - pub mode: PermissionMode, - pub additional_working_directories: HashMap, - pub always_allow_rules: ToolPermissionRulesBySource, - pub always_deny_rules: ToolPermissionRulesBySource, - pub always_ask_rules: ToolPermissionRulesBySource, - /// Session-level allow grants (cleared on session end). - /// - /// Checked between `always_allow_rules` and mode fallback. - /// Used for Computer Use "always allow" to avoid permanent rules - /// for high-risk desktop control tools. - pub session_allow_rules: ToolPermissionRulesBySource, - pub is_bypass_permissions_mode_available: bool, - pub is_auto_mode_available: Option, - /// 计划模式之前的权限模式 (用于恢复) - pub pre_plan_mode: Option, -} - -impl ToolPermissionContext { - /// Add a session-level allow grant for a tool. - pub fn grant_session_allow(&mut self, tool_name: &str) { - self.session_allow_rules - .entry("session".into()) - .or_default() - .push(tool_name.to_string()); - } - - /// Check if a tool has a session-level allow grant. - pub fn has_session_grant(&self, tool_name: &str) -> bool { - self.session_allow_rules - .values() - .any(|rules| rules.iter().any(|r| r == tool_name)) - } - - /// Clear all session-level grants (called on session end). - pub fn clear_session_grants(&mut self) { - self.session_allow_rules.clear(); - } -} - -#[derive(Debug, Clone, Default)] -pub struct AdditionalWorkingDirectory { - pub path: String, - pub read_only: bool, -} - -/// 权限规则, 按来源分组 -pub type ToolPermissionRulesBySource = HashMap>; +// Permission-context types moved into `cc-types::permissions` in Phase 4 +// (issue #73) so workspace crates like cc-sandbox and cc-permissions can +// consult them without a reverse dep on the root crate. Re-exported here +// so existing `crate::types::tool::{PermissionMode, …}` paths resolve. +// `ToolPermissionRulesBySource` is a pub alias — re-export it too so any +// future consumer in the root crate can still reach it via the classic +// `crate::types::tool::` path. +pub use cc_types::permissions::{ + AdditionalWorkingDirectory, PermissionMode, ToolPermissionContext, +}; +#[allow(unused_imports)] +pub use cc_types::permissions::ToolPermissionRulesBySource; /// 文件状态缓存 (LRU, 追踪工具已读/已写的文件) #[derive(Debug, Clone, Default)] diff --git a/crates/claude-code-rs/src/utils/mod.rs b/crates/claude-code-rs/src/utils/mod.rs deleted file mode 100644 index 813ec368..00000000 --- a/crates/claude-code-rs/src/utils/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -pub mod abort; -pub mod bash; -pub mod cwd; -pub mod file_state_cache; -pub mod git; -pub mod messages; -pub mod shell; -pub mod tokens;