Compare commits

...
31 Commits
Author SHA1 Message Date
YeonGyu-Kim 08106b0c37 docs: add hierarchical AGENTS.md knowledge base
Root knowledge base plus complexity-scored subdirectory files for the
rust/ workspace, its five highest-mass crates (runtime, rusty-claude-cli,
api, tools, commands, plugins), and the src/ Python porting workspace.

Generated via init-deep: 13 parallel explore agents, LSP/ast-grep code
map, centrality-scored placement. Snapshot in .omo/init-deep.json (local).
2026-08-16 15:18:33 +09:00
YeonGyu-Kim b71afddae1 Merge pull request #3280 from Einspanner123/fix/sandbox-map-auto-fallback
fix(sandbox): fall back to --map-auto when root-user mapping is restricted
2026-08-06 20:18:35 +09:00
code-yeongyuandlinkst 525035b51b fix(sandbox): probe the full launcher shape, not just the mapping
The startup probe validated only the mapping flags against the trivial
program `true`, but the real launcher always adds
--mount --ipc --pid --uts --fork. On environments where the user
namespace is created but mount propagation inside it is restricted
(e.g. AppArmor-restricted CI runners), the fallback mapping passed the
probe and the sandbox activated, yet every sandboxed command died with
"cannot change root filesystem propagation: Permission denied",
silently returning empty tool output and breaking the mock parity
suite.

The candidates now define the complete static launcher shape (mapping
flags + namespace flags), so probe success implies launch success; the
launcher reuses the candidate instead of re-appending the namespace
flags, keeping probe and launch as one source of truth. The
order-guarding test asserts the namespace flags are present in every
candidate.

Co-authored-by: linkst <2024023709@m.scnu.edu.cn>
2026-08-06 19:41:27 +09:00
linkst 9cbe6d9a8c docs(sandbox): document newuidmap/newgidmap dependency for --map-auto fallback
The fallback candidate relies on the setuid newuidmap/newgidmap helpers
(uidmap package) plus a subuid/subgid range for the current user. Note in
the candidate docs that the startup probe rejects the candidate when those
are missing, so the plain --map-root-user form is used instead.
2026-08-01 15:21:56 +08:00
linkst 277fdda894 fix(sandbox): fall back to --map-auto when root-user mapping is restricted
Plain `unshare --user --map-root-user` fails on kernels and containers
that block unprivileged writes to /proc/self/uid_map (e.g. GitHub Actions,
restricted AppArmor profiles). On those systems util-linux delegates to the
setuid newuidmap/newgidmap helpers when --map-auto is also present.

Add the combined form as a fallback candidate and build the launcher args
from the probed mapping, so systems without newuidmap/newgidmap or a
/etc/subuid range keep using the plain form.
2026-07-31 17:09:00 +08:00
linkst 1c8338ea66 refactor(sandbox): extract unshare mapping probe into cached helper
No behavior change. Move the inline probe out of
unshare_user_namespace_works into a reusable unshare_probe helper and a
cached working_unshare_mapping() that picks the first working candidate
from UNSHARE_MAPPING_CANDIDATES, so the launcher and the capability probe
share one code path.
2026-07-31 17:08:51 +08:00
Sigrid Jin (ง'̀-'́)ง oO 4ea31c1bc9 Merge pull request #3253 from EmreCelenli/docs/mlx-compatibility
docs: document mlx-lm backend for Apple Silicon and known gotchas
2026-06-27 01:17:10 +09:00
Sigrid Jin (ง'̀-'́)ง oO 4e0cba76cc Merge pull request #3263 from hiisandog/fix/claw-small-cleanup-c6b25
Improve command lookup normalization
2026-06-27 01:16:51 +09:00
陈家名 5babda196b Improve command lookup normalization 2026-06-25 15:43:30 +08:00
EmreCelenli 3ae922508c docs: document mlx-lm backend and known model-resolution/EOS gotchas 2026-06-18 18:36:39 +02:00
YeonGyu-Kim d229a9b022 Merge pull request #3227 from TheArchitectit/worktree-wizard-entry-points
feat: wizard entry points — /setup command, claw setup subcommand (rebased)
2026-06-08 15:06:11 +09:00
YeonGyu-Kim 05f0201ec7 fix: preserve runtime config validation compatibility 2026-06-08 15:01:48 +09:00
YeonGyu-Kim 36f6afcafd Merge pull request #3230 from Gaurav-x111/contributor/example-change
docs: add interactive session example to quick start
2026-06-08 14:44:43 +09:00
YeonGyu-Kim 2e52ea7a67 Merge pull request #3237 from hiisandog/fix/redirection-path-scope-20260608
fix: validate attached redirection paths
2026-06-08 14:43:52 +09:00
陈家名 eb21179dde fix: validate attached redirection paths 2026-06-08 10:18:48 +08:00
YeonGyu-Kim 9b3548ca43 Merge pull request #3236 from ultraworkers/fix/ollama-qwen-reasoning-field
fix: parse Ollama reasoning fields
2026-06-08 10:11:52 +09:00
YeonGyu-Kim 01f4dd48a3 docs: mention local Ollama reasoning setup 2026-06-08 10:08:44 +09:00
YeonGyu-Kim 222faabd7f fix(cli): hint Ollama for Qwen tags 2026-06-08 10:08:38 +09:00
YeonGyu-Kim 7503c1c031 fix(providers): parse Ollama reasoning fields 2026-06-08 10:08:32 +09:00
YeonGyu-Kim 6001156a6c Merge pull request #3234 from ultraworkers/fix/openai-compatible-reasoning-history
fix(providers): preserve OpenAI-compatible reasoning history
2026-06-08 09:27:33 +09:00
YeonGyu-Kim a1da1ca8e6 test(cli): serialize env-sensitive model alias checks 2026-06-08 01:37:28 +09:00
YeonGyu-Kim 27acfe1014 test(runtime): isolate session and git metadata checks 2026-06-08 01:23:32 +09:00
YeonGyu-Kim c1646613d1 fix(providers): preserve OpenAI-compatible reasoning history 2026-06-08 01:23:13 +09:00
Sigrid Jin (ง'̀-'́)ง oO ae2f203eb5 Merge pull request #3232 from Ajinkya-Ghuge/fix/deepseek-model-routing 2026-06-07 18:40:11 +09:00
Ajinkya-Ghuge 0755ddff3c fix(providers): strip provider prefix from model names for openai_compat endpoints 2026-06-06 22:29:59 +05:30
Gaurav-x111andClaude Opus 4.8 db9ff49256 docs: add interactive session example to quick start
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 22:25:55 +05:45
Sigrid Jin (ง'̀-'́)ง oO 3acb677d70 Update README.md 2026-06-06 18:16:58 +09:00
Sigrid Jin (ง'̀-'́)ง oO 43eac8fbec Update README.md 2026-06-06 18:16:26 +09:00
Sigrid Jin (ง'̀-'́)ง oO c8505092f8 update readme 2026-06-06 18:14:04 +09:00
TheArchitectit d58197cca4 fix: update slash command count and add /setup assertion in test
- Update slash_command_specs().len() assertion from 139 to 140.
  The /setup command added by this PR increased the spec count by 1
  but the test's expected count was not updated, causing CI failure.

- Add assert!(help.contains("/setup")) to the
  renders_help_from_shared_specs test so the new command is
  verified in the help output.

  Fixes CI Build  and Test  on #3218.
2026-06-04 22:06:32 -05:00
Your NameandClaude Opus 4.8 3845040b9d feat: wizard entry points -- /setup command, claw setup subcommand, and RuntimeProviderConfig
The setup wizard was merged in PR #3017 but was orphaned -- it was not
declared as a module in main.rs, making it unreachable. Additionally,
the setup_wizard.rs imports RuntimeProviderConfig which did not exist
on upstream/main. This commit makes the wizard accessible and adds the
necessary RuntimeProviderConfig type.

Changes:
- Add RuntimeProviderConfig struct to runtime/src/config.rs with
  kind(), api_key(), base_url(), model() accessors.
- Add parse_optional_provider_config() to parse the provider object
  from merged settings JSON.
- Add provider() method to RuntimeConfig and RuntimeFeatureConfig.
- Export RuntimeProviderConfig, save_user_provider_settings,
  clear_user_provider_settings, and default_config_home from runtime
  crate public API (runtime/src/lib.rs).
- Add mod setup_wizard to rusty-claude-cli/src/main.rs.
- Add claw setup CLI subcommand.
- Add /setup slash command.
- Add Setup variant to SlashCommand enum.
- Add Setup to LocalHelpTopic enum.
- Add setup to diagnostic subcommand matching.
- Add subagentModel to TOP_LEVEL_FIELDS in config_validate.rs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:52:48 -05:00
28 changed files with 1233 additions and 124 deletions
+92
View File
@@ -0,0 +1,92 @@
# PROJECT KNOWLEDGE BASE
**Generated:** 2026-08-16
**Commit:** b71afdd
**Branch:** main
## OVERVIEW
Claw Code: public Rust implementation of the `claw` CLI agent harness (Claude-Code-style). Canonical code lives in `rust/`; the repo is an agent-managed exhibit (harnesses plan/execute/verify per README), not a hand-operated product. `src/` is a companion Python porting/parity workspace, not production code.
## STRUCTURE
```
claw-code/
├── rust/ # canonical Cargo workspace: 11 crates, `claw` binary
├── src/ # Python porting workspace + reference_data/ parity snapshots
├── tests/ # Python unittest validation of src/ + scripts/ (stdlib unittest)
├── docs/ # g0XX gate verification maps + topic docs
├── scripts/ # fmt.sh, dogfood-build.sh, roadmap/board helpers
├── assets/ # README images only
└── install.sh, Containerfile, docker-compose.yml
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| CLI subcommands | rust/crates/rusty-claude-cli/src/main.rs | hand-rolled parser; CliAction enum ~L1162; dispatch in run() L995-1158 |
| Sessions/permissions/MCP | rust/crates/runtime/src/ | 47 flat modules |
| Provider clients | rust/crates/api/src/providers/ | anthropic.rs + openai_compat.rs |
| Tool definitions | rust/crates/tools/src/lib.rs | 55-tool spec table L484-1348 |
| Slash commands | rust/crates/commands/src/lib.rs | 120+ spec table L60-1047 |
| Plugins/hooks | rust/crates/plugins/src/ | manifest = .claude-plugin/plugin.json |
| Lean agent harness | rust/crates/claw-analog/src/lib.rs | lib+bin; tool loop over api+runtime |
| RAG HTTP service | rust/crates/claw-rag-service/src/ | axum; SQLite + optional Qdrant |
| Test mock server | rust/crates/mock-anthropic-service/ | SCENARIO_PREFIX scripted responses |
| Python porting CLI | src/main.py | argparse: manifest, parity-audit, graphs |
| Parity reference DB | src/reference_data/subsystems/ | 29 JSON snapshots of TS archive |
## CODE MAP
| Symbol | Type | Location | Refs | Role |
|--------|------|----------|------|------|
| Session | struct | runtime/src/session.rs:117 | 229 | session persistence/lifecycle |
| ConfigLoader | struct | runtime/src/config.rs:409 | 83 | config schema/load |
| PluginManager | struct | plugins/src/lib.rs | 48 | plugin install/registry |
| PermissionEnforcer | struct | runtime/src/permission_enforcer.rs:27 | 35 | pre-dispatch permission gate |
| ConversationRuntime | struct | runtime/src/conversation.rs:130 | 32 | conversation loop driver |
| McpServerManager | struct | runtime/src/mcp_stdio.rs:488 | 30 | MCP JSON-RPC processes |
| HookRunner | struct | runtime/src/hooks.rs:155 | 25 | shell hook execution |
| CliAction | enum | rusty-claude-cli/src/main.rs:1162 | — | 25 subcommand variants |
| mvp_tool_specs | fn | tools/src/lib.rs:484 | — | static 55-tool table |
| SLASH_COMMAND_SPECS | const | commands/src/lib.rs:60 | — | 120+ slash commands |
(Refs = rg count across rust/crates; rust-analyzer references timed out during mapping.)
## CONVENTIONS
- `unsafe_code = "forbid"` workspace-wide; every crate opts in via `[lints] workspace = true`; clippy all=warn, pedantic=allow
- Edition 2021, resolver 2, publish=false; no rust-toolchain pin (CI floats stable); no rustfmt.toml/clippy.toml — stock defaults
- Giant flat files by design (main.rs 19.8k, tools/lib.rs 10.9k, commands/lib.rs 7.2k): organization is positional — types → spec table → dispatch → handlers → tests at EOF
- Dual output paths everywhere: `render_x` + `render_x_json`; JSON errors to **stdout**, text errors to **stderr**
- Tests: inline `#[cfg(test)] mod tests` primary; integration tests spawn `CARGO_BIN_EXE_claw` subprocess against mock-anthropic-service; tempfile everywhere; env-mutating tests serialize via env_lock/test_env_lock
- Comments carry issue numbers (#824, #146); gate tests named by roadmap gate (g004_conformance.rs)
- Python side: stdlib only, `python -m unittest`; src/ mixes camelCase (QueryEngine.py) and snake_case filenames
## ANTI-PATTERNS (THIS PROJECT)
- NEVER `cargo install claw-code` — crates.io stub is deprecated and installs `claw-code-deprecated.exe`; build from source
- Forbidden doc strings (CI-enforced by .github/scripts/check_doc_source_of_truth.py): old org links `github.com/Yeachan-Heo/claw-code`, `github.com/code-yeongyu/claw-code`, `discord.gg/6ztZB9jvWq`, `assets/clawd-hero.jpeg`
- Deprecated config keys: `permissionMode``permissions.defaultMode`; `enabledPlugins``plugins.enabled`; env `RUSTY_CLAUDE_PERMISSION_MODE` is dead
- Direct push to main is policy-blocked (`main_push_forbidden` approval scope)
- Automation lanes must not merge/close remote PRs/issues (docs/anti-slop-triage.md)
- `claw init` must not scaffold `dontAsk` permission mode (regression-pinned in output_format_contract.rs)
- File-level `#![allow(dead_code)]` blocks (main.rs, session_control.rs) are tolerated legacy — do not extend the pattern
## UNIQUE STYLES
- Dogfood build: scripts/dogfood-build.sh injects GIT_SHA; `claw version` provenance must equal HEAD
- Mock parity: rust/mock_parity_scenarios.json drives CLI subprocess vs MockAnthropicService
- Dogfooding uses `CLAW_CONFIG_HOME=$(mktemp -d)` for config isolation
- Env contracts: GIT_SHA (build), CLAW_CONFIG_HOME (config dir), OLLAMA_HOST (provider override), `*_API_KEY`/`*_BASE_URL` per provider
## COMMANDS
```bash
scripts/fmt.sh --check # fmt check (apply: scripts/fmt.sh)
cd rust && cargo clippy --workspace --all-targets -- -D warnings
cd rust && cargo test --workspace
cd rust && cargo build -p rusty-claude-cli # binary: rust/target/debug/claw
python -m unittest discover -s tests # Python suite
python .github/scripts/check_doc_source_of_truth.py && scripts/roadmap-check-ids.sh # docs/roadmap CI
```
## NOTES
- `claw` binary comes from crate `rusty-claude-cli` (package/bin name mismatch)
- rust-ci.yml triggers only on rust/**, docs/**, listed meta file changes (path filters)
- CI clippy job runs without `-D warnings` — weaker than the documented gate; known pre-existing failures recorded in docs/g002/g003 maps
- `claw acp` is a status stub, not a real ACP server
- rust/ has committed harness dotdirs (.clawd-agents/, .omc/, .sandbox-home/) — intentional
+64
View File
@@ -1,5 +1,66 @@
# Claw Code
<p align="center">
<a href="https://github.com/code-yeongyu/lazycodex">
<img src="https://img.shields.io/badge/LazyCodex-codex%20for%20no--brainers-111111?style=for-the-badge&logo=github&logoColor=white" alt="LazyCodex banner" />
</a>
<a href="https://github.com/Yeachan-Heo/gajae-code">
<img src="https://img.shields.io/badge/Gajae--Code-red--claw%20agent%20harness-B22222?style=for-the-badge&logo=github&logoColor=white" alt="Gajae-Code banner" />
</a>
</p>
<p align="center">
<a href="https://github.com/code-yeongyu/lazycodex">
<img src="https://opengraph.githubassets.com/lazycodex-card/code-yeongyu/lazycodex" alt="LazyCodex GitHub card" width="280" />
</a>
<a href="https://github.com/Yeachan-Heo/gajae-code">
<img src="https://opengraph.githubassets.com/gajae-code-card/Yeachan-Heo/gajae-code" alt="Gajae-Code GitHub card" width="280" />
</a>
</p>
<h3 align="center">start with the real crab-powered harnesses</h3>
<p align="center">
<a href="https://github.com/code-yeongyu/lazycodex"><b>github.com/code-yeongyu/lazycodex</b></a>
<br/>
<a href="https://github.com/Yeachan-Heo/gajae-code"><b>github.com/Yeachan-Heo/gajae-code</b></a>
</p>
<p align="center">
<a href="https://github.com/code-yeongyu/lazycodex">
<img src="https://img.shields.io/badge/Open-LazyCodex-111111?style=flat-square&logo=github&logoColor=white" alt="Open LazyCodex on GitHub" />
</a>
<a href="https://github.com/Yeachan-Heo/gajae-code">
<img src="https://img.shields.io/badge/Open-Gajae--Code-B22222?style=flat-square&logo=github&logoColor=white" alt="Open Gajae-Code on GitHub" />
</a>
</p>
<p align="center">
<a href="https://discord.gg/GtjhvgjnV">
<img src="https://img.shields.io/badge/Discord-join%20the%20harness%20lab-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join the harness lab on Discord" />
</a>
<a href="https://discord.gg/4Rt79F7dF">
<img src="https://img.shields.io/badge/Discord-join%20the%20crab%20tank-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join the crab tank on Discord" />
</a>
</p>
<p align="center">
Join the Discords:
<a href="https://discord.gg/GtjhvgjnV"><b>ultraworkers discord</b></a>
·
<a href="https://discord.gg/4Rt79F7dF"><b>gajae-code discord</b></a>
</p>
> [!IMPORTANT]
> **Claw Code is not the serious production project here.**
> This repository is closer to a museum exhibit than a product pitch, a crustacean-run artifact kept alive by clawed gajaes, swept and labeled by agents, and automatically maintained according to the harnesses above.
>
> As already described in the project philosophy, this is not meant to be hand-operated like a normal product repo. It is an **agent-managed exhibit**: the harnesses plan, execute, verify, label, and preserve the artifact while the crabs keep the tank running.
>
> If you want to actually run work, start with **[LazyCodex](https://github.com/code-yeongyu/lazycodex)** or **[Gajae-Code](https://github.com/Yeachan-Heo/gajae-code)**. If you want to inspect the strange little fossil of the Claw Code moment, continue below.
>
> For the longer public explanation behind this philosophy, see [here](https://x.com/realsigridjin/status/2039472968624185713).
<p align="center">
<a href="https://github.com/ultraworkers/claw-code">ultraworkers/claw-code</a>
·
@@ -73,6 +134,9 @@ export ANTHROPIC_API_KEY="sk-ant-..."
# 4. Run a prompt
./target/debug/claw prompt "say hello"
# 5. Start an interactive session
./target/debug/claw
```
> [!NOTE]
+23
View File
@@ -97,6 +97,27 @@ export OPENAI_API_KEY="local-dev-token"
claw --model "Qwen/Qwen2.5-Coder-7B-Instruct" prompt "Reply exactly HELLO_WORLD_123"
```
## mlx-lm (Apple Silicon)
On Apple Silicon, [mlx-lm](https://github.com/ml-explore/mlx-lm) gives meaningfully faster inference than llama.cpp-based backends for models under roughly 14B parameters.
Install and start the server:
```bash
pipx install mlx-lm
mlx_lm.server --model mlx-community/Qwen2.5-Coder-7B-Instruct-4bit --port 8080
```
Then route Claw to it:
```bash
export OPENAI_BASE_URL="http://127.0.0.1:8080/v1"
export OPENAI_API_KEY="local-dev-token"
claw --model "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit" prompt "Reply exactly HELLO_WORLD_123"
```
mlx-lm serves models under their full Hugging Face repo ID. Use the exact `id` field from `curl $OPENAI_BASE_URL/models` for `--model`. A bare name like `qwen2.5-coder-7b-instruct` will fail model resolution before the request ever reaches the server.
## Local skills install from disk
Skills are discovered from Claw skill roots such as `.claw/skills/` in a workspace and `~/.claw/skills/` for user-level installs. Legacy `.codex/skills/` roots may also be scanned for compatibility, but new local Claw projects should prefer `.claw/skills/`.
@@ -149,3 +170,5 @@ Offline install checklist:
| Plain prompt works but tools fail | Confirm the model/server supports OpenAI-compatible tool calls and response shapes. |
| Skill says installed but `/skills <name>` fails | Check `/skills list` for the discovered name and source; verify provider credentials separately with `claw doctor`. |
| A local docs/log file contains secrets | Redact it before using `@path` file context or attaching it to an issue. |
| `404 Repository Not Found` from huggingface.co when running `claw` | The `--model` value isn't a full Hugging Face repo ID. Use the exact `id` field from `curl $OPENAI_BASE_URL/models`, not a bare model name. |
| mlx-lm output includes a trailing `<|im_end|>`, or generation runs long | Unfixed mlx-lm bug ([#973](https://github.com/ml-explore/mlx-lm/issues/973), closed without a merge). Set `eos_token_id` in the cached `generation_config.json` (or `config.json`) to the real end-of-turn token. |
+80
View File
@@ -0,0 +1,80 @@
# AGENTS.md — rust/ workspace
## OVERVIEW
Virtual Cargo workspace (resolver 2, edition 2021) housing 11 crates that compose the `claw` CLI and supporting services.
## STRUCTURE
| Crate | Kind | Purpose |
|---|---|---|
| `rusty-claude-cli` | bin (`claw`) | Main CLI binary. Package name ≠ binary name. |
| `claw-analog` | lib+bin | Alternate entry point; depends on api + runtime only. |
| `claw-rag-service` | bin | RAG service. Only crate with `[features]` (`qdrant-index`). |
| `mock-anthropic-service` | lib+bin | Mock Anthropic Messages API. Prints `MOCK_ANTHROPIC_BASE_URL`. Dev-dep for CLI and analog tests. |
| `runtime` | lib | Core: sessions, permissions, MCP, conversation loop. ~47 modules. |
| `api` | lib | Provider clients: Anthropic, OpenAI-compat (xAI, OpenAI, DashScope, Ollama). |
| `tools` | lib | 55-tool surface area. Depends on `commands` (not vice versa). |
| `commands` | lib | 120+ slash commands. |
| `plugins` | lib | Plugin manifest and lifecycle. |
| `telemetry` | lib | Request identity + analytics sinks. |
| `compat-harness` | lib | Extracts upstream TS claude-code manifest/commands/tools for parity comparison. |
Dependency direction: `rusty-claude-cli` → tools/commands/runtime/api/plugins. `tools``commands`.
## WHERE TO LOOK
- **Parity testing**: `mock_parity_scenarios.json` at workspace root, loaded via `CARGO_MANIFEST_DIR/../../mock_parity_scenarios.json`. Scripts in `scripts/` (`run_mock_parity_harness.sh`, `run_mock_parity_diff.py`).
- **CI**: `.github/workflows/rust-ci.yml` (fmt, clippy, test, docs, Windows smoke) and `release.yml` (v* tag builds for linux-x64/macos-arm64/windows-x64).
- **Committed test fixtures**: `.clawd-agents/`, `.omc/`, `.sandbox-home/` are checked-in harness dotdirs.
- **Docs**: `PARITY.md`, `TUI-ENHANCEMENT-PLAN.md`, `README.md` alongside this file.
## CONVENTIONS
Workspace lints (all crates opt in via `[lints] workspace = true`):
- `unsafe_code` = **forbid**. No exceptions.
- clippy `all` = warn, `pedantic` = allow. Explicitly allowed: `module_name_repetitions`, `missing_panics_doc`, `missing_errors_doc`.
No `rustfmt.toml` or `clippy.toml`. Stock defaults only.
TUI rule: formatting fns take `&mut impl Write`, never stdout directly. Never mix raw ANSI escapes with crossterm.
Library crates don't carry the `claw-` prefix. Binary crates do (except legacy `rusty-claude-cli`).
Workspace version is `0.1.3`, `publish = false`, MIT license.
No rust-toolchain file, no MSRV. CI pins `dtolnay/rust-toolchain@stable`.
## ANTI-PATTERNS
- Don't run `cargo fmt --manifest-path rust/Cargo.toml` from the repo root. Use `../scripts/fmt.sh` instead.
- Don't add `unsafe` code. The lint is set to `forbid`, not `deny`. You can't `#[allow]` it.
- Don't create dependencies from `commands``tools`. The arrow goes `tools``commands`.
- Don't write TUI output directly to stdout or use raw ANSI escape sequences.
- Don't add features to crates other than `claw-rag-service` without good reason; the workspace is feature-lean by design.
## COMMANDS
All run from `rust/`:
```sh
# Format (check only)
../scripts/fmt.sh --check
# Format (apply)
../scripts/fmt.sh
# Lint (strict, matches what you should pass before pushing)
cargo clippy --workspace --all-targets -- -D warnings
# Test
cargo test --workspace
# Build specific binary
cargo build -p rusty-claude-cli
cargo build -p claw-analog
cargo build -p claw-rag-service
cargo build -p mock-anthropic-service
```
Note: CI clippy runs without `-D warnings`, so the local check above is stricter than the gate.
+5
View File
@@ -40,6 +40,11 @@ Or provide an OAuth bearer token directly:
export ANTHROPIC_AUTH_TOKEN="anthropic-oauth-or-proxy-bearer-token"
```
For local OpenAI-compatible servers such as Ollama, including Qwen reasoning
models, see [`../docs/local-openai-compatible-providers.md`](../docs/local-openai-compatible-providers.md).
Use the exact model tag exposed by the server, for example `qwen3:latest`, and
prefer `OLLAMA_HOST` for Ollama-specific local routing.
## Mock parity harness
The workspace now includes a deterministic Anthropic-compatible mock service and a clean-environment CLI harness for end-to-end parity checks.
+39
View File
@@ -0,0 +1,39 @@
# AGENTS.md — api crate
## OVERVIEW
LLM provider client layer: dispatches Anthropic, xAI, OpenAI, DashScope, and Ollama behind two wire protocols (Anthropic Messages native, OpenAI Chat Completions compat).
## WHERE TO LOOK
| Module | What lives here |
|---|---|
| `client.rs` | `ProviderClient` enum (facade). `from_model(model)` resolves alias, picks `ProviderKind`, handles OLLAMA_HOST and DashScope qwen-prefix cases. `send_message`/`stream_message`. |
| `providers/mod.rs` | `Provider` trait (generic, dead_code-allowed, not used for dispatch). `ProviderKind`, `resolve_model_alias`, `ProviderMetadata` (auth_env, base_url_env, default_base_url), `max_tokens_for_model[_with_override]`, capability/diagnostic reporting, `preflight_message_request` validation. |
| `providers/anthropic.rs` | `AnthropicClient` (re-exported as `ApiClient` at crate root). Dual auth: API key env vs saved OAuth (`AuthSource`, `OAuthTokenSet`, token expiry checks). Base-url resolution. SSE `MessageStream`. Prompt-cache hooks. |
| `providers/openai_compat.rs` | `OpenAiCompatClient` parameterized by `OpenAiCompatConfig` (presets: `xai()`, `openai()`, `dashscope()`, `OLLAMA_CONFIG`). Heavy translation layer: `build_chat_completion_request`, `translate_message`, `sanitize_tool_message_pairing`, `flatten_tool_result_content`. Model-quirk predicates (`is_reasoning_model`, etc.). Body-size estimation/guards. |
| `types.rs` | Provider-agnostic wire types: `MessageRequest`, `InputMessage`, `ContentBlock`, `StreamEvent`, `Usage`, `ToolDefinition`, `ToolChoice`. |
| `sse.rs` | `SseParser`, `parse_frame`. |
| `http_client.rs` | reqwest builders, `ProxyConfig` from env proxy vars, `TimeoutConfig`. |
| `error.rs` | `ApiError`. |
| `prompt_cache.rs` | `PromptCache` + `Stats` (Anthropic-only). |
| `lib.rs` | Curated `pub use` lists define the public surface. Also re-exports sibling telemetry crate items. |
## CONVENTIONS
- Module-private by default. `lib.rs` `pub use` lists are the sole public API surface.
- `#[must_use]` on pure constructors.
- Provider config follows an env-var pair pattern: `*_API_KEY` / `*_BASE_URL`, recorded in `ProviderMetadata`.
- Leaf files carry targeted `#![allow(clippy::cast_possible_truncation)]` where needed.
- Dispatch goes through the `ProviderClient` enum, not trait objects. The `Provider` trait exists but is dead-code-allowed.
- Streams unify into `MessageStream` with `next_event()` yielding `StreamEvent`.
## TESTS
- Four integration test files under `tests/`:
- `client_integration` — core client behavior
- `openai_compat_integration` — OpenAI-compat translation paths
- `provider_client_integration``ProviderClient` dispatch
- `proxy_integration` — proxy config
- Tests that touch env vars serialize through a shared `env_lock()` mutex. Don't skip this or you'll get flaky parallel failures.
- `benches/request_building.rs` is the workspace's only Criterion bench. Targets hot translation functions. This file bulk-opts out of strict lints.
+9
View File
@@ -296,6 +296,15 @@ pub fn metadata_for_model(model: &str) -> Option<ProviderMetadata> {
None
}
#[must_use]
pub fn strip_provider_prefix(canonical_model: &str) -> String {
if let Some(pos) = canonical_model.find('/') {
canonical_model[pos + 1..].to_string()
} else {
canonical_model.to_string()
}
}
#[must_use]
pub fn provider_diagnostics_for_model(model: &str) -> ProviderDiagnostics {
let resolved_model = resolve_model_alias(model);
+32 -13
View File
@@ -16,7 +16,7 @@ use crate::types::{
ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
};
use super::{preflight_message_request, Provider, ProviderFuture};
use super::{preflight_message_request, resolve_model_alias, Provider, ProviderFuture};
pub const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1";
pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
@@ -215,18 +215,19 @@ impl OpenAiCompatClient {
&self,
request: &MessageRequest,
) -> Result<MessageResponse, ApiError> {
let request = MessageRequest {
let original_model = request.model.clone();
let canonical = resolve_model_alias(&request.model);
let mut request = MessageRequest {
stream: false,
..request.clone()
};
request.model = canonical;
preflight_message_request(&request)?;
let response = self.send_with_retry(&request).await?;
let request_id = request_id_from_headers(response.headers());
let body = response.text().await.map_err(ApiError::from)?;
// Some backends return {"error":{"message":"...","type":"...","code":...}}
// instead of a valid completion object. Check for this before attempting
// full deserialization so the user sees the actual error, not a cryptic
// "missing field 'id'" parse failure.
if let Ok(raw) = serde_json::from_str::<serde_json::Value>(&body) {
if let Some(err_obj) = raw.get("error") {
let msg = err_obj
@@ -258,12 +259,13 @@ impl OpenAiCompatClient {
}
}
let payload = serde_json::from_str::<ChatCompletionResponse>(&body).map_err(|error| {
ApiError::json_deserialize(self.config.provider_name, &request.model, &body, error)
ApiError::json_deserialize(self.config.provider_name, &original_model, &body, error)
})?;
let mut normalized = normalize_response(&request.model, payload)?;
if normalized.request_id.is_none() {
normalized.request_id = request_id;
}
normalized.model = original_model;
Ok(normalized)
}
@@ -271,17 +273,25 @@ impl OpenAiCompatClient {
&self,
request: &MessageRequest,
) -> Result<MessageStream, ApiError> {
preflight_message_request(request)?;
let response = self
.send_with_retry(&request.clone().with_streaming())
.await?;
let original_model = request.model.clone();
let canonical = resolve_model_alias(&request.model);
let mut streaming_request = request.clone().with_streaming();
streaming_request.model = canonical;
preflight_message_request(&streaming_request)?;
let response = self.send_with_retry(&streaming_request).await?;
Ok(MessageStream {
request_id: request_id_from_headers(response.headers()),
response,
parser: OpenAiSseParser::with_context(self.config.provider_name, request.model.clone()),
parser: OpenAiSseParser::with_context(
self.config.provider_name,
original_model.clone(),
),
pending: VecDeque::new(),
done: false,
state: StreamState::new(request.model.clone()),
state: StreamState::new(original_model),
})
}
@@ -562,6 +572,7 @@ impl StreamState {
.delta
.reasoning_content
.filter(|value| !value.is_empty())
.or(choice.delta.reasoning.filter(|value| !value.is_empty()))
.or(choice
.delta
.thinking
@@ -817,6 +828,8 @@ struct ChatMessage {
#[serde(default)]
reasoning_content: Option<String>,
#[serde(default)]
reasoning: Option<String>,
#[serde(default)]
tool_calls: Vec<ResponseToolCall>,
}
@@ -891,6 +904,8 @@ struct ChunkDelta {
#[serde(default)]
reasoning_content: Option<String>,
#[serde(default)]
reasoning: Option<String>,
#[serde(default)]
thinking: Option<ThinkingDelta>,
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
tool_calls: Vec<DeltaToolCall>,
@@ -1500,6 +1515,7 @@ fn normalize_response(
.message
.reasoning_content
.filter(|value| !value.is_empty())
.or(choice.message.reasoning.filter(|value| !value.is_empty()))
{
content.push(OutputContentBlock::Thinking {
thinking,
@@ -1982,6 +1998,7 @@ mod tests {
role: "assistant".to_string(),
content: Some("final answer".to_string()),
reasoning_content: Some("hidden thought".to_string()),
reasoning: None,
tool_calls: Vec::new(),
},
finish_reason: Some("stop".to_string()),
@@ -2019,6 +2036,7 @@ mod tests {
delta: super::ChunkDelta {
content: None,
reasoning_content: Some("think".to_string()),
reasoning: None,
thinking: None,
tool_calls: Vec::new(),
},
@@ -2036,6 +2054,7 @@ mod tests {
delta: super::ChunkDelta {
content: Some(" answer".to_string()),
reasoning_content: None,
reasoning: None,
thinking: None,
tool_calls: Vec::new(),
},
@@ -166,6 +166,55 @@ async fn send_message_preserves_deepseek_reasoning_content_before_text() {
assert_eq!(body["thinking"], json!({"type": "enabled"}));
}
#[tokio::test]
async fn send_message_preserves_ollama_reasoning_before_text() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let body = concat!(
"{",
"\"id\":\"chatcmpl_ollama_reasoning\",",
"\"model\":\"qwen3:latest\",",
"\"choices\":[{",
"\"message\":{\"role\":\"assistant\",\"reasoning\":\"Think locally\",\"content\":\"Answer locally\",\"tool_calls\":[]},",
"\"finish_reason\":\"stop\"",
"}],",
"\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":5}",
"}"
);
let server = spawn_server(
state.clone(),
vec![http_response("200 OK", "application/json", body)],
)
.await;
let client = OpenAiCompatClient::new("ollama-test-key", OpenAiCompatConfig::openai())
.with_base_url(server.base_url());
let response = client
.send_message(&MessageRequest {
model: "openai/qwen3:latest".to_string(),
..sample_request(false)
})
.await
.expect("request should succeed");
assert_eq!(
response.content,
vec![
OutputContentBlock::Thinking {
thinking: "Think locally".to_string(),
signature: None,
},
OutputContentBlock::Text {
text: "Answer locally".to_string(),
},
]
);
let captured = state.lock().await;
let request = captured.first().expect("server should capture request");
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["model"], json!("qwen3:latest"));
}
#[tokio::test]
async fn local_openai_gateway_strips_routing_prefix_and_preserves_extra_body_params() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
@@ -389,6 +438,83 @@ async fn stream_message_normalizes_text_and_multiple_tool_calls() {
assert!(request.body.contains("\"stream\":true"));
}
#[tokio::test]
async fn stream_message_preserves_ollama_reasoning_before_text() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let sse = concat!(
"data: {\"id\":\"chatcmpl_stream_ollama_reasoning\",\"model\":\"qwen3:latest\",\"choices\":[{\"delta\":{\"reasoning\":\"Think\"}}]}\n\n",
"data: {\"id\":\"chatcmpl_stream_ollama_reasoning\",\"choices\":[{\"delta\":{\"content\":\" answer\"},\"finish_reason\":\"stop\"}]}\n\n",
"data: [DONE]\n\n"
);
let server = spawn_server(
state.clone(),
vec![http_response_with_headers(
"200 OK",
"text/event-stream",
sse,
&[("x-request-id", "req_ollama_reasoning_stream")],
)],
)
.await;
let client = OpenAiCompatClient::new("ollama-test-key", OpenAiCompatConfig::openai())
.with_base_url(server.base_url());
let mut stream = client
.stream_message(&MessageRequest {
model: "openai/qwen3:latest".to_string(),
..sample_request(false)
})
.await
.expect("stream should start");
assert_eq!(stream.request_id(), Some("req_ollama_reasoning_stream"));
let mut events = Vec::new();
while let Some(event) = stream.next_event().await.expect("event should parse") {
events.push(event);
}
assert!(matches!(events[0], StreamEvent::MessageStart(_)));
assert!(matches!(
events[1],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
index: 0,
content_block: OutputContentBlock::Thinking { .. },
})
));
assert!(matches!(
events[2],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
index: 0,
delta: ContentBlockDelta::ThinkingDelta { .. },
})
));
assert!(matches!(
events[3],
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 0 })
));
assert!(matches!(
events[4],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
index: 1,
content_block: OutputContentBlock::Text { .. },
})
));
assert!(matches!(
events[5],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
index: 1,
delta: ContentBlockDelta::TextDelta { .. },
})
));
let captured = state.lock().await;
let request = captured.first().expect("captured request");
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["model"], json!("qwen3:latest"));
assert_eq!(body["stream"], json!(true));
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn stream_message_retries_retryable_sse_handshake_failures() {
@@ -548,12 +674,13 @@ async fn openai_compatible_client_honors_http_proxy_for_requests() {
.with_base_url("http://origin.invalid/v1");
let response = client
.send_message(&MessageRequest {
model: "gpt-4o".to_string(),
model: "openai/gpt-4.1-mini".to_string(),
..sample_request(false)
})
.await
.expect("proxy should return the OpenAI-compatible response");
assert_eq!(response.model, "openai/gpt-4.1-mini");
assert_eq!(response.total_tokens(), 7);
let captured = state.lock().await;
let request = captured.first().expect("proxy should capture request");
@@ -562,6 +689,8 @@ async fn openai_compatible_client_honors_http_proxy_for_requests() {
request.headers.get("authorization").map(String::as_str),
Some("Bearer openai-test-key")
);
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["model"], json!("openai/gpt-4.1-mini"));
}
#[allow(clippy::await_holding_lock)]
+37
View File
@@ -0,0 +1,37 @@
# commands crate
## OVERVIEW
REPL slash-command surface: parsing, spec registry, help rendering, and a handful of in-crate handlers. Single flat `src/lib.rs` (~7k lines). Deps: `plugins`, `runtime`, `serde_json` only. Note: `tools` depends on `commands`, not the reverse.
## lib.rs MAP
| Lines | Landmark |
|-------------|----------|
| 1658 | Registry types: `CommandManifestEntry`, `CommandSource` (Builtin / InternalOnly / FeatureGated), `CommandRegistry`, `SlashCommandSpec`, `SkillSlashDispatch` |
| 601047 | `SLASH_COMMAND_SPECS` static table. 120+ entries (help, status, sandbox, compact, model, permissions, clear, cost, resume, config, mcp, memory, init, diff, version, bughunter, commit, pr, issue, ultraplan, teleport, debug-tool-call, export, session, plugin, agents, skills, doctor, plan, review, tasks, theme, vim, voice, chat, ...) |
| 10481303 | `SlashCommand` enum (~65 variants + `Unknown(String)`), `SlashCommandParseError`, `SlashCommand::parse` (L1218) |
| 15151899 | Per-command arg parsers: `parse_mcp_command`, `parse_plugin_command`, `parse_session_command`, etc. |
| 19002108 | Help/suggestion rendering: `render_slash_command_help*`, `suggest_slash_commands` (Levenshtein), category grouping |
| 21092682 | Result types + handlers: `handle_plugins_slash_command`, `handle_agents/mcp/skills_slash_command(_json)`, skill dispatch/resolve |
| 31605293 | Reporting layer: paired text and `_json` renderers for plugins/agents/skills/mcp reports, skill install/uninstall/create-agent logic, frontmatter parsing, root discovery |
| 5294 | `handle_slash_command(input, session, compaction)` top dispatch. Only Compact and Help execute here; all other variants return to the REPL caller |
| 54037183 | `mod tests` (~1780 lines) |
## ADDING A SLASH COMMAND
1. **Spec.** Add a `SlashCommandSpec` entry to `SLASH_COMMAND_SPECS`. Set `resume_supported` honestly.
2. **Enum + parse.** Add a variant to `SlashCommand`. Wire a match arm in `SlashCommand::parse`. If the command takes arguments, add a dedicated `parse_*_command` function in the arg-parser block.
3. **Handler.** Decide where execution lives:
- In-crate (like Compact/Help): handle it inside `handle_slash_command`.
- Returned to caller: just return the parsed variant. The REPL layer executes it.
4. **Help.** Make sure the spec's `summary` and `argument_hint` are set so help rendering and suggestion matching pick it up automatically.
5. **Tests.** Cover parsing (valid input, bad input, edge cases) in the inline `mod tests`.
## CONVENTIONS
- **Dual renderers.** Every report surface has a text variant and a `_json` variant: `handle_x` / `handle_x_json`, `render_*` / `render_*_json`. Keep them in sync.
- **Error style.** Handlers return `std::io::Result`. Parse failures use `SlashCommandParseError`.
- **Manifest registries.** Pattern is `entries: Vec<_Entry>` backed by the static spec table.
- **Dependency direction.** This crate knows nothing about `tools`. Don't import it.
- **No execution here.** Almost all commands pass through as parsed data. Only Compact and Help run inside this crate. Respect that boundary.
+17 -2
View File
@@ -720,6 +720,13 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "setup",
aliases: &[],
summary: "Run the interactive provider setup wizard",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "notifications",
aliases: &[],
@@ -1102,6 +1109,7 @@ pub enum SlashCommand {
args: Option<String>,
},
Doctor,
Setup,
Login,
Logout,
Vim,
@@ -1223,6 +1231,7 @@ impl SlashCommand {
Self::Compact { .. } => "/compact",
Self::Cost => "/cost",
Self::Doctor => "/doctor",
Self::Setup => "/setup",
Self::Config { .. } => "/config",
Self::Memory { .. } => "/memory",
Self::History { .. } => "/history",
@@ -1392,6 +1401,10 @@ pub fn validate_slash_command_input(
validate_no_args(command, &args)?;
SlashCommand::Doctor
}
"setup" => {
validate_no_args(command, &args)?;
SlashCommand::Setup
}
"login" | "logout" => {
return Err(command_error(
"This auth flow was removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead.",
@@ -1914,7 +1927,7 @@ fn slash_command_category(name: &str) -> &'static str {
| "stickers" | "language" | "profile" | "max-tokens" | "temperature" | "system-prompt"
| "api-key" | "terminal-setup" | "notifications" | "telemetry" | "providers" | "env"
| "project" | "reasoning" | "budget" | "rate-limit" | "workspace" | "reset" | "ide"
| "desktop" | "upgrade" => "Config",
| "desktop" | "upgrade" | "setup" => "Config",
"debug-tool-call" | "doctor" | "sandbox" | "diagnostics" | "tool-details" | "changelog"
| "metrics" => "Debug",
_ => "Tools",
@@ -5381,6 +5394,7 @@ pub fn handle_slash_command(
| SlashCommand::AddDir { .. }
| SlashCommand::History { .. }
| SlashCommand::Team { .. }
| SlashCommand::Setup
| SlashCommand::Unknown(_) => None,
}
}
@@ -5997,7 +6011,8 @@ mod tests {
assert!(help.contains("aliases: /skill"));
assert!(!help.contains("/login"));
assert!(!help.contains("/logout"));
assert_eq!(slash_command_specs().len(), 139);
assert!(help.contains("/setup"));
assert_eq!(slash_command_specs().len(), 140);
assert!(resume_supported_slash_commands().len() >= 39);
}
+39
View File
@@ -0,0 +1,39 @@
# AGENTS.md — plugins crate
## OVERVIEW
Plugin subsystem: how third-party, builtin, and bundled tools/commands/hooks enter the runtime.
## WHERE TO LOOK
- `src/lib.rs` (~3,863 lines): the bulk of the crate. Manifest parsing (`.claude-plugin/plugin.json`), installed-plugin registry, lifecycle model, permission model, install/update management.
- `src/hooks.rs`: hook event model (`HookEvent`, `HookRunResult`) and `HookRunner` for shell-hook execution. Re-exported from `lib.rs`. **Caution:** the runtime crate has its own `hooks.rs` with a separate `HookRunner` (execution + abort-signal side, around L155). Know which layer you need before editing.
- `src/test_isolation.rs`: test isolation helpers.
- `bundled/`: example plugin fixtures. `example-bundled/` and `sample-hooks/` each contain `.claude-plugin/plugin.json` plus `pre.sh`/`post.sh` shell hooks. Treat these as the reference shape when authoring a new plugin.
## CONVENTIONS
**Key public types** (all in `src/lib.rs` unless noted):
- Kinds/definitions: `PluginKind`, `PluginDefinition`, `BuiltinPlugin`, `BundledPlugin`, `ExternalPlugin`.
- Manifests: `PluginManifest`, `PluginToolManifest`, `PluginToolDefinition`, `PluginToolPermission`, `PluginCommandManifest`.
- Hooks: `PluginHooks`, `HookEvent`, `HookRunResult` (from `hooks.rs`).
- Lifecycle/permissions: `PluginLifecycle`, `PluginPermission`.
- Registry: `InstalledPluginRecord`, `InstalledPluginRegistry`, `RegisteredPlugin`, `PluginRegistry` (+ `Report`, `Summary`, `LoadFailure`).
- Management: `PluginManager` (+ `Config`), `InstallOutcome`, `UpdateOutcome`.
- Trait: `Plugin`.
- Errors: `PluginError`.
- Entry points: `builtin_plugins()`, `load_plugin_from_directory()`.
**Lifecycle spans two crates.** Manifest parsing and registry live here. Health checks, degraded-mode, and `PluginState` live in `runtime/src/plugin_lifecycle.rs`. Changes to plugin lifecycle logic often touch both.
**Plugin shape.** A plugin directory contains `.claude-plugin/plugin.json` at minimum. Shell hooks (`pre.sh`, `post.sh`) sit alongside. See `bundled/` for working examples.
**Consumers.** `PluginManager` has ~48 references across the workspace. CLI wires plugins via `RuntimePluginStateBuildOutput` in `rusty-claude-cli`. The tools crate exposes plugin tools through `GlobalToolRegistry`.
## NOTES
- Don't confuse the two `HookRunner` implementations. This crate's version handles the event model. The runtime crate's version handles execution and abort signals.
- `lib.rs` is large. Most searches for plugin behavior start and end there.
- Bundled plugin fixtures under `bundled/` are used in tests. Breaking their structure breaks CI.
- Permission model is enforced at install time and checked at runtime. Both paths matter when modifying `PluginPermission`.
+46
View File
@@ -0,0 +1,46 @@
# AGENTS.md — runtime crate
## OVERVIEW
Core crate of `claw`: session persistence, permissions, prompt assembly, MCP plumbing, tool-facing file ops, conversation loop. 47 flat modules in src/, ~330 pub symbols, ~170 re-exported flat from lib.rs.
## WHERE TO LOOK
| Group | Files | Entry points |
|---|---|---|
| session/conversation | session.rs, session_control.rs, conversation.rs, compact.rs, summary_compression.rs, usage.rs | `Session` (L117), `SessionStore`, `ConversationRuntime` (L130), `ApiClient`/`ToolExecutor` traits |
| config | config.rs, config_validate.rs, bootstrap.rs | `ConfigLoader` (L409), type-export heaviest file; MCP server config enums live here |
| MCP (6-file split) | mcp.rs, mcp_client.rs, mcp_stdio.rs, mcp_server.rs, mcp_tool_bridge.rs, mcp_lifecycle_hardened.rs | `McpServerManager` (L488 in mcp_stdio.rs), JSON-RPC spawn |
| hooks/plugins | hooks.rs, plugin_lifecycle.rs | `HookRunner` (L155), abort signal, healthcheck, degraded mode |
| permissions/safety | permissions.rs, permission_enforcer.rs, policy_engine.rs, approval_tokens.rs, sandbox.rs, bash_validation.rs, trust_resolver.rs | `PermissionEnforcer` (L27), `GreenLevel`, lane decisions |
| tools/execution | bash.rs, file_ops.rs, lsp_client.rs | `execute_bash`, `*_in_workspace` file op variants |
| lane/worker | lane_events.rs, worker_boot.rs, task_packet.rs, task_registry.rs, team_cron_registry.rs, branch_lock.rs, stale_base.rs, stale_branch.rs | `LaneEvent` dedupe/provenance, `LaneBoard` |
| prompt | prompt.rs | `SystemPromptBuilder`, `ContextFile`, dynamic boundary marker |
| git/remote/auth | git_context.rs, remote.rs, oauth.rs | Upstream proxy, PKCE flow |
| misc | json.rs, sse.rs, g004_conformance.rs, green_contract.rs, recovery_recipes.rs, report_schema.rs, trident.rs | Report v1 + redaction |
Largest files by line count: config.rs (3894), mcp_stdio.rs (2969), lane_events.rs (2561), worker_boot.rs (2441), session.rs (1961), conversation.rs (1878).
## CONVENTIONS
- One file per module, flat layout. No subdirectories.
- Most modules are private `mod x` with selective `pub use`. 21 modules are `pub mod`, so consumers use both the re-export and the qualified path.
- Deps kept minimal: serde, tokio, glob, regex, sha2, walkdir + internal plugins/telemetry. No reqwest, no async-trait. Remote/SSE done by hand.
- Inline `#[cfg(test)]` tests per file. session.rs has two test modules.
- `pub(crate) test_env_lock()` mutex in lib.rs serializes env-mutating tests. Use it when touching env vars.
- trust_resolver.rs is `#[cfg(test)]`-gated yet pub-used: test-only API surface.
## INVARIANTS (do not break)
1. **Compaction pairs**: compact.rs must never split assistant(ToolUse)/ToolResult pairs.
2. **No side effects on construction**: SessionStore construction must not create `.claw` directories (session_control.rs:1090).
3. **Workspace containment**: file_ops.rs workspace ops must not escape the workspace root.
4. **Permission ordering**: a leading read-only permission token must not launder a trailing destructive one (permission_enforcer.rs:450).
## ANTI-PATTERNS
- Don't extend whole-file `#![allow(...)]` blocks. They exist as legacy tolerance in worker_boot.rs, mcp_tool_bridge.rs, lsp_client.rs, stale_branch.rs, stale_base.rs, recovery_recipes.rs, mcp_lifecycle_hardened.rs, session_control.rs. Adding new ones is not acceptable.
- Don't add reqwest or async-trait as deps. Remote calls go through the manual SSE/proxy layer in remote.rs and sse.rs.
- Don't create subdirectories under src/. The flat module layout is intentional.
- Don't bypass `*_in_workspace` variants for file ops when running inside a workspace context. The unchecked versions exist for bootstrap and out-of-workspace scenarios only.
- Don't add new `pub mod` exports without reason. Prefer private mod + selective `pub use` from lib.rs.
+68
View File
@@ -162,6 +162,7 @@ pub struct RuntimeFeatureConfig {
trusted_roots: Vec<String>,
api_timeout: ApiTimeoutConfig,
rules_import: RulesImportConfig,
provider: RuntimeProviderConfig,
}
/// Controls which external AI coding framework rules are imported into the system prompt.
@@ -189,6 +190,41 @@ impl RulesImportConfig {
}
}
/// Stored provider configuration from the setup wizard.
///
/// Represents the `provider` section in `~/.claw/settings.json`, used as a
/// fallback when environment variables are absent (3-tier resolution:
/// env var > .env file > stored config).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimeProviderConfig {
kind: Option<String>,
api_key: Option<String>,
base_url: Option<String>,
model: Option<String>,
}
impl RuntimeProviderConfig {
#[must_use]
pub fn kind(&self) -> Option<&str> {
self.kind.as_deref()
}
#[must_use]
pub fn api_key(&self) -> Option<&str> {
self.api_key.as_deref()
}
#[must_use]
pub fn base_url(&self) -> Option<&str> {
self.base_url.as_deref()
}
#[must_use]
pub fn model(&self) -> Option<&str> {
self.model.as_deref()
}
}
/// Ordered chain of fallback model identifiers used when the primary
/// provider returns a retryable failure (429/500/503/etc.). The chain is
/// strict: each entry is tried in order until one succeeds.
@@ -764,6 +800,7 @@ fn build_runtime_config(
trusted_roots: parse_optional_trusted_roots(&merged_value)?,
api_timeout: parse_optional_api_timeout_config(&merged_value)?,
rules_import: parse_optional_rules_import(&merged_value)?,
provider: parse_optional_provider_config(&merged_value)?,
};
Ok(RuntimeConfig {
@@ -878,6 +915,11 @@ impl RuntimeConfig {
&self.feature_config.rules_import
}
#[must_use]
pub fn provider(&self) -> &RuntimeProviderConfig {
&self.feature_config.provider
}
/// Merge config-level default trusted roots with per-call roots.
///
/// Config roots are defaults and are kept first; per-call roots extend the
@@ -891,6 +933,13 @@ impl RuntimeConfig {
}
impl RuntimeFeatureConfig {
/// Parsed provider configuration (kind, apiKey, baseUrl, model) from
/// merged settings.
#[must_use]
pub fn provider(&self) -> &RuntimeProviderConfig {
&self.provider
}
#[must_use]
pub fn with_hooks(mut self, hooks: RuntimeHookConfig) -> Self {
self.hooks = hooks;
@@ -2104,6 +2153,25 @@ fn parse_optional_rules_import(root: &JsonValue) -> Result<RulesImportConfig, Co
}
}
fn parse_optional_provider_config(root: &JsonValue) -> Result<RuntimeProviderConfig, ConfigError> {
let Some(provider_value) = root.as_object().and_then(|object| object.get("provider")) else {
return Ok(RuntimeProviderConfig::default());
};
let Some(object) = provider_value.as_object() else {
return Ok(RuntimeProviderConfig::default());
};
let kind = optional_string(object, "kind", "provider")?.map(str::to_string);
let api_key = optional_string(object, "apiKey", "provider")?.map(str::to_string);
let base_url = optional_string(object, "baseUrl", "provider")?.map(str::to_string);
let model = optional_string(object, "model", "provider")?.map(str::to_string);
Ok(RuntimeProviderConfig {
kind,
api_key,
base_url,
model,
})
}
fn parse_filesystem_mode_label(value: &str) -> Result<FilesystemIsolationMode, ConfigError> {
match value {
"off" => Ok(FilesystemIsolationMode::Off),
+4 -51
View File
@@ -216,6 +216,10 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
name: "rulesImport",
expected: FieldType::RulesImport,
},
FieldSpec {
name: "subagentModel",
expected: FieldType::String,
},
];
const HOOKS_FIELDS: &[FieldSpec] = &[
@@ -421,8 +425,6 @@ fn validate_object_keys(
} else if DEPRECATED_FIELDS.iter().any(|d| d.name == key) {
// Deprecated key — handled separately, not an unknown-key error.
} else {
// Unknown key — preserve compatibility by surfacing it as a warning
// instead of blocking otherwise valid config files.
let suggestion = suggest_field(key, &known_names);
result.warnings.push(ConfigDiagnostic {
path: path_display.to_string(),
@@ -436,56 +438,8 @@ fn validate_object_keys(
result
}
/// Emit deprecation warnings for bare string hook entries in the hooks object.
/// Legacy `["command-string"]` arrays still load but suggest migration to the
/// structured `{matcher, hooks:[{type, command}]}` form.
fn validate_hook_entry_format(
hooks: &BTreeMap<String, JsonValue>,
source: &str,
path_display: &str,
) -> ValidationResult {
let mut result = ValidationResult {
errors: Vec::new(),
warnings: Vec::new(),
};
for spec in HOOKS_FIELDS {
let Some(value) = hooks.get(spec.name) else {
continue;
};
let Some(array) = value.as_array() else {
continue;
};
for item in array {
if item.as_str().is_some() {
result.warnings.push(ConfigDiagnostic {
path: path_display.to_string(),
field: format!("hooks.{}", spec.name),
line: find_key_line(source, spec.name),
kind: DiagnosticKind::Deprecated {
replacement: "object-style hook entries with hooks:[{type:\"command\",command:\"...\"}]",
},
});
// One deprecation warning per event is enough
break;
}
}
}
result
}
fn suggest_field(input: &str, candidates: &[&str]) -> Option<String> {
let input_lower = input.to_ascii_lowercase();
// #461: prefix-aware matching — if input is a prefix of a candidate,
// treat it as distance 0 (perfect prefix match) to avoid edit-distance
// misranking (e.g., "mcp" → "env" instead of "mcpServers").
let prefix_match = candidates
.iter()
.filter(|c| c.to_ascii_lowercase().starts_with(&input_lower))
.min_by_key(|c| c.len())
.map(|name| name.to_string());
if prefix_match.is_some() {
return prefix_match;
}
candidates
.iter()
.filter_map(|candidate| {
@@ -555,7 +509,6 @@ pub fn validate_config_file(
source,
&path_display,
));
result.merge(validate_hook_entry_format(hooks, source, &path_display));
}
if let Some(permissions) = object.get("permissions").and_then(JsonValue::as_object) {
result.merge(validate_object_keys(
+2 -1
View File
@@ -65,6 +65,7 @@ pub use compact::{
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
};
pub use config::{
clear_user_provider_settings, default_config_home, save_user_provider_settings,
suppress_config_warnings_for_json_mode, ApiTimeoutConfig, ConfigEntry, ConfigError,
ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource,
McpConfigCollection, McpInvalidServerConfig, McpManagedProxyServerConfig, McpOAuthConfig,
@@ -72,7 +73,7 @@ pub use config::{
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig,
RuntimeInvalidHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig,
ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
RuntimeProviderConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
};
pub use config_validate::{
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,
+131 -16
View File
@@ -220,15 +220,14 @@ pub fn build_linux_sandbox_command(
return None;
}
let mut args = vec![
"--user".to_string(),
"--map-root-user".to_string(),
"--mount".to_string(),
"--ipc".to_string(),
"--pid".to_string(),
"--uts".to_string(),
"--fork".to_string(),
];
let mut args: Vec<String> = working_unshare_mapping()
.unwrap_or(UNSHARE_MAPPING_CANDIDATES[0])
.iter()
.map(|arg| arg.to_string())
.collect();
// The candidates already carry the namespace flags, so the probe and the
// launcher share a single argument shape; only the opt-in `--net` is
// added here.
if status.network_active {
args.push("--net".to_string());
}
@@ -282,6 +281,82 @@ fn command_exists(command: &str) -> bool {
.is_some_and(|paths| env::split_paths(&paths).any(|path| path.join(command).exists()))
}
/// Candidate `unshare` user-namespace mapping options, in preference order.
///
/// Most systems accept `--map-root-user` alone. Some hardened containers and
/// seccomp profiles block unprivileged writes to `/proc/self/uid_map`; there,
/// util-linux delegates to the setuid `newuidmap`/`newgidmap` helpers when
/// `--map-auto` is also present.
///
/// That fallback therefore depends on the setuid helpers (the `uidmap`
/// package on Debian/Ubuntu) and on the current user having a range in
/// `/etc/subuid` and `/etc/subgid`. When either is missing, `--map-auto`
/// fails and the startup probe rejects the candidate, keeping the plain form.
///
/// Each candidate is the **complete** static argument shape the launcher
/// uses (see `build_linux_sandbox_command`): mapping flags followed by the
/// namespace flags `--mount --ipc --pid --uts --fork`. The startup probe
/// runs each candidate verbatim (plus a trivial program), so probe success
/// implies launch success: on systems where the mapping works but the
/// namespace flags are denied (e.g. AppArmor-restricted CI runners that
/// block mount propagation in user namespaces), the probe fails and the
/// sandbox stays disabled instead of activating a launcher that always
/// errors.
///
/// `--net` is intentionally absent: it is appended only when network
/// isolation is active (the non-default path), and probing with it would
/// disable the sandbox on hosts that block network-namespace creation (e.g.
/// Docker's default seccomp profile) even when network isolation is never
/// requested.
const UNSHARE_MAPPING_CANDIDATES: &[&[&str]] = &[
&[
"--user",
"--map-root-user",
"--mount",
"--ipc",
"--pid",
"--uts",
"--fork",
],
&[
"--user",
"--map-root-user",
"--map-auto",
"--mount",
"--ipc",
"--pid",
"--uts",
"--fork",
],
];
/// Probe a candidate `unshare` mapping invocation with a trivial program.
fn unshare_probe(args: &[&str]) -> bool {
std::process::Command::new("unshare")
.args(args)
.arg("true")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|status| status.success())
}
/// The first mapping option set that works on this machine, if any.
///
/// Probes are cached for the process lifetime; a missing `unshare` binary or a
/// kernel that refuses every mapping yields `None`.
fn working_unshare_mapping() -> Option<&'static [&'static str]> {
use std::sync::OnceLock;
static MAPPING: OnceLock<Option<&'static [&'static str]>> = OnceLock::new();
*MAPPING.get_or_init(|| {
UNSHARE_MAPPING_CANDIDATES
.iter()
.copied()
.find(|args| unshare_probe(args))
})
}
/// Check whether `unshare --user` actually works on this system.
/// On some CI environments (e.g. GitHub Actions), the binary exists but
/// user namespaces are restricted, causing silent failures.
@@ -292,13 +367,7 @@ fn unshare_user_namespace_works() -> bool {
if !command_exists("unshare") {
return false;
}
std::process::Command::new("unshare")
.args(["--user", "--map-root-user", "true"])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|status| status.success())
working_unshare_mapping().is_some()
})
}
@@ -359,6 +428,52 @@ mod tests {
assert_eq!(request.allowed_mounts, vec!["tmp"]);
}
#[test]
fn mapping_candidates_prefer_plain_root_mapping() {
assert!(!super::UNSHARE_MAPPING_CANDIDATES.is_empty());
for candidate in super::UNSHARE_MAPPING_CANDIDATES {
// Mapping flags.
assert!(candidate.contains(&"--user"));
assert!(candidate.contains(&"--map-root-user"));
// Namespace flags the real launcher appends — the probe must
// exercise the full invocation shape, not just mapping flags.
assert!(candidate.contains(&"--mount"));
assert!(candidate.contains(&"--ipc"));
assert!(candidate.contains(&"--pid"));
assert!(candidate.contains(&"--uts"));
assert!(candidate.contains(&"--fork"));
}
// The plain form must be tried first; `--map-auto` is only a fallback
// for kernels/containers that block unprivileged uid_map writes.
assert_eq!(
super::UNSHARE_MAPPING_CANDIDATES[0],
&[
"--user",
"--map-root-user",
"--mount",
"--ipc",
"--pid",
"--uts",
"--fork",
]
);
// The second candidate inserts `--map-auto` in the position util-linux
// expects (after `--map-root-user`, before the namespace flags).
assert_eq!(
super::UNSHARE_MAPPING_CANDIDATES[1],
&[
"--user",
"--map-root-user",
"--map-auto",
"--mount",
"--ipc",
"--pid",
"--uts",
"--fork",
]
);
}
#[test]
fn builds_linux_launcher_with_network_flag_when_requested() {
let config = SandboxConfig::default();
@@ -832,6 +832,28 @@ mod tests {
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
struct EnvVarGuard {
key: &'static str,
previous: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: &Path) -> Self {
let previous = std::env::var_os(key);
std::env::set_var(key, value);
Self { key, previous }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.previous {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
fn temp_dir() -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -1290,8 +1312,11 @@ mod tests {
#[test]
fn latest_session_returns_all_empty_error_when_sessions_exist_but_have_no_messages() {
// given — create sessions with 0 messages (empty)
let _env_guard = crate::test_env_lock();
let base = temp_dir();
fs::create_dir_all(&base).expect("base dir should exist");
let isolated_config_home = base.join("config-home");
let _claw_config_home = EnvVarGuard::set("CLAW_CONFIG_HOME", &isolated_config_home);
let store = SessionStore::from_cwd(&base).expect("store should build");
let empty_handle = store.create_handle("empty-session");
+6 -9
View File
@@ -1644,16 +1644,13 @@ mod tests {
let tmp = tempfile::tempdir().expect("tempdir");
let worktree = tmp.path().join("worktree");
let git_dir = tmp.path().join("external-gitdir");
fs::create_dir_all(&worktree).expect("worktree dir");
fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
fs::create_dir_all(git_dir.join("refs/heads")).expect("refs dir");
fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("HEAD");
fs::write(
worktree.join(".git"),
format!("gitdir: {}\n", git_dir.display()),
)
.expect(".git file");
Command::new("git")
.arg("init")
.current_dir(&worktree)
.output()
.expect("git init should run");
let git_dir = worktree.join(".git");
let original_permissions = fs::metadata(&git_dir)
.expect("gitdir metadata")
+60
View File
@@ -0,0 +1,60 @@
# AGENTS.md — rusty-claude-cli (the `claw` binary)
## OVERVIEW
Crate `rusty-claude-cli` produces the `claw` binary (~25 subcommands). Wires together api, runtime, tools, commands, plugins; terminal via crossterm/rustyline/syntect. Hand-rolled arg parser (no clap).
## main.rs MAP
src/main.rs is ~19,800 lines. Landmark table:
| Lines | Section |
|---|---|
| 73330 | Provenance/model types (`ModelSource`, `ModelProvenance`, `PermissionModeSource`), build constants |
| 330682 | Error taxonomy (`classify_error_kind`, JSON/text error output) |
| 774994 | Global flags, `--cwd`/`-C` stripping, stdin plumbing |
| 9951158 | `run()`: single flat match dispatching `CliAction` variants |
| 11621280 | `CliAction` enum, 25 struct variants (each carries `output_format`) |
| 13121477 | Output-format machinery: `OnceLock` statics, duplicate-flag tracking |
| 14782272 | `parse_args`: ~800-line manual token loop |
| 25423389 | Per-subcommand sub-parsers inside `parse_args` |
| 29023389 | Model/permission/allowed-tools resolution |
| 33904696 | Doctor subsystem (~13 `check_*_health` fns) |
| 46976062 | Manifests, bootstrap-plan, system-prompt, version, `resume_session` |
| 54597047 | `StatusContext`, `BinaryProvenance`, broad-cwd policy, stale-base preflight |
| 70489423 | `run_repl` interactive loop (`LiveCli` struct, streaming, heartbeat, `HookAbortMonitor`) |
| 942414264 | Snapshot printers: session-list, status, sandbox, models, help topics, acp, `run_init` (L11027), `run_export` (L11715), `print_help` (L14055) |
| 1426619831 | In-file `mod tests` + 3 smaller test modules (one embeds a Python MCP fixture) |
## CONVENTIONS
- Every subcommand supports `--output-format text|json`, env-overridable.
- `raw_args_request_json_output` pre-scans argv before parsing to suppress stderr config warnings in JSON mode.
- Dual renderers for each output path: human text + structured `*_json`.
- Unknown flags/typos get levenshtein-distance suggestions.
- Comments carry issue numbers (#824, #146, etc.) when tracking known problems.
- `main()` (L330) is only the error envelope. All real work happens in `run()`.
- Error output always includes `status`, `error_kind`, `action`, `hint`, `exit_code` fields.
- `classify_error_kind` maps message prefixes to snake_case kind tokens.
- JSON errors go to stdout, text errors to stderr.
- Sibling modules: `init.rs`, `input.rs` (rustyline), `render.rs` (MarkdownStreamState, Spinner, syntect), `setup_wizard.rs`.
- `build.rs` injects `GIT_SHA`, `RUSTC_VERSION`, `GIT_DIRTY` via `cargo:rustc-env`.
## ANTI-PATTERNS
- File opens with crate-wide `#![allow(...)]` suppressing 13 lints including `dead_code` and `unused_imports`. Legacy. Do NOT extend this list.
- 9+ functions carry `#[allow(clippy::too_many_lines)]`. Tolerated, not license for more.
- Package/bin name mismatch: crate is `rusty-claude-cli`, binary is `claw`. Watch for this in paths and test macros.
## TESTS
All tests live in `tests/` (6 files), black-box style. They spawn `env!("CARGO_BIN_EXE_claw")` in unique temp dirs (AtomicU64 counter).
| File | What it covers |
|---|---|
| `output_format_contract.rs` | ~105 tests pinning `kind`/`status`/`action` JSON contract for EVERY subcommand. Must update when adding/changing any command output. 5,986 lines. |
| `resume_slash_commands.rs` | Resume and slash-command behavior |
| `cli_flags_and_config_defaults.rs` | Flag parsing, config file defaults |
| `compact_output.rs` | Compact output mode |
| `compact_repl_panic.rs` | Nested-runtime panic regression |
| `mock_parity_harness.rs` | Scenario-driven tests against mock-anthropic-service, driven by `rust/mock_parity_scenarios.json` |
+125 -6
View File
@@ -17,6 +17,7 @@
mod init;
mod input;
mod render;
mod setup_wizard;
use std::collections::BTreeSet;
use std::env;
@@ -1095,6 +1096,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
CliAction::SessionList { output_format } => run_session_list(output_format)?,
CliAction::State { output_format } => run_worker_state(output_format)?,
CliAction::Init { output_format } => run_init(output_format)?,
CliAction::Setup { output_format: _ } => run_setup()?,
// #146: dispatch pure-local introspection. Text mode uses existing
// render_config_report/render_diff_report; JSON mode uses the
// corresponding _json helpers already exposed for resume sessions.
@@ -1238,6 +1240,9 @@ enum CliAction {
Init {
output_format: CliOutputFormat,
},
Setup {
output_format: CliOutputFormat,
},
// #146: `claw config` and `claw diff` are pure-local read-only
// introspection commands; wire them as standalone CLI subcommands.
Config {
@@ -1301,6 +1306,7 @@ enum LocalHelpTopic {
Model,
Settings,
Diff,
Setup,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -1765,6 +1771,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
"doctor" => Some(LocalHelpTopic::Doctor),
"acp" => Some(LocalHelpTopic::Acp),
"init" => Some(LocalHelpTopic::Init),
"setup" => Some(LocalHelpTopic::Setup),
"state" => Some(LocalHelpTopic::State),
"resume" => Some(LocalHelpTopic::Resume),
"session" => Some(LocalHelpTopic::Session),
@@ -2144,6 +2151,15 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
}
Ok(CliAction::Init { output_format })
}
"setup" => {
if rest.len() > 1 {
let extra = rest[1..].join(" ");
return Err(format!(
"unexpected extra arguments after `claw setup`: {extra}\nUsage: claw setup"
));
}
Ok(CliAction::Setup { output_format })
}
"export" => parse_export_args(&rest[1..], output_format),
"prompt" => {
let mut read_stdin = false;
@@ -2271,6 +2287,7 @@ fn parse_local_help_action(
"doctor" => LocalHelpTopic::Doctor,
"acp" => LocalHelpTopic::Acp,
"init" => LocalHelpTopic::Init,
"setup" => LocalHelpTopic::Setup,
"state" => LocalHelpTopic::State,
"export" => LocalHelpTopic::Export,
"version" => LocalHelpTopic::Version,
@@ -2316,7 +2333,7 @@ fn parse_single_word_command_alias(
let verb = &rest[0];
let is_diagnostic = matches!(
verb.as_str(),
"help" | "version" | "status" | "sandbox" | "doctor" | "state"
"help" | "version" | "status" | "sandbox" | "doctor" | "setup" | "state"
);
if is_diagnostic && rest.len() > 1 {
@@ -2336,6 +2353,7 @@ fn parse_single_word_command_alias(
"doctor" => Some(LocalHelpTopic::Doctor),
"acp" => Some(LocalHelpTopic::Acp),
"init" => Some(LocalHelpTopic::Init),
"setup" => Some(LocalHelpTopic::Setup),
"state" => Some(LocalHelpTopic::State),
"export" => Some(LocalHelpTopic::Export),
"version" => Some(LocalHelpTopic::Version),
@@ -2390,6 +2408,7 @@ fn parse_single_word_command_alias(
"doctor" => Some(LocalHelpTopic::Doctor),
"acp" => Some(LocalHelpTopic::Acp),
"init" => Some(LocalHelpTopic::Init),
"setup" => Some(LocalHelpTopic::Setup),
"state" => Some(LocalHelpTopic::State),
"export" => Some(LocalHelpTopic::Export),
"version" => Some(LocalHelpTopic::Version),
@@ -2452,6 +2471,7 @@ fn parse_single_word_command_alias(
.map(PermissionModeProvenance::from_flag)
.unwrap_or_else(permission_mode_provenance_for_current_dir),
})),
"setup" => Some(Ok(CliAction::Setup { output_format })),
"state" => Some(Ok(CliAction::State { output_format })),
// #146: let `config` and `diff` fall through to parse_subcommand
// where they are wired as pure-local introspection, instead of
@@ -2744,6 +2764,7 @@ fn suggest_similar_subcommand(input: &str) -> Option<Vec<String>> {
"status",
"sandbox",
"doctor",
"setup",
"state",
"dump-manifests",
"bootstrap-plan",
@@ -2939,6 +2960,10 @@ fn validate_model_syntax(model: &str) -> Result<(), String> {
err_msg.push_str("\nDid you mean `openai/");
err_msg.push_str(trimmed);
err_msg.push_str("`? (Requires OPENAI_API_KEY env var)");
} else if trimmed.starts_with("qwen") && trimmed.contains(':') {
err_msg.push_str("\nFor a local Ollama model, set `OPENAI_BASE_URL=http://127.0.0.1:11434/v1` before using tagged names like `");
err_msg.push_str(trimmed);
err_msg.push_str("`.");
} else if trimmed.starts_with("qwen") {
err_msg.push_str("\nDid you mean `qwen/");
err_msg.push_str(trimmed);
@@ -3725,6 +3750,11 @@ fn run_doctor(
Ok(())
}
/// Run the interactive setup wizard to configure provider, API key, and model.
fn run_setup() -> Result<(), Box<dyn std::error::Error>> {
setup_wizard::run_setup_wizard()
}
/// Starts a minimal Model Context Protocol server that exposes claw's
/// built-in tools over stdio.
///
@@ -6896,7 +6926,8 @@ fn run_resume_command(
| SlashCommand::Tag { .. }
| SlashCommand::OutputStyle { .. }
| SlashCommand::AddDir { .. }
| SlashCommand::Team { .. } => Err("unsupported resumed slash command".into()),
| SlashCommand::Team { .. }
| SlashCommand::Setup => Err("unsupported resumed slash command".into()),
}
}
@@ -8157,6 +8188,12 @@ impl LiveCli {
);
false
}
SlashCommand::Setup => {
if let Err(e) = setup_wizard::run_setup_wizard() {
eprintln!("Setup wizard failed: {e}");
}
false
}
SlashCommand::History { count } => {
self.print_prompt_history(count.as_deref());
false
@@ -10226,6 +10263,13 @@ fn render_help_topic(topic: LocalHelpTopic) -> String {
Formats text (default), json
Related /diff · ROADMAP #148"
.to_string(),
LocalHelpTopic::Setup => "Setup
Usage claw setup
Aliases /setup (inside the REPL)
Purpose run the interactive provider setup wizard to configure API key, model, and base URL
Output writes provider settings to ~/.claw/settings.json (0600 permissions)
Related /model · /config · claw doctor"
.to_string(),
}
}
@@ -10253,6 +10297,7 @@ fn local_help_topic_command(topic: LocalHelpTopic) -> &'static str {
LocalHelpTopic::Model => "models",
LocalHelpTopic::Settings => "settings",
LocalHelpTopic::Diff => "diff",
LocalHelpTopic::Setup => "setup",
}
}
@@ -13737,8 +13782,15 @@ fn push_output_block(
};
*pending_tool = Some((id, name, initial_input));
}
OutputContentBlock::Thinking { thinking, .. } => {
OutputContentBlock::Thinking {
thinking,
signature,
} => {
render_thinking_block_summary(out, Some(thinking.chars().count()), false)?;
events.push(AssistantEvent::Thinking {
thinking,
signature,
});
*block_has_thinking_summary = true;
}
OutputContentBlock::RedactedThinking { .. } => {
@@ -19073,6 +19125,13 @@ UU conflicted.rs",
assert!(matches!(
&events[0],
AssistantEvent::Thinking {
thinking,
signature
} if thinking == "step 1" && signature.as_deref() == Some("sig_123")
));
assert!(matches!(
&events[1],
AssistantEvent::TextDelta(text) if text == "Final answer"
));
let rendered = String::from_utf8(out).expect("utf8");
@@ -19649,6 +19708,41 @@ mod dump_manifests_tests {
#[cfg(test)]
mod alias_resolution_tests {
fn ollama_env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.expect("ollama env lock poisoned")
}
struct EnvVarGuard {
key: &'static str,
previous: Option<String>,
}
impl EnvVarGuard {
fn unset(key: &'static str) -> Self {
let previous = std::env::var(key).ok();
std::env::remove_var(key);
Self { key, previous }
}
fn set(key: &'static str, value: &str) -> Self {
let previous = std::env::var(key).ok();
std::env::set_var(key, value);
Self { key, previous }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.previous {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
use super::{resolve_model_alias_with_config, validate_model_syntax};
#[test]
@@ -19670,6 +19764,8 @@ mod alias_resolution_tests {
#[test]
fn test_alias_resolution_syntax_validation() {
let _guard = ollama_env_lock();
let _env = EnvVarGuard::unset("OLLAMA_HOST");
// Resolved aliases should pass syntax validation
let resolved = resolve_model_alias_with_config("opus");
assert!(validate_model_syntax(&resolved).is_ok());
@@ -19680,6 +19776,8 @@ mod alias_resolution_tests {
#[test]
fn test_unknown_alias_fails_validation() {
let _guard = ollama_env_lock();
let _env = EnvVarGuard::unset("OLLAMA_HOST");
// Unknown aliases resolve to themselves
let resolved = resolve_model_alias_with_config("unknown-alias");
assert_eq!(resolved, "unknown-alias");
@@ -19690,6 +19788,28 @@ mod alias_resolution_tests {
assert!(result.unwrap_err().contains("invalid model syntax"));
}
#[test]
fn qwen_invalid_model_hint_mentions_local_ollama_openai_base_url() {
let _guard = ollama_env_lock();
let _ollama_env = EnvVarGuard::unset("OLLAMA_HOST");
let _openai_env = EnvVarGuard::unset("OPENAI_BASE_URL");
let result = validate_model_syntax("qwen3:8b");
let error = result.expect_err("Ollama tag without local base URL should fail");
assert!(
error.contains("Ollama"),
"Qwen Ollama tag error should mention Ollama: {error}"
);
assert!(
error.contains("OPENAI_BASE_URL"),
"Qwen Ollama tag error should mention OPENAI_BASE_URL: {error}"
);
assert!(
error.contains("http://127.0.0.1:11434/v1"),
"Qwen Ollama tag error should show local Ollama OpenAI URL: {error}"
);
}
#[test]
fn test_direct_provider_model_passes() {
// Direct provider/model strings should remain unchanged and pass
@@ -19699,14 +19819,13 @@ mod alias_resolution_tests {
}
#[test]
fn test_ollama_host_bypasses_model_validation() {
// Safety: test sets and clears env var within the test.
std::env::set_var("OLLAMA_HOST", "http://127.0.0.1:11434");
let _guard = ollama_env_lock();
let _env = EnvVarGuard::set("OLLAMA_HOST", "http://127.0.0.1:11434");
// Ollama model names with colons pass
assert!(validate_model_syntax("qwen3:8b").is_ok());
assert!(validate_model_syntax("gemma4:e2b").is_ok());
assert!(validate_model_syntax("qwen3.6:27b-nvfp4").is_ok());
// Empty model still rejected
assert!(validate_model_syntax("").is_err());
std::env::remove_var("OLLAMA_HOST");
}
}
@@ -23,7 +23,10 @@ const DEFAULT_BASE_URLS: &[(&str, &str)] = &[
("anthropic", "https://api.anthropic.com"),
("xai", "https://api.x.ai/v1"),
("openai", "https://api.openai.com/v1"),
("dashscope", "https://dashscope.aliyuncs.com/compatible-mode/v1"),
(
"dashscope",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
),
];
const API_KEY_ENV_VARS: &[(&str, &str)] = &[
@@ -51,12 +54,7 @@ pub fn run_setup_wizard() -> Result<(), Box<dyn std::error::Error>> {
let model = prompt_model(&kind, &current)?;
let fast_model = prompt_fast_model(&current, model.as_deref())?;
save_user_provider_settings(
&kind,
&api_key,
base_url.as_deref(),
model.as_deref(),
)?;
save_user_provider_settings(&kind, &api_key, base_url.as_deref(), model.as_deref())?;
if let Some(fast) = &fast_model {
save_settings_field("subagentModel", fast)?;
@@ -64,7 +62,10 @@ pub fn run_setup_wizard() -> Result<(), Box<dyn std::error::Error>> {
println!();
println!(" \x1b[32mProvider saved to ~/.claw/settings.json\x1b[0m");
println!(" Run \x1b[1m/model {}\x1b[0m or restart claw to activate.", model.as_deref().unwrap_or(&kind));
println!(
" Run \x1b[1m/model {}\x1b[0m or restart claw to activate.",
model.as_deref().unwrap_or(&kind)
);
println!();
Ok(())
@@ -82,7 +83,11 @@ fn prompt_provider(current: &RuntimeProviderConfig) -> Result<String, Box<dyn st
let current_kind = current.kind().unwrap_or("anthropic");
println!(" \x1b[1mProvider\x1b[0m");
for (num, label, kind) in PROVIDERS {
let marker = if *kind == current_kind { " (current)" } else { "" };
let marker = if *kind == current_kind {
" (current)"
} else {
""
};
println!(" [{num}] {label}{marker}");
}
let default = PROVIDERS
@@ -129,9 +134,7 @@ fn prompt_api_key(
};
// Check if env var is already set
let env_set = std::env::var(env_var)
.ok()
.is_some_and(|v| !v.is_empty());
let env_set = std::env::var(env_var).ok().is_some_and(|v| !v.is_empty());
if env_set {
println!(" {env_var} is set in environment (will take priority over stored key)");
}
@@ -144,7 +147,9 @@ fn prompt_api_key(
};
if key.is_empty() && !env_set {
eprintln!(" \x1b[33mWarning: no API key configured. Set {env_var} or re-run setup.\x1b[0m");
eprintln!(
" \x1b[33mWarning: no API key configured. Set {env_var} or re-run setup.\x1b[0m"
);
}
Ok(key)
@@ -174,9 +179,7 @@ fn prompt_base_url(
"dashscope" => "DASHSCOPE_BASE_URL",
_ => "BASE_URL",
};
let env_set = std::env::var(env_var)
.ok()
.is_some_and(|v| !v.is_empty());
let env_set = std::env::var(env_var).ok().is_some_and(|v| !v.is_empty());
if env_set {
println!(" {env_var} is set in environment (will take priority over stored URL)");
}
@@ -203,7 +206,9 @@ fn prompt_model(
.find(|(k, _)| *k == kind)
.map_or(empty, |(_, models)| *models);
let current_model = current.model().unwrap_or(aliases.first().copied().unwrap_or(""));
let current_model = current
.model()
.unwrap_or(aliases.first().copied().unwrap_or(""));
println!(" \x1b[1mModel\x1b[0m");
if !aliases.is_empty() {
@@ -235,12 +240,16 @@ fn prompt_fast_model(
println!(" Press Enter to skip (agents will use your main model).");
let current_fast = load_current_settings_field("subagentModel");
let default_hint = current_fast
.as_deref()
.or(main_model)
.unwrap_or("");
let default_hint = current_fast.as_deref().or(main_model).unwrap_or("");
let input = read_line(&format!(" Fast model [{}]: ", if default_hint.is_empty() { "same as main" } else { default_hint }))?;
let input = read_line(&format!(
" Fast model [{}]: ",
if default_hint.is_empty() {
"same as main"
} else {
default_hint
}
))?;
if input.trim().is_empty() {
Ok(current_fast)
} else {
@@ -269,7 +278,10 @@ fn save_settings_field(field: &str, value: &str) -> Result<(), Box<dyn std::erro
};
if let Some(obj) = settings.as_object_mut() {
obj.insert(field.to_string(), serde_json::Value::String(value.to_string()));
obj.insert(
field.to_string(),
serde_json::Value::String(value.to_string()),
);
}
std::fs::create_dir_all(&settings_dir)?;
+47
View File
@@ -0,0 +1,47 @@
# AGENTS.md — rust/crates/tools
## OVERVIEW
Single-crate tool surface: registry, 55 tool specs, permission-gated dispatch, all implementations. One flat `src/lib.rs` (10,892 lines, ~37% tests).
## lib.rs MAP
| Lines | Landmark |
|-------------|----------|
| 174 | Imports + six `global_*_registry()` OnceLock singletons (Lsp, McpTool, Team, Cron, Task, Worker) |
| 75483 | Registry API: `ToolManifestEntry`, `ToolSource`, `ToolRegistry`, `ToolSpec`, `GlobalToolRegistry`, `RuntimeToolDefinition`, `canonical_allowed_tool_name` |
| 4841348 | `mvp_tool_specs()` static table of 55 tools with inline JSON schemas |
| 13491524 | `enforce_permission_check` + `execute_tool()` string-match dispatch, permission classification helpers |
| 15252735 | `run_*` wrappers: deserialize input, call into `execute_*` or runtime fns |
| 27362763 | `workspace_traversal_guard_tests` mod |
| 27643354 | ~45 private serde IO structs |
| 33556824 | Real implementations: web fetch/search, todo store, skill resolution, agent/subagent spawning (`ProviderRuntimeClient` L5182, `SubagentToolExecutor` L5361), notebook edit, sleep, config, plan-mode, structured output, REPL, PowerShell |
| 68256826 | `pub mod lane_completion; pub mod pdf_extract;` |
| 682910892 | `mod tests` (~4000 lines) |
## ADDING A TOOL
1. Add a `ToolSpec` entry in `mvp_tool_specs()`. Include `name`, `description`, `input_schema` (inline JSON), and `required_permission: PermissionMode`.
2. Add a dispatch arm in `execute_tool()` matching the tool name string.
3. Write a `run_<tool>()` wrapper. Deserialize input from a dedicated serde struct.
4. Implement the actual logic below L3355 (or call into another crate).
5. Add inline tests in `mod tests`. Follow BDD naming: `given_x_when_y_then_z`.
6. Permission gating is automatic: `GlobalToolRegistry` / `SubagentToolExecutor` hold an optional `PermissionEnforcer` checked pre-dispatch.
## CONVENTIONS
- **Tool boundary signature**: `Result<String, String>`. Always.
- **Tool naming**: snake_case for file/shell tools, PascalCase otherwise. `canonical_allowed_tool_name` normalizes aliases.
- **State**: OnceLock registries for global singletons. JSON state files under config dirs for persistence.
- **Input validation**: reject empty strings for todos, descriptions, prompts, messages, code. ~12 validation sites between L38066167. Keep that contract.
- **Test naming**: BDD style (`given_x_when_y_then_z`).
- **Env-mutating tests**: acquire `env_lock()` mutex first.
- **Dependencies**: runtime, api, plugins, commands, reqwest(blocking), aspect-*, tokio.
## ANTI-PATTERNS
- **Don't add more `#[allow(clippy::...)]` suppressions.** ~50 `needless_pass_by_value` and several `too_many_lines` allows exist as legacy debt. Don't extend.
- **Don't skip input validation.** Empty-string rejection is a contract across all user-facing text fields.
- **Don't scatter implementation across new submodules.** The crate is intentionally flat (one lib.rs + two leaf mods). Only `lane_completion` and `pdf_extract` break out.
- **Don't duplicate tool names.** The canonical name mapping already handles aliases.
- **Don't bypass `enforce_permission_check`.** Every tool dispatch goes through permission gating. No exceptions.
+69
View File
@@ -0,0 +1,69 @@
# src/ AGENTS.md
## OVERVIEW
This is a **porting workspace**, not production code. Nothing here is imported by,
built with, or shipped in the Rust product (`rust/crates/`). The Python tree exists
solely to mirror and track parity against the Claude Code TypeScript source.
## STRUCTURE
```
src/
├── main.py # argparse CLI entry point (summary, parity-audit, manifest, etc.)
├── ~38 top-level .py # mirror TS root files one-to-one
├── models.py # frozen dataclasses: Subsystem, PortingModule, PermissionDenial, ...
├── parity_audit.py # hard-coded TS→Python filename mapping (main.tsx→main.py, etc.)
├── port_manifest.py # builds a manifest of the src/ tree itself
├── reference_data/ # tracked JSON snapshots extracted from the TS archive
│ ├── archive_surface_snapshot.json
│ ├── tools_snapshot.json, commands_snapshot.json
│ └── subsystems/*.json (29 per-subsystem records)
└── ~30 subdirectories/ # PLACEHOLDER PACKAGES (assistant/, bootstrap/, voice/, vim/, ...)
└── each contains only __init__.py loading subsystems/<name>.json
```
Placeholder packages follow an identical template: load
`reference_data/subsystems/<name>.json` via `_archive_helper.load_archive_metadata()`,
re-export `ARCHIVE_NAME`, `MODULE_COUNT`, `SAMPLE_FILES`, `PORTING_NOTE`. There is no
voice, vim, or other real functionality behind them.
## WHERE TO LOOK
| Goal | Start here |
|---|---|
| Understand the CLI subcommands | `main.py` |
| See which TS files map to which .py | `parity_audit.py` |
| Find shared data structures | `models.py` |
| Check TS archive metadata | `reference_data/` |
| Real logic (permissions, path scoping) | `permissions.py`, `path_scope.py` |
| Query engine shim | `query_engine.py` |
| Runtime simulation | `runtime.py` |
| Tests | repo-root `tests/` (test_porting_workspace.py, test_security_scope.py) |
Run tests: `python -m unittest discover -s tests` from repo root.
## CONVENTIONS
- snake_case filenames, preserving TS names. camelCase exceptions exist where the
TS original used it: `QueryEngine.py`, `costHook.py`, `replLauncher.py`.
- Every mirrored entry carries a `source_hint` pointing back to its original `.ts`/`.tsx` path.
- `from __future__ import annotations` throughout. Stdlib only, no third-party deps.
- `models.py` dataclasses are frozen. Renderers are `as_markdown()` / `to_markdown()`.
- main.py simulates routing, turn-loops, bootstrap over mirrored inventories.
Read-only shims return handled/message results. It never calls an LLM.
- Thin shim modules (e.g. `ink.py`) exist as backlog metadata, not working code.
## ANTI-PATTERNS
**Don't add real agent, tool, voice, or vim functionality here.** That belongs in
`rust/crates/`. This tree is a scaffold, not an implementation target.
**Don't commit anything from `archive/`.** The local TS snapshot
(`archive/claude_code_ts_snapshot/src`) is gitignored. `reference_data/` holds the
tracked extracts; work from those.
**Don't let parity_audit.py drift.** When you rename a mirrored module, update the
hard-coded mapping in `parity_audit.py` to match.
**Don't add third-party dependencies.** Everything runs on stdlib.
+10 -2
View File
@@ -55,7 +55,8 @@ def command_names() -> list[str]:
def get_command(name: str) -> PortingModule | None:
needle = COMMAND_ALIASES.get(name.lower(), name.lower())
normalized = name.strip().lower()
needle = COMMAND_ALIASES.get(normalized, normalized)
for module in PORTED_COMMANDS:
if module.name.lower() == needle:
return module
@@ -72,8 +73,15 @@ def get_commands(cwd: str | None = None, include_plugin_commands: bool = True, i
def find_commands(query: str, limit: int = 20) -> list[PortingModule]:
needle = query.lower()
needle = query.strip().lower()
matches = [module for module in PORTED_COMMANDS if needle in module.name.lower() or needle in module.source_hint.lower()]
matches.sort(
key=lambda module: (
module.name.lower() != needle,
not module.name.lower().startswith(needle),
needle not in module.name.lower(),
)
)
return matches[:limit]
+9
View File
@@ -11,6 +11,7 @@ _GLOB_META = set('*?[')
_WINDOWS_DRIVE_RE = re.compile(r'^[A-Za-z]:[\\/]')
_WINDOWS_UNC_RE = re.compile(r'^(?:\\\\|//)[^\\/]+[\\/][^\\/]+')
_ENV_ASSIGNMENT_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*=')
_REDIRECTION_TARGET_RE = re.compile(r'^(?:\d*)?(?:<>|>>?|<)(.+)$|^&>>?(.+)$')
@dataclass(frozen=True)
@@ -118,6 +119,7 @@ def extract_path_candidates(payload: str) -> tuple[str, ...]:
for token in (*tokens, *raw_tokens):
if not token or token.startswith('-') or _ENV_ASSIGNMENT_RE.match(token):
continue
token = _strip_redirection_operator(token)
expanded = os.path.expandvars(os.path.expanduser(token))
if _looks_like_path(token) or _looks_like_path(expanded):
candidate = expanded if _looks_like_path(expanded) else token
@@ -138,6 +140,13 @@ def _looks_like_path(token: str) -> bool:
)
def _strip_redirection_operator(token: str) -> str:
match = _REDIRECTION_TARGET_RE.match(token)
if match is None:
return token
return next(group for group in match.groups() if group is not None)
def _is_windows_absolute(value: str) -> bool:
return bool(_WINDOWS_DRIVE_RE.match(value) or _WINDOWS_UNC_RE.match(value))
+7
View File
@@ -183,6 +183,13 @@ class PortingWorkspaceTests(unittest.TestCase):
self.assertIn("Mirrored command 'plugin'", result.stdout)
self.assertNotIn('Unknown mirrored command', result.stdout)
def test_command_lookup_normalizes_user_input_whitespace(self) -> None:
from src.commands import execute_command, find_commands, get_command
self.assertEqual('plugin', get_command(' PLUGINS ').name)
self.assertEqual('review', find_commands(' review ', limit=1)[0].name)
self.assertIn("Mirrored command 'plugin'", execute_command(' marketplace ', 'browse').message)
def test_route_plugin_slash_commands_match_commands(self) -> None:
prompts = ('/plugin list', '/plugins list', '/marketplace browse', '/reload-plugins')
for prompt in prompts:
+22
View File
@@ -72,6 +72,28 @@ class WorkspacePathScopeTests(unittest.TestCase):
self.assertFalse(decision.allowed)
self.assertIn(str(outside.resolve()), decision.resolved or '')
def test_attached_shell_redirection_targets_are_validated(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
workspace = root / 'workspace'
outside = root / 'outside'
workspace.mkdir()
outside.mkdir()
(outside / 'secret.txt').write_text('secret')
self.assertEqual(
('../outside/secret.txt', '../outside/error.log'),
extract_path_candidates(
'cat <../outside/secret.txt 2>../outside/error.log'
),
)
decision = WorkspacePathScope.from_root(workspace).validate_payload(
'cat <../outside/secret.txt 2>../outside/error.log'
)
self.assertFalse(decision.allowed)
self.assertIn(str(outside.resolve()), decision.resolved or '')
def test_explicit_worktree_roots_are_allowed(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)