mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-06 09:52:43 -04:00
Compare commits
409 Commits
docs/roadm
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3acb677d70 | ||
|
|
43eac8fbec | ||
|
|
c8505092f8 | ||
|
|
eaa2e320d9 | ||
|
|
be8112f5f5 | ||
|
|
503d515f38 | ||
|
|
114c2da9fc | ||
|
|
b90f18f75a | ||
|
|
5b15197117 | ||
|
|
61e8ad9a8e | ||
|
|
ef0d0c30a4 | ||
|
|
7305e5509a | ||
|
|
2f0b5b36eb | ||
|
|
c848eeb768 | ||
|
|
d4aad7103e | ||
|
|
b60cbebb3a | ||
|
|
7c4bcd92b6 | ||
|
|
f8822aabdb | ||
|
|
aca6584fd5 | ||
|
|
b8cbb1834f | ||
|
|
cb027adf65 | ||
|
|
5bdffbe161 | ||
|
|
04f18862a8 | ||
|
|
f0e10ff182 | ||
|
|
0cab03df75 | ||
|
|
eb86f4d2c3 | ||
|
|
6c3d7be370 | ||
|
|
e3ffaefba1 | ||
|
|
cd18cf5385 | ||
|
|
2f3120e70a | ||
|
|
f1a16398fe | ||
|
|
8d50276366 | ||
|
|
13992ade54 | ||
|
|
b04b1d6ac8 | ||
|
|
934bf2837a | ||
|
|
b94c49c323 | ||
|
|
5df485cba9 | ||
|
|
c8e973513c | ||
|
|
8f9315bdc9 | ||
|
|
12a091afe6 | ||
|
|
9f0cf3b888 | ||
|
|
6e94c1a97f | ||
|
|
38626cad20 | ||
|
|
e84f7c8034 | ||
|
|
b8f066347b | ||
|
|
5adc751053 | ||
|
|
adf5bd165e | ||
|
|
b220366176 | ||
|
|
b5d67ef249 | ||
|
|
9a40568e1c | ||
|
|
41b3566468 | ||
|
|
b86159cbb7 | ||
|
|
5e5b9966c0 | ||
|
|
33f771f3f5 | ||
|
|
3fbfcc40ca | ||
|
|
c7c5c11d1e | ||
|
|
c0447e2be1 | ||
|
|
1b22ed700a | ||
|
|
76f9a134ae | ||
|
|
9c11325e83 | ||
|
|
4708ab1611 | ||
|
|
311e719e5d | ||
|
|
b926a9d25f | ||
|
|
61d641d722 | ||
|
|
2f8679bd15 | ||
|
|
9ef21e23f3 | ||
|
|
6ac0386094 | ||
|
|
4c939c0ad6 | ||
|
|
4d41ab37e1 | ||
|
|
662a50bdc0 | ||
|
|
1da4aa454f | ||
|
|
3f50f33407 | ||
|
|
0ce4168c93 | ||
|
|
7f1dd0c116 | ||
|
|
42f56e7f77 | ||
|
|
f25fae6d22 | ||
|
|
be66d961bf | ||
|
|
b1a40a2364 | ||
|
|
726d55d1ea | ||
|
|
e4b8f9c07f | ||
|
|
b3a5a74237 | ||
|
|
ad76389b31 | ||
|
|
e68733d72e | ||
|
|
4d4d72cd49 | ||
|
|
9bc2f3631d | ||
|
|
f40927ba97 | ||
|
|
5bcbc2f874 | ||
|
|
a671969688 | ||
|
|
6757ebde74 | ||
|
|
2447273d09 | ||
|
|
5a76ecb760 | ||
|
|
db56498460 | ||
|
|
6fcd0c57ae | ||
|
|
de66bfc082 | ||
|
|
5d85739358 | ||
|
|
8fd11e82c4 | ||
|
|
9f9b14a76d | ||
|
|
78c2a49ec8 | ||
|
|
0e54ec4c04 | ||
|
|
58a30f6ab8 | ||
|
|
453d8945bb | ||
|
|
9e50cb6e20 | ||
|
|
346772a8b3 | ||
|
|
ef392e5938 | ||
|
|
4619375c14 | ||
|
|
10fe72498a | ||
|
|
5b22bc0480 | ||
|
|
ae7da0ec74 | ||
|
|
7dd17c6344 | ||
|
|
d8535bf938 | ||
|
|
b45c61eff9 | ||
|
|
7cfd83f66a | ||
|
|
b5bead9028 | ||
|
|
41678eb097 | ||
|
|
ecd3e4ceb9 | ||
|
|
22fdaeae2c | ||
|
|
41034bb3f3 | ||
|
|
76783377ec | ||
|
|
4522490bd5 | ||
|
|
cd58c054ca | ||
|
|
94579eace5 | ||
|
|
2ab2f44e1d | ||
|
|
fa35018769 | ||
|
|
94be902ce1 | ||
|
|
bcc5bfde9c | ||
|
|
9522674c87 | ||
|
|
c91a3062d5 | ||
|
|
54d785d0c0 | ||
|
|
36218ac1b1 | ||
|
|
6388a2ba3f | ||
|
|
9c8375da99 | ||
|
|
0cef5390f7 | ||
|
|
1bd18be372 | ||
|
|
d07664b44c | ||
|
|
ce116d9dfa | ||
|
|
372ec09c47 | ||
|
|
78f446f68e | ||
|
|
55da189315 | ||
|
|
e752b05425 | ||
|
|
0c83a26dc7 | ||
|
|
286638fa04 | ||
|
|
47d6c3d5d3 | ||
|
|
f529fb0e55 | ||
|
|
e459a727e9 | ||
|
|
04bc5f5788 | ||
|
|
571d3cdc0f | ||
|
|
414a1aca4f | ||
|
|
d8c57ed317 | ||
|
|
e8c8ef1142 | ||
|
|
4d3dc5b873 | ||
|
|
ac5b19dee1 | ||
|
|
fdfb9f4dc1 | ||
|
|
187aebd74f | ||
|
|
9d05573f24 | ||
|
|
58902915f6 | ||
|
|
d47b015100 | ||
|
|
5458d3547a | ||
|
|
70d64be033 | ||
|
|
de7edd5bb1 | ||
|
|
f0e6671538 | ||
|
|
b4b1ba10f6 | ||
|
|
e50c46c1ed | ||
|
|
3dbb35c3aa | ||
|
|
3a76c4f4fd | ||
|
|
69b59079c5 | ||
|
|
42aff269d1 | ||
|
|
efe59c22e4 | ||
|
|
37a9a543d6 | ||
|
|
0800d7ae88 | ||
|
|
69b8b367c1 | ||
|
|
9494e3c26f | ||
|
|
ed3a616e62 | ||
|
|
89e7f415a9 | ||
|
|
c3e7b6af60 | ||
|
|
3af2d9f986 | ||
|
|
09ff1caf42 | ||
|
|
0e6d48d9dc | ||
|
|
b7ea04661a | ||
|
|
73d8d6e638 | ||
|
|
9ac66cbeb3 | ||
|
|
773aa021be | ||
|
|
5c3e1c1444 | ||
|
|
3260258b56 | ||
|
|
a88d52fe88 | ||
|
|
60f44d314b | ||
|
|
d4e9829329 | ||
|
|
e17098cc70 | ||
|
|
e17936158a | ||
|
|
760e69675c | ||
|
|
193f11171a | ||
|
|
f11ac23e1f | ||
|
|
c4770e6571 | ||
|
|
b0e94c996b | ||
|
|
85d63b071c | ||
|
|
db81598525 | ||
|
|
86f45a11ef | ||
|
|
1d516be779 | ||
|
|
87b7e74770 | ||
|
|
ae6a207d4e | ||
|
|
efd34c151a | ||
|
|
2c3c0f60e7 | ||
|
|
bad1b97f8e | ||
|
|
fcebf64468 | ||
|
|
53953a8157 | ||
|
|
1201dc60ef | ||
|
|
23d7761e50 | ||
|
|
6ee67d6c61 | ||
|
|
efb1542a39 | ||
|
|
bff370003b | ||
|
|
9976585f87 | ||
|
|
18b4cee5fd | ||
|
|
491f179a03 | ||
|
|
57a57ef771 | ||
|
|
abfa2e4cf7 | ||
|
|
93a159dca5 | ||
|
|
9968a27e92 | ||
|
|
e4c3c1aa80 | ||
|
|
abdbf61acf | ||
|
|
113145a42a | ||
|
|
22b423b651 | ||
|
|
87f4334728 | ||
|
|
e628b4bb68 | ||
|
|
81fe0ccbb7 | ||
|
|
32c9276fdb | ||
|
|
16c1117af6 | ||
|
|
d9844cfe8d | ||
|
|
364e7909f4 | ||
|
|
fded4f6b11 | ||
|
|
e02030364d | ||
|
|
2684737d9e | ||
|
|
028998d040 | ||
|
|
c760a49c47 | ||
|
|
727a1ea4a3 | ||
|
|
212f0b2ad4 | ||
|
|
bf212b986d | ||
|
|
3a1d88386c | ||
|
|
9e1be05634 | ||
|
|
b778d4e3d4 | ||
|
|
89735dbd33 | ||
|
|
d29a8e216b | ||
|
|
4ea255ca6a | ||
|
|
c86dc73d8c | ||
|
|
88ce181031 | ||
|
|
d83de563c1 | ||
|
|
7fa81b5dae | ||
|
|
ef31328aab | ||
|
|
b8b3af6fc9 | ||
|
|
02d77ae1f1 | ||
|
|
4df146188f | ||
|
|
0e8a449ea9 | ||
|
|
c70312bd04 | ||
|
|
e93271356f | ||
|
|
cfc26729cf | ||
|
|
ddc71b5620 | ||
|
|
ac925ed41c | ||
|
|
2dfb7af66e | ||
|
|
3975f2b3ab | ||
|
|
04eb661e57 | ||
|
|
18e7744e42 | ||
|
|
3c5459a33b | ||
|
|
92e053a133 | ||
|
|
1d5db5f77d | ||
|
|
2036f0bd4c | ||
|
|
6e78c1fc8b | ||
|
|
5d072d21e9 | ||
|
|
d5f0d6ed3e | ||
|
|
4c3cb0f347 | ||
|
|
c592313d9a | ||
|
|
ad982d20c2 | ||
|
|
b3242e8c04 | ||
|
|
d4494a8aeb | ||
|
|
cc86f54d65 | ||
|
|
db80c9b96e | ||
|
|
4c16a42f39 | ||
|
|
29dcd478a0 | ||
|
|
425d94ee43 | ||
|
|
8f44ad308d | ||
|
|
fa29909f05 | ||
|
|
9757fef8a7 | ||
|
|
a0c6c8ba53 | ||
|
|
49d5b3fcdc | ||
|
|
25ee5f3d30 | ||
|
|
922c239863 | ||
|
|
d8a6109085 | ||
|
|
6e44da10fe | ||
|
|
02d1f6a04d | ||
|
|
fe2b13a46a | ||
|
|
92539cad68 | ||
|
|
556a598f2d | ||
|
|
8d80f2ffe7 | ||
|
|
8280f66aa1 | ||
|
|
a0b375c157 | ||
|
|
6a007344ae | ||
|
|
920d5c6c3a | ||
|
|
98f8926998 | ||
|
|
76c8d4801e | ||
|
|
4b8731ba11 | ||
|
|
789ea9aac8 | ||
|
|
590b5b614c | ||
|
|
45dc4f6ff0 | ||
|
|
7037d84d52 | ||
|
|
7d6b2044d5 | ||
|
|
fdde5e45cf | ||
|
|
bae0099c7c | ||
|
|
42c17bc4bf | ||
|
|
f8a901c2a5 | ||
|
|
a30624d6d4 | ||
|
|
8f8eb41e0f | ||
|
|
47c0226a61 | ||
|
|
26a50d918b | ||
|
|
401f6b152c | ||
|
|
1b5a9b02c2 | ||
|
|
dedad14ae4 | ||
|
|
f84799c8ef | ||
|
|
732007da8e | ||
|
|
8f809d9a9e | ||
|
|
f6cab2711f | ||
|
|
1a6f54b970 | ||
|
|
1555785294 | ||
|
|
2f9429cbf0 | ||
|
|
4daefc7bd5 | ||
|
|
a7a30627a9 | ||
|
|
5bca9ef039 | ||
|
|
b8eca2a68e | ||
|
|
566992c331 | ||
|
|
36b36267ec | ||
|
|
21a986034e | ||
|
|
ee24ff2d83 | ||
|
|
9e6f753640 | ||
|
|
de2e32c5d4 | ||
|
|
9d1998b3fd | ||
|
|
181b12f0a9 | ||
|
|
47521cf178 | ||
|
|
9c5f190fcc | ||
|
|
9f14a7aa9e | ||
|
|
f9e98a2634 | ||
|
|
c08395ca92 | ||
|
|
10957f59c5 | ||
|
|
eb7c14c4ae | ||
|
|
11a6e081a2 | ||
|
|
16604a111b | ||
|
|
cc1462a7f8 | ||
|
|
f2a90228fb | ||
|
|
0581894b7e | ||
|
|
5b79413e87 | ||
|
|
85e736c73f | ||
|
|
b64df99134 | ||
|
|
c345ce6d02 | ||
|
|
91a0681ae9 | ||
|
|
c613e8e676 | ||
|
|
1003510a75 | ||
|
|
63a5a87471 | ||
|
|
da7924d079 | ||
|
|
bb2a9238d9 | ||
|
|
8806e62a9f | ||
|
|
78a0ff615a | ||
|
|
706ac0f8e1 | ||
|
|
bd8a27b100 | ||
|
|
60108dfbf6 | ||
|
|
bd9102f851 | ||
|
|
e7d5d08892 | ||
|
|
f003a108e3 | ||
|
|
a76dda2b19 | ||
|
|
013694476e | ||
|
|
3d02baf567 | ||
|
|
6f5465aeaf | ||
|
|
fdbc789694 | ||
|
|
779cf1c234 | ||
|
|
1f330c6737 | ||
|
|
3489ec51d5 | ||
|
|
0423321cb1 | ||
|
|
06c126ab6b | ||
|
|
1f572ff8de | ||
|
|
03bd461984 | ||
|
|
ba941f7f69 | ||
|
|
bf7bae82ae | ||
|
|
495e7a015c | ||
|
|
3364dc4bee | ||
|
|
499125c9a3 | ||
|
|
c32288bd6b | ||
|
|
c8b44878c5 | ||
|
|
ae30bf4f04 | ||
|
|
a4efdc43d7 | ||
|
|
52572d5883 | ||
|
|
b43a6f2d29 | ||
|
|
f1a55a211e | ||
|
|
0975252976 | ||
|
|
cef45efc16 | ||
|
|
bc1b3c837a | ||
|
|
88f79bb2a5 | ||
|
|
7149bbc3d9 | ||
|
|
aefa5b0f19 | ||
|
|
96ddecab81 | ||
|
|
271283cd03 | ||
|
|
5fb2ed9464 | ||
|
|
f967df7f01 | ||
|
|
5a9550d388 | ||
|
|
b071fac2cf | ||
|
|
fdcb05b2c4 | ||
|
|
fc26e16ce2 | ||
|
|
1c62116e25 | ||
|
|
739488f613 | ||
|
|
f72681f998 | ||
|
|
a61d023583 | ||
|
|
c881069ff8 | ||
|
|
5200d1a476 | ||
|
|
fa8eecaf8f | ||
|
|
2033c90921 | ||
|
|
8cada12c48 |
17
.dockerignore
Normal file
17
.dockerignore
Normal file
@@ -0,0 +1,17 @@
|
||||
# Keep docker build context small (Windows-friendly).
|
||||
.git
|
||||
.github
|
||||
**/target
|
||||
**/.claw-rag
|
||||
**/.claw
|
||||
**/.claude
|
||||
**/.cursor
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/build
|
||||
**/*.log
|
||||
**/*.tmp
|
||||
**/*.sqlite
|
||||
**/*.sqlite-wal
|
||||
**/*.sqlite-shm
|
||||
**/.DS_Store
|
||||
31
.github/hooks/pre-push
vendored
Executable file
31
.github/hooks/pre-push
vendored
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Claw Code local pre-push safety gate.
|
||||
#
|
||||
# Install with:
|
||||
# git config core.hooksPath .github/hooks
|
||||
#
|
||||
# This intentionally mirrors the CI build gate so stale field/enum references are
|
||||
# caught before pushing to main or PR branches.
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel 2>/dev/null)"
|
||||
cd "$repo_root"
|
||||
|
||||
if [[ -x scripts/roadmap-check-ids.sh ]]; then
|
||||
echo "pre-push: scripts/roadmap-check-ids.sh" >&2
|
||||
scripts/roadmap-check-ids.sh >&2
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_CLAW_PRE_PUSH_BUILD:-}" == "1" ]]; then
|
||||
echo "pre-push: SKIP_CLAW_PRE_PUSH_BUILD=1 set; skipping cargo workspace build" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ! -f rust/Cargo.toml ]]; then
|
||||
echo "pre-push: rust/Cargo.toml not found; skipping cargo workspace build" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
build_cmd=(cargo build --manifest-path rust/Cargo.toml --workspace --locked)
|
||||
echo "pre-push: ${build_cmd[*]}" >&2
|
||||
"${build_cmd[@]}"
|
||||
8
.github/workflows/rust-ci.yml
vendored
8
.github/workflows/rust-ci.yml
vendored
@@ -21,6 +21,8 @@ on:
|
||||
- PARITY.md
|
||||
- PHILOSOPHY.md
|
||||
- ROADMAP.md
|
||||
- scripts/roadmap-*.sh
|
||||
- tests/test_roadmap_helpers.py
|
||||
- docs/**
|
||||
- rust/**
|
||||
pull_request:
|
||||
@@ -41,6 +43,8 @@ on:
|
||||
- PARITY.md
|
||||
- PHILOSOPHY.md
|
||||
- ROADMAP.md
|
||||
- scripts/roadmap-*.sh
|
||||
- tests/test_roadmap_helpers.py
|
||||
- docs/**
|
||||
- rust/**
|
||||
workflow_dispatch:
|
||||
@@ -72,6 +76,10 @@ jobs:
|
||||
run: python .github/scripts/check_doc_source_of_truth.py
|
||||
- name: Check release policy docs and local links
|
||||
run: python .github/scripts/check_release_readiness.py
|
||||
- name: Check ROADMAP ids
|
||||
run: scripts/roadmap-check-ids.sh
|
||||
- name: Check ROADMAP helper behavior
|
||||
run: python -m unittest discover -s tests -p test_roadmap_helpers.py
|
||||
|
||||
fmt:
|
||||
name: cargo fmt
|
||||
|
||||
25
.github/workflows/rust.yml
vendored
Normal file
25
.github/workflows/rust.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Rust
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: rust
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build
|
||||
run: cargo build --verbose
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,6 +8,7 @@ archive/
|
||||
# Claw Code local artifacts
|
||||
.claw/settings.local.json
|
||||
.claw/sessions/
|
||||
.claw/rules.local/
|
||||
.clawhip/
|
||||
status-help.txt
|
||||
# Legacy Python port session scratch artifacts
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
"duplicate_roadmap_heading_lines": [],
|
||||
"roadmap_actions_mapped": 542,
|
||||
"roadmap_actions_total": 542,
|
||||
"roadmap_headings_mapped": 124,
|
||||
"roadmap_headings_total": 124,
|
||||
"roadmap_headings_mapped": 127,
|
||||
"roadmap_headings_total": 127,
|
||||
"unmapped_roadmap_heading_lines": []
|
||||
},
|
||||
"generated_at": "2026-05-14T08:13:45+00:00",
|
||||
"generated_at": "2026-05-25T04:30:33+00:00",
|
||||
"generation_policy": {
|
||||
"release_buckets": [
|
||||
"2.x_intake",
|
||||
@@ -14823,6 +14823,69 @@
|
||||
"status": "context",
|
||||
"title": "Parity source metadata: openai/codex",
|
||||
"verification_required": "none_context_only"
|
||||
},
|
||||
{
|
||||
"category": "boot",
|
||||
"deferral_rationale": "",
|
||||
"dependencies": [
|
||||
"stream_0_governance"
|
||||
],
|
||||
"id": "CC2-RM-H0125-pinpoint-693-claw-analog-bootstrap-plan",
|
||||
"lifecycle_status": "done_verify",
|
||||
"owner_lane": "stream_1_worker_boot_session_control",
|
||||
"release_bucket": "alpha_blocker",
|
||||
"source_anchor": "ROADMAP.md:L7528",
|
||||
"source_context": "Clawable Coding Harness Roadmap > Pinpoint follow-up intake",
|
||||
"source_level": 2,
|
||||
"source_line": 7528,
|
||||
"source_ordinal": null,
|
||||
"source_path": "ROADMAP.md",
|
||||
"source_type": "roadmap_heading",
|
||||
"status": "done_verify",
|
||||
"title": "Pinpoint #693. `claw-analog` bootstrap-plan phase parser silently falls back to `\"unknown\"` \u2014 `lib.rs:1114` uses `.unwrap_or(\"unknown\")` for phase field; unrecognized phases emit opaque kind instead of typed error",
|
||||
"verification_required": "targeted_regression_or_acceptance_test_required"
|
||||
},
|
||||
{
|
||||
"category": "branch_recovery",
|
||||
"deferral_rationale": "",
|
||||
"dependencies": [
|
||||
"stream_0_governance"
|
||||
],
|
||||
"id": "CC2-RM-H0126-pinpoint-694-no-pre-push-cargo-build-gat",
|
||||
"lifecycle_status": "done_verify",
|
||||
"owner_lane": "stream_3_branch_test_recovery",
|
||||
"release_bucket": "alpha_blocker",
|
||||
"source_anchor": "ROADMAP.md:L7538",
|
||||
"source_context": "Clawable Coding Harness Roadmap > Pinpoint follow-up intake",
|
||||
"source_level": 2,
|
||||
"source_line": 7538,
|
||||
"source_ordinal": null,
|
||||
"source_path": "ROADMAP.md",
|
||||
"source_type": "roadmap_heading",
|
||||
"status": "done_verify",
|
||||
"title": "Pinpoint #694. No pre-push `cargo build` gate \u2014 stale field refs (`retry_after`, `Team` variant, `config_load_error_kind`) broke main build undetected until CI",
|
||||
"verification_required": "git_fixture_or_recovery_recipe_test"
|
||||
},
|
||||
{
|
||||
"category": "boot",
|
||||
"deferral_rationale": "",
|
||||
"dependencies": [
|
||||
"stream_0_governance"
|
||||
],
|
||||
"id": "CC2-RM-H0127-pinpoint-695-agent-starts-in-stale-wrong",
|
||||
"lifecycle_status": "done_verify",
|
||||
"owner_lane": "stream_1_worker_boot_session_control",
|
||||
"release_bucket": "alpha_blocker",
|
||||
"source_anchor": "ROADMAP.md:L7548",
|
||||
"source_context": "Clawable Coding Harness Roadmap > Pinpoint follow-up intake",
|
||||
"source_level": 2,
|
||||
"source_line": 7548,
|
||||
"source_ordinal": null,
|
||||
"source_path": "ROADMAP.md",
|
||||
"source_type": "roadmap_heading",
|
||||
"status": "done_verify",
|
||||
"title": "Pinpoint #695. Agent starts in stale/wrong worktree and burns a full turn before noticing \u2014 no pre-flight check for \"file exists on current branch\" or \"this .git is writable from sandbox\"",
|
||||
"verification_required": "worker_boot_state_machine_or_cli_json_contract_test"
|
||||
}
|
||||
],
|
||||
"schema_version": "cc2.board.v1",
|
||||
@@ -14839,7 +14902,7 @@
|
||||
"root": "/Users/bellman/Documents/Workspace/claw-code/.omx/research"
|
||||
},
|
||||
"roadmap": {
|
||||
"heading_count": 124,
|
||||
"heading_count": 127,
|
||||
"ordered_action_count": 542,
|
||||
"path": "ROADMAP.md",
|
||||
"sha256_prefix": "2aba3315e52f3079"
|
||||
@@ -14850,15 +14913,15 @@
|
||||
"adoption_overlay": 357,
|
||||
"parity_overlay": 20,
|
||||
"stream_0_governance": 221,
|
||||
"stream_1_worker_boot_session_control": 15,
|
||||
"stream_1_worker_boot_session_control": 17,
|
||||
"stream_2_event_reporting_contracts": 73,
|
||||
"stream_3_branch_test_recovery": 16,
|
||||
"stream_3_branch_test_recovery": 17,
|
||||
"stream_4_claws_first_execution": 5,
|
||||
"stream_5_plugin_mcp_lifecycle": 22
|
||||
},
|
||||
"by_release_bucket": {
|
||||
"2.x_intake": 30,
|
||||
"alpha_blocker": 240,
|
||||
"alpha_blocker": 243,
|
||||
"beta_adoption": 417,
|
||||
"context": 15,
|
||||
"ga_ecosystem": 22,
|
||||
@@ -14870,13 +14933,13 @@
|
||||
"latest_open_issue": 30,
|
||||
"parity_repo_context": 2,
|
||||
"roadmap_action": 542,
|
||||
"roadmap_heading": 124
|
||||
"roadmap_heading": 127
|
||||
},
|
||||
"by_status": {
|
||||
"active": 73,
|
||||
"context": 15,
|
||||
"deferred_with_rationale": 9,
|
||||
"done_verify": 313,
|
||||
"done_verify": 316,
|
||||
"open": 285,
|
||||
"rejected_not_claw": 2,
|
||||
"stale_done": 31,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Claw Code 2.0 Canonical Board
|
||||
|
||||
Generated from board schema: `2026-05-14T08:13:45+00:00`
|
||||
Generated from board schema: `2026-05-25T04:30:33+00:00`
|
||||
Schema version: `cc2.board.v1`
|
||||
Ultragoal mutation policy: `.omx/ultragoal` is leader-owned and was not modified by this rendering task.
|
||||
|
||||
@@ -8,7 +8,7 @@ Ultragoal mutation policy: `.omx/ultragoal` is leader-owned and was not modified
|
||||
|
||||
| Source | Frozen evidence |
|
||||
| --- | --- |
|
||||
| Roadmap | `ROADMAP.md` sha256 prefix `2aba3315e52f3079`; 124 headings; 542 ordered actions |
|
||||
| Roadmap | `ROADMAP.md` sha256 prefix `2aba3315e52f3079`; 127 headings; 542 ordered actions |
|
||||
| Approved plan | `.omx/plans/claw-code-2-0-adaptive-plan.md` sha256 prefix `e7ef6faf23bfc16b` |
|
||||
| Research bundle | root `/Users/bellman/Documents/Workspace/claw-code/.omx/research`; latest open issues 30; issue corpus 1000; codex/opencode clone metadata included |
|
||||
|
||||
@@ -16,11 +16,11 @@ Ultragoal mutation policy: `.omx/ultragoal` is leader-owned and was not modified
|
||||
|
||||
| Coverage gate | Mapped | Total | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| ROADMAP headings | 124 | 124 | PASS |
|
||||
| ROADMAP headings | 127 | 127 | PASS |
|
||||
| ROADMAP ordered actions | 542 | 542 | PASS |
|
||||
| Duplicate heading lines | 0 | 0 | PASS |
|
||||
|
||||
Total canonical board items: **729**
|
||||
Total canonical board items: **732**
|
||||
|
||||
## Lifecycle Enum Reference
|
||||
|
||||
@@ -29,7 +29,7 @@ Total canonical board items: **729**
|
||||
| `active` | 73 | Current Claw Code 2.0 implementation surface that should remain visible on the board. |
|
||||
| `context` | 15 | Context-only heading or evidence anchor; not an implementation work item. |
|
||||
| `deferred_with_rationale` | 9 | Intentionally deferred; rationale must be present in the board item. |
|
||||
| `done_verify` | 313 | Marked as done upstream but retained for verification against current CC2 behavior. |
|
||||
| `done_verify` | 316 | Marked as done upstream but retained for verification against current CC2 behavior. |
|
||||
| `open` | 285 | Actionable unresolved work that needs implementation or acceptance evidence. |
|
||||
| `rejected_not_claw` | 2 | Excluded because it is not Claw Code product work. |
|
||||
| `stale_done` | 31 | Historically completed or merged work that may be stale and needs freshness checks before relying on it. |
|
||||
@@ -40,7 +40,7 @@ Total canonical board items: **729**
|
||||
| Bucket | Count | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `2.x_intake` | 30 | Post-2.0 intake or follow-up candidate retained for sequencing. |
|
||||
| `alpha_blocker` | 240 | Must be resolved before alpha-quality autonomous coding lanes are dependable. |
|
||||
| `alpha_blocker` | 243 | Must be resolved before alpha-quality autonomous coding lanes are dependable. |
|
||||
| `beta_adoption` | 417 | Important for broader dogfood/adoption once alpha blockers are controlled. |
|
||||
| `context` | 15 | Non-actionable roadmap context. |
|
||||
| `ga_ecosystem` | 22 | Required for mature plugin/MCP/provider ecosystem behavior. |
|
||||
@@ -54,9 +54,9 @@ Total canonical board items: **729**
|
||||
| Adoption overlay — user-visible parity and release polish | 357 | 329 | `deferred_with_rationale` 3, `done_verify` 237, `open` 92, `rejected_not_claw` 2, `stale_done` 23 |
|
||||
| Parity overlay — opencode/codex comparison context | 20 | 16 | `context` 2, `deferred_with_rationale` 1, `done_verify` 5, `open` 11, `stale_done` 1 |
|
||||
| Stream 0 — Governance, intake, and cross-cutting roadmap triage | 221 | 198 | `active` 6, `context` 13, `deferred_with_rationale` 4, `done_verify` 45, `open` 147, `stale_done` 5, `superseded` 1 |
|
||||
| Stream 1 — Worker boot and session control | 15 | 14 | `active` 8, `deferred_with_rationale` 1, `open` 6 |
|
||||
| Stream 1 — Worker boot and session control | 17 | 16 | `active` 8, `deferred_with_rationale` 1, `done_verify` 2, `open` 6 |
|
||||
| Stream 2 — Event/reporting contracts | 73 | 73 | `active` 45, `done_verify` 20, `open` 8 |
|
||||
| Stream 3 — Branch/test recovery | 16 | 14 | `active` 6, `done_verify` 1, `open` 7, `stale_done` 2 |
|
||||
| Stream 3 — Branch/test recovery | 17 | 15 | `active` 6, `done_verify` 2, `open` 7, `stale_done` 2 |
|
||||
| Stream 4 — Claws-first task execution | 5 | 5 | `active` 4, `done_verify` 1 |
|
||||
| Stream 5 — Plugin/MCP lifecycle | 22 | 22 | `active` 4, `done_verify` 4, `open` 14 |
|
||||
|
||||
@@ -68,7 +68,7 @@ Total canonical board items: **729**
|
||||
| `latest_open_issue` | 30 |
|
||||
| `parity_repo_context` | 2 |
|
||||
| `roadmap_action` | 542 |
|
||||
| `roadmap_heading` | 124 |
|
||||
| `roadmap_heading` | 127 |
|
||||
|
||||
## Board Items by Stream
|
||||
|
||||
@@ -704,6 +704,8 @@ Total canonical board items: **729**
|
||||
| `CC2-RM-A0363-surface-inconsistency-cluster-of-3-after` | **Surface inconsistency (cluster of 3)**: after #143 Phase 1, the behavior matrix is: | `ROADMAP.md:L5515` / `roadmap_action` | `alpha_blocker` | `open` | `plugin_mcp_lifecycle_contract_test` | `stream_1_worker_boot_session_control` | — |
|
||||
| `CC2-RM-A0391-remove-the-error-prefix-from-format-unkn` | Remove the "error:" prefix from format_unknown_verb_option (already added by top-level handler) | `ROADMAP.md:L5916` / `roadmap_action` | `alpha_blocker` | `open` | `worker_boot_state_machine_or_cli_json_contract_test` | none | — |
|
||||
| `CC2-RM-A0512-system-prompt-output-format-json-exposes` | **`system-prompt --output-format json` exposes `"__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__"` as a literal element in the `sections` array — an internal split delimiter leaked into the public structured output** — dogfooded 2026-04-30 by Jobdori on `e939777f`. Running `claw system-prompt --output-format json` returns `{"kind":"system-prompt","message":"<full prose>","sections":["You are an interactive agent...", "# System\n...", "# Doing tasks\n...", "# Executing actions with care\n...", "__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__", "# Environment context\n...", "# Project context\n...", "# Claude instructions\n...", "# Runtime config\n..."]}`. The `sections` array has 9 elements; element index 4 is the raw string `"__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__"`. This internal sentinel marks the boundary between the static and dynamic sections of the compiled system prompt, used during assembly to split the prompt at injection time. It appears in the public JSON output verbatim as a first-class section, indistinguishable from real sections by type alone. Automation that iterates `sections[]` must special-case this sentinel or it will process an internal implementation string as if it were a real system prompt section. **Required fix shape:** (a) strip `"__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__"` and any similar internal delimiters from the `sections` array before serializing to JSON; (b) if the static/dynamic boundary is semantically meaningful for callers, expose it as a structured metadata field such as `boundary_index:4` or as a `section_type:"static"\|"dynamic"` field on each section entry, not as a raw sentinel string in the array; (c) rename the `sections` type from `string[]` to `[{id, type, content}]` to enable this without breaking the boundary signal; (d) add regression coverage proving the `system-prompt --output-format json` output's `sections` array contains no elements whose value equals `"__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__"` or matches `/__[A-Z_]+__/`. **Why this matters:** internal sentinel strings in public JSON are a contract liability — they couple the wire format to internal implementation details. Any refactor that renames or removes the sentinel breaks callers that don't special-case it, and automation that doesn't know to filter it will miscount, misparse, or misrender the system prompt. Source: Jobdori live dogfood, `e939777f`, 2026-04-30. | `ROADMAP.md:L6333` / `roadmap_action` | `beta_adoption` | `open` | `worker_boot_state_machine_or_cli_json_contract_test` | none | — |
|
||||
| `CC2-RM-H0125-pinpoint-693-claw-analog-bootstrap-plan` | Pinpoint #693. `claw-analog` bootstrap-plan phase parser silently falls back to `"unknown"` — `lib.rs:1114` uses `.unwrap_or("unknown")` for phase field; unrecognized phases emit opaque kind instead of typed error | `ROADMAP.md:L7528` / `roadmap_heading` | `alpha_blocker` | `done_verify` | `targeted_regression_or_acceptance_test_required` | `stream_0_governance` | — |
|
||||
| `CC2-RM-H0127-pinpoint-695-agent-starts-in-stale-wrong` | Pinpoint #695. Agent starts in stale/wrong worktree and burns a full turn before noticing — no pre-flight check for "file exists on current branch" or "this .git is writable from sandbox" | `ROADMAP.md:L7548` / `roadmap_heading` | `alpha_blocker` | `done_verify` | `worker_boot_state_machine_or_cli_json_contract_test` | `stream_0_governance` | — |
|
||||
|
||||
### Stream 2 — Event/reporting contracts
|
||||
|
||||
@@ -803,6 +805,7 @@ Total canonical board items: **729**
|
||||
| `CC2-RM-A0410-remediation-registry-a-function-remediat` | **Remediation registry:** A function `remediation_for(kind: &str, operation: &str) -> Remediation` that maps `(error_kind, operation_context)` pairs to stable remediation structs: | `ROADMAP.md:L6041` / `roadmap_action` | `alpha_blocker` | `open` | `targeted_regression_or_acceptance_test_required` | `stream_2_event_reporting_contracts` | — |
|
||||
| `CC2-RM-A0411-stable-hint-outputs-per-class-each-error` | **Stable hint outputs per class:** Each `error_kind` maps to exactly one remediation shape. No more prose splitting. | `ROADMAP.md:L6049` / `roadmap_action` | `alpha_blocker` | `open` | `targeted_regression_or_acceptance_test_required` | `stream_2_event_reporting_contracts` | — |
|
||||
| `CC2-RM-A0412-golden-fixture-tests-test-each-kind-oper` | **Golden fixture tests:** Test each `(kind, operation)` pair against expected remediation output as golden fixtures instead of the current `split_error_hint()` string hacks. | `ROADMAP.md:L6050` / `roadmap_action` | `alpha_blocker` | `open` | `targeted_regression_or_acceptance_test_required` | `stream_2_event_reporting_contracts` | — |
|
||||
| `CC2-RM-H0126-pinpoint-694-no-pre-push-cargo-build-gat` | Pinpoint #694. No pre-push `cargo build` gate — stale field refs (`retry_after`, `Team` variant, `config_load_error_kind`) broke main build undetected until CI | `ROADMAP.md:L7538` / `roadmap_heading` | `alpha_blocker` | `done_verify` | `git_fixture_or_recovery_recipe_test` | `stream_0_governance` | — |
|
||||
|
||||
### Stream 4 — Claws-first task execution
|
||||
|
||||
|
||||
@@ -55,8 +55,15 @@ REQUIRED_ITEM_FIELDS = [
|
||||
|
||||
|
||||
def load_board(path: Path) -> dict[str, Any]:
|
||||
with path.open() as f:
|
||||
board = json.load(f)
|
||||
try:
|
||||
with path.open() as f:
|
||||
board = json.load(f)
|
||||
except FileNotFoundError:
|
||||
raise ValueError(f"board not found at {path}") from None
|
||||
except IsADirectoryError:
|
||||
raise ValueError(f"board path is a directory: {path}") from None
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"invalid board JSON at {path}: {exc}") from None
|
||||
if not isinstance(board, dict):
|
||||
raise ValueError("board JSON root must be an object")
|
||||
items = board.get("items")
|
||||
@@ -226,7 +233,11 @@ def main() -> int:
|
||||
parser.add_argument("--check", action="store_true", help="fail if board_md is not up to date")
|
||||
args = parser.parse_args()
|
||||
|
||||
board = load_board(args.board_json)
|
||||
try:
|
||||
board = load_board(args.board_json)
|
||||
except ValueError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
errors = validate_board(board)
|
||||
if errors:
|
||||
for error in errors:
|
||||
@@ -234,14 +245,22 @@ def main() -> int:
|
||||
return 1
|
||||
rendered = render(board)
|
||||
if args.check:
|
||||
existing = args.board_md.read_text() if args.board_md.exists() else ""
|
||||
try:
|
||||
existing = args.board_md.read_text() if args.board_md.exists() else ""
|
||||
except IsADirectoryError:
|
||||
print(f"ERROR: board markdown path is a directory: {args.board_md}", file=sys.stderr)
|
||||
return 1
|
||||
if existing != rendered:
|
||||
print(f"ERROR: {args.board_md} is not up to date", file=sys.stderr)
|
||||
return 1
|
||||
print(f"PASS: {args.board_md} is up to date and roadmap coverage is complete")
|
||||
return 0
|
||||
args.board_md.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.board_md.write_text(rendered)
|
||||
try:
|
||||
args.board_md.write_text(rendered)
|
||||
except IsADirectoryError:
|
||||
print(f"ERROR: board markdown path is a directory: {args.board_md}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"wrote {args.board_md}")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"createdAt": "2026-05-14T07:53:46.061Z",
|
||||
"updatedAt": "2026-05-15T04:38:54.887Z",
|
||||
"updatedAt": "2026-05-25T04:18:52.711Z",
|
||||
"briefPath": ".omx/ultragoal/brief.md",
|
||||
"goalsPath": ".omx/ultragoal/goals.json",
|
||||
"ledgerPath": ".omx/ultragoal/ledger.jsonl",
|
||||
@@ -148,7 +148,19 @@
|
||||
"updatedAt": "2026-05-15T04:38:54.887Z",
|
||||
"evidence": "G012-final-gate complete: team g012-final-gate-ultra-e61d2271 8/8 tasks complete; final gate log /tmp/g012-final-quality-gate-pass4.log; commit 04c2abb pushed; docs/pr-triage-g012-final-gate.json docs/pr-issue-resolution-gate.md docs/g012-final-release-readiness-report.md; .omx/ultragoal/goals.json and ledger.jsonl updated; aiSlopCleaner and codeReview evidence included in quality gate JSON.",
|
||||
"completedAt": "2026-05-15T04:38:54.887Z"
|
||||
},
|
||||
{
|
||||
"id": "G013-implement-roadmap-pinpoints-693-695",
|
||||
"title": "Implement ROADMAP pinpoints #693-#695",
|
||||
"objective": "Map and implement the newly appended ROADMAP.md pinpoints #693, #694, and #695 after reset to origin/main: typed claw-analog bootstrap phase errors, a local pre-push cargo build gate, and startup/worktree preflight diagnostics; update CC2 board/coverage and verify with targeted and workspace checks.",
|
||||
"status": "in_progress",
|
||||
"attempt": 1,
|
||||
"createdAt": "2026-05-25T04:18:43.420Z",
|
||||
"updatedAt": "2026-05-25T04:18:52.711Z",
|
||||
"evidence": "Current-head verification after reset: python3 scripts/validate_cc2_board.py --board .omx/cc2/board.json failed with unmapped ROADMAP headings [7528,7538,7548], corresponding to Pinpoints #693-#695.",
|
||||
"startedAt": "2026-05-25T04:18:52.711Z"
|
||||
}
|
||||
],
|
||||
"codexObjective": "Complete the approved Claw Code 2.0 ultragoal delivery: implement all classified ROADMAP.md backlog work through execution-sized stream goals G001-G012, using .omx/ultragoal/ledger.jsonl as the durable audit trail and .omx/plans/claw-code-2-0-adaptive-plan.md as the source plan."
|
||||
"codexObjective": "Complete the approved Claw Code 2.0 ultragoal delivery: implement all classified ROADMAP.md backlog work through execution-sized stream goals G001-G012, using .omx/ultragoal/ledger.jsonl as the durable audit trail and .omx/plans/claw-code-2-0-adaptive-plan.md as the source plan.",
|
||||
"activeGoalId": "G013-implement-roadmap-pinpoints-693-695"
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -33,6 +33,41 @@ cargo build --workspace
|
||||
.\target\debug\claw.exe --help
|
||||
```
|
||||
|
||||
## Local pre-push build gate
|
||||
|
||||
Install the repository-local hook to catch stale compile errors before pushing:
|
||||
|
||||
```bash
|
||||
git config core.hooksPath .github/hooks
|
||||
```
|
||||
|
||||
This sets the repo's Git hook directory to `.github/hooks`; if you already use a
|
||||
custom `core.hooksPath`, copy or chain `.github/hooks/pre-push` instead. The hook
|
||||
runs the ROADMAP id guard, then runs
|
||||
`cargo build --manifest-path rust/Cargo.toml --workspace --locked` from the
|
||||
repository root. If you must bypass the cargo build for a docs-only push, set
|
||||
`SKIP_CLAW_PRE_PUSH_BUILD=1`; the hook still runs the ROADMAP guard and prints
|
||||
when the cargo-build escape hatch is used.
|
||||
|
||||
## ROADMAP id allocation
|
||||
|
||||
Before appending a new numeric ROADMAP entry, pull/rebase onto the latest
|
||||
`main`, allocate the id from the file you are about to edit, and run the duplicate
|
||||
id guard before pushing:
|
||||
|
||||
```bash
|
||||
git pull --rebase
|
||||
NEXT=$(scripts/roadmap-next-id.sh)
|
||||
# append "${NEXT}. **...**" to ROADMAP.md
|
||||
scripts/roadmap-check-ids.sh
|
||||
```
|
||||
|
||||
The duplicate guard currently checks helper-era ids (`>=723`) by default so it
|
||||
catches new optimistic-append collisions without failing on legacy numbered lists
|
||||
already present in the historical roadmap. Use `scripts/roadmap-check-ids.sh
|
||||
--min-id 1` for a strict whole-file audit after those legacy collisions are
|
||||
cleaned up.
|
||||
|
||||
## Checks before opening a pull request
|
||||
|
||||
Run the smallest relevant tests for your change, then the broader checks when
|
||||
|
||||
62
README.md
62
README.md
@@ -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>
|
||||
·
|
||||
@@ -226,6 +287,7 @@ Claw Code is built in the open alongside the broader UltraWorkers toolchain:
|
||||
- [oh-my-openagent](https://github.com/code-yeongyu/oh-my-openagent)
|
||||
- [oh-my-claudecode](https://github.com/Yeachan-Heo/oh-my-claudecode)
|
||||
- [oh-my-codex](https://github.com/Yeachan-Heo/oh-my-codex)
|
||||
- [gajae-code](https://github.com/Yeachan-Heo/gajae-code)
|
||||
- [UltraWorkers Discord](https://discord.gg/5TUQKqFWd)
|
||||
|
||||
## Ownership / affiliation disclaimer
|
||||
|
||||
1978
ROADMAP.md
1978
ROADMAP.md
File diff suppressed because one or more lines are too long
177
USAGE.md
177
USAGE.md
@@ -51,26 +51,27 @@ cd rust
|
||||
```
|
||||
|
||||
**Note:** Diagnostic verbs (`doctor`, `status`, `sandbox`, `version`) support `--output-format json` for machine-readable output. Invalid suffix arguments (e.g., `--json`) are now rejected at parse time rather than falling through to prompt dispatch.
|
||||
`version --output-format json` reports structured build provenance including full `git_sha`, derived `git_sha_short`, `is_dirty`, `branch`, `commit_date`, `commit_timestamp`, `rustc_version`, runtime `executable_path`, and `binary_provenance`; JSON keeps the prose report in `human_readable` instead of duplicating it under `message`. `status --output-format json` exposes `workspace.memory_files[]` with `path`, `source`, `origin`, `scope_path`, `outside_project`, `chars`, and `contributes` for every loaded project memory file.
|
||||
|
||||
### Initialize a repository
|
||||
|
||||
Set up a new repository with `.claw` config, `.claw.json`, `.gitignore` entries, and a `CLAUDE.md` guidance file:
|
||||
Set up a new repository with `.claw/settings.json`, `.claw.json`, `.gitignore` entries, and a `CLAUDE.md` guidance file:
|
||||
|
||||
```bash
|
||||
cd /path/to/your/repo
|
||||
./target/debug/claw init
|
||||
```
|
||||
|
||||
Text mode (human-readable) shows artifact creation summary with project path and next steps. Idempotent — running multiple times in the same repo marks already-created files as "skipped".
|
||||
Text mode (human-readable) shows artifact creation summary with project path and next steps. Idempotent — running multiple times in the same repo marks already-created files as "skipped", reports `.claw/` as "partial" when missing sub-files are materialized, and keeps `.claw/sessions/` deferred until the first successful session save.
|
||||
|
||||
JSON mode for scripting:
|
||||
```bash
|
||||
./target/debug/claw init --output-format json
|
||||
```
|
||||
|
||||
Returns structured output with `project_path`, `created[]`, `updated[]`, `skipped[]` arrays (one per artifact), and `artifacts[]` carrying each file's `name` and machine-stable `status` tag. The legacy `message` field preserves backward compatibility.
|
||||
Returns structured output with `project_path`, `created[]`, `updated[]`, `partial[]`, `deferred[]`, and `skipped[]` arrays (one per artifact status), and `artifacts[]` carrying each file's `name` and machine-stable `status` tag. The legacy `message` field preserves backward compatibility.
|
||||
|
||||
**Why structured fields matter:** Claws can detect per-artifact state (`created` vs `updated` vs `skipped`) without substring-matching human prose. Use the `created[]`, `updated[]`, and `skipped[]` arrays for conditional follow-up logic (e.g., only commit if files were actually created, not just updated).
|
||||
**Why structured fields matter:** Claws can detect per-artifact state (`created`, `updated`, `partial`, `deferred`, or `skipped`) without substring-matching human prose. Use the status arrays for conditional follow-up logic (e.g., only commit if files were actually created, not just updated).
|
||||
|
||||
### Interactive REPL
|
||||
|
||||
@@ -86,6 +87,12 @@ cd rust
|
||||
./target/debug/claw prompt "summarize this repository"
|
||||
```
|
||||
|
||||
Pipe prompt text through stdin when automation already produces the prompt body:
|
||||
|
||||
```bash
|
||||
printf 'summarize this repository\n' | ./target/debug/claw prompt --output-format json
|
||||
```
|
||||
|
||||
### Shorthand prompt mode
|
||||
|
||||
```bash
|
||||
@@ -93,6 +100,12 @@ cd rust
|
||||
./target/debug/claw "explain rust/crates/runtime/src/lib.rs"
|
||||
```
|
||||
|
||||
Use the POSIX `--` end-of-flags separator when the shorthand prompt itself begins with `-` or `--`:
|
||||
|
||||
```bash
|
||||
./target/debug/claw -- "-summarize this dash-prefixed text"
|
||||
```
|
||||
|
||||
### JSON output for scripting
|
||||
|
||||
```bash
|
||||
@@ -187,17 +200,24 @@ cd rust
|
||||
./target/debug/claw --permission-mode read-only prompt "summarize Cargo.toml"
|
||||
./target/debug/claw --permission-mode workspace-write prompt "update README.md"
|
||||
./target/debug/claw --allowedTools read,glob "inspect the runtime crate"
|
||||
./target/debug/claw --cwd ../other-workspace status --output-format json
|
||||
```
|
||||
|
||||
Supported permission modes:
|
||||
Global workspace override flags: `--cwd PATH`, `-C PATH`, and `--directory PATH` are accepted before any subcommand. They are validated before command dispatch and take precedence over the process `$PWD`; invalid paths return typed `invalid_cwd` JSON errors in JSON mode.
|
||||
|
||||
- `read-only`
|
||||
- `workspace-write`
|
||||
- `danger-full-access`
|
||||
`--allowedTools` accepts canonical snake_case tool names (for example `read_file`, `glob_search`, `web_fetch`) plus documented aliases such as `read`, `glob`, `Read`, and `WebFetch`. `claw status --output-format json` exposes `allowed_tools.available` and `allowed_tools.aliases`, and invalid values return typed `invalid_tool_name` JSON with `tool_name`, `available`, and `tool_aliases`. A missing value before a subcommand or another flag returns `missing_argument` with `argument:"--allowedTools"`.
|
||||
|
||||
`--output-format` accepts `text` or `json` case-insensitively and normalizes to the canonical lowercase modes. `CLAW_OUTPUT_FORMAT=json` sets the default output format for scripts, while an explicit `--output-format` flag takes precedence. Repeating the flag emits a stderr warning and JSON status envelopes expose `format_source`, `format_raw`, and `format_overridden` so composed flag arrays are auditable; invalid values return typed `invalid_output_format` JSON with `value` and `expected:["text","json"]`.
|
||||
|
||||
Supported permission modes (default: `workspace-write`):
|
||||
|
||||
- `read-only` allows inspection-only local tools such as file reads, glob/grep searches, local skills, and status-style reporting. It does not allow workspace mutation, network-fetch/search tools, or arbitrary command execution.
|
||||
- `workspace-write` is the safe default. It allows reads plus direct file-editing tools inside the current workspace, including write/edit/notebook/config/plan-mode updates, while still gating network-fetch/search tools, arbitrary shell execution, subagent launches, REPL subprocesses, and other full-access tools behind an explicit escalation.
|
||||
- `danger-full-access` allows every registered tool requirement, including arbitrary command execution, web fetch/search, subagent launches, subprocess REPLs, and unrestricted tool access. Select it only with an explicit `--permission-mode danger-full-access`, `--dangerously-skip-permissions`, `--skip-permissions`, env, or config opt-in.
|
||||
|
||||
Model aliases currently supported by the CLI:
|
||||
|
||||
- `opus` → `claude-opus-4-6`
|
||||
- `opus` → `claude-opus-4-7`
|
||||
- `sonnet` → `claude-sonnet-4-6`
|
||||
- `haiku` → `claude-haiku-4-5-20251213`
|
||||
|
||||
@@ -225,6 +245,7 @@ export ANTHROPIC_AUTH_TOKEN="anthropic-oauth-or-proxy-bearer-token"
|
||||
| `sk-ant-*` API key | `ANTHROPIC_API_KEY` | `x-api-key: sk-ant-...` | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| OAuth access token (opaque) | `ANTHROPIC_AUTH_TOKEN` | `Authorization: Bearer ...` | an Anthropic-compatible proxy or OAuth flow that mints bearer tokens |
|
||||
| OpenRouter key (`sk-or-v1-*`) | `OPENAI_API_KEY` + `OPENAI_BASE_URL=https://openrouter.ai/api/v1` | `Authorization: Bearer ...` | [openrouter.ai/keys](https://openrouter.ai/keys) |
|
||||
| Ollama local instance | `OLLAMA_HOST` | no auth header (Ollama requires none) | local Ollama server at `http://127.0.0.1:11434` |
|
||||
|
||||
**Why this matters:** if you paste an `sk-ant-*` key into `ANTHROPIC_AUTH_TOKEN`, Anthropic's API will return `401 Invalid bearer token` because `sk-ant-*` keys are rejected over the Bearer header. The fix is a one-line env var swap — move the key to `ANTHROPIC_API_KEY`. Recent `claw` builds detect this exact shape (401 + `sk-ant-*` in the Bearer slot) and append a hint to the error message pointing at the fix.
|
||||
|
||||
@@ -285,13 +306,25 @@ cd rust
|
||||
### Ollama
|
||||
|
||||
```bash
|
||||
export OPENAI_BASE_URL="http://127.0.0.1:11434/v1"
|
||||
unset OPENAI_API_KEY
|
||||
export OLLAMA_HOST="http://127.0.0.1:11434"
|
||||
|
||||
cd rust
|
||||
./target/debug/claw --model "llama3.2" prompt "summarize this repository in one sentence"
|
||||
```
|
||||
|
||||
`OLLAMA_HOST` is the preferred env var. Claw routes all models to the local Ollama endpoint automatically, and no API key is needed. The older `OPENAI_BASE_URL` + `OPENAI_API_KEY` workaround is also supported.
|
||||
|
||||
For Ollama tags with punctuation (for example `qwen2.5-coder:7b`), both approaches work:
|
||||
|
||||
```bash
|
||||
export OLLAMA_HOST="http://127.0.0.1:11434"
|
||||
|
||||
cd rust
|
||||
./target/debug/claw --model "qwen2.5-coder:7b" prompt "reply with ready"
|
||||
```
|
||||
|
||||
If the local server exposes a slash-containing model ID, prefix it with `local/` so Claw selects the OpenAI-compatible transport while sending the remainder verbatim on the wire: `--model "local/Qwen/Qwen3.6-27B-FP8"`.
|
||||
|
||||
### OpenRouter
|
||||
|
||||
```bash
|
||||
@@ -334,7 +367,7 @@ Reasoning variants (`qwen-qwq-*`, `qwq-*`, `*-thinking`) automatically strip `te
|
||||
|
||||
The OpenAI-compatible backend also serves as the gateway for **OpenRouter**, **Ollama**, and any other service that speaks the OpenAI `/v1/chat/completions` wire format — just point `OPENAI_BASE_URL` at the service.
|
||||
|
||||
**Model-name prefix routing:** If a model name starts with `openai/`, `gpt-`, `qwen/`, `qwen-`, `kimi/`, or `kimi-`, the provider is selected by the prefix regardless of which env vars are set. This prevents accidental misrouting to Anthropic when multiple credentials exist in the environment. For the default OpenAI API, `openai/` is a routing prefix and is stripped before the request hits the wire. For a custom `OPENAI_BASE_URL`, slash-containing OpenAI-compatible slugs (for example OpenRouter-style `openai/gpt-4.1-mini`) are preserved so the gateway receives the model ID it expects.
|
||||
**Model-name prefix routing:** If a model name starts with `openai/`, `local/`, `gpt-`, `qwen/`, `qwen-`, `kimi/`, or `kimi-`, the provider is selected by the prefix regardless of which env vars are set. This prevents accidental misrouting to Anthropic when multiple credentials exist in the environment. For the default OpenAI API and local/private OpenAI-compatible endpoints, `openai/` is a routing prefix and is stripped before the request hits the wire. For non-local custom `OPENAI_BASE_URL` gateways, slash-containing OpenAI-compatible slugs (for example OpenRouter-style `openai/gpt-4.1-mini`) are preserved so the gateway receives the model ID it expects. The `local/` prefix is an explicit escape hatch for local slash-containing model IDs: it is stripped while the rest of the model ID is sent verbatim.
|
||||
|
||||
### Tested models and aliases
|
||||
|
||||
@@ -342,17 +375,19 @@ These are the models registered in the built-in alias table with known token lim
|
||||
|
||||
| Alias | Resolved model name | Provider | Max output tokens | Context window |
|
||||
|---|---|---|---|---|
|
||||
| `opus` | `claude-opus-4-6` | Anthropic | 32 000 | 200 000 |
|
||||
| `opus` | `claude-opus-4-7` | Anthropic | 32 000 | 200 000 |
|
||||
| `sonnet` | `claude-sonnet-4-6` | Anthropic | 64 000 | 200 000 |
|
||||
| `haiku` | `claude-haiku-4-5-20251213` | Anthropic | 64 000 | 200 000 |
|
||||
| `grok` / `grok-3` | `grok-3` | xAI | 64 000 | 131 072 |
|
||||
| `grok-mini` / `grok-3-mini` | `grok-3-mini` | xAI | 64 000 | 131 072 |
|
||||
| `grok-2` | `grok-2` | xAI | — | — |
|
||||
| `kimi` | `kimi-k2.5` | DashScope | 16 384 | 256 000 |
|
||||
| `qwen-max` | `qwen-max` | DashScope | 8 192 | 131 072 |
|
||||
| `qwen-plus` | `qwen-plus` | DashScope | 8 192 | 131 072 |
|
||||
| `gpt-4.1` / `gpt-4.1-mini` / `gpt-4.1-nano` | same | OpenAI-compatible | 32 768 | 1 047 576 |
|
||||
| `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.4-nano` | same | OpenAI-compatible | 128 000 | 1 000 000 / 400 000 |
|
||||
|
||||
Any model name that does not match an alias is passed through verbatim after provider routing is resolved. This is how you use OpenRouter model slugs (`openai/gpt-4.1-mini` with a custom `OPENAI_BASE_URL`), Ollama tags (`llama3.2`), or full Anthropic model IDs (`claude-sonnet-4-20250514`).
|
||||
Any model name that does not match an alias is passed through verbatim after provider routing is resolved. This is how you use OpenRouter model slugs (`openai/gpt-4.1-mini` with a custom `OPENAI_BASE_URL`), Ollama tags (`llama3.2` or `qwen2.5-coder:7b`), slash-containing local IDs (`local/Qwen/Qwen3.6-27B-FP8`), or full Anthropic model IDs (`claude-sonnet-4-20250514`).
|
||||
|
||||
### User-defined aliases
|
||||
|
||||
@@ -362,7 +397,7 @@ You can add custom aliases in any settings file (`~/.claw/settings.json`, `.claw
|
||||
{
|
||||
"aliases": {
|
||||
"fast": "claude-haiku-4-5-20251213",
|
||||
"smart": "claude-opus-4-6",
|
||||
"smart": "claude-opus-4-7",
|
||||
"cheap": "grok-3-mini"
|
||||
}
|
||||
}
|
||||
@@ -370,13 +405,15 @@ You can add custom aliases in any settings file (`~/.claw/settings.json`, `.claw
|
||||
|
||||
Local project settings override user-level settings. Aliases resolve through the built-in table, so `"fast": "haiku"` also works.
|
||||
|
||||
Model selection precedence is CLI flag, environment, config, then default. The environment model slot accepts `CLAW_MODEL`, `ANTHROPIC_MODEL`, and `ANTHROPIC_DEFAULT_MODEL` in that order; aliases from those variables are resolved and validated before provider startup. `claw --output-format json status` exposes `model_raw`, `model_alias_resolved_to`, and `model_env_var` so automation can see the winning value.
|
||||
|
||||
### How provider detection works
|
||||
|
||||
1. If the resolved model name starts with `claude` → Anthropic.
|
||||
2. If it starts with `grok` → xAI.
|
||||
3. If it starts with `openai/` or `gpt-` → OpenAI-compatible.
|
||||
3. If it starts with `openai/`, `local/`, or `gpt-` → OpenAI-compatible.
|
||||
4. If it starts with `qwen/`, `qwen-`, `kimi/`, or `kimi-` → DashScope-compatible OpenAI wire format.
|
||||
5. If `OPENAI_BASE_URL` and `OPENAI_API_KEY` are set, unknown model names route to the OpenAI-compatible client for local/gateway servers.
|
||||
5. If `OPENAI_BASE_URL` is set, local-looking unknown model names such as `llama3.2` or `qwen2.5-coder:7b` route to the OpenAI-compatible client for local/gateway servers.
|
||||
6. Otherwise, `claw` checks which credential is set: Anthropic first, then OpenAI, then xAI. If only `OPENAI_BASE_URL` is set, it still routes to OpenAI-compatible for authless local servers.
|
||||
7. If nothing matches, it defaults to Anthropic.
|
||||
|
||||
@@ -417,6 +454,9 @@ The name "codex" appears in the Claw Code ecosystem but it does **not** refer to
|
||||
export HTTPS_PROXY="http://proxy.corp.example:3128"
|
||||
export HTTP_PROXY="http://proxy.corp.example:3128"
|
||||
export NO_PROXY="localhost,127.0.0.1,.corp.example"
|
||||
export CLAW_OUTPUT_FORMAT="json" # default non-interactive output format; flags override it
|
||||
export CLAW_LOG="debug" # claw-specific log level selector surfaced by help/doctor
|
||||
export RUST_LOG="claw=debug" # Rust logging convention surfaced by help/doctor
|
||||
|
||||
cd rust
|
||||
./target/debug/claw prompt "hello via the corporate proxy"
|
||||
@@ -452,11 +492,12 @@ let client = build_http_client_with(&config).expect("proxy client");
|
||||
|
||||
## Skills
|
||||
|
||||
Use `/skills list` in the interactive REPL or `claw skills --output-format json` from the direct CLI to inspect installed skills. For offline/local installs, install the directory that contains `SKILL.md`, then verify the discovered name before invoking it:
|
||||
Use `/skills list` in the interactive REPL or `claw skills --output-format json` from the direct CLI to inspect installed skills. For offline/local installs, install the directory that contains `SKILL.md`, then verify the discovered name before invoking it. `skills install`, `skills uninstall`, and `agents create` are local filesystem lifecycle commands; they do not require provider credentials.
|
||||
|
||||
```text
|
||||
/skills install /absolute/path/to/my-skill
|
||||
/skills list
|
||||
/skills uninstall my-skill
|
||||
/skills my-skill
|
||||
```
|
||||
|
||||
@@ -469,11 +510,43 @@ cd rust
|
||||
./target/debug/claw status
|
||||
./target/debug/claw sandbox
|
||||
./target/debug/claw agents
|
||||
./target/debug/claw agents create my-agent
|
||||
./target/debug/claw mcp
|
||||
./target/debug/claw skills
|
||||
./target/debug/claw system-prompt --cwd .. --date 2026-04-04
|
||||
```
|
||||
|
||||
## Install an external skill
|
||||
|
||||
`claw skills install <path>` accepts a local skill directory that contains
|
||||
`SKILL.md` or a standalone markdown file. This is useful when a companion
|
||||
repository ships a skill prompt that should be available through `/skills`.
|
||||
|
||||
For example, install TweetClaw as an X/Twitter automation skill:
|
||||
|
||||
```bash
|
||||
# From a parent directory that contains claw-code
|
||||
git clone https://github.com/Xquik-dev/tweetclaw
|
||||
cd claw-code/rust
|
||||
./target/debug/claw skills install ../../tweetclaw/skills/tweetclaw
|
||||
./target/debug/claw skills show tweetclaw
|
||||
./target/debug/claw skills uninstall tweetclaw
|
||||
```
|
||||
|
||||
TweetClaw gives `claw` users a local skill guide for OpenClaw/Xquik workflows
|
||||
such as tweet search, reply search, follower export, monitors, webhooks, and
|
||||
approval-gated posting. Configure any Xquik credentials outside the prompt and
|
||||
avoid pasting API keys into chat.
|
||||
|
||||
## Author a local agent
|
||||
|
||||
`claw agents create <name>` scaffolds a local `.claw/agents/<name>.toml` file for the current workspace. The scaffold is intentionally small so you can edit the description, model, and reasoning effort before listing or invoking agents:
|
||||
|
||||
```bash
|
||||
./target/debug/claw agents create release-checker
|
||||
./target/debug/claw agents list
|
||||
```
|
||||
|
||||
## Session management
|
||||
|
||||
REPL turns are persisted under `.claw/sessions/` in the current workspace.
|
||||
@@ -496,6 +569,74 @@ Runtime config is loaded in this order, with later entries overriding earlier on
|
||||
4. `<repo>/.claw/settings.json`
|
||||
5. `<repo>/.claw/settings.local.json`
|
||||
|
||||
The list is also the precedence chain: project-local settings override project settings, project settings override the legacy project `.claw.json`, and project files override user files. `claw --output-format json config` includes each discovered file's `precedence_rank`, `wins_for_keys`, and `shadowed_keys` so automation can see which file controls each effective key without reimplementing the merge order.
|
||||
|
||||
## MCP server validation
|
||||
|
||||
`claw mcp --output-format json` loads valid `mcpServers` entries even when sibling entries are malformed. The JSON list envelope distinguishes the total configured entries from the valid and invalid subsets:
|
||||
|
||||
```json
|
||||
{
|
||||
"configured_servers": 1,
|
||||
"total_configured": 2,
|
||||
"valid_count": 1,
|
||||
"invalid_count": 1,
|
||||
"servers": [{ "name": "valid-server", "valid": true }],
|
||||
"invalid_servers": [
|
||||
{
|
||||
"name": "missing-command",
|
||||
"error_field": "command",
|
||||
"reason": ".claw.json: mcpServers.missing-command: missing string field command",
|
||||
"valid": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`status --output-format json` mirrors this under `mcp_validation`, and `doctor --output-format json` includes an `mcp validation` check so automation can repair every rejected server entry without losing usable MCP servers.
|
||||
|
||||
## Hook configuration
|
||||
|
||||
`hooks.PreToolUse`, `hooks.PostToolUse`, and `hooks.PostToolUseFailure` accept either legacy command strings or object-style entries with a `matcher` and nested command hooks:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
"echo legacy hook",
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "scripts/audit-bash.sh" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Object-style matchers are optional. When present, they match tool names case-insensitively and support `*` wildcards plus comma or pipe separated alternatives. Nested hook `type` may be omitted or set to `"command"`; each nested command runs in configuration order.
|
||||
Legacy bare-string hook entries still load for backward compatibility but emit deprecation warnings suggesting migration to object-style entries. Unknown hook event names (e.g. `Stop`, `Notification`) are recorded as invalid without rejecting valid hooks. `status --output-format json` mirrors partial hook validation under `hook_validation` with `valid_count`, `invalid_count`, and `invalid_hooks:[{event, index, hook_index, kind, error_field, reason, valid:false}]`. `doctor --output-format json` includes a `hook validation` check so automation can repair every rejected hook entry without losing usable hooks.
|
||||
|
||||
## Project instruction rules
|
||||
|
||||
In addition to root instruction files such as `CLAUDE.md`, `CLAW.md`, `AGENTS.md`, `.claw/CLAUDE.md`, `.claude/CLAUDE.md`, and `.claw/instructions.md`, `claw` loads sorted Markdown/text rule files from:
|
||||
|
||||
- `<repo>/.claw/rules/` (`.md`, `.txt`, `.mdc`) for shared project rules.
|
||||
- `<repo>/.claw/rules.local/` for personal local rules; this path is gitignored.
|
||||
|
||||
Root instruction-file priority is `CLAUDE.md`, then `CLAW.md`, then `AGENTS.md` for each discovered directory. Discovery is bounded to the current git root when one exists, otherwise to the current directory only, so stale parent files outside the project do not silently bleed into the prompt. All loaded files contribute to the system prompt and to `status --output-format json` as `workspace.memory_files:[{path, source, origin, scope_path, outside_project, chars, contributes}]`; `claw doctor --output-format json` includes a `memory` check so automation can detect loaded and unexpected unloaded memory-file candidates without parsing prompt text.
|
||||
|
||||
By default, `claw` also imports detected rules from common AI coding tools such as Cursor (`.cursorrules`, `.cursor/rules/`), GitHub Copilot (`.github/copilot-instructions.md`), Windsurf, Plandex, and Crush. Control this with `rulesImport` in any settings file:
|
||||
|
||||
```json
|
||||
{
|
||||
"rulesImport": "none"
|
||||
}
|
||||
```
|
||||
|
||||
Use `"auto"` (the default) to import every supported framework, `"none"` to load only Claw instruction/rules files, or an array such as `["cursor", "copilot"]` to import selected frameworks.
|
||||
|
||||
## Mock parity harness
|
||||
|
||||
The workspace includes a deterministic Anthropic-compatible mock service and parity harness.
|
||||
|
||||
125
concept.md
Normal file
125
concept.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Концепция проекта Claw Code
|
||||
|
||||
Документ фиксирует **цели**, **архитектуру** и **принципы** репозитория **Claw Code** — публичной Rust-реализации CLI-агента **`claw`** и сопутствующих инструментов. Источник правды по кодовой базе: workspace в каталоге [`rust/`](rust/README.md); операционные сценарии — [`USAGE.md`](USAGE.md), [`how_to_run.md`](how_to_run.md) (claw-analog), бэклог идеи — [`futute.md`](futute.md).
|
||||
|
||||
Отдельная продуктовая линия «из CLI → в личного помощника» (каналы/память/инструменты/проактивность/сессии) описана в [`docs/personal-assistant-roadmap.md`](docs/personal-assistant-roadmap.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Назначение продукта
|
||||
|
||||
**Claw Code** — это:
|
||||
|
||||
1. **Основной CLI `claw`** (`rusty-claude-cli`): полнофункциональный агент с REPL, OAuth, расширенным набором инструментов (включая bash, MCP, плагины и др.), стримингом и интеграцией с провайдерами **Anthropic**, **OpenAI-совместимыми** API и **xAI**.
|
||||
2. **`claw-analog`** — облегчённая оболочка на **том же слое API** (`api` crate): узкий, предсказуемый набор инструментов только для работы с файловой системой воркспейса, явные режимы прав, пригодность для **CI**, **скриптов** и **внешних агентов** (NDJSON).
|
||||
3. **`claw-rag-service`** — отдельный процесс: **индексация** репозитория (чанки + эмбеддинги в SQLite), **HTTP API** для семантического поиска и минимальный **веб-UI** для ручной проверки индекса.
|
||||
|
||||
Общая идея: дать **безопасный**, **аудируемый** и **воспроизводимый** способ вызова LLM над кодом и документацией, с путём эволюции от минимального harness до полного `claw`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Целевая аудитория и сценарии
|
||||
|
||||
| Сегмент | Задача |
|
||||
|---------|--------|
|
||||
| Разработчик | Ежедневная работа с кодовой базой через полный `claw`: REPL, инструменты, сессии. |
|
||||
| Автор автоматизации | Одноразовые промпты, пайплайны с `--output-format json`, встроенные агенты без bash. |
|
||||
| Сопровождение / аудит | `claw-analog` в **read-only** + пресет **audit**; явные лимиты и политика. |
|
||||
| Порт и parity | Сравнение поведения с эталоном (`PARITY.md`, mock-harness). |
|
||||
| RAG над монорепо | Отдельный `ingest` + `serve`; агент подключает контекст через **`retrieve_context`** при заданном `RAG_BASE_URL`. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Архитектура (логическая)
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────┐
|
||||
│ Провайдеры (Anthropic / OpenAI / …) │
|
||||
└─────────────────┬───────────────────┘
|
||||
│
|
||||
┌──────────────────────────────┼──────────────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
|
||||
│ rusty- │ │ claw-analog │ │ claw-rag-service │
|
||||
│ claude-cli │ │ (lean loop) │ │ HTTP + SQLite │
|
||||
│ («claw») │ │ │ │ ingest / query │
|
||||
└──────┬───────┘ └──────┬───────┘ └────────┬─────────┘
|
||||
│ │ │
|
||||
│ crates/api │ retrieve_context │
|
||||
│ runtime, tools, … │ (POST /v1/query) │
|
||||
└──────────────┬───────────────┴───────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
Файловая система / workspace (-w)
|
||||
```
|
||||
|
||||
**Принцип разделения:** тяжёлая индексация и хранение эмбеддингов **не** зашиваются в `claw-analog`, а живут в **`claw-rag-service`**. Агент только вызывает retrieval по HTTP — проще масштабировать, менять векторное хранилище и секреты эмбеддингов.
|
||||
|
||||
---
|
||||
|
||||
## 4. Принципы проектирования
|
||||
|
||||
1. **Безопасность по умолчанию** — относительные пути, запрет `..`, проверка выхода за canonical workspace; режимы `PermissionMode` согласованы с полным CLI; в неинтерактивном режиме опасные режимы блокируются без явного флага.
|
||||
2. **Явные лимиты** — размер чтения, число ходов, glob/grep caps, таймауты RAG; сбои предсказуемы, а не «OOM или вечный цикл».
|
||||
3. **Наблюдаемость для агентов** — NDJSON с `schema` и `format_version` на `run_start`, структурированные `tool_result`.
|
||||
4. **Модульность** — общий `api` для провайдеров; `claw-analog` не дублирует стек ключей RAG, только HTTP-клиент к сервису.
|
||||
5. **Паритет и тесты** — mock Anthropic, сценарии harness, отдельные job’ы CI для критичных crate’ов.
|
||||
6. **Документация рядом с кодом** — `how_to_run.md`, `docs/rag-web-ui.md`, `docs/container.md` и т.д.
|
||||
|
||||
---
|
||||
|
||||
## 5. Компоненты workspace (кратко)
|
||||
|
||||
- **`rusty-claude-cli`** — основной бинарь **`claw`**: пользовательский продукт полной мощности.
|
||||
- **`api`** — клиенты провайдеров, стриминг, типы запросов/ответов.
|
||||
- **`runtime`** — сессии, конфиг, **PermissionPolicy** / **PermissionEnforcer**, промпты, MCP и др.
|
||||
- **`tools`** — встроенные инструменты полного CLI.
|
||||
- **`claw-analog`** — минимальный цикл: инструменты чтения/поиска/записи (по режиму), стриминг и JSON, TOML-конфиг, сессии, doctor, config validate, **retrieve_context** при наличии `RAG_BASE_URL` / `rag_base_url`.
|
||||
- **`claw-rag-service`** — `ingest`, `serve`, маршруты `/`, `/health`, `/v1/stats`, `/v1/query`; SQLite + OpenAI-совместимые эмбеддинги (или mock для тестов).
|
||||
- **`mock-anthropic-service`**, **`compat-harness`** и др. — воспроизводимость и миграция.
|
||||
|
||||
Подробная раскладка: [`rust/README.md`](rust/README.md).
|
||||
|
||||
---
|
||||
|
||||
## 6. Claw-analog: роль и границы
|
||||
|
||||
**Задача:** дать «агента с инструментами» без разрастания поверхности атаки (нет произвольного shell в базовом сценарии).
|
||||
|
||||
**Инструменты (концептуально):** чтение и обход дерева (`read_file`, `list_dir`, `glob_workspace`), литеральный поиск (`grep_workspace` / `grep_search`), опционально `write_file`, опционально **`retrieve_context`** к RAG-сервису.
|
||||
|
||||
**Не входит в минимальный дизайн:** MCP, плагины, bash — это зона **полного `claw`**.
|
||||
|
||||
---
|
||||
|
||||
## 7. RAG-сервис: роль и эволюция
|
||||
|
||||
**Сейчас (MVP):** полный переиндекс при `ingest`, векторы в SQLite, поиск — линейный косинус по всем чанкам; подходит для умеренных объёмов кода.
|
||||
|
||||
**Направления роста (концепция):** инкрементальная индексация, ANN (sqlite-vec, Qdrant/Chroma в Docker), rate limits на эмбеддинги. Веб-UI на `GET /` — вспомогательный; продвинутый UI и авторизация — по мере необходимости.
|
||||
|
||||
Детали: [`docs/rag-web-ui.md`](docs/rag-web-ui.md).
|
||||
|
||||
---
|
||||
|
||||
## 8. Репозиторий вне основного runtime
|
||||
|
||||
- **`src/`**, **`tests/`** (Python и прочее) — вспомогательные/экспериментальные артефакты; **канонический runtime** — **`rust/`**.
|
||||
- Документы **PHILOSOPHY.md**, **ROADMAP.md**, **PARITY.md** дополняют концепцию процессом и намерениями сообщества/мейнтейнеров.
|
||||
|
||||
---
|
||||
|
||||
## 9. Связанные концепции (не ядро Claw Code)
|
||||
|
||||
В **`docs/`** могут находиться переносимые заметки для **других** продуктов (например локальный vision для NestJS-приложений) — они **не** определяют обязательное поведение `claw`, но отражают смежный интерес contributors.
|
||||
|
||||
---
|
||||
|
||||
## 10. Итоговая формулировка
|
||||
|
||||
**Claw Code** — это экосистема **Rust** вокруг агента **`claw`**: полный CLI для разработчиков, **`claw-analog`** как управляемый минимальный агент для автоматизации и **отдельный RAG-сервис** для семантического поиска по коду. Проект опирается на **явные права**, **лимиты**, **тестируемость** и **чёткие HTTP-границы** между агентом и тяжёлой индексацией.
|
||||
|
||||
---
|
||||
|
||||
*Обновляйте этот файл при смене ключевых продуктовых решений; детальный чеклист фич и backlog — в [`futute.md`](futute.md).*
|
||||
50
docker-compose.yml
Normal file
50
docker-compose.yml
Normal file
@@ -0,0 +1,50 @@
|
||||
services:
|
||||
qdrant:
|
||||
image: qdrant/qdrant:latest
|
||||
ports:
|
||||
- "6333:6333"
|
||||
- "6334:6334"
|
||||
environment:
|
||||
QDRANT__SERVICE__GRPC_PORT: "6334"
|
||||
volumes:
|
||||
- qdrant-storage:/qdrant/storage
|
||||
|
||||
rag-serve:
|
||||
build:
|
||||
context: ./rust
|
||||
dockerfile: crates/claw-rag-service/Dockerfile
|
||||
command: ["serve", "--db", "/data/index.sqlite"]
|
||||
environment:
|
||||
# Use mock embeddings by default for local dev; override in your shell for real providers.
|
||||
CLAW_RAG_MOCK_PROVIDERS: "1"
|
||||
CLAW_RAG_DB: "/data/index.sqlite"
|
||||
CLAW_RAG_HOST: "0.0.0.0"
|
||||
CLAW_RAG_QDRANT_URL: "http://qdrant:6334"
|
||||
CLAW_RAG_QDRANT_COLLECTION: "claw_rag_chunks"
|
||||
ports:
|
||||
- "8787:8787"
|
||||
depends_on:
|
||||
- qdrant
|
||||
volumes:
|
||||
- rag-data:/data
|
||||
|
||||
rag-ingest:
|
||||
build:
|
||||
context: ./rust
|
||||
dockerfile: crates/claw-rag-service/Dockerfile
|
||||
command: ["ingest", "--db", "/data/index.sqlite"]
|
||||
environment:
|
||||
CLAW_RAG_MOCK_PROVIDERS: "1"
|
||||
CLAW_RAG_DB: "/data/index.sqlite"
|
||||
CLAW_RAG_QDRANT_URL: "http://qdrant:6334"
|
||||
CLAW_RAG_QDRANT_COLLECTION: "claw_rag_chunks"
|
||||
depends_on:
|
||||
- qdrant
|
||||
volumes:
|
||||
- rag-data:/data
|
||||
# Mount example workspace roots under /workspaces
|
||||
- ./:/workspaces/main:ro
|
||||
|
||||
volumes:
|
||||
qdrant-storage:
|
||||
rag-data:
|
||||
@@ -148,12 +148,12 @@ pub const DEFAULT_DASHSCOPE_BASE_URL: &str = "https://dashscope.aliyuncs.com/com
|
||||
**Affected models:** Slash-containing model IDs routed through the OpenAI-compatible provider, especially custom gateways configured with `OPENAI_BASE_URL` such as OpenRouter, local routers, or other `/v1/chat/completions` services.
|
||||
|
||||
**Behavior:**
|
||||
- The default OpenAI API treats `openai/` as a routing prefix and sends the bare model name on the wire.
|
||||
- Custom OpenAI-compatible base URLs preserve slash-containing slugs such as `openai/gpt-4.1-mini` so the gateway receives the exact model ID it expects.
|
||||
- The default OpenAI API and local/private OpenAI-compatible base URLs treat `openai/` as a routing prefix and send the bare model name on the wire.
|
||||
- Non-local custom OpenAI-compatible base URLs preserve slash-containing slugs such as `openai/gpt-4.1-mini` so gateways like OpenRouter receive the exact model ID they expect. Local slash-containing model IDs can use `local/`, which strips only that escape-hatch prefix and sends the remainder verbatim.
|
||||
- `MessageRequest::extra_body` passes through custom request JSON after core fields are populated. This supports provider-specific options such as `web_search_options` and `parallel_tool_calls`.
|
||||
- Protected core fields (`model`, `messages`, `stream`, `tools`, `tool_choice`, `max_tokens`, `max_completion_tokens`) cannot be overridden through `extra_body`.
|
||||
|
||||
**Testing:** See `custom_openai_gateway_preserves_slash_model_ids_and_extra_body_params` in `openai_compat_integration.rs` and `extra_body_params_are_passed_through_without_overriding_core_fields` in `openai_compat.rs`.
|
||||
**Testing:** See `custom_openai_gateway_preserves_slash_model_ids_and_extra_body_params` in `openai_compat_integration.rs`, `wire_model_strips_openai_prefix_for_default_and_local_preserves_custom_gateways`, `local_routing_prefix_strips_only_escape_hatch`, and `extra_body_params_are_passed_through_without_overriding_core_fields` in `openai_compat.rs`.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
|
||||
51
docs/g013-roadmap-pinpoints-693-695-verification-map.md
Normal file
51
docs/g013-roadmap-pinpoints-693-695-verification-map.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# G013 ROADMAP pinpoints #693-#695 verification map
|
||||
|
||||
This map records the current-head follow-up that was discovered after resetting
|
||||
`main` to `origin/main`: ROADMAP.md contained three new Pinpoint headings not
|
||||
covered by the Claw Code 2.0 board.
|
||||
|
||||
## Pinpoint #693 — typed phase error instead of silent `unknown`
|
||||
|
||||
- Code: `rust/crates/claw-analog/src/lib.rs`
|
||||
- Behavior: `format_rag_query_json_for_model` now rejects missing, empty, or
|
||||
literal `"unknown"` phase values with a structured error envelope containing
|
||||
`kind:"unknown_bootstrap_phase"`, `field:"phase"`, and `received_value`.
|
||||
- Regression tests: `rag_response_missing_phase_returns_typed_error` and
|
||||
`rag_response_unknown_phase_returns_typed_error`.
|
||||
|
||||
## Pinpoint #694 — local pre-push build gate
|
||||
|
||||
- Hook: `.github/hooks/pre-push`
|
||||
- Install command: `git config core.hooksPath .github/hooks`
|
||||
- Gate: `cargo build --manifest-path rust/Cargo.toml --workspace --locked`
|
||||
- Escape hatch: `SKIP_CLAW_PRE_PUSH_BUILD=1` prints an explicit skip message.
|
||||
- Regression test: `tests/test_pre_push_hook_contract.py` locks the skip
|
||||
hatch and `--locked` build command contract.
|
||||
- Purpose: mirror the CI build job locally so stale field/variant references are
|
||||
caught before push.
|
||||
|
||||
## Pinpoint #695 — startup/worktree preflight diagnostics
|
||||
|
||||
- Code: `rust/crates/runtime/src/worker_boot.rs`
|
||||
- Behavior: `startup_preflight_warnings` and
|
||||
`WorkerRegistry::observe_startup_preflight` emit structured warnings before
|
||||
the first model turn when a task mentions a path not tracked on the current
|
||||
branch (`file_absent_on_branch`) or git metadata is not writable
|
||||
(`git_metadata_not_writable`).
|
||||
- Regression tests:
|
||||
- `startup_preflight_warns_when_task_file_is_absent_on_branch`
|
||||
- `startup_preflight_records_structured_warning_event`
|
||||
|
||||
## Verification commands
|
||||
|
||||
```bash
|
||||
python3 scripts/generate_cc2_board.py
|
||||
python3 scripts/validate_cc2_board.py --board .omx/cc2/board.json
|
||||
python3 .omx/cc2/validate_issue_parity_intake.py .omx/cc2/issue-parity-intake.json
|
||||
bash -n .github/hooks/pre-push
|
||||
python3 tests/test_pre_push_hook_contract.py -v
|
||||
cargo fmt --manifest-path rust/Cargo.toml --all -- --check
|
||||
cargo test --manifest-path rust/Cargo.toml -p claw-analog rag_response_ -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p runtime startup_preflight -- --nocapture
|
||||
cargo build --manifest-path rust/Cargo.toml --workspace --locked
|
||||
```
|
||||
@@ -13,7 +13,7 @@ If you need the most polished daily-driver experience for a specific non-Claude
|
||||
|
||||
## OpenAI-compatible routing basics
|
||||
|
||||
Set `OPENAI_BASE_URL` to the server’s `/v1` endpoint and set `OPENAI_API_KEY` to either the required token or a harmless placeholder for local servers that expect an Authorization header. The model name must match what the server exposes.
|
||||
Set `OPENAI_BASE_URL` to the server’s `/v1` endpoint and set `OPENAI_API_KEY` to either the required token or a harmless placeholder for local servers that expect an Authorization header. Authless local/private OpenAI-compatible servers can leave `OPENAI_API_KEY` unset. The model name must match what the server exposes.
|
||||
|
||||
```bash
|
||||
export OPENAI_BASE_URL="http://127.0.0.1:11434/v1"
|
||||
@@ -24,8 +24,8 @@ claw --model "qwen3:latest" prompt "Reply exactly HELLO_WORLD_123"
|
||||
Routing notes:
|
||||
|
||||
- Use the `openai/` prefix for OpenAI-compatible gateways when you need prefix routing to win over ambient Anthropic credentials, for example `--model "openai/gpt-4.1-mini"` with OpenRouter.
|
||||
- For local servers, prefer the exact model ID reported by the server (`qwen3:latest`, `llama3.2`, `Qwen/Qwen2.5-Coder-7B-Instruct`, etc.). If your local gateway exposes slash-containing IDs, use that exact slug.
|
||||
- If you have multiple provider keys in your environment, remove unrelated keys while smoke-testing a local route or choose a model prefix that unambiguously selects the intended provider.
|
||||
- For local servers, prefer the exact model ID reported by the server (`qwen3:latest`, `llama3.2`, etc.). If your local gateway exposes slash-containing IDs, prefix the exact slug with `local/` so Claw routes through OpenAI-compatible transport while sending the rest verbatim, for example `--model "local/Qwen/Qwen2.5-Coder-7B-Instruct"`.
|
||||
- If you have multiple provider keys in your environment, `OPENAI_BASE_URL` plus local-looking tags such as `llama3.2` or `qwen2.5-coder:7b` selects the local OpenAI-compatible route; use `local/` for slash-containing local IDs.
|
||||
- Tool workflows need model/server support for OpenAI-compatible tool calls. Plain prompt smoke tests can pass even when slash/tool workflows still fail because the server returns an incompatible tool-call shape.
|
||||
|
||||
## Raw `/v1/chat/completions` smoke test
|
||||
@@ -57,12 +57,13 @@ ollama serve
|
||||
In another shell:
|
||||
|
||||
```bash
|
||||
export OPENAI_BASE_URL="http://127.0.0.1:11434/v1"
|
||||
export OPENAI_API_KEY="local-dev-token"
|
||||
export OLLAMA_HOST="http://127.0.0.1:11434"
|
||||
claw --model "qwen3:latest" prompt "Reply exactly HELLO_WORLD_123"
|
||||
```
|
||||
|
||||
If Ollama is running without auth and your build accepts authless local OpenAI-compatible servers, `unset OPENAI_API_KEY` is also acceptable. Use a placeholder token rather than a real cloud API key for local testing.
|
||||
`OLLAMA_HOST` is the preferred env var for Ollama. Claw routes all models to the local OpenAI-compatible endpoint automatically when this is set, and no API key is needed. The older `OPENAI_BASE_URL` + `OPENAI_API_KEY` workaround is also supported for existing setups.
|
||||
|
||||
If Ollama is running without auth, `unset OPENAI_API_KEY` is acceptable. Use a placeholder token rather than a real cloud API key if your local server requires an Authorization header.
|
||||
|
||||
## llama.cpp server
|
||||
|
||||
|
||||
131
docs/personal-assistant-roadmap.md
Normal file
131
docs/personal-assistant-roadmap.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# From Claw Code to a Personal AI Assistant (Life OS)
|
||||
|
||||
This document turns the current “developer CLI agent” direction into a concrete path toward a **personal AI assistant**: a multi-channel interface (chat/voice), personal memory (RAG for life), tool/action integrations (MCP + plugins), proactivity (OmX-style loops), and long-lived identity (sessions + profile).
|
||||
|
||||
It is intentionally pragmatic: each section has **MVP scope**, **next step**, and **evolution**.
|
||||
|
||||
---
|
||||
|
||||
## 1) Interface: out of the terminal
|
||||
|
||||
### Goal
|
||||
Make `claw` usable without opening an IDE or terminal — from a phone, from chat, and eventually by voice.
|
||||
|
||||
### MVP
|
||||
- **Chat bridge**: a small service that relays messages from **Discord** (primary) or Telegram to `claw` / `claw-analog`.
|
||||
- Treat the chat thread as the “front-end”, and `claw` as the execution runtime.
|
||||
- Map a channel/thread to a **session id** (resume/append).
|
||||
- **Basic UX**: slash-like commands in chat:
|
||||
- `/prompt …`, `/resume latest`, `/status`, `/cost`, `/help`
|
||||
- “safe mode” defaults (read-only) unless elevated explicitly.
|
||||
|
||||
### Next step
|
||||
- **Voice**:
|
||||
- Speech-to-text input (e.g. Whisper-class STT) into the same chat bridge.
|
||||
- Text-to-speech output for hands-free feedback.
|
||||
|
||||
### Evolution
|
||||
- Multi-modal: attachments (images/PDF) routed into ingest/personal memory.
|
||||
- Presence and notifications: summaries pushed back into chat.
|
||||
|
||||
---
|
||||
|
||||
## 2) Memory: from “RAG for code” to “RAG for life”
|
||||
|
||||
### Goal
|
||||
Let the assistant answer personal questions and make decisions using *your* long-term context, not only the current repo.
|
||||
|
||||
### MVP
|
||||
- Extend ingestion inputs beyond git workspaces:
|
||||
- Notes (Markdown), exported chats, simple text logs.
|
||||
- PDFs (initially text extraction outside Rust is OK; later: built-in pipeline).
|
||||
- Keep a clear separation:
|
||||
- **Work RAG** (code/workspaces)
|
||||
- **Personal RAG** (notes, plans, history)
|
||||
|
||||
### Next step
|
||||
- Evolve `retrieve_context` into a **multi-source retrieval tool**:
|
||||
- “where to search” selector (work/personal/both)
|
||||
- metadata filters (source, date ranges, tags)
|
||||
|
||||
### Evolution
|
||||
- Incremental ingestion + event-based updates (watch folders, chat events).
|
||||
- Better stores (ANN/Qdrant/etc) when scale demands it.
|
||||
|
||||
---
|
||||
|
||||
## 3) Hands: tools, MCP, plugins
|
||||
|
||||
### Goal
|
||||
The assistant is valuable because it can **do** things, not only talk.
|
||||
|
||||
### MVP
|
||||
- Wire in external systems via **MCP servers**:
|
||||
- Calendar, notes (Notion), email, task trackers, smart home (as available).
|
||||
- Establish a convention for “personal skills”:
|
||||
- a dedicated directory (e.g. `.claw/skills/`) for user-specific automations
|
||||
- small, composable tools (digest, budgeting, reminders) rather than monoliths
|
||||
|
||||
### Next step
|
||||
- “Tool discovery” UX: list available MCP/tools/skills directly from chat.
|
||||
- Permission boundaries per tool category (read vs write, destructive actions require explicit confirmation).
|
||||
|
||||
### Evolution
|
||||
- Plugin marketplace flows for reusing “skills”.
|
||||
- Audit logging and replay of actions.
|
||||
|
||||
---
|
||||
|
||||
## 4) Proactivity: OmX-style loops
|
||||
|
||||
### Goal
|
||||
Move from reactive “answer me” to proactive “notice + prepare + propose + execute”.
|
||||
|
||||
### MVP
|
||||
- A scheduled runner that periodically:
|
||||
- checks inbox/notifications
|
||||
- extracts actionable tasks
|
||||
- drafts responses
|
||||
- posts a short digest to chat
|
||||
|
||||
### Next step
|
||||
- Multi-agent patterns (Architect/Executor/Reviewer) for higher reliability:
|
||||
- executor proposes actions
|
||||
- reviewer validates safety and correctness
|
||||
- only then does the bridge run the write/action tool
|
||||
|
||||
### Evolution
|
||||
- Event-driven triggers (webhooks) instead of only cron.
|
||||
- “Autopilot” modes with bounded scopes (time, tools, spend limits).
|
||||
|
||||
---
|
||||
|
||||
## 5) Long-lived identity: sessions + profile
|
||||
|
||||
### Goal
|
||||
Make the assistant feel continuous and personalized across days/weeks.
|
||||
|
||||
### MVP
|
||||
- Default to resuming the latest session (`--resume latest`-style behavior).
|
||||
- Use a short, user-owned profile/system-prompt for tone and preferences.
|
||||
|
||||
### Next step
|
||||
- Separate:
|
||||
- “personality” (style, preferences)
|
||||
- “memory” (facts, history)
|
||||
- “policies” (permissions, safety rules)
|
||||
|
||||
### Evolution
|
||||
- Multiple personas (work/personal) with explicit switching.
|
||||
- Transparent memory controls (“forget this”, “store this”).
|
||||
|
||||
---
|
||||
|
||||
## Suggested milestone sequence
|
||||
|
||||
1. **Discord bridge + session mapping** (no new AI capabilities; just distribution).
|
||||
2. **Personal ingest source #1** (notes folder) + retrieval selector (personal/work).
|
||||
3. **One MCP integration** (calendar or notes) + a single “daily digest” skill.
|
||||
4. **Scheduled digest loop** (cron) with bounded permissions.
|
||||
5. **Voice input/output** on top of the same bridge.
|
||||
|
||||
78
docs/rag-web-ui.md
Normal file
78
docs/rag-web-ui.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# RAG и веб‑UI: архитектура и фазы
|
||||
|
||||
Цель: **не** раздувать `claw-analog` и основной `claw` — вынести индексацию и (позже) UI в отдельные процессы с явными HTTP/MCP контрактами.
|
||||
|
||||
## Принципы
|
||||
|
||||
1. **RAG как сервис** — отдельный бинарь (сейчас `claw-rag-service`), свой жизненный цикл, свои секреты (embedding API), своё хранилище.
|
||||
2. **Агент только вызывает retrieval** — в **`claw-analog`** инструмент **`retrieve_context`** → HTTP `POST {RAG_BASE_URL}/v1/query` (база без суффикса `/v1`); лимиты **`rag_timeout_secs`**, **`rag_top_k_max`** в `.claw-analog.toml`; ответ для модели — фрагменты с `path` + `snippet` + `score`.
|
||||
3. **Веб‑UI** — минимальная страница **`GET /`** в `claw-rag-service` (stats + форма `POST /v1/query`); чат с моделью и «переиндексировать» из браузера — при необходимости позже.
|
||||
|
||||
## Компоненты (целевая картина)
|
||||
|
||||
```text
|
||||
┌─────────────────┐ POST /v1/query ┌──────────────────────┐
|
||||
│ claw-analog │ ──────────────────────►│ claw-rag-service │
|
||||
│ (+ tool) │◄──────────────────────│ (embed + vector DB) │
|
||||
└─────────────────┘ JSON hits └──────────┬───────────┘
|
||||
│
|
||||
ingest (watch / CLI)
|
||||
▼
|
||||
workspace files / git tree
|
||||
```
|
||||
|
||||
- **Индексация**: отдельная команда или воркер (chunking, хеш файла, инкремент). Хранилище: на старте SQLite + `sqlite-vec` / файловый эмбеддинг-кэш; при росте — Qdrant/Chroma в Docker.
|
||||
- **Эмбеддинги**: HTTP к OpenAI/Anthropic-совместимому embedding endpoint или локальная модель (отдельное решение по лицензии и размеру).
|
||||
- **Веб‑UI**: авторизация (минимум: токен + reverse proxy), SSE или WebSocket для стрима ответа модели; UI **не** владеет секретами провайдера, если продукт так решит — прокси через бэкенд.
|
||||
|
||||
## Текущая реализация
|
||||
|
||||
Крейт **`rust/crates/claw-rag-service`** (из каталога `rust/`):
|
||||
|
||||
### HTTP
|
||||
|
||||
- `GET /` — одностраничный UI (встроенный `static/index.html`): счётчики из `/v1/stats`, поиск через `/v1/query`.
|
||||
- `GET /health` — `ok`.
|
||||
- `GET /v1/stats` — `{ "chunks": N, "phase": "1-sqlite" }` (если БД ещё нет: `chunks: 0`, `phase`: `1-sqlite-no-db`).
|
||||
- `POST /v1/query` — тело `{"query":"...", "top_k":8}`; ответ `{"hits":[{"path","snippet","score"}], "phase":"1-sqlite"|"1-sqlite-empty"|"1-sqlite-no-db"}`.
|
||||
|
||||
Поиск: **линейный обход** всех векторов в SQLite (MVP; для больших репозиториев планировать Qdrant/sqlite-vec или батчевый ANN).
|
||||
|
||||
### Индексация (фаза 1)
|
||||
|
||||
```powershell
|
||||
cd D:\path\to\claw-code-main\rust
|
||||
$env:OPENAI_API_KEY = "sk-..."
|
||||
cargo run -p claw-rag-service -- ingest -w D:\path\to\repo --db D:\path\to\index.sqlite
|
||||
cargo run -p claw-analog -- ... # при RAG_BASE_URL или rag_base_url в TOML — инструмент retrieve_context
|
||||
```
|
||||
|
||||
Переменные окружения:
|
||||
|
||||
- **`OPENAI_API_KEY`** или **`CLAW_RAG_OPENAI_API_KEY`** — для вызова `POST …/embeddings`.
|
||||
- **`CLAW_RAG_EMBEDDING_BASE_URL`** — по умолчанию `https://api.openai.com/v1`.
|
||||
- **`CLAW_RAG_EMBEDDING_MODEL`** — по умолчанию `text-embedding-3-small`.
|
||||
- **`CLAW_RAG_DB`** — путь к SQLite (у ingest/`serve`; у `serve` есть default `.claw-rag/index.sqlite`).
|
||||
- **`CLAW_RAG_PORT`** — порт HTTP (по умолчанию `8787`).
|
||||
- **`CLAW_RAG_MOCK_PROVIDERS=1`** — детерминированные вектора без сети (для тестов CI).
|
||||
|
||||
Запуск сервера: `cargo run -p claw-rag-service` или `cargo run -p claw-rag-service -- serve --db path\to\index.sqlite`.
|
||||
|
||||
### Дальше по фазам
|
||||
|
||||
| Фаза | Содержание |
|
||||
|------|------------|
|
||||
| 1 | ~~Ingest + SQLite + embeddings~~ (базово сделано; улучшения: инкремент, ANN, Docker-векторка). |
|
||||
| 2 | ~~Инструмент `retrieve_context`~~: `RAG_BASE_URL` / `rag_base_url`, `rag_timeout_secs`, `rag_top_k_max` в `.claw-analog.toml`. |
|
||||
| 3 | ~~Минимальный UI~~: `GET /` + те же `/v1/*` (дальше: чат, кнопка re-index из UI). |
|
||||
|
||||
## Риски и ограничения
|
||||
|
||||
- Секреты и PII в индексе; размер индекса и стоимость эмбеддингов.
|
||||
- Согласованность с symlink/jail как в `claw-analog` — retrieval не должен «утекать» за пределы workspace.
|
||||
- Локаль на UI: i18n отдельно от `AnalogLanguage` в CLI.
|
||||
|
||||
## Связанные документы
|
||||
|
||||
- Локальный запуск контейнеров (если поднимете векторку): [`container.md`](container.md).
|
||||
- Обзор `claw-analog`: [`how_to_run.md`](../how_to_run.md).
|
||||
389
how_to_run.md
Normal file
389
how_to_run.md
Normal file
@@ -0,0 +1,389 @@
|
||||
# claw-analog — как запускать и как это устроено
|
||||
|
||||
Минимальный агент поверх того же стека API, что и основной CLI [`claw`](rust/README.md): провайдеры Anthropic / OpenAI‑совместимые / xAI выбираются по модели и переменным окружения (см. [USAGE.md](USAGE.md)).
|
||||
|
||||
Дальше в примерах **рабочий каталог** — папка **`claw-code-main\rust`** (внутри клона репозитория). Если приглашение PowerShell уже `…\claw-code-main\rust>`, **не** выполняйте второй раз `cd rust` (иначе будет `rust\rust` и ошибка пути).
|
||||
|
||||
## Требования
|
||||
|
||||
- Установленный **Rust** и **cargo** (в PATH: обычно `%USERPROFILE%\.cargo\bin` на Windows).
|
||||
- Ключ API для выбранного провайдера (например `ANTHROPIC_API_KEY`).
|
||||
|
||||
## Сборка и справка
|
||||
|
||||
```powershell
|
||||
cd D:\path\to\claw-code-main\rust
|
||||
cargo build -p claw-analog
|
||||
cargo run -p claw-analog -- --help
|
||||
```
|
||||
|
||||
### Диагностика (`doctor`)
|
||||
|
||||
Подкоманда **`claw-analog doctor`** (у неё свой `--help`, отдельно от основного режима):
|
||||
|
||||
- **превью конфигурации** — итог после слияния **`.claw-analog.toml`** (путь `<workspace>/.claw-analog.toml` или **`--config`**) и **тех же флагов**, что у основного run: **`--model`**, **`--permission`**, **`--preset`**, **`--output-format`**, **`--stream`**, **`--no-stream`**, **`--no-runtime-enforcer`**, **`--accept-danger-non-interactive`**, плюс **`--profile`** для отображения пути к профилю. Печатаются контракт NDJSON (`schema`, `format_version`), эффективные поля и строки **provenance** (что победило: CLI, TOML или default);
|
||||
- статус типовых переменных (**без** значений: только `set` / `unset` и длина строки);
|
||||
- поиск workspace вверх от cwd (или **`--manifest-dir`**) и по умолчанию **`cargo check -p claw-analog`** (только компиляция, **не** перезаписывает `target\debug\claw-analog.exe` — иначе на Windows при `cargo run … doctor` часто «Отказано в доступе» при вложенном `cargo build`);
|
||||
- **`--release-build`** — **`cargo build --release -p claw-analog`** (бинарь в `target\release\`, не конфликтует с запущенным debug‑exe);
|
||||
- **`--no-build`** — пропустить cargo;
|
||||
- **`--tcp-ping`** (алиас **`--mock`**) — TCP **`connect`** к хосту:порту из **`ANTHROPIC_BASE_URL`** (или к дефолтному `https://api.anthropic.com`); не проверяет HTTP/TLS и тело ответа.
|
||||
|
||||
Примеры (из каталога `…\claw-code-main\rust`):
|
||||
|
||||
```powershell
|
||||
cargo run -p claw-analog -- doctor
|
||||
cargo run -p claw-analog -- doctor --no-build
|
||||
cargo run -p claw-analog -- doctor --tcp-ping
|
||||
cargo run -p claw-analog -- doctor -w D:\path\to\repo --preset implement
|
||||
cargo run -p claw-analog -- doctor --release-build
|
||||
```
|
||||
|
||||
### Проверка конфигурации без API (`config validate`)
|
||||
|
||||
Подкоманда **`claw-analog config validate`**:
|
||||
|
||||
- парсит **`.claw-analog.toml`** (по умолчанию `<workspace>/.claw-analog.toml`, переопределение **`--config`**) и выводит краткий **merge preview** (как у `doctor`, но **только TOML + defaults**, без флагов основного run);
|
||||
- проверяет **`profile.toml`**: тот же порядок, что у run (`--profile`, поле `profile` в TOML, иначе дефолтный `~/.claw-analog/profile.toml` при наличии файла);
|
||||
- **никаких** запросов к LLM и сети API.
|
||||
|
||||
**`--strict`** — ошибка (код выхода 1), если файла конфигурации нет или профиль не читается.
|
||||
|
||||
```powershell
|
||||
cargo run -p claw-analog -- config validate -w D:\path\to\repo
|
||||
cargo run -p claw-analog -- config validate --strict -w .
|
||||
```
|
||||
|
||||
### Дополнение оболочки (`complete`)
|
||||
|
||||
Скрипт автодополнения в **stdout** (перенаправьте в файл из документации вашей оболочки):
|
||||
|
||||
```powershell
|
||||
cargo run -p claw-analog -- complete powershell >> $PROFILE
|
||||
# bash:zsh:fish — см. вывод `complete --help`
|
||||
```
|
||||
|
||||
Доступные значения: **`bash`**, **`zsh`**, **`fish`**, **`powershell`** (алиас **`pwsh`**).
|
||||
|
||||
## Основные команды
|
||||
|
||||
Одна задача в аргументе (или текст с **stdin**):
|
||||
|
||||
```powershell
|
||||
# из ...\claw-code-main\rust
|
||||
cargo run -p claw-analog -- -w D:\path\to\repo "Кратко опиши структуру rust/crates"
|
||||
```
|
||||
|
||||
С **живым выводом** (SSE через `stream_message`):
|
||||
|
||||
```powershell
|
||||
cargo run -p claw-analog -- --stream -w . "Объясни claw-analog в двух предложениях"
|
||||
```
|
||||
|
||||
Разрешить **запись файлов** в workspace:
|
||||
|
||||
```powershell
|
||||
cargo run -p claw-analog -- --permission workspace-write -w . "Добавь комментарий в начало crates/claw-analog/Cargo.toml"
|
||||
```
|
||||
|
||||
Отключить проверку через **`runtime::PermissionEnforcer`** (только своя тюрьма путей; не рекомендуется):
|
||||
|
||||
```powershell
|
||||
cargo run -p claw-analog -- --no-runtime-enforcer -w . "…"
|
||||
```
|
||||
|
||||
Полезные лимиты (CLI **перекрывает** значения из `.claw-analog.toml`, см. ниже):
|
||||
|
||||
| Флаг | Значение по умолчанию | Назначение |
|
||||
|------|------------------------|------------|
|
||||
| `--max-read-bytes` | 262144 | Максимум байт для `read_file` / `grep_workspace` / `git_diff` / `git_log` |
|
||||
| `--max-turns` | 24 | Максимум раундов «модель → инструменты → модель» |
|
||||
| `--max-list-entries` | 500 | Лимит строк `list_dir` |
|
||||
| `--grep-max-lines` | 200 | Верхняя граница **суммарных** строк совпадений в `grep_workspace` (в т.ч. по нескольким файлам; в одном файле можно задать меньше через `max_lines`) |
|
||||
| `--glob-max-paths` | 2000 | Максимум путей, возвращаемых `glob_workspace` и при расширении `glob` внутри `grep_workspace` |
|
||||
| `--glob-max-depth` | 32 | Глубина обхода каталогов для glob (через `walkdir`), без бесконечной рекурсии |
|
||||
| `--output-format` | `rich` | `json` — NDJSON на stdout для скриптов и агентов |
|
||||
| `--print-tools` | — | Список эффективных инструментов для итоговых `permission` / enforcer, затем выход (**без** промпта и API) |
|
||||
| `--lang` | `en` | Подсказка в system: `en` или `ru` (язык ответов; **не** меняет id модели в API) |
|
||||
| `--preset` | — | `none` \| `audit` \| `explain` \| `implement` — см. раздел ниже |
|
||||
| `--session` | — | Путь к JSON-сессии (относительно `-w`, если не абсолютный): сохранение истории и resume |
|
||||
| `--save-session` | — | Дополнительный путь: тот же снимок сессии пишется сюда при каждом сохранении (можно **без** `--session`, чтобы только экспортировать JSON после прогона) |
|
||||
| `--profile` | — | TOML с полем `line` (подмешивается в system). Без флага: пробуется `%USERPROFILE%\.claw-analog\profile.toml` (Windows) / `~/.claw-analog/profile.toml` |
|
||||
| `--permission` | `read-only` | см. ниже: `read-only`, `workspace-write`, `prompt`, `danger-full-access`, `allow` |
|
||||
| `--accept-danger-non-interactive` | — | Разрешить `danger-full-access` / `allow`, когда stdin **не** TTY (CI; осознанный риск). В TOML: `accept_danger_non_interactive = true` |
|
||||
|
||||
Конфиг по умолчанию читается из **`<workspace>/.claw-analog.toml`**, если файл существует. Другой путь: **`--config PATH`**. Неизвестные ключи в TOML — ошибка парсинга (строгая схема).
|
||||
|
||||
Пример `.claw-analog.toml`:
|
||||
|
||||
```toml
|
||||
model = "sonnet"
|
||||
stream = true
|
||||
output_format = "rich"
|
||||
permission = "read-only"
|
||||
language = "en"
|
||||
preset = "audit"
|
||||
session = ".claw-analog.session.json"
|
||||
profile = "~/.claw-analog/profile.toml"
|
||||
no_runtime_enforcer = false
|
||||
accept_danger_non_interactive = false
|
||||
max_read_bytes = 262144
|
||||
max_turns = 24
|
||||
max_list_entries = 500
|
||||
grep_max_lines = 200
|
||||
glob_max_paths = 2000
|
||||
glob_max_depth = 32
|
||||
# Опционально: RAG (`claw-rag-service`) — см. раздел про RAG ниже
|
||||
# rag_base_url = "http://127.0.0.1:8787"
|
||||
# rag_timeout_secs = 30
|
||||
# rag_top_k_max = 32
|
||||
```
|
||||
|
||||
**RAG (`retrieve_context`):** если заданы **`RAG_BASE_URL`** (per-env) или непустой **`rag_base_url`** в `.claw-analog.toml`, в набор инструментов добавляется **`retrieve_context`** (семантический поиск по уже проиндексированному воркспейсу). Значение — корень HTTP сервиса, без суффикса `/v1` (запрос идёт на `{base}/v1/query`). Таймаут и верхняя граница **`top_k`** задаются **`rag_timeout_secs`** и **`rag_top_k_max`** (по умолчанию 30 с и 32; «жёсткий» потолок 256). Индексация по-прежнему отдельной командой **`claw-rag-service`**, см. [`docs/rag-web-ui.md`](docs/rag-web-ui.md).
|
||||
|
||||
**`permission`** (как у полного `claw`, те же строки в TOML):
|
||||
|
||||
| Значение | Инструмент `write_file` | Неинтерактив (stdin не TTY) |
|
||||
|----------|-------------------------|------------------------------|
|
||||
| `read-only` | нет | OK |
|
||||
| `workspace-write` | да (в пределах `-w`) | OK |
|
||||
| `prompt` | нет (в этом harness Enforcer не даёт писать без подтверждений) | предупреждение в stderr; для автозаписи используйте `workspace-write` |
|
||||
| `danger-full-access`, `allow` | да | **запрещено**, пока не задан `--accept-danger-non-interactive` или `accept_danger_non_interactive = true` в TOML |
|
||||
|
||||
**`--stream`** в командной строке включает стриминг; **`--no-stream`** явно выключает (полезно поверх `stream = true` в файле).
|
||||
|
||||
**`language`** в TOML: `en` или `ru` (те же значения, что у **`--lang`**); CLI имеет приоритет.
|
||||
|
||||
### Сессия (`--session`)
|
||||
|
||||
Файл JSON (версия `1`): метаданные `workspace`, `model`, опционально `preset`, массив `messages` в формате API (`role` + `content`). При запуске с существующим файлом история **догружается**, текущий текст запроса (аргумент или stdin) добавляется как **новое** пользовательское сообщение. Состояние сохраняется после каждого полного раунда с инструментами и при завершении без `tool_use`.
|
||||
|
||||
**`--save-session`** — тот же формат файла, что и у `--session`: при каждом шаге, где обновлялся бы файл сессии, запись дублируется (если путь совпадает с `--session`, вторая запись не выполняется). Без **`--session`** можно собрать историю одного прогона в JSON для скриптов или последующего **`--session`** без ручной сборки `messages`.
|
||||
|
||||
**Риски:** в файле могут оказаться **секреты** (вывод `read_file`, ключи из логов), файл не шифруется; длинная история **дороже** по токенам API. В stderr печатается напоминание при **`--session`** или **`--save-session`**. Несовпадение `workspace` / `model` / `preset` с текущим запуском даёт **предупреждение**, но прогон продолжается.
|
||||
|
||||
### Пресеты (`--preset`)
|
||||
|
||||
Добавляют краткий абзац к system prompt (аудит / обучение / правки). Набор инструментов по-прежнему задаётся **permission**: для **`implement`**, если ни CLI, ни файл не задали `permission`, по умолчанию подставляется **workspace-write** (чтобы был `write_file`). Явный `permission = "read-only"` в файле или `--permission read-only` в CLI имеет приоритет.
|
||||
|
||||
### Профиль (`profile.toml`)
|
||||
|
||||
Мини-файл:
|
||||
|
||||
```toml
|
||||
line = "Короткая подсказка стиля (одна строка в system)."
|
||||
```
|
||||
|
||||
Ограничения: размер файла не больше **2048** байт; длина строки после trim — не больше **512** символов Unicode (иначе усечение с предупреждением). Содержимое добавляется в system одной строкой: `Learner hint: …`.
|
||||
|
||||
## Инструменты (без произвольного shell)
|
||||
|
||||
| Имя | Режим | Описание |
|
||||
|-----|--------|----------|
|
||||
| `read_file` | read-only+ | Чтение UTF‑8 файла под `-w` |
|
||||
| `list_dir` | read-only+ | Список каталога (не рекурсивно) |
|
||||
| `glob_workspace` | read-only+ | Список **путей файлов** под `-w`: аргументы `pattern` (glob относительно `root`, слэши `/`), опционально `root` (по умолчанию `.`), `max_paths` (урезается лимитом CLI). В шаблоне нельзя `..`. |
|
||||
| `grep_workspace` | read-only+ | Та же **литеральная** подстрока по строкам, что и раньше; ровно один из селекторов: `path`, массив `paths` или `glob` (+ опционально `glob_root`). Общий бюджет строк — `max_lines` и `--grep-max-lines`. В нескольких файлах формат строк: `относительный/путь:номер_строки:содержимое`. |
|
||||
| `grep_search` | read-only+ | Тот же обработчик, что у `grep_workspace` (совместимость промптов с полным `claw`). |
|
||||
| `git_diff` | read-only+ | `git diff` (без цвета) внутри репозитория в `-w`. Опционально `cached` (staged), `rev_range`, `context_lines`, `paths`. Вывод ограничен `--max-read-bytes`. |
|
||||
| `git_log` | read-only+ | `git log` (без цвета) внутри репозитория в `-w`. Опционально `max_count` (по умолчанию 20), `rev_range`, `paths`. Вывод ограничен `--max-read-bytes`. |
|
||||
| `retrieve_context` | read-only+ | Только если задан **`RAG_BASE_URL`** или **`rag_base_url`** в TOML: HTTP **`POST {base}/v1/query`** к `claw-rag-service`, ответ — пути и сниппеты чанков (лимиты см. выше). |
|
||||
| `write_file` | `workspace-write`, `danger-full-access` или `allow` | Запись файла; родительские каталоги создаются при необходимости (`prompt` не даёт записать через Enforcer) |
|
||||
|
||||
## Принципы работы
|
||||
|
||||
1. **Корень workspace** (`-w`) приводится к каноническому пути; все пути в инструментах **относительные**, без `..` и без абсолютных сегментов.
|
||||
2. Перед доступом к файлу проверяется, что реальный путь остаётся **внутри** корня (symlink/`canonicalize`).
|
||||
3. **Политика прав** (если не отключена `--no-runtime-enforcer`): те же сущности, что у основного CLI — `PermissionPolicy` + `PermissionEnforcer::check` для инструмента и `check_file_write` для записи.
|
||||
4. **Цикл агента**: запрос к провайдеру → если `stop_reason == tool_use`, выполняются вызовы, результаты уходят в историю как `tool_result` → следующий раунд.
|
||||
5. **Стриминг**: при `--stream` текст ассистента печатается по мере прихода дельт; история для следующего раунда собирается из SSE так же, как в полном пайплайне (индексы блоков + JSON tool input). Отключить стриминг при настройке из файла можно флагом **`--no-stream`**.
|
||||
|
||||
Логи вида `[claw-analog] ...` пишутся в **stderr**. В режиме **rich** ответ модели — обычный текст в **stdout**; в режиме **json** в **stdout** идёт только **NDJSON** (см. ниже).
|
||||
|
||||
## Вывод JSON (CI и внешние агенты)
|
||||
|
||||
Флаг **`--output-format json`** переключает stdout на **поток строк JSON** (один объект = одна строка). Поля стабильны по смыслу, но набор может расширяться.
|
||||
|
||||
Основные `type`:
|
||||
|
||||
| `type` | Когда |
|
||||
|--------|--------|
|
||||
| `run_start` | Старт прогона: **`schema`** (`claw-analog-ndjson`), **`format_version`**, далее `workspace`, `model`, `stream`, `permission`, опционально `preset`, `session`, опционально `session_save`, булево **`rag_enabled`** (есть ли база для `retrieve_context`) |
|
||||
| `turn_start` | Начало раунда с моделью (`turn`) |
|
||||
| `assistant_text_delta` | Только при `--stream`: фрагмент текста ассистента |
|
||||
| `assistant_turn` | Итог раунда: `stop_reason`, `usage`, полный `text`, массив `tool_calls` |
|
||||
| `tool_result` | После выполнения инструмента: `name`, `tool_use_id`, `is_error`, `output` (может быть усечён), `truncated`, `output_len_chars` |
|
||||
| `run_end` | Успешное завершение (`ok: true`) |
|
||||
| `error` | Ошибка (печатается отдельной строкой при падении или пустом промпте) |
|
||||
|
||||
Пример (PowerShell): разбор потока построчно удобен **`jq`** или любом JSON‑парсере.
|
||||
|
||||
```powershell
|
||||
# из ...\claw-code-main\rust
|
||||
$env:ANTHROPIC_API_KEY = "sk-ant-..."
|
||||
cargo run -p claw-analog -- --output-format json -w . "Summarize rust/README.md" 2>$null | ForEach-Object { $_ | ConvertFrom-Json | Select-Object -ExpandProperty type }
|
||||
```
|
||||
|
||||
С **`--stream`** в stdout сначала идут события `assistant_text_delta`, затем для того же раунда — одна строка `assistant_turn` с полным собранным `text` (удобно для воспроизводимых логов).
|
||||
|
||||
### Ограничения и риски для агентов
|
||||
|
||||
- В **`tool_result.output`** большие файлы обрезаются (~32 KiB UTF‑8), поле **`truncated`: true**.
|
||||
- **Секреты**: не перенаправляйте stderr сырьём в публичные логи без фильтра; в `output` теоретически может попасть содержимое прочитанных файлов.
|
||||
- Контракт для оркестраторов: NDJSON из stdout, диагностика из stderr; код возврата ≠ 0 при ошибке. На первой строке **`run_start`** имеет смысл сверять **`schema`** и **`format_version`**; **`run_start`** также раскрывает путь workspace и модель — учитывайте при шаринге логов.
|
||||
|
||||
## Автотесты без реальной сети
|
||||
|
||||
Юнит‑тесты и интеграция с локальным **mock-anthropic-service**:
|
||||
|
||||
```powershell
|
||||
# из ...\claw-code-main\rust
|
||||
cargo test -p claw-analog
|
||||
```
|
||||
|
||||
В **GitHub Actions** отдельный job **`claw-analog (test + clippy -p)`** гоняет `cargo test -p claw-analog` и `cargo clippy -p claw-analog --no-deps` (в дополнение к полному `cargo test` / `clippy` по workspace).
|
||||
|
||||
При параллельном запуске тестов переменные окружения Anthropic изолированы **mutex**‑ом только для mock‑сценария; при сбоях можно запустить `cargo test -p claw-analog -- --test-threads=1`.
|
||||
|
||||
## Отдельно: `claw-rag-service` (RAG)
|
||||
|
||||
Индексация воркспейса и HTTP API живут в **`cargo run -p claw-rag-service`** (`ingest` + `serve`). После `serve` откройте **`http://127.0.0.1:8787/`** — лёгкий UI (stats + поиск). К `claw-analog` подключается через **`RAG_BASE_URL`** / `retrieve_context`. Подробности и env: [`docs/rag-web-ui.md`](docs/rag-web-ui.md).
|
||||
|
||||
### Ingest (один или несколько репозиториев)
|
||||
|
||||
`ingest` принимает **повторяемый** `--workspace` — это позволяет сделать **cross-repo RAG** (несколько реп в одну БД/коллекцию).
|
||||
|
||||
```powershell
|
||||
# из ...\claw-code-main\rust
|
||||
|
||||
# один workspace
|
||||
cargo run -p claw-rag-service -- ingest --workspace "D:\v\kria\s6"
|
||||
|
||||
# несколько workspace (cross-repo)
|
||||
cargo run -p claw-rag-service -- ingest --workspace "D:\repo1" --workspace "D:\repo2"
|
||||
```
|
||||
|
||||
В ответах `path` будет вида `repoId:relative/path` (чтобы не было коллизий одинаковых путей между репозиториями).
|
||||
|
||||
### Mock embeddings (без ключей / без сети)
|
||||
|
||||
Для локальных прогонов/тестов можно включить mock-эмбеддинги:
|
||||
|
||||
```powershell
|
||||
$env:CLAW_RAG_MOCK_PROVIDERS = "1"
|
||||
cargo run -p claw-rag-service -- ingest --workspace "D:\v\kria\s6"
|
||||
```
|
||||
|
||||
### Qdrant (рекомендуемый локальный вариант) через Docker
|
||||
|
||||
Для больших репозиториев лучше поднять локальный Qdrant: это снимает нагрузку с линейного сканирования `SQLite` и ускоряет запросы.
|
||||
|
||||
Запуск Qdrant (gRPC на 6334):
|
||||
|
||||
```powershell
|
||||
docker run --rm -p 6333:6333 -p 6334:6334 -e QDRANT__SERVICE__GRPC_PORT=6334 qdrant/qdrant
|
||||
```
|
||||
|
||||
#### Qdrant с persist volume (чтобы индекс сохранялся)
|
||||
|
||||
Вариант через именованный volume Docker:
|
||||
|
||||
```powershell
|
||||
docker volume create claw-qdrant-data
|
||||
docker run --rm -p 6333:6333 -p 6334:6334 `
|
||||
-e QDRANT__SERVICE__GRPC_PORT=6334 `
|
||||
-v claw-qdrant-data:/qdrant/storage `
|
||||
qdrant/qdrant
|
||||
```
|
||||
|
||||
Вариант через bind-mount (путь на хосте):
|
||||
|
||||
```powershell
|
||||
mkdir .claw-qdrant | Out-Null
|
||||
docker run --rm -p 6333:6333 -p 6334:6334 `
|
||||
-e QDRANT__SERVICE__GRPC_PORT=6334 `
|
||||
-v "${PWD}/.claw-qdrant:/qdrant/storage" `
|
||||
qdrant/qdrant
|
||||
```
|
||||
|
||||
Затем включите env и запускайте ingest с фичей `qdrant-index`:
|
||||
|
||||
```powershell
|
||||
$env:CLAW_RAG_QDRANT_URL = "http://127.0.0.1:6334"
|
||||
$env:CLAW_RAG_QDRANT_COLLECTION = "claw_rag_chunks"
|
||||
|
||||
# (опционально) без реального API для эмбеддингов
|
||||
$env:CLAW_RAG_MOCK_PROVIDERS = "1"
|
||||
|
||||
cargo run -p claw-rag-service --features qdrant-index -- ingest --workspace "D:\v\kria\s6"
|
||||
```
|
||||
|
||||
`ingest` сам создаст коллекцию, если её ещё нет (по размерности эмбеддингов).
|
||||
|
||||
### Запуск через Docker (Qdrant + claw-rag-service)
|
||||
|
||||
Если хочется поднимать всё одной командой, удобнее использовать `docker compose`.
|
||||
|
||||
1) Запуск сервисов:
|
||||
|
||||
```powershell
|
||||
cd D:\path\to\claw-code-main
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Примечание: образ `rag-serve`/`rag-ingest` собирается на достаточно свежем Rust (см. `rust/crates/claw-rag-service/Dockerfile`), потому что `qdrant-client` может требовать более новую версию Rust, чем старые pinned-теги.
|
||||
|
||||
Если сборка Docker падает и вы видите строки вроде `transferring context: 21.02GB`, проверьте что:
|
||||
|
||||
- вы запускаете compose из корня репозитория (где лежит `docker-compose.yml`)
|
||||
- используется `.dockerignore` (уменьшает build-context, особенно если есть `target/` и локальные индексы)
|
||||
|
||||
Если сборка падает сразу с `EOF` на шаге `load local bake definitions`, попробуйте:
|
||||
|
||||
```powershell
|
||||
$env:COMPOSE_BAKE = "0"
|
||||
$env:DOCKER_BUILDKIT = "0"
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
2) Ingest (запускать отдельно, т.к. это batch job). Пример для одного workspace:
|
||||
|
||||
```powershell
|
||||
docker compose run --rm rag-ingest ingest --workspace "/workspaces/main"
|
||||
```
|
||||
|
||||
По умолчанию `rag-ingest` пишет индекс в общий volume, так что `rag-serve` сразу увидит чанки.
|
||||
|
||||
### Подключение к `claw-analog`
|
||||
|
||||
```powershell
|
||||
$env:RAG_BASE_URL = "http://127.0.0.1:8787"
|
||||
cargo run -p claw-analog -- -w "D:\v\kria\s6" "Найди где реализован ingest в RAG сервисе"
|
||||
```
|
||||
|
||||
## Auto‑TDD (автопроверки после `write_file`/`edit_file`)
|
||||
|
||||
В полном `claw` (и в других потребителях `runtime`) можно включить автозапуск линтера/тестов после успешных write-инструментов через `.claw/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"autoTdd": {
|
||||
"enabled": true,
|
||||
"tools": ["write_file", "edit_file"],
|
||||
"commands": [
|
||||
"cd rust && cargo fmt",
|
||||
"cd rust && cargo clippy --workspace --all-targets -- -D warnings",
|
||||
"cd rust && cargo test --workspace"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Отличия от полного `claw`
|
||||
|
||||
- Узкий набор инструментов (нет bash/MCP/плагинов).
|
||||
- Проще аудировать и ограничивать по `--permission` и лимитам.
|
||||
- Основной продукт по-прежнему `cargo run -p rusty-claude-cli` → бинарь `claw`.
|
||||
|
||||
## Дальнейшая разработка
|
||||
|
||||
План и чеклист идей (в т.ч. заимствованные из продуктового слоя вроде DeepTutor): [`futute.md`](futute.md) в корне репозитория.
|
||||
15
rust/.dockerignore
Normal file
15
rust/.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
||||
# This .dockerignore applies to docker-compose build context: ./rust
|
||||
target
|
||||
**/target
|
||||
.claw
|
||||
.claw-rag
|
||||
.claude
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
*.log
|
||||
*.tmp
|
||||
*.sqlite
|
||||
*.sqlite-wal
|
||||
*.sqlite-shm
|
||||
.DS_Store
|
||||
1123
rust/Cargo.lock
generated
Normal file → Executable file
1123
rust/Cargo.lock
generated
Normal file → Executable file
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
@@ -15,7 +15,7 @@ cargo run -p rusty-claude-cli -- --help
|
||||
cargo build --workspace
|
||||
|
||||
# Run the interactive REPL
|
||||
cargo run -p rusty-claude-cli -- --model claude-opus-4-6
|
||||
cargo run -p rusty-claude-cli -- --model claude-opus-4-7
|
||||
|
||||
# One-shot prompt
|
||||
cargo run -p rusty-claude-cli -- prompt "explain this codebase"
|
||||
@@ -87,7 +87,7 @@ Primary artifacts:
|
||||
| Sub-agent / agent surfaces | ✅ |
|
||||
| Todo tracking | ✅ |
|
||||
| Notebook editing | ✅ |
|
||||
| CLAUDE.md / project memory | ✅ |
|
||||
| CLAUDE.md / CLAW.md / AGENTS.md project memory | ✅ |
|
||||
| Config file hierarchy (`.claw.json` + merged config sections) | ✅ |
|
||||
| Permission system | ✅ |
|
||||
| MCP server lifecycle + inspection | ✅ |
|
||||
@@ -100,7 +100,7 @@ Primary artifacts:
|
||||
| Slash commands (including `/skills`, `/agents`, `/mcp`, `/doctor`, `/plugin`, `/subagent`) | ✅ |
|
||||
| Hooks (`/hooks`, config-backed lifecycle hooks) | ✅ |
|
||||
| Plugin management surfaces | ✅ |
|
||||
| Skills inventory / install surfaces | ✅ |
|
||||
| Skills inventory / install / uninstall surfaces | ✅ |
|
||||
| Machine-readable JSON output across core CLI surfaces | ✅ |
|
||||
|
||||
## Model Aliases
|
||||
@@ -109,7 +109,7 @@ Short names resolve to the latest model versions:
|
||||
|
||||
| Alias | Resolves To |
|
||||
|-------|------------|
|
||||
| `opus` | `claude-opus-4-6` |
|
||||
| `opus` | `claude-opus-4-7` |
|
||||
| `sonnet` | `claude-sonnet-4-6` |
|
||||
| `haiku` | `claude-haiku-4-5-20251213` |
|
||||
|
||||
@@ -122,10 +122,11 @@ claw [OPTIONS] [COMMAND]
|
||||
|
||||
Flags:
|
||||
--model MODEL
|
||||
--output-format text|json
|
||||
--output-format text|json (case-insensitive; CLAW_OUTPUT_FORMAT supplies the default, flags override env)
|
||||
--permission-mode MODE
|
||||
--dangerously-skip-permissions
|
||||
--allowedTools TOOLS
|
||||
--cwd PATH, -C PATH, --directory PATH
|
||||
--dangerously-skip-permissions, --skip-permissions
|
||||
--allowedTools TOOLS canonical snake_case names or aliases; status JSON exposes allowed_tools.available/aliases
|
||||
--resume [SESSION.jsonl|session-id|latest]
|
||||
--version, -V
|
||||
|
||||
@@ -146,6 +147,13 @@ Top-level commands:
|
||||
```
|
||||
|
||||
`claw acp` is a local discoverability surface for editor-first users: it reports the current ACP/Zed status without starting the runtime. As of April 16, 2026, claw-code does **not** ship an ACP/Zed daemon or JSON-RPC entrypoint yet, and `claw acp serve` is only a status alias until the real protocol surface lands. Status queries exit 0 and expose the same machine-readable contract via `--output-format json`; malformed ACP invocations exit 1 with `kind: unsupported_acp_invocation`.
|
||||
`--output-format` accepts `text` or `json` in any casing. `CLAW_OUTPUT_FORMAT=json` selects JSON as the default for non-interactive commands, explicit flags override it, repeated flags warn on stderr, and status JSON exposes `format_source`, `format_raw`, and `format_overridden`. Help and doctor output also surface `CLAW_LOG` / `RUST_LOG` as the logging environment knobs.
|
||||
`claw version --output-format json` is the provenance probe for automation: it reports full `git_sha`, derived `git_sha_short`, `is_dirty`, `branch`, `commit_date`, `commit_timestamp`, `rustc_version`, runtime `executable_path`, and `binary_provenance`; the text report is available as `human_readable` instead of a duplicate `message` field.
|
||||
`status --output-format json` reports loaded project memory files under `workspace.memory_files[]` with each file's `path`, `source` (`claude_md`, `claw_md`, `agents_md`, or scoped/rule sources), `origin`, `scope_path`, `outside_project`, `chars`, and `contributes`; `claw doctor --output-format json` includes a dedicated `memory` check. Root instruction-file priority is `CLAUDE.md`, then `CLAW.md`, then `AGENTS.md`, discovery is bounded to the current git root when present (otherwise cwd only), and all non-duplicate loaded files contribute to the rendered system prompt.
|
||||
`claw mcp --output-format json` reports partial MCP config success: valid servers remain in `servers[]` while malformed siblings appear in `invalid_servers[]`, with `total_configured`, `valid_count`, and `invalid_count` split out for automation. `status` mirrors this as `mcp_validation`, and doctor includes an `mcp validation` check.
|
||||
`status --output-format json` also reports partial hook config success under `hook_validation`: valid hook entries are retained while malformed or unknown-event siblings appear in `invalid_hooks[]`, with `valid_count`, `invalid_count`, and typed `kind` fields (`invalid_hooks_config` or `unknown_hook_event`) for automation. `doctor --output-format json` includes a `hook validation` check, and `config --output-format json` includes `hook_validation` metadata with degraded status when invalid entries exist.
|
||||
Shorthand prompt mode honors the POSIX `--` end-of-flags separator, so `claw -- "-prompt-with-dash"` and unknown dash-prefixed non-flag text stay on the prompt path instead of being treated as CLI options.
|
||||
`claw dump-manifests` is self-contained: it emits the Rust resolver inventory for the selected workspace (commands, tools, agents, skills, and bootstrap phases) without requiring an upstream Claude Code TypeScript checkout. Use `--manifests-dir PATH` only to scope resolver discovery to another directory.
|
||||
|
||||
The command surface is moving quickly. For the canonical live help text, run:
|
||||
|
||||
@@ -166,8 +174,8 @@ The REPL now exposes a much broader surface than the original minimal shell:
|
||||
- plugin management: `/plugin` (with aliases `/plugins`, `/marketplace`)
|
||||
|
||||
Notable claw-first surfaces now available directly in slash form:
|
||||
- `/skills [list|install <path>|help]`
|
||||
- `/agents [list|help]`
|
||||
- `/skills [list|show <name>|install <path>|uninstall <name>|help]`
|
||||
- `/agents [list|show <name>|create <name>|help]`
|
||||
- `/mcp [list|show <server>|help]`
|
||||
- `/doctor`
|
||||
- `/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]`
|
||||
@@ -184,7 +192,7 @@ rust/
|
||||
└── crates/
|
||||
├── api/ # Provider clients + streaming + request preflight
|
||||
├── commands/ # Shared slash-command registry + help rendering
|
||||
├── compat-harness/ # TS manifest extraction harness
|
||||
├── compat-harness/ # Compatibility/parity harness utilities
|
||||
├── mock-anthropic-service/ # Deterministic local Anthropic-compatible mock
|
||||
├── plugins/ # Plugin metadata, manager, install/enable/disable surfaces
|
||||
├── runtime/ # Session, config, permissions, MCP, prompts, auth/runtime loop
|
||||
@@ -197,7 +205,7 @@ rust/
|
||||
|
||||
- **api** — provider clients, SSE streaming, request/response types, auth (`ANTHROPIC_API_KEY` + bearer-token support), request-size/context-window preflight
|
||||
- **commands** — slash command definitions, parsing, help text generation, JSON/text command rendering
|
||||
- **compat-harness** — extracts tool/prompt manifests from upstream TS source
|
||||
- **compat-harness** — compatibility and parity helpers for comparing behavior with upstream fixtures
|
||||
- **mock-anthropic-service** — deterministic `/v1/messages` mock for CLI parity tests and local harness runs
|
||||
- **plugins** — plugin metadata, install/enable/disable/update flows, plugin tool definitions, hook integration surfaces
|
||||
- **runtime** — `ConversationRuntime`, config loading, session persistence, permission policy, MCP client lifecycle, system prompt assembly, usage tracking
|
||||
@@ -210,8 +218,8 @@ rust/
|
||||
- **~20K lines** of Rust
|
||||
- **9 crates** in workspace
|
||||
- **Binary name:** `claw`
|
||||
- **Default model:** `claude-opus-4-6`
|
||||
- **Default permissions:** `danger-full-access`
|
||||
- **Default model:** `claude-opus-4-7`
|
||||
- **Default permissions:** `workspace-write`
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -32,16 +32,25 @@ impl ProviderClient {
|
||||
OpenAiCompatConfig::xai(),
|
||||
)?)),
|
||||
ProviderKind::OpenAi => {
|
||||
// DashScope models (qwen-*) also return ProviderKind::OpenAi because they
|
||||
// speak the OpenAI wire format, but they need the DashScope config which
|
||||
// reads DASHSCOPE_API_KEY and points at dashscope.aliyuncs.com.
|
||||
let config = match providers::metadata_for_model(&resolved_model) {
|
||||
Some(meta) if meta.auth_env == "DASHSCOPE_API_KEY" => {
|
||||
OpenAiCompatConfig::dashscope()
|
||||
}
|
||||
_ => OpenAiCompatConfig::openai(),
|
||||
};
|
||||
Ok(Self::OpenAi(OpenAiCompatClient::from_env(config)?))
|
||||
// OLLAMA_HOST takes priority: local Ollama needs no API key
|
||||
// and ignores DashScope/OpenAI env-based dispatch.
|
||||
if std::env::var_os("OLLAMA_HOST").is_some() {
|
||||
Ok(Self::OpenAi(
|
||||
openai_compat::OpenAiCompatClient::from_ollama_env()
|
||||
.expect("from_ollama_env always returns Some"),
|
||||
))
|
||||
} else {
|
||||
// DashScope models (qwen-*) also return ProviderKind::OpenAi because they
|
||||
// speak the OpenAI wire format, but they need the DashScope config which
|
||||
// reads DASHSCOPE_API_KEY and points at dashscope.aliyuncs.com.
|
||||
let config = match providers::metadata_for_model(&resolved_model) {
|
||||
Some(meta) if meta.auth_env == "DASHSCOPE_API_KEY" => {
|
||||
OpenAiCompatConfig::dashscope()
|
||||
}
|
||||
_ => OpenAiCompatConfig::openai(),
|
||||
};
|
||||
Ok(Self::OpenAi(OpenAiCompatClient::from_env(config)?))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,7 +170,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resolves_existing_and_grok_aliases() {
|
||||
assert_eq!(resolve_model_alias("opus"), "claude-opus-4-6");
|
||||
assert_eq!(resolve_model_alias("opus"), "claude-opus-4-7");
|
||||
assert_eq!(resolve_model_alias("grok"), "grok-3");
|
||||
assert_eq!(resolve_model_alias("grok-mini"), "grok-3-mini");
|
||||
}
|
||||
@@ -235,4 +244,22 @@ mod tests {
|
||||
other => panic!("Expected ProviderClient::OpenAi for qwen-plus, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_base_url_routes_authless_ollama_models() {
|
||||
let _lock = env_lock();
|
||||
let _base_url = EnvVarGuard::set("OPENAI_BASE_URL", Some("http://127.0.0.1:11434/v1"));
|
||||
let _openai_key = EnvVarGuard::set("OPENAI_API_KEY", None);
|
||||
let _anthropic_key = EnvVarGuard::set("ANTHROPIC_API_KEY", Some("test-anthropic-key"));
|
||||
let _anthropic_token = EnvVarGuard::set("ANTHROPIC_AUTH_TOKEN", None);
|
||||
|
||||
let client = ProviderClient::from_model("qwen2.5-coder:7b")
|
||||
.expect("local model should route to OpenAI-compatible client without auth");
|
||||
match client {
|
||||
ProviderClient::OpenAi(openai_client) => {
|
||||
assert_eq!(openai_client.base_url(), "http://127.0.0.1:11434/v1")
|
||||
}
|
||||
other => panic!("Expected ProviderClient::OpenAi for local model, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ const CONTEXT_WINDOW_ERROR_MARKERS: &[&str] = &[
|
||||
"completion tokens",
|
||||
"prompt tokens",
|
||||
"request is too large",
|
||||
"no parseable body",
|
||||
];
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -60,6 +61,9 @@ pub enum ApiError {
|
||||
retryable: bool,
|
||||
/// Suggested user action based on error type (e.g., "Reduce prompt size" for 413)
|
||||
suggested_action: Option<String>,
|
||||
/// Parsed Retry-After header value (seconds) for 429 responses.
|
||||
/// When present, overrides the exponential backoff delay.
|
||||
retry_after: Option<Duration>,
|
||||
},
|
||||
RetriesExhausted {
|
||||
attempts: u32,
|
||||
@@ -128,6 +132,17 @@ impl ApiError {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Return the `Retry-After` delay if this error came from a 429 response
|
||||
/// that included a `retry-after` header. Callers should prefer this value
|
||||
/// over the computed backoff delay when it exists.
|
||||
pub fn retry_after(&self) -> Option<Duration> {
|
||||
match self {
|
||||
Self::Api { retry_after, .. } => *retry_after,
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.retry_after(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_retryable(&self) -> bool {
|
||||
match self {
|
||||
Self::Http(error) => error.is_connect() || error.is_timeout() || error.is_request(),
|
||||
@@ -273,7 +288,10 @@ impl Display for ApiError {
|
||||
}
|
||||
}
|
||||
if let Some(hint) = hint {
|
||||
write!(f, " — hint: {hint}")?;
|
||||
// #754: newline-delimited so split_error_hint() can extract the hint
|
||||
// into the JSON envelope's `hint` field. The em-dash form was a
|
||||
// single-line string that left hint:null in --output-format json.
|
||||
write!(f, "\n{hint}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -308,6 +326,36 @@ impl Display for ApiError {
|
||||
f,
|
||||
"failed to parse {provider} response for model {model}: {source}; first 200 chars of body: {body_snippet}"
|
||||
),
|
||||
// #28: enhance 401/403 errors with actionable auth guidance
|
||||
Self::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
..
|
||||
} if matches!(status.as_u16(), 401 | 403) => {
|
||||
if let (Some(error_type), Some(message)) = (error_type, message) {
|
||||
write!(f, "api returned {status} ({error_type})")?;
|
||||
if let Some(request_id) = request_id {
|
||||
write!(f, " [trace {request_id}]")?;
|
||||
}
|
||||
write!(f, ": {message}")?;
|
||||
} else {
|
||||
write!(f, "api returned {status}")?;
|
||||
if let Some(request_id) = request_id {
|
||||
write!(f, " [trace {request_id}]")?;
|
||||
}
|
||||
write!(f, ": {body}")?;
|
||||
}
|
||||
write!(
|
||||
f,
|
||||
"\nhint: check that your API key is valid and matches the target provider. \
|
||||
For OpenAI-compatible providers set OPENAI_API_KEY or OPENAI_BASE_URL. \
|
||||
For Anthropic set ANTHROPIC_API_KEY. \
|
||||
Run `claw doctor` to verify your credential configuration."
|
||||
)
|
||||
}
|
||||
Self::Api {
|
||||
status,
|
||||
error_type,
|
||||
@@ -496,6 +544,7 @@ mod tests {
|
||||
body: String::new(),
|
||||
retryable: true,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
|
||||
assert!(error.is_generic_fatal_wrapper());
|
||||
@@ -519,6 +568,7 @@ mod tests {
|
||||
body: String::new(),
|
||||
retryable: true,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -540,6 +590,7 @@ mod tests {
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
|
||||
assert!(error.is_context_window_failure());
|
||||
@@ -560,6 +611,7 @@ mod tests {
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
|
||||
assert!(error.is_context_window_failure());
|
||||
@@ -608,11 +660,16 @@ mod tests {
|
||||
rendered.starts_with("missing Anthropic credentials;"),
|
||||
"hint should be appended, not replace the base message: {rendered}"
|
||||
);
|
||||
let hint_marker = " — hint: I see OPENAI_API_KEY is set — if you meant to use the OpenAI-compat provider, prefix your model name with `openai/` so prefix routing selects it.";
|
||||
// #754: hint is now newline-delimited so split_error_hint() can extract it
|
||||
let hint_text = "I see OPENAI_API_KEY is set — if you meant to use the OpenAI-compat provider, prefix your model name with `openai/` so prefix routing selects it.";
|
||||
assert!(
|
||||
rendered.ends_with(hint_marker),
|
||||
rendered.ends_with(hint_text),
|
||||
"rendered error should end with the hint: {rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains('\n'),
|
||||
"rendered error must contain newline separator so split_error_hint works: {rendered}"
|
||||
);
|
||||
// Classification semantics are unaffected by the presence of a hint.
|
||||
assert_eq!(error.safe_failure_class(), "provider_auth");
|
||||
assert!(!error.is_retryable());
|
||||
|
||||
@@ -1,9 +1,69 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::error::ApiError;
|
||||
|
||||
const HTTP_PROXY_KEYS: [&str; 2] = ["HTTP_PROXY", "http_proxy"];
|
||||
const HTTPS_PROXY_KEYS: [&str; 2] = ["HTTPS_PROXY", "https_proxy"];
|
||||
const NO_PROXY_KEYS: [&str; 2] = ["NO_PROXY", "no_proxy"];
|
||||
|
||||
/// Timeout configuration for outbound HTTP requests.
|
||||
///
|
||||
/// When set, the `reqwest::Client` will abort requests that take longer
|
||||
/// than the configured duration and return a timeout error (which is
|
||||
/// retryable by the existing exponential backoff logic).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TimeoutConfig {
|
||||
/// Maximum time to wait for a connection to be established.
|
||||
/// Defaults to 30 seconds.
|
||||
pub connect_timeout: Duration,
|
||||
/// Maximum time for the entire request (including reading the response
|
||||
/// body). For streaming responses this is the timeout for the initial
|
||||
/// handshake only; the stream itself is governed by SSE parsing.
|
||||
/// Defaults to 5 minutes (300 seconds).
|
||||
pub request_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for TimeoutConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
connect_timeout: Duration::from_secs(30),
|
||||
request_timeout: Duration::from_secs(300),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TimeoutConfig {
|
||||
/// Read timeout settings from the process environment.
|
||||
/// - `CLAW_API_CONNECT_TIMEOUT` — connect timeout in seconds
|
||||
/// - `CLAW_API_REQUEST_TIMEOUT` — overall request timeout in seconds
|
||||
#[must_use]
|
||||
pub fn from_env() -> Self {
|
||||
let connect_timeout = std::env::var("CLAW_API_CONNECT_TIMEOUT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.map(Duration::from_secs)
|
||||
.unwrap_or(Duration::from_secs(30));
|
||||
let request_timeout = std::env::var("CLAW_API_REQUEST_TIMEOUT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.map(Duration::from_secs)
|
||||
.unwrap_or(Duration::from_secs(300));
|
||||
Self {
|
||||
connect_timeout,
|
||||
request_timeout,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create from explicit second values (used by config file parsing).
|
||||
#[must_use]
|
||||
pub fn from_seconds(connect_secs: u64, request_secs: u64) -> Self {
|
||||
Self {
|
||||
connect_timeout: Duration::from_secs(connect_secs),
|
||||
request_timeout: Duration::from_secs(request_secs),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot of the proxy-related environment variables that influence the
|
||||
/// outbound HTTP client. Captured up front so callers can inspect, log, and
|
||||
/// test the resolved configuration without re-reading the process environment.
|
||||
@@ -61,7 +121,7 @@ impl ProxyConfig {
|
||||
/// `HTTPS_PROXY`, and `NO_PROXY` environment variables. When no proxy is
|
||||
/// configured the client behaves identically to `reqwest::Client::new()`.
|
||||
pub fn build_http_client() -> Result<reqwest::Client, ApiError> {
|
||||
build_http_client_with(&ProxyConfig::from_env())
|
||||
build_http_client_with_opts(&ProxyConfig::from_env(), &TimeoutConfig::from_env())
|
||||
}
|
||||
|
||||
/// Infallible counterpart to [`build_http_client`] for constructors that
|
||||
@@ -71,7 +131,13 @@ pub fn build_http_client() -> Result<reqwest::Client, ApiError> {
|
||||
/// first outbound request instead of at construction time.
|
||||
#[must_use]
|
||||
pub fn build_http_client_or_default() -> reqwest::Client {
|
||||
build_http_client().unwrap_or_else(|_| reqwest::Client::new())
|
||||
build_http_client_with_opts(&ProxyConfig::from_env(), &TimeoutConfig::from_env())
|
||||
.unwrap_or_else(|_| {
|
||||
reqwest::Client::builder()
|
||||
.user_agent("clawd-rust-tools/0.1")
|
||||
.build()
|
||||
.expect("default client with user_agent should always succeed")
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a `reqwest::Client` from an explicit [`ProxyConfig`]. Used by tests
|
||||
@@ -81,7 +147,20 @@ pub fn build_http_client_or_default() -> reqwest::Client {
|
||||
/// and `https_proxy` fields and is registered as both an HTTP and HTTPS
|
||||
/// proxy so a single value can route every outbound request.
|
||||
pub fn build_http_client_with(config: &ProxyConfig) -> Result<reqwest::Client, ApiError> {
|
||||
let mut builder = reqwest::Client::builder().no_proxy();
|
||||
build_http_client_with_opts(config, &TimeoutConfig::from_env())
|
||||
}
|
||||
|
||||
/// Build a `reqwest::Client` from explicit [`ProxyConfig`] and [`TimeoutConfig`].
|
||||
/// Used by callers that want to control both proxy routing and request timing.
|
||||
pub fn build_http_client_with_opts(
|
||||
config: &ProxyConfig,
|
||||
timeout: &TimeoutConfig,
|
||||
) -> Result<reqwest::Client, ApiError> {
|
||||
let mut builder = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.user_agent("clawd-rust-tools/0.1")
|
||||
.connect_timeout(timeout.connect_timeout)
|
||||
.timeout(timeout.request_timeout);
|
||||
|
||||
let no_proxy = config
|
||||
.no_proxy
|
||||
@@ -124,7 +203,7 @@ where
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{build_http_client_with, ProxyConfig};
|
||||
use super::{build_http_client_with, build_http_client_with_opts, ProxyConfig, TimeoutConfig};
|
||||
|
||||
fn config_from_map(pairs: &[(&str, &str)]) -> ProxyConfig {
|
||||
let map: HashMap<String, String> = pairs
|
||||
@@ -136,30 +215,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn proxy_config_is_empty_when_no_env_vars_are_set() {
|
||||
// given
|
||||
let config = config_from_map(&[]);
|
||||
|
||||
// when
|
||||
let empty = config.is_empty();
|
||||
|
||||
// then
|
||||
assert!(empty);
|
||||
assert!(config.is_empty());
|
||||
assert_eq!(config, ProxyConfig::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_config_reads_uppercase_http_https_and_no_proxy() {
|
||||
// given
|
||||
let pairs = [
|
||||
("HTTP_PROXY", "http://proxy.internal:3128"),
|
||||
("HTTPS_PROXY", "http://secure.internal:3129"),
|
||||
("NO_PROXY", "localhost,127.0.0.1,.corp"),
|
||||
];
|
||||
|
||||
// when
|
||||
let config = config_from_map(&pairs);
|
||||
|
||||
// then
|
||||
assert_eq!(
|
||||
config.http_proxy.as_deref(),
|
||||
Some("http://proxy.internal:3128")
|
||||
@@ -177,17 +245,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn proxy_config_falls_back_to_lowercase_keys() {
|
||||
// given
|
||||
let pairs = [
|
||||
("http_proxy", "http://lower.internal:3128"),
|
||||
("https_proxy", "http://lower-secure.internal:3129"),
|
||||
("no_proxy", ".lower"),
|
||||
];
|
||||
|
||||
// when
|
||||
let config = config_from_map(&pairs);
|
||||
|
||||
// then
|
||||
assert_eq!(
|
||||
config.http_proxy.as_deref(),
|
||||
Some("http://lower.internal:3128")
|
||||
@@ -201,16 +264,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn proxy_config_prefers_uppercase_over_lowercase_when_both_set() {
|
||||
// given
|
||||
let pairs = [
|
||||
("HTTP_PROXY", "http://upper.internal:3128"),
|
||||
("http_proxy", "http://lower.internal:3128"),
|
||||
];
|
||||
|
||||
// when
|
||||
let config = config_from_map(&pairs);
|
||||
|
||||
// then
|
||||
assert_eq!(
|
||||
config.http_proxy.as_deref(),
|
||||
Some("http://upper.internal:3128")
|
||||
@@ -219,59 +277,39 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn proxy_config_treats_empty_strings_as_unset() {
|
||||
// given
|
||||
let pairs = [("HTTP_PROXY", ""), ("http_proxy", "")];
|
||||
|
||||
// when
|
||||
let config = config_from_map(&pairs);
|
||||
|
||||
// then
|
||||
assert!(config.http_proxy.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_http_client_succeeds_when_no_proxy_is_configured() {
|
||||
// given
|
||||
let config = ProxyConfig::default();
|
||||
|
||||
// when
|
||||
let result = build_http_client_with(&config);
|
||||
|
||||
// then
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_http_client_succeeds_with_valid_http_and_https_proxies() {
|
||||
// given
|
||||
let config = ProxyConfig {
|
||||
http_proxy: Some("http://proxy.internal:3128".to_string()),
|
||||
https_proxy: Some("http://secure.internal:3129".to_string()),
|
||||
no_proxy: Some("localhost,127.0.0.1".to_string()),
|
||||
proxy_url: None,
|
||||
};
|
||||
|
||||
// when
|
||||
let result = build_http_client_with(&config);
|
||||
|
||||
// then
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_http_client_returns_http_error_for_invalid_proxy_url() {
|
||||
// given
|
||||
let config = ProxyConfig {
|
||||
http_proxy: None,
|
||||
https_proxy: Some("not a url".to_string()),
|
||||
no_proxy: None,
|
||||
proxy_url: None,
|
||||
};
|
||||
|
||||
// when
|
||||
let result = build_http_client_with(&config);
|
||||
|
||||
// then
|
||||
let error = result.expect_err("invalid proxy URL must be reported as a build failure");
|
||||
assert!(
|
||||
matches!(error, crate::error::ApiError::Http(_)),
|
||||
@@ -281,10 +319,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn from_proxy_url_sets_unified_field_and_leaves_per_scheme_empty() {
|
||||
// given / when
|
||||
let config = ProxyConfig::from_proxy_url("http://unified.internal:3128");
|
||||
|
||||
// then
|
||||
assert_eq!(
|
||||
config.proxy_url.as_deref(),
|
||||
Some("http://unified.internal:3128")
|
||||
@@ -296,49 +331,56 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn build_http_client_succeeds_with_unified_proxy_url() {
|
||||
// given
|
||||
let config = ProxyConfig {
|
||||
proxy_url: Some("http://unified.internal:3128".to_string()),
|
||||
no_proxy: Some("localhost".to_string()),
|
||||
..ProxyConfig::default()
|
||||
};
|
||||
|
||||
// when
|
||||
let result = build_http_client_with(&config);
|
||||
|
||||
// then
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_url_takes_precedence_over_per_scheme_fields() {
|
||||
// given – both per-scheme and unified are set
|
||||
let config = ProxyConfig {
|
||||
http_proxy: Some("http://per-scheme.internal:1111".to_string()),
|
||||
https_proxy: Some("http://per-scheme.internal:2222".to_string()),
|
||||
no_proxy: None,
|
||||
proxy_url: Some("http://unified.internal:3128".to_string()),
|
||||
};
|
||||
|
||||
// when – building succeeds (the unified URL is valid)
|
||||
let result = build_http_client_with(&config);
|
||||
|
||||
// then
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_http_client_returns_error_for_invalid_unified_proxy_url() {
|
||||
// given
|
||||
let config = ProxyConfig::from_proxy_url("not a url");
|
||||
|
||||
// when
|
||||
let result = build_http_client_with(&config);
|
||||
|
||||
// then
|
||||
assert!(
|
||||
matches!(result, Err(crate::error::ApiError::Http(_))),
|
||||
"invalid unified proxy URL should fail: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_config_defaults() {
|
||||
let config = TimeoutConfig::default();
|
||||
assert_eq!(config.connect_timeout, std::time::Duration::from_secs(30));
|
||||
assert_eq!(config.request_timeout, std::time::Duration::from_secs(300));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_config_from_seconds() {
|
||||
let config = TimeoutConfig::from_seconds(10, 60);
|
||||
assert_eq!(config.connect_timeout, std::time::Duration::from_secs(10));
|
||||
assert_eq!(config.request_timeout, std::time::Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_http_client_with_custom_timeouts() {
|
||||
let config = ProxyConfig::default();
|
||||
let timeout = TimeoutConfig::from_seconds(5, 120);
|
||||
let result = build_http_client_with_opts(&config, &timeout);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ pub use client::{
|
||||
};
|
||||
pub use error::ApiError;
|
||||
pub use http_client::{
|
||||
build_http_client, build_http_client_or_default, build_http_client_with, ProxyConfig,
|
||||
build_http_client, build_http_client_or_default, build_http_client_with,
|
||||
build_http_client_with_opts, ProxyConfig, TimeoutConfig,
|
||||
};
|
||||
pub use prompt_cache::{
|
||||
CacheBreakEvent, PromptCache, PromptCacheConfig, PromptCachePaths, PromptCacheRecord,
|
||||
|
||||
@@ -211,6 +211,19 @@ impl AnthropicClient {
|
||||
self
|
||||
}
|
||||
|
||||
/// Replace the internal HTTP client with one that respects the given
|
||||
/// timeout configuration. This controls connect and request-level
|
||||
/// timeouts for all outbound API calls.
|
||||
#[must_use]
|
||||
pub fn with_timeout(mut self, timeout: &crate::http_client::TimeoutConfig) -> Self {
|
||||
self.http = crate::http_client::build_http_client_with_opts(
|
||||
&crate::http_client::ProxyConfig::from_env(),
|
||||
timeout,
|
||||
)
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_session_tracer(mut self, session_tracer: SessionTracer) -> Self {
|
||||
self.session_tracer = Some(session_tracer);
|
||||
@@ -454,7 +467,13 @@ impl AnthropicClient {
|
||||
break;
|
||||
}
|
||||
|
||||
tokio::time::sleep(self.jittered_backoff_for_attempt(attempts)?).await;
|
||||
let delay = if let Some(retry_after) = last_error.as_ref().and_then(|e| e.retry_after())
|
||||
{
|
||||
retry_after
|
||||
} else {
|
||||
self.jittered_backoff_for_attempt(attempts)?
|
||||
};
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
|
||||
Err(ApiError::RetriesExhausted {
|
||||
@@ -468,8 +487,7 @@ impl AnthropicClient {
|
||||
request: &MessageRequest,
|
||||
) -> Result<reqwest::Response, ApiError> {
|
||||
let request_url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
|
||||
let mut request_body = self.request_profile.render_json_body(request)?;
|
||||
strip_unsupported_beta_body_fields(&mut request_body);
|
||||
let request_body = render_standard_messages_body(&self.request_profile, request)?;
|
||||
let request_builder = self.build_request(&request_url).json(&request_body);
|
||||
request_builder.send().await.map_err(ApiError::from)
|
||||
}
|
||||
@@ -529,8 +547,7 @@ impl AnthropicClient {
|
||||
"{}/v1/messages/count_tokens",
|
||||
self.base_url.trim_end_matches('/')
|
||||
);
|
||||
let mut request_body = self.request_profile.render_json_body(request)?;
|
||||
strip_unsupported_beta_body_fields(&mut request_body);
|
||||
let request_body = render_standard_messages_body(&self.request_profile, request)?;
|
||||
let response = self
|
||||
.build_request(&request_url)
|
||||
.json(&request_body)
|
||||
@@ -868,10 +885,12 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let request_id = request_id_from_headers(response.headers());
|
||||
let headers = response.headers().clone();
|
||||
let request_id = request_id_from_headers(&headers);
|
||||
let body = response.text().await.unwrap_or_else(|_| String::new());
|
||||
let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&body).ok();
|
||||
let retryable = is_retryable_status(status);
|
||||
let retry_after = parse_retry_after(&headers, status);
|
||||
|
||||
Err(ApiError::Api {
|
||||
status,
|
||||
@@ -885,13 +904,44 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
body,
|
||||
retryable,
|
||||
suggested_action: None,
|
||||
retry_after,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_retry_after(
|
||||
headers: &reqwest::header::HeaderMap,
|
||||
status: reqwest::StatusCode,
|
||||
) -> Option<std::time::Duration> {
|
||||
if status != reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||
return None;
|
||||
}
|
||||
headers
|
||||
.get("retry-after")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.map(std::time::Duration::from_secs)
|
||||
}
|
||||
|
||||
const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
|
||||
matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
|
||||
}
|
||||
|
||||
/// Some providers return HTTP 400 with an unparseable body when a gateway
|
||||
/// or proxy flakes (e.g. "HTTP 400 from backend (no parseable body)").
|
||||
/// These are transient network blips, not actual bad requests, and should
|
||||
/// be retried. We detect them by checking the body for known gateway error
|
||||
/// phrases.
|
||||
fn is_retryable_400(status: reqwest::StatusCode, body: &str) -> bool {
|
||||
if status != reqwest::StatusCode::BAD_REQUEST {
|
||||
return false;
|
||||
}
|
||||
let lowered = body.to_ascii_lowercase();
|
||||
lowered.contains("no parseable body")
|
||||
|| lowered.contains("connection reset")
|
||||
|| lowered.contains("broken pipe")
|
||||
|| lowered.contains("empty reply from server")
|
||||
}
|
||||
|
||||
/// Anthropic API keys (`sk-ant-*`) are accepted over the `x-api-key` header
|
||||
/// and rejected with HTTP 401 "Invalid bearer token" when sent as a Bearer
|
||||
/// token via `ANTHROPIC_AUTH_TOKEN`. This happens often enough in the wild
|
||||
@@ -910,6 +960,8 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
..
|
||||
} = error
|
||||
else {
|
||||
return error;
|
||||
@@ -923,6 +975,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
};
|
||||
}
|
||||
let Some(bearer_token) = auth.bearer_token() else {
|
||||
@@ -934,6 +987,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
};
|
||||
};
|
||||
if !bearer_token.starts_with("sk-ant-") {
|
||||
@@ -945,6 +999,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
};
|
||||
}
|
||||
// Only append the hint when the AuthSource is pure BearerToken. If both
|
||||
@@ -960,6 +1015,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
};
|
||||
}
|
||||
let enriched_message = match message {
|
||||
@@ -974,9 +1030,25 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
}
|
||||
}
|
||||
|
||||
fn anthropic_wire_model(model: &str) -> &str {
|
||||
model.strip_prefix("anthropic/").unwrap_or(model)
|
||||
}
|
||||
|
||||
fn render_standard_messages_body(
|
||||
request_profile: &AnthropicRequestProfile,
|
||||
request: &MessageRequest,
|
||||
) -> Result<Value, serde_json::Error> {
|
||||
let mut wire_request = request.clone();
|
||||
wire_request.model = anthropic_wire_model(&request.model).to_string();
|
||||
let mut body = request_profile.render_json_body(&wire_request)?;
|
||||
strip_unsupported_beta_body_fields(&mut body);
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Remove beta-only body fields that the standard `/v1/messages` and
|
||||
/// `/v1/messages/count_tokens` endpoints reject as `Extra inputs are not
|
||||
/// permitted`. The `betas` opt-in is communicated via the `anthropic-beta`
|
||||
@@ -1550,6 +1622,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_messages_body_strips_anthropic_routing_prefix() {
|
||||
let client = AnthropicClient::new("test-key");
|
||||
let request = MessageRequest {
|
||||
model: "anthropic/claude-opus-4-6".to_string(),
|
||||
max_tokens: 64,
|
||||
messages: vec![],
|
||||
system: None,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
stream: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let rendered = super::render_standard_messages_body(client.request_profile(), &request)
|
||||
.expect("body should render");
|
||||
|
||||
assert_eq!(rendered["model"], serde_json::json!("claude-opus-4-6"));
|
||||
assert!(rendered.get("betas").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enrich_bearer_auth_error_appends_sk_ant_hint_on_401_with_pure_bearer_token() {
|
||||
// given
|
||||
@@ -1562,6 +1655,7 @@ mod tests {
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
|
||||
// when
|
||||
@@ -1603,6 +1697,7 @@ mod tests {
|
||||
body: String::new(),
|
||||
retryable: true,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
|
||||
// when
|
||||
@@ -1632,6 +1727,7 @@ mod tests {
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
|
||||
// when
|
||||
@@ -1660,6 +1756,7 @@ mod tests {
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
|
||||
// when
|
||||
@@ -1685,6 +1782,7 @@ mod tests {
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
|
||||
// when
|
||||
|
||||
@@ -211,7 +211,7 @@ pub fn resolve_model_alias(model: &str) -> String {
|
||||
.find_map(|(alias, metadata)| {
|
||||
(*alias == lower).then_some(match metadata.provider {
|
||||
ProviderKind::Anthropic => match *alias {
|
||||
"opus" => "claude-opus-4-6",
|
||||
"opus" => "claude-opus-4-7",
|
||||
"sonnet" => "claude-sonnet-4-6",
|
||||
"haiku" => "claude-haiku-4-5-20251213",
|
||||
_ => trimmed,
|
||||
@@ -234,7 +234,7 @@ pub fn resolve_model_alias(model: &str) -> String {
|
||||
#[must_use]
|
||||
pub fn metadata_for_model(model: &str) -> Option<ProviderMetadata> {
|
||||
let canonical = resolve_model_alias(model);
|
||||
if canonical.starts_with("claude") {
|
||||
if canonical.starts_with("claude") || canonical.starts_with("anthropic/") {
|
||||
return Some(ProviderMetadata {
|
||||
provider: ProviderKind::Anthropic,
|
||||
auth_env: "ANTHROPIC_API_KEY",
|
||||
@@ -262,6 +262,14 @@ pub fn metadata_for_model(model: &str) -> Option<ProviderMetadata> {
|
||||
default_base_url: openai_compat::DEFAULT_OPENAI_BASE_URL,
|
||||
});
|
||||
}
|
||||
if canonical.starts_with("local/") {
|
||||
return Some(ProviderMetadata {
|
||||
provider: ProviderKind::OpenAi,
|
||||
auth_env: "OPENAI_API_KEY",
|
||||
base_url_env: "OPENAI_BASE_URL",
|
||||
default_base_url: openai_compat::DEFAULT_OPENAI_BASE_URL,
|
||||
});
|
||||
}
|
||||
// Alibaba DashScope compatible-mode endpoint. Routes qwen/* and bare
|
||||
// qwen-* model names (qwen-max, qwen-plus, qwen-turbo, qwen-qwq, etc.)
|
||||
// to the OpenAI-compat client pointed at DashScope's /compatible-mode/v1.
|
||||
@@ -337,17 +345,26 @@ pub fn provider_diagnostics_for_model(model: &str) -> ProviderDiagnostics {
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_local_openai_model(model: &str) -> bool {
|
||||
model.contains(':') || model.contains('.')
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn detect_provider_kind(model: &str) -> ProviderKind {
|
||||
if let Some(metadata) = metadata_for_model(model) {
|
||||
// OLLAMA_HOST takes priority: if set, route all models through the local
|
||||
// OpenAI-compatible endpoint regardless of model name or other env vars.
|
||||
if std::env::var_os("OLLAMA_HOST").is_some() {
|
||||
return ProviderKind::OpenAi;
|
||||
}
|
||||
let resolved_model = resolve_model_alias(model);
|
||||
if let Some(metadata) = metadata_for_model(&resolved_model) {
|
||||
return metadata.provider;
|
||||
}
|
||||
// When OPENAI_BASE_URL is set, the user explicitly configured an
|
||||
// OpenAI-compatible endpoint. Prefer it over the Anthropic fallback
|
||||
// even when the model name has no recognized prefix — this is the
|
||||
// common case for local providers (Ollama, LM Studio, vLLM, etc.)
|
||||
// where model names like "qwen2.5-coder:7b" don't match any prefix.
|
||||
if std::env::var_os("OPENAI_BASE_URL").is_some() && openai_compat::has_api_key("OPENAI_API_KEY")
|
||||
// When OPENAI_BASE_URL is set and the unknown model name looks like a
|
||||
// local server tag (for example `llama3.2` or `qwen2.5-coder:7b`), prefer
|
||||
// the OpenAI-compatible endpoint over ambient Anthropic credentials.
|
||||
if std::env::var_os("OPENAI_BASE_URL").is_some()
|
||||
&& looks_like_local_openai_model(&resolved_model)
|
||||
{
|
||||
return ProviderKind::OpenAi;
|
||||
}
|
||||
@@ -608,7 +625,7 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
|
||||
let canonical = resolve_model_alias(model);
|
||||
let base_model = canonical.rsplit('/').next().unwrap_or(canonical.as_str());
|
||||
match base_model {
|
||||
"claude-opus-4-6" => Some(ModelTokenLimit {
|
||||
"claude-opus-4-7" | "claude-opus-4-6" => Some(ModelTokenLimit {
|
||||
max_output_tokens: 32_000,
|
||||
context_window_tokens: 200_000,
|
||||
}),
|
||||
@@ -640,6 +657,14 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
|
||||
max_output_tokens: 16_384,
|
||||
context_window_tokens: 256_000,
|
||||
}),
|
||||
"qwen-max" => Some(ModelTokenLimit {
|
||||
max_output_tokens: 8_192,
|
||||
context_window_tokens: 131_072,
|
||||
}),
|
||||
"qwen-plus" => Some(ModelTokenLimit {
|
||||
max_output_tokens: 8_192,
|
||||
context_window_tokens: 131_072,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1034,6 +1059,18 @@ mod tests {
|
||||
assert_eq!(kind2, ProviderKind::OpenAi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_prefix_routes_to_openai_not_anthropic() {
|
||||
let meta = super::metadata_for_model("local/Qwen/Qwen3.6-27B-FP8")
|
||||
.expect("local/ prefix must resolve to OpenAI-compatible metadata");
|
||||
assert_eq!(meta.provider, ProviderKind::OpenAi);
|
||||
assert_eq!(meta.auth_env, "OPENAI_API_KEY");
|
||||
assert_eq!(meta.base_url_env, "OPENAI_BASE_URL");
|
||||
|
||||
let kind = detect_provider_kind("local/Qwen/Qwen3.6-27B-FP8");
|
||||
assert_eq!(kind, ProviderKind::OpenAi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qwen_prefix_routes_to_dashscope_not_anthropic() {
|
||||
// User request from Discord #clawcode-get-help: web3g wants to use
|
||||
@@ -1641,10 +1678,15 @@ NO_EQUALS_LINE
|
||||
rendered.starts_with("missing Anthropic credentials;"),
|
||||
"canonical base message should still lead the rendered error: {rendered}"
|
||||
);
|
||||
// #754: hint delimiter changed from " — hint: " to "\n" so split_error_hint works
|
||||
assert!(
|
||||
rendered.contains(" — hint: I see OPENAI_API_KEY is set"),
|
||||
rendered.contains("I see OPENAI_API_KEY is set"),
|
||||
"rendered error should carry the env-driven hint: {rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains('\n'),
|
||||
"rendered error must use newline separator (#754): {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -48,6 +49,14 @@ const XAI_MAX_REQUEST_BODY_BYTES: usize = 52_428_800; // 50MB
|
||||
const OPENAI_MAX_REQUEST_BODY_BYTES: usize = 104_857_600; // 100MB
|
||||
const DASHSCOPE_MAX_REQUEST_BODY_BYTES: usize = 6_291_456; // 6MB (observed limit in dogfood)
|
||||
|
||||
pub const OLLAMA_CONFIG: OpenAiCompatConfig = OpenAiCompatConfig {
|
||||
provider_name: "Ollama",
|
||||
api_key_env: "OLLAMA_HOST",
|
||||
base_url_env: "OLLAMA_HOST",
|
||||
default_base_url: "http://127.0.0.1:11434/v1",
|
||||
max_request_body_bytes: 104_857_600,
|
||||
};
|
||||
|
||||
impl OpenAiCompatConfig {
|
||||
#[must_use]
|
||||
pub const fn xai() -> Self {
|
||||
@@ -131,13 +140,38 @@ impl OpenAiCompatClient {
|
||||
}
|
||||
|
||||
pub fn from_env(config: OpenAiCompatConfig) -> Result<Self, ApiError> {
|
||||
let Some(api_key) = read_env_non_empty(config.api_key_env)? else {
|
||||
return Err(ApiError::missing_credentials(
|
||||
config.provider_name,
|
||||
config.credential_env_vars(),
|
||||
));
|
||||
let base_url = read_base_url(config);
|
||||
let api_key = match read_env_non_empty(config.api_key_env)? {
|
||||
Some(api_key) => api_key,
|
||||
None if config.provider_name == "OpenAI"
|
||||
&& is_local_openai_compatible_base_url(&base_url) =>
|
||||
{
|
||||
"local-dev-token".to_string()
|
||||
}
|
||||
None => {
|
||||
return Err(ApiError::missing_credentials(
|
||||
config.provider_name,
|
||||
config.credential_env_vars(),
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(Self::new(api_key, config))
|
||||
Ok(Self::new(api_key, config).with_base_url(base_url))
|
||||
}
|
||||
/// Create an Ollama client from `OLLAMA_HOST` env var.
|
||||
/// Ollama requires no API key; a placeholder is used for the Authorization header.
|
||||
pub fn from_ollama_env() -> Option<Self> {
|
||||
let host =
|
||||
std::env::var("OLLAMA_HOST").unwrap_or_else(|_| "http://127.0.0.1:11434".to_string());
|
||||
let base_url = format!("{}/v1", host.trim_end_matches('/'));
|
||||
Some(Self {
|
||||
http: build_http_client_or_default(),
|
||||
api_key: "ollama".to_string(),
|
||||
config: OLLAMA_CONFIG,
|
||||
base_url,
|
||||
max_retries: DEFAULT_MAX_RETRIES,
|
||||
initial_backoff: DEFAULT_INITIAL_BACKOFF,
|
||||
max_backoff: DEFAULT_MAX_BACKOFF,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -165,6 +199,18 @@ impl OpenAiCompatClient {
|
||||
self
|
||||
}
|
||||
|
||||
/// Replace the internal HTTP client with one that respects the given
|
||||
/// timeout configuration.
|
||||
#[must_use]
|
||||
pub fn with_timeout(mut self, timeout: &crate::http_client::TimeoutConfig) -> Self {
|
||||
self.http = crate::http_client::build_http_client_with_opts(
|
||||
&crate::http_client::ProxyConfig::from_env(),
|
||||
timeout,
|
||||
)
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn send_message(
|
||||
&self,
|
||||
request: &MessageRequest,
|
||||
@@ -207,6 +253,7 @@ impl OpenAiCompatClient {
|
||||
reqwest::StatusCode::from_u16(code.unwrap_or(400))
|
||||
.unwrap_or(reqwest::StatusCode::BAD_REQUEST),
|
||||
),
|
||||
retry_after: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -260,7 +307,12 @@ impl OpenAiCompatClient {
|
||||
break retryable_error;
|
||||
}
|
||||
|
||||
tokio::time::sleep(self.jittered_backoff_for_attempt(attempts)?).await;
|
||||
let delay = if let Some(retry_after) = retryable_error.retry_after() {
|
||||
retry_after
|
||||
} else {
|
||||
self.jittered_backoff_for_attempt(attempts)?
|
||||
};
|
||||
tokio::time::sleep(delay).await;
|
||||
};
|
||||
|
||||
Err(ApiError::RetriesExhausted {
|
||||
@@ -505,10 +557,16 @@ impl StreamState {
|
||||
}
|
||||
|
||||
for choice in chunk.choices {
|
||||
// Handle reasoning/thinking from various provider fields
|
||||
if let Some(reasoning) = choice
|
||||
.delta
|
||||
.reasoning_content
|
||||
.filter(|value| !value.is_empty())
|
||||
.or(choice
|
||||
.delta
|
||||
.thinking
|
||||
.and_then(|t| t.content)
|
||||
.filter(|value| !value.is_empty()))
|
||||
{
|
||||
if !self.thinking_started {
|
||||
self.thinking_started = true;
|
||||
@@ -736,6 +794,7 @@ impl ToolCallState {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChatCompletionResponse {
|
||||
#[serde(default)]
|
||||
id: String,
|
||||
model: String,
|
||||
choices: Vec<ChatChoice>,
|
||||
@@ -806,6 +865,7 @@ impl OpenAiUsage {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChatCompletionChunk {
|
||||
#[serde(default)]
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
model: Option<String>,
|
||||
@@ -817,6 +877,7 @@ struct ChatCompletionChunk {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChunkChoice {
|
||||
#[serde(default)]
|
||||
delta: ChunkDelta,
|
||||
#[serde(default)]
|
||||
finish_reason: Option<String>,
|
||||
@@ -826,12 +887,21 @@ struct ChunkChoice {
|
||||
struct ChunkDelta {
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
/// Some providers (GLM, DeepSeek) emit reasoning in `reasoning_content`
|
||||
#[serde(default)]
|
||||
reasoning_content: Option<String>,
|
||||
#[serde(default)]
|
||||
thinking: Option<ThinkingDelta>,
|
||||
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
|
||||
tool_calls: Vec<DeltaToolCall>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct ThinkingDelta {
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DeltaToolCall {
|
||||
#[serde(default)]
|
||||
@@ -897,14 +967,18 @@ pub fn model_requires_reasoning_content_in_history(model: &str) -> bool {
|
||||
|
||||
/// Strip routing prefix (e.g., "openai/gpt-4" → "gpt-4") for the wire.
|
||||
/// The prefix is used only to select transport; the backend expects the
|
||||
/// bare model id.
|
||||
/// bare model id. Use `local/` to force OpenAI-compatible routing while
|
||||
/// preserving any slashes that follow the prefix.
|
||||
#[allow(dead_code)]
|
||||
fn strip_routing_prefix(model: &str) -> &str {
|
||||
if let Some(pos) = model.find('/') {
|
||||
let prefix = &model[..pos];
|
||||
// Only strip if the prefix before "/" is a known routing prefix,
|
||||
// not if "/" appears in the middle of the model name for other reasons.
|
||||
if matches!(prefix, "openai" | "xai" | "grok" | "qwen" | "kimi") {
|
||||
if matches!(
|
||||
prefix,
|
||||
"openai" | "xai" | "grok" | "qwen" | "kimi" | "local"
|
||||
) {
|
||||
&model[pos + 1..]
|
||||
} else {
|
||||
model
|
||||
@@ -914,6 +988,44 @@ fn strip_routing_prefix(model: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_base_url_for_model_routing(url: &str) -> &str {
|
||||
let trimmed = url.trim_end_matches('/');
|
||||
trimmed
|
||||
.strip_suffix("/chat/completions")
|
||||
.map(|value| value.trim_end_matches('/'))
|
||||
.unwrap_or(trimmed)
|
||||
}
|
||||
|
||||
fn url_host(url: &str) -> &str {
|
||||
let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
|
||||
let authority = after_scheme.split(['/', '?', '#']).next().unwrap_or("");
|
||||
let host_port = authority
|
||||
.rsplit_once('@')
|
||||
.map_or(authority, |(_, host_port)| host_port);
|
||||
if host_port.starts_with('[') {
|
||||
return host_port
|
||||
.split(']')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim_start_matches('[');
|
||||
}
|
||||
host_port.split(':').next().unwrap_or("")
|
||||
}
|
||||
|
||||
fn is_local_openai_compatible_base_url(url: &str) -> bool {
|
||||
let host = url_host(url.trim());
|
||||
if host.eq_ignore_ascii_case("localhost") || host == "::1" {
|
||||
return true;
|
||||
}
|
||||
let Ok(address) = host.parse::<Ipv4Addr>() else {
|
||||
return false;
|
||||
};
|
||||
let [first, second, ..] = address.octets();
|
||||
matches!(first, 10 | 127)
|
||||
|| first == 192 && second == 168
|
||||
|| first == 172 && (16..=31).contains(&second)
|
||||
}
|
||||
|
||||
fn wire_model_for_base_url<'a>(
|
||||
model: &'a str,
|
||||
config: OpenAiCompatConfig,
|
||||
@@ -926,22 +1038,22 @@ fn wire_model_for_base_url<'a>(
|
||||
let lowered_prefix = prefix.to_ascii_lowercase();
|
||||
|
||||
if lowered_prefix == "openai" {
|
||||
let trimmed_base_url = base_url.trim_end_matches('/');
|
||||
let default_openai = DEFAULT_OPENAI_BASE_URL.trim_end_matches('/');
|
||||
if config.provider_name == "OpenAI" && trimmed_base_url != default_openai {
|
||||
// OpenAI-compatible gateways such as OpenRouter commonly use
|
||||
// slash-containing model slugs (for example `openai/gpt-4.1-mini`).
|
||||
// Preserve the slug when the user configured a non-default OpenAI
|
||||
// base URL; the prefix still routed to the OpenAI-compatible client,
|
||||
// but the gateway owns the final model namespace.
|
||||
return Cow::Borrowed(model);
|
||||
let normalized_base_url = normalize_base_url_for_model_routing(base_url);
|
||||
let default_base_url = normalize_base_url_for_model_routing(config.default_base_url);
|
||||
if normalized_base_url.eq_ignore_ascii_case(default_base_url)
|
||||
|| is_local_openai_compatible_base_url(base_url)
|
||||
{
|
||||
return Cow::Borrowed(&model[pos + 1..]);
|
||||
}
|
||||
return Cow::Borrowed(&model[pos + 1..]);
|
||||
return Cow::Borrowed(model);
|
||||
}
|
||||
|
||||
if matches!(lowered_prefix.as_str(), "xai" | "grok" | "qwen" | "kimi") {
|
||||
return Cow::Borrowed(&model[pos + 1..]);
|
||||
}
|
||||
if lowered_prefix == "local" {
|
||||
return Cow::Borrowed(&model[pos + 1..]);
|
||||
}
|
||||
|
||||
Cow::Borrowed(model)
|
||||
}
|
||||
@@ -1093,6 +1205,13 @@ fn build_chat_completion_request_for_base_url(
|
||||
payload[key] = value.clone();
|
||||
}
|
||||
|
||||
// DeepSeek V4 Pro/Flash thinking mode requires this provider-specific opt-in
|
||||
// and also requires assistant reasoning history to be echoed as `reasoning_content`.
|
||||
// Apply it after extra_body so callers cannot accidentally override the required shape.
|
||||
if model_requires_reasoning_content_in_history(wire_model) {
|
||||
payload["thinking"] = json!({"type": "enabled"});
|
||||
}
|
||||
|
||||
payload
|
||||
}
|
||||
|
||||
@@ -1150,16 +1269,19 @@ pub fn translate_message(message: &InputMessage, model: &str) -> Vec<Value> {
|
||||
InputContentBlock::ToolResult { .. } => {}
|
||||
}
|
||||
}
|
||||
let include_reasoning =
|
||||
model_requires_reasoning_content_in_history(model) && !reasoning.is_empty();
|
||||
if text.is_empty() && tool_calls.is_empty() && !include_reasoning {
|
||||
let needs_reasoning = model_requires_reasoning_content_in_history(model);
|
||||
if text.is_empty() && tool_calls.is_empty() && reasoning.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let mut msg = serde_json::json!({
|
||||
"role": "assistant",
|
||||
"content": (!text.is_empty()).then_some(text),
|
||||
});
|
||||
if include_reasoning {
|
||||
if !text.is_empty() {
|
||||
msg["content"] = json!(text);
|
||||
} else if !needs_reasoning {
|
||||
msg["content"] = Value::Null;
|
||||
}
|
||||
if needs_reasoning {
|
||||
msg["reasoning_content"] = json!(reasoning);
|
||||
}
|
||||
// Only include tool_calls when non-empty: some providers reject
|
||||
@@ -1454,7 +1576,52 @@ fn parse_sse_frame(
|
||||
data_lines.push(data.trim_start());
|
||||
}
|
||||
}
|
||||
// If no SSE data lines found, check if the entire frame is raw JSON (error or otherwise)
|
||||
if data_lines.is_empty() {
|
||||
// Detect raw JSON error response (not SSE-framed)
|
||||
if let Ok(raw) = serde_json::from_str::<serde_json::Value>(trimmed) {
|
||||
if let Some(err_obj) = raw.get("error") {
|
||||
let msg = err_obj
|
||||
.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("provider returned an error")
|
||||
.to_string();
|
||||
let code = err_obj
|
||||
.get("code")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.map(|c| c as u16);
|
||||
let status = reqwest::StatusCode::from_u16(code.unwrap_or(500))
|
||||
.unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
|
||||
return Err(ApiError::Api {
|
||||
status,
|
||||
error_type: err_obj
|
||||
.get("type")
|
||||
.and_then(|t| t.as_str())
|
||||
.map(str::to_owned),
|
||||
message: Some(msg),
|
||||
request_id: None,
|
||||
body: trimmed.chars().take(500).collect(),
|
||||
retryable: false,
|
||||
suggested_action: suggested_action_for_status(status),
|
||||
retry_after: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Detect HTML responses
|
||||
if trimmed.starts_with('<') || trimmed.starts_with("<!") {
|
||||
return Err(ApiError::Api {
|
||||
status: reqwest::StatusCode::BAD_REQUEST,
|
||||
error_type: Some("invalid_response".to_string()),
|
||||
message: Some(
|
||||
"provider returned HTML instead of JSON (check endpoint URL)".to_string(),
|
||||
),
|
||||
request_id: None,
|
||||
body: trimmed.chars().take(200).collect(),
|
||||
retryable: false,
|
||||
suggested_action: Some("verify the API endpoint URL is correct".to_string()),
|
||||
retry_after: None,
|
||||
});
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
let payload = data_lines.join("\n");
|
||||
@@ -1488,9 +1655,26 @@ fn parse_sse_frame(
|
||||
body: payload.clone(),
|
||||
retryable: false,
|
||||
suggested_action: suggested_action_for_status(status),
|
||||
retry_after: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Detect HTML or other non-JSON responses early for better error messages
|
||||
let trimmed_payload = payload.trim();
|
||||
if trimmed_payload.starts_with('<') || trimmed_payload.starts_with("<!") {
|
||||
return Err(ApiError::Api {
|
||||
status: reqwest::StatusCode::BAD_REQUEST,
|
||||
error_type: Some("invalid_response".to_string()),
|
||||
message: Some(
|
||||
"provider returned HTML instead of JSON (check endpoint URL)".to_string(),
|
||||
),
|
||||
request_id: None,
|
||||
body: payload.chars().take(200).collect(),
|
||||
retryable: false,
|
||||
suggested_action: Some("verify the API endpoint URL is correct".to_string()),
|
||||
retry_after: None,
|
||||
});
|
||||
}
|
||||
serde_json::from_str::<ChatCompletionChunk>(&payload)
|
||||
.map(Some)
|
||||
.map_err(|error| ApiError::json_deserialize(provider, model, &payload, error))
|
||||
@@ -1540,10 +1724,12 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let request_id = request_id_from_headers(response.headers());
|
||||
let headers = response.headers().clone();
|
||||
let request_id = request_id_from_headers(&headers);
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let parsed_error = serde_json::from_str::<ErrorEnvelope>(&body).ok();
|
||||
let retryable = is_retryable_status(status);
|
||||
let retry_after = parse_retry_after(&headers, status);
|
||||
|
||||
let suggested_action = suggested_action_for_status(status);
|
||||
|
||||
@@ -1559,13 +1745,43 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_retry_after(
|
||||
headers: &reqwest::header::HeaderMap,
|
||||
status: reqwest::StatusCode,
|
||||
) -> Option<std::time::Duration> {
|
||||
if status != reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||
return None;
|
||||
}
|
||||
headers
|
||||
.get("retry-after")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.map(std::time::Duration::from_secs)
|
||||
}
|
||||
|
||||
const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
|
||||
matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
|
||||
}
|
||||
|
||||
/// Some providers return HTTP 400 with an unparseable body when a gateway
|
||||
/// or proxy flakes (e.g. "HTTP 400 from backend (no parseable body)").
|
||||
/// These are transient network blips, not actual bad requests, and should
|
||||
/// be retried.
|
||||
fn is_retryable_400(status: reqwest::StatusCode, body: &str) -> bool {
|
||||
if status != reqwest::StatusCode::BAD_REQUEST {
|
||||
return false;
|
||||
}
|
||||
let lowered = body.to_ascii_lowercase();
|
||||
lowered.contains("no parseable body")
|
||||
|| lowered.contains("connection reset")
|
||||
|| lowered.contains("broken pipe")
|
||||
|| lowered.contains("empty reply from server")
|
||||
}
|
||||
|
||||
/// Generate a suggested user action based on the HTTP status code and error context.
|
||||
/// This provides actionable guidance when API requests fail.
|
||||
fn suggested_action_for_status(status: reqwest::StatusCode) -> Option<String> {
|
||||
@@ -1618,6 +1834,7 @@ mod tests {
|
||||
ToolChoice, ToolDefinition, ToolResultContentBlock,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
@@ -1716,6 +1933,31 @@ mod tests {
|
||||
assert_eq!(assistant["content"], json!("answer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deepseek_v4_assistant_with_only_tool_calls_omits_content_and_includes_reasoning() {
|
||||
let request = MessageRequest {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
max_tokens: 100,
|
||||
messages: vec![InputMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![InputContentBlock::ToolUse {
|
||||
id: "call_1".to_string(),
|
||||
name: "get_weather".to_string(),
|
||||
input: json!({"city": "Paris"}),
|
||||
}],
|
||||
}],
|
||||
stream: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
|
||||
let assistant = &payload["messages"][0];
|
||||
|
||||
assert!(assistant.get("content").is_none());
|
||||
assert_eq!(assistant["reasoning_content"], json!(""));
|
||||
assert_eq!(assistant["tool_calls"].as_array().map(Vec::len), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deepseek_v4_flash_request_includes_reasoning_content_for_assistant_history() {
|
||||
// Given an assistant history turn containing thinking.
|
||||
@@ -1777,6 +2019,7 @@ mod tests {
|
||||
delta: super::ChunkDelta {
|
||||
content: None,
|
||||
reasoning_content: Some("think".to_string()),
|
||||
thinking: None,
|
||||
tool_calls: Vec::new(),
|
||||
},
|
||||
finish_reason: None,
|
||||
@@ -1793,6 +2036,7 @@ mod tests {
|
||||
delta: super::ChunkDelta {
|
||||
content: Some(" answer".to_string()),
|
||||
reasoning_content: None,
|
||||
thinking: None,
|
||||
tool_calls: Vec::new(),
|
||||
},
|
||||
finish_reason: Some("stop".to_string()),
|
||||
@@ -1900,6 +2144,49 @@ mod tests {
|
||||
assert_eq!(payload["reasoning_effort"], json!("high"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deepseek_v4_request_includes_thinking_parameter() {
|
||||
let payload = build_chat_completion_request(
|
||||
&MessageRequest {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
max_tokens: 1024,
|
||||
messages: vec![InputMessage::user_text("hello")],
|
||||
..Default::default()
|
||||
},
|
||||
OpenAiCompatConfig::openai(),
|
||||
);
|
||||
assert_eq!(payload["thinking"], json!({"type": "enabled"}));
|
||||
assert_eq!(payload["model"], json!("deepseek-v4-pro"));
|
||||
|
||||
let mut extra_body = BTreeMap::new();
|
||||
extra_body.insert("thinking".to_string(), json!({"type": "disabled"}));
|
||||
let payload_with_override = build_chat_completion_request(
|
||||
&MessageRequest {
|
||||
model: "openai/deepseek-v4-flash".to_string(),
|
||||
max_tokens: 1024,
|
||||
messages: vec![InputMessage::user_text("hello")],
|
||||
extra_body,
|
||||
..Default::default()
|
||||
},
|
||||
OpenAiCompatConfig::openai(),
|
||||
);
|
||||
assert_eq!(
|
||||
payload_with_override["thinking"],
|
||||
json!({"type": "enabled"})
|
||||
);
|
||||
|
||||
let non_deepseek_payload = build_chat_completion_request(
|
||||
&MessageRequest {
|
||||
model: "gpt-4o".to_string(),
|
||||
max_tokens: 64,
|
||||
messages: vec![InputMessage::user_text("hello")],
|
||||
..Default::default()
|
||||
},
|
||||
OpenAiCompatConfig::openai(),
|
||||
);
|
||||
assert!(non_deepseek_payload.get("thinking").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_omitted_when_not_set() {
|
||||
let payload = build_chat_completion_request(
|
||||
@@ -1987,6 +2274,28 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_base_url_does_not_require_api_key() {
|
||||
let _lock = env_lock();
|
||||
let original_base_url = std::env::var_os("OPENAI_BASE_URL");
|
||||
let original_api_key = std::env::var_os("OPENAI_API_KEY");
|
||||
std::env::set_var("OPENAI_BASE_URL", "http://127.0.0.1:11434/v1");
|
||||
std::env::remove_var("OPENAI_API_KEY");
|
||||
|
||||
let client = OpenAiCompatClient::from_env(OpenAiCompatConfig::openai())
|
||||
.expect("local OpenAI-compatible endpoint should not require an API key");
|
||||
assert_eq!(client.base_url(), "http://127.0.0.1:11434/v1");
|
||||
|
||||
match original_base_url {
|
||||
Some(value) => std::env::set_var("OPENAI_BASE_URL", value),
|
||||
None => std::env::remove_var("OPENAI_BASE_URL"),
|
||||
}
|
||||
match original_api_key {
|
||||
Some(value) => std::env::set_var("OPENAI_API_KEY", value),
|
||||
None => std::env::remove_var("OPENAI_API_KEY"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_builder_accepts_base_urls_and_full_endpoints() {
|
||||
assert_eq!(
|
||||
@@ -2602,6 +2911,66 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_model_strips_openai_prefix_for_default_and_local_preserves_custom_gateways() {
|
||||
assert_eq!(
|
||||
super::wire_model_for_base_url(
|
||||
"openai/gpt-4o",
|
||||
OpenAiCompatConfig::openai(),
|
||||
super::DEFAULT_OPENAI_BASE_URL,
|
||||
),
|
||||
Cow::Borrowed("gpt-4o")
|
||||
);
|
||||
assert_eq!(
|
||||
super::wire_model_for_base_url(
|
||||
"openai/qwen2.5-coder:7b",
|
||||
OpenAiCompatConfig::openai(),
|
||||
"http://127.0.0.1:11434/v1",
|
||||
),
|
||||
Cow::Borrowed("qwen2.5-coder:7b")
|
||||
);
|
||||
assert_eq!(
|
||||
super::wire_model_for_base_url(
|
||||
"openai/llama3.2",
|
||||
OpenAiCompatConfig::openai(),
|
||||
"http://localhost:11434/v1/chat/completions",
|
||||
),
|
||||
Cow::Borrowed("llama3.2")
|
||||
);
|
||||
assert_eq!(
|
||||
super::wire_model_for_base_url(
|
||||
"openai/gpt-4.1-mini",
|
||||
OpenAiCompatConfig::openai(),
|
||||
"https://openrouter.ai/api/v1",
|
||||
),
|
||||
Cow::Borrowed("openai/gpt-4.1-mini")
|
||||
);
|
||||
assert_eq!(
|
||||
super::wire_model_for_base_url(
|
||||
"openai/gpt-4.1-mini",
|
||||
OpenAiCompatConfig::openai(),
|
||||
"https://not-localhost.example.com/v1",
|
||||
),
|
||||
Cow::Borrowed("openai/gpt-4.1-mini")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_routing_prefix_strips_only_escape_hatch() {
|
||||
assert_eq!(
|
||||
super::strip_routing_prefix("local/Qwen/Qwen3.6-27B-FP8"),
|
||||
"Qwen/Qwen3.6-27B-FP8"
|
||||
);
|
||||
assert_eq!(
|
||||
super::wire_model_for_base_url(
|
||||
"local/Qwen/Qwen3.6-27B-FP8",
|
||||
OpenAiCompatConfig::openai(),
|
||||
"http://127.0.0.1:8000/v1",
|
||||
),
|
||||
Cow::Borrowed("Qwen/Qwen3.6-27B-FP8")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_request_body_size_allows_large_requests_for_openai() {
|
||||
// Create a request that exceeds DashScope's limit but is under OpenAI's 100MB limit
|
||||
|
||||
@@ -82,7 +82,7 @@ async fn send_message_posts_json_and_parses_response() {
|
||||
);
|
||||
assert_eq!(
|
||||
request.headers.get("user-agent").map(String::as_str),
|
||||
Some("claude-code/0.1.0")
|
||||
Some("claude-code/0.1.3")
|
||||
);
|
||||
assert_eq!(
|
||||
request.headers.get("anthropic-beta").map(String::as_str),
|
||||
@@ -103,6 +103,58 @@ async fn send_message_posts_json_and_parses_response() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_message_strips_anthropic_routing_prefix_on_wire() {
|
||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
let server = spawn_server(
|
||||
state.clone(),
|
||||
vec![
|
||||
http_response("200 OK", "application/json", "{\"input_tokens\":1}"),
|
||||
http_response(
|
||||
"200 OK",
|
||||
"application/json",
|
||||
concat!(
|
||||
"{",
|
||||
"\"id\":\"msg_prefixed\",",
|
||||
"\"type\":\"message\",",
|
||||
"\"role\":\"assistant\",",
|
||||
"\"content\":[{\"type\":\"text\",\"text\":\"ok\"}],",
|
||||
"\"model\":\"claude-opus-4-6\",",
|
||||
"\"stop_reason\":\"end_turn\",",
|
||||
"\"stop_sequence\":null,",
|
||||
"\"usage\":{\"input_tokens\":1,\"output_tokens\":1}",
|
||||
"}"
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
|
||||
client
|
||||
.send_message(&MessageRequest {
|
||||
model: "anthropic/claude-opus-4-6".to_string(),
|
||||
..sample_request(false)
|
||||
})
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
let captured = state.lock().await;
|
||||
assert_eq!(
|
||||
captured.len(),
|
||||
2,
|
||||
"count_tokens and messages requests should be captured"
|
||||
);
|
||||
let count_tokens_body: serde_json::Value =
|
||||
serde_json::from_str(&captured[0].body).expect("count_tokens body should be json");
|
||||
let messages_body: serde_json::Value =
|
||||
serde_json::from_str(&captured[1].body).expect("request body should be json");
|
||||
assert_eq!(captured[0].path, "/v1/messages/count_tokens");
|
||||
assert_eq!(captured[1].path, "/v1/messages");
|
||||
assert_eq!(count_tokens_body["model"], json!("claude-opus-4-6"));
|
||||
assert_eq!(messages_body["model"], json!("claude-opus-4-6"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_message_blocks_oversized_requests_before_the_http_call() {
|
||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
|
||||
@@ -159,10 +159,15 @@ async fn send_message_preserves_deepseek_reasoning_content_before_text() {
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
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["thinking"], json!({"type": "enabled"}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_openai_gateway_preserves_slash_model_ids_and_extra_body_params() {
|
||||
async fn local_openai_gateway_strips_routing_prefix_and_preserves_extra_body_params() {
|
||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
let body = concat!(
|
||||
"{",
|
||||
@@ -206,7 +211,7 @@ async fn custom_openai_gateway_preserves_slash_model_ids_and_extra_body_params()
|
||||
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!("openai/gpt-4.1-mini"));
|
||||
assert_eq!(body["model"], json!("gpt-4.1-mini"));
|
||||
assert_eq!(
|
||||
body["web_search_options"],
|
||||
json!({"search_context_size": "low"})
|
||||
|
||||
33
rust/crates/claw-analog/Cargo.toml
Normal file
33
rust/crates/claw-analog/Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "claw-analog"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Minimal agent harness: tool loop with explicit permissions and workspace jail."
|
||||
|
||||
[lib]
|
||||
name = "claw_analog"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "claw-analog"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
api = { path = "../api" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
clap_complete = "4"
|
||||
globset = "0.4"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
runtime = { path = "../runtime" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
toml = "0.8"
|
||||
walkdir = "2"
|
||||
ignore = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
mock-anthropic-service = { path = "../mock-anthropic-service" }
|
||||
tempfile = "3"
|
||||
489
rust/crates/claw-analog/src/agents.rs
Normal file
489
rust/crates/claw-analog/src/agents.rs
Normal file
@@ -0,0 +1,489 @@
|
||||
//! `claw-analog agents` — run multiple specialized sub-agents sequentially.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use api::InputMessage;
|
||||
use clap::{Parser, ValueEnum};
|
||||
use claw_analog::{
|
||||
enforce_non_interactive_permission_rules, load_analog_toml, resolve_analog_options,
|
||||
resolve_analog_profile_path, resolve_rag_base_url, AnalogConfig, AnalogDoctorOverrides,
|
||||
AnalogFileConfig, OutputFormat, PermissionMode, Preset, StreamOverride,
|
||||
};
|
||||
|
||||
const DEF_MAX_READ: u64 = 256 * 1024;
|
||||
const DEF_MAX_TURNS: u32 = 24;
|
||||
const DEF_MAX_LIST: usize = 500;
|
||||
const DEF_GREP_MAX: usize = 200;
|
||||
const DEF_GLOB_PATHS: usize = 2000;
|
||||
const DEF_GLOB_DEPTH: usize = 32;
|
||||
const DEF_RAG_TIMEOUT_SECS: u64 = 30;
|
||||
const DEF_RAG_TOP_K_MAX: u32 = 32;
|
||||
const RAG_TOP_K_ABS_CAP: u32 = 256;
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
pub enum AgentsPresetArg {
|
||||
Audit,
|
||||
Explain,
|
||||
Implement,
|
||||
}
|
||||
|
||||
impl From<AgentsPresetArg> for Preset {
|
||||
fn from(p: AgentsPresetArg) -> Self {
|
||||
match p {
|
||||
AgentsPresetArg::Audit => Preset::Audit,
|
||||
AgentsPresetArg::Explain => Preset::Explain,
|
||||
AgentsPresetArg::Implement => Preset::Implement,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
pub enum AgentsPermissionArg {
|
||||
ReadOnly,
|
||||
WorkspaceWrite,
|
||||
Prompt,
|
||||
#[value(name = "danger-full-access")]
|
||||
DangerFullAccess,
|
||||
Allow,
|
||||
}
|
||||
|
||||
impl From<AgentsPermissionArg> for PermissionMode {
|
||||
fn from(p: AgentsPermissionArg) -> Self {
|
||||
match p {
|
||||
AgentsPermissionArg::ReadOnly => PermissionMode::ReadOnly,
|
||||
AgentsPermissionArg::WorkspaceWrite => PermissionMode::WorkspaceWrite,
|
||||
AgentsPermissionArg::Prompt => PermissionMode::Prompt,
|
||||
AgentsPermissionArg::DangerFullAccess => PermissionMode::DangerFullAccess,
|
||||
AgentsPermissionArg::Allow => PermissionMode::Allow,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentSpec {
|
||||
pub name: String,
|
||||
pub preset: Preset,
|
||||
pub permission: PermissionMode,
|
||||
pub model: Option<String>,
|
||||
pub prompt: Option<String>,
|
||||
}
|
||||
|
||||
fn default_permission_for_preset(p: Preset) -> PermissionMode {
|
||||
match p {
|
||||
Preset::Audit | Preset::Explain => PermissionMode::ReadOnly,
|
||||
Preset::Implement => PermissionMode::WorkspaceWrite,
|
||||
Preset::None => PermissionMode::ReadOnly,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_agent_spec(s: &str) -> Result<AgentSpec, String> {
|
||||
// Allowed forms:
|
||||
// - "audit" | "explain" | "implement"
|
||||
// - "name=audit,preset=audit,permission=read-only,model=...,prompt=..."
|
||||
let raw = s.trim();
|
||||
if raw.is_empty() {
|
||||
return Err("empty --agent spec".to_string());
|
||||
}
|
||||
|
||||
if !raw.contains('=') {
|
||||
let preset = match raw.to_ascii_lowercase().as_str() {
|
||||
"audit" => Preset::Audit,
|
||||
"explain" => Preset::Explain,
|
||||
"implement" | "fix" => Preset::Implement,
|
||||
other => return Err(format!("unknown agent shorthand: {other}")),
|
||||
};
|
||||
return Ok(AgentSpec {
|
||||
name: raw.to_string(),
|
||||
preset,
|
||||
permission: default_permission_for_preset(preset),
|
||||
model: None,
|
||||
prompt: None,
|
||||
});
|
||||
}
|
||||
|
||||
let mut name: Option<String> = None;
|
||||
let mut preset: Option<Preset> = None;
|
||||
let mut permission: Option<PermissionMode> = None;
|
||||
let mut model: Option<String> = None;
|
||||
let mut prompt: Option<String> = None;
|
||||
|
||||
for part in raw.split(',') {
|
||||
let (k, v) = part
|
||||
.split_once('=')
|
||||
.ok_or_else(|| format!("invalid agent spec part {part:?} (expected k=v)"))?;
|
||||
let k = k.trim().to_ascii_lowercase();
|
||||
let v = v.trim();
|
||||
if v.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match k.as_str() {
|
||||
"name" => name = Some(v.to_string()),
|
||||
"preset" => {
|
||||
let p = match v.to_ascii_lowercase().as_str() {
|
||||
"audit" => Preset::Audit,
|
||||
"explain" => Preset::Explain,
|
||||
"implement" | "fix" => Preset::Implement,
|
||||
"none" => Preset::None,
|
||||
other => return Err(format!("unknown preset {other:?}")),
|
||||
};
|
||||
preset = Some(p);
|
||||
}
|
||||
"permission" => {
|
||||
let pm = match v.to_ascii_lowercase().replace('_', "-").as_str() {
|
||||
"read-only" | "readonly" => PermissionMode::ReadOnly,
|
||||
"workspace-write" | "write" => PermissionMode::WorkspaceWrite,
|
||||
"prompt" => PermissionMode::Prompt,
|
||||
"danger-full-access" | "danger" => PermissionMode::DangerFullAccess,
|
||||
"allow" => PermissionMode::Allow,
|
||||
other => return Err(format!("unknown permission {other:?}")),
|
||||
};
|
||||
permission = Some(pm);
|
||||
}
|
||||
"model" => model = Some(v.to_string()),
|
||||
"prompt" => prompt = Some(v.to_string()),
|
||||
other => return Err(format!("unknown agent spec key {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
let preset = preset.unwrap_or(Preset::Audit);
|
||||
let permission = permission.unwrap_or_else(|| default_permission_for_preset(preset));
|
||||
let name = name.unwrap_or_else(|| preset.label().unwrap_or("agent").to_string());
|
||||
|
||||
Ok(AgentSpec {
|
||||
name,
|
||||
preset,
|
||||
permission,
|
||||
model,
|
||||
prompt,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct AgentsCli {
|
||||
/// Workspace root.
|
||||
#[arg(short = 'w', long, default_value = ".", value_name = "DIR")]
|
||||
pub workspace: PathBuf,
|
||||
|
||||
/// Config path (default: `<workspace>/.claw-analog.toml`).
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub config: Option<PathBuf>,
|
||||
|
||||
/// Base session path. If missing, it will be created from the base prompt.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub base_session: PathBuf,
|
||||
|
||||
/// Base prompt. If omitted, reads from stdin.
|
||||
#[arg(long)]
|
||||
pub prompt: Option<String>,
|
||||
|
||||
/// Repeatable agent specs, e.g. `--agent audit` or `--agent name=fix,preset=implement,permission=workspace-write`.
|
||||
#[arg(long, required = true)]
|
||||
pub agent: Vec<String>,
|
||||
|
||||
/// If set, each agent writes its own session file next to base session.
|
||||
#[arg(long, default_value_t = true)]
|
||||
pub split_sessions: bool,
|
||||
}
|
||||
|
||||
fn load_file_config(path: &Path) -> AnalogFileConfig {
|
||||
if !path.is_file() {
|
||||
return AnalogFileConfig::default();
|
||||
}
|
||||
load_analog_toml(path).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn config_path(args: &AgentsCli) -> PathBuf {
|
||||
args.config
|
||||
.clone()
|
||||
.unwrap_or_else(|| args.workspace.join(".claw-analog.toml"))
|
||||
}
|
||||
|
||||
fn derive_agent_session_path(base: &Path, agent_name: &str) -> PathBuf {
|
||||
let base_s = base.to_string_lossy();
|
||||
PathBuf::from(format!("{base_s}.agent-{agent_name}.json"))
|
||||
}
|
||||
|
||||
fn read_stdin_prompt() -> Result<String, String> {
|
||||
use std::io::Read;
|
||||
let mut buf = String::new();
|
||||
std::io::stdin()
|
||||
.read_to_string(&mut buf)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let t = buf.trim();
|
||||
if t.is_empty() {
|
||||
return Err("empty prompt (pass --prompt or stdin)".to_string());
|
||||
}
|
||||
Ok(t.to_string())
|
||||
}
|
||||
|
||||
fn ensure_base_session(base_session: &Path, workspace: &Path, prompt: &str) -> Result<(), String> {
|
||||
if base_session.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let ws_s = workspace.display().to_string();
|
||||
let model = "base".to_string();
|
||||
let messages = if prompt.trim().is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![InputMessage::user_text(prompt.to_string())]
|
||||
};
|
||||
claw_analog::session_save(base_session, &ws_s, &model, Preset::None, &messages)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_agents(args: AgentsCli) -> Result<(), String> {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
rt.block_on(async { run_agents_async(args).await })
|
||||
}
|
||||
|
||||
pub async fn run_agents_async(args: AgentsCli) -> Result<(), String> {
|
||||
run_agents_inner(args, |cfg, out| {
|
||||
Box::pin(async move {
|
||||
claw_analog::run(cfg, out)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
type RunFuture<'a> = std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>> + 'a>>;
|
||||
|
||||
async fn run_agents_inner<F>(args: AgentsCli, mut run_one: F) -> Result<(), String>
|
||||
where
|
||||
for<'a> F: FnMut(AnalogConfig, &'a mut Vec<u8>) -> RunFuture<'a>,
|
||||
{
|
||||
let workspace = if args.workspace.is_absolute() {
|
||||
args.workspace.clone()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join(&args.workspace)
|
||||
};
|
||||
let cfg_path = config_path(&args);
|
||||
let file_cfg = load_file_config(&cfg_path);
|
||||
|
||||
let base_prompt = match args.prompt.clone() {
|
||||
Some(p) => p,
|
||||
None => read_stdin_prompt()?,
|
||||
};
|
||||
ensure_base_session(&args.base_session, &workspace, base_prompt.as_str())?;
|
||||
|
||||
let mut specs = Vec::new();
|
||||
for a in &args.agent {
|
||||
specs.push(parse_agent_spec(a)?);
|
||||
}
|
||||
|
||||
println!("claw-analog agents (sequential)\n");
|
||||
println!(" workspace: {}", workspace.display());
|
||||
println!(" base_session: {}", args.base_session.display());
|
||||
println!(" agents: {}", specs.len());
|
||||
println!();
|
||||
|
||||
for (i, spec) in specs.into_iter().enumerate() {
|
||||
println!(
|
||||
"== Agent {} / {}: {} ==",
|
||||
i + 1,
|
||||
args.agent.len(),
|
||||
spec.name
|
||||
);
|
||||
println!(" preset: {}", spec.preset.label().unwrap_or("none"));
|
||||
println!(" permission: {}", spec.permission.as_str());
|
||||
if let Some(m) = &spec.model {
|
||||
println!(" model: {m}");
|
||||
}
|
||||
|
||||
enforce_non_interactive_permission_rules(spec.permission, false)?;
|
||||
|
||||
let agent_session = if args.split_sessions {
|
||||
derive_agent_session_path(&args.base_session, spec.name.as_str())
|
||||
} else {
|
||||
args.base_session.clone()
|
||||
};
|
||||
if args.split_sessions {
|
||||
std::fs::copy(&args.base_session, &agent_session).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let overrides = AnalogDoctorOverrides {
|
||||
model: spec.model.clone(),
|
||||
permission: Some(spec.permission),
|
||||
preset: Some(spec.preset),
|
||||
output_format: Some(OutputFormat::Rich),
|
||||
stream: StreamOverride::ForceOff,
|
||||
..Default::default()
|
||||
};
|
||||
let resolved = resolve_analog_options(&file_cfg, &overrides);
|
||||
|
||||
let profile_path =
|
||||
resolve_analog_profile_path(&workspace, None, file_cfg.profile.as_deref());
|
||||
let profile_hint = if let Some(ref p) = profile_path {
|
||||
claw_analog::load_profile_hint(p).unwrap_or(None)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let rag_base_url = resolve_rag_base_url(&file_cfg);
|
||||
|
||||
let agent_prompt = spec.prompt.unwrap_or_else(|| {
|
||||
format!(
|
||||
"Agent {}: run preset {}",
|
||||
spec.name,
|
||||
resolved.preset.label().unwrap_or("none")
|
||||
)
|
||||
});
|
||||
|
||||
let cfg = AnalogConfig {
|
||||
model: resolved.model,
|
||||
workspace: workspace.clone(),
|
||||
permission_mode: resolved.permission_mode,
|
||||
accept_danger_non_interactive: false,
|
||||
use_stream: false,
|
||||
output_format: resolved.output_format,
|
||||
use_runtime_enforcer: resolved.use_runtime_enforcer,
|
||||
max_read_bytes: file_cfg.max_read_bytes.unwrap_or(DEF_MAX_READ),
|
||||
max_turns: file_cfg.max_turns.unwrap_or(DEF_MAX_TURNS),
|
||||
max_list_entries: file_cfg.max_list_entries.unwrap_or(DEF_MAX_LIST),
|
||||
grep_max_lines: file_cfg.grep_max_lines.unwrap_or(DEF_GREP_MAX),
|
||||
glob_max_paths: file_cfg.glob_max_paths.unwrap_or(DEF_GLOB_PATHS),
|
||||
glob_max_depth: file_cfg.glob_max_depth.unwrap_or(DEF_GLOB_DEPTH),
|
||||
preset: resolved.preset,
|
||||
language: file_cfg
|
||||
.language
|
||||
.as_deref()
|
||||
.and_then(claw_analog::AnalogLanguage::from_toml_str)
|
||||
.unwrap_or_default(),
|
||||
session_path: Some(agent_session.clone()),
|
||||
session_save_path: None,
|
||||
profile_hint,
|
||||
prompt: agent_prompt,
|
||||
rag_base_url,
|
||||
rag_http_timeout: std::time::Duration::from_secs(
|
||||
file_cfg.rag_timeout_secs.unwrap_or(DEF_RAG_TIMEOUT_SECS),
|
||||
),
|
||||
rag_top_k_max: file_cfg
|
||||
.rag_top_k_max
|
||||
.unwrap_or(DEF_RAG_TOP_K_MAX)
|
||||
.clamp(1, RAG_TOP_K_ABS_CAP),
|
||||
};
|
||||
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let run_res = run_one(cfg, &mut buf).await;
|
||||
match run_res {
|
||||
Ok(()) => {
|
||||
let text = String::from_utf8_lossy(&buf);
|
||||
let summary = tail_chars(text.as_ref(), 1600);
|
||||
println!(" result: OK");
|
||||
if args.split_sessions {
|
||||
println!(" session: {}", agent_session.display());
|
||||
}
|
||||
println!(" summary_tail:\n{}\n", indent_lines(&summary, 4));
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" result: FAIL — {e}\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tail_chars(s: &str, n: usize) -> String {
|
||||
let total = s.chars().count();
|
||||
if total <= n {
|
||||
return s.to_string();
|
||||
}
|
||||
s.chars().skip(total - n).collect()
|
||||
}
|
||||
|
||||
fn indent_lines(s: &str, spaces: usize) -> String {
|
||||
let pad = " ".repeat(spaces);
|
||||
s.lines()
|
||||
.map(|l| format!("{pad}{l}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
fn mock_env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_agent_shorthand() {
|
||||
let a = parse_agent_spec("audit").unwrap();
|
||||
assert_eq!(a.preset, Preset::Audit);
|
||||
assert_eq!(a.permission, PermissionMode::ReadOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_agent_kv() {
|
||||
let a = parse_agent_spec("name=fix,preset=implement,permission=workspace-write").unwrap();
|
||||
assert_eq!(a.name, "fix");
|
||||
assert_eq!(a.preset, Preset::Implement);
|
||||
assert_eq!(a.permission, PermissionMode::WorkspaceWrite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_two_agents_sequentially_with_stub_runner() {
|
||||
let _g = mock_env_lock();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().canonicalize().unwrap();
|
||||
std::fs::write(workspace.join("fixture.txt"), "hello parity fixture\n").unwrap();
|
||||
|
||||
let base_session = workspace.join(".claw").join("agents-base.json");
|
||||
std::fs::create_dir_all(base_session.parent().unwrap()).unwrap();
|
||||
std::fs::write(
|
||||
&base_session,
|
||||
format!(
|
||||
"{{\n \"version\": 1,\n \"workspace\": \"{}\",\n \"model\": \"base\",\n \"messages\": []\n}}\n",
|
||||
workspace.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let args = AgentsCli {
|
||||
workspace: workspace.clone(),
|
||||
config: None,
|
||||
base_session: base_session.clone(),
|
||||
prompt: Some(String::new()),
|
||||
agent: vec![
|
||||
"name=audit,preset=audit,permission=read-only,prompt=check 1".to_string(),
|
||||
"name=explain,preset=explain,permission=read-only,prompt=check 2".to_string(),
|
||||
],
|
||||
split_sessions: true,
|
||||
};
|
||||
let called = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let called2 = called.clone();
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(1)
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("runtime");
|
||||
rt.block_on(async {
|
||||
run_agents_inner(args, move |_cfg, out| {
|
||||
let called3 = called2.clone();
|
||||
Box::pin(async move {
|
||||
called3.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
out.extend_from_slice(b"stub ok");
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.await
|
||||
.expect("agents should run");
|
||||
});
|
||||
assert_eq!(called.load(std::sync::atomic::Ordering::Relaxed), 2);
|
||||
|
||||
assert!(derive_agent_session_path(&base_session, "audit").is_file());
|
||||
assert!(derive_agent_session_path(&base_session, "explain").is_file());
|
||||
}
|
||||
}
|
||||
144
rust/crates/claw-analog/src/config_cmd.rs
Normal file
144
rust/crates/claw-analog/src/config_cmd.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
//! `claw-analog config validate` — parse TOML and profile without calling the API.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
use claw_analog::{
|
||||
load_analog_toml, load_profile_hint, resolve_analog_options, resolve_analog_profile_path,
|
||||
AnalogDoctorOverrides, AnalogFileConfig, AnalogLanguage, OutputFormat,
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
pub struct ValidateCli {
|
||||
#[arg(short = 'w', long, default_value = ".", value_name = "DIR")]
|
||||
pub workspace: PathBuf,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub config: Option<PathBuf>,
|
||||
/// Require `<workspace>/.claw-analog.toml` (or `--config`) to exist and parse.
|
||||
#[arg(long, default_value_t = false, action = clap::ArgAction::SetTrue)]
|
||||
pub strict: bool,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub profile: Option<PathBuf>,
|
||||
}
|
||||
|
||||
pub fn run_validate(cli: ValidateCli) -> i32 {
|
||||
let cfg_path = cli
|
||||
.config
|
||||
.clone()
|
||||
.unwrap_or_else(|| cli.workspace.join(".claw-analog.toml"));
|
||||
|
||||
let file_cfg = if cfg_path.is_file() {
|
||||
match load_analog_toml(&cfg_path) {
|
||||
Ok(c) => {
|
||||
println!("OK: {} parses", cfg_path.display());
|
||||
c
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("ERROR: {}: {e}", cfg_path.display());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
} else if cli.strict {
|
||||
eprintln!(
|
||||
"ERROR: --strict: config file missing: {}",
|
||||
cfg_path.display()
|
||||
);
|
||||
return 1;
|
||||
} else {
|
||||
println!(
|
||||
"Note: {} absent — using empty TOML defaults for preview",
|
||||
cfg_path.display()
|
||||
);
|
||||
AnalogFileConfig::default()
|
||||
};
|
||||
|
||||
let prof_path = resolve_analog_profile_path(
|
||||
&cli.workspace,
|
||||
cli.profile.clone(),
|
||||
file_cfg.profile.as_deref(),
|
||||
);
|
||||
let mut ok = true;
|
||||
match &prof_path {
|
||||
None => println!(
|
||||
"Profile: (none — no CLI/TOML path and no default ~/.claw-analog/profile.toml)"
|
||||
),
|
||||
Some(p) => match load_profile_hint(p) {
|
||||
Ok(Some(line)) => println!(
|
||||
"OK: profile {} (line: {} chars)",
|
||||
p.display(),
|
||||
line.chars().count()
|
||||
),
|
||||
Ok(None) => println!("OK: profile {} (empty `line`)", p.display()),
|
||||
Err(e) => {
|
||||
eprintln!("ERROR: profile {}: {e}", p.display());
|
||||
ok = false;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
let lang = file_cfg
|
||||
.language
|
||||
.as_deref()
|
||||
.and_then(AnalogLanguage::from_toml_str)
|
||||
.unwrap_or_default();
|
||||
|
||||
let r = resolve_analog_options(&file_cfg, &AnalogDoctorOverrides::default());
|
||||
println!("\nMerge preview (TOML + defaults only; main-run CLI flags not applied):");
|
||||
println!(" language (TOML): {}", lang.as_str());
|
||||
println!(" model: {}", r.model);
|
||||
println!(" permission: {}", r.permission_mode.as_str());
|
||||
println!(" preset: {}", r.preset.label().unwrap_or("none"));
|
||||
println!(
|
||||
" output_format: {}",
|
||||
match r.output_format {
|
||||
OutputFormat::Rich => "rich",
|
||||
OutputFormat::Json => "json",
|
||||
}
|
||||
);
|
||||
println!(" stream: {}", r.use_stream);
|
||||
println!(
|
||||
" runtime_enforcer: {}",
|
||||
if r.use_runtime_enforcer { "on" } else { "off" }
|
||||
);
|
||||
println!(
|
||||
" accept_danger_non_interactive: {}",
|
||||
r.accept_danger_non_interactive
|
||||
);
|
||||
println!(" Provenance:");
|
||||
for line in &r.provenance {
|
||||
println!(" - {line}");
|
||||
}
|
||||
|
||||
i32::from(!ok)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strict_fails_when_config_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let code = run_validate(ValidateCli {
|
||||
workspace: dir.path().to_path_buf(),
|
||||
config: None,
|
||||
strict: true,
|
||||
profile: None,
|
||||
});
|
||||
assert_eq!(code, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_when_config_present() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join(".claw-analog.toml");
|
||||
std::fs::write(&p, r#"model = "sonnet""#).unwrap();
|
||||
let code = run_validate(ValidateCli {
|
||||
workspace: dir.path().to_path_buf(),
|
||||
config: None,
|
||||
strict: true,
|
||||
profile: None,
|
||||
});
|
||||
assert_eq!(code, 0);
|
||||
}
|
||||
}
|
||||
733
rust/crates/claw-analog/src/doctor.rs
Normal file
733
rust/crates/claw-analog/src/doctor.rs
Normal file
@@ -0,0 +1,733 @@
|
||||
//! `claw-analog doctor` — environment and Cargo sanity checks.
|
||||
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::ValueEnum;
|
||||
use claw_analog::{
|
||||
load_analog_toml, load_profile_hint, resolve_analog_options, AnalogDoctorOverrides,
|
||||
AnalogFileConfig, OutputFormat, PermissionMode, Preset, StreamOverride, NDJSON_FORMAT_VERSION,
|
||||
NDJSON_SCHEMA,
|
||||
};
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
|
||||
const ENV_CHECK: &[&str] = &[
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"OPENAI_API_KEY",
|
||||
"OPENAI_BASE_URL",
|
||||
"XAI_API_KEY",
|
||||
"RAG_BASE_URL",
|
||||
];
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
pub enum DoctorPermissionArg {
|
||||
ReadOnly,
|
||||
WorkspaceWrite,
|
||||
Prompt,
|
||||
#[value(name = "danger-full-access")]
|
||||
DangerFullAccess,
|
||||
Allow,
|
||||
}
|
||||
|
||||
impl From<DoctorPermissionArg> for PermissionMode {
|
||||
fn from(p: DoctorPermissionArg) -> Self {
|
||||
match p {
|
||||
DoctorPermissionArg::ReadOnly => PermissionMode::ReadOnly,
|
||||
DoctorPermissionArg::WorkspaceWrite => PermissionMode::WorkspaceWrite,
|
||||
DoctorPermissionArg::Prompt => PermissionMode::Prompt,
|
||||
DoctorPermissionArg::DangerFullAccess => PermissionMode::DangerFullAccess,
|
||||
DoctorPermissionArg::Allow => PermissionMode::Allow,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
pub enum DoctorOutputArg {
|
||||
Rich,
|
||||
Json,
|
||||
}
|
||||
|
||||
impl From<DoctorOutputArg> for OutputFormat {
|
||||
fn from(o: DoctorOutputArg) -> Self {
|
||||
match o {
|
||||
DoctorOutputArg::Rich => OutputFormat::Rich,
|
||||
DoctorOutputArg::Json => OutputFormat::Json,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
pub enum DoctorPresetCli {
|
||||
None,
|
||||
Audit,
|
||||
Explain,
|
||||
Implement,
|
||||
}
|
||||
|
||||
impl From<DoctorPresetCli> for Preset {
|
||||
fn from(p: DoctorPresetCli) -> Self {
|
||||
match p {
|
||||
DoctorPresetCli::None => Preset::None,
|
||||
DoctorPresetCli::Audit => Preset::Audit,
|
||||
DoctorPresetCli::Explain => Preset::Explain,
|
||||
DoctorPresetCli::Implement => Preset::Implement,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct DoctorCli {
|
||||
/// Workspace root (same as `claw-analog -w`; config defaults to `<workspace>/.claw-analog.toml`).
|
||||
#[arg(short = 'w', long, default_value = ".", value_name = "DIR")]
|
||||
pub workspace: PathBuf,
|
||||
/// Config path (default: `<workspace>/.claw-analog.toml`).
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub config: Option<PathBuf>,
|
||||
/// Override model (same precedence as main CLI).
|
||||
#[arg(long)]
|
||||
pub model: Option<String>,
|
||||
#[arg(long, value_enum)]
|
||||
pub permission: Option<DoctorPermissionArg>,
|
||||
#[arg(long, value_enum)]
|
||||
pub preset: Option<DoctorPresetCli>,
|
||||
#[arg(long, value_enum)]
|
||||
pub output_format: Option<DoctorOutputArg>,
|
||||
#[arg(long, default_value_t = false, conflicts_with = "no_stream")]
|
||||
pub stream: bool,
|
||||
#[arg(long, default_value_t = false, conflicts_with = "stream")]
|
||||
pub no_stream: bool,
|
||||
/// Disable `runtime::PermissionEnforcer` (same as main CLI).
|
||||
#[arg(
|
||||
long = "no-runtime-enforcer",
|
||||
default_value_t = false,
|
||||
action = clap::ArgAction::SetTrue
|
||||
)]
|
||||
pub no_runtime_enforcer: bool,
|
||||
#[arg(
|
||||
long = "accept-danger-non-interactive",
|
||||
default_value_t = false,
|
||||
action = clap::ArgAction::SetTrue
|
||||
)]
|
||||
pub accept_danger_non_interactive: bool,
|
||||
/// Profile TOML path (optional; if omitted, uses TOML `profile` or default `~/.claw-analog/profile.toml`).
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub profile: Option<PathBuf>,
|
||||
/// TCP connect to host:port from `ANTHROPIC_BASE_URL` (or default API URL); not a full HTTP check.
|
||||
#[arg(long, visible_alias = "mock")]
|
||||
pub tcp_ping: bool,
|
||||
/// Skip HTTPS/TLS + auth + quota header checks against configured providers.
|
||||
#[arg(long, default_value_t = false)]
|
||||
pub no_http_check: bool,
|
||||
/// Also probe the embeddings endpoint for OpenAI-compatible providers (may incur minimal cost).
|
||||
#[arg(long, default_value_t = false)]
|
||||
pub embeddings_check: bool,
|
||||
/// Skip compile check (`cargo check` / `build --release`).
|
||||
#[arg(long)]
|
||||
pub no_build: bool,
|
||||
/// Run `cargo build --release -p claw-analog` (writes `target/release/…`, safe while `cargo run` holds `target/debug/…` on Windows).
|
||||
#[arg(long, conflicts_with = "no_build")]
|
||||
pub release_build: bool,
|
||||
/// Directory containing the repo workspace `Cargo.toml` (default: search upward from cwd).
|
||||
#[arg(long, value_name = "DIR")]
|
||||
pub manifest_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
pub fn run_doctor(args: DoctorCli) -> i32 {
|
||||
println!("claw-analog doctor — environment and build checks\n");
|
||||
|
||||
let workspace = args.workspace.clone();
|
||||
let canon_ws = std::fs::canonicalize(&workspace).unwrap_or_else(|_| workspace.clone());
|
||||
let cfg_path = args
|
||||
.config
|
||||
.clone()
|
||||
.unwrap_or_else(|| workspace.join(".claw-analog.toml"));
|
||||
let (file_cfg, cfg_note) = if cfg_path.is_file() {
|
||||
match load_analog_toml(&cfg_path) {
|
||||
Ok(c) => (c, "loaded"),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[claw-analog] doctor: failed to parse {}: {e} (using empty TOML defaults)",
|
||||
cfg_path.display()
|
||||
);
|
||||
(AnalogFileConfig::default(), "parse error (defaults)")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(AnalogFileConfig::default(), "file missing (defaults only)")
|
||||
};
|
||||
|
||||
let stream_ov = if args.no_stream {
|
||||
StreamOverride::ForceOff
|
||||
} else if args.stream {
|
||||
StreamOverride::ForceOn
|
||||
} else {
|
||||
StreamOverride::FromFile
|
||||
};
|
||||
let overrides = AnalogDoctorOverrides {
|
||||
model: args.model.clone(),
|
||||
permission: args.permission.map(Into::into),
|
||||
preset: args.preset.map(Into::into),
|
||||
output_format: args.output_format.map(Into::into),
|
||||
stream: stream_ov,
|
||||
no_runtime_enforcer: args.no_runtime_enforcer,
|
||||
accept_danger_non_interactive: args.accept_danger_non_interactive,
|
||||
};
|
||||
let resolved = resolve_analog_options(&file_cfg, &overrides);
|
||||
|
||||
println!("NDJSON contract (for `--output-format json` runs):");
|
||||
println!(" schema: {NDJSON_SCHEMA}");
|
||||
println!(" format_version: {NDJSON_FORMAT_VERSION}\n");
|
||||
|
||||
println!("Effective config (merge of `.claw-analog.toml` + flags below):");
|
||||
println!(" workspace: {}", canon_ws.display());
|
||||
println!(" config: {} ({cfg_note})", cfg_path.display());
|
||||
println!(" model: {}", resolved.model);
|
||||
println!(" permission: {}", resolved.permission_mode.as_str());
|
||||
println!(" preset: {}", resolved.preset.label().unwrap_or("none"));
|
||||
println!(
|
||||
" output_format: {}",
|
||||
match resolved.output_format {
|
||||
OutputFormat::Rich => "rich",
|
||||
OutputFormat::Json => "json",
|
||||
}
|
||||
);
|
||||
println!(" stream: {}", resolved.use_stream);
|
||||
println!(
|
||||
" runtime_enforcer: {}",
|
||||
if resolved.use_runtime_enforcer {
|
||||
"on"
|
||||
} else {
|
||||
"off"
|
||||
}
|
||||
);
|
||||
println!(
|
||||
" accept_danger_non_interactive: {}",
|
||||
resolved.accept_danger_non_interactive
|
||||
);
|
||||
println!(" Provenance (which side won src ← …):");
|
||||
for line in &resolved.provenance {
|
||||
println!(" - {line}");
|
||||
}
|
||||
println!();
|
||||
|
||||
let prof = resolve_profile_path_doctor(
|
||||
args.profile.as_ref(),
|
||||
file_cfg.profile.as_deref(),
|
||||
&workspace,
|
||||
);
|
||||
print_profile_hint_section(&prof);
|
||||
println!();
|
||||
|
||||
check_env();
|
||||
println!();
|
||||
let build_ok = if args.no_build {
|
||||
println!("cargo: skipped (--no-build)");
|
||||
true
|
||||
} else if args.release_build {
|
||||
run_cargo_release_build(args.manifest_dir.as_deref())
|
||||
} else {
|
||||
run_cargo_check(args.manifest_dir.as_deref())
|
||||
};
|
||||
println!();
|
||||
if args.tcp_ping {
|
||||
ping_print();
|
||||
println!();
|
||||
}
|
||||
if !args.no_http_check {
|
||||
http_checks_print(args.embeddings_check);
|
||||
println!();
|
||||
}
|
||||
if build_ok {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn home_dir() -> Option<PathBuf> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::env::var_os("USERPROFILE").map(PathBuf::from)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
std::env::var_os("HOME").map(PathBuf::from)
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_user_path(raw: &str) -> PathBuf {
|
||||
if let Some(rest) = raw.strip_prefix("~/") {
|
||||
home_dir()
|
||||
.map(|h| h.join(rest))
|
||||
.unwrap_or_else(|| PathBuf::from(raw))
|
||||
} else {
|
||||
PathBuf::from(raw)
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_profile_path_doctor(
|
||||
cli: Option<&PathBuf>,
|
||||
file: Option<&str>,
|
||||
workspace: &Path,
|
||||
) -> Option<PathBuf> {
|
||||
if let Some(p) = cli {
|
||||
return Some(if p.is_absolute() {
|
||||
p.clone()
|
||||
} else {
|
||||
workspace.join(p)
|
||||
});
|
||||
}
|
||||
if let Some(s) = file {
|
||||
let p = expand_user_path(s.trim());
|
||||
return Some(if p.is_absolute() {
|
||||
p
|
||||
} else {
|
||||
workspace.join(p)
|
||||
});
|
||||
}
|
||||
let def = home_dir()?.join(".claw-analog").join("profile.toml");
|
||||
if def.is_file() {
|
||||
Some(def)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn print_profile_hint_section(path: &Option<PathBuf>) {
|
||||
println!("Profile (system prompt snippet):");
|
||||
match path {
|
||||
None => println!(" (none — no --profile, no `profile` in TOML, default file absent)"),
|
||||
Some(p) => {
|
||||
print!(" path: {}", p.display());
|
||||
match load_profile_hint(p) {
|
||||
Ok(Some(h)) => println!(" — loaded, {} chars", h.chars().count()),
|
||||
Ok(None) => println!(" — file ok, empty `line`"),
|
||||
Err(e) => println!(" — error: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mask_env_line(name: &str) {
|
||||
match std::env::var(name) {
|
||||
Ok(v) if !v.trim().is_empty() => {
|
||||
println!(" {name}: set ({} chars)", v.chars().count());
|
||||
}
|
||||
Ok(_) => println!(" {name}: set but empty"),
|
||||
Err(_) => println!(" {name}: unset"),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_env() {
|
||||
println!("Environment (values are not printed):");
|
||||
for name in ENV_CHECK {
|
||||
mask_env_line(name);
|
||||
}
|
||||
let anthro_ok = std::env::var("ANTHROPIC_API_KEY")
|
||||
.map(|s| !s.trim().is_empty())
|
||||
.unwrap_or(false)
|
||||
|| std::env::var("ANTHROPIC_AUTH_TOKEN")
|
||||
.map(|s| !s.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
let openai_ok = std::env::var("OPENAI_API_KEY")
|
||||
.map(|s| !s.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
println!();
|
||||
if anthro_ok {
|
||||
println!("Anthropic credentials: OK (API key and/or auth token).");
|
||||
} else {
|
||||
println!("Anthropic credentials: not set — needed for default Claude/Anthropic models.");
|
||||
}
|
||||
if openai_ok {
|
||||
println!("OpenAI API key: set — use `openai/...` model prefix for that provider.");
|
||||
} else {
|
||||
println!("OpenAI API key: unset — only relevant for `openai/` models.");
|
||||
}
|
||||
if !anthro_ok && !openai_ok {
|
||||
println!("\nNote: neither Anthropic nor OpenAI keys are set; live runs will fail until you export credentials (see USAGE.md).");
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk upward from `start` for a `Cargo.toml` that defines `[workspace]`.
|
||||
pub fn discover_cargo_workspace(start: &Path) -> Option<PathBuf> {
|
||||
let mut dir = start.to_path_buf();
|
||||
for _ in 0..32 {
|
||||
let manifest = dir.join("Cargo.toml");
|
||||
if manifest.is_file() {
|
||||
if let Ok(txt) = std::fs::read_to_string(&manifest) {
|
||||
if txt.contains("[workspace]") {
|
||||
return Some(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
dir = dir.parent()?.to_path_buf();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn workspace_root_or_eprint(manifest_dir: Option<&Path>) -> Option<PathBuf> {
|
||||
let start = manifest_dir
|
||||
.map(Path::to_path_buf)
|
||||
.or_else(|| std::env::current_dir().ok())
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
discover_cargo_workspace(&start).or_else(|| {
|
||||
eprintln!(
|
||||
"cargo: could not find a [workspace] Cargo.toml above {}.\n Pass --manifest-dir pointing at the `rust` folder of claw-code.",
|
||||
start.display()
|
||||
);
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
/// `cargo check` does not replace `target/debug/claw-analog.exe`, so `cargo run … doctor` works on Windows.
|
||||
fn run_cargo_check(manifest_dir: Option<&Path>) -> bool {
|
||||
let Some(root) = workspace_root_or_eprint(manifest_dir) else {
|
||||
return false;
|
||||
};
|
||||
println!("cargo check -p claw-analog (workspace {})", root.display());
|
||||
println!(" (compile-only; avoids “access denied” replacing the running debug exe on Windows)");
|
||||
let status = Command::new("cargo")
|
||||
.args(["check", "-p", "claw-analog"])
|
||||
.current_dir(&root)
|
||||
.status();
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
println!("cargo check: OK");
|
||||
true
|
||||
}
|
||||
Ok(s) => {
|
||||
eprintln!("cargo check: failed ({s})");
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("cargo check: could not run `cargo` ({e}). Is Rust/Cargo on PATH?");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_cargo_release_build(manifest_dir: Option<&Path>) -> bool {
|
||||
let Some(root) = workspace_root_or_eprint(manifest_dir) else {
|
||||
return false;
|
||||
};
|
||||
println!(
|
||||
"cargo build --release -p claw-analog (workspace {})",
|
||||
root.display()
|
||||
);
|
||||
println!(" (output in target/release/; does not overwrite a running target/debug/ binary)");
|
||||
let status = Command::new("cargo")
|
||||
.args(["build", "--release", "-p", "claw-analog"])
|
||||
.current_dir(&root)
|
||||
.status();
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
println!("cargo build --release: OK");
|
||||
true
|
||||
}
|
||||
Ok(s) => {
|
||||
eprintln!("cargo build --release: failed ({s})");
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("cargo build --release: could not run `cargo` ({e}). Is Rust/Cargo on PATH?");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_anthropic_base() -> String {
|
||||
std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| "https://api.anthropic.com".into())
|
||||
}
|
||||
|
||||
fn parse_host_port(url: &str) -> Result<(String, u16), String> {
|
||||
let url = url.trim().trim_end_matches('/');
|
||||
let (scheme, rest) = if let Some(r) = url.strip_prefix("https://") {
|
||||
("https", r)
|
||||
} else if let Some(r) = url.strip_prefix("http://") {
|
||||
("http", r)
|
||||
} else {
|
||||
return Err("URL must start with http:// or https://".into());
|
||||
};
|
||||
let host_part = rest
|
||||
.split('/')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| "missing host".to_string())?;
|
||||
if let Some((host, port_s)) = host_part.rsplit_once(':') {
|
||||
if let Ok(p) = port_s.parse::<u16>() {
|
||||
let host = host.trim_start_matches('[').trim_end_matches(']');
|
||||
return Ok((host.to_string(), p));
|
||||
}
|
||||
}
|
||||
let default_port = if scheme == "https" { 443 } else { 80 };
|
||||
Ok((host_part.to_string(), default_port))
|
||||
}
|
||||
|
||||
fn ping_print() {
|
||||
let url = default_anthropic_base();
|
||||
println!("TCP check for ANTHROPIC_BASE_URL (default if unset): {url}");
|
||||
match parse_host_port(&url) {
|
||||
Ok((host, port)) => match tcp_ping(&host, port) {
|
||||
Ok(()) => println!(" reachability: OK ({host}:{port})"),
|
||||
Err(e) => println!(" reachability: FAIL ({host}:{port}) — {e}"),
|
||||
},
|
||||
Err(e) => println!(" could not parse URL: {e}"),
|
||||
}
|
||||
println!(" (HTTP/TLS application data is not validated; this is connect() only.)");
|
||||
}
|
||||
|
||||
fn tcp_ping(host: &str, port: u16) -> Result<(), String> {
|
||||
let addr = (host, port)
|
||||
.to_socket_addrs()
|
||||
.map_err(|e| e.to_string())?
|
||||
.next()
|
||||
.ok_or_else(|| "no resolved addresses".to_string())?;
|
||||
TcpStream::connect_timeout(&addr, Duration::from_secs(3)).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn http_checks_print(embeddings_check: bool) {
|
||||
println!("HTTP/TLS checks (auth + TLS validation + quota headers when available):");
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build();
|
||||
let Ok(rt) = rt else {
|
||||
println!(" runtime: FAIL (could not build tokio runtime)");
|
||||
return;
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
// OpenAI-compatible providers (OPENAI_BASE_URL, OPENAI_API_KEY)
|
||||
if let Ok(key) = std::env::var("OPENAI_API_KEY") {
|
||||
if !key.trim().is_empty() {
|
||||
let base = std::env::var("OPENAI_BASE_URL")
|
||||
.ok()
|
||||
.unwrap_or_else(|| "https://api.openai.com/v1".to_string());
|
||||
let url = openai_models_url(base.as_str());
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Ok(v) = HeaderValue::from_str(format!("Bearer {}", key.trim()).as_str()) {
|
||||
headers.insert(reqwest::header::AUTHORIZATION, v);
|
||||
}
|
||||
let _ = http_check_and_print("openai", url.as_str(), headers).await;
|
||||
|
||||
if embeddings_check {
|
||||
let model = std::env::var("OPENAI_EMBEDDING_MODEL")
|
||||
.ok()
|
||||
.or_else(|| std::env::var("CLAW_RAG_EMBEDDING_MODEL").ok())
|
||||
.unwrap_or_else(|| "text-embedding-3-small".to_string());
|
||||
let eurl = openai_embeddings_url(base.as_str());
|
||||
let mut eheaders = HeaderMap::new();
|
||||
if let Ok(v) = HeaderValue::from_str(format!("Bearer {}", key.trim()).as_str())
|
||||
{
|
||||
eheaders.insert(reqwest::header::AUTHORIZATION, v);
|
||||
}
|
||||
let _ = openai_embeddings_probe(
|
||||
"openai embeddings",
|
||||
eurl.as_str(),
|
||||
&model,
|
||||
eheaders,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
println!(" openai embeddings: skipped (pass --embeddings-check to enable)");
|
||||
}
|
||||
} else {
|
||||
println!(" openai: skipped (OPENAI_API_KEY empty)");
|
||||
}
|
||||
} else {
|
||||
println!(" openai: skipped (OPENAI_API_KEY unset)");
|
||||
}
|
||||
|
||||
// Anthropic (ANTHROPIC_BASE_URL, ANTHROPIC_API_KEY/AUTH_TOKEN)
|
||||
let a_key = std::env::var("ANTHROPIC_API_KEY").ok();
|
||||
let a_tok = std::env::var("ANTHROPIC_AUTH_TOKEN").ok();
|
||||
let a_base = std::env::var("ANTHROPIC_BASE_URL")
|
||||
.ok()
|
||||
.unwrap_or_else(|| "https://api.anthropic.com".to_string());
|
||||
if a_key.as_deref().is_some_and(|s| !s.trim().is_empty())
|
||||
|| a_tok.as_deref().is_some_and(|s| !s.trim().is_empty())
|
||||
{
|
||||
let url = anthropic_models_url(a_base.as_str());
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
HeaderName::from_static("anthropic-version"),
|
||||
HeaderValue::from_static("2023-06-01"),
|
||||
);
|
||||
if let Some(k) = a_key.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||
if let Ok(v) = HeaderValue::from_str(k) {
|
||||
headers.insert(HeaderName::from_static("x-api-key"), v);
|
||||
}
|
||||
} else if let Some(t) = a_tok.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||
if let Ok(v) = HeaderValue::from_str(format!("Bearer {t}").as_str()) {
|
||||
headers.insert(reqwest::header::AUTHORIZATION, v);
|
||||
}
|
||||
}
|
||||
let _ = http_check_and_print("anthropic", url.as_str(), headers).await;
|
||||
} else {
|
||||
println!(" anthropic: skipped (no API key/token)");
|
||||
}
|
||||
|
||||
// RAG service (RAG_BASE_URL) — just basic health + stats.
|
||||
if let Ok(base) = std::env::var("RAG_BASE_URL") {
|
||||
let base = base.trim().trim_end_matches('/');
|
||||
if !base.is_empty() {
|
||||
let headers = HeaderMap::new();
|
||||
let _ =
|
||||
http_check_and_print("rag health", &format!("{base}/health"), headers.clone())
|
||||
.await;
|
||||
let _ =
|
||||
http_check_and_print("rag stats", &format!("{base}/v1/stats"), headers).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
println!(" (TLS validation is performed by the HTTP client; certificate errors surface as request failures.)");
|
||||
}
|
||||
|
||||
fn openai_models_url(base: &str) -> String {
|
||||
let b = base.trim().trim_end_matches('/');
|
||||
if b.ends_with("/v1") {
|
||||
format!("{b}/models")
|
||||
} else {
|
||||
format!("{b}/v1/models")
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_embeddings_url(base: &str) -> String {
|
||||
let b = base.trim().trim_end_matches('/');
|
||||
if b.ends_with("/v1") {
|
||||
format!("{b}/embeddings")
|
||||
} else {
|
||||
format!("{b}/v1/embeddings")
|
||||
}
|
||||
}
|
||||
|
||||
fn anthropic_models_url(base: &str) -> String {
|
||||
let b = base.trim().trim_end_matches('/');
|
||||
format!("{b}/v1/models?limit=1")
|
||||
}
|
||||
|
||||
async fn http_check_and_print(label: &str, url: &str, headers: HeaderMap) -> Result<(), ()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(8))
|
||||
.build();
|
||||
let Ok(client) = client else {
|
||||
println!(" {label}: FAIL (client build)");
|
||||
return Err(());
|
||||
};
|
||||
|
||||
let resp = client.get(url).headers(headers).send().await;
|
||||
match resp {
|
||||
Ok(r) => {
|
||||
let status = r.status();
|
||||
println!(" {label}: {status} ({url})");
|
||||
print_quota_headers(r.headers());
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
if msg.to_ascii_lowercase().contains("certificate")
|
||||
|| msg.to_ascii_lowercase().contains("tls")
|
||||
{
|
||||
println!(" {label}: FAIL (TLS/cert) ({url}) — {msg}");
|
||||
} else {
|
||||
println!(" {label}: FAIL ({url}) — {msg}");
|
||||
}
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_quota_headers(headers: &HeaderMap) {
|
||||
let mut out: Vec<(String, String)> = Vec::new();
|
||||
for (k, v) in headers.iter() {
|
||||
let name = k.as_str().to_ascii_lowercase();
|
||||
if name.contains("ratelimit") || name.contains("quota") {
|
||||
if let Ok(s) = v.to_str() {
|
||||
out.push((k.as_str().to_string(), s.to_string()));
|
||||
}
|
||||
}
|
||||
// OpenAI-compatible common headers:
|
||||
if name.starts_with("x-ratelimit-") {
|
||||
if let Ok(s) = v.to_str() {
|
||||
out.push((k.as_str().to_string(), s.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out.dedup();
|
||||
for (k, v) in out {
|
||||
println!(" {k}: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn openai_embeddings_probe(
|
||||
label: &str,
|
||||
url: &str,
|
||||
model: &str,
|
||||
headers: HeaderMap,
|
||||
) -> Result<(), ()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(12))
|
||||
.build();
|
||||
let Ok(client) = client else {
|
||||
println!(" {label}: FAIL (client build)");
|
||||
return Err(());
|
||||
};
|
||||
|
||||
// Minimal request: one short string. We don't parse the embedding content.
|
||||
let body = serde_json::json!({
|
||||
"model": model,
|
||||
"input": ["ping"]
|
||||
});
|
||||
|
||||
let resp = client.post(url).headers(headers).json(&body).send().await;
|
||||
match resp {
|
||||
Ok(r) => {
|
||||
let status = r.status();
|
||||
println!(" {label}: {status} ({url}) model={model}");
|
||||
print_quota_headers(r.headers());
|
||||
if !status.is_success() {
|
||||
let t = r.text().await.unwrap_or_default();
|
||||
if !t.trim().is_empty() {
|
||||
println!(" body: {}", t.chars().take(400).collect::<String>());
|
||||
}
|
||||
return Err(());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
if msg.to_ascii_lowercase().contains("certificate")
|
||||
|| msg.to_ascii_lowercase().contains("tls")
|
||||
{
|
||||
println!(" {label}: FAIL (TLS/cert) ({url}) — {msg}");
|
||||
} else {
|
||||
println!(" {label}: FAIL ({url}) — {msg}");
|
||||
}
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_base_url_host_port() {
|
||||
assert_eq!(
|
||||
parse_host_port("http://127.0.0.1:8080/v1").unwrap(),
|
||||
("127.0.0.1".into(), 8080)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_host_port("https://api.anthropic.com").unwrap(),
|
||||
("api.anthropic.com".into(), 443)
|
||||
);
|
||||
}
|
||||
}
|
||||
2944
rust/crates/claw-analog/src/lib.rs
Normal file
2944
rust/crates/claw-analog/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
522
rust/crates/claw-analog/src/main.rs
Normal file
522
rust/crates/claw-analog/src/main.rs
Normal file
@@ -0,0 +1,522 @@
|
||||
//! Binary wrapper for `claw_analog::run` — see `how_to_run.md` in repo root.
|
||||
|
||||
mod agents;
|
||||
mod config_cmd;
|
||||
mod doctor;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
|
||||
use clap_complete::{generate, Shell};
|
||||
use claw_analog::{
|
||||
load_analog_toml, load_profile_hint, permission_mode_from_toml_str, print_tools_dry_run,
|
||||
resolve_analog_profile_path, resolve_rag_base_url, AnalogConfig, AnalogFileConfig,
|
||||
AnalogLanguage, OutputFormat, PermissionMode, Preset, ANALOG_DEFAULT_MODEL,
|
||||
};
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
enum PermissionArg {
|
||||
ReadOnly,
|
||||
WorkspaceWrite,
|
||||
Prompt,
|
||||
#[value(name = "danger-full-access")]
|
||||
DangerFullAccess,
|
||||
/// Same unrestricted posture as danger-full-access for this narrow tool set.
|
||||
Allow,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
enum OutputFormatArg {
|
||||
Rich,
|
||||
Json,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
enum LangArg {
|
||||
En,
|
||||
Ru,
|
||||
}
|
||||
|
||||
impl From<LangArg> for AnalogLanguage {
|
||||
fn from(a: LangArg) -> Self {
|
||||
match a {
|
||||
LangArg::En => AnalogLanguage::En,
|
||||
LangArg::Ru => AnalogLanguage::Ru,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
enum PresetCli {
|
||||
None,
|
||||
/// Automatically infer a preset from the initial prompt.
|
||||
Auto,
|
||||
Audit,
|
||||
Explain,
|
||||
Implement,
|
||||
}
|
||||
|
||||
impl From<PresetCli> for Preset {
|
||||
fn from(p: PresetCli) -> Self {
|
||||
match p {
|
||||
PresetCli::None => Preset::None,
|
||||
PresetCli::Auto => Preset::None,
|
||||
PresetCli::Audit => Preset::Audit,
|
||||
PresetCli::Explain => Preset::Explain,
|
||||
PresetCli::Implement => Preset::Implement,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "claw-analog",
|
||||
version,
|
||||
about = "Lean tool-agent loop (read/list/grep/write) on claw-code `api` providers"
|
||||
)]
|
||||
#[command(args_conflicts_with_subcommands = true)]
|
||||
struct RootCli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Commands>,
|
||||
#[command(flatten)]
|
||||
run: RunCli,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Commands {
|
||||
/// Verify credentials, `cargo check -p claw-analog` (or `--release-build`), config merge preview, optional `--tcp-ping`.
|
||||
Doctor(doctor::DoctorCli),
|
||||
Config {
|
||||
#[command(subcommand)]
|
||||
command: ConfigSub,
|
||||
},
|
||||
/// Print shell completion script for this binary (redirect to a file or `source` it).
|
||||
Complete(CompleteCli),
|
||||
/// Run multiple specialized sub-agents sequentially (shared base session).
|
||||
Agents(agents::AgentsCli),
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum ConfigSub {
|
||||
/// Parse `.claw-analog.toml` and profile; print a merge preview (no API calls).
|
||||
Validate(config_cmd::ValidateCli),
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
struct CompleteCli {
|
||||
#[arg(value_enum)]
|
||||
shell: ShellKind,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
enum ShellKind {
|
||||
Bash,
|
||||
Zsh,
|
||||
Fish,
|
||||
#[value(name = "powershell", alias = "pwsh")]
|
||||
Powershell,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
struct RunCli {
|
||||
/// Config file (default: `<workspace>/.claw-analog.toml` if that path exists).
|
||||
#[arg(long, value_name = "PATH")]
|
||||
config: Option<PathBuf>,
|
||||
#[arg(short, long)]
|
||||
model: Option<String>,
|
||||
#[arg(short = 'w', long, default_value = ".")]
|
||||
workspace: PathBuf,
|
||||
#[arg(long, value_enum)]
|
||||
permission: Option<PermissionArg>,
|
||||
#[arg(long, value_enum)]
|
||||
preset: Option<PresetCli>,
|
||||
/// Reply language hint for the assistant (`en` or `ru` in system prompt; not the API model id).
|
||||
#[arg(long, value_enum)]
|
||||
lang: Option<LangArg>,
|
||||
/// Print effective tools for merged `permission` / enforcer, then exit (no prompt, no API).
|
||||
#[arg(long, default_value_t = false, action = clap::ArgAction::SetTrue)]
|
||||
print_tools: bool,
|
||||
/// Persist message history for resume (JSON). See `how_to_run.md` for risks.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
session: Option<PathBuf>,
|
||||
/// Write session JSON to this path on each snapshot (export without `--session`, or an extra copy).
|
||||
#[arg(long, value_name = "PATH")]
|
||||
save_session: Option<PathBuf>,
|
||||
/// Profile snippet TOML (`line = "..."`). Default: `~/.claw-analog/profile.toml` if it exists.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
profile: Option<PathBuf>,
|
||||
/// Stream assistant text to stdout as tokens arrive (uses `stream_message`).
|
||||
#[arg(long, default_value_t = false, conflicts_with = "no_stream")]
|
||||
stream: bool,
|
||||
/// Turn streaming off (overrides `stream` in config).
|
||||
#[arg(long, default_value_t = false, conflicts_with = "stream")]
|
||||
no_stream: bool,
|
||||
/// Newline-delimited JSON events on stdout (for agents / CI). Diagnostics stay on stderr.
|
||||
#[arg(long, value_enum)]
|
||||
output_format: Option<OutputFormatArg>,
|
||||
/// Disable `runtime::PermissionEnforcer` (paths are still jailed; policy checks are weakened).
|
||||
#[arg(long = "no-runtime-enforcer", default_value_t = false, action = clap::ArgAction::SetTrue)]
|
||||
no_runtime_enforcer: bool,
|
||||
/// Allow `danger-full-access` / `allow` when stdin is not a TTY (CI/automation; use with care).
|
||||
#[arg(long = "accept-danger-non-interactive", default_value_t = false, action = clap::ArgAction::SetTrue)]
|
||||
accept_danger_non_interactive: bool,
|
||||
#[arg(long)]
|
||||
max_read_bytes: Option<u64>,
|
||||
#[arg(long)]
|
||||
max_turns: Option<u32>,
|
||||
#[arg(long)]
|
||||
max_list_entries: Option<usize>,
|
||||
#[arg(long)]
|
||||
grep_max_lines: Option<usize>,
|
||||
#[arg(long)]
|
||||
glob_max_paths: Option<usize>,
|
||||
#[arg(long)]
|
||||
glob_max_depth: Option<usize>,
|
||||
prompt: Option<String>,
|
||||
}
|
||||
|
||||
const DEF_MAX_READ: u64 = 256 * 1024;
|
||||
const DEF_MAX_TURNS: u32 = 24;
|
||||
const DEF_MAX_LIST: usize = 500;
|
||||
const DEF_GREP_MAX: usize = 200;
|
||||
const DEF_GLOB_PATHS: usize = 2000;
|
||||
const DEF_GLOB_DEPTH: usize = 32;
|
||||
const DEF_RAG_TIMEOUT_SECS: u64 = 30;
|
||||
const DEF_RAG_TOP_K_MAX: u32 = 32;
|
||||
const RAG_TOP_K_ABS_CAP: u32 = 256;
|
||||
|
||||
fn config_file_path(cli: &RunCli) -> PathBuf {
|
||||
cli.config
|
||||
.clone()
|
||||
.unwrap_or_else(|| cli.workspace.join(".claw-analog.toml"))
|
||||
}
|
||||
|
||||
fn load_file_config(path: &Path) -> AnalogFileConfig {
|
||||
if !path.is_file() {
|
||||
return AnalogFileConfig::default();
|
||||
}
|
||||
match load_analog_toml(path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[claw-analog] warning: failed to read {}: {e}",
|
||||
path.display()
|
||||
);
|
||||
AnalogFileConfig::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn output_format_from_toml(s: &str) -> Option<OutputFormat> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"json" => Some(OutputFormat::Json),
|
||||
"rich" => Some(OutputFormat::Rich),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_session_path(
|
||||
cli: Option<PathBuf>,
|
||||
file: Option<&str>,
|
||||
workspace: &Path,
|
||||
) -> Option<PathBuf> {
|
||||
let p = cli.or_else(|| file.map(PathBuf::from))?;
|
||||
Some(if p.is_absolute() {
|
||||
p
|
||||
} else {
|
||||
workspace.join(p)
|
||||
})
|
||||
}
|
||||
|
||||
fn merge_language(cli: Option<LangArg>, file: Option<&str>) -> AnalogLanguage {
|
||||
if let Some(l) = cli {
|
||||
return l.into();
|
||||
}
|
||||
file.and_then(AnalogLanguage::from_toml_str)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn merge_preset(cli: Option<PresetCli>, file: Option<&str>, prompt: &str) -> Preset {
|
||||
if let Some(p) = cli {
|
||||
return match p {
|
||||
PresetCli::Auto => claw_analog::infer_preset_from_prompt(prompt),
|
||||
other => Preset::from(other),
|
||||
};
|
||||
}
|
||||
if file.is_some_and(|s| s.trim().eq_ignore_ascii_case("auto")) {
|
||||
return claw_analog::infer_preset_from_prompt(prompt);
|
||||
}
|
||||
if let Some(s) = file.and_then(Preset::from_toml_str) {
|
||||
return s;
|
||||
}
|
||||
claw_analog::infer_preset_from_prompt(prompt)
|
||||
}
|
||||
|
||||
fn merge_permission(
|
||||
cli: Option<PermissionArg>,
|
||||
file_perm: Option<String>,
|
||||
preset: Preset,
|
||||
) -> PermissionMode {
|
||||
if let Some(p) = cli {
|
||||
return match p {
|
||||
PermissionArg::ReadOnly => PermissionMode::ReadOnly,
|
||||
PermissionArg::WorkspaceWrite => PermissionMode::WorkspaceWrite,
|
||||
PermissionArg::Prompt => PermissionMode::Prompt,
|
||||
PermissionArg::DangerFullAccess => PermissionMode::DangerFullAccess,
|
||||
PermissionArg::Allow => PermissionMode::Allow,
|
||||
};
|
||||
}
|
||||
if let Some(s) = file_perm.as_deref().and_then(permission_mode_from_toml_str) {
|
||||
return s;
|
||||
}
|
||||
match preset {
|
||||
Preset::Implement => PermissionMode::WorkspaceWrite,
|
||||
_ => PermissionMode::ReadOnly,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_config(
|
||||
cli: &RunCli,
|
||||
file: &AnalogFileConfig,
|
||||
prompt: String,
|
||||
profile_hint: Option<String>,
|
||||
session_path: Option<PathBuf>,
|
||||
preset: Preset,
|
||||
permission_mode: PermissionMode,
|
||||
) -> AnalogConfig {
|
||||
let model = cli
|
||||
.model
|
||||
.clone()
|
||||
.or_else(|| file.model.clone())
|
||||
.unwrap_or_else(|| ANALOG_DEFAULT_MODEL.into());
|
||||
|
||||
let output_format = cli
|
||||
.output_format
|
||||
.map(|o| match o {
|
||||
OutputFormatArg::Rich => OutputFormat::Rich,
|
||||
OutputFormatArg::Json => OutputFormat::Json,
|
||||
})
|
||||
.or_else(|| {
|
||||
file.output_format
|
||||
.as_deref()
|
||||
.and_then(output_format_from_toml)
|
||||
})
|
||||
.unwrap_or(OutputFormat::Rich);
|
||||
|
||||
let use_stream = if cli.no_stream {
|
||||
false
|
||||
} else if cli.stream {
|
||||
true
|
||||
} else {
|
||||
file.stream.unwrap_or(false)
|
||||
};
|
||||
|
||||
let use_runtime_enforcer =
|
||||
!cli.no_runtime_enforcer && !file.no_runtime_enforcer.unwrap_or(false);
|
||||
|
||||
let accept_danger_non_interactive =
|
||||
cli.accept_danger_non_interactive || file.accept_danger_non_interactive.unwrap_or(false);
|
||||
|
||||
let max_read_bytes = cli
|
||||
.max_read_bytes
|
||||
.or(file.max_read_bytes)
|
||||
.unwrap_or(DEF_MAX_READ);
|
||||
let max_turns = cli.max_turns.or(file.max_turns).unwrap_or(DEF_MAX_TURNS);
|
||||
let max_list_entries = cli
|
||||
.max_list_entries
|
||||
.or(file.max_list_entries)
|
||||
.unwrap_or(DEF_MAX_LIST);
|
||||
let grep_max_lines = cli
|
||||
.grep_max_lines
|
||||
.or(file.grep_max_lines)
|
||||
.unwrap_or(DEF_GREP_MAX);
|
||||
let glob_max_paths = cli
|
||||
.glob_max_paths
|
||||
.or(file.glob_max_paths)
|
||||
.unwrap_or(DEF_GLOB_PATHS);
|
||||
let glob_max_depth = cli
|
||||
.glob_max_depth
|
||||
.or(file.glob_max_depth)
|
||||
.unwrap_or(DEF_GLOB_DEPTH);
|
||||
|
||||
let rag_base_url = resolve_rag_base_url(file);
|
||||
let rag_http_timeout =
|
||||
Duration::from_secs(file.rag_timeout_secs.unwrap_or(DEF_RAG_TIMEOUT_SECS).max(1));
|
||||
let rag_top_k_max = file
|
||||
.rag_top_k_max
|
||||
.unwrap_or(DEF_RAG_TOP_K_MAX)
|
||||
.clamp(1, RAG_TOP_K_ABS_CAP);
|
||||
|
||||
let session_save_path = cli.save_session.as_ref().map(|p| {
|
||||
if p.is_absolute() {
|
||||
p.clone()
|
||||
} else {
|
||||
cli.workspace.join(p)
|
||||
}
|
||||
});
|
||||
|
||||
let language = merge_language(cli.lang, file.language.as_deref());
|
||||
|
||||
AnalogConfig {
|
||||
model,
|
||||
workspace: cli.workspace.clone(),
|
||||
permission_mode,
|
||||
accept_danger_non_interactive,
|
||||
use_stream,
|
||||
output_format,
|
||||
use_runtime_enforcer,
|
||||
max_read_bytes,
|
||||
max_turns,
|
||||
max_list_entries,
|
||||
grep_max_lines,
|
||||
glob_max_paths,
|
||||
glob_max_depth,
|
||||
preset,
|
||||
language,
|
||||
session_path,
|
||||
session_save_path,
|
||||
profile_hint,
|
||||
prompt,
|
||||
rag_base_url,
|
||||
rag_http_timeout,
|
||||
rag_top_k_max,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let root = RootCli::parse();
|
||||
match root.command {
|
||||
Some(Commands::Doctor(d)) => {
|
||||
let code = doctor::run_doctor(d);
|
||||
std::process::exit(code);
|
||||
}
|
||||
Some(Commands::Agents(a)) => {
|
||||
let code = match agents::run_agents(a) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => {
|
||||
eprintln!("agents: {e}");
|
||||
1
|
||||
}
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
Some(Commands::Config { command }) => {
|
||||
let code = match command {
|
||||
ConfigSub::Validate(v) => config_cmd::run_validate(v),
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
Some(Commands::Complete(co)) => {
|
||||
let shell = match co.shell {
|
||||
ShellKind::Bash => Shell::Bash,
|
||||
ShellKind::Zsh => Shell::Zsh,
|
||||
ShellKind::Fish => Shell::Fish,
|
||||
ShellKind::Powershell => Shell::PowerShell,
|
||||
};
|
||||
let mut cmd = RootCli::command();
|
||||
generate(shell, &mut cmd, "claw-analog", &mut std::io::stdout());
|
||||
return Ok(());
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
let cli = root.run;
|
||||
let cfg_path = config_file_path(&cli);
|
||||
let file_cfg = load_file_config(&cfg_path);
|
||||
|
||||
if cli.print_tools {
|
||||
let preset = merge_preset(
|
||||
cli.preset,
|
||||
file_cfg.preset.as_deref(),
|
||||
&cli.prompt.clone().unwrap_or_default(),
|
||||
);
|
||||
let permission_mode = merge_permission(cli.permission, file_cfg.permission.clone(), preset);
|
||||
let use_runtime_enforcer =
|
||||
!cli.no_runtime_enforcer && !file_cfg.no_runtime_enforcer.unwrap_or(false);
|
||||
let rag_url = resolve_rag_base_url(&file_cfg);
|
||||
print_tools_dry_run(
|
||||
permission_mode,
|
||||
use_runtime_enforcer,
|
||||
rag_url.as_deref(),
|
||||
&mut std::io::stdout(),
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let pre_output_format = cli
|
||||
.output_format
|
||||
.map(|o| match o {
|
||||
OutputFormatArg::Rich => OutputFormat::Rich,
|
||||
OutputFormatArg::Json => OutputFormat::Json,
|
||||
})
|
||||
.or_else(|| {
|
||||
file_cfg
|
||||
.output_format
|
||||
.as_deref()
|
||||
.and_then(output_format_from_toml)
|
||||
})
|
||||
.unwrap_or(OutputFormat::Rich);
|
||||
|
||||
let prompt = if let Some(p) = cli.prompt.clone() {
|
||||
p
|
||||
} else {
|
||||
use std::io::Read;
|
||||
let mut buf = String::new();
|
||||
std::io::stdin().read_to_string(&mut buf)?;
|
||||
if buf.trim().is_empty() {
|
||||
if matches!(pre_output_format, OutputFormat::Json) {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({"type": "error", "message": "empty prompt (pass as arg or stdin)"})
|
||||
);
|
||||
}
|
||||
return Err("empty prompt (pass as arg or stdin)".into());
|
||||
}
|
||||
buf
|
||||
};
|
||||
|
||||
let preset = merge_preset(cli.preset, file_cfg.preset.as_deref(), &prompt);
|
||||
let permission_mode = merge_permission(cli.permission, file_cfg.permission.clone(), preset);
|
||||
|
||||
let session_path = resolve_session_path(
|
||||
cli.session.clone(),
|
||||
file_cfg.session.as_deref(),
|
||||
&cli.workspace,
|
||||
);
|
||||
|
||||
let profile_path = resolve_analog_profile_path(
|
||||
&cli.workspace,
|
||||
cli.profile.clone(),
|
||||
file_cfg.profile.as_deref(),
|
||||
);
|
||||
|
||||
let profile_hint = if let Some(ref p) = profile_path {
|
||||
load_profile_hint(p)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let config = build_config(
|
||||
&cli,
|
||||
&file_cfg,
|
||||
prompt,
|
||||
profile_hint,
|
||||
session_path,
|
||||
preset,
|
||||
permission_mode,
|
||||
);
|
||||
let output_format = config.output_format;
|
||||
|
||||
let mut out = std::io::stdout();
|
||||
if let Err(e) = claw_analog::run(config, &mut out).await {
|
||||
if matches!(output_format, OutputFormat::Json) {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({"type": "error", "message": e.to_string()})
|
||||
);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
30
rust/crates/claw-rag-service/Cargo.toml
Normal file
30
rust/crates/claw-rag-service/Cargo.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "claw-rag-service"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Workspace RAG service: SQLite index, OpenAI-compatible embeddings, query API."
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
dotenvy = "0.15"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal"] }
|
||||
walkdir = "2"
|
||||
qdrant-client = { version = "1.17", optional = true }
|
||||
blake3 = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
qdrant-index = ["dep:qdrant-client"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
20
rust/crates/claw-rag-service/Dockerfile
Normal file
20
rust/crates/claw-rag-service/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
# qdrant-client currently requires a fairly recent stable Rust.
|
||||
# Keep this pinned to avoid surprise breaks from `rust:latest`.
|
||||
FROM rust:1.91-bookworm AS builder
|
||||
|
||||
WORKDIR /repo
|
||||
COPY . /repo/rust/
|
||||
|
||||
WORKDIR /repo/rust
|
||||
# Sanity check toolchain version (helps debug CI/Docker Desktop issues).
|
||||
RUN rustc --version && cargo --version
|
||||
# Build the service with qdrant support enabled (works even if you don't use qdrant).
|
||||
RUN cargo build -p claw-rag-service --release --features qdrant-index
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /repo/rust/target/release/claw-rag-service /app/claw-rag-service
|
||||
|
||||
EXPOSE 8787
|
||||
ENTRYPOINT ["/app/claw-rag-service"]
|
||||
41
rust/crates/claw-rag-service/src/chunk.rs
Normal file
41
rust/crates/claw-rag-service/src/chunk.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
//! Split file text into overlapping windows (character-based UTF-8).
|
||||
|
||||
#[must_use]
|
||||
pub fn chunk_text(text: &str, max_chars: usize, overlap: usize) -> Vec<String> {
|
||||
if max_chars == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let overlap = overlap.min(max_chars.saturating_sub(1));
|
||||
let mut out = Vec::new();
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() {
|
||||
return out;
|
||||
}
|
||||
let mut start = 0;
|
||||
loop {
|
||||
let end = (start + max_chars).min(chars.len());
|
||||
let piece: String = chars[start..end].iter().collect();
|
||||
if !piece.trim().is_empty() {
|
||||
out.push(piece);
|
||||
}
|
||||
if end >= chars.len() {
|
||||
break;
|
||||
}
|
||||
let step = max_chars.saturating_sub(overlap).max(1);
|
||||
start += step;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn chunks_non_empty() {
|
||||
let c = chunk_text("hello world test", 5, 2);
|
||||
assert!(!c.is_empty());
|
||||
let joined: String = c.join("");
|
||||
assert!(joined.contains("hello"));
|
||||
}
|
||||
}
|
||||
210
rust/crates/claw-rag-service/src/db.rs
Normal file
210
rust/crates/claw-rag-service/src/db.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
//! `SQLite` storage for chunks and embedding vectors.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
const SCHEMA: &str = r"
|
||||
CREATE TABLE IF NOT EXISTS chunks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
path TEXT NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
UNIQUE(path, ordinal)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS embeddings (
|
||||
chunk_id INTEGER PRIMARY KEY,
|
||||
dim INTEGER NOT NULL,
|
||||
vec BLOB NOT NULL,
|
||||
FOREIGN KEY (chunk_id) REFERENCES chunks(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
path TEXT PRIMARY KEY,
|
||||
content_hash TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
mtime_ms INTEGER NOT NULL,
|
||||
indexed_at_ms INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chunks_path ON chunks(path);
|
||||
";
|
||||
|
||||
pub fn open_db(path: &Path) -> Result<Connection, String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
let conn = Connection::open(path).map_err(|e| e.to_string())?;
|
||||
conn.execute_batch(
|
||||
r"
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA journal_mode = WAL;
|
||||
",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
conn.execute_batch(SCHEMA).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn truncate_index(conn: &Connection) -> Result<(), String> {
|
||||
conn.execute_batch("DELETE FROM embeddings; DELETE FROM chunks; DELETE FROM files;")
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn file_is_unchanged(
|
||||
conn: &Connection,
|
||||
path: &str,
|
||||
content_hash: &str,
|
||||
size_bytes: i64,
|
||||
mtime_ms: i64,
|
||||
) -> Result<bool, String> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT content_hash, size_bytes, mtime_ms FROM files WHERE path=?1 LIMIT 1")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut rows = stmt.query(params![path]).map_err(|e| e.to_string())?;
|
||||
if let Some(r) = rows.next().map_err(|e| e.to_string())? {
|
||||
let h: String = r.get(0).map_err(|e| e.to_string())?;
|
||||
let sz: i64 = r.get(1).map_err(|e| e.to_string())?;
|
||||
let mt: i64 = r.get(2).map_err(|e| e.to_string())?;
|
||||
return Ok(h == content_hash && sz == size_bytes && mt == mtime_ms);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub fn upsert_file_meta(
|
||||
conn: &Connection,
|
||||
path: &str,
|
||||
content_hash: &str,
|
||||
size_bytes: i64,
|
||||
mtime_ms: i64,
|
||||
indexed_at_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
conn.execute(
|
||||
r"
|
||||
INSERT INTO files(path, content_hash, size_bytes, mtime_ms, indexed_at_ms)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
content_hash=excluded.content_hash,
|
||||
size_bytes=excluded.size_bytes,
|
||||
mtime_ms=excluded.mtime_ms,
|
||||
indexed_at_ms=excluded.indexed_at_ms
|
||||
",
|
||||
params![path, content_hash, size_bytes, mtime_ms, indexed_at_ms],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_file_and_chunks(conn: &Connection, path: &str) -> Result<(), String> {
|
||||
// Delete chunks first (embeddings cascade); then remove file meta.
|
||||
conn.execute("DELETE FROM chunks WHERE path=?1", params![path])
|
||||
.map_err(|e| e.to_string())?;
|
||||
conn.execute("DELETE FROM files WHERE path=?1", params![path])
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_all_files(conn: &Connection) -> Result<Vec<String>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT path FROM files")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| r.get::<_, String>(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r.map_err(|e| e.to_string())?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn insert_chunk(
|
||||
conn: &Connection,
|
||||
path: &str,
|
||||
ordinal: i32,
|
||||
text: &str,
|
||||
) -> Result<i64, String> {
|
||||
conn.execute(
|
||||
"INSERT INTO chunks (path, ordinal, text) VALUES (?1, ?2, ?3)",
|
||||
params![path, ordinal, text],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn insert_embedding(
|
||||
conn: &Connection,
|
||||
chunk_id: i64,
|
||||
dim: usize,
|
||||
vec: &[f32],
|
||||
) -> Result<(), String> {
|
||||
let bytes = f32_slice_to_blob(vec);
|
||||
let dim_i64 = i64::try_from(dim).map_err(|_| "embedding dim too large".to_string())?;
|
||||
conn.execute(
|
||||
"INSERT INTO embeddings (chunk_id, dim, vec) VALUES (?1, ?2, ?3)",
|
||||
params![chunk_id, dim_i64, bytes],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn f32_slice_to_blob(v: &[f32]) -> Vec<u8> {
|
||||
let mut b = Vec::with_capacity(v.len() * 4);
|
||||
for x in v {
|
||||
b.extend_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
pub fn blob_to_f32_vec(blob: &[u8], dim: usize) -> Option<Vec<f32>> {
|
||||
if blob.len() != dim * 4 {
|
||||
return None;
|
||||
}
|
||||
let mut v = Vec::with_capacity(dim);
|
||||
for chunk in blob.chunks_exact(4) {
|
||||
v.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChunkRow {
|
||||
pub path: String,
|
||||
pub text: String,
|
||||
pub vec: Vec<f32>,
|
||||
}
|
||||
|
||||
pub fn load_all_indexed(conn: &Connection) -> Result<Vec<ChunkRow>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT c.path, c.text, e.dim, e.vec FROM chunks c
|
||||
INNER JOIN embeddings e ON e.chunk_id = c.id",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(r) = rows.next().map_err(|e| e.to_string())? {
|
||||
let path: String = r.get(0).map_err(|e| e.to_string())?;
|
||||
let text: String = r.get(1).map_err(|e| e.to_string())?;
|
||||
let dim: i64 = r.get(2).map_err(|e| e.to_string())?;
|
||||
let blob: Vec<u8> = r.get(3).map_err(|e| e.to_string())?;
|
||||
let dim = usize::try_from(dim).map_err(|_| "invalid embedding dim in db".to_string())?;
|
||||
let Some(vec) = blob_to_f32_vec(&blob, dim) else {
|
||||
continue;
|
||||
};
|
||||
out.push(ChunkRow { path, text, vec });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn chunk_count(conn: &Connection) -> Result<i64, String> {
|
||||
let n: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM chunks", [], |r| r.get(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(n)
|
||||
}
|
||||
129
rust/crates/claw-rag-service/src/embed.rs
Normal file
129
rust/crates/claw-rag-service/src/embed.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
//! OpenAI-compatible embeddings HTTP client.
|
||||
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EmbedConfig {
|
||||
pub api_key: String,
|
||||
pub base_url: String,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
impl EmbedConfig {
|
||||
pub fn from_env() -> Result<Self, String> {
|
||||
let api_key = std::env::var("CLAW_RAG_OPENAI_API_KEY")
|
||||
.or_else(|_| std::env::var("OPENAI_API_KEY"))
|
||||
.map_err(|_| {
|
||||
"set CLAW_RAG_OPENAI_API_KEY or OPENAI_API_KEY for embeddings".to_string()
|
||||
})?;
|
||||
let base_url = std::env::var("CLAW_RAG_EMBEDDING_BASE_URL")
|
||||
.unwrap_or_else(|_| "https://api.openai.com/v1".into());
|
||||
let model = std::env::var("CLAW_RAG_EMBEDDING_MODEL")
|
||||
.unwrap_or_else(|_| "text-embedding-3-small".into());
|
||||
Ok(Self {
|
||||
api_key,
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
model,
|
||||
})
|
||||
}
|
||||
|
||||
/// Deterministic fake vectors for tests / dry-run (1536 dims match common `OpenAI` models;
|
||||
/// truncated scan still works if dim mismatches — ingest uses same mock for all).
|
||||
#[must_use]
|
||||
pub fn mock_from_env() -> Option<Self> {
|
||||
if std::env::var("CLAW_RAG_MOCK_PROVIDERS").ok().as_deref() != Some("1") {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
api_key: "mock".into(),
|
||||
base_url: "mock://".into(),
|
||||
model: "mock-embedding".into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct EmbeddingsRequest<'a> {
|
||||
model: &'a str,
|
||||
input: Vec<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EmbeddingsResponse {
|
||||
data: Vec<EmbeddingItem>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EmbeddingItem {
|
||||
embedding: Vec<f32>,
|
||||
}
|
||||
|
||||
pub async fn embed_batch(
|
||||
client: &Client,
|
||||
cfg: &EmbedConfig,
|
||||
texts: &[String],
|
||||
) -> Result<Vec<Vec<f32>>, String> {
|
||||
if cfg.base_url.starts_with("mock://") {
|
||||
return Ok(texts
|
||||
.iter()
|
||||
.map(|s| mock_vector_for_text(s.as_str()))
|
||||
.collect());
|
||||
}
|
||||
|
||||
let url = format!("{}/embeddings", cfg.base_url);
|
||||
let inputs: Vec<&str> = texts.iter().map(String::as_str).collect();
|
||||
let body = EmbeddingsRequest {
|
||||
model: &cfg.model,
|
||||
input: inputs,
|
||||
};
|
||||
let res = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", cfg.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !res.status().is_success() {
|
||||
let t = res.text().await.unwrap_or_default();
|
||||
return Err(format!("embeddings HTTP error: {t}"));
|
||||
}
|
||||
let parsed: EmbeddingsResponse = res.json().await.map_err(|e| e.to_string())?;
|
||||
if parsed.data.len() != texts.len() {
|
||||
return Err(format!(
|
||||
"embeddings count mismatch: got {} for {} inputs",
|
||||
parsed.data.len(),
|
||||
texts.len()
|
||||
));
|
||||
}
|
||||
Ok(parsed.data.into_iter().map(|d| d.embedding).collect())
|
||||
}
|
||||
|
||||
fn mock_vector_for_text(s: &str) -> Vec<f32> {
|
||||
const DIM: usize = 16;
|
||||
let mut v = vec![0f32; DIM];
|
||||
for (i, b) in s.bytes().enumerate().take(DIM * 4) {
|
||||
v[i % DIM] += f32::from(b) / 255.0;
|
||||
}
|
||||
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if norm > 0.0 {
|
||||
for x in &mut v {
|
||||
*x /= norm;
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
if a.len() != b.len() || a.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
|
||||
let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if na == 0.0 || nb == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
dot / (na * nb)
|
||||
}
|
||||
219
rust/crates/claw-rag-service/src/ingest.rs
Normal file
219
rust/crates/claw-rag-service/src/ingest.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
//! Walk workspace and fill `SQLite` + embeddings.
|
||||
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use reqwest::Client;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::chunk::chunk_text;
|
||||
use crate::db::{
|
||||
delete_file_and_chunks, file_is_unchanged, insert_chunk, insert_embedding, list_all_files,
|
||||
open_db, upsert_file_meta,
|
||||
};
|
||||
use crate::embed::{embed_batch, EmbedConfig};
|
||||
#[cfg(feature = "qdrant-index")]
|
||||
use crate::qdrant_index::{upsert_points, ChunkPoint};
|
||||
|
||||
const DEFAULT_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
|
||||
const CHUNK_CHARS: usize = 900;
|
||||
const CHUNK_OVERLAP: usize = 120;
|
||||
const EMBED_BATCH: usize = 16;
|
||||
|
||||
static SKIP_DIR_NAMES: &[&str] = &[".git", "target", "node_modules", "__pycache__", ".claw-rag"];
|
||||
|
||||
static TEXT_EXTENSIONS: &[&str] = &[
|
||||
"rs", "md", "toml", "txt", "json", "yaml", "yml", "js", "ts", "tsx", "jsx", "py", "go", "c",
|
||||
"h", "cpp", "hpp", "cs", "java", "kt", "swift", "rb", "php", "sh", "ps1", "html", "css", "sql",
|
||||
];
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct IngestStats {
|
||||
pub files_indexed: usize,
|
||||
pub chunks_total: usize,
|
||||
pub embeddings_written: usize,
|
||||
}
|
||||
|
||||
fn should_skip_dir(path: &Path) -> bool {
|
||||
path.file_name()
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.is_some_and(|n| SKIP_DIR_NAMES.contains(&n))
|
||||
}
|
||||
|
||||
fn is_text_extension(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.is_some_and(|e| TEXT_EXTENSIONS.contains(&e.to_ascii_lowercase().as_str()))
|
||||
}
|
||||
|
||||
async fn flush_path_batch(
|
||||
conn: &rusqlite::Connection,
|
||||
path: &str,
|
||||
batch: &mut Vec<(i32, String)>,
|
||||
client: &Client,
|
||||
cfg: &EmbedConfig,
|
||||
stats: &mut IngestStats,
|
||||
) -> Result<(), String> {
|
||||
if batch.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let texts: Vec<String> = batch.iter().map(|(_, t)| t.clone()).collect();
|
||||
let vecs = embed_batch(client, cfg, &texts).await?;
|
||||
if vecs.len() != batch.len() {
|
||||
return Err("embed batch size mismatch".into());
|
||||
}
|
||||
|
||||
#[cfg(feature = "qdrant-index")]
|
||||
let mut qdrant_points: Vec<ChunkPoint> = Vec::with_capacity(batch.len());
|
||||
|
||||
for ((ord, t), vec) in batch.drain(..).zip(vecs.into_iter()) {
|
||||
let dim = vec.len();
|
||||
let cid = insert_chunk(conn, path, ord, &t)?;
|
||||
insert_embedding(conn, cid, dim, &vec)?;
|
||||
stats.embeddings_written += 1;
|
||||
|
||||
#[cfg(feature = "qdrant-index")]
|
||||
{
|
||||
qdrant_points.push(ChunkPoint {
|
||||
id: cid,
|
||||
vec,
|
||||
path: path.to_string(),
|
||||
text: t,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "qdrant-index")]
|
||||
upsert_points(qdrant_points).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run_ingest(
|
||||
workspaces: &[PathBuf],
|
||||
db_path: &Path,
|
||||
cfg: &EmbedConfig,
|
||||
client: &Client,
|
||||
) -> Result<IngestStats, String> {
|
||||
let conn = open_db(db_path)?;
|
||||
|
||||
let mut all_files: Vec<(String, PathBuf)> = Vec::new();
|
||||
let mut seen_paths: Vec<String> = Vec::new();
|
||||
|
||||
for ws in workspaces {
|
||||
let workspace = ws
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("workspace: {}: {e}", ws.display()))?;
|
||||
let ws_prefix = workspace.clone();
|
||||
let repo_id = repo_id_for_workspace(&workspace);
|
||||
|
||||
for entry in WalkDir::new(&workspace)
|
||||
.into_iter()
|
||||
.filter_entry(|e| !should_skip_dir(e.path()))
|
||||
{
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
if !entry.file_type().is_file() {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
if !is_text_extension(path) {
|
||||
continue;
|
||||
}
|
||||
let meta = entry.metadata().map_err(|e| e.to_string())?;
|
||||
if meta.len() > DEFAULT_MAX_FILE_BYTES {
|
||||
continue;
|
||||
}
|
||||
let rel = path
|
||||
.strip_prefix(&ws_prefix)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
let key = format!("{repo_id}:{rel}");
|
||||
seen_paths.push(key.clone());
|
||||
all_files.push((key, path.to_path_buf()));
|
||||
}
|
||||
}
|
||||
|
||||
all_files.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
seen_paths.sort();
|
||||
|
||||
let mut stats = IngestStats {
|
||||
files_indexed: all_files.len(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for (rel, file) in all_files {
|
||||
let Ok(meta) = std::fs::metadata(&file) else {
|
||||
continue;
|
||||
};
|
||||
let size_bytes =
|
||||
i64::try_from(meta.len()).map_err(|_| "file size too large".to_string())?;
|
||||
let mtime_ms = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.and_then(|d| i64::try_from(d.as_millis()).ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let Ok(raw) = std::fs::read_to_string(&file) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let content_hash = blake3::hash(raw.as_bytes()).to_hex().to_string();
|
||||
if file_is_unchanged(&conn, &rel, &content_hash, size_bytes, mtime_ms)? {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Re-index this file: delete previous chunks (and embeddings) for path.
|
||||
delete_file_and_chunks(&conn, &rel)?;
|
||||
|
||||
let pieces = chunk_text(&raw, CHUNK_CHARS, CHUNK_OVERLAP);
|
||||
if pieces.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut batch: Vec<(i32, String)> = Vec::new();
|
||||
for (ord, piece) in pieces.into_iter().enumerate() {
|
||||
stats.chunks_total += 1;
|
||||
let ord_i32 =
|
||||
i32::try_from(ord).map_err(|_| "file produced too many chunks".to_string())?;
|
||||
batch.push((ord_i32, piece));
|
||||
if batch.len() >= EMBED_BATCH {
|
||||
flush_path_batch(&conn, &rel, &mut batch, client, cfg, &mut stats).await?;
|
||||
}
|
||||
}
|
||||
flush_path_batch(&conn, &rel, &mut batch, client, cfg, &mut stats).await?;
|
||||
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| i64::try_from(d.as_millis()).unwrap_or(0))
|
||||
.unwrap_or(0);
|
||||
upsert_file_meta(&conn, &rel, &content_hash, size_bytes, mtime_ms, now_ms)?;
|
||||
}
|
||||
|
||||
// Delete entries for files that no longer exist.
|
||||
// (We compare against file list from DB to avoid needing a SQL "NOT IN" temp table.)
|
||||
let mut seen_set = std::collections::BTreeSet::new();
|
||||
for p in &seen_paths {
|
||||
seen_set.insert(p.as_str());
|
||||
}
|
||||
for p in list_all_files(&conn)? {
|
||||
if !seen_set.contains(p.as_str()) {
|
||||
delete_file_and_chunks(&conn, &p)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
fn repo_id_for_workspace(workspace: &Path) -> String {
|
||||
let name = workspace
|
||||
.file_name()
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("workspace");
|
||||
let hash = blake3::hash(workspace.to_string_lossy().as_bytes())
|
||||
.to_hex()
|
||||
.to_string();
|
||||
format!("{name}-{h}", name = name, h = &hash[..8])
|
||||
}
|
||||
111
rust/crates/claw-rag-service/src/lib.rs
Normal file
111
rust/crates/claw-rag-service/src/lib.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
//! Workspace RAG: ingest files → `SQLite` + embeddings, query via cosine similarity (linear scan MVP).
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod chunk;
|
||||
mod db;
|
||||
mod embed;
|
||||
mod ingest;
|
||||
#[cfg(feature = "qdrant-index")]
|
||||
mod qdrant_index;
|
||||
mod search;
|
||||
|
||||
pub use db::{chunk_count, open_db};
|
||||
pub use embed::EmbedConfig;
|
||||
pub use ingest::{run_ingest, IngestStats};
|
||||
pub use search::query_index;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One retrieved chunk for the model or UI.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RagHit {
|
||||
pub path: String,
|
||||
pub snippet: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub score: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct QueryRequest {
|
||||
pub query: String,
|
||||
#[serde(default = "default_top_k")]
|
||||
pub top_k: u32,
|
||||
}
|
||||
|
||||
fn default_top_k() -> u32 {
|
||||
8
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct QueryResponse {
|
||||
pub hits: Vec<RagHit>,
|
||||
/// `0-stub` (legacy), `1-sqlite`, `1-sqlite-empty`, `1-sqlite-no-db`
|
||||
pub phase: &'static str,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
use reqwest::Client;
|
||||
use tempfile::tempdir;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_missing_db_reports_phase() {
|
||||
let client = Client::new();
|
||||
let cfg = EmbedConfig {
|
||||
api_key: "x".into(),
|
||||
base_url: "mock://".into(),
|
||||
model: "m".into(),
|
||||
};
|
||||
let r = query_index(
|
||||
Path::new("/no/such/claw_rag.sqlite"),
|
||||
&client,
|
||||
&cfg,
|
||||
&QueryRequest {
|
||||
query: "hello".into(),
|
||||
top_k: 3,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.phase, "1-sqlite-no-db");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ingest_and_query_roundtrip_mock() {
|
||||
std::env::set_var("CLAW_RAG_MOCK_PROVIDERS", "1");
|
||||
let dir = tempdir().unwrap();
|
||||
let ws1 = dir.path().join("ws1");
|
||||
let ws2 = dir.path().join("ws2");
|
||||
std::fs::create_dir_all(&ws1).unwrap();
|
||||
std::fs::create_dir_all(&ws2).unwrap();
|
||||
std::fs::write(ws1.join("note.md"), "hello RAG service test content").unwrap();
|
||||
std::fs::write(ws2.join("docs.md"), "secondary repo doc about embeddings").unwrap();
|
||||
let db = dir.path().join("idx.sqlite");
|
||||
let client = Client::new();
|
||||
let cfg = EmbedConfig::mock_from_env().expect("mock");
|
||||
let st = run_ingest(&[ws1.clone(), ws2.clone()], &db, &cfg, &client)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(st.embeddings_written >= 1);
|
||||
|
||||
let r = query_index(
|
||||
&db,
|
||||
&client,
|
||||
&cfg,
|
||||
&QueryRequest {
|
||||
query: "RAG service".into(),
|
||||
top_k: 4,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.phase, "1-sqlite");
|
||||
assert!(!r.hits.is_empty());
|
||||
assert!(r.hits.iter().all(|h| h.path.contains(':')));
|
||||
std::env::remove_var("CLAW_RAG_MOCK_PROVIDERS");
|
||||
}
|
||||
}
|
||||
175
rust/crates/claw-rag-service/src/main.rs
Normal file
175
rust/crates/claw-rag-service/src/main.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
//! `claw-rag-service` — HTTP API + `ingest` subcommand.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::Html,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use clap::{Parser, Subcommand};
|
||||
use claw_rag_service::{
|
||||
chunk_count, open_db, query_index, run_ingest, EmbedConfig, QueryRequest, QueryResponse,
|
||||
};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "claw-rag-service",
|
||||
about = "Workspace RAG index + HTTP query API"
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Cmd>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Run HTTP server (default when no subcommand).
|
||||
Serve(ServeArgs),
|
||||
/// Index a workspace into `SQLite` (calls embedding API).
|
||||
Ingest(IngestArgs),
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
struct ServeArgs {
|
||||
#[arg(long, env = "CLAW_RAG_DB", default_value = ".claw-rag/index.sqlite")]
|
||||
db: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
struct IngestArgs {
|
||||
/// Workspace roots to ingest. Repeat `--workspace` to ingest multiple repos (cross-repo RAG).
|
||||
#[arg(short, long)]
|
||||
workspace: Vec<PathBuf>,
|
||||
#[arg(long, env = "CLAW_RAG_DB", default_value = ".claw-rag/index.sqlite")]
|
||||
db: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
db_path: PathBuf,
|
||||
client: reqwest::Client,
|
||||
cfg: EmbedConfig,
|
||||
}
|
||||
|
||||
/// Single-page UI for phase 3 (served at `GET /`).
|
||||
static INDEX_HTML: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/static/index.html"));
|
||||
|
||||
async fn ui_index() -> Html<&'static str> {
|
||||
Html(INDEX_HTML)
|
||||
}
|
||||
|
||||
fn rag_router(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(ui_index))
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.route("/v1/stats", get(stats))
|
||||
.route("/v1/query", post(query))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
fn resolve_embed_config() -> Result<EmbedConfig, String> {
|
||||
if let Some(c) = EmbedConfig::mock_from_env() {
|
||||
return Ok(c);
|
||||
}
|
||||
EmbedConfig::from_env()
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Load `.env` if present (walks up parent directories).
|
||||
// This is a convenience for local development; CI/production should set real env vars.
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
if let Some(Cmd::Ingest(a)) = cli.command {
|
||||
let cfg = resolve_embed_config()?;
|
||||
let client = reqwest::Client::new();
|
||||
let st = run_ingest(&a.workspace, &a.db, &cfg, &client).await?;
|
||||
eprintln!(
|
||||
"ingest: files={} chunks={} embeddings={}",
|
||||
st.files_indexed, st.chunks_total, st.embeddings_written
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let db = if let Some(Cmd::Serve(s)) = cli.command {
|
||||
s.db
|
||||
} else {
|
||||
PathBuf::from(
|
||||
std::env::var("CLAW_RAG_DB").unwrap_or_else(|_| ".claw-rag/index.sqlite".into()),
|
||||
)
|
||||
};
|
||||
|
||||
let cfg = resolve_embed_config()?;
|
||||
let state = Arc::new(AppState {
|
||||
db_path: db,
|
||||
client: reqwest::Client::new(),
|
||||
cfg,
|
||||
});
|
||||
|
||||
let app = rag_router(state.clone());
|
||||
|
||||
let port: u16 = std::env::var("CLAW_RAG_PORT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(8787);
|
||||
let host: std::net::IpAddr = std::env::var("CLAW_RAG_HOST")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
|
||||
let addr = std::net::SocketAddr::from((host, port));
|
||||
eprintln!(
|
||||
"claw-rag-service db={} listen=http://{addr}",
|
||||
state.db_path.display()
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stats(State(state): State<Arc<AppState>>) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
let path = state.db_path.clone();
|
||||
if !path.is_file() {
|
||||
return Ok(Json(serde_json::json!({
|
||||
"chunks": 0,
|
||||
"phase": "1-sqlite-no-db"
|
||||
})));
|
||||
}
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
let conn = open_db(&path).map_err(|_| ())?;
|
||||
chunk_count(&conn).map_err(|_| ())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.map_err(|()| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(Json(serde_json::json!({
|
||||
"chunks": res,
|
||||
"phase": "1-sqlite"
|
||||
})))
|
||||
}
|
||||
|
||||
async fn query(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<QueryRequest>,
|
||||
) -> Result<Json<QueryResponse>, (StatusCode, String)> {
|
||||
query_index(&state.db_path, &state.client, &state.cfg, &req)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::INDEX_HTML;
|
||||
|
||||
#[test]
|
||||
fn index_html_wires_api_paths() {
|
||||
assert!(INDEX_HTML.contains("/v1/stats"));
|
||||
assert!(INDEX_HTML.contains("/v1/query"));
|
||||
}
|
||||
}
|
||||
177
rust/crates/claw-rag-service/src/qdrant_index.rs
Normal file
177
rust/crates/claw-rag-service/src/qdrant_index.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
use crate::{QueryResponse, RagHit};
|
||||
use serde_json::json;
|
||||
|
||||
async fn ensure_collection(
|
||||
client: &qdrant_client::Qdrant,
|
||||
collection: &str,
|
||||
dim: usize,
|
||||
) -> Result<(), String> {
|
||||
let dim_u64 = u64::try_from(dim).map_err(|_| "embedding dim too large".to_string())?;
|
||||
|
||||
// Try to create the collection; if it already exists, Qdrant will error.
|
||||
// We treat "already exists" as success to keep ingest idempotent.
|
||||
let res = client
|
||||
.create_collection(
|
||||
qdrant_client::qdrant::CreateCollectionBuilder::new(collection).vectors_config(
|
||||
qdrant_client::qdrant::VectorParamsBuilder::new(
|
||||
dim_u64,
|
||||
qdrant_client::qdrant::Distance::Cosine,
|
||||
),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("already exists") || msg.contains("Already exists") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("qdrant create_collection: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QdrantConfig {
|
||||
pub url: String,
|
||||
pub api_key: Option<String>,
|
||||
pub collection: String,
|
||||
}
|
||||
|
||||
impl QdrantConfig {
|
||||
pub fn from_env() -> Option<Self> {
|
||||
let url = std::env::var("CLAW_RAG_QDRANT_URL").ok()?;
|
||||
let collection = std::env::var("CLAW_RAG_QDRANT_COLLECTION")
|
||||
.ok()
|
||||
.unwrap_or_else(|| "claw_rag_chunks".to_string());
|
||||
let api_key = std::env::var("CLAW_RAG_QDRANT_API_KEY").ok();
|
||||
Some(Self {
|
||||
url,
|
||||
api_key,
|
||||
collection,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn query_qdrant(q: &[f32], top_k: u32) -> Result<Option<QueryResponse>, String> {
|
||||
let Some(cfg) = QdrantConfig::from_env() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let limit = top_k.min(64);
|
||||
let mut client = qdrant_client::Qdrant::from_url(&cfg.url);
|
||||
if let Some(key) = &cfg.api_key {
|
||||
client = client.api_key(key.clone());
|
||||
}
|
||||
let client = client.build().map_err(|e| format!("qdrant client: {e}"))?;
|
||||
|
||||
// If collection doesn't exist yet, treat it as "no results" and fall back.
|
||||
// (We avoid creating it on query because ingest controls dimension/model.)
|
||||
if let Err(e) = client.collection_info(&cfg.collection).await {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("doesn't exist") || msg.contains("Not found") {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(format!("qdrant collection_info: {e}"));
|
||||
}
|
||||
|
||||
let res = client
|
||||
.query(
|
||||
qdrant_client::qdrant::QueryPointsBuilder::new(&cfg.collection)
|
||||
.query(q.to_vec())
|
||||
.limit(u64::from(limit))
|
||||
.with_payload(true),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("qdrant query: {e}"))?;
|
||||
|
||||
let mut hits = Vec::new();
|
||||
for p in res.result {
|
||||
let payload = p.payload;
|
||||
let path = payload
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_default();
|
||||
let text = payload
|
||||
.get("text")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_default();
|
||||
let score = p.score;
|
||||
if !path.is_empty() {
|
||||
hits.push(RagHit {
|
||||
path,
|
||||
snippet: truncate_snippet(&text, 480),
|
||||
score: Some(score),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(QueryResponse {
|
||||
hits,
|
||||
phase: "2-qdrant",
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChunkPoint {
|
||||
pub id: i64,
|
||||
pub vec: Vec<f32>,
|
||||
pub path: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
pub async fn upsert_points(points: Vec<ChunkPoint>) -> Result<(), String> {
|
||||
let Some(cfg) = QdrantConfig::from_env() else {
|
||||
return Ok(());
|
||||
};
|
||||
if points.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut client = qdrant_client::Qdrant::from_url(&cfg.url);
|
||||
if let Some(key) = &cfg.api_key {
|
||||
client = client.api_key(key.clone());
|
||||
}
|
||||
let client = client.build().map_err(|e| format!("qdrant client: {e}"))?;
|
||||
|
||||
let dim = points[0].vec.len();
|
||||
ensure_collection(&client, &cfg.collection, dim).await?;
|
||||
|
||||
let mut qpoints = Vec::with_capacity(points.len());
|
||||
for p in points {
|
||||
if p.vec.len() != dim {
|
||||
return Err("qdrant upsert: embedding dimension mismatch within batch".to_string());
|
||||
}
|
||||
let id = u64::try_from(p.id).map_err(|_| "chunk id must be non-negative".to_string())?;
|
||||
let payload_map = serde_json::Map::from_iter([
|
||||
("path".to_string(), json!(p.path)),
|
||||
("text".to_string(), json!(p.text)),
|
||||
]);
|
||||
let payload: qdrant_client::Payload = payload_map.into();
|
||||
|
||||
qpoints.push(qdrant_client::qdrant::PointStruct::new(id, p.vec, payload));
|
||||
}
|
||||
|
||||
client
|
||||
.upsert_points(qdrant_client::qdrant::UpsertPointsBuilder::new(
|
||||
&cfg.collection,
|
||||
qpoints,
|
||||
))
|
||||
.await
|
||||
.map_err(|e| format!("qdrant upsert: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn truncate_snippet(s: &str, max_chars: usize) -> String {
|
||||
let n = s.chars().count();
|
||||
if n <= max_chars {
|
||||
return s.to_string();
|
||||
}
|
||||
s.chars().take(max_chars).collect::<String>() + "…"
|
||||
}
|
||||
87
rust/crates/claw-rag-service/src/search.rs
Normal file
87
rust/crates/claw-rag-service/src/search.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
//! Vector search over indexed chunks (linear scan MVP).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::db::{load_all_indexed, open_db};
|
||||
use crate::embed::{cosine_similarity, embed_batch, EmbedConfig};
|
||||
use crate::{QueryRequest, QueryResponse, RagHit};
|
||||
|
||||
pub async fn query_index(
|
||||
db_path: &Path,
|
||||
client: &Client,
|
||||
cfg: &EmbedConfig,
|
||||
req: &QueryRequest,
|
||||
) -> Result<QueryResponse, String> {
|
||||
if !db_path.is_file() {
|
||||
return Ok(QueryResponse {
|
||||
hits: Vec::new(),
|
||||
phase: "1-sqlite-no-db",
|
||||
});
|
||||
}
|
||||
|
||||
let conn = open_db(db_path)?;
|
||||
let qvecs = embed_batch(client, cfg, std::slice::from_ref(&req.query)).await?;
|
||||
let q = qvecs
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| "no query embedding".to_string())?;
|
||||
|
||||
#[cfg(feature = "qdrant-index")]
|
||||
if let Ok(Some(r)) = crate::qdrant_index::query_qdrant(&q, req.top_k).await {
|
||||
return Ok(r);
|
||||
}
|
||||
|
||||
let rows = load_all_indexed(&conn)?;
|
||||
drop(conn);
|
||||
|
||||
if rows.is_empty() {
|
||||
return Ok(QueryResponse {
|
||||
hits: Vec::new(),
|
||||
phase: "1-sqlite-empty",
|
||||
});
|
||||
}
|
||||
|
||||
let expected = rows[0].vec.len();
|
||||
if q.len() != expected {
|
||||
return Err(format!(
|
||||
"embedding dimension mismatch: index uses dim {} but query embedding has {} (same model/env as ingest required)",
|
||||
expected, q.len()
|
||||
));
|
||||
}
|
||||
|
||||
let mut scored: Vec<(f32, usize)> = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, r)| (cosine_similarity(&q, &r.vec), i))
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
let top = req.top_k.min(64) as usize;
|
||||
let hits: Vec<RagHit> = scored
|
||||
.into_iter()
|
||||
.take(top)
|
||||
.map(|(score, i)| {
|
||||
let r = &rows[i];
|
||||
RagHit {
|
||||
path: r.path.clone(),
|
||||
snippet: truncate_snippet(&r.text, 480),
|
||||
score: Some(score),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(QueryResponse {
|
||||
hits,
|
||||
phase: "1-sqlite",
|
||||
})
|
||||
}
|
||||
|
||||
fn truncate_snippet(s: &str, max_chars: usize) -> String {
|
||||
let n = s.chars().count();
|
||||
if n <= max_chars {
|
||||
return s.to_string();
|
||||
}
|
||||
s.chars().take(max_chars).collect::<String>() + "…"
|
||||
}
|
||||
233
rust/crates/claw-rag-service/static/index.html
Normal file
233
rust/crates/claw-rag-service/static/index.html
Normal file
@@ -0,0 +1,233 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>claw-rag</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #12141a;
|
||||
--surface: #1a1d26;
|
||||
--border: #2a3140;
|
||||
--text: #e8eaef;
|
||||
--muted: #8b93a8;
|
||||
--accent: #e8a035;
|
||||
--ok: #6daf8a;
|
||||
--err: #d97b7b;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: ui-sans-serif, system-ui, "Segoe UI", Roboto, sans-serif;
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
header {
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
header p { margin: 0.35rem 0 0; font-size: 0.85rem; color: var(--muted); }
|
||||
main { max-width: 52rem; margin: 0 auto; padding: 1.25rem; }
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.stats span { color: var(--muted); }
|
||||
.stats strong { color: var(--accent); }
|
||||
form {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
label { font-size: 0.8rem; color: var(--muted); }
|
||||
textarea, input[type="number"] {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
textarea { min-height: 5rem; resize: vertical; }
|
||||
.row { display: flex; gap: 1rem; align-items: end; flex-wrap: wrap; }
|
||||
.row > div:first-child { flex: 1; min-width: 12rem; }
|
||||
button {
|
||||
padding: 0.55rem 1.1rem;
|
||||
background: var(--accent);
|
||||
color: #1a1206;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
button:not(:disabled):hover { filter: brightness(1.05); }
|
||||
.status { font-size: 0.85rem; min-height: 1.25rem; }
|
||||
.status.err { color: var(--err); }
|
||||
.status.ok { color: var(--ok); }
|
||||
.hits { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.hit {
|
||||
padding: 0.85rem 1rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
.hit header {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.hit .path { font-family: ui-monospace, monospace; font-size: 0.85rem; color: var(--accent); }
|
||||
.hit .score { font-size: 0.75rem; color: var(--muted); }
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
footer {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>claw-rag-service</h1>
|
||||
<p>Local index · same-origin <code>/v1/*</code> API</p>
|
||||
</header>
|
||||
<main>
|
||||
<div class="stats" id="stats">
|
||||
<span>chunks: <strong id="chunks">—</strong></span>
|
||||
<span>phase: <strong id="phase">—</strong></span>
|
||||
<button type="button" id="refresh" style="margin-left:auto">Refresh stats</button>
|
||||
</div>
|
||||
|
||||
<form id="qform">
|
||||
<div>
|
||||
<label for="query">Query</label>
|
||||
<textarea id="query" name="query" placeholder="Natural language search…" required></textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="top_k">top_k</label>
|
||||
<input type="number" id="top_k" name="top_k" value="8" min="1" max="64" />
|
||||
</div>
|
||||
<button type="submit" id="submit">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="status" id="status"></div>
|
||||
<div class="hits" id="hits"></div>
|
||||
|
||||
<footer>
|
||||
Index is read-only here; run <code>claw-rag-service ingest</code> to (re)build. Phase 3 UI — no auth; bind to loopback only in production.
|
||||
</footer>
|
||||
</main>
|
||||
<script>
|
||||
async function loadStats() {
|
||||
const elC = document.getElementById('chunks');
|
||||
const elP = document.getElementById('phase');
|
||||
try {
|
||||
const r = await fetch('/v1/stats');
|
||||
const j = await r.json();
|
||||
elC.textContent = j.chunks ?? '?';
|
||||
elP.textContent = j.phase ?? '?';
|
||||
} catch (e) {
|
||||
elC.textContent = '?';
|
||||
elP.textContent = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(msg, cls) {
|
||||
const s = document.getElementById('status');
|
||||
s.textContent = msg || '';
|
||||
s.className = 'status' + (cls ? ' ' + cls : '');
|
||||
}
|
||||
|
||||
function renderHits(data) {
|
||||
const root = document.getElementById('hits');
|
||||
root.innerHTML = '';
|
||||
const hits = data.hits || [];
|
||||
if (hits.length === 0) {
|
||||
setStatus('No hits (phase: ' + (data.phase || '?') + ')', 'ok');
|
||||
return;
|
||||
}
|
||||
setStatus(hits.length + ' hit(s) · phase: ' + (data.phase || '?'), 'ok');
|
||||
for (const h of hits) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'hit';
|
||||
const hdr = document.createElement('header');
|
||||
const path = document.createElement('div');
|
||||
path.className = 'path';
|
||||
path.textContent = h.path || '';
|
||||
hdr.appendChild(path);
|
||||
if (h.score != null) {
|
||||
const sc = document.createElement('div');
|
||||
sc.className = 'score';
|
||||
sc.textContent = 'score: ' + h.score;
|
||||
hdr.appendChild(sc);
|
||||
}
|
||||
card.appendChild(hdr);
|
||||
const pre = document.createElement('pre');
|
||||
pre.textContent = h.snippet || '';
|
||||
card.appendChild(pre);
|
||||
root.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('refresh').addEventListener('click', loadStats);
|
||||
document.getElementById('qform').addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault();
|
||||
const query = document.getElementById('query').value.trim();
|
||||
const top_k = Math.min(64, Math.max(1, parseInt(document.getElementById('top_k').value, 10) || 8));
|
||||
const btn = document.getElementById('submit');
|
||||
btn.disabled = true;
|
||||
setStatus('Searching…', '');
|
||||
document.getElementById('hits').innerHTML = '';
|
||||
try {
|
||||
const r = await fetch('/v1/query', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query, top_k }),
|
||||
});
|
||||
const text = await r.text();
|
||||
if (!r.ok) {
|
||||
setStatus('HTTP ' + r.status + ': ' + text, 'err');
|
||||
return;
|
||||
}
|
||||
renderHits(JSON.parse(text));
|
||||
} catch (e) {
|
||||
setStatus(String(e), 'err');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
loadStats();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1050,8 +1050,59 @@ impl PluginManager {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Returns the default bundled plugins root directory.
|
||||
///
|
||||
/// Resolution order (first existing path wins):
|
||||
/// 1. `<exe_dir>/../share/claw/plugins/bundled` — standard install layout
|
||||
/// 2. `<exe_dir>/bundled` — simple relocated layout
|
||||
/// 3. `CARGO_MANIFEST_DIR/bundled` — dev/source-tree fallback (only if it exists)
|
||||
/// 4. `<exe_dir>/../share/claw/plugins/bundled` — canonical default even if missing
|
||||
///
|
||||
/// This avoids baking in a compile-time source-tree path that may be
|
||||
/// inaccessible at runtime (e.g. a root-owned repo directory).
|
||||
#[must_use]
|
||||
pub fn bundled_root() -> PathBuf {
|
||||
// Candidate 1: standard FHS install layout — <prefix>/bin/claw -> <prefix>/share/claw/plugins/bundled
|
||||
if let Ok(exe_path) = std::env::current_exe() {
|
||||
if let Some(exe_dir) = exe_path.parent() {
|
||||
let share_path = exe_dir
|
||||
.join("..")
|
||||
.join("share")
|
||||
.join("claw")
|
||||
.join("plugins")
|
||||
.join("bundled");
|
||||
if share_path.exists() {
|
||||
return share_path;
|
||||
}
|
||||
|
||||
// Candidate 2: simple adjacent layout — <exe_dir>/bundled
|
||||
let adjacent = exe_dir.join("bundled");
|
||||
if adjacent.exists() {
|
||||
return adjacent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Candidate 3: dev/source-tree fallback — only if the directory actually exists
|
||||
let dev_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("bundled");
|
||||
if dev_path.exists() {
|
||||
return dev_path;
|
||||
}
|
||||
|
||||
// Default (nothing found): return the canonical install path even if missing,
|
||||
// so callers get an empty plugin list rather than a permission error.
|
||||
if let Ok(exe_path) = std::env::current_exe() {
|
||||
if let Some(exe_dir) = exe_path.parent() {
|
||||
return exe_dir
|
||||
.join("..")
|
||||
.join("share")
|
||||
.join("claw")
|
||||
.join("plugins")
|
||||
.join("bundled");
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort fallback
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("bundled")
|
||||
}
|
||||
|
||||
@@ -1370,12 +1421,24 @@ impl PluginManager {
|
||||
}
|
||||
|
||||
fn sync_bundled_plugins(&self) -> Result<(), PluginError> {
|
||||
let explicit_root = self.config.bundled_root.is_some();
|
||||
let bundled_root = self
|
||||
.config
|
||||
.bundled_root
|
||||
.clone()
|
||||
.unwrap_or_else(Self::bundled_root);
|
||||
let bundled_plugins = discover_plugin_dirs(&bundled_root)?;
|
||||
let bundled_plugins = match discover_plugin_dirs(&bundled_root) {
|
||||
Ok(plugins) => plugins,
|
||||
// When the bundled root is the auto-detected default and the directory is
|
||||
// inaccessible (e.g. a root-owned source tree), treat it as empty rather
|
||||
// than fatally failing. An explicit config override still surfaces errors.
|
||||
Err(PluginError::Io(ref error))
|
||||
if !explicit_root && error.kind() == std::io::ErrorKind::PermissionDenied =>
|
||||
{
|
||||
Vec::new()
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let mut registry = self.load_registry()?;
|
||||
let mut changed = false;
|
||||
let install_root = self.install_root();
|
||||
@@ -2989,17 +3052,139 @@ mod tests {
|
||||
fn default_bundled_root_loads_repo_bundles_as_installed_plugins() {
|
||||
let _guard = env_guard();
|
||||
let config_home = temp_dir("default-bundled-home");
|
||||
let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
||||
|
||||
// Use the repo bundled path explicitly so the test is reliable regardless
|
||||
// of where the binary runs from.
|
||||
let repo_bundled = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("bundled");
|
||||
let mut config = PluginManagerConfig::new(&config_home);
|
||||
config.bundled_root = Some(repo_bundled.clone());
|
||||
let manager = PluginManager::new(config);
|
||||
|
||||
if repo_bundled.exists() {
|
||||
let installed = manager
|
||||
.list_installed_plugins()
|
||||
.expect("bundled plugins should auto-install from repo path");
|
||||
assert!(installed
|
||||
.iter()
|
||||
.any(|plugin| plugin.metadata.id == "example-bundled@bundled"));
|
||||
assert!(installed
|
||||
.iter()
|
||||
.any(|plugin| plugin.metadata.id == "sample-hooks@bundled"));
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(config_home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_bundled_root_is_not_blindly_cargo_manifest_dir() {
|
||||
// Verify that bundled_root() no longer unconditionally returns
|
||||
// CARGO_MANIFEST_DIR/bundled. The returned path must either exist
|
||||
// (a valid runtime or dev location was found) OR differ from the
|
||||
// compile-time source path (a runtime-relative default was chosen).
|
||||
let resolved = PluginManager::bundled_root();
|
||||
let compile_time_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("bundled");
|
||||
|
||||
// If the compile-time path does not exist (e.g. installed binary running
|
||||
// outside the source tree), the resolved path must NOT be the CARGO_MANIFEST_DIR
|
||||
// path, because that would re-introduce the original bug.
|
||||
if !compile_time_path.exists() {
|
||||
assert_ne!(
|
||||
resolved, compile_time_path,
|
||||
"bundled_root() must not fall back to CARGO_MANIFEST_DIR when that path \
|
||||
does not exist — this would regress the root-owned-dir permission bug"
|
||||
);
|
||||
}
|
||||
// Either the path exists (dev scenario) or we got a runtime-relative path.
|
||||
// Either way the function should not panic or return an obviously wrong value.
|
||||
assert!(
|
||||
!resolved.as_os_str().is_empty(),
|
||||
"bundled_root() should return a non-empty path"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_bundled_root_is_used_exactly() {
|
||||
let _guard = env_guard();
|
||||
let config_home = temp_dir("override-bundled-home");
|
||||
let bundled_root = temp_dir("override-bundled-root");
|
||||
write_bundled_plugin(
|
||||
&bundled_root.join("override-plugin"),
|
||||
"override-plugin",
|
||||
"1.0.0",
|
||||
false,
|
||||
);
|
||||
|
||||
let mut config = PluginManagerConfig::new(&config_home);
|
||||
config.bundled_root = Some(bundled_root.clone());
|
||||
let manager = PluginManager::new(config);
|
||||
|
||||
let installed = manager
|
||||
.list_installed_plugins()
|
||||
.expect("default bundled plugins should auto-install");
|
||||
assert!(installed
|
||||
.iter()
|
||||
.any(|plugin| plugin.metadata.id == "example-bundled@bundled"));
|
||||
assert!(installed
|
||||
.iter()
|
||||
.any(|plugin| plugin.metadata.id == "sample-hooks@bundled"));
|
||||
.expect("override bundled_root should be used");
|
||||
assert!(
|
||||
installed
|
||||
.iter()
|
||||
.any(|plugin| plugin.metadata.id == "override-plugin@bundled"),
|
||||
"only the override bundled root should be scanned, not CARGO_MANIFEST_DIR"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(config_home);
|
||||
let _ = fs::remove_dir_all(bundled_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_nonexistent_bundled_root_does_not_fail() {
|
||||
// When bundled_root is explicitly configured to a path that does not exist,
|
||||
// plugin list should succeed with an empty bundled section rather than
|
||||
// returning an error (discover_plugin_dirs treats NotFound as empty).
|
||||
let _guard = env_guard();
|
||||
let config_home = temp_dir("missing-bundled-home");
|
||||
|
||||
let nonexistent = temp_dir("nonexistent-bundled-XXXXXXXX");
|
||||
assert!(
|
||||
!nonexistent.exists(),
|
||||
"test precondition: path must not exist"
|
||||
);
|
||||
|
||||
let mut config = PluginManagerConfig::new(&config_home);
|
||||
config.bundled_root = Some(nonexistent);
|
||||
let manager = PluginManager::new(config);
|
||||
|
||||
// Should succeed with zero bundled plugins, not crash with ENOENT.
|
||||
let result = manager.list_installed_plugins();
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"nonexistent explicit bundled root should not fail: {result:?}"
|
||||
);
|
||||
let installed = result.unwrap();
|
||||
assert!(
|
||||
installed
|
||||
.iter()
|
||||
.all(|p| p.metadata.kind != PluginKind::Bundled),
|
||||
"no bundled plugins should be installed when bundled root path does not exist"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(config_home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_bundled_root_config_uses_auto_detection_without_panic() {
|
||||
// When bundled_root is not set (None), auto-detection runs. The resolved
|
||||
// path should either exist (dev environment) or be a runtime-relative path
|
||||
// that doesn't cause a panic or EACCES crash.
|
||||
let _guard = env_guard();
|
||||
let config_home = temp_dir("auto-detect-bundled-home");
|
||||
|
||||
// No bundled_root set — forces auto-detection in bundled_root().
|
||||
let config = PluginManagerConfig::new(&config_home);
|
||||
let manager = PluginManager::new(config);
|
||||
|
||||
// Should not panic or return a hard IO error.
|
||||
let result = manager.list_installed_plugins();
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"auto-detected bundled root resolution must not fail: {result:?}"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(config_home);
|
||||
}
|
||||
|
||||
@@ -16,5 +16,8 @@ telemetry = { path = "../telemetry" }
|
||||
tokio = { version = "1", features = ["io-std", "io-util", "macros", "process", "rt", "rt-multi-thread", "time"] }
|
||||
walkdir = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -330,20 +330,24 @@ fn prepare_tokio_command(
|
||||
prepare_sandbox_dirs(cwd);
|
||||
}
|
||||
|
||||
if let Some(launcher) = build_linux_sandbox_command(command, cwd, sandbox_status) {
|
||||
let mut prepared = TokioCommand::new(launcher.program);
|
||||
prepared.args(launcher.args);
|
||||
prepared.current_dir(cwd);
|
||||
prepared.envs(launcher.env);
|
||||
return prepared;
|
||||
}
|
||||
let mut prepared =
|
||||
if let Some(launcher) = build_linux_sandbox_command(command, cwd, sandbox_status) {
|
||||
let mut cmd = TokioCommand::new(launcher.program);
|
||||
cmd.args(launcher.args);
|
||||
cmd.envs(launcher.env);
|
||||
cmd
|
||||
} else {
|
||||
let mut cmd = TokioCommand::new("sh");
|
||||
cmd.arg("-lc").arg(command);
|
||||
if sandbox_status.filesystem_active {
|
||||
cmd.env("HOME", cwd.join(".sandbox-home"));
|
||||
cmd.env("TMPDIR", cwd.join(".sandbox-tmp"));
|
||||
}
|
||||
cmd
|
||||
};
|
||||
|
||||
let mut prepared = TokioCommand::new("sh");
|
||||
prepared.arg("-lc").arg(command).current_dir(cwd);
|
||||
if sandbox_status.filesystem_active {
|
||||
prepared.env("HOME", cwd.join(".sandbox-home"));
|
||||
prepared.env("TMPDIR", cwd.join(".sandbox-tmp"));
|
||||
}
|
||||
prepared.current_dir(cwd);
|
||||
prepared.stdin(Stdio::null());
|
||||
prepared
|
||||
}
|
||||
|
||||
@@ -419,6 +423,27 @@ mod tests {
|
||||
assert_eq!(structured[0]["event"], "test.hung");
|
||||
assert_eq!(structured[0]["data"]["provenance"], "bash.timeout");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prevents_stdin_hangs_by_redirecting_to_null() {
|
||||
let output = execute_bash(BashCommandInput {
|
||||
command: String::from("cat"),
|
||||
timeout: Some(2_000),
|
||||
description: None,
|
||||
run_in_background: Some(false),
|
||||
dangerously_disable_sandbox: Some(true),
|
||||
namespace_restrictions: None,
|
||||
isolate_network: None,
|
||||
filesystem_mode: None,
|
||||
allowed_mounts: None,
|
||||
})
|
||||
.expect("bash command should execute cleanly");
|
||||
|
||||
assert!(
|
||||
!output.interrupted,
|
||||
"Command hung and was cut off by the timeout!"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum output bytes before truncation (16 KiB, matching upstream).
|
||||
|
||||
@@ -108,10 +108,18 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
|
||||
.first()
|
||||
.and_then(extract_existing_compacted_summary);
|
||||
let compacted_prefix_len = usize::from(existing_summary.is_some());
|
||||
let raw_keep_from = session
|
||||
.messages
|
||||
.len()
|
||||
.saturating_sub(config.preserve_recent_messages);
|
||||
// When preserve_recent_messages is 0, the caller wants maximum compaction
|
||||
// (no recent messages preserved). Without this guard, saturating_sub(0)
|
||||
// returns messages.len(), which later indexes past the end of the array
|
||||
// at session.messages[k] because keep_from == messages.len() is out of bounds.
|
||||
let raw_keep_from = if config.preserve_recent_messages == 0 {
|
||||
session.messages.len()
|
||||
} else {
|
||||
session
|
||||
.messages
|
||||
.len()
|
||||
.saturating_sub(config.preserve_recent_messages)
|
||||
};
|
||||
// Ensure we do not split a tool-use / tool-result pair at the compaction
|
||||
// boundary. If the first preserved message is a user message whose first
|
||||
// block is a ToolResult, the assistant message with the matching ToolUse
|
||||
@@ -128,7 +136,7 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
|
||||
// is NOT an assistant message that contains a ToolUse block (i.e. the
|
||||
// pair is actually broken at the boundary).
|
||||
loop {
|
||||
if k == 0 || k <= compacted_prefix_len {
|
||||
if k == 0 || k <= compacted_prefix_len || k >= session.messages.len() {
|
||||
break;
|
||||
}
|
||||
let first_preserved = &session.messages[k];
|
||||
@@ -291,12 +299,14 @@ fn merge_compact_summaries(existing_summary: Option<&str>, new_summary: &str) ->
|
||||
|
||||
let mut lines = vec!["<summary>".to_string(), "Conversation summary:".to_string()];
|
||||
|
||||
// Flatten prior highlights directly — do NOT re-nest them under
|
||||
// "- Previously compacted context:" or the nesting compounds with each
|
||||
// compaction cycle, inflating the summary by ~depth * overhead per turn.
|
||||
if !previous_highlights.is_empty() {
|
||||
lines.push("- Previously compacted context:".to_string());
|
||||
lines.extend(
|
||||
previous_highlights
|
||||
.into_iter()
|
||||
.map(|line| format!(" {line}")),
|
||||
.map(|line| format!("- {line}")),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -678,7 +688,9 @@ mod tests {
|
||||
second_session.messages = follow_up_messages;
|
||||
let second = compact_session(&second_session, config);
|
||||
|
||||
assert!(second
|
||||
// "Previously compacted context:" header is intentionally flattened
|
||||
// (no re-nesting) to avoid summary inflation on repeated compaction.
|
||||
assert!(!second
|
||||
.formatted_summary
|
||||
.contains("Previously compacted context:"));
|
||||
assert!(second
|
||||
@@ -693,7 +705,7 @@ mod tests {
|
||||
assert!(matches!(
|
||||
&second.compacted_session.messages[0].blocks[0],
|
||||
ContentBlock::Text { text }
|
||||
if text.contains("Previously compacted context:")
|
||||
if !text.contains("Previously compacted context:")
|
||||
&& text.contains("Newly compacted context:")
|
||||
));
|
||||
assert!(matches!(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,8 @@ enum FieldType {
|
||||
Bool,
|
||||
Object,
|
||||
StringArray,
|
||||
HookArray,
|
||||
RulesImport,
|
||||
Number,
|
||||
}
|
||||
|
||||
@@ -102,6 +104,8 @@ impl FieldType {
|
||||
Self::Bool => "a boolean",
|
||||
Self::Object => "an object",
|
||||
Self::StringArray => "an array of strings",
|
||||
Self::RulesImport => "a string or an array of strings",
|
||||
Self::HookArray => "an array of strings or hook objects",
|
||||
Self::Number => "a number",
|
||||
}
|
||||
}
|
||||
@@ -114,6 +118,13 @@ impl FieldType {
|
||||
Self::StringArray => value
|
||||
.as_array()
|
||||
.is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())),
|
||||
Self::HookArray => true,
|
||||
Self::RulesImport => {
|
||||
value.as_str().is_some()
|
||||
|| value
|
||||
.as_array()
|
||||
.is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some()))
|
||||
}
|
||||
Self::Number => value.as_i64().is_some(),
|
||||
}
|
||||
}
|
||||
@@ -197,20 +208,28 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
|
||||
name: "trustedRoots",
|
||||
expected: FieldType::StringArray,
|
||||
},
|
||||
FieldSpec {
|
||||
name: "provider",
|
||||
expected: FieldType::Object,
|
||||
},
|
||||
FieldSpec {
|
||||
name: "rulesImport",
|
||||
expected: FieldType::RulesImport,
|
||||
},
|
||||
];
|
||||
|
||||
const HOOKS_FIELDS: &[FieldSpec] = &[
|
||||
FieldSpec {
|
||||
name: "PreToolUse",
|
||||
expected: FieldType::StringArray,
|
||||
expected: FieldType::HookArray,
|
||||
},
|
||||
FieldSpec {
|
||||
name: "PostToolUse",
|
||||
expected: FieldType::StringArray,
|
||||
expected: FieldType::HookArray,
|
||||
},
|
||||
FieldSpec {
|
||||
name: "PostToolUseFailure",
|
||||
expected: FieldType::StringArray,
|
||||
expected: FieldType::HookArray,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -223,6 +242,10 @@ const PERMISSIONS_FIELDS: &[FieldSpec] = &[
|
||||
name: "allow",
|
||||
expected: FieldType::StringArray,
|
||||
},
|
||||
FieldSpec {
|
||||
name: "deniedTools",
|
||||
expected: FieldType::StringArray,
|
||||
},
|
||||
FieldSpec {
|
||||
name: "deny",
|
||||
expected: FieldType::StringArray,
|
||||
@@ -310,6 +333,25 @@ const OAUTH_FIELDS: &[FieldSpec] = &[
|
||||
},
|
||||
];
|
||||
|
||||
const PROVIDER_FIELDS: &[FieldSpec] = &[
|
||||
FieldSpec {
|
||||
name: "kind",
|
||||
expected: FieldType::String,
|
||||
},
|
||||
FieldSpec {
|
||||
name: "apiKey",
|
||||
expected: FieldType::String,
|
||||
},
|
||||
FieldSpec {
|
||||
name: "baseUrl",
|
||||
expected: FieldType::String,
|
||||
},
|
||||
FieldSpec {
|
||||
name: "model",
|
||||
expected: FieldType::String,
|
||||
},
|
||||
];
|
||||
|
||||
const DEPRECATED_FIELDS: &[DeprecatedField] = &[
|
||||
DeprecatedField {
|
||||
name: "permissionMode",
|
||||
@@ -379,9 +421,10 @@ 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.
|
||||
// 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.errors.push(ConfigDiagnostic {
|
||||
result.warnings.push(ConfigDiagnostic {
|
||||
path: path_display.to_string(),
|
||||
field: field_path,
|
||||
line: find_key_line(source, key),
|
||||
@@ -393,8 +436,56 @@ 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| {
|
||||
@@ -464,6 +555,7 @@ 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(
|
||||
@@ -501,6 +593,15 @@ pub fn validate_config_file(
|
||||
&path_display,
|
||||
));
|
||||
}
|
||||
if let Some(provider) = object.get("provider").and_then(JsonValue::as_object) {
|
||||
result.merge(validate_object_keys(
|
||||
provider,
|
||||
PROVIDER_FIELDS,
|
||||
"provider",
|
||||
source,
|
||||
&path_display,
|
||||
));
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
@@ -551,10 +652,11 @@ mod tests {
|
||||
let result = validate_config_file(object, source, &test_path());
|
||||
|
||||
// then
|
||||
assert_eq!(result.errors.len(), 1);
|
||||
assert_eq!(result.errors[0].field, "unknownField");
|
||||
assert!(result.errors.is_empty());
|
||||
assert_eq!(result.warnings.len(), 1);
|
||||
assert_eq!(result.warnings[0].field, "unknownField");
|
||||
assert!(matches!(
|
||||
result.errors[0].kind,
|
||||
result.warnings[0].kind,
|
||||
DiagnosticKind::UnknownKey { .. }
|
||||
));
|
||||
}
|
||||
@@ -634,9 +736,10 @@ mod tests {
|
||||
let result = validate_config_file(object, source, &test_path());
|
||||
|
||||
// then
|
||||
assert_eq!(result.errors.len(), 1);
|
||||
assert_eq!(result.errors[0].line, Some(3));
|
||||
assert_eq!(result.errors[0].field, "badKey");
|
||||
assert!(result.errors.is_empty());
|
||||
assert_eq!(result.warnings.len(), 1);
|
||||
assert_eq!(result.warnings[0].line, Some(3));
|
||||
assert_eq!(result.warnings[0].field, "badKey");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -657,7 +760,7 @@ mod tests {
|
||||
#[test]
|
||||
fn validates_nested_hooks_keys() {
|
||||
// given
|
||||
let source = r#"{"hooks": {"PreToolUse": ["cmd"], "BadHook": ["x"]}}"#;
|
||||
let source = r#"{"hooks": {"PreToolUse": [{"hooks":[{"type":"command","command":"cmd"}]}], "BadHook": ["x"]}}"#;
|
||||
let parsed = JsonValue::parse(source).expect("valid json");
|
||||
let object = parsed.as_object().expect("object");
|
||||
|
||||
@@ -665,8 +768,64 @@ mod tests {
|
||||
let result = validate_config_file(object, source, &test_path());
|
||||
|
||||
// then
|
||||
assert!(result.errors.is_empty());
|
||||
assert_eq!(
|
||||
result.warnings.len(),
|
||||
1,
|
||||
"expected only the unknown key warning, got {:?}",
|
||||
result.warnings
|
||||
);
|
||||
assert_eq!(result.warnings[0].field, "hooks.BadHook");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_object_style_hook_entries() {
|
||||
let source = r#"{"hooks":{"PreToolUse":["legacy",{"matcher":"Bash","hooks":[{"type":"command","command":"echo ok"}]}]}}"#;
|
||||
let parsed = JsonValue::parse(source).expect("valid json");
|
||||
let object = parsed.as_object().expect("object");
|
||||
|
||||
let result = validate_config_file(object, source, &test_path());
|
||||
|
||||
assert!(result.errors.is_empty(), "{:?}", result.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_wrong_hook_entry_types_for_partial_runtime_validation_441() {
|
||||
let source = r#"{"hooks":{"PreToolUse":[42]}}"#;
|
||||
let parsed = JsonValue::parse(source).expect("valid json");
|
||||
let object = parsed.as_object().expect("object");
|
||||
|
||||
let result = validate_config_file(object, source, &test_path());
|
||||
|
||||
assert!(result.errors.is_empty(), "{:?}", result.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_rules_import_string_and_array_forms() {
|
||||
for source in [
|
||||
r#"{"rulesImport":"auto"}"#,
|
||||
r#"{"rulesImport":"none"}"#,
|
||||
r#"{"rulesImport":["cursor","copilot"]}"#,
|
||||
] {
|
||||
let parsed = JsonValue::parse(source).expect("valid json");
|
||||
let object = parsed.as_object().expect("object");
|
||||
|
||||
let result = validate_config_file(object, source, &test_path());
|
||||
|
||||
assert!(result.errors.is_empty(), "{source}: {:?}", result.errors);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_rules_import_wrong_type() {
|
||||
let source = r#"{"rulesImport":42}"#;
|
||||
let parsed = JsonValue::parse(source).expect("valid json");
|
||||
let object = parsed.as_object().expect("object");
|
||||
|
||||
let result = validate_config_file(object, source, &test_path());
|
||||
|
||||
assert_eq!(result.errors.len(), 1);
|
||||
assert_eq!(result.errors[0].field, "hooks.BadHook");
|
||||
assert_eq!(result.errors[0].field, "rulesImport");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -680,8 +839,9 @@ mod tests {
|
||||
let result = validate_config_file(object, source, &test_path());
|
||||
|
||||
// then
|
||||
assert_eq!(result.errors.len(), 1);
|
||||
assert_eq!(result.errors[0].field, "permissions.denyAll");
|
||||
assert!(result.errors.is_empty());
|
||||
assert_eq!(result.warnings.len(), 1);
|
||||
assert_eq!(result.warnings[0].field, "permissions.denyAll");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -695,8 +855,9 @@ mod tests {
|
||||
let result = validate_config_file(object, source, &test_path());
|
||||
|
||||
// then
|
||||
assert_eq!(result.errors.len(), 1);
|
||||
assert_eq!(result.errors[0].field, "sandbox.containerMode");
|
||||
assert!(result.errors.is_empty());
|
||||
assert_eq!(result.warnings.len(), 1);
|
||||
assert_eq!(result.warnings[0].field, "sandbox.containerMode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -710,8 +871,9 @@ mod tests {
|
||||
let result = validate_config_file(object, source, &test_path());
|
||||
|
||||
// then
|
||||
assert_eq!(result.errors.len(), 1);
|
||||
assert_eq!(result.errors[0].field, "plugins.autoUpdate");
|
||||
assert!(result.errors.is_empty());
|
||||
assert_eq!(result.warnings.len(), 1);
|
||||
assert_eq!(result.warnings[0].field, "plugins.autoUpdate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -725,8 +887,9 @@ mod tests {
|
||||
let result = validate_config_file(object, source, &test_path());
|
||||
|
||||
// then
|
||||
assert_eq!(result.errors.len(), 1);
|
||||
assert_eq!(result.errors[0].field, "oauth.secret");
|
||||
assert!(result.errors.is_empty());
|
||||
assert_eq!(result.warnings.len(), 1);
|
||||
assert_eq!(result.warnings[0].field, "oauth.secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -734,7 +897,7 @@ mod tests {
|
||||
// given
|
||||
let source = r#"{
|
||||
"model": "opus",
|
||||
"hooks": {"PreToolUse": ["guard"]},
|
||||
"hooks": {"PreToolUse": [{"hooks":[{"type":"command","command":"guard"}]}]},
|
||||
"permissions": {"defaultMode": "plan", "allow": ["Read"]},
|
||||
"mcpServers": {},
|
||||
"sandbox": {"enabled": false}
|
||||
@@ -761,8 +924,9 @@ mod tests {
|
||||
let result = validate_config_file(object, source, &test_path());
|
||||
|
||||
// then
|
||||
assert_eq!(result.errors.len(), 1);
|
||||
match &result.errors[0].kind {
|
||||
assert!(result.errors.is_empty());
|
||||
assert_eq!(result.warnings.len(), 1);
|
||||
match &result.warnings[0].kind {
|
||||
DiagnosticKind::UnknownKey {
|
||||
suggestion: Some(s),
|
||||
} => assert_eq!(s, "model"),
|
||||
@@ -773,7 +937,7 @@ mod tests {
|
||||
#[test]
|
||||
fn format_diagnostics_includes_all_entries() {
|
||||
// given
|
||||
let source = r#"{"permissionMode": "plan", "badKey": 1}"#;
|
||||
let source = r#"{"model": 42, "badKey": 1}"#;
|
||||
let parsed = JsonValue::parse(source).expect("valid json");
|
||||
let object = parsed.as_object().expect("object");
|
||||
let result = validate_config_file(object, source, &test_path());
|
||||
@@ -785,7 +949,7 @@ mod tests {
|
||||
assert!(output.contains("warning:"));
|
||||
assert!(output.contains("error:"));
|
||||
assert!(output.contains("badKey"));
|
||||
assert!(output.contains("permissionMode"));
|
||||
assert!(output.contains("model"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -204,6 +204,13 @@ where
|
||||
self
|
||||
}
|
||||
|
||||
/// Update the auto-compaction threshold after construction. This allows the
|
||||
/// caller to tune the threshold based on runtime information (e.g., the
|
||||
/// server-returned context window size from a 400 error).
|
||||
pub fn set_auto_compaction_input_tokens_threshold(&mut self, threshold: u32) {
|
||||
self.auto_compaction_input_tokens_threshold = threshold;
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_hook_abort_signal(mut self, hook_abort_signal: HookAbortSignal) -> Self {
|
||||
self.hook_abort_signal = hook_abort_signal;
|
||||
@@ -342,6 +349,7 @@ where
|
||||
let mut tool_results = Vec::new();
|
||||
let mut prompt_cache_events = Vec::new();
|
||||
let mut iterations = 0;
|
||||
let mut auto_compaction = None;
|
||||
|
||||
loop {
|
||||
iterations += 1;
|
||||
@@ -397,6 +405,12 @@ where
|
||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||
assistant_messages.push(assistant_message);
|
||||
|
||||
// Run auto-compaction check before next API call, including on the terminal
|
||||
// (no-tool) iteration, to prevent unbounded session growth (#3106).
|
||||
if let Some(compaction) = self.maybe_auto_compact() {
|
||||
auto_compaction = Some(compaction);
|
||||
}
|
||||
|
||||
if pending_tool_uses.is_empty() {
|
||||
break;
|
||||
}
|
||||
@@ -503,8 +517,6 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
let auto_compaction = self.maybe_auto_compact();
|
||||
|
||||
let summary = TurnSummary {
|
||||
assistant_messages,
|
||||
tool_results,
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::time::Duration;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
|
||||
use crate::config::{RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig};
|
||||
use crate::permissions::PermissionOverride;
|
||||
|
||||
const HOOK_PREVIEW_CHAR_LIMIT: usize = 160;
|
||||
@@ -182,7 +182,7 @@ impl HookRunner {
|
||||
) -> HookRunResult {
|
||||
Self::run_commands(
|
||||
HookEvent::PreToolUse,
|
||||
self.config.pre_tool_use(),
|
||||
self.config.pre_tool_use_entries(),
|
||||
tool_name,
|
||||
tool_input,
|
||||
None,
|
||||
@@ -232,7 +232,7 @@ impl HookRunner {
|
||||
) -> HookRunResult {
|
||||
Self::run_commands(
|
||||
HookEvent::PostToolUse,
|
||||
self.config.post_tool_use(),
|
||||
self.config.post_tool_use_entries(),
|
||||
tool_name,
|
||||
tool_input,
|
||||
Some(tool_output),
|
||||
@@ -282,7 +282,7 @@ impl HookRunner {
|
||||
) -> HookRunResult {
|
||||
Self::run_commands(
|
||||
HookEvent::PostToolUseFailure,
|
||||
self.config.post_tool_use_failure(),
|
||||
self.config.post_tool_use_failure_entries(),
|
||||
tool_name,
|
||||
tool_input,
|
||||
Some(tool_error),
|
||||
@@ -312,7 +312,7 @@ impl HookRunner {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run_commands(
|
||||
event: HookEvent,
|
||||
commands: &[String],
|
||||
commands: &[RuntimeHookCommand],
|
||||
tool_name: &str,
|
||||
tool_input: &str,
|
||||
tool_output: Option<&str>,
|
||||
@@ -342,17 +342,21 @@ impl HookRunner {
|
||||
let payload = hook_payload(event, tool_name, tool_input, tool_output, is_error).to_string();
|
||||
let mut result = HookRunResult::allow(Vec::new());
|
||||
|
||||
for command in commands {
|
||||
for command in commands
|
||||
.iter()
|
||||
.filter(|command| command.matches_tool(tool_name))
|
||||
{
|
||||
let command_text = command.command();
|
||||
if let Some(reporter) = reporter.as_deref_mut() {
|
||||
reporter.on_event(&HookProgressEvent::Started {
|
||||
event,
|
||||
tool_name: tool_name.to_string(),
|
||||
command: command.clone(),
|
||||
command: command_text.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
match Self::run_command(
|
||||
command,
|
||||
command_text,
|
||||
event,
|
||||
tool_name,
|
||||
tool_input,
|
||||
@@ -366,7 +370,7 @@ impl HookRunner {
|
||||
reporter.on_event(&HookProgressEvent::Completed {
|
||||
event,
|
||||
tool_name: tool_name.to_string(),
|
||||
command: command.clone(),
|
||||
command: command_text.to_string(),
|
||||
});
|
||||
}
|
||||
merge_parsed_hook_output(&mut result, parsed);
|
||||
@@ -376,7 +380,7 @@ impl HookRunner {
|
||||
reporter.on_event(&HookProgressEvent::Completed {
|
||||
event,
|
||||
tool_name: tool_name.to_string(),
|
||||
command: command.clone(),
|
||||
command: command_text.to_string(),
|
||||
});
|
||||
}
|
||||
merge_parsed_hook_output(&mut result, parsed);
|
||||
@@ -388,7 +392,7 @@ impl HookRunner {
|
||||
reporter.on_event(&HookProgressEvent::Completed {
|
||||
event,
|
||||
tool_name: tool_name.to_string(),
|
||||
command: command.clone(),
|
||||
command: command_text.to_string(),
|
||||
});
|
||||
}
|
||||
merge_parsed_hook_output(&mut result, parsed);
|
||||
@@ -400,7 +404,7 @@ impl HookRunner {
|
||||
reporter.on_event(&HookProgressEvent::Cancelled {
|
||||
event,
|
||||
tool_name: tool_name.to_string(),
|
||||
command: command.clone(),
|
||||
command: command_text.to_string(),
|
||||
});
|
||||
}
|
||||
result.cancelled = true;
|
||||
@@ -737,7 +741,7 @@ fn format_hook_failure(command: &str, code: i32, stdout: Option<&str>, stderr: &
|
||||
|
||||
fn shell_command(command: &str) -> CommandWithStdin {
|
||||
#[cfg(windows)]
|
||||
let mut command_builder = {
|
||||
let command_builder = {
|
||||
let mut command_builder = Command::new("cmd");
|
||||
command_builder.arg("/C").arg(command);
|
||||
CommandWithStdin::new(command_builder)
|
||||
@@ -825,7 +829,7 @@ mod tests {
|
||||
HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult,
|
||||
HookRunner,
|
||||
};
|
||||
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
|
||||
use crate::config::{RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig};
|
||||
use crate::permissions::PermissionOverride;
|
||||
|
||||
struct RecordingReporter {
|
||||
@@ -851,6 +855,37 @@ mod tests {
|
||||
assert_eq!(result, HookRunResult::allow(vec!["pre ok".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_style_hook_matchers_filter_runtime_execution() {
|
||||
let runner = HookRunner::new(RuntimeHookConfig::from_hook_commands(
|
||||
vec![
|
||||
RuntimeHookCommand::new(shell_snippet("printf 'legacy'")),
|
||||
RuntimeHookCommand::with_matcher(
|
||||
shell_snippet("printf 'bash only'"),
|
||||
Some("Bash".to_string()),
|
||||
),
|
||||
RuntimeHookCommand::with_matcher(
|
||||
shell_snippet("printf 'read only'"),
|
||||
Some("Read*".to_string()),
|
||||
),
|
||||
],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
));
|
||||
|
||||
let read_result = runner.run_pre_tool_use("ReadFile", r#"{"path":"README.md"}"#);
|
||||
let bash_result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
|
||||
|
||||
assert_eq!(
|
||||
read_result,
|
||||
HookRunResult::allow(vec!["legacy".to_string(), "read only".to_string()])
|
||||
);
|
||||
assert_eq!(
|
||||
bash_result,
|
||||
HookRunResult::allow(vec!["legacy".to_string(), "bash only".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_exit_code_two() {
|
||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
||||
|
||||
@@ -39,6 +39,7 @@ mod report_schema;
|
||||
pub mod sandbox;
|
||||
mod session;
|
||||
pub mod session_control;
|
||||
pub mod trident;
|
||||
pub use session_control::SessionStore;
|
||||
mod sse;
|
||||
pub mod stale_base;
|
||||
@@ -64,12 +65,14 @@ pub use compact::{
|
||||
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
|
||||
};
|
||||
pub use config::{
|
||||
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpConfigCollection,
|
||||
McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
|
||||
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
|
||||
ProviderFallbackConfig, ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig,
|
||||
RuntimeHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig,
|
||||
CLAW_SETTINGS_SCHEMA_NAME,
|
||||
suppress_config_warnings_for_json_mode, ApiTimeoutConfig, ConfigEntry, ConfigError,
|
||||
ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource,
|
||||
McpConfigCollection, McpInvalidServerConfig, McpManagedProxyServerConfig, McpOAuthConfig,
|
||||
McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
|
||||
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
|
||||
RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig,
|
||||
RuntimeInvalidHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig,
|
||||
ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
||||
};
|
||||
pub use config_validate::{
|
||||
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,
|
||||
@@ -140,8 +143,9 @@ pub use policy_engine::{
|
||||
PolicyEvaluation, PolicyRule, ReconcileReason, ReviewStatus,
|
||||
};
|
||||
pub use prompt::{
|
||||
load_system_prompt, prepend_bullets, ContextFile, ModelFamilyIdentity, ProjectContext,
|
||||
PromptBuildError, SystemPromptBuilder, FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||
load_system_prompt, load_system_prompt_with_context, prepend_bullets, ContextFile,
|
||||
ModelFamilyIdentity, ProjectContext, PromptBuildError, SystemPromptBuilder,
|
||||
FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||
};
|
||||
pub use recovery_recipes::{
|
||||
attempt_recovery, recipe_for, EscalationPolicy, FailureScenario, RecoveryAttemptState,
|
||||
|
||||
@@ -173,32 +173,112 @@ impl PermissionEnforcer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple workspace boundary check via string prefix.
|
||||
/// Workspace boundary check.
|
||||
///
|
||||
/// Resolves `.` and `..` components lexically *before* comparing against the
|
||||
/// workspace root, so that traversal sequences like `/workspace/../../etc`
|
||||
/// cannot escape the sandbox via a naive string prefix match. Normalization is
|
||||
/// lexical (it does not touch the filesystem) because the target path may not
|
||||
/// exist yet on a write, and we must not depend on CWD.
|
||||
fn is_within_workspace(path: &str, workspace_root: &str) -> bool {
|
||||
let normalized = if path.starts_with('/') {
|
||||
let combined = if path.starts_with('/') {
|
||||
path.to_owned()
|
||||
} else {
|
||||
format!("{workspace_root}/{path}")
|
||||
};
|
||||
|
||||
let root = if workspace_root.ends_with('/') {
|
||||
workspace_root.to_owned()
|
||||
let normalized = lexically_normalize(&combined);
|
||||
let root = lexically_normalize(workspace_root);
|
||||
let root_with_slash = if root.ends_with('/') {
|
||||
root.clone()
|
||||
} else {
|
||||
format!("{workspace_root}/")
|
||||
format!("{root}/")
|
||||
};
|
||||
|
||||
normalized.starts_with(&root) || normalized == workspace_root.trim_end_matches('/')
|
||||
normalized == root || normalized.starts_with(&root_with_slash)
|
||||
}
|
||||
|
||||
/// Collapse `.` and `..` segments without consulting the filesystem.
|
||||
/// `..` that would climb above an absolute root is clamped at `/`, so the
|
||||
/// result can never be a prefix-match for a deeper workspace root.
|
||||
fn lexically_normalize(path: &str) -> String {
|
||||
let is_absolute = path.starts_with('/');
|
||||
let mut stack: Vec<&str> = Vec::new();
|
||||
for component in path.split('/') {
|
||||
match component {
|
||||
"" | "." => {}
|
||||
".." => {
|
||||
stack.pop();
|
||||
}
|
||||
other => stack.push(other),
|
||||
}
|
||||
}
|
||||
let joined = stack.join("/");
|
||||
if is_absolute {
|
||||
format!("/{joined}")
|
||||
} else {
|
||||
joined
|
||||
}
|
||||
}
|
||||
|
||||
/// Conservative heuristic: is this bash command read-only?
|
||||
///
|
||||
/// Hardening notes:
|
||||
/// - Any shell metacharacter that could chain, substitute, pipe, or redirect
|
||||
/// into a state-changing command rejects the whole line. This blocks
|
||||
/// `cat x; rm -rf y`, `cat x | sh`, `$(...)`, backticks, redirects, and
|
||||
/// subshells regardless of the leading token.
|
||||
/// - Language interpreters (`python`, `node`, `ruby`) and build drivers
|
||||
/// (`cargo`, `rustc`) are NOT read-only: they execute arbitrary code, so they
|
||||
/// are excluded from the allow-list.
|
||||
/// - `git` is allowed only for a known set of non-mutating subcommands.
|
||||
/// - `find` is rejected when it carries an action that can execute or delete.
|
||||
///
|
||||
/// Residual known gaps (documented, not yet closed): `sed`'s `w`/`e` script
|
||||
/// commands and `awk`'s `system()` can still mutate — these require quoting or
|
||||
/// metacharacters that the checks above usually catch, but a dedicated parser
|
||||
/// would be more robust. Tracked as follow-up.
|
||||
fn is_read_only_command(command: &str) -> bool {
|
||||
let first_token = command
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
// Shell metacharacters that enable command chaining, substitution,
|
||||
// piping, redirection, or subshells. Presence of any of these means we
|
||||
// cannot reason about the command from its leading token alone.
|
||||
const SHELL_METACHARS: &[char] = &[';', '|', '&', '$', '`', '>', '<', '(', ')', '{', '}', '\n'];
|
||||
if command.contains(SHELL_METACHARS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut tokens = command.split_whitespace();
|
||||
let first_token = tokens.next().unwrap_or("").rsplit('/').next().unwrap_or("");
|
||||
|
||||
// `git` is only read-only for a curated set of subcommands.
|
||||
if first_token == "git" {
|
||||
let subcommand = tokens.next().unwrap_or("");
|
||||
return matches!(
|
||||
subcommand,
|
||||
"status"
|
||||
| "log"
|
||||
| "diff"
|
||||
| "show"
|
||||
| "branch"
|
||||
| "rev-parse"
|
||||
| "ls-files"
|
||||
| "blame"
|
||||
| "describe"
|
||||
| "tag"
|
||||
| "remote"
|
||||
);
|
||||
}
|
||||
|
||||
// `find` can execute or delete via actions; reject those forms.
|
||||
if first_token == "find"
|
||||
&& (command.contains("-exec")
|
||||
|| command.contains("-execdir")
|
||||
|| command.contains("-delete")
|
||||
|| command.contains("-ok")
|
||||
|| command.contains("-fprintf"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
matches!(
|
||||
first_token,
|
||||
@@ -237,8 +317,6 @@ fn is_read_only_command(command: &str) -> bool {
|
||||
| "tr"
|
||||
| "cut"
|
||||
| "paste"
|
||||
| "tee"
|
||||
| "xargs"
|
||||
| "test"
|
||||
| "true"
|
||||
| "false"
|
||||
@@ -257,18 +335,8 @@ fn is_read_only_command(command: &str) -> bool {
|
||||
| "tree"
|
||||
| "jq"
|
||||
| "yq"
|
||||
| "python3"
|
||||
| "python"
|
||||
| "node"
|
||||
| "ruby"
|
||||
| "cargo"
|
||||
| "rustc"
|
||||
| "git"
|
||||
| "gh"
|
||||
) && !command.contains("-i ")
|
||||
&& !command.contains("--in-place")
|
||||
&& !command.contains(" > ")
|
||||
&& !command.contains(" >> ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -375,6 +443,91 @@ mod tests {
|
||||
assert!(!is_read_only_command("sed -i 's/a/b/' file"));
|
||||
}
|
||||
|
||||
// --- Hardening regression tests (#2: read-only bypasses) ---
|
||||
|
||||
#[test]
|
||||
fn read_only_rejects_command_chaining() {
|
||||
// A leading read-only token must not launder a trailing destructive one.
|
||||
assert!(!is_read_only_command("cat foo; rm -rf bar"));
|
||||
assert!(!is_read_only_command("cat foo && rm -rf bar"));
|
||||
assert!(!is_read_only_command("ls || rm bar"));
|
||||
assert!(!is_read_only_command("cat foo | sh"));
|
||||
assert!(!is_read_only_command("echo `rm bar`"));
|
||||
assert!(!is_read_only_command("echo $(rm bar)"));
|
||||
assert!(!is_read_only_command("echo x>file")); // redirect without spaces
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_rejects_interpreters_and_build_drivers() {
|
||||
// These execute arbitrary code and are no longer read-only.
|
||||
assert!(!is_read_only_command(
|
||||
"python3 -c \"import os; os.system('rm -rf .')\""
|
||||
));
|
||||
assert!(!is_read_only_command("python script.py"));
|
||||
assert!(!is_read_only_command("node app.js"));
|
||||
assert!(!is_read_only_command("ruby x.rb"));
|
||||
assert!(!is_read_only_command("cargo run"));
|
||||
assert!(!is_read_only_command("rustc evil.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_gates_git_subcommands() {
|
||||
// Read-only git subcommands remain allowed...
|
||||
assert!(is_read_only_command("git status"));
|
||||
assert!(is_read_only_command("git diff HEAD~1"));
|
||||
assert!(is_read_only_command("git show abc123"));
|
||||
// ...but mutating/exfiltrating ones are rejected.
|
||||
assert!(!is_read_only_command("git commit -m x"));
|
||||
assert!(!is_read_only_command("git push origin main"));
|
||||
assert!(!is_read_only_command("git reset --hard"));
|
||||
assert!(!is_read_only_command("git clean -fd"));
|
||||
assert!(!is_read_only_command("git config user.email a@b.c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_rejects_find_actions() {
|
||||
assert!(is_read_only_command("find . -name Cargo.toml"));
|
||||
assert!(!is_read_only_command("find . -delete"));
|
||||
// -exec uses braces/semicolon which also trip the metachar guard,
|
||||
// but the explicit action check is the primary defense.
|
||||
assert!(!is_read_only_command("find . -execdir rm rf"));
|
||||
}
|
||||
|
||||
// --- Hardening regression tests (#1: workspace path traversal) ---
|
||||
|
||||
#[test]
|
||||
fn workspace_rejects_parent_traversal() {
|
||||
assert!(!is_within_workspace(
|
||||
"/workspace/../etc/passwd",
|
||||
"/workspace"
|
||||
));
|
||||
assert!(!is_within_workspace(
|
||||
"/workspace/../../etc/crontab",
|
||||
"/workspace"
|
||||
));
|
||||
assert!(!is_within_workspace("../etc/passwd", "/workspace"));
|
||||
assert!(!is_within_workspace(
|
||||
"/workspace/sub/../../outside",
|
||||
"/workspace"
|
||||
));
|
||||
// Legitimate paths still resolve inside.
|
||||
assert!(is_within_workspace(
|
||||
"/workspace/./src/main.rs",
|
||||
"/workspace"
|
||||
));
|
||||
assert!(is_within_workspace(
|
||||
"/workspace/src/../src/main.rs",
|
||||
"/workspace"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_write_denies_traversal_escape() {
|
||||
let enforcer = make_enforcer(PermissionMode::WorkspaceWrite);
|
||||
let result = enforcer.check_file_write("/workspace/../../etc/crontab", "/workspace");
|
||||
assert!(matches!(result, EnforcementResult::Denied { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_mode_returns_policy_mode() {
|
||||
// given
|
||||
|
||||
@@ -102,6 +102,10 @@ pub struct PermissionPolicy {
|
||||
allow_rules: Vec<PermissionRule>,
|
||||
deny_rules: Vec<PermissionRule>,
|
||||
ask_rules: Vec<PermissionRule>,
|
||||
/// #159: simple tool-name denials. Tools in this list are unconditionally
|
||||
/// denied regardless of permission mode, checked before the rule-based
|
||||
/// deny/allow/ask evaluation.
|
||||
denied_tools: Vec<String>,
|
||||
}
|
||||
|
||||
impl PermissionPolicy {
|
||||
@@ -113,6 +117,7 @@ impl PermissionPolicy {
|
||||
allow_rules: Vec::new(),
|
||||
deny_rules: Vec::new(),
|
||||
ask_rules: Vec::new(),
|
||||
denied_tools: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +149,12 @@ impl PermissionPolicy {
|
||||
.iter()
|
||||
.map(|rule| PermissionRule::parse(rule))
|
||||
.collect();
|
||||
// #94: normalize denied tool names to lowercase to match runtime convention
|
||||
self.denied_tools = config
|
||||
.denied_tools()
|
||||
.iter()
|
||||
.map(|t| t.to_lowercase())
|
||||
.collect();
|
||||
self
|
||||
}
|
||||
|
||||
@@ -179,6 +190,15 @@ impl PermissionPolicy {
|
||||
context: &PermissionContext,
|
||||
prompter: Option<&mut dyn PermissionPrompter>,
|
||||
) -> PermissionOutcome {
|
||||
// #159: check denied_tools before rule-based evaluation. Tools listed
|
||||
// in the denied_tools config are unconditionally denied regardless of
|
||||
// permission mode.
|
||||
if self.denied_tools.iter().any(|t| t == tool_name) {
|
||||
return PermissionOutcome::Deny {
|
||||
reason: format!("tool '{tool_name}' has been denied by denied_tools configuration"),
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(rule) = Self::find_matching_rule(&self.deny_rules, tool_name, input) {
|
||||
return PermissionOutcome::Deny {
|
||||
reason: format!(
|
||||
@@ -360,7 +380,8 @@ impl PermissionRule {
|
||||
let matcher = parse_rule_matcher(content);
|
||||
return Self {
|
||||
raw: trimmed.to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
// #94: normalize tool name to lowercase to match runtime convention
|
||||
tool_name: tool_name.to_lowercase(),
|
||||
matcher,
|
||||
};
|
||||
}
|
||||
@@ -369,7 +390,8 @@ impl PermissionRule {
|
||||
|
||||
Self {
|
||||
raw: trimmed.to_string(),
|
||||
tool_name: trimmed.to_string(),
|
||||
// #94: normalize tool name to lowercase to match runtime convention
|
||||
tool_name: trimmed.to_lowercase(),
|
||||
matcher: PermissionRuleMatcher::Any,
|
||||
}
|
||||
}
|
||||
@@ -571,6 +593,7 @@ mod tests {
|
||||
vec!["bash(git:*)".to_string()],
|
||||
vec!["bash(rm -rf:*)".to_string()],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
|
||||
.with_tool_requirement("bash", PermissionMode::DangerFullAccess)
|
||||
@@ -586,12 +609,39 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denied_tools_denies_listed_tools_unconditionally() {
|
||||
let rules = RuntimePermissionRuleConfig::new(
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
vec!["bash".to_string(), "write_file".to_string()],
|
||||
);
|
||||
let policy = PermissionPolicy::new(PermissionMode::Allow).with_permission_rules(&rules);
|
||||
|
||||
let result = policy.authorize("bash", "echo hello", None);
|
||||
assert!(matches!(
|
||||
result,
|
||||
PermissionOutcome::Deny { reason } if reason.contains("denied_tools")
|
||||
));
|
||||
|
||||
let result = policy.authorize("write_file", "{}", None);
|
||||
assert!(matches!(
|
||||
result,
|
||||
PermissionOutcome::Deny { reason } if reason.contains("denied_tools")
|
||||
));
|
||||
|
||||
let result = policy.authorize("read_file", "{}", None);
|
||||
assert_eq!(result, PermissionOutcome::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_rules_force_prompt_even_when_mode_allows() {
|
||||
let rules = RuntimePermissionRuleConfig::new(
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
vec!["bash(git:*)".to_string()],
|
||||
Vec::new(),
|
||||
);
|
||||
let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess)
|
||||
.with_tool_requirement("bash", PermissionMode::DangerFullAccess)
|
||||
@@ -617,6 +667,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
vec!["bash(git:*)".to_string()],
|
||||
Vec::new(),
|
||||
);
|
||||
let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
|
||||
.with_tool_requirement("bash", PermissionMode::DangerFullAccess)
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::config::{ConfigError, ConfigLoader, RuntimeConfig};
|
||||
use crate::config::{ConfigError, ConfigLoader, RulesImportConfig, RuntimeConfig};
|
||||
use crate::git_context::GitContext;
|
||||
|
||||
/// Errors raised while assembling the final system prompt.
|
||||
@@ -42,6 +42,7 @@ pub const SYSTEM_PROMPT_DYNAMIC_BOUNDARY: &str = "__SYSTEM_PROMPT_DYNAMIC_BOUNDA
|
||||
pub const FRONTIER_MODEL_NAME: &str = "Claude Opus 4.6";
|
||||
const MAX_INSTRUCTION_FILE_CHARS: usize = 4_000;
|
||||
const MAX_TOTAL_INSTRUCTION_CHARS: usize = 12_000;
|
||||
const MAX_GIT_DIFF_CHARS: usize = 50_000;
|
||||
|
||||
/// Neutral identity for the model family line in generated prompts.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
@@ -68,6 +69,18 @@ pub struct ContextFile {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
impl ContextFile {
|
||||
#[must_use]
|
||||
pub fn source(&self) -> &'static str {
|
||||
instruction_file_source(&self.path)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn char_count(&self) -> usize {
|
||||
self.content.chars().count()
|
||||
}
|
||||
}
|
||||
|
||||
/// Project-local context injected into the rendered system prompt.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ProjectContext {
|
||||
@@ -85,7 +98,24 @@ impl ProjectContext {
|
||||
current_date: impl Into<String>,
|
||||
) -> std::io::Result<Self> {
|
||||
let cwd = cwd.into();
|
||||
let instruction_files = discover_instruction_files(&cwd)?;
|
||||
let instruction_files = discover_instruction_files(&cwd, &RulesImportConfig::default())?;
|
||||
Ok(Self {
|
||||
cwd,
|
||||
current_date: current_date.into(),
|
||||
git_status: None,
|
||||
git_diff: None,
|
||||
git_context: None,
|
||||
instruction_files,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn discover_with_rules_import(
|
||||
cwd: impl Into<PathBuf>,
|
||||
current_date: impl Into<String>,
|
||||
rules_import: &RulesImportConfig,
|
||||
) -> std::io::Result<Self> {
|
||||
let cwd = cwd.into();
|
||||
let instruction_files = discover_instruction_files(&cwd, rules_import)?;
|
||||
Ok(Self {
|
||||
cwd,
|
||||
current_date: current_date.into(),
|
||||
@@ -108,6 +138,18 @@ impl ProjectContext {
|
||||
}
|
||||
}
|
||||
|
||||
fn discover_with_git_and_rules_import(
|
||||
cwd: impl Into<PathBuf>,
|
||||
current_date: impl Into<String>,
|
||||
rules_import: &RulesImportConfig,
|
||||
) -> std::io::Result<ProjectContext> {
|
||||
let mut context = ProjectContext::discover_with_rules_import(cwd, current_date, rules_import)?;
|
||||
context.git_status = read_git_status(&context.cwd);
|
||||
context.git_diff = read_git_diff(&context.cwd);
|
||||
context.git_context = GitContext::detect(&context.cwd);
|
||||
Ok(context)
|
||||
}
|
||||
|
||||
/// Builder for the runtime system prompt and dynamic environment sections.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct SystemPromptBuilder {
|
||||
@@ -226,30 +268,81 @@ pub fn prepend_bullets(items: Vec<String>) -> Vec<String> {
|
||||
items.into_iter().map(|item| format!(" - {item}")).collect()
|
||||
}
|
||||
|
||||
fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
|
||||
let mut directories = Vec::new();
|
||||
let mut cursor = Some(cwd);
|
||||
while let Some(dir) = cursor {
|
||||
directories.push(dir.to_path_buf());
|
||||
cursor = dir.parent();
|
||||
fn instruction_file_source(path: &Path) -> &'static str {
|
||||
let file_name = path.file_name().and_then(|name| name.to_str());
|
||||
let parent_name = path
|
||||
.parent()
|
||||
.and_then(|parent| parent.file_name())
|
||||
.and_then(|name| name.to_str());
|
||||
|
||||
match (parent_name, file_name) {
|
||||
(Some(".claw"), Some("CLAUDE.md")) => "claw_claude_md",
|
||||
(Some(".claude"), Some("CLAUDE.md")) => "claude_claude_md",
|
||||
(_, Some("CLAUDE.md")) => "claude_md",
|
||||
(_, Some("CLAW.md")) => "claw_md",
|
||||
(_, Some("AGENTS.md")) => "agents_md",
|
||||
(_, Some("CLAUDE.local.md")) => "claude_local_md",
|
||||
(Some(".claw"), Some("instructions.md")) => "claw_instructions",
|
||||
_ => "rule_file",
|
||||
}
|
||||
}
|
||||
fn discover_instruction_files(
|
||||
cwd: &Path,
|
||||
rules_import: &RulesImportConfig,
|
||||
) -> std::io::Result<Vec<ContextFile>> {
|
||||
let mut directories = instruction_discovery_dirs(cwd);
|
||||
directories.reverse();
|
||||
|
||||
let mut files = Vec::new();
|
||||
for dir in directories {
|
||||
for candidate in [
|
||||
dir.join("CLAUDE.md"),
|
||||
dir.join("CLAW.md"),
|
||||
dir.join("AGENTS.md"),
|
||||
dir.join("CLAUDE.local.md"),
|
||||
dir.join(".claw").join("CLAUDE.md"),
|
||||
dir.join(".claude").join("CLAUDE.md"),
|
||||
dir.join(".claw").join("instructions.md"),
|
||||
] {
|
||||
push_context_file(&mut files, candidate)?;
|
||||
}
|
||||
push_rules_dir(&mut files, dir.join(".claw").join("rules"))?;
|
||||
push_rules_dir(&mut files, dir.join(".claw").join("rules.local"))?;
|
||||
push_framework_imports(&mut files, &dir, rules_import)?
|
||||
}
|
||||
Ok(dedupe_instruction_files(files))
|
||||
}
|
||||
|
||||
fn instruction_discovery_dirs(cwd: &Path) -> Vec<PathBuf> {
|
||||
let boundary = nearest_git_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
|
||||
let mut directories = Vec::new();
|
||||
let mut cursor = Some(cwd);
|
||||
while let Some(dir) = cursor {
|
||||
directories.push(dir.to_path_buf());
|
||||
if dir == boundary {
|
||||
break;
|
||||
}
|
||||
cursor = dir.parent();
|
||||
}
|
||||
directories
|
||||
}
|
||||
|
||||
fn nearest_git_root(cwd: &Path) -> Option<PathBuf> {
|
||||
let mut cursor = Some(cwd);
|
||||
while let Some(dir) = cursor {
|
||||
let git_marker = dir.join(".git");
|
||||
if git_marker.is_dir() || git_marker.is_file() {
|
||||
return Some(dir.to_path_buf());
|
||||
}
|
||||
cursor = dir.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Result<()> {
|
||||
if path.is_dir() {
|
||||
return Ok(());
|
||||
}
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(content) if !content.trim().is_empty() => {
|
||||
files.push(ContextFile { path, content });
|
||||
@@ -261,6 +354,64 @@ fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Re
|
||||
}
|
||||
}
|
||||
|
||||
fn push_rules_dir(files: &mut Vec<ContextFile>, dir: PathBuf) -> std::io::Result<()> {
|
||||
if dir.is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
let entries = match fs::read_dir(&dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let mut paths = entries
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.is_file() && is_supported_rule_file(path))
|
||||
.collect::<Vec<_>>();
|
||||
paths.sort();
|
||||
for path in paths {
|
||||
push_context_file(files, path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_supported_rule_file(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| {
|
||||
matches!(
|
||||
extension.to_ascii_lowercase().as_str(),
|
||||
"md" | "txt" | "mdc"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn push_framework_imports(
|
||||
files: &mut Vec<ContextFile>,
|
||||
dir: &Path,
|
||||
rules_import: &RulesImportConfig,
|
||||
) -> std::io::Result<()> {
|
||||
if rules_import.should_import("cursor") {
|
||||
push_context_file(files, dir.join(".cursorrules"))?;
|
||||
push_rules_dir(files, dir.join(".cursor").join("rules"))?;
|
||||
}
|
||||
if rules_import.should_import("copilot") {
|
||||
push_context_file(files, dir.join(".github").join("copilot-instructions.md"))?;
|
||||
}
|
||||
if rules_import.should_import("windsurf") {
|
||||
push_context_file(files, dir.join(".windsurfrules"))?;
|
||||
push_rules_dir(files, dir.join(".windsurfrules"))?;
|
||||
}
|
||||
if rules_import.should_import("plandex") {
|
||||
push_context_file(files, dir.join(".plandex").join("instructions.md"))?;
|
||||
}
|
||||
if rules_import.should_import("crush") {
|
||||
push_context_file(files, dir.join(".crush").join("CLAUDE.md"))?;
|
||||
push_rules_dir(files, dir.join(".crush").join("rules"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_git_status(cwd: &Path) -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["--no-optional-locks", "status", "--short", "--branch"])
|
||||
@@ -295,10 +446,22 @@ fn read_git_diff(cwd: &Path) -> Option<String> {
|
||||
if sections.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(sections.join("\n\n"))
|
||||
Some(truncate_diff(sections.join("\n\n")))
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_diff(mut diff: String) -> String {
|
||||
if diff.len() > MAX_GIT_DIFF_CHARS {
|
||||
let mut end = MAX_GIT_DIFF_CHARS;
|
||||
while !diff.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
diff.truncate(end);
|
||||
diff.push_str("\n\n... [diff truncated — too large for system prompt]");
|
||||
}
|
||||
diff
|
||||
}
|
||||
|
||||
fn read_git_output(cwd: &Path, args: &[&str]) -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
@@ -319,7 +482,7 @@ fn render_project_context(project_context: &ProjectContext) -> String {
|
||||
];
|
||||
if !project_context.instruction_files.is_empty() {
|
||||
bullets.push(format!(
|
||||
"Claude instruction files discovered: {}.",
|
||||
"Project instruction files discovered: {}.",
|
||||
project_context.instruction_files.len()
|
||||
));
|
||||
}
|
||||
@@ -354,7 +517,7 @@ fn render_project_context(project_context: &ProjectContext) -> String {
|
||||
}
|
||||
|
||||
fn render_instruction_files(files: &[ContextFile]) -> String {
|
||||
let mut sections = vec!["# Claude instructions".to_string()];
|
||||
let mut sections = vec!["# Project instructions".to_string()];
|
||||
let mut remaining_chars = MAX_TOTAL_INSTRUCTION_CHARS;
|
||||
for file in files {
|
||||
if remaining_chars == 0 {
|
||||
@@ -463,14 +626,30 @@ pub fn load_system_prompt(
|
||||
model_family: ModelFamilyIdentity,
|
||||
) -> Result<Vec<String>, PromptBuildError> {
|
||||
let cwd = cwd.into();
|
||||
let project_context = ProjectContext::discover_with_git(&cwd, current_date.into())?;
|
||||
let (sections, _) =
|
||||
load_system_prompt_with_context(cwd, current_date, os_name, os_version, model_family)?;
|
||||
Ok(sections)
|
||||
}
|
||||
|
||||
/// Loads config and project context, then renders the system prompt text plus metadata.
|
||||
pub fn load_system_prompt_with_context(
|
||||
cwd: impl Into<PathBuf>,
|
||||
current_date: impl Into<String>,
|
||||
os_name: impl Into<String>,
|
||||
os_version: impl Into<String>,
|
||||
model_family: ModelFamilyIdentity,
|
||||
) -> Result<(Vec<String>, ProjectContext), PromptBuildError> {
|
||||
let cwd = cwd.into();
|
||||
let config = ConfigLoader::default_for(&cwd).load()?;
|
||||
Ok(SystemPromptBuilder::new()
|
||||
let project_context =
|
||||
discover_with_git_and_rules_import(&cwd, current_date.into(), config.rules_import())?;
|
||||
let sections = SystemPromptBuilder::new()
|
||||
.with_os(os_name, os_version)
|
||||
.with_model_family(model_family)
|
||||
.with_project_context(project_context)
|
||||
.with_project_context(project_context.clone())
|
||||
.with_runtime_config(config)
|
||||
.build())
|
||||
.build();
|
||||
Ok((sections, project_context))
|
||||
}
|
||||
|
||||
fn render_config_section(config: &RuntimeConfig) -> String {
|
||||
@@ -549,9 +728,9 @@ fn get_actions_section() -> String {
|
||||
mod tests {
|
||||
use super::{
|
||||
collapse_blank_lines, display_context_path, normalize_instruction_content,
|
||||
render_instruction_content, render_instruction_files, truncate_instruction_content,
|
||||
ContextFile, ModelFamilyIdentity, ProjectContext, SystemPromptBuilder,
|
||||
SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||
render_instruction_content, render_instruction_files, truncate_diff,
|
||||
truncate_instruction_content, ContextFile, ModelFamilyIdentity, ProjectContext,
|
||||
SystemPromptBuilder, MAX_GIT_DIFF_CHARS, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||
};
|
||||
use crate::config::ConfigLoader;
|
||||
use std::fs;
|
||||
@@ -577,11 +756,84 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_claw_rules_files_in_sorted_order() {
|
||||
let root = temp_dir();
|
||||
let rules = root.join(".claw").join("rules");
|
||||
let local_rules = root.join(".claw").join("rules.local");
|
||||
fs::create_dir_all(&rules).expect("rules dir");
|
||||
fs::create_dir_all(&local_rules).expect("local rules dir");
|
||||
fs::write(rules.join("b.txt"), "b rule").expect("write b rule");
|
||||
fs::write(rules.join("a.md"), "a rule").expect("write a rule");
|
||||
fs::write(rules.join("ignored.json"), "ignored rule").expect("write ignored");
|
||||
fs::write(local_rules.join("c.mdc"), "c local rule").expect("write local rule");
|
||||
|
||||
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
|
||||
let contents = context
|
||||
.instruction_files
|
||||
.iter()
|
||||
.map(|file| file.content.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(contents, vec!["a rule", "b rule", "c local rule"]);
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_import_none_suppresses_external_framework_rules() {
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(root.join(".claw").join("rules")).expect("rules dir");
|
||||
fs::write(
|
||||
root.join(".claw").join("rules").join("project.md"),
|
||||
"claw rule",
|
||||
)
|
||||
.expect("write claw rule");
|
||||
fs::write(root.join(".cursorrules"), "cursor rule").expect("write cursor rule");
|
||||
|
||||
let context = ProjectContext::discover_with_rules_import(
|
||||
&root,
|
||||
"2026-03-31",
|
||||
&crate::config::RulesImportConfig::None,
|
||||
)
|
||||
.expect("context should load");
|
||||
let rendered = render_instruction_files(&context.instruction_files);
|
||||
|
||||
assert!(rendered.contains("claw rule"));
|
||||
assert!(!rendered.contains("cursor rule"));
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_import_list_loads_only_selected_framework_rules() {
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(&root).expect("root dir");
|
||||
fs::write(root.join(".cursorrules"), "cursor rule").expect("write cursor rule");
|
||||
fs::create_dir_all(root.join(".github")).expect("github dir");
|
||||
fs::write(
|
||||
root.join(".github").join("copilot-instructions.md"),
|
||||
"copilot rule",
|
||||
)
|
||||
.expect("write copilot rule");
|
||||
|
||||
let context = ProjectContext::discover_with_rules_import(
|
||||
&root,
|
||||
"2026-03-31",
|
||||
&crate::config::RulesImportConfig::List(vec!["copilot".to_string()]),
|
||||
)
|
||||
.expect("context should load");
|
||||
let rendered = render_instruction_files(&context.instruction_files);
|
||||
|
||||
assert!(rendered.contains("copilot rule"));
|
||||
assert!(!rendered.contains("cursor rule"));
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_instruction_files_from_ancestor_chain() {
|
||||
let root = temp_dir();
|
||||
let nested = root.join("apps").join("api");
|
||||
fs::create_dir_all(nested.join(".claw")).expect("nested claw dir");
|
||||
fs::create_dir(root.join(".git")).expect("git boundary");
|
||||
fs::write(root.join("CLAUDE.md"), "root instructions").expect("write root instructions");
|
||||
fs::write(root.join("CLAUDE.local.md"), "local instructions")
|
||||
.expect("write local instructions");
|
||||
@@ -623,11 +875,80 @@ mod tests {
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_agents_markdown_instruction_file() {
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(&root).expect("root dir");
|
||||
fs::write(root.join("AGENTS.md"), "agents-only instructions").expect("write AGENTS.md");
|
||||
|
||||
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
|
||||
|
||||
assert_eq!(context.instruction_files.len(), 1);
|
||||
assert!(context.instruction_files[0].path.ends_with("AGENTS.md"));
|
||||
assert!(render_instruction_files(&context.instruction_files)
|
||||
.contains("agents-only instructions"));
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_scoped_dot_claude_claude_markdown_instruction_file() {
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(root.join(".claude")).expect("dot claude dir");
|
||||
fs::write(
|
||||
root.join(".claude").join("CLAUDE.md"),
|
||||
"dot-claude-only instructions",
|
||||
)
|
||||
.expect("write .claude/CLAUDE.md");
|
||||
|
||||
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
|
||||
|
||||
assert_eq!(context.instruction_files.len(), 1);
|
||||
assert!(context.instruction_files[0]
|
||||
.path
|
||||
.ends_with(".claude/CLAUDE.md"));
|
||||
assert!(render_instruction_files(&context.instruction_files)
|
||||
.contains("dot-claude-only instructions"));
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_claude_claw_agents_and_dot_claude_instruction_files_together() {
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(root.join(".claude")).expect("dot claude dir");
|
||||
fs::write(root.join("CLAUDE.md"), "claude instructions").expect("write CLAUDE.md");
|
||||
fs::write(root.join("CLAW.md"), "claw instructions").expect("write CLAW.md");
|
||||
fs::write(root.join("AGENTS.md"), "agents instructions").expect("write AGENTS.md");
|
||||
fs::write(
|
||||
root.join(".claude").join("CLAUDE.md"),
|
||||
"dot claude instructions",
|
||||
)
|
||||
.expect("write .claude/CLAUDE.md");
|
||||
|
||||
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
|
||||
let rendered = render_instruction_files(&context.instruction_files);
|
||||
let sources = context
|
||||
.instruction_files
|
||||
.iter()
|
||||
.map(ContextFile::source)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
sources,
|
||||
vec!["claude_md", "claw_md", "agents_md", "claude_claude_md"]
|
||||
);
|
||||
assert!(rendered.contains("claude instructions"));
|
||||
assert!(rendered.contains("claw instructions"));
|
||||
assert!(rendered.contains("agents instructions"));
|
||||
assert!(rendered.contains("dot claude instructions"));
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedupes_identical_instruction_content_across_scopes() {
|
||||
let root = temp_dir();
|
||||
let nested = root.join("apps").join("api");
|
||||
fs::create_dir_all(&nested).expect("nested dir");
|
||||
fs::create_dir(root.join(".git")).expect("git boundary");
|
||||
fs::write(root.join("CLAUDE.md"), "same rules\n\n").expect("write root");
|
||||
fs::write(nested.join("CLAUDE.md"), "same rules\n").expect("write nested");
|
||||
|
||||
@@ -640,6 +961,50 @@ mod tests {
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_stops_at_git_root_boundary_439() {
|
||||
let root = temp_dir();
|
||||
let repo = root.join("repo");
|
||||
let nested = repo.join("subproj").join("deep").join("nest");
|
||||
fs::create_dir_all(&nested).expect("nested dir");
|
||||
fs::create_dir(repo.join(".git")).expect("git boundary");
|
||||
fs::write(root.join("CLAUDE.md"), "PARENT_CLAUDE").expect("write parent");
|
||||
fs::write(repo.join("CLAUDE.md"), "REPO_CLAUDE").expect("write repo");
|
||||
fs::write(repo.join("subproj").join("CLAUDE.md"), "CHILD_CLAUDE").expect("write child");
|
||||
fs::write(
|
||||
repo.join("subproj").join("deep").join("CLAUDE.md"),
|
||||
"DEEP_CLAUDE",
|
||||
)
|
||||
.expect("write deep");
|
||||
|
||||
let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
|
||||
let rendered = render_instruction_files(&context.instruction_files);
|
||||
|
||||
assert!(!rendered.contains("PARENT_CLAUDE"));
|
||||
assert!(rendered.contains("REPO_CLAUDE"));
|
||||
assert!(rendered.contains("CHILD_CLAUDE"));
|
||||
assert!(rendered.contains("DEEP_CLAUDE"));
|
||||
assert_eq!(context.instruction_files.len(), 3);
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_without_git_root_stays_cwd_local_439() {
|
||||
let root = temp_dir();
|
||||
let nested = root.join("scratch");
|
||||
fs::create_dir_all(&nested).expect("nested dir");
|
||||
fs::write(root.join("CLAUDE.md"), "PARENT_CLAUDE").expect("write parent");
|
||||
fs::write(nested.join("CLAUDE.md"), "SCRATCH_CLAUDE").expect("write scratch");
|
||||
|
||||
let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
|
||||
let rendered = render_instruction_files(&context.instruction_files);
|
||||
|
||||
assert!(!rendered.contains("PARENT_CLAUDE"));
|
||||
assert!(rendered.contains("SCRATCH_CLAUDE"));
|
||||
assert_eq!(context.instruction_files.len(), 1);
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_large_instruction_content_for_rendering() {
|
||||
let rendered = render_instruction_content(&"x".repeat(4500));
|
||||
@@ -863,6 +1228,51 @@ mod tests {
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_system_prompt_respects_rules_import_config() {
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(root.join(".claw")).expect("claw dir");
|
||||
fs::write(root.join(".cursorrules"), "cursor rule").expect("write cursor rule");
|
||||
fs::write(
|
||||
root.join(".claw").join("settings.json"),
|
||||
r#"{"rulesImport":"none"}"#,
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let _guard = env_lock();
|
||||
ensure_valid_cwd();
|
||||
let previous = std::env::current_dir().expect("cwd");
|
||||
let original_home = std::env::var("HOME").ok();
|
||||
let original_claw_home = std::env::var("CLAW_CONFIG_HOME").ok();
|
||||
std::env::set_var("HOME", &root);
|
||||
std::env::set_var("CLAW_CONFIG_HOME", root.join("missing-home"));
|
||||
std::env::set_current_dir(&root).expect("change cwd");
|
||||
let prompt = super::load_system_prompt(
|
||||
&root,
|
||||
"2026-03-31",
|
||||
"linux",
|
||||
"6.8",
|
||||
ModelFamilyIdentity::Claude,
|
||||
)
|
||||
.expect("system prompt should load")
|
||||
.join("\n\n");
|
||||
std::env::set_current_dir(previous).expect("restore cwd");
|
||||
if let Some(value) = original_home {
|
||||
std::env::set_var("HOME", value);
|
||||
} else {
|
||||
std::env::remove_var("HOME");
|
||||
}
|
||||
if let Some(value) = original_claw_home {
|
||||
std::env::set_var("CLAW_CONFIG_HOME", value);
|
||||
} else {
|
||||
std::env::remove_var("CLAW_CONFIG_HOME");
|
||||
}
|
||||
|
||||
assert!(!prompt.contains("cursor rule"));
|
||||
assert!(prompt.contains("rulesImport"));
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_default_claude_model_family_identity() {
|
||||
// given: a prompt builder without an explicit model family override
|
||||
@@ -932,7 +1342,7 @@ mod tests {
|
||||
|
||||
assert!(prompt.contains("# System"));
|
||||
assert!(prompt.contains("# Project context"));
|
||||
assert!(prompt.contains("# Claude instructions"));
|
||||
assert!(prompt.contains("# Project instructions"));
|
||||
assert!(prompt.contains("Project rules"));
|
||||
assert!(prompt.contains("permissionMode"));
|
||||
assert!(prompt.contains(SYSTEM_PROMPT_DYNAMIC_BOUNDARY));
|
||||
@@ -977,8 +1387,50 @@ mod tests {
|
||||
path: PathBuf::from("/tmp/project/CLAUDE.md"),
|
||||
content: "Project rules".to_string(),
|
||||
}]);
|
||||
assert!(rendered.contains("# Claude instructions"));
|
||||
assert!(rendered.contains("# Project instructions"));
|
||||
assert!(rendered.contains("scope: /tmp/project"));
|
||||
assert!(rendered.contains("Project rules"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_diff_preserves_short_content() {
|
||||
let short = "a".repeat(1_000);
|
||||
let result = truncate_diff(short.clone());
|
||||
assert_eq!(result, short);
|
||||
assert!(!result.contains("[diff truncated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_diff_caps_oversized_content() {
|
||||
let large = "x".repeat(MAX_GIT_DIFF_CHARS + 5_000);
|
||||
let result = truncate_diff(large);
|
||||
assert!(result.contains("... [diff truncated — too large for system prompt]"));
|
||||
// The body before the marker must be at most MAX_GIT_DIFF_CHARS bytes
|
||||
let marker = "\n\n... [diff truncated — too large for system prompt]";
|
||||
let body_len = result.len() - marker.len();
|
||||
assert!(body_len <= MAX_GIT_DIFF_CHARS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_diff_respects_utf8_char_boundaries() {
|
||||
// Build a string where MAX_GIT_DIFF_CHARS falls in the middle of a
|
||||
// multi-byte character (U+1F600 = 4 bytes in UTF-8).
|
||||
let prefix_len = MAX_GIT_DIFF_CHARS - 2;
|
||||
let mut input = "a".repeat(prefix_len);
|
||||
// Append a 4-byte emoji so bytes [prefix_len..prefix_len+4] are the
|
||||
// emoji. MAX_GIT_DIFF_CHARS lands at prefix_len+2, inside the emoji.
|
||||
input.push('\u{1F600}');
|
||||
input.push_str(&"b".repeat(10_000));
|
||||
|
||||
let result = truncate_diff(input);
|
||||
// Must be valid UTF-8 (the fact that we have a String proves this, but
|
||||
// let's also verify the truncation marker is present).
|
||||
assert!(result.contains("[diff truncated"));
|
||||
// The body (before marker) should end before the emoji since cutting
|
||||
// inside it would be invalid UTF-8.
|
||||
let marker = "\n\n... [diff truncated — too large for system prompt]";
|
||||
let body = &result[..result.len() - marker.len()];
|
||||
assert!(body.len() <= MAX_GIT_DIFF_CHARS);
|
||||
assert!(body.is_char_boundary(body.len()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,8 +231,31 @@ impl Session {
|
||||
pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), SessionError> {
|
||||
let path = path.as_ref();
|
||||
let snapshot = self.render_jsonl_snapshot()?;
|
||||
rotate_session_file_if_needed(path)?;
|
||||
write_atomic(path, &snapshot)?;
|
||||
// #112: wrap ENOENT during rotate as concurrent modification
|
||||
match rotate_session_file_if_needed(path) {
|
||||
Ok(()) => {}
|
||||
Err(SessionError::Io(ref io_err)) if io_err.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Err(SessionError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!(
|
||||
"session file was removed during save (possible concurrent modification): {io_err}"
|
||||
),
|
||||
)));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
write_atomic(path, &snapshot).map_err(|e| {
|
||||
// #112: wrap ENOENT during write as concurrent modification
|
||||
match &e {
|
||||
SessionError::Io(io_err) if io_err.kind() == std::io::ErrorKind::NotFound => {
|
||||
SessionError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("session file was removed during write (possible concurrent modification): {io_err}"),
|
||||
))
|
||||
}
|
||||
_ => e,
|
||||
}
|
||||
})?;
|
||||
cleanup_rotated_logs(path)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -413,6 +436,7 @@ impl Session {
|
||||
.get("created_at_ms")
|
||||
.map(|value| required_u64_from_value(value, "created_at_ms"))
|
||||
.transpose()?
|
||||
.or_else(|| parse_created_at_ms_from_session_id(&session_id))
|
||||
.unwrap_or(now);
|
||||
let updated_at_ms = object
|
||||
.get("updated_at_ms")
|
||||
@@ -500,7 +524,10 @@ impl Session {
|
||||
"session_meta" => {
|
||||
version = required_u32(object, "version")?;
|
||||
session_id = Some(required_string(object, "session_id")?);
|
||||
created_at_ms = Some(required_u64(object, "created_at_ms")?);
|
||||
created_at_ms = object
|
||||
.get("created_at_ms")
|
||||
.map(|value| required_u64_from_value(value, "created_at_ms"))
|
||||
.transpose()?;
|
||||
updated_at_ms = Some(required_u64(object, "updated_at_ms")?);
|
||||
fork = object.get("fork").map(SessionFork::from_json).transpose()?;
|
||||
workspace_root = object
|
||||
@@ -543,11 +570,15 @@ impl Session {
|
||||
}
|
||||
|
||||
let now = current_time_millis();
|
||||
let session_id = session_id.unwrap_or_else(generate_session_id);
|
||||
let created_at_ms = created_at_ms
|
||||
.or_else(|| parse_created_at_ms_from_session_id(&session_id))
|
||||
.unwrap_or(now);
|
||||
Ok(Self {
|
||||
version,
|
||||
session_id: session_id.unwrap_or_else(generate_session_id),
|
||||
created_at_ms: created_at_ms.unwrap_or(now),
|
||||
updated_at_ms: updated_at_ms.unwrap_or(created_at_ms.unwrap_or(now)),
|
||||
session_id,
|
||||
created_at_ms,
|
||||
updated_at_ms: updated_at_ms.unwrap_or(created_at_ms),
|
||||
messages,
|
||||
compaction,
|
||||
fork,
|
||||
@@ -1291,6 +1322,15 @@ fn current_time_millis() -> u64 {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_created_at_ms_from_session_id(session_id: &str) -> Option<u64> {
|
||||
let timestamp_and_suffix = session_id.strip_prefix("session-")?;
|
||||
let (timestamp, suffix) = timestamp_and_suffix.split_once('-')?;
|
||||
if suffix.is_empty() {
|
||||
return None;
|
||||
}
|
||||
timestamp.parse::<u64>().ok()
|
||||
}
|
||||
|
||||
fn generate_session_id() -> String {
|
||||
let millis = current_time_millis();
|
||||
let counter = SESSION_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -1380,8 +1420,9 @@ fn cleanup_rotated_logs(path: &Path) -> Result<(), SessionError> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
cleanup_rotated_logs, current_time_millis, rotate_session_file_if_needed, ContentBlock,
|
||||
ConversationMessage, MessageRole, Session, SessionFork,
|
||||
cleanup_rotated_logs, current_time_millis, parse_created_at_ms_from_session_id,
|
||||
rotate_session_file_if_needed, ContentBlock, ConversationMessage, MessageRole, Session,
|
||||
SessionFork,
|
||||
};
|
||||
use crate::json::JsonValue;
|
||||
use crate::usage::TokenUsage;
|
||||
@@ -1502,6 +1543,44 @@ mod tests {
|
||||
assert!(!restored.session_id.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_at_parser_requires_full_session_id_shape() {
|
||||
assert_eq!(
|
||||
parse_created_at_ms_from_session_id("session-1743724800123-0"),
|
||||
Some(1_743_724_800_123)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_created_at_ms_from_session_id("session-1743724800123"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
parse_created_at_ms_from_session_id("session-1743724800123-"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
parse_created_at_ms_from_session_id("other-1743724800123-0"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_legacy_jsonl_created_at_from_session_id_when_meta_omits_it() {
|
||||
let path = temp_session_path("legacy-jsonl-created-at");
|
||||
fs::write(
|
||||
&path,
|
||||
r#"{"type":"session_meta","version":3,"session_id":"session-1743724800123-0","updated_at_ms":1743724800456}
|
||||
"#,
|
||||
)
|
||||
.expect("legacy jsonl should write");
|
||||
|
||||
let restored = Session::load_from_path(&path).expect("legacy jsonl should load");
|
||||
fs::remove_file(&path).expect("temp file should be removable");
|
||||
|
||||
assert_eq!(restored.session_id, "session-1743724800123-0");
|
||||
assert_eq!(restored.created_at_ms, 1_743_724_800_123);
|
||||
assert_eq!(restored.updated_at_ms, 1_743_724_800_456);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appends_messages_to_persisted_jsonl_session() {
|
||||
let path = temp_session_path("append");
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use crate::session::{Session, SessionError};
|
||||
use crate::session::{parse_created_at_ms_from_session_id, Session, SessionError};
|
||||
|
||||
/// Per-worktree session store that namespaces on-disk session files by
|
||||
/// workspace fingerprint so that parallel `opencode serve` instances never
|
||||
@@ -28,7 +28,8 @@ pub struct SessionStore {
|
||||
impl SessionStore {
|
||||
/// Build a store from the server's current working directory.
|
||||
///
|
||||
/// The on-disk layout becomes `<cwd>/.claw/sessions/<workspace_hash>/`.
|
||||
/// The on-disk layout is `<cwd>/.claw/sessions/<workspace_hash>/`,
|
||||
/// created lazily on first successful session save.
|
||||
pub fn from_cwd(cwd: impl AsRef<Path>) -> Result<Self, SessionControlError> {
|
||||
let cwd = cwd.as_ref();
|
||||
// #151: canonicalize so equivalent paths (symlinks, relative vs
|
||||
@@ -40,7 +41,6 @@ impl SessionStore {
|
||||
.join(".claw")
|
||||
.join("sessions")
|
||||
.join(workspace_fingerprint(&canonical_cwd));
|
||||
fs::create_dir_all(&sessions_root)?;
|
||||
Ok(Self {
|
||||
sessions_root,
|
||||
workspace_root: canonical_cwd,
|
||||
@@ -49,7 +49,8 @@ impl SessionStore {
|
||||
|
||||
/// Build a store from an explicit `--data-dir` flag.
|
||||
///
|
||||
/// The on-disk layout becomes `<data_dir>/sessions/<workspace_hash>/`
|
||||
/// The on-disk layout is `<data_dir>/sessions/<workspace_hash>/`,
|
||||
/// created lazily on first successful session save.
|
||||
/// where `<workspace_hash>` is derived from `workspace_root`.
|
||||
pub fn from_data_dir(
|
||||
data_dir: impl AsRef<Path>,
|
||||
@@ -64,7 +65,6 @@ impl SessionStore {
|
||||
.as_ref()
|
||||
.join("sessions")
|
||||
.join(workspace_fingerprint(&canonical_workspace));
|
||||
fs::create_dir_all(&sessions_root)?;
|
||||
Ok(Self {
|
||||
sessions_root,
|
||||
workspace_root: canonical_workspace,
|
||||
@@ -93,8 +93,19 @@ impl SessionStore {
|
||||
}
|
||||
|
||||
pub fn resolve_reference(&self, reference: &str) -> Result<SessionHandle, SessionControlError> {
|
||||
self.resolve_reference_excluding(reference, None)
|
||||
}
|
||||
|
||||
/// Resolve a session reference, optionally excluding a session by ID.
|
||||
/// When the reference is an alias, the excluded session is skipped
|
||||
/// so /resume latest returns the previous session, not the current one.
|
||||
pub fn resolve_reference_excluding(
|
||||
&self,
|
||||
reference: &str,
|
||||
exclude_id: Option<&str>,
|
||||
) -> Result<SessionHandle, SessionControlError> {
|
||||
if is_session_reference_alias(reference) {
|
||||
let latest = self.latest_session()?;
|
||||
let latest = self.latest_session_excluding(exclude_id)?;
|
||||
return Ok(SessionHandle {
|
||||
id: latest.id,
|
||||
path: latest.path,
|
||||
@@ -158,9 +169,48 @@ impl SessionStore {
|
||||
}
|
||||
|
||||
pub fn latest_session(&self) -> Result<ManagedSessionSummary, SessionControlError> {
|
||||
self.list_sessions()?.into_iter().next().ok_or_else(|| {
|
||||
SessionControlError::Format(format_no_managed_sessions(&self.sessions_root))
|
||||
})
|
||||
self.latest_session_excluding(None)
|
||||
}
|
||||
|
||||
/// Find the most recent session, optionally excluding a session by ID
|
||||
/// and skipping sessions with 0 messages. Used by /resume latest to skip
|
||||
/// the current empty session and find the previous session with actual
|
||||
/// conversation history.
|
||||
pub fn latest_session_excluding(
|
||||
&self,
|
||||
exclude_id: Option<&str>,
|
||||
) -> Result<ManagedSessionSummary, SessionControlError> {
|
||||
let exclude = exclude_id.unwrap_or("");
|
||||
// First: look in the current workspace's session namespace
|
||||
if let Some(latest) = self
|
||||
.list_sessions()?
|
||||
.into_iter()
|
||||
.find(|s| s.id != exclude && s.message_count > 0)
|
||||
{
|
||||
return Ok(latest);
|
||||
}
|
||||
// Fallback: scan all workspace namespaces under ~/.claw/sessions/
|
||||
// and project-local .claw/sessions/ so /resume latest finds sessions
|
||||
// from other workspaces.
|
||||
if let Some(latest) = self
|
||||
.scan_global_sessions()?
|
||||
.into_iter()
|
||||
.find(|s| s.id != exclude && s.message_count > 0)
|
||||
{
|
||||
return Ok(latest);
|
||||
}
|
||||
// Distinguish between "no sessions at all" and "sessions exist but
|
||||
// all are empty" so the user gets a clear signal about what to do.
|
||||
let has_any_session = self.list_sessions()?.iter().any(|s| s.id != exclude)
|
||||
|| self.scan_global_sessions()?.iter().any(|s| s.id != exclude);
|
||||
if has_any_session {
|
||||
return Err(SessionControlError::Format(format_all_sessions_empty(
|
||||
&self.sessions_root,
|
||||
)));
|
||||
}
|
||||
Err(SessionControlError::Format(format_no_managed_sessions(
|
||||
&self.sessions_root,
|
||||
)))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -190,6 +240,51 @@ impl SessionStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Load a session by reference, allowing cross-workspace resume for aliases.
|
||||
/// When the reference is an alias ("latest", "last", "recent"), workspace
|
||||
/// mismatch validation is skipped so `/resume latest` works across workspaces.
|
||||
/// For explicit session references, workspace validation is still enforced.
|
||||
pub fn load_session_loose(
|
||||
&self,
|
||||
reference: &str,
|
||||
) -> Result<LoadedManagedSession, SessionControlError> {
|
||||
self.load_session_excluding(reference, None)
|
||||
}
|
||||
|
||||
/// Like `load_session_loose` but also excludes a session by ID.
|
||||
/// Used by /resume latest to skip the current empty session and find
|
||||
/// the previous session with actual conversation history.
|
||||
pub fn load_session_excluding(
|
||||
&self,
|
||||
reference: &str,
|
||||
exclude_id: Option<&str>,
|
||||
) -> Result<LoadedManagedSession, SessionControlError> {
|
||||
let handle = self.resolve_reference_excluding(reference, exclude_id)?;
|
||||
let session = Session::load_from_path(&handle.path)?;
|
||||
// For alias references, allow cross-workspace resume
|
||||
if is_session_reference_alias(reference) {
|
||||
if let Err(SessionControlError::WorkspaceMismatch {
|
||||
expected: _,
|
||||
actual,
|
||||
}) = self.validate_loaded_session(&handle.path, &session)
|
||||
{
|
||||
eprintln!(
|
||||
" Note: resuming session from a different workspace (origin: {})",
|
||||
actual.display()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.validate_loaded_session(&handle.path, &session)?;
|
||||
}
|
||||
Ok(LoadedManagedSession {
|
||||
handle: SessionHandle {
|
||||
id: session.session_id.clone(),
|
||||
path: handle.path,
|
||||
},
|
||||
session,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn fork_session(
|
||||
&self,
|
||||
session: &Session,
|
||||
@@ -221,6 +316,47 @@ impl SessionStore {
|
||||
.map(Path::to_path_buf)
|
||||
}
|
||||
|
||||
/// Scan all known session storage locations for sessions from any workspace.
|
||||
/// Checks both the global root (~/.claw/sessions/) and the project-local
|
||||
/// .claw/sessions/ parent directory. Used as a fallback when the current
|
||||
/// workspace has no sessions.
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
fn scan_global_sessions(&self) -> Result<Vec<ManagedSessionSummary>, SessionControlError> {
|
||||
let mut sessions = Vec::new();
|
||||
|
||||
// Scan global root: ~/.claw/sessions/<fingerprint>/
|
||||
let global_root = global_sessions_root();
|
||||
if let Ok(entries) = fs::read_dir(&global_root) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
let _ = Self::collect_sessions_from_dir_unvalidated(&path, &mut sessions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan project-local parent: <cwd>/.claw/sessions/<fingerprint>/
|
||||
// Sessions are stored here by from_cwd(), so we must check all
|
||||
// fingerprint subdirs, not just the current workspace's.
|
||||
if let Some(local_parent) = self.legacy_sessions_root() {
|
||||
if let Ok(entries) = fs::read_dir(&local_parent) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() && path != self.sessions_root {
|
||||
let _ = Self::collect_sessions_from_dir_unvalidated(&path, &mut sessions);
|
||||
} else if path == self.sessions_root {
|
||||
// Already searched in list_sessions(), but include here
|
||||
// in case this is called standalone
|
||||
let _ = Self::collect_sessions_from_dir_unvalidated(&path, &mut sessions);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort_managed_sessions(&mut sessions);
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
fn validate_loaded_session(
|
||||
&self,
|
||||
session_path: &Path,
|
||||
@@ -266,6 +402,9 @@ impl SessionStore {
|
||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or_default();
|
||||
let fallback_id = session_id_from_path(&path).unwrap_or_else(|| "unknown".to_string());
|
||||
let fallback_created_at_ms =
|
||||
parse_created_at_ms_from_session_id(&fallback_id).unwrap_or(0);
|
||||
let summary = match Session::load_from_path(&path) {
|
||||
Ok(session) => {
|
||||
if self.validate_loaded_session(&path, &session).is_err() {
|
||||
@@ -274,6 +413,7 @@ impl SessionStore {
|
||||
ManagedSessionSummary {
|
||||
id: session.session_id,
|
||||
path,
|
||||
created_at_ms: session.created_at_ms,
|
||||
updated_at_ms: session.updated_at_ms,
|
||||
modified_epoch_millis,
|
||||
message_count: session.messages.len(),
|
||||
@@ -288,12 +428,69 @@ impl SessionStore {
|
||||
}
|
||||
}
|
||||
Err(_) => ManagedSessionSummary {
|
||||
id: path
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
id: fallback_id,
|
||||
path,
|
||||
created_at_ms: fallback_created_at_ms,
|
||||
updated_at_ms: 0,
|
||||
modified_epoch_millis,
|
||||
message_count: 0,
|
||||
parent_session_id: None,
|
||||
branch_name: None,
|
||||
},
|
||||
};
|
||||
sessions.push(summary);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Like `collect_sessions_from_dir` but skips workspace validation.
|
||||
/// Used by the global scan fallback to discover sessions from any workspace.
|
||||
fn collect_sessions_from_dir_unvalidated(
|
||||
directory: &Path,
|
||||
sessions: &mut Vec<ManagedSessionSummary>,
|
||||
) -> Result<(), SessionControlError> {
|
||||
let entries = match fs::read_dir(directory) {
|
||||
Ok(entries) => entries,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if !is_managed_session_file(&path) {
|
||||
continue;
|
||||
}
|
||||
let metadata = entry.metadata()?;
|
||||
let modified_epoch_millis = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or_default();
|
||||
let fallback_id = session_id_from_path(&path).unwrap_or_else(|| "unknown".to_string());
|
||||
let fallback_created_at_ms =
|
||||
parse_created_at_ms_from_session_id(&fallback_id).unwrap_or(0);
|
||||
let summary = match Session::load_from_path(&path) {
|
||||
Ok(session) => ManagedSessionSummary {
|
||||
id: session.session_id,
|
||||
path,
|
||||
created_at_ms: session.created_at_ms,
|
||||
updated_at_ms: session.updated_at_ms,
|
||||
modified_epoch_millis,
|
||||
message_count: session.messages.len(),
|
||||
parent_session_id: session
|
||||
.fork
|
||||
.as_ref()
|
||||
.map(|fork| fork.parent_session_id.clone()),
|
||||
branch_name: session
|
||||
.fork
|
||||
.as_ref()
|
||||
.and_then(|fork| fork.branch_name.clone()),
|
||||
},
|
||||
Err(_) => ManagedSessionSummary {
|
||||
id: fallback_id,
|
||||
path,
|
||||
created_at_ms: fallback_created_at_ms,
|
||||
updated_at_ms: 0,
|
||||
modified_epoch_millis,
|
||||
message_count: 0,
|
||||
@@ -322,6 +519,13 @@ pub fn workspace_fingerprint(workspace_root: &Path) -> String {
|
||||
format!("{hash:016x}")
|
||||
}
|
||||
|
||||
/// The global sessions directory shared across all workspaces.
|
||||
/// Points to `~/.claw/sessions/` (or `$CLAW_CONFIG_HOME/sessions/`).
|
||||
#[must_use]
|
||||
pub fn global_sessions_root() -> PathBuf {
|
||||
crate::config::default_config_home().join("sessions")
|
||||
}
|
||||
|
||||
pub const PRIMARY_SESSION_EXTENSION: &str = "jsonl";
|
||||
pub const LEGACY_SESSION_EXTENSION: &str = "json";
|
||||
pub const LATEST_SESSION_REFERENCE: &str = "latest";
|
||||
@@ -338,6 +542,7 @@ pub struct SessionHandle {
|
||||
pub struct ManagedSessionSummary {
|
||||
pub id: String,
|
||||
pub path: PathBuf,
|
||||
pub created_at_ms: u64,
|
||||
pub updated_at_ms: u64,
|
||||
pub modified_epoch_millis: u128,
|
||||
pub message_count: usize,
|
||||
@@ -574,7 +779,17 @@ fn format_no_managed_sessions(sessions_root: &Path) -> String {
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("<unknown>");
|
||||
format!(
|
||||
"no managed sessions found in .claw/sessions/{fingerprint_dir}/\nStart `claw` to create a session, then rerun with `--resume {LATEST_SESSION_REFERENCE}`.\nNote: claw partitions sessions per workspace fingerprint; sessions from other CWDs are invisible."
|
||||
"no managed sessions found in .claw/sessions/{fingerprint_dir}/\nStart `claw` to create a session, then rerun with `--resume {LATEST_SESSION_REFERENCE}`.\nNote: /resume {LATEST_SESSION_REFERENCE} searches all workspaces."
|
||||
)
|
||||
}
|
||||
|
||||
fn format_all_sessions_empty(sessions_root: &Path) -> String {
|
||||
let fingerprint_dir = sessions_root
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("<unknown>");
|
||||
format!(
|
||||
"all sessions are empty (0 messages) in .claw/sessions/{fingerprint_dir}/\nThis usually means a fresh `claw` session is running but no messages have been sent yet.\nWait for a response in your other session, then try `--resume {LATEST_SESSION_REFERENCE}` again."
|
||||
)
|
||||
}
|
||||
|
||||
@@ -612,14 +827,21 @@ mod tests {
|
||||
use crate::session::Session;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn temp_dir() -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("time should be after epoch")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("runtime-session-control-{nanos}"))
|
||||
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!(
|
||||
"runtime-session-control-{}-{nanos}-{counter}",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
|
||||
fn persist_session(root: &Path, text: &str) -> Session {
|
||||
@@ -665,6 +887,7 @@ mod tests {
|
||||
ManagedSessionSummary {
|
||||
id: "older-file-newer-session".to_string(),
|
||||
path: PathBuf::from("/tmp/older"),
|
||||
created_at_ms: 100,
|
||||
updated_at_ms: 200,
|
||||
modified_epoch_millis: 100,
|
||||
message_count: 2,
|
||||
@@ -674,6 +897,7 @@ mod tests {
|
||||
ManagedSessionSummary {
|
||||
id: "newer-file-older-session".to_string(),
|
||||
path: PathBuf::from("/tmp/newer"),
|
||||
created_at_ms: 50,
|
||||
updated_at_ms: 100,
|
||||
modified_epoch_millis: 200,
|
||||
message_count: 1,
|
||||
@@ -831,6 +1055,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_store_from_cwd_is_side_effect_free_until_save() {
|
||||
// given
|
||||
let base = temp_dir();
|
||||
let workspace = base.join("fresh-workspace");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
|
||||
// when
|
||||
let store = SessionStore::from_cwd(&workspace).expect("store should build");
|
||||
|
||||
// then — resolving the store must not create .claw/session partitions.
|
||||
assert!(
|
||||
!workspace.join(".claw").exists(),
|
||||
"session store construction must not create .claw side effects"
|
||||
);
|
||||
assert!(
|
||||
!store.sessions_dir().exists(),
|
||||
"session partition should be created lazily on save"
|
||||
);
|
||||
|
||||
let session = persist_session_via_store(&store, "first saved turn");
|
||||
assert!(
|
||||
store
|
||||
.sessions_dir()
|
||||
.join(format!("{}.jsonl", session.session_id))
|
||||
.exists(),
|
||||
"saving a managed session should create the lazy session partition"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(base).expect("temp dir should clean up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_store_from_cwd_isolates_sessions_by_workspace() {
|
||||
// given
|
||||
@@ -1031,6 +1287,114 @@ mod tests {
|
||||
fs::remove_dir_all(base).expect("temp dir should clean up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_session_returns_all_empty_error_when_sessions_exist_but_have_no_messages() {
|
||||
// given — create sessions with 0 messages (empty)
|
||||
let base = temp_dir();
|
||||
fs::create_dir_all(&base).expect("base dir should exist");
|
||||
let store = SessionStore::from_cwd(&base).expect("store should build");
|
||||
|
||||
let empty_handle = store.create_handle("empty-session");
|
||||
Session::new()
|
||||
.with_persistence_path(empty_handle.path.clone())
|
||||
.save_to_path(&empty_handle.path)
|
||||
.expect("empty session should save");
|
||||
|
||||
// when — latest_session should fail with the "all sessions empty" message
|
||||
let result = store.latest_session();
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"latest_session should fail when all sessions are empty"
|
||||
);
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("all sessions are empty"),
|
||||
"error should mention 'all sessions are empty', got: {err_msg}"
|
||||
);
|
||||
assert!(
|
||||
err_msg.contains("0 messages"),
|
||||
"error should mention '0 messages', got: {err_msg}"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(base).expect("temp dir should clean up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_session_excluding_skips_excluded_id_and_returns_previous() {
|
||||
// given — two sessions WITH messages, newest excluded
|
||||
let base = temp_dir();
|
||||
fs::create_dir_all(&base).expect("base dir should exist");
|
||||
let store = SessionStore::from_cwd(&base).expect("store should build");
|
||||
let older = persist_session_via_store(&store, "older work");
|
||||
wait_for_next_millisecond();
|
||||
let newer = persist_session_via_store(&store, "newer work");
|
||||
|
||||
// when — exclude the newest session
|
||||
let latest = store
|
||||
.latest_session_excluding(Some(&newer.session_id))
|
||||
.expect("latest excluding newest should resolve");
|
||||
|
||||
// then — the older session wins because the newest is skipped
|
||||
assert_eq!(
|
||||
latest.id, older.session_id,
|
||||
"excluded id must be skipped, returning the previous session"
|
||||
);
|
||||
fs::remove_dir_all(base).expect("temp dir should clean up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_session_filters_out_zero_message_sessions() {
|
||||
// given — one empty (0-message) session and one non-empty session
|
||||
let base = temp_dir();
|
||||
fs::create_dir_all(&base).expect("base dir should exist");
|
||||
let store = SessionStore::from_cwd(&base).expect("store should build");
|
||||
|
||||
let empty_handle = store.create_handle("empty-session");
|
||||
Session::new()
|
||||
.with_persistence_path(empty_handle.path.clone())
|
||||
.save_to_path(&empty_handle.path)
|
||||
.expect("empty session should save");
|
||||
wait_for_next_millisecond();
|
||||
let non_empty = persist_session_via_store(&store, "real conversation");
|
||||
|
||||
// when
|
||||
let latest = store.latest_session().expect("latest should resolve");
|
||||
|
||||
// then — the non-empty session wins; the 0-message one is filtered out
|
||||
assert_eq!(
|
||||
latest.id, non_empty.session_id,
|
||||
"0-message session must be filtered out, non-empty session wins"
|
||||
);
|
||||
assert!(
|
||||
latest.message_count > 0,
|
||||
"resolved session must have messages"
|
||||
);
|
||||
fs::remove_dir_all(base).expect("temp dir should clean up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_reference_excluding_latest_skips_excluded_id() {
|
||||
// given — two sessions WITH messages
|
||||
let base = temp_dir();
|
||||
fs::create_dir_all(&base).expect("base dir should exist");
|
||||
let store = SessionStore::from_cwd(&base).expect("store should build");
|
||||
let older = persist_session_via_store(&store, "older work");
|
||||
wait_for_next_millisecond();
|
||||
let newer = persist_session_via_store(&store, "newer work");
|
||||
|
||||
// when — resolve the "latest" alias while excluding the newest session
|
||||
let handle = store
|
||||
.resolve_reference_excluding("latest", Some(&newer.session_id))
|
||||
.expect("latest alias excluding newest should resolve");
|
||||
|
||||
// then — the excluded id is skipped, so the older session resolves
|
||||
assert_eq!(
|
||||
handle.id, older.session_id,
|
||||
"excluded id must be skipped when resolving the latest alias"
|
||||
);
|
||||
fs::remove_dir_all(base).expect("temp dir should clean up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_exists_and_delete_are_scoped_to_workspace_store() {
|
||||
// given
|
||||
@@ -1085,4 +1449,44 @@ mod tests {
|
||||
);
|
||||
fs::remove_dir_all(base).expect("temp dir should clean up");
|
||||
}
|
||||
|
||||
/// #160 regression: store-level list_sessions/session_exists/delete_session
|
||||
/// lifecycle works end-to-end.
|
||||
#[test]
|
||||
fn session_store_lifecycle_regression_160() {
|
||||
// given
|
||||
let base = temp_dir();
|
||||
fs::create_dir_all(&base).expect("base dir should exist");
|
||||
let store = SessionStore::from_cwd(&base).expect("store should build");
|
||||
let session = persist_session_via_store(&store, "160 regression test");
|
||||
|
||||
// when/then — session exists and is listed before deletion
|
||||
assert!(
|
||||
!store.list_sessions().expect("list").is_empty(),
|
||||
"store should have at least one session"
|
||||
);
|
||||
assert!(
|
||||
store.session_exists(&session.session_id),
|
||||
"session should exist before deletion"
|
||||
);
|
||||
|
||||
// when — delete the session
|
||||
let deleted = store
|
||||
.delete_session(&session.session_id)
|
||||
.expect("delete should succeed");
|
||||
|
||||
// then — session is gone
|
||||
assert_eq!(deleted.id, session.session_id);
|
||||
assert!(!deleted.path.exists(), "session file should be removed");
|
||||
assert!(
|
||||
!store.session_exists(&session.session_id),
|
||||
"session should not exist after deletion"
|
||||
);
|
||||
assert!(
|
||||
store.list_sessions().expect("list").is_empty(),
|
||||
"store should have no sessions after deletion"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(base).expect("temp dir should clean up");
|
||||
}
|
||||
}
|
||||
|
||||
849
rust/crates/runtime/src/trident.rs
Normal file
849
rust/crates/runtime/src/trident.rs
Normal file
@@ -0,0 +1,849 @@
|
||||
use crate::compact::{compact_session, CompactionConfig, CompactionResult};
|
||||
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// Configuration for the Trident compaction pipeline.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TridentConfig {
|
||||
pub supersede_enabled: bool,
|
||||
pub collapse_enabled: bool,
|
||||
pub cluster_enabled: bool,
|
||||
pub collapse_threshold: usize,
|
||||
pub cluster_min_size: usize,
|
||||
pub cluster_similarity_threshold: f64,
|
||||
pub max_file_operations: usize,
|
||||
}
|
||||
|
||||
impl Default for TridentConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
supersede_enabled: true,
|
||||
collapse_enabled: true,
|
||||
cluster_enabled: true,
|
||||
collapse_threshold: 4,
|
||||
cluster_min_size: 3,
|
||||
cluster_similarity_threshold: 0.6,
|
||||
max_file_operations: 100,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistics from a Trident compaction run.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TridentStats {
|
||||
pub superseded_count: usize,
|
||||
pub collapsed_chains: usize,
|
||||
pub messages_collapsed: usize,
|
||||
pub clusters_found: usize,
|
||||
pub messages_clustered: usize,
|
||||
pub tokens_saved_estimate: usize,
|
||||
pub original_message_count: usize,
|
||||
pub final_message_count: usize,
|
||||
}
|
||||
|
||||
impl Default for TridentStats {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
superseded_count: 0,
|
||||
collapsed_chains: 0,
|
||||
messages_collapsed: 0,
|
||||
clusters_found: 0,
|
||||
messages_clustered: 0,
|
||||
tokens_saved_estimate: 0,
|
||||
original_message_count: 0,
|
||||
final_message_count: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TridentStats {
|
||||
pub fn format_report(&self) -> String {
|
||||
let compression = if self.final_message_count > 0 {
|
||||
self.original_message_count as f64 / self.final_message_count as f64
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let mut lines = vec![
|
||||
"Trident Compaction Complete".to_string(),
|
||||
format!(
|
||||
" Stage 1 (Supersede): {} obsolete removed",
|
||||
self.superseded_count
|
||||
),
|
||||
format!(
|
||||
" Stage 2 (Collapse): {} -> {} summaries",
|
||||
self.messages_collapsed, self.collapsed_chains
|
||||
),
|
||||
format!(
|
||||
" Stage 3 (Cluster): {} -> {} clusters",
|
||||
self.messages_clustered, self.clusters_found
|
||||
),
|
||||
format!(" Original: {} messages", self.original_message_count),
|
||||
format!(
|
||||
" Final: {} messages ({:.1}x compression)",
|
||||
self.final_message_count, compression
|
||||
),
|
||||
];
|
||||
if self.tokens_saved_estimate > 0 {
|
||||
lines.push(format!(
|
||||
" Est. tokens saved: ~{}",
|
||||
self.tokens_saved_estimate
|
||||
));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of the Trident compaction pipeline.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TridentResult {
|
||||
pub compacted_session: Session,
|
||||
pub stats: TridentStats,
|
||||
}
|
||||
|
||||
/// Run the full Trident compaction pipeline on a session, then apply
|
||||
/// the standard summary-based compaction.
|
||||
pub fn trident_compact_session(
|
||||
session: &Session,
|
||||
compaction_config: CompactionConfig,
|
||||
trident_config: &TridentConfig,
|
||||
) -> CompactionResult {
|
||||
let original_count = session.messages.len();
|
||||
let original_tokens: usize = session.messages.iter().map(estimate_message_tokens).sum();
|
||||
|
||||
let mut stats = TridentStats {
|
||||
original_message_count: original_count,
|
||||
..TridentStats::default()
|
||||
};
|
||||
|
||||
let mut messages = session.messages.clone();
|
||||
|
||||
if trident_config.supersede_enabled {
|
||||
let (kept, superseded_count) = stage1_supersede(&messages);
|
||||
stats.superseded_count = superseded_count;
|
||||
messages = kept;
|
||||
}
|
||||
|
||||
if trident_config.collapse_enabled {
|
||||
let (collapsed, chains, collapsed_count) =
|
||||
stage2_collapse(&messages, trident_config.collapse_threshold);
|
||||
stats.collapsed_chains = chains;
|
||||
stats.messages_collapsed = collapsed_count;
|
||||
messages = collapsed;
|
||||
}
|
||||
|
||||
if trident_config.cluster_enabled {
|
||||
let (clustered, clusters_found, messages_clustered) = stage3_cluster(
|
||||
&messages,
|
||||
trident_config.cluster_min_size,
|
||||
trident_config.cluster_similarity_threshold,
|
||||
);
|
||||
stats.clusters_found = clusters_found;
|
||||
stats.messages_clustered = messages_clustered;
|
||||
messages = clustered;
|
||||
}
|
||||
|
||||
stats.final_message_count = messages.len();
|
||||
|
||||
let final_tokens: usize = messages.iter().map(estimate_message_tokens).sum();
|
||||
stats.tokens_saved_estimate = original_tokens.saturating_sub(final_tokens);
|
||||
|
||||
let mut trident_session = session.clone();
|
||||
trident_session.messages = messages;
|
||||
|
||||
let result = compact_session(&trident_session, compaction_config);
|
||||
|
||||
if stats.superseded_count > 0 || stats.collapsed_chains > 0 || stats.clusters_found > 0 {
|
||||
eprintln!("{}", stats.format_report());
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STAGE 1: SUPERSEDE — Zero-cost factual pruning
|
||||
// =============================================================================
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum FileOp {
|
||||
Read,
|
||||
Write,
|
||||
Edit,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct FileOperation {
|
||||
index: usize,
|
||||
op_type: FileOp,
|
||||
}
|
||||
|
||||
fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessage>, usize) {
|
||||
let mut file_ops: BTreeMap<String, Vec<FileOperation>> = BTreeMap::new();
|
||||
|
||||
for (i, msg) in messages.iter().enumerate() {
|
||||
for block in &msg.blocks {
|
||||
if let Some((path, op_type)) = extract_file_operation(block) {
|
||||
file_ops
|
||||
.entry(path)
|
||||
.or_default()
|
||||
.push(FileOperation { index: i, op_type });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut obsolete_indices: BTreeSet<usize> = BTreeSet::new();
|
||||
|
||||
for (_path, ops) in &file_ops {
|
||||
if ops.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let last_write_idx = ops
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|op| op.op_type == FileOp::Write || op.op_type == FileOp::Edit)
|
||||
.map(|op| op.index);
|
||||
|
||||
if let Some(last_write) = last_write_idx {
|
||||
for op in ops {
|
||||
if op.op_type == FileOp::Read && op.index < last_write {
|
||||
obsolete_indices.insert(op.index);
|
||||
} else if (op.op_type == FileOp::Write || op.op_type == FileOp::Edit)
|
||||
&& op.index < last_write
|
||||
{
|
||||
obsolete_indices.insert(op.index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let superseded_count = obsolete_indices.len();
|
||||
let kept: Vec<ConversationMessage> = messages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| !obsolete_indices.contains(i))
|
||||
.map(|(_, msg)| msg.clone())
|
||||
.collect();
|
||||
|
||||
(kept, superseded_count)
|
||||
}
|
||||
|
||||
fn extract_file_operation(block: &ContentBlock) -> Option<(String, FileOp)> {
|
||||
match block {
|
||||
ContentBlock::ToolUse { name, input, .. } => {
|
||||
let path = extract_path_from_tool_input(name, input)?;
|
||||
let op_type = match name.as_str() {
|
||||
"read_file" | "Read" => FileOp::Read,
|
||||
"write_file" | "Write" => FileOp::Write,
|
||||
"edit_file" | "Edit" => FileOp::Edit,
|
||||
_ => return None,
|
||||
};
|
||||
Some((path, op_type))
|
||||
}
|
||||
ContentBlock::ToolResult {
|
||||
tool_name, output, ..
|
||||
} => {
|
||||
let path = extract_path_from_tool_output(tool_name, output)?;
|
||||
let op_type = match tool_name.as_str() {
|
||||
"read_file" | "Read" => FileOp::Read,
|
||||
"write_file" | "Write" => FileOp::Write,
|
||||
"edit_file" | "Edit" => FileOp::Edit,
|
||||
_ => return None,
|
||||
};
|
||||
Some((path, op_type))
|
||||
}
|
||||
ContentBlock::Text { .. } => None,
|
||||
ContentBlock::Thinking { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_path_from_tool_input(tool_name: &str, input: &str) -> Option<String> {
|
||||
if !matches!(
|
||||
tool_name,
|
||||
"read_file" | "write_file" | "edit_file" | "Read" | "Write" | "Edit"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_str::<serde_json::Value>(input)
|
||||
.ok()
|
||||
.and_then(|v| v.get("path")?.as_str().map(String::from))
|
||||
.or_else(|| {
|
||||
serde_json::from_str::<serde_json::Value>(input)
|
||||
.ok()
|
||||
.and_then(|v| v.get("file_path")?.as_str().map(String::from))
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_path_from_tool_output(tool_name: &str, output: &str) -> Option<String> {
|
||||
if !matches!(
|
||||
tool_name,
|
||||
"read_file" | "write_file" | "edit_file" | "Read" | "Write" | "Edit"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_str::<serde_json::Value>(output)
|
||||
.ok()
|
||||
.and_then(|v| v.get("path")?.as_str().map(String::from))
|
||||
.or_else(|| {
|
||||
output
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|line| line.strip_prefix("path: "))
|
||||
.map(String::from)
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STAGE 2: COLLAPSE — Summarize chatty exchanges
|
||||
// =============================================================================
|
||||
|
||||
fn stage2_collapse(
|
||||
messages: &[ConversationMessage],
|
||||
threshold: usize,
|
||||
) -> (Vec<ConversationMessage>, usize, usize) {
|
||||
if messages.len() < threshold {
|
||||
return (messages.to_vec(), 0, 0);
|
||||
}
|
||||
|
||||
let mut result: Vec<ConversationMessage> = Vec::new();
|
||||
let mut buffer: Vec<ConversationMessage> = Vec::new();
|
||||
let mut total_chains = 0;
|
||||
let mut total_collapsed = 0;
|
||||
|
||||
for msg in messages {
|
||||
if is_chatty_message(msg) {
|
||||
buffer.push(msg.clone());
|
||||
} else {
|
||||
if buffer.len() >= threshold {
|
||||
let summary = generate_collapse_summary(&buffer);
|
||||
total_chains += 1;
|
||||
total_collapsed += buffer.len();
|
||||
result.push(ConversationMessage {
|
||||
role: MessageRole::System,
|
||||
blocks: vec![ContentBlock::Text {
|
||||
text: format!("[Collapsed Conversation]\n{summary}"),
|
||||
}],
|
||||
usage: None,
|
||||
});
|
||||
} else {
|
||||
result.extend(buffer.drain(..));
|
||||
}
|
||||
buffer.clear();
|
||||
result.push(msg.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if buffer.len() >= threshold {
|
||||
let summary = generate_collapse_summary(&buffer);
|
||||
total_chains += 1;
|
||||
total_collapsed += buffer.len();
|
||||
result.push(ConversationMessage {
|
||||
role: MessageRole::System,
|
||||
blocks: vec![ContentBlock::Text {
|
||||
text: format!("[Collapsed Conversation]\n{summary}"),
|
||||
}],
|
||||
usage: None,
|
||||
});
|
||||
} else {
|
||||
result.extend(buffer);
|
||||
}
|
||||
|
||||
(result, total_chains, total_collapsed)
|
||||
}
|
||||
|
||||
fn is_chatty_message(msg: &ConversationMessage) -> bool {
|
||||
let total_chars: usize = msg
|
||||
.blocks
|
||||
.iter()
|
||||
.map(|b| match b {
|
||||
ContentBlock::Text { text } => text.len(),
|
||||
ContentBlock::ToolUse { input, .. } => input.len(),
|
||||
ContentBlock::ToolResult { output, .. } => output.len(),
|
||||
ContentBlock::Thinking { thinking, .. } => thinking.len(),
|
||||
})
|
||||
.sum();
|
||||
|
||||
let has_tool_use = msg
|
||||
.blocks
|
||||
.iter()
|
||||
.any(|b| matches!(b, ContentBlock::ToolUse { .. }));
|
||||
let has_tool_result = msg
|
||||
.blocks
|
||||
.iter()
|
||||
.any(|b| matches!(b, ContentBlock::ToolResult { .. }));
|
||||
|
||||
if has_tool_use || has_tool_result {
|
||||
return false;
|
||||
}
|
||||
|
||||
total_chars < 200
|
||||
}
|
||||
|
||||
fn generate_collapse_summary(messages: &[ConversationMessage]) -> String {
|
||||
let user_count = messages
|
||||
.iter()
|
||||
.filter(|m| m.role == MessageRole::User)
|
||||
.count();
|
||||
let assistant_count = messages
|
||||
.iter()
|
||||
.filter(|m| m.role == MessageRole::Assistant)
|
||||
.count();
|
||||
|
||||
let mut topics: Vec<String> = messages
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
m.blocks.iter().find_map(|b| match b {
|
||||
ContentBlock::Text { text } if !text.trim().is_empty() => {
|
||||
Some(truncate_text(text, 80))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
.take(5)
|
||||
.collect();
|
||||
topics.dedup();
|
||||
|
||||
let mut lines = vec![format!(
|
||||
"Collapsed {} messages ({} user, {} assistant).",
|
||||
messages.len(),
|
||||
user_count,
|
||||
assistant_count
|
||||
)];
|
||||
|
||||
if !topics.is_empty() {
|
||||
lines.push("Topics:".to_string());
|
||||
for topic in &topics {
|
||||
lines.push(format!(" - {topic}"));
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STAGE 3: CLUSTER — Semantic grouping and deep storage
|
||||
// =============================================================================
|
||||
|
||||
fn stage3_cluster(
|
||||
messages: &[ConversationMessage],
|
||||
min_cluster_size: usize,
|
||||
similarity_threshold: f64,
|
||||
) -> (Vec<ConversationMessage>, usize, usize) {
|
||||
if messages.len() < min_cluster_size {
|
||||
return (messages.to_vec(), 0, 0);
|
||||
}
|
||||
|
||||
let fingerprints: Vec<MessageFingerprint> = messages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, msg)| fingerprint_message(i, msg))
|
||||
.collect();
|
||||
|
||||
if fingerprints.len() < min_cluster_size {
|
||||
return (messages.to_vec(), 0, 0);
|
||||
}
|
||||
|
||||
let mut cluster_assignments: BTreeMap<usize, usize> = BTreeMap::new();
|
||||
let mut cluster_id = 0;
|
||||
|
||||
for i in 0..fingerprints.len() {
|
||||
if cluster_assignments.contains_key(&fingerprints[i].index) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut cluster_members: Vec<usize> = vec![fingerprints[i].index];
|
||||
|
||||
for j in (i + 1)..fingerprints.len() {
|
||||
if cluster_assignments.contains_key(&fingerprints[j].index) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let similarity = compute_similarity(&fingerprints[i], &fingerprints[j]);
|
||||
if similarity >= similarity_threshold {
|
||||
cluster_members.push(fingerprints[j].index);
|
||||
}
|
||||
}
|
||||
|
||||
if cluster_members.len() >= min_cluster_size {
|
||||
for member_idx in &cluster_members {
|
||||
cluster_assignments.insert(*member_idx, cluster_id);
|
||||
}
|
||||
cluster_id += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if cluster_assignments.is_empty() {
|
||||
return (messages.to_vec(), 0, 0);
|
||||
}
|
||||
|
||||
let total_clustered: usize = cluster_assignments.len();
|
||||
let clusters_found = cluster_id as usize;
|
||||
|
||||
let mut result: Vec<ConversationMessage> = Vec::new();
|
||||
let mut cluster_buffers: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
|
||||
|
||||
for (msg_idx, &cid) in &cluster_assignments {
|
||||
cluster_buffers.entry(cid).or_default().push(*msg_idx);
|
||||
}
|
||||
|
||||
for (i, msg) in messages.iter().enumerate() {
|
||||
if let Some(&cid) = cluster_assignments.get(&i) {
|
||||
if let Some(buffer) = cluster_buffers.get_mut(&cid) {
|
||||
if buffer[0] == i {
|
||||
let cluster_messages: Vec<&ConversationMessage> =
|
||||
buffer.iter().filter_map(|&idx| messages.get(idx)).collect();
|
||||
let summary = generate_cluster_summary(&cluster_messages);
|
||||
result.push(ConversationMessage {
|
||||
role: MessageRole::System,
|
||||
blocks: vec![ContentBlock::Text {
|
||||
text: format!("[Clustered {} messages]\n{summary}", buffer.len()),
|
||||
}],
|
||||
usage: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.push(msg.clone());
|
||||
}
|
||||
}
|
||||
|
||||
(result, clusters_found, total_clustered)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct MessageFingerprint {
|
||||
index: usize,
|
||||
tool_names: BTreeSet<String>,
|
||||
file_paths: BTreeSet<String>,
|
||||
role: MessageRole,
|
||||
text_length: usize,
|
||||
}
|
||||
|
||||
fn fingerprint_message(index: usize, msg: &ConversationMessage) -> Option<MessageFingerprint> {
|
||||
if msg.role == MessageRole::System {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut tool_names: BTreeSet<String> = BTreeSet::new();
|
||||
let mut file_paths: BTreeSet<String> = BTreeSet::new();
|
||||
let mut text_length = 0;
|
||||
|
||||
for block in &msg.blocks {
|
||||
match block {
|
||||
ContentBlock::ToolUse { name, input, .. } => {
|
||||
tool_names.insert(name.clone());
|
||||
if let Some(path) = extract_path_from_tool_input(name, input) {
|
||||
file_paths.insert(path);
|
||||
}
|
||||
text_length += input.len();
|
||||
}
|
||||
ContentBlock::ToolResult {
|
||||
tool_name, output, ..
|
||||
} => {
|
||||
tool_names.insert(tool_name.clone());
|
||||
if let Some(path) = extract_path_from_tool_output(tool_name, output) {
|
||||
file_paths.insert(path);
|
||||
}
|
||||
text_length += output.len();
|
||||
}
|
||||
ContentBlock::Text { text } => {
|
||||
text_length += text.len();
|
||||
}
|
||||
ContentBlock::Thinking { thinking, .. } => {
|
||||
text_length += thinking.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(MessageFingerprint {
|
||||
index,
|
||||
tool_names,
|
||||
file_paths,
|
||||
role: msg.role,
|
||||
text_length,
|
||||
})
|
||||
}
|
||||
|
||||
fn compute_similarity(a: &MessageFingerprint, b: &MessageFingerprint) -> f64 {
|
||||
if a.role != b.role {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let tool_overlap = if a.tool_names.is_empty() && b.tool_names.is_empty() {
|
||||
1.0
|
||||
} else if a.tool_names.is_empty() || b.tool_names.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
let intersection: usize = a.tool_names.intersection(&b.tool_names).count();
|
||||
let union: usize = a.tool_names.union(&b.tool_names).count();
|
||||
intersection as f64 / union as f64
|
||||
};
|
||||
|
||||
let file_overlap = if a.file_paths.is_empty() && b.file_paths.is_empty() {
|
||||
1.0
|
||||
} else if a.file_paths.is_empty() || b.file_paths.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
let intersection: usize = a.file_paths.intersection(&b.file_paths).count();
|
||||
let union: usize = a.file_paths.union(&b.file_paths).count();
|
||||
intersection as f64 / union as f64
|
||||
};
|
||||
|
||||
let length_similarity = if a.text_length == 0 && b.text_length == 0 {
|
||||
1.0
|
||||
} else if a.text_length == 0 || b.text_length == 0 {
|
||||
0.0
|
||||
} else {
|
||||
let min_len = a.text_length.min(b.text_length) as f64;
|
||||
let max_len = a.text_length.max(b.text_length) as f64;
|
||||
min_len / max_len
|
||||
};
|
||||
|
||||
0.4 * tool_overlap + 0.4 * file_overlap + 0.2 * length_similarity
|
||||
}
|
||||
|
||||
fn generate_cluster_summary(messages: &[&ConversationMessage]) -> String {
|
||||
let mut tool_names: BTreeSet<String> = BTreeSet::new();
|
||||
let mut file_paths: BTreeSet<String> = BTreeSet::new();
|
||||
|
||||
for msg in messages {
|
||||
for block in &msg.blocks {
|
||||
match block {
|
||||
ContentBlock::ToolUse { name, input, .. } => {
|
||||
tool_names.insert(name.clone());
|
||||
if let Some(path) = extract_path_from_tool_input(name, input) {
|
||||
file_paths.insert(path);
|
||||
}
|
||||
}
|
||||
ContentBlock::ToolResult {
|
||||
tool_name, output, ..
|
||||
} => {
|
||||
tool_names.insert(tool_name.clone());
|
||||
if let Some(path) = extract_path_from_tool_output(tool_name, output) {
|
||||
file_paths.insert(path);
|
||||
}
|
||||
}
|
||||
ContentBlock::Text { .. } => {}
|
||||
ContentBlock::Thinking { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut lines = vec![format!("{} similar messages grouped.", messages.len())];
|
||||
|
||||
if !tool_names.is_empty() {
|
||||
lines.push(format!(
|
||||
"Tools: {}.",
|
||||
tool_names.iter().cloned().collect::<Vec<_>>().join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
if !file_paths.is_empty() {
|
||||
let paths: Vec<String> = file_paths.iter().take(5).cloned().collect();
|
||||
lines.push(format!("Files: {}.", paths.join(", ")));
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Utilities
|
||||
// =============================================================================
|
||||
|
||||
fn estimate_message_tokens(message: &ConversationMessage) -> usize {
|
||||
message
|
||||
.blocks
|
||||
.iter()
|
||||
.map(|block| match block {
|
||||
ContentBlock::Text { text } => text.len() / 4 + 1,
|
||||
ContentBlock::ToolUse { name, input, .. } => (name.len() + input.len()) / 4 + 1,
|
||||
ContentBlock::ToolResult {
|
||||
tool_name, output, ..
|
||||
} => (tool_name.len() + output.len()) / 4 + 1,
|
||||
ContentBlock::Thinking { thinking, .. } => thinking.len() / 4 + 1,
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn truncate_text(text: &str, max_chars: usize) -> String {
|
||||
if text.chars().count() <= max_chars {
|
||||
return text.to_string();
|
||||
}
|
||||
let mut truncated: String = text.chars().take(max_chars).collect();
|
||||
truncated.push('…');
|
||||
truncated
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::compact::CompactionConfig;
|
||||
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
||||
|
||||
#[test]
|
||||
fn stage1_removes_obsolete_file_reads() {
|
||||
let messages = vec![
|
||||
ConversationMessage::assistant(vec![ContentBlock::ToolUse {
|
||||
id: "1".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
input: r#"{"path":"src/main.rs"}"#.to_string(),
|
||||
}]),
|
||||
ConversationMessage::tool_result(
|
||||
"1",
|
||||
"read_file",
|
||||
r#"{"path":"src/main.rs","content":"old"}"#,
|
||||
false,
|
||||
),
|
||||
ConversationMessage::assistant(vec![ContentBlock::ToolUse {
|
||||
id: "2".to_string(),
|
||||
name: "edit_file".to_string(),
|
||||
input: r#"{"path":"src/main.rs","old":"old","new":"new"}"#.to_string(),
|
||||
}]),
|
||||
ConversationMessage::tool_result(
|
||||
"2",
|
||||
"edit_file",
|
||||
r#"{"path":"src/main.rs","ok":true}"#,
|
||||
false,
|
||||
),
|
||||
];
|
||||
|
||||
let (kept, superseded) = stage1_supersede(&messages);
|
||||
assert!(superseded > 0, "should supersede the earlier read");
|
||||
assert!(kept.len() < messages.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage1_keeps_standalone_reads() {
|
||||
let messages = vec![
|
||||
ConversationMessage::assistant(vec![ContentBlock::ToolUse {
|
||||
id: "1".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
input: r#"{"path":"src/main.rs"}"#.to_string(),
|
||||
}]),
|
||||
ConversationMessage::tool_result(
|
||||
"1",
|
||||
"read_file",
|
||||
r#"{"path":"src/main.rs","content":"data"}"#,
|
||||
false,
|
||||
),
|
||||
];
|
||||
|
||||
let (kept, superseded) = stage1_supersede(&messages);
|
||||
assert_eq!(superseded, 0);
|
||||
assert_eq!(kept.len(), messages.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage2_collapses_chatty_messages() {
|
||||
let mut messages = vec![];
|
||||
for i in 0..6 {
|
||||
messages.push(ConversationMessage::user_text(&format!("ok {i}")));
|
||||
messages.push(ConversationMessage::assistant(vec![ContentBlock::Text {
|
||||
text: format!("got {i}"),
|
||||
}]));
|
||||
}
|
||||
messages.push(ConversationMessage::assistant(vec![
|
||||
ContentBlock::ToolUse {
|
||||
id: "t".to_string(),
|
||||
name: "bash".to_string(),
|
||||
input: r#"{"command":"ls"}"#.to_string(),
|
||||
},
|
||||
]));
|
||||
|
||||
let (result, chains, collapsed) = stage2_collapse(&messages, 4);
|
||||
assert!(chains > 0, "should collapse at least one chain");
|
||||
assert!(collapsed > 0);
|
||||
assert!(result.len() < messages.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage3_clusters_similar_messages() {
|
||||
let mut messages = vec![];
|
||||
for i in 0..5 {
|
||||
messages.push(ConversationMessage::assistant(vec![
|
||||
ContentBlock::ToolUse {
|
||||
id: format!("read_{i}"),
|
||||
name: "read_file".to_string(),
|
||||
input: format!(r#"{{"path":"src/{i}.rs"}}"#),
|
||||
},
|
||||
]));
|
||||
messages.push(ConversationMessage::tool_result(
|
||||
&format!("read_{i}"),
|
||||
"read_file",
|
||||
&format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#),
|
||||
false,
|
||||
));
|
||||
}
|
||||
|
||||
let (result, clusters, clustered) = stage3_cluster(&messages, 3, 0.4);
|
||||
assert!(clusters > 0, "should find at least one cluster");
|
||||
assert!(clustered > 0);
|
||||
assert!(result.len() < messages.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trident_full_pipeline_preserves_important_content() {
|
||||
let mut session = Session::new();
|
||||
session.messages = vec![
|
||||
ConversationMessage::user_text("Read and fix main.rs"),
|
||||
ConversationMessage::assistant(vec![ContentBlock::ToolUse {
|
||||
id: "1".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
input: r#"{"path":"src/main.rs"}"#.to_string(),
|
||||
}]),
|
||||
ConversationMessage::tool_result(
|
||||
"1",
|
||||
"read_file",
|
||||
r#"{"path":"src/main.rs","content":"fn main() { buggy }"}"#,
|
||||
false,
|
||||
),
|
||||
ConversationMessage::assistant(vec![ContentBlock::ToolUse {
|
||||
id: "2".to_string(),
|
||||
name: "edit_file".to_string(),
|
||||
input: r#"{"path":"src/main.rs","old":"buggy","new":"fixed"}"#.to_string(),
|
||||
}]),
|
||||
ConversationMessage::tool_result(
|
||||
"2",
|
||||
"edit_file",
|
||||
r#"{"path":"src/main.rs","ok":true}"#,
|
||||
false,
|
||||
),
|
||||
ConversationMessage::assistant(vec![ContentBlock::Text {
|
||||
text: "Fixed the bug in main.rs".to_string(),
|
||||
}]),
|
||||
];
|
||||
|
||||
let trident_config = TridentConfig::default();
|
||||
let result = trident_compact_session(
|
||||
&session,
|
||||
CompactionConfig {
|
||||
preserve_recent_messages: 4,
|
||||
max_estimated_tokens: 1,
|
||||
},
|
||||
&trident_config,
|
||||
);
|
||||
|
||||
assert!(
|
||||
result.removed_message_count > 0
|
||||
|| result.compacted_session.messages.len() < session.messages.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trident_stats_report() {
|
||||
let stats = TridentStats {
|
||||
superseded_count: 5,
|
||||
collapsed_chains: 2,
|
||||
messages_collapsed: 8,
|
||||
clusters_found: 1,
|
||||
messages_clustered: 3,
|
||||
tokens_saved_estimate: 1200,
|
||||
original_message_count: 20,
|
||||
final_message_count: 8,
|
||||
};
|
||||
let report = stats.format_report();
|
||||
assert!(report.contains("Stage 1 (Supersede): 5"));
|
||||
assert!(report.contains("Stage 2 (Collapse): 8 -> 2"));
|
||||
assert!(report.contains("Stage 3 (Cluster): 3 -> 1"));
|
||||
assert!(report.contains("1200") || report.contains("1,200"));
|
||||
}
|
||||
}
|
||||
@@ -438,13 +438,24 @@ fn normalize_path(path: &Path) -> PathBuf {
|
||||
/// Extract repository name from a path for event context.
|
||||
fn extract_repo_name(cwd: &str) -> Option<String> {
|
||||
let path = Path::new(cwd);
|
||||
// Try to find a .git directory to identify repo root
|
||||
let mut current = Some(path);
|
||||
while let Some(p) = current {
|
||||
if p.join(".git").is_dir() {
|
||||
return p.file_name().map(|n| n.to_string_lossy().to_string());
|
||||
// Ask git from the cwd itself. Walking ancestors manually can accidentally
|
||||
// classify synthetic/nonexistent paths as an unrelated parent repo (for
|
||||
// example `/tmp/.git`), which makes trust events point at the wrong repo.
|
||||
if path.is_dir() {
|
||||
if let Ok(output) = std::process::Command::new("git")
|
||||
.args(["rev-parse", "--show-toplevel"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !root.is_empty() {
|
||||
return Path::new(&root)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
current = p.parent();
|
||||
}
|
||||
// Fallback: use the last component of the path
|
||||
path.file_name().map(|n| n.to_string_lossy().to_string())
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -73,6 +74,7 @@ pub struct WorkerFailure {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkerEventKind {
|
||||
Spawning,
|
||||
StartupPreflightWarning,
|
||||
TrustRequired,
|
||||
ToolPermissionRequired,
|
||||
TrustResolved,
|
||||
@@ -102,6 +104,21 @@ pub enum WorkerPromptTarget {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkerStartupPreflightWarningKind {
|
||||
FileAbsentOnBranch,
|
||||
GitMetadataNotWritable,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkerStartupPreflightWarning {
|
||||
pub kind: WorkerStartupPreflightWarningKind,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// Classification of startup failure when no evidence is available.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -212,6 +229,12 @@ pub enum WorkerEventPayload {
|
||||
evidence: StartupEvidenceBundle,
|
||||
classification: StartupFailureClassification,
|
||||
},
|
||||
StartupPreflightWarning {
|
||||
kind: WorkerStartupPreflightWarningKind,
|
||||
message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
path: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -329,6 +352,34 @@ impl WorkerRegistry {
|
||||
inner.workers.get(worker_id).cloned()
|
||||
}
|
||||
|
||||
pub fn observe_startup_preflight(
|
||||
&self,
|
||||
worker_id: &str,
|
||||
task_prompt: &str,
|
||||
) -> Result<Worker, String> {
|
||||
let mut inner = self.inner.lock().expect("worker registry lock poisoned");
|
||||
let worker = inner
|
||||
.workers
|
||||
.get_mut(worker_id)
|
||||
.ok_or_else(|| format!("worker not found: {worker_id}"))?;
|
||||
|
||||
for warning in startup_preflight_warnings(Path::new(&worker.cwd), task_prompt) {
|
||||
push_event(
|
||||
worker,
|
||||
WorkerEventKind::StartupPreflightWarning,
|
||||
worker.status,
|
||||
Some(warning.message.clone()),
|
||||
Some(WorkerEventPayload::StartupPreflightWarning {
|
||||
kind: warning.kind,
|
||||
message: warning.message,
|
||||
path: warning.path,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(worker.clone())
|
||||
}
|
||||
|
||||
pub fn observe(&self, worker_id: &str, screen_text: &str) -> Result<Worker, String> {
|
||||
let mut inner = self.inner.lock().expect("worker registry lock poisoned");
|
||||
let worker = inner
|
||||
@@ -1064,6 +1115,128 @@ fn extract_server_from_qualified_tool(tool: &str) -> Option<String> {
|
||||
(!server.is_empty()).then(|| server.to_string())
|
||||
}
|
||||
|
||||
pub fn startup_preflight_warnings(
|
||||
cwd: &Path,
|
||||
task_prompt: &str,
|
||||
) -> Vec<WorkerStartupPreflightWarning> {
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
if let Some(git_path) = git_metadata_path(cwd) {
|
||||
if !path_is_writable(&git_path) {
|
||||
warnings.push(WorkerStartupPreflightWarning {
|
||||
kind: WorkerStartupPreflightWarningKind::GitMetadataNotWritable,
|
||||
message: format!(
|
||||
"git metadata is not writable; commits or pushes may fail: {}",
|
||||
git_path.display()
|
||||
),
|
||||
path: Some(git_path.display().to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for path in mentioned_repo_paths(task_prompt) {
|
||||
if !git_tracks_path(cwd, &path) {
|
||||
warnings.push(WorkerStartupPreflightWarning {
|
||||
kind: WorkerStartupPreflightWarningKind::FileAbsentOnBranch,
|
||||
message: format!(
|
||||
"task mentions {path}, but git does not track it on the current branch"
|
||||
),
|
||||
path: Some(path),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
warnings
|
||||
}
|
||||
|
||||
fn mentioned_repo_paths(task_prompt: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for raw in task_prompt.split_whitespace() {
|
||||
let token = raw.trim_matches(|ch: char| {
|
||||
matches!(
|
||||
ch,
|
||||
'`' | '"' | '\'' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';' | ':'
|
||||
)
|
||||
});
|
||||
if !token.contains('/') || token.contains("://") || token.starts_with('/') {
|
||||
continue;
|
||||
}
|
||||
let token = token.trim_start_matches("./");
|
||||
if token.contains("..") {
|
||||
continue;
|
||||
}
|
||||
if token
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '_' | '-' | '.'))
|
||||
&& token
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.is_some_and(|name| name.contains('.'))
|
||||
&& !out.iter().any(|seen| seen == token)
|
||||
{
|
||||
out.push(token.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn git_tracks_path(cwd: &Path, path: &str) -> bool {
|
||||
Command::new("git")
|
||||
.arg("ls-files")
|
||||
.arg("--error-unmatch")
|
||||
.arg("--")
|
||||
.arg(path)
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.is_ok_and(|output| output.status.success())
|
||||
}
|
||||
|
||||
fn git_metadata_path(cwd: &Path) -> Option<PathBuf> {
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "--git-dir"])
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let path = PathBuf::from(text);
|
||||
Some(if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
cwd.join(path)
|
||||
})
|
||||
}
|
||||
|
||||
fn path_is_writable(path: &Path) -> bool {
|
||||
let probe_dir = if path.is_dir() {
|
||||
path
|
||||
} else {
|
||||
path.parent().unwrap_or(path)
|
||||
};
|
||||
std::fs::metadata(probe_dir)
|
||||
.ok()
|
||||
.filter(std::fs::Metadata::is_dir)
|
||||
.is_some_and(|metadata| metadata_allows_directory_writes(&metadata))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn metadata_allows_directory_writes(metadata: &std::fs::Metadata) -> bool {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mode = metadata.permissions().mode();
|
||||
mode & 0o222 != 0 && mode & 0o111 != 0
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn metadata_allows_directory_writes(metadata: &std::fs::Metadata) -> bool {
|
||||
!metadata.permissions().readonly()
|
||||
}
|
||||
|
||||
fn detect_trust_prompt(lowered: &str) -> bool {
|
||||
[
|
||||
"do you trust the files in this folder",
|
||||
@@ -1285,6 +1458,8 @@ fn cwd_matches_observed_target(expected_cwd: &str, observed_cwd: &str) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn allowlisted_trust_prompt_auto_resolves_then_reaches_ready_state() {
|
||||
@@ -1431,6 +1606,116 @@ mod tests {
|
||||
assert!(!readiness.ready);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_preflight_warns_when_task_file_is_absent_on_branch() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
Command::new("git")
|
||||
.arg("init")
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.expect("git init should run");
|
||||
fs::create_dir_all(tmp.path().join("src")).expect("src dir");
|
||||
fs::write(tmp.path().join("src/lib.rs"), "pub fn present() {}\n").expect("write file");
|
||||
Command::new("git")
|
||||
.args(["add", "src/lib.rs"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.expect("git add should run");
|
||||
|
||||
let warnings = startup_preflight_warnings(
|
||||
tmp.path(),
|
||||
"Fix src/lib.rs and rust/crates/runtime/src/trident.rs before testing.",
|
||||
);
|
||||
|
||||
assert!(warnings.iter().any(|warning| {
|
||||
warning.kind == WorkerStartupPreflightWarningKind::FileAbsentOnBranch
|
||||
&& warning.path.as_deref() == Some("rust/crates/runtime/src/trident.rs")
|
||||
}));
|
||||
assert!(!warnings.iter().any(|warning| {
|
||||
warning.kind == WorkerStartupPreflightWarningKind::FileAbsentOnBranch
|
||||
&& warning.path.as_deref() == Some("src/lib.rs")
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn startup_preflight_warns_when_git_metadata_is_not_writable() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
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");
|
||||
|
||||
let original_permissions = fs::metadata(&git_dir)
|
||||
.expect("gitdir metadata")
|
||||
.permissions();
|
||||
let mut read_only_permissions = original_permissions.clone();
|
||||
read_only_permissions.set_mode(0o555);
|
||||
fs::set_permissions(&git_dir, read_only_permissions).expect("make gitdir read-only");
|
||||
|
||||
let warnings = startup_preflight_warnings(&worktree, "Audit repository.");
|
||||
let registry = WorkerRegistry::new();
|
||||
let worker = registry.create(&worktree.display().to_string(), &[], true);
|
||||
let observed = registry
|
||||
.observe_startup_preflight(&worker.worker_id, "Audit repository.")
|
||||
.expect("preflight should run");
|
||||
|
||||
fs::set_permissions(&git_dir, original_permissions).expect("restore gitdir permissions");
|
||||
|
||||
assert!(warnings.iter().any(|warning| {
|
||||
warning.kind == WorkerStartupPreflightWarningKind::GitMetadataNotWritable
|
||||
&& warning.path.as_deref() == Some(git_dir.to_string_lossy().as_ref())
|
||||
}));
|
||||
assert!(observed.events.iter().any(|event| {
|
||||
matches!(
|
||||
&event.payload,
|
||||
Some(WorkerEventPayload::StartupPreflightWarning {
|
||||
kind: WorkerStartupPreflightWarningKind::GitMetadataNotWritable,
|
||||
path: Some(path),
|
||||
..
|
||||
}) if path == git_dir.to_string_lossy().as_ref()
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_preflight_records_structured_warning_event() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
Command::new("git")
|
||||
.arg("init")
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.expect("git init should run");
|
||||
let registry = WorkerRegistry::new();
|
||||
let worker = registry.create(&tmp.path().display().to_string(), &[], true);
|
||||
|
||||
let observed = registry
|
||||
.observe_startup_preflight(&worker.worker_id, "Open missing/file.rs")
|
||||
.expect("preflight should run");
|
||||
|
||||
let event = observed
|
||||
.events
|
||||
.iter()
|
||||
.find(|event| event.kind == WorkerEventKind::StartupPreflightWarning)
|
||||
.expect("preflight warning event");
|
||||
assert!(matches!(
|
||||
event.payload,
|
||||
Some(WorkerEventPayload::StartupPreflightWarning {
|
||||
kind: WorkerStartupPreflightWarningKind::FileAbsentOnBranch,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_timeout_classifies_tool_permission_prompt() {
|
||||
let registry = WorkerRegistry::new();
|
||||
|
||||
@@ -12,7 +12,6 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
api = { path = "../api" }
|
||||
commands = { path = "../commands" }
|
||||
compat-harness = { path = "../compat-harness" }
|
||||
crossterm = "0.28"
|
||||
pulldown-cmark = "0.13"
|
||||
rustyline = "15"
|
||||
@@ -23,6 +22,8 @@ serde_json.workspace = true
|
||||
syntect = "5"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "signal", "time"] }
|
||||
tools = { path = "../tools" }
|
||||
log = "0.4"
|
||||
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
use std::env;
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
// Get git SHA (short hash)
|
||||
let git_sha = Command::new("git")
|
||||
.args(["rev-parse", "--short", "HEAD"])
|
||||
fn command_output(program: &str, args: &[&str]) -> Option<String> {
|
||||
Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| {
|
||||
@@ -14,11 +13,37 @@ fn main() {
|
||||
None
|
||||
}
|
||||
})
|
||||
.map_or_else(|| "unknown".to_string(), |s| s.trim().to_string());
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let git_sha =
|
||||
command_output("git", &["rev-parse", "HEAD"]).unwrap_or_else(|| "unknown".to_string());
|
||||
let git_sha_short = command_output("git", &["rev-parse", "--short=12", "HEAD"])
|
||||
.or_else(|| git_sha.get(..git_sha.len().min(12)).map(str::to_string))
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let git_dirty = command_output("git", &["status", "--porcelain"])
|
||||
.map(|status| (!status.trim().is_empty()).to_string())
|
||||
.unwrap_or_else(|| "false".to_string());
|
||||
let git_branch = command_output("git", &["branch", "--show-current"])
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let git_commit_date = command_output("git", &["show", "-s", "--format=%cI", "HEAD"])
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let git_commit_timestamp = command_output("git", &["show", "-s", "--format=%ct", "HEAD"])
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let rustc_version =
|
||||
command_output("rustc", &["--version"]).unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
println!("cargo:rustc-env=GIT_SHA={git_sha}");
|
||||
println!("cargo:rustc-env=GIT_SHA_SHORT={git_sha_short}");
|
||||
println!("cargo:rustc-env=GIT_DIRTY={git_dirty}");
|
||||
println!("cargo:rustc-env=GIT_BRANCH={git_branch}");
|
||||
println!("cargo:rustc-env=GIT_COMMIT_DATE={git_commit_date}");
|
||||
println!("cargo:rustc-env=GIT_COMMIT_TIMESTAMP={git_commit_timestamp}");
|
||||
println!("cargo:rustc-env=RUSTC_VERSION={rustc_version}");
|
||||
|
||||
// TARGET is always set by Cargo during build
|
||||
// TARGET is always set by Cargo during build.
|
||||
let target = env::var("TARGET").unwrap_or_else(|_| "unknown".to_string());
|
||||
println!("cargo:rustc-env=TARGET={target}");
|
||||
|
||||
@@ -35,23 +60,12 @@ fn main() {
|
||||
})
|
||||
.or_else(|| std::env::var("BUILD_DATE").ok())
|
||||
.unwrap_or_else(|| {
|
||||
// Fall back to current date via `date` command
|
||||
Command::new("date")
|
||||
.args(["+%Y-%m-%d"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
if o.status.success() {
|
||||
String::from_utf8(o.stdout).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.map_or_else(|| "unknown".to_string(), |s| s.trim().to_string())
|
||||
command_output("date", &["+%Y-%m-%d"]).unwrap_or_else(|| "unknown".to_string())
|
||||
});
|
||||
println!("cargo:rustc-env=BUILD_DATE={build_date}");
|
||||
|
||||
// Rerun if git state changes
|
||||
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||
println!("cargo:rerun-if-changed=.git/refs");
|
||||
// Rerun if git state changes. Paths are relative to this package root.
|
||||
println!("cargo:rerun-if-changed=../../../.git/HEAD");
|
||||
println!("cargo:rerun-if-changed=../../../.git/refs");
|
||||
println!("cargo:rerun-if-changed=../../../.git/index");
|
||||
}
|
||||
|
||||
@@ -4,7 +4,14 @@ use std::path::{Path, PathBuf};
|
||||
const STARTER_CLAW_JSON: &str = concat!(
|
||||
"{\n",
|
||||
" \"permissions\": {\n",
|
||||
" \"defaultMode\": \"dontAsk\"\n",
|
||||
" \"defaultMode\": \"acceptEdits\"\n",
|
||||
" }\n",
|
||||
"}\n",
|
||||
);
|
||||
const STARTER_SETTINGS_JSON: &str = concat!(
|
||||
"{\n",
|
||||
" \"permissions\": {\n",
|
||||
" \"defaultMode\": \"acceptEdits\"\n",
|
||||
" }\n",
|
||||
"}\n",
|
||||
);
|
||||
@@ -15,6 +22,8 @@ const GITIGNORE_ENTRIES: [&str; 3] = [".claw/settings.local.json", ".claw/sessio
|
||||
pub(crate) enum InitStatus {
|
||||
Created,
|
||||
Updated,
|
||||
Partial,
|
||||
Deferred,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
@@ -24,6 +33,8 @@ impl InitStatus {
|
||||
match self {
|
||||
Self::Created => "created",
|
||||
Self::Updated => "updated",
|
||||
Self::Partial => "partial (created missing sub-files)",
|
||||
Self::Deferred => "deferred (created on first session save)",
|
||||
Self::Skipped => "skipped (already exists)",
|
||||
}
|
||||
}
|
||||
@@ -36,6 +47,8 @@ impl InitStatus {
|
||||
match self {
|
||||
Self::Created => "created",
|
||||
Self::Updated => "updated",
|
||||
Self::Partial => "partial",
|
||||
Self::Deferred => "deferred",
|
||||
Self::Skipped => "skipped",
|
||||
}
|
||||
}
|
||||
@@ -123,9 +136,30 @@ pub(crate) fn initialize_repo(cwd: &Path) -> Result<InitReport, Box<dyn std::err
|
||||
let mut artifacts = Vec::new();
|
||||
|
||||
let claw_dir = cwd.join(".claw");
|
||||
let claw_dir_status = ensure_dir(&claw_dir)?;
|
||||
let settings_json = claw_dir.join("settings.json");
|
||||
let settings_status = write_file_if_missing(&settings_json, STARTER_SETTINGS_JSON)?;
|
||||
let claw_dir_status =
|
||||
if claw_dir_status == InitStatus::Skipped && settings_status == InitStatus::Created {
|
||||
InitStatus::Partial
|
||||
} else {
|
||||
claw_dir_status
|
||||
};
|
||||
artifacts.push(InitArtifact {
|
||||
name: ".claw/",
|
||||
status: ensure_dir(&claw_dir)?,
|
||||
status: claw_dir_status,
|
||||
});
|
||||
artifacts.push(InitArtifact {
|
||||
name: ".claw/settings.json",
|
||||
status: settings_status,
|
||||
});
|
||||
artifacts.push(InitArtifact {
|
||||
name: ".claw/sessions/",
|
||||
status: if claw_dir.join("sessions").is_dir() {
|
||||
InitStatus::Skipped
|
||||
} else {
|
||||
InitStatus::Deferred
|
||||
},
|
||||
});
|
||||
|
||||
let claw_json = cwd.join(".claw.json");
|
||||
@@ -381,11 +415,16 @@ mod tests {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir() -> std::path::PathBuf {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("time should be after epoch")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("rusty-claude-init-{nanos}"))
|
||||
// Combine counter + nanoseconds so parallel tests in the same process
|
||||
// never collide even if two calls land in the same nanosecond (#707).
|
||||
std::env::temp_dir().join(format!("rusty-claude-init-{nanos}-{id}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -409,11 +448,26 @@ mod tests {
|
||||
concat!(
|
||||
"{\n",
|
||||
" \"permissions\": {\n",
|
||||
" \"defaultMode\": \"dontAsk\"\n",
|
||||
" \"defaultMode\": \"acceptEdits\"\n",
|
||||
" }\n",
|
||||
"}\n",
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join(".claw").join("settings.json"))
|
||||
.expect("read project settings"),
|
||||
concat!(
|
||||
"{\n",
|
||||
" \"permissions\": {\n",
|
||||
" \"defaultMode\": \"acceptEdits\"\n",
|
||||
" }\n",
|
||||
"}\n",
|
||||
)
|
||||
);
|
||||
assert!(
|
||||
!root.join(".claw").join("sessions").exists(),
|
||||
"sessions directory should be deferred until first session save"
|
||||
);
|
||||
let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
|
||||
assert!(gitignore.contains(".claw/settings.local.json"));
|
||||
assert!(gitignore.contains(".claw/sessions/"));
|
||||
@@ -431,14 +485,24 @@ mod tests {
|
||||
fs::create_dir_all(&root).expect("create root");
|
||||
fs::write(root.join("CLAUDE.md"), "custom guidance\n").expect("write existing claude md");
|
||||
fs::write(root.join(".gitignore"), ".claw/settings.local.json\n").expect("write gitignore");
|
||||
fs::create_dir_all(root.join(".claw")).expect("create existing .claw dir");
|
||||
|
||||
let first = initialize_repo(&root).expect("first init should succeed");
|
||||
assert!(first
|
||||
.render()
|
||||
.contains("CLAUDE.md skipped (already exists)"));
|
||||
assert_eq!(
|
||||
first.artifacts_with_status(InitStatus::Partial),
|
||||
vec![".claw/".to_string()],
|
||||
"existing .claw/ should report partial when init creates missing settings.json"
|
||||
);
|
||||
assert!(root.join(".claw").join("settings.json").is_file());
|
||||
|
||||
let second = initialize_repo(&root).expect("second init should succeed");
|
||||
let second_rendered = second.render();
|
||||
assert!(second_rendered.contains(".claw/"));
|
||||
assert!(second_rendered.contains(".claw/settings.json"));
|
||||
assert!(second_rendered.contains(".claw/sessions/"));
|
||||
assert!(second_rendered.contains(".claw.json"));
|
||||
assert!(second_rendered.contains("skipped (already exists)"));
|
||||
assert!(second_rendered.contains(".gitignore skipped (already exists)"));
|
||||
@@ -469,16 +533,22 @@ mod tests {
|
||||
created_names,
|
||||
vec![
|
||||
".claw/".to_string(),
|
||||
".claw/settings.json".to_string(),
|
||||
".claw.json".to_string(),
|
||||
".gitignore".to_string(),
|
||||
"CLAUDE.md".to_string(),
|
||||
],
|
||||
"fresh init should place all four artifacts in created[]"
|
||||
"fresh init should place created artifacts in created[]"
|
||||
);
|
||||
assert!(
|
||||
fresh.artifacts_with_status(InitStatus::Skipped).is_empty(),
|
||||
"fresh init should have no skipped artifacts"
|
||||
);
|
||||
assert_eq!(
|
||||
fresh.artifacts_with_status(InitStatus::Deferred),
|
||||
vec![".claw/sessions/".to_string()],
|
||||
"fresh init should report session storage as deferred"
|
||||
);
|
||||
|
||||
let second = initialize_repo(&root).expect("second init should succeed");
|
||||
let skipped_names = second.artifacts_with_status(InitStatus::Skipped);
|
||||
@@ -486,27 +556,38 @@ mod tests {
|
||||
skipped_names,
|
||||
vec![
|
||||
".claw/".to_string(),
|
||||
".claw/settings.json".to_string(),
|
||||
".claw.json".to_string(),
|
||||
".gitignore".to_string(),
|
||||
"CLAUDE.md".to_string(),
|
||||
],
|
||||
"idempotent init should place all four artifacts in skipped[]"
|
||||
"idempotent init should place existing artifacts in skipped[]"
|
||||
);
|
||||
assert!(
|
||||
second.artifacts_with_status(InitStatus::Created).is_empty(),
|
||||
"idempotent init should have no created artifacts"
|
||||
);
|
||||
assert_eq!(
|
||||
second.artifacts_with_status(InitStatus::Deferred),
|
||||
vec![".claw/sessions/".to_string()],
|
||||
"idempotent init should keep session storage deferred until first save"
|
||||
);
|
||||
|
||||
// artifact_json_entries() uses the machine-stable `json_tag()` which
|
||||
// never changes wording (unlike `label()` which says "skipped (already exists)").
|
||||
let entries = second.artifact_json_entries();
|
||||
assert_eq!(entries.len(), 4);
|
||||
assert_eq!(entries.len(), 6);
|
||||
for entry in &entries {
|
||||
let name = entry.get("name").and_then(|v| v.as_str()).unwrap();
|
||||
let status = entry.get("status").and_then(|v| v.as_str()).unwrap();
|
||||
assert_eq!(
|
||||
status, "skipped",
|
||||
"machine status tag should be the bare word 'skipped', not label()'s 'skipped (already exists)'"
|
||||
);
|
||||
if name == ".claw/sessions/" {
|
||||
assert_eq!(status, "deferred");
|
||||
} else {
|
||||
assert_eq!(
|
||||
status, "skipped",
|
||||
"machine status tag should be the bare word 'skipped', not label()'s 'skipped (already exists)'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
287
rust/crates/rusty-claude-cli/src/setup_wizard.rs
Normal file
287
rust/crates/rusty-claude-cli/src/setup_wizard.rs
Normal file
@@ -0,0 +1,287 @@
|
||||
use std::io::{self, IsTerminal, Write};
|
||||
|
||||
use runtime::{save_user_provider_settings, ConfigLoader, RuntimeProviderConfig};
|
||||
|
||||
use serde_json;
|
||||
|
||||
const PROVIDERS: &[(&str, &str, &str)] = &[
|
||||
("1", "Anthropic", "anthropic"),
|
||||
("2", "xAI / Grok", "xai"),
|
||||
("3", "OpenAI", "openai"),
|
||||
("4", "DashScope (Qwen/Kimi)", "dashscope"),
|
||||
("5", "Custom (OpenAI-compat)", "openai"),
|
||||
];
|
||||
|
||||
const PROVIDER_MODELS: &[(&str, &[&str])] = &[
|
||||
("anthropic", &["opus", "sonnet", "haiku"]),
|
||||
("xai", &["grok", "grok-mini", "grok-2"]),
|
||||
("openai", &["gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano"]),
|
||||
("dashscope", &["qwen-plus", "qwen-max", "kimi"]),
|
||||
];
|
||||
|
||||
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"),
|
||||
];
|
||||
|
||||
const API_KEY_ENV_VARS: &[(&str, &str)] = &[
|
||||
("anthropic", "ANTHROPIC_API_KEY"),
|
||||
("xai", "XAI_API_KEY"),
|
||||
("openai", "OPENAI_API_KEY"),
|
||||
("dashscope", "DASHSCOPE_API_KEY"),
|
||||
];
|
||||
|
||||
pub fn run_setup_wizard() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if !io::stdin().is_terminal() {
|
||||
return Err("setup wizard requires an interactive terminal".into());
|
||||
}
|
||||
|
||||
let current = load_current_provider_config();
|
||||
|
||||
println!();
|
||||
println!(" \x1b[1mClaw Code Setup Wizard\x1b[0m");
|
||||
println!(" Configure your provider, API key, and model.");
|
||||
println!(" Press Enter to keep current value.\n");
|
||||
|
||||
let kind = prompt_provider(¤t)?;
|
||||
let api_key = prompt_api_key(&kind, ¤t)?;
|
||||
let base_url = prompt_base_url(&kind, ¤t)?;
|
||||
let model = prompt_model(&kind, ¤t)?;
|
||||
let fast_model = prompt_fast_model(¤t, 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)?;
|
||||
}
|
||||
|
||||
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!();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_current_provider_config() -> RuntimeProviderConfig {
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
ConfigLoader::default_for(&cwd)
|
||||
.load()
|
||||
.map(|c| c.provider().clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn prompt_provider(current: &RuntimeProviderConfig) -> Result<String, Box<dyn std::error::Error>> {
|
||||
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 { "" };
|
||||
println!(" [{num}] {label}{marker}");
|
||||
}
|
||||
let default = PROVIDERS
|
||||
.iter()
|
||||
.position(|(_, _, k)| *k == current_kind)
|
||||
.map_or_else(|| "1".to_string(), |i| (i + 1).to_string());
|
||||
|
||||
let input = read_line(&format!(" Select provider [{default}]: "))?;
|
||||
let choice = if input.trim().is_empty() {
|
||||
default
|
||||
} else {
|
||||
input.trim().to_string()
|
||||
};
|
||||
|
||||
let kind = PROVIDERS
|
||||
.iter()
|
||||
.find(|(num, _, _)| *num == choice)
|
||||
.map(|(_, _, kind)| *kind)
|
||||
.ok_or_else(|| format!("invalid provider choice: {choice}"))?;
|
||||
|
||||
Ok(kind.to_string())
|
||||
}
|
||||
|
||||
fn prompt_api_key(
|
||||
kind: &str,
|
||||
current: &RuntimeProviderConfig,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let env_var = API_KEY_ENV_VARS
|
||||
.iter()
|
||||
.find(|(k, _)| *k == kind)
|
||||
.map_or("API_KEY", |(_, v)| *v);
|
||||
|
||||
let current_key = current.api_key();
|
||||
let hint = match current_key {
|
||||
Some(key) if !key.is_empty() => {
|
||||
let masked = if key.len() > 4 {
|
||||
format!("****{}", &key[key.len() - 4..])
|
||||
} else {
|
||||
"****".to_string()
|
||||
};
|
||||
format!("[{masked}]")
|
||||
}
|
||||
_ => "(none)".to_string(),
|
||||
};
|
||||
|
||||
// Check if env var is already set
|
||||
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)");
|
||||
}
|
||||
|
||||
let input = read_line(&format!(" API key ({env_var}) {hint}: "))?;
|
||||
let key = if input.trim().is_empty() {
|
||||
current_key.unwrap_or("").to_string()
|
||||
} else {
|
||||
input.trim().to_string()
|
||||
};
|
||||
|
||||
if key.is_empty() && !env_set {
|
||||
eprintln!(" \x1b[33mWarning: no API key configured. Set {env_var} or re-run setup.\x1b[0m");
|
||||
}
|
||||
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn prompt_base_url(
|
||||
kind: &str,
|
||||
current: &RuntimeProviderConfig,
|
||||
) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
||||
let default_url = DEFAULT_BASE_URLS
|
||||
.iter()
|
||||
.find(|(k, _)| *k == kind)
|
||||
.map_or("", |(_, v)| *v);
|
||||
|
||||
let current_url = current.base_url().unwrap_or(default_url);
|
||||
let display = if current_url.is_empty() {
|
||||
default_url.to_string()
|
||||
} else {
|
||||
current_url.to_string()
|
||||
};
|
||||
|
||||
// Check if the relevant env var is already set
|
||||
let env_var = match kind {
|
||||
"anthropic" => "ANTHROPIC_BASE_URL",
|
||||
"xai" => "XAI_BASE_URL",
|
||||
"openai" => "OPENAI_BASE_URL",
|
||||
"dashscope" => "DASHSCOPE_BASE_URL",
|
||||
_ => "BASE_URL",
|
||||
};
|
||||
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)");
|
||||
}
|
||||
|
||||
let input = read_line(&format!(" Base URL [{display}]: "))?;
|
||||
if input.trim().is_empty() {
|
||||
if current_url == default_url || current_url.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(current_url.to_string()))
|
||||
}
|
||||
} else {
|
||||
Ok(Some(input.trim().to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_model(
|
||||
kind: &str,
|
||||
current: &RuntimeProviderConfig,
|
||||
) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
||||
let empty: &[&str] = &[];
|
||||
let aliases = PROVIDER_MODELS
|
||||
.iter()
|
||||
.find(|(k, _)| *k == kind)
|
||||
.map_or(empty, |(_, models)| *models);
|
||||
|
||||
let current_model = current.model().unwrap_or(aliases.first().copied().unwrap_or(""));
|
||||
|
||||
println!(" \x1b[1mModel\x1b[0m");
|
||||
if !aliases.is_empty() {
|
||||
println!(" Common: {}", aliases.join(", "));
|
||||
}
|
||||
println!(" Or enter any model name (e.g. openai/gpt-4.1-mini for custom routing)");
|
||||
|
||||
let input = read_line(&format!(" Model [{current_model}]: "))?;
|
||||
if input.trim().is_empty() {
|
||||
if current_model.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(current_model.to_string()))
|
||||
}
|
||||
} else {
|
||||
Ok(Some(input.trim().to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_fast_model(
|
||||
current: &RuntimeProviderConfig,
|
||||
main_model: Option<&str>,
|
||||
) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
||||
println!();
|
||||
println!(" \x1b[1mFast Model (for Agent subtasks)\x1b[0m");
|
||||
println!(" A smaller/cheaper model used by the Agent tool when spawning");
|
||||
println!(" Explore, Plan, or Verification sub-agents. This saves tokens");
|
||||
println!(" by using a fast model for information-gathering tasks.");
|
||||
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 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 {
|
||||
Ok(Some(input.trim().to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn load_current_settings_field(field: &str) -> Option<String> {
|
||||
let home = std::env::var("HOME").ok()?;
|
||||
let settings_path = std::path::Path::new(&home).join(".claw/settings.json");
|
||||
let content = std::fs::read_to_string(&settings_path).ok()?;
|
||||
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
|
||||
json.get(field)?.as_str().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn save_settings_field(field: &str, value: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let home = std::env::var("HOME")?;
|
||||
let settings_dir = std::path::Path::new(&home).join(".claw");
|
||||
let settings_path = settings_dir.join("settings.json");
|
||||
|
||||
let mut settings: serde_json::Value = if settings_path.exists() {
|
||||
let content = std::fs::read_to_string(&settings_path)?;
|
||||
serde_json::from_str(&content)?
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
|
||||
if let Some(obj) = settings.as_object_mut() {
|
||||
obj.insert(field.to_string(), serde_json::Value::String(value.to_string()));
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(&settings_dir)?;
|
||||
std::fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_line(prompt: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let mut stdout = io::stdout();
|
||||
write!(stdout, "{prompt}")?;
|
||||
stdout.flush()?;
|
||||
let mut buffer = String::new();
|
||||
io::stdin().read_line(&mut buffer)?;
|
||||
Ok(buffer)
|
||||
}
|
||||
@@ -31,7 +31,7 @@ fn status_command_applies_model_and_permission_mode_flags() {
|
||||
assert_success(&output);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
assert!(stdout.contains("Status"));
|
||||
assert!(stdout.contains("Model claude-sonnet-4-6"));
|
||||
assert!(stdout.contains("Model anthropic/claude-sonnet-4-6"));
|
||||
assert!(stdout.contains("Permission mode read-only"));
|
||||
|
||||
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
#![allow(clippy::while_let_on_iterator)]
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use mock_anthropic_service::{MockAnthropicService, SCENARIO_PREFIX};
|
||||
use serde_json::Value;
|
||||
@@ -239,12 +240,215 @@ stderr:
|
||||
"Mock streaming says hello from the parity harness."
|
||||
);
|
||||
assert_eq!(parsed["compact"], true);
|
||||
assert_eq!(parsed["model"], "claude-sonnet-4-6");
|
||||
assert_eq!(parsed["model"], "anthropic/claude-sonnet-4-6");
|
||||
assert!(parsed["usage"].is_object());
|
||||
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_subcommand_reads_prompt_from_stdin_when_no_positional_arg_423() {
|
||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||
let server = runtime
|
||||
.block_on(MockAnthropicService::spawn())
|
||||
.expect("mock service should start");
|
||||
let base_url = server.base_url();
|
||||
|
||||
let workspace = unique_temp_dir("prompt-stdin-423");
|
||||
let config_home = workspace.join("config-home");
|
||||
let home = workspace.join("home");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
|
||||
let prompt = format!("{SCENARIO_PREFIX}streaming_text\n");
|
||||
let output = run_claw_with_stdin(
|
||||
&workspace,
|
||||
&config_home,
|
||||
&home,
|
||||
&base_url,
|
||||
&[
|
||||
"prompt",
|
||||
"--output-format",
|
||||
"json",
|
||||
"--compact",
|
||||
"--permission-mode",
|
||||
"read-only",
|
||||
"--model",
|
||||
"sonnet",
|
||||
],
|
||||
&prompt,
|
||||
);
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"prompt stdin run should succeed\nstdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
let parsed: Value = serde_json::from_slice(&output.stdout).expect("stdout should parse");
|
||||
assert_eq!(
|
||||
parsed["message"],
|
||||
"Mock streaming says hello from the parity harness."
|
||||
);
|
||||
let captured = runtime.block_on(server.captured_requests());
|
||||
assert!(
|
||||
captured
|
||||
.iter()
|
||||
.any(|request| request.raw_body.contains("PARITY_SCENARIO:streaming_text")),
|
||||
"stdin prompt should reach the provider request: {captured:?}"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_subcommand_stdin_flag_appends_pipe_context_423() {
|
||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||
let server = runtime
|
||||
.block_on(MockAnthropicService::spawn())
|
||||
.expect("mock service should start");
|
||||
let base_url = server.base_url();
|
||||
|
||||
let workspace = unique_temp_dir("prompt-stdin-flag-423");
|
||||
let config_home = workspace.join("config-home");
|
||||
let home = workspace.join("home");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
|
||||
let prompt_context = format!("{SCENARIO_PREFIX}streaming_text\n");
|
||||
let output = run_claw_with_stdin(
|
||||
&workspace,
|
||||
&config_home,
|
||||
&home,
|
||||
&base_url,
|
||||
&[
|
||||
"prompt",
|
||||
"Use stdin context",
|
||||
"--stdin",
|
||||
"--output-format",
|
||||
"json",
|
||||
"--compact",
|
||||
"--permission-mode",
|
||||
"read-only",
|
||||
"--model",
|
||||
"sonnet",
|
||||
],
|
||||
&prompt_context,
|
||||
);
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"prompt --stdin run should succeed\nstdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
let captured = runtime.block_on(server.captured_requests());
|
||||
let provider_body = captured
|
||||
.iter()
|
||||
.find(|request| request.raw_body.contains("Use stdin context"))
|
||||
.expect("merged prompt should reach provider");
|
||||
assert!(
|
||||
provider_body
|
||||
.raw_body
|
||||
.contains("PARITY_SCENARIO:streaming_text"),
|
||||
"merged prompt should include stdin context: {provider_body:?}"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_subcommand_json_fails_fast_when_stdin_closed() {
|
||||
let workspace = unique_temp_dir("compact-nontty-json");
|
||||
let config_home = workspace.join("config-home");
|
||||
let home = workspace.join("home");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
|
||||
let output = run_claw_closed_stdin_with_timeout(
|
||||
&workspace,
|
||||
&config_home,
|
||||
&home,
|
||||
&["compact", "--output-format", "json"],
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"compact json should fail non-zero"
|
||||
);
|
||||
// #819/#820/#823: JSON abort envelopes route to stdout
|
||||
let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8");
|
||||
assert!(
|
||||
stderr.trim().is_empty() || !stderr.trim_start().starts_with('{'),
|
||||
"compact json should not emit JSON envelope to stderr (#819/#820/#823): {stderr}"
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
let parsed: Value =
|
||||
serde_json::from_str(stdout.trim()).expect("stdout should be JSON error envelope");
|
||||
assert_eq!(parsed["status"], "error");
|
||||
assert_eq!(parsed["error_kind"], "interactive_only");
|
||||
assert_eq!(parsed["action"], "abort");
|
||||
assert!(
|
||||
parsed["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("claw compact"),
|
||||
"message should name compact: {parsed}"
|
||||
);
|
||||
// #749: hint must be non-empty (was null before fix — same class as #738/#745/#746)
|
||||
let hint = parsed["hint"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
!hint.is_empty(),
|
||||
"compact interactive-only JSON must have non-empty hint (#749); got: {parsed}"
|
||||
);
|
||||
assert!(
|
||||
hint.contains("/compact") || hint.contains("--resume"),
|
||||
"hint should mention /compact or --resume: {hint}"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_subcommand_text_fails_fast_when_stdin_closed() {
|
||||
let workspace = unique_temp_dir("compact-nontty-text");
|
||||
let config_home = workspace.join("config-home");
|
||||
let home = workspace.join("home");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
|
||||
let output = run_claw_closed_stdin_with_timeout(
|
||||
&workspace,
|
||||
&config_home,
|
||||
&home,
|
||||
&["compact"],
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"compact text should fail non-zero"
|
||||
);
|
||||
assert!(
|
||||
output.stdout.is_empty(),
|
||||
"compact text should not start a prompt/spinner on stdout: {}",
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
);
|
||||
let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8");
|
||||
assert!(
|
||||
stderr.contains("[error-kind: interactive_only]"),
|
||||
"{stderr}"
|
||||
);
|
||||
assert!(stderr.contains("claw compact"), "{stderr}");
|
||||
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
fn run_claw(
|
||||
cwd: &std::path::Path,
|
||||
config_home: &std::path::Path,
|
||||
@@ -266,6 +470,81 @@ fn run_claw(
|
||||
command.output().expect("claw should launch")
|
||||
}
|
||||
|
||||
fn run_claw_with_stdin(
|
||||
cwd: &std::path::Path,
|
||||
config_home: &std::path::Path,
|
||||
home: &std::path::Path,
|
||||
base_url: &str,
|
||||
args: &[&str],
|
||||
stdin: &str,
|
||||
) -> Output {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_claw"))
|
||||
.current_dir(cwd)
|
||||
.env_clear()
|
||||
.env("ANTHROPIC_API_KEY", "test-compact-key")
|
||||
.env("ANTHROPIC_BASE_URL", base_url)
|
||||
.env("CLAW_CONFIG_HOME", config_home)
|
||||
.env("HOME", home)
|
||||
.env("NO_COLOR", "1")
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.args(args)
|
||||
.spawn()
|
||||
.expect("claw should launch");
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.expect("stdin should be piped")
|
||||
.write_all(stdin.as_bytes())
|
||||
.expect("stdin should write");
|
||||
child.stdin.take();
|
||||
child.wait_with_output().expect("output should collect")
|
||||
}
|
||||
|
||||
fn run_claw_closed_stdin_with_timeout(
|
||||
cwd: &std::path::Path,
|
||||
config_home: &std::path::Path,
|
||||
home: &std::path::Path,
|
||||
args: &[&str],
|
||||
timeout: Duration,
|
||||
) -> Output {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_claw"))
|
||||
.current_dir(cwd)
|
||||
.env_clear()
|
||||
.env("CLAW_CONFIG_HOME", config_home)
|
||||
.env("HOME", home)
|
||||
.env("NO_COLOR", "1")
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.args(args)
|
||||
.spawn()
|
||||
.expect("claw should launch");
|
||||
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if child.try_wait().expect("try_wait should succeed").is_some() {
|
||||
return child.wait_with_output().expect("output should collect");
|
||||
}
|
||||
if start.elapsed() > timeout {
|
||||
let _ = child.kill();
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.expect("killed output should collect");
|
||||
panic!(
|
||||
"claw did not exit within {:?}\nstdout:\n{}\nstderr:\n{}",
|
||||
timeout,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_temp_dir(label: &str) -> PathBuf {
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
@@ -426,11 +426,15 @@ fn prepare_plugin_fixture(workspace: &HarnessWorkspace) {
|
||||
"#!/bin/sh\nINPUT=$(cat)\nprintf '{\"plugin\":\"%s\",\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_PLUGIN_ID\" \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
|
||||
)
|
||||
.expect("plugin script should write");
|
||||
let mut permissions = fs::metadata(&script_path)
|
||||
.expect("plugin script metadata")
|
||||
.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions).expect("plugin script should be executable");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut permissions = fs::metadata(&script_path)
|
||||
.expect("plugin script metadata")
|
||||
.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions).expect("plugin script should be executable");
|
||||
}
|
||||
|
||||
fs::write(
|
||||
manifest_dir.join("plugin.json"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -108,7 +108,7 @@ fn status_command_applies_cli_flags_end_to_end() {
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
assert!(stdout.contains("Status"));
|
||||
assert!(stdout.contains("Model claude-sonnet-4-6"));
|
||||
assert!(stdout.contains("Model anthropic/claude-sonnet-4-6"));
|
||||
assert!(stdout.contains("Permission mode read-only"));
|
||||
}
|
||||
|
||||
@@ -222,6 +222,73 @@ fn resume_latest_restores_the_most_recent_managed_session() {
|
||||
assert!(stdout.contains(newer_path.to_str().expect("utf8 path")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_latest_missing_session_fails_without_creating_session_dirs_435() {
|
||||
// given
|
||||
let temp_dir = unique_temp_dir("resume-latest-missing-435");
|
||||
let project_dir = temp_dir.join("project");
|
||||
let config_home = temp_dir.join("config-home");
|
||||
let home = temp_dir.join("home");
|
||||
fs::create_dir_all(&project_dir).expect("project dir should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
let envs = [
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
config_home.to_str().expect("utf8 config home"),
|
||||
),
|
||||
("HOME", home.to_str().expect("utf8 home")),
|
||||
("ANTHROPIC_API_KEY", ""),
|
||||
("ANTHROPIC_AUTH_TOKEN", ""),
|
||||
("OPENAI_API_KEY", ""),
|
||||
];
|
||||
|
||||
// when — both text and JSON resume failures should be non-zero and read-only.
|
||||
let text = run_claw_with_env(&project_dir, &["--resume", "latest"], &envs);
|
||||
let json = run_claw_with_env(
|
||||
&project_dir,
|
||||
&["--output-format", "json", "--resume", "latest"],
|
||||
&envs,
|
||||
);
|
||||
|
||||
// then
|
||||
assert_eq!(
|
||||
text.status.code(),
|
||||
Some(1),
|
||||
"text resume failure must be non-zero"
|
||||
);
|
||||
assert!(
|
||||
text.stdout.is_empty(),
|
||||
"text resume failure should not claim success on stdout: {}",
|
||||
String::from_utf8_lossy(&text.stdout)
|
||||
);
|
||||
let text_stderr = String::from_utf8_lossy(&text.stderr);
|
||||
assert!(
|
||||
text_stderr.contains("no managed sessions found"),
|
||||
"text failure should explain missing sessions: {text_stderr}"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
json.status.code(),
|
||||
Some(1),
|
||||
"JSON resume failure must be non-zero"
|
||||
);
|
||||
assert!(
|
||||
json.stderr.is_empty(),
|
||||
"JSON resume failure should keep stderr empty: {}",
|
||||
String::from_utf8_lossy(&json.stderr)
|
||||
);
|
||||
let parsed: Value = serde_json::from_slice(&json.stdout)
|
||||
.expect("JSON resume failure should emit JSON to stdout");
|
||||
assert_eq!(parsed["status"], "error");
|
||||
assert_eq!(parsed["action"], "restore");
|
||||
assert_eq!(parsed["error_kind"], "no_managed_sessions");
|
||||
assert!(
|
||||
!project_dir.join(".claw").exists(),
|
||||
"failed resume must not create .claw/session directories"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_status_command_emits_structured_json_when_requested() {
|
||||
// given
|
||||
@@ -268,7 +335,7 @@ fn resumed_status_command_emits_structured_json_when_requested() {
|
||||
assert_eq!(parsed["kind"], "status");
|
||||
// model is null in resume mode (not known without --model flag)
|
||||
assert!(parsed["model"].is_null());
|
||||
assert_eq!(parsed["permission_mode"], "danger-full-access");
|
||||
assert_eq!(parsed["permission_mode"], "workspace-write");
|
||||
assert_eq!(parsed["usage"]["messages"], 1);
|
||||
assert!(parsed["usage"]["turns"].is_number());
|
||||
assert!(parsed["workspace"]["cwd"].as_str().is_some());
|
||||
@@ -289,7 +356,7 @@ fn resumed_status_surfaces_persisted_model() {
|
||||
let session_path = temp_dir.join("session.jsonl");
|
||||
|
||||
let mut session = workspace_session(&temp_dir);
|
||||
session.model = Some("claude-sonnet-4-6".to_string());
|
||||
session.model = Some("anthropic/claude-sonnet-4-6".to_string());
|
||||
session
|
||||
.push_user_text("model persistence fixture")
|
||||
.expect("write ok");
|
||||
@@ -317,7 +384,7 @@ fn resumed_status_surfaces_persisted_model() {
|
||||
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
|
||||
assert_eq!(parsed["kind"], "status");
|
||||
assert_eq!(
|
||||
parsed["model"], "claude-sonnet-4-6",
|
||||
parsed["model"], "anthropic/claude-sonnet-4-6",
|
||||
"model should round-trip through session metadata"
|
||||
);
|
||||
}
|
||||
@@ -396,6 +463,9 @@ fn resumed_version_command_emits_structured_json() {
|
||||
assert!(parsed["version"].as_str().is_some());
|
||||
assert!(parsed["git_sha"].as_str().is_some());
|
||||
assert!(parsed["target"].as_str().is_some());
|
||||
assert!(parsed["git_sha_short"].as_str().is_some());
|
||||
assert!(parsed.get("message").is_none());
|
||||
assert!(parsed["human_readable"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -460,8 +530,9 @@ fn resumed_help_command_emits_structured_json() {
|
||||
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
||||
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
|
||||
assert_eq!(parsed["kind"], "help");
|
||||
assert!(parsed["text"].as_str().is_some());
|
||||
let text = parsed["text"].as_str().unwrap();
|
||||
// #338: resume help now uses 'message' field for parity with top-level help
|
||||
assert!(parsed["message"].as_str().is_some());
|
||||
let text = parsed["message"].as_str().unwrap();
|
||||
assert!(text.contains("/status"), "help text should list /status");
|
||||
}
|
||||
|
||||
@@ -521,9 +592,17 @@ fn resumed_stub_command_emits_not_implemented_json() {
|
||||
|
||||
// Stub commands exit with code 2
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8(output.stderr).expect("utf8");
|
||||
let parsed: Value = serde_json::from_str(stderr.trim()).expect("should be json");
|
||||
assert_eq!(parsed["type"], "error");
|
||||
// #819/#820/#823: JSON abort envelopes route to stdout
|
||||
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
||||
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
|
||||
assert_eq!(
|
||||
parsed["status"], "error",
|
||||
"stub command should emit status:error"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["kind"], "unsupported_command",
|
||||
"stub command should emit kind:unsupported_command"
|
||||
);
|
||||
assert!(
|
||||
parsed["error"]
|
||||
.as_str()
|
||||
|
||||
@@ -15,6 +15,10 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking",
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||
aspect-core = "0.1"
|
||||
aspect-macros = "0.1"
|
||||
aspect-std = "0.1"
|
||||
log = "0.4"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
157
rust/crates/tools/GIT_TOOLS_README.md
Normal file
157
rust/crates/tools/GIT_TOOLS_README.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Git-Aware Context Tools
|
||||
|
||||
Adds five native git tools to claw-code that provide structured, read-only access to repository state. These replace ad-hoc `git` commands via bash with purpose-built tool definitions the model can discover and invoke directly.
|
||||
|
||||
## Tools
|
||||
|
||||
### GitStatus
|
||||
|
||||
Show the working tree status (branch, staged, unstaged, untracked). Equivalent to `git status --short --branch`.
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `short` | boolean | no | `true` | Use `--short --branch` format for concise output |
|
||||
|
||||
**Example input:**
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
**Example output:**
|
||||
```json
|
||||
{
|
||||
"output": "## feat/git-aware-tools...upstream/main [ahead 1]\nM rust/crates/tools/src/lib.rs"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GitDiff
|
||||
|
||||
Show changes between commits, the index, and the working tree. Supports staged changes, specific paths, commit ranges, and comparing two commits.
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `staged` | boolean | no | `false` | Show staged changes (`git diff --cached`) |
|
||||
| `commit` | string | no | — | Commit hash, tag, or branch to diff against |
|
||||
| `commit2` | string | no | — | Second commit for range diff (`commit...commit2`) |
|
||||
| `path` | string | no | — | File path to restrict the diff to |
|
||||
|
||||
**Example inputs:**
|
||||
```json
|
||||
{}
|
||||
```
|
||||
```json
|
||||
{ "staged": true }
|
||||
```
|
||||
```json
|
||||
{ "commit": "HEAD~3", "path": "rust/crates/tools/src/lib.rs" }
|
||||
```
|
||||
```json
|
||||
{ "commit": "main", "commit2": "feat/git-aware-tools" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GitLog
|
||||
|
||||
Show commit history. Supports limiting count, filtering by author/date/path, and oneline format.
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `count` | integer | no | `20` | Maximum number of commits to return |
|
||||
| `oneline` | boolean | no | `false` | Use `--oneline` format (hash + subject only) |
|
||||
| `author` | string | no | — | Filter commits by author pattern |
|
||||
| `since` | string | no | — | Filter commits since date (e.g. `"2024-01-01"` or `"2.weeks"`) |
|
||||
| `until` | string | no | — | Filter commits until date |
|
||||
| `path` | string | no | — | File or directory path to filter commits by |
|
||||
|
||||
**Example inputs:**
|
||||
```json
|
||||
{ "count": 5, "oneline": true }
|
||||
```
|
||||
```json
|
||||
{ "author": "alice", "since": "1.week", "path": "src/main.rs" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GitShow
|
||||
|
||||
Show a commit, tag, or tree object with its diff. Supports showing a specific file at a commit and stat-only mode.
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `commit` | string | **yes** | — | Commit hash, tag, or branch ref to show |
|
||||
| `path` | string | no | — | Show only this file at the given commit (`commit:path` syntax) |
|
||||
| `stat` | boolean | no | `false` | Show diffstat summary instead of full diff |
|
||||
|
||||
**Example inputs:**
|
||||
```json
|
||||
{ "commit": "HEAD" }
|
||||
```
|
||||
```json
|
||||
{ "commit": "abc1234", "stat": true }
|
||||
```
|
||||
```json
|
||||
{ "commit": "main", "path": "src/lib.rs" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GitBlame
|
||||
|
||||
Show what revision and author last modified each line of a file. Supports line range filtering.
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `path` | string | **yes** | — | File path to blame |
|
||||
| `start_line` | integer | no | — | Start of line range (1-based) |
|
||||
| `end_line` | integer | no | — | End of line range (1-based) |
|
||||
|
||||
**Example inputs:**
|
||||
```json
|
||||
{ "path": "src/main.rs" }
|
||||
```
|
||||
```json
|
||||
{ "path": "src/main.rs", "start_line": 100, "end_line": 150 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
All five tools follow the same pattern:
|
||||
|
||||
1. **ToolSpec** — Defines the tool name, description, JSON input schema, and `PermissionMode::ReadOnly`
|
||||
2. **Input struct** — Derives `Deserialize` with `#[serde(default)]` on optional fields
|
||||
3. **Run function** — Builds git arguments, calls `git_stdout()`, wraps result in JSON via `to_pretty_json()`
|
||||
4. **Dispatch** — Matched in `execute_tool_with_enforcer()` like all other tools
|
||||
|
||||
The existing `git_stdout(args: &[&str]) -> Option<String>` helper (at `tools/src/lib.rs`) handles running the `git` subprocess and returning trimmed stdout. Git tools simply construct the right arguments and delegate to this helper.
|
||||
|
||||
## Why native git tools?
|
||||
|
||||
Before this PR, the model had to use the `bash` tool for git operations, which has several drawbacks:
|
||||
|
||||
- **No structured output** — Bash returns raw text that the model must parse
|
||||
- **Over-permissioned** — Bash requires `DangerFullAccess` even for read-only git commands
|
||||
- **No discoverability** — The model can't search for git-capable tools via `ToolSearch`
|
||||
- **Inconsistent** — Each invocation may use different flags or formatting
|
||||
|
||||
With native git tools:
|
||||
|
||||
- All five are `ReadOnly` — safe in restricted permission modes
|
||||
- Structured JSON output — consistent, parseable results
|
||||
- Discoverable via `ToolSearch` with keywords like "git", "diff", "blame"
|
||||
- Model-friendly descriptions explain when to use each tool vs bash
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
cd rust
|
||||
cargo build --release
|
||||
cargo test -p tools
|
||||
```
|
||||
|
||||
The 3 pre-existing test failures (agent_fake_runner, agent_persists_handoff, worker_create_merges_config) are unrelated to this change — they fail due to local settings.json incompatibilities.
|
||||
@@ -3,6 +3,9 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use aspect_macros::aspect;
|
||||
use aspect_std::LoggingAspect;
|
||||
|
||||
use api::{
|
||||
max_tokens_for_model, model_family_identity_for, resolve_model_alias, ApiError,
|
||||
ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, MessageResponse,
|
||||
@@ -198,30 +201,20 @@ impl GlobalToolRegistry {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let builtin_specs = mvp_tool_specs();
|
||||
let canonical_names = builtin_specs
|
||||
.iter()
|
||||
.map(|spec| spec.name.to_string())
|
||||
.chain(
|
||||
self.plugin_tools
|
||||
.iter()
|
||||
.map(|tool| tool.definition().name.clone()),
|
||||
)
|
||||
.chain(self.runtime_tools.iter().map(|tool| tool.name.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
let mut name_map = canonical_names
|
||||
.iter()
|
||||
.map(|name| (normalize_tool_name(name), name.clone()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let actual_names = self.actual_tool_names();
|
||||
let canonical_names = self.canonical_allowed_tool_names();
|
||||
let canonical_name_set = canonical_names.iter().cloned().collect::<BTreeSet<_>>();
|
||||
let mut name_map = BTreeMap::new();
|
||||
for actual in &actual_names {
|
||||
let canonical = canonical_allowed_tool_name(actual);
|
||||
name_map.insert(allowed_tool_lookup_key(actual), canonical.clone());
|
||||
name_map.insert(allowed_tool_lookup_key(&canonical), canonical);
|
||||
}
|
||||
|
||||
for (alias, canonical) in [
|
||||
("read", "read_file"),
|
||||
("write", "write_file"),
|
||||
("edit", "edit_file"),
|
||||
("glob", "glob_search"),
|
||||
("grep", "grep_search"),
|
||||
] {
|
||||
name_map.insert(alias.to_string(), canonical.to_string());
|
||||
for (alias, canonical) in self.allowed_tool_aliases() {
|
||||
if canonical_name_set.contains(&canonical) {
|
||||
name_map.insert(allowed_tool_lookup_key(&alias), canonical);
|
||||
}
|
||||
}
|
||||
|
||||
let mut allowed = BTreeSet::new();
|
||||
@@ -230,11 +223,11 @@ impl GlobalToolRegistry {
|
||||
.split(|ch: char| ch == ',' || ch.is_whitespace())
|
||||
.filter(|token| !token.is_empty())
|
||||
{
|
||||
let normalized = normalize_tool_name(token);
|
||||
let canonical = name_map.get(&normalized).ok_or_else(|| {
|
||||
let canonical = name_map.get(&allowed_tool_lookup_key(token)).ok_or_else(|| {
|
||||
format!(
|
||||
"unsupported tool in --allowedTools: {token} (expected one of: {})",
|
||||
canonical_names.join(", ")
|
||||
"invalid_tool_name: unsupported tool in --allowedTools: {token}\nAvailable: {}\nAliases: {}\nHint: Use canonical snake_case tool names from Available or aliases from Aliases.",
|
||||
canonical_names.join(", "),
|
||||
format_allowed_tool_aliases(&self.allowed_tool_aliases())
|
||||
)
|
||||
})?;
|
||||
allowed.insert(canonical.clone());
|
||||
@@ -255,7 +248,10 @@ impl GlobalToolRegistry {
|
||||
pub fn definitions(&self, allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolDefinition> {
|
||||
let builtin = mvp_tool_specs()
|
||||
.into_iter()
|
||||
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
|
||||
.filter(|spec| {
|
||||
allowed_tools
|
||||
.is_none_or(|allowed| allowed.contains(&canonical_allowed_tool_name(spec.name)))
|
||||
})
|
||||
.map(|spec| ToolDefinition {
|
||||
name: spec.name.to_string(),
|
||||
description: Some(spec.description.to_string()),
|
||||
@@ -264,7 +260,11 @@ impl GlobalToolRegistry {
|
||||
let runtime = self
|
||||
.runtime_tools
|
||||
.iter()
|
||||
.filter(|tool| allowed_tools.is_none_or(|allowed| allowed.contains(tool.name.as_str())))
|
||||
.filter(|tool| {
|
||||
allowed_tools.is_none_or(|allowed| {
|
||||
allowed.contains(&canonical_allowed_tool_name(&tool.name))
|
||||
})
|
||||
})
|
||||
.map(|tool| ToolDefinition {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
@@ -274,8 +274,11 @@ impl GlobalToolRegistry {
|
||||
.plugin_tools
|
||||
.iter()
|
||||
.filter(|tool| {
|
||||
allowed_tools
|
||||
.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str()))
|
||||
allowed_tools.is_none_or(|allowed| {
|
||||
allowed.contains(&canonical_allowed_tool_name(
|
||||
tool.definition().name.as_str(),
|
||||
))
|
||||
})
|
||||
})
|
||||
.map(|tool| ToolDefinition {
|
||||
name: tool.definition().name.clone(),
|
||||
@@ -291,19 +294,29 @@ impl GlobalToolRegistry {
|
||||
) -> Result<Vec<(String, PermissionMode)>, String> {
|
||||
let builtin = mvp_tool_specs()
|
||||
.into_iter()
|
||||
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
|
||||
.filter(|spec| {
|
||||
allowed_tools
|
||||
.is_none_or(|allowed| allowed.contains(&canonical_allowed_tool_name(spec.name)))
|
||||
})
|
||||
.map(|spec| (spec.name.to_string(), spec.required_permission));
|
||||
let runtime = self
|
||||
.runtime_tools
|
||||
.iter()
|
||||
.filter(|tool| allowed_tools.is_none_or(|allowed| allowed.contains(tool.name.as_str())))
|
||||
.filter(|tool| {
|
||||
allowed_tools.is_none_or(|allowed| {
|
||||
allowed.contains(&canonical_allowed_tool_name(&tool.name))
|
||||
})
|
||||
})
|
||||
.map(|tool| (tool.name.clone(), tool.required_permission));
|
||||
let plugin = self
|
||||
.plugin_tools
|
||||
.iter()
|
||||
.filter(|tool| {
|
||||
allowed_tools
|
||||
.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str()))
|
||||
allowed_tools.is_none_or(|allowed| {
|
||||
allowed.contains(&canonical_allowed_tool_name(
|
||||
tool.definition().name.as_str(),
|
||||
))
|
||||
})
|
||||
})
|
||||
.map(|tool| {
|
||||
permission_mode_from_plugin(tool.required_permission())
|
||||
@@ -313,6 +326,52 @@ impl GlobalToolRegistry {
|
||||
Ok(builtin.chain(runtime).chain(plugin).collect())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn actual_tool_names(&self) -> Vec<String> {
|
||||
mvp_tool_specs()
|
||||
.iter()
|
||||
.map(|spec| spec.name.to_string())
|
||||
.chain(
|
||||
self.plugin_tools
|
||||
.iter()
|
||||
.map(|tool| tool.definition().name.clone()),
|
||||
)
|
||||
.chain(self.runtime_tools.iter().map(|tool| tool.name.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn canonical_allowed_tool_names(&self) -> Vec<String> {
|
||||
self.actual_tool_names()
|
||||
.into_iter()
|
||||
.map(|name| canonical_allowed_tool_name(&name))
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn allowed_tool_aliases(&self) -> BTreeMap<String, String> {
|
||||
let mut aliases = BTreeMap::from([
|
||||
("read".to_string(), "read_file".to_string()),
|
||||
("Read".to_string(), "read_file".to_string()),
|
||||
("write".to_string(), "write_file".to_string()),
|
||||
("Write".to_string(), "write_file".to_string()),
|
||||
("edit".to_string(), "edit_file".to_string()),
|
||||
("Edit".to_string(), "edit_file".to_string()),
|
||||
("glob".to_string(), "glob_search".to_string()),
|
||||
("Glob".to_string(), "glob_search".to_string()),
|
||||
("grep".to_string(), "grep_search".to_string()),
|
||||
("Grep".to_string(), "grep_search".to_string()),
|
||||
]);
|
||||
for actual in self.actual_tool_names() {
|
||||
let canonical = canonical_allowed_tool_name(&actual);
|
||||
if actual != canonical {
|
||||
aliases.insert(actual, canonical);
|
||||
}
|
||||
}
|
||||
aliases
|
||||
}
|
||||
#[must_use]
|
||||
pub fn has_runtime_tool(&self, name: &str) -> bool {
|
||||
self.runtime_tools.iter().any(|tool| tool.name == name)
|
||||
@@ -375,8 +434,40 @@ impl GlobalToolRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_tool_name(value: &str) -> String {
|
||||
value.trim().replace('-', "_").to_ascii_lowercase()
|
||||
pub fn canonical_allowed_tool_name(value: &str) -> String {
|
||||
let trimmed = value.trim().replace('-', "_");
|
||||
let mut output = String::new();
|
||||
let chars = trimmed.chars().collect::<Vec<_>>();
|
||||
for (index, ch) in chars.iter().copied().enumerate() {
|
||||
if ch == '_' || ch.is_whitespace() {
|
||||
output.push('_');
|
||||
continue;
|
||||
}
|
||||
let previous = index.checked_sub(1).and_then(|i| chars.get(i)).copied();
|
||||
let next = chars.get(index + 1).copied();
|
||||
if ch.is_ascii_uppercase()
|
||||
&& index > 0
|
||||
&& !output.ends_with('_')
|
||||
&& (previous.is_some_and(|p| p.is_ascii_lowercase() || p.is_ascii_digit())
|
||||
|| next.is_some_and(|n| n.is_ascii_lowercase()))
|
||||
{
|
||||
output.push('_');
|
||||
}
|
||||
output.push(ch.to_ascii_lowercase());
|
||||
}
|
||||
output.trim_matches('_').to_string()
|
||||
}
|
||||
|
||||
fn allowed_tool_lookup_key(value: &str) -> String {
|
||||
canonical_allowed_tool_name(value).replace('_', "")
|
||||
}
|
||||
|
||||
fn format_allowed_tool_aliases(aliases: &BTreeMap<String, String>) -> String {
|
||||
aliases
|
||||
.iter()
|
||||
.map(|(alias, canonical)| format!("{alias}={canonical}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
fn permission_mode_from_plugin(value: &str) -> Result<PermissionMode, String> {
|
||||
@@ -511,7 +602,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
||||
"required": ["url", "prompt"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
required_permission: PermissionMode::ReadOnly,
|
||||
required_permission: PermissionMode::DangerFullAccess,
|
||||
},
|
||||
ToolSpec {
|
||||
name: "WebSearch",
|
||||
@@ -532,7 +623,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
||||
"required": ["query"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
required_permission: PermissionMode::ReadOnly,
|
||||
required_permission: PermissionMode::DangerFullAccess,
|
||||
},
|
||||
ToolSpec {
|
||||
name: "TodoWrite",
|
||||
@@ -1176,6 +1267,81 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
||||
}),
|
||||
required_permission: PermissionMode::DangerFullAccess,
|
||||
},
|
||||
ToolSpec {
|
||||
name: "GitStatus",
|
||||
description: "Show the working tree status (branch, staged, unstaged, untracked). Equivalent to 'git status --short --branch'. Use this instead of running git status via bash to get structured, parseable output.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"short": { "type": "boolean" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
required_permission: PermissionMode::ReadOnly,
|
||||
},
|
||||
ToolSpec {
|
||||
name: "GitDiff",
|
||||
description: "Show changes between commits, the index, and the working tree. Supports staged changes ('git diff --cached'), specific paths, commit ranges, and comparing two commits. Use this instead of running git diff via bash to get structured output.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": { "type": "string" },
|
||||
"staged": { "type": "boolean" },
|
||||
"commit": { "type": "string" },
|
||||
"commit2": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
required_permission: PermissionMode::ReadOnly,
|
||||
},
|
||||
ToolSpec {
|
||||
name: "GitLog",
|
||||
description: "Show commit history. Supports limiting count, filtering by author/date/path, and oneline format. Defaults to the last 20 commits. Use this instead of running git log via bash to get structured output.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": { "type": "string" },
|
||||
"count": { "type": "integer", "minimum": 1 },
|
||||
"oneline": { "type": "boolean" },
|
||||
"author": { "type": "string" },
|
||||
"since": { "type": "string" },
|
||||
"until": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
required_permission: PermissionMode::ReadOnly,
|
||||
},
|
||||
ToolSpec {
|
||||
name: "GitShow",
|
||||
description: "Show a commit, tag, or tree object. Use format to control output: patch (default) shows the full diff, stat shows a diffstat summary, and metadata shows commit info without the diff. Supports showing a specific file at a commit (commit:path) for patch/stat output. Use this instead of running git show via bash to get structured output.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"commit": { "type": "string" },
|
||||
"path": { "type": "string" },
|
||||
"stat": { "type": "boolean" },
|
||||
"format": { "type": "string", "enum": ["patch", "stat", "metadata"] },
|
||||
},
|
||||
"required": ["commit"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
required_permission: PermissionMode::ReadOnly,
|
||||
},
|
||||
ToolSpec {
|
||||
name: "GitBlame",
|
||||
description: "Show what revision and author last modified each line of a file. Supports line range filtering (start_line, end_line). Use this instead of running git blame via bash to get structured output.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": { "type": "string" },
|
||||
"start_line": { "type": "integer", "minimum": 1 },
|
||||
"end_line": { "type": "integer", "minimum": 1 }
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
required_permission: PermissionMode::ReadOnly,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1199,6 +1365,7 @@ pub fn execute_tool(name: &str, input: &Value) -> Result<String, String> {
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
#[aspect(LoggingAspect::new().log_args().log_result())]
|
||||
fn execute_tool_with_enforcer(
|
||||
enforcer: Option<&PermissionEnforcer>,
|
||||
name: &str,
|
||||
@@ -1242,8 +1409,26 @@ fn execute_tool_with_enforcer(
|
||||
maybe_enforce_permission_check_with_mode(enforcer, name, input, required_mode)?;
|
||||
run_grep_search(grep_input)
|
||||
}
|
||||
"WebFetch" => from_value::<WebFetchInput>(input).and_then(run_web_fetch),
|
||||
"WebSearch" => from_value::<WebSearchInput>(input).and_then(run_web_search),
|
||||
"WebFetch" => {
|
||||
let web_input = from_value::<WebFetchInput>(input)?;
|
||||
maybe_enforce_permission_check_with_mode(
|
||||
enforcer,
|
||||
name,
|
||||
input,
|
||||
PermissionMode::DangerFullAccess,
|
||||
)?;
|
||||
run_web_fetch(web_input)
|
||||
}
|
||||
"WebSearch" => {
|
||||
let web_input = from_value::<WebSearchInput>(input)?;
|
||||
maybe_enforce_permission_check_with_mode(
|
||||
enforcer,
|
||||
name,
|
||||
input,
|
||||
PermissionMode::DangerFullAccess,
|
||||
)?;
|
||||
run_web_search(web_input)
|
||||
}
|
||||
"TodoWrite" => from_value::<TodoWriteInput>(input).and_then(run_todo_write),
|
||||
"Skill" => from_value::<SkillInput>(input).and_then(run_skill),
|
||||
"Agent" => from_value::<AgentInput>(input).and_then(run_agent),
|
||||
@@ -1305,6 +1490,11 @@ fn execute_tool_with_enforcer(
|
||||
"TestingPermission" => {
|
||||
from_value::<TestingPermissionInput>(input).and_then(run_testing_permission)
|
||||
}
|
||||
"GitStatus" => from_value::<GitStatusInput>(input).and_then(run_git_status),
|
||||
"GitDiff" => from_value::<GitDiffInput>(input).and_then(run_git_diff),
|
||||
"GitLog" => from_value::<GitLogInput>(input).and_then(run_git_log),
|
||||
"GitShow" => from_value::<GitShowInput>(input).and_then(run_git_show),
|
||||
"GitBlame" => from_value::<GitBlameInput>(input).and_then(run_git_blame),
|
||||
_ => Err(format!("unsupported tool: {name}")),
|
||||
}
|
||||
}
|
||||
@@ -1840,6 +2030,156 @@ fn run_testing_permission(input: TestingPermissionInput) -> Result<String, Strin
|
||||
"message": "Testing permission tool stub"
|
||||
}))
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
/// Execute `git status --short --branch` and return structured JSON output.
|
||||
/// Falls back to full `git status` if `short` is explicitly set to false.
|
||||
fn run_git_status(input: GitStatusInput) -> Result<String, String> {
|
||||
let mut args: Vec<&str> = vec!["status"];
|
||||
if input.short.unwrap_or(true) {
|
||||
args.push("--short");
|
||||
args.push("--branch");
|
||||
}
|
||||
match git_stdout(&args) {
|
||||
Some(output) => to_pretty_json(json!({
|
||||
"output": output
|
||||
})),
|
||||
None => Err(
|
||||
"git status failed. Ensure the current directory is inside a git repository."
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
/// Execute `git diff` with optional --cached, commit, and path filters.
|
||||
/// Returns the diff output wrapped in a JSON object.
|
||||
fn run_git_diff(input: GitDiffInput) -> Result<String, String> {
|
||||
let mut args: Vec<String> = vec!["diff".to_string()];
|
||||
if input.staged.unwrap_or(false) {
|
||||
args.push("--cached".to_string());
|
||||
}
|
||||
if let Some(ref commit) = input.commit {
|
||||
if let Some(ref commit2) = input.commit2 {
|
||||
args.push(format!("{commit}...{commit2}"));
|
||||
} else {
|
||||
args.push(commit.clone());
|
||||
}
|
||||
}
|
||||
if let Some(ref path) = input.path {
|
||||
args.push("--".to_string());
|
||||
args.push(path.clone());
|
||||
}
|
||||
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
||||
match git_stdout(&arg_refs) {
|
||||
Some(output) => to_pretty_json(json!({
|
||||
"output": output
|
||||
})),
|
||||
None => Err(
|
||||
"git diff failed. Ensure the current directory is inside a git repository.".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
/// Execute `git log` with count, author, date, and path filters.
|
||||
/// Defaults to the last 20 commits.
|
||||
fn run_git_log(input: GitLogInput) -> Result<String, String> {
|
||||
let mut args: Vec<String> = vec!["log".to_string()];
|
||||
let count = input.count.unwrap_or(20);
|
||||
args.push(format!("-n{count}"));
|
||||
if input.oneline.unwrap_or(false) {
|
||||
args.push("--oneline".to_string());
|
||||
}
|
||||
if let Some(ref author) = input.author {
|
||||
args.push(format!("--author={author}"));
|
||||
}
|
||||
if let Some(ref since) = input.since {
|
||||
args.push(format!("--since={since}"));
|
||||
}
|
||||
if let Some(ref until) = input.until {
|
||||
args.push(format!("--until={until}"));
|
||||
}
|
||||
if let Some(ref path) = input.path {
|
||||
args.push("--".to_string());
|
||||
args.push(path.clone());
|
||||
}
|
||||
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
||||
match git_stdout(&arg_refs) {
|
||||
Some(output) => to_pretty_json(json!({
|
||||
"output": output
|
||||
})),
|
||||
None => Err(
|
||||
"git log failed. Ensure the current directory is inside a git repository.".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute `git show` for a given commit, optionally with --stat or a file path.
|
||||
/// Uses the `commit:path` syntax when a path is specified.
|
||||
fn run_git_show(input: GitShowInput) -> Result<String, String> {
|
||||
let mut args: Vec<String> = vec!["show".to_string()];
|
||||
|
||||
match input.format.as_deref() {
|
||||
Some("metadata") if input.path.is_some() => {
|
||||
return Err(
|
||||
"GitShow format \"metadata\" cannot be combined with path; metadata describes a commit, not a blob. Use format \"patch\" or \"stat\" with path, or omit path."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Some("metadata") => {
|
||||
args.push("--format=medium".to_string());
|
||||
args.push("--no-patch".to_string());
|
||||
}
|
||||
Some("stat") => {
|
||||
args.push("--stat".to_string());
|
||||
}
|
||||
Some("patch") | None => {
|
||||
if input.format.is_none() && input.stat.unwrap_or(false) {
|
||||
args.push("--stat".to_string());
|
||||
}
|
||||
}
|
||||
Some(other) => {
|
||||
return Err(format!(
|
||||
"unknown GitShow format: \"{other}\". Supported values: \"patch\" (default), \"stat\", \"metadata\"."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref path) = input.path {
|
||||
args.push(format!("{}:{}", input.commit, path));
|
||||
} else {
|
||||
args.push(input.commit.clone());
|
||||
}
|
||||
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
||||
match git_stdout(&arg_refs) {
|
||||
Some(output) => to_pretty_json(json!({
|
||||
"output": output
|
||||
})),
|
||||
None => Err(format!(
|
||||
"git show {} failed. Ensure the commit exists.",
|
||||
input.commit
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
/// Execute `git blame` on a file, optionally restricted to a line range.
|
||||
fn run_git_blame(input: GitBlameInput) -> Result<String, String> {
|
||||
let mut args: Vec<String> = vec!["blame".to_string()];
|
||||
if let (Some(start), Some(end)) = (input.start_line, input.end_line) {
|
||||
args.push(format!("-L{start},{end}"));
|
||||
}
|
||||
args.push(input.path.clone());
|
||||
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
||||
match git_stdout(&arg_refs) {
|
||||
Some(output) => to_pretty_json(json!({
|
||||
"output": output
|
||||
})),
|
||||
None => Err(format!("git blame {} failed. Ensure the file exists and the directory is inside a git repository.", input.path)),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_value<T: for<'de> Deserialize<'de>>(input: &Value) -> Result<T, String> {
|
||||
serde_json::from_value(input.clone()).map_err(|error| error.to_string())
|
||||
}
|
||||
@@ -2361,6 +2701,20 @@ fn is_within_workspace(path: &str) -> bool {
|
||||
|
||||
let path = PathBuf::from(trimmed);
|
||||
|
||||
// Reject any parent-directory traversal. Callers never need `..` to refer
|
||||
// to files inside the workspace, and `..` defeats both checks below: the
|
||||
// relative branch only inspects the leading component, and the absolute
|
||||
// branch's `canonicalize()` silently falls back to the literal `..` path
|
||||
// when the target does not exist yet (e.g. a file about to be created).
|
||||
// Returning false here is the safe direction: it classifies the command as
|
||||
// requiring full-access permission rather than workspace-write.
|
||||
if path
|
||||
.components()
|
||||
.any(|component| matches!(component, std::path::Component::ParentDir))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// If path is absolute, check if it starts with CWD
|
||||
if path.is_absolute() {
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
@@ -2378,6 +2732,26 @@ fn run_powershell(input: PowerShellInput) -> Result<String, String> {
|
||||
to_pretty_json(execute_powershell(input).map_err(|error| error.to_string())?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod workspace_traversal_guard_tests {
|
||||
use super::is_within_workspace;
|
||||
|
||||
#[test]
|
||||
fn rejects_parent_traversal_components() {
|
||||
// Leading and embedded `..` must both be rejected (was previously a hole
|
||||
// because only the leading component was inspected).
|
||||
assert!(!is_within_workspace("../secrets"));
|
||||
assert!(!is_within_workspace("src/../../etc/passwd"));
|
||||
assert!(!is_within_workspace("a/b/../../../etc/crontab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_plain_relative_paths() {
|
||||
assert!(is_within_workspace("src/main.rs"));
|
||||
assert!(is_within_workspace("Cargo.toml"));
|
||||
}
|
||||
}
|
||||
|
||||
fn to_pretty_json<T: serde::Serialize>(value: T) -> Result<String, String> {
|
||||
serde_json::to_string_pretty(&value).map_err(|error| error.to_string())
|
||||
}
|
||||
@@ -2692,6 +3066,88 @@ struct TestingPermissionInput {
|
||||
action: String,
|
||||
}
|
||||
|
||||
/// Input for the GitStatus tool: shows working tree status.
|
||||
/// Defaults to --short --branch mode for concise, parseable output.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GitStatusInput {
|
||||
#[serde(default)]
|
||||
/// If true, use --short --branch format. Defaults to true.
|
||||
short: Option<bool>,
|
||||
}
|
||||
|
||||
/// Input for the GitDiff tool: shows changes between commits, index, and working tree.
|
||||
/// All fields are optional - calling with no options is equivalent to `git diff`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GitDiffInput {
|
||||
#[serde(default)]
|
||||
/// File path to diff. Prepends `--` before the path.
|
||||
path: Option<String>,
|
||||
#[serde(default)]
|
||||
/// If true, show staged changes (`git diff --cached`).
|
||||
staged: Option<bool>,
|
||||
#[serde(default)]
|
||||
/// A commit hash, tag, or branch to diff against.
|
||||
commit: Option<String>,
|
||||
#[serde(default)]
|
||||
/// A second commit for range diffs (commit...commit2).
|
||||
commit2: Option<String>,
|
||||
}
|
||||
|
||||
/// Input for the GitLog tool: shows commit history.
|
||||
/// Defaults to the last 20 commits in full format.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GitLogInput {
|
||||
#[serde(default)]
|
||||
/// File or directory path to filter commits by.
|
||||
path: Option<String>,
|
||||
#[serde(default)]
|
||||
/// Maximum number of commits to return. Defaults to 20.
|
||||
count: Option<usize>,
|
||||
#[serde(default)]
|
||||
/// If true, use --oneline format (hash + subject only).
|
||||
oneline: Option<bool>,
|
||||
#[serde(default)]
|
||||
/// Filter commits by author pattern.
|
||||
author: Option<String>,
|
||||
#[serde(default)]
|
||||
/// Filter commits since date (e.g. "2024-01-01" or "2.weeks").
|
||||
since: Option<String>,
|
||||
#[serde(default)]
|
||||
/// Filter commits until date.
|
||||
until: Option<String>,
|
||||
}
|
||||
|
||||
/// Input for the GitShow tool: shows a commit, tag, or tree object.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GitShowInput {
|
||||
/// Commit hash, tag, or branch ref to show. Required.
|
||||
commit: String,
|
||||
#[serde(default)]
|
||||
/// If set, show only this file at the given commit (commit:path syntax).
|
||||
path: Option<String>,
|
||||
#[serde(default)]
|
||||
/// If true, show diffstat summary instead of full diff.
|
||||
stat: Option<bool>,
|
||||
#[serde(default)]
|
||||
/// Output format: "patch" (default) shows the full diff, "stat" shows a diffstat summary, and "metadata" shows commit info without the diff. When set, takes priority over `stat`.
|
||||
format: Option<String>,
|
||||
}
|
||||
|
||||
/// Input for the GitBlame tool: shows per-line author/revision info for a file.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GitBlameInput {
|
||||
/// File path to blame. Required.
|
||||
path: String,
|
||||
#[serde(rename = "start_line")]
|
||||
#[serde(default)]
|
||||
/// Start of line range (1-based). Only used if end_line is also set.
|
||||
start_line: Option<usize>,
|
||||
#[serde(rename = "end_line")]
|
||||
#[serde(default)]
|
||||
/// End of line range (1-based). Only used if start_line is also set.
|
||||
end_line: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct WebFetchOutput {
|
||||
bytes: usize,
|
||||
@@ -3876,7 +4332,7 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet<String> {
|
||||
"PowerShell",
|
||||
],
|
||||
};
|
||||
tools.into_iter().map(str::to_string).collect()
|
||||
tools.into_iter().map(canonical_allowed_tool_name).collect()
|
||||
}
|
||||
|
||||
fn agent_permission_policy() -> PermissionPolicy {
|
||||
@@ -4904,7 +5360,10 @@ impl SubagentToolExecutor {
|
||||
|
||||
impl ToolExecutor for SubagentToolExecutor {
|
||||
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
|
||||
if !self.allowed_tools.contains(tool_name) {
|
||||
if !self
|
||||
.allowed_tools
|
||||
.contains(&canonical_allowed_tool_name(tool_name))
|
||||
{
|
||||
return Err(ToolError::new(format!(
|
||||
"tool `{tool_name}` is not enabled for this sub-agent"
|
||||
)));
|
||||
@@ -4919,7 +5378,10 @@ impl ToolExecutor for SubagentToolExecutor {
|
||||
fn tool_specs_for_allowed_tools(allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolSpec> {
|
||||
mvp_tool_specs()
|
||||
.into_iter()
|
||||
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
|
||||
.filter(|spec| {
|
||||
allowed_tools
|
||||
.is_none_or(|allowed| allowed.contains(&canonical_allowed_tool_name(spec.name)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -6490,6 +6952,87 @@ mod tests {
|
||||
assert!(names.contains(&"WorkerSendPrompt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_show_schema_exposes_format_enum() {
|
||||
let spec = mvp_tool_specs()
|
||||
.into_iter()
|
||||
.find(|spec| spec.name == "GitShow")
|
||||
.expect("GitShow spec");
|
||||
assert_eq!(
|
||||
spec.input_schema["properties"]["format"]["enum"],
|
||||
json!(["patch", "stat", "metadata"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_show_supports_patch_stat_metadata_and_rejects_metadata_path() {
|
||||
let _guard = env_guard();
|
||||
let root = temp_path("git-show-format");
|
||||
init_git_repo(&root);
|
||||
commit_file(&root, "README.md", "initial\nupdated\n", "update readme");
|
||||
let previous = std::env::current_dir().expect("cwd");
|
||||
std::env::set_current_dir(&root).expect("set cwd");
|
||||
|
||||
let patch = execute_tool("GitShow", &json!({"commit": "HEAD", "format": "patch"}))
|
||||
.expect("patch git show");
|
||||
let patch: serde_json::Value = serde_json::from_str(&patch).expect("patch json");
|
||||
assert!(patch["output"]
|
||||
.as_str()
|
||||
.expect("patch output")
|
||||
.contains("diff --git"));
|
||||
|
||||
let stat = execute_tool("GitShow", &json!({"commit": "HEAD", "format": "stat"}))
|
||||
.expect("stat git show");
|
||||
let stat: serde_json::Value = serde_json::from_str(&stat).expect("stat json");
|
||||
assert!(stat["output"]
|
||||
.as_str()
|
||||
.expect("stat output")
|
||||
.contains("README.md"));
|
||||
|
||||
let legacy_stat = execute_tool("GitShow", &json!({"commit": "HEAD", "stat": true}))
|
||||
.expect("legacy stat git show");
|
||||
let legacy_stat: serde_json::Value =
|
||||
serde_json::from_str(&legacy_stat).expect("legacy stat json");
|
||||
assert!(legacy_stat["output"]
|
||||
.as_str()
|
||||
.expect("legacy stat output")
|
||||
.contains("README.md"));
|
||||
|
||||
let metadata = execute_tool("GitShow", &json!({"commit": "HEAD", "format": "metadata"}))
|
||||
.expect("metadata git show");
|
||||
let metadata: serde_json::Value = serde_json::from_str(&metadata).expect("metadata json");
|
||||
let metadata_output = metadata["output"].as_str().expect("metadata output");
|
||||
assert!(metadata_output.contains("commit "));
|
||||
assert!(metadata_output.contains("update readme"));
|
||||
assert!(!metadata_output.contains("diff --git"));
|
||||
|
||||
let file_patch = execute_tool(
|
||||
"GitShow",
|
||||
&json!({"commit": "HEAD", "path": "README.md", "format": "patch"}),
|
||||
)
|
||||
.expect("file patch git show");
|
||||
let file_patch: serde_json::Value =
|
||||
serde_json::from_str(&file_patch).expect("file patch json");
|
||||
assert_eq!(
|
||||
file_patch["output"].as_str().expect("file patch output"),
|
||||
"initial\nupdated"
|
||||
);
|
||||
|
||||
let metadata_path = execute_tool(
|
||||
"GitShow",
|
||||
&json!({"commit": "HEAD", "path": "README.md", "format": "metadata"}),
|
||||
)
|
||||
.expect_err("metadata with path should be rejected");
|
||||
assert!(metadata_path.contains("cannot be combined with path"));
|
||||
|
||||
let invalid = execute_tool("GitShow", &json!({"commit": "HEAD", "format": "bogus"}))
|
||||
.expect_err("invalid format should be rejected");
|
||||
assert!(invalid.contains("unknown GitShow format"));
|
||||
|
||||
std::env::set_current_dir(&previous).expect("restore cwd");
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_tool_names() {
|
||||
let error = execute_tool("nope", &json!({})).expect_err("tool should be rejected");
|
||||
@@ -7188,6 +7731,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_tools_normalize_to_canonical_snake_case_and_aliases_432() {
|
||||
let registry = GlobalToolRegistry::builtin();
|
||||
let allowed = registry
|
||||
.normalize_allowed_tools(&["Read,WebFetch,MCP".to_string()])
|
||||
.expect("aliases and legacy names should normalize")
|
||||
.expect("allow-list should be populated");
|
||||
assert!(allowed.contains("read_file"));
|
||||
assert!(allowed.contains("web_fetch"));
|
||||
assert!(allowed.contains("mcp"));
|
||||
assert!(!allowed.contains("Read"));
|
||||
assert!(!allowed.contains("WebFetch"));
|
||||
|
||||
let canonical = registry.canonical_allowed_tool_names();
|
||||
assert!(canonical.contains(&"web_fetch".to_string()));
|
||||
assert!(canonical.contains(&"todo_write".to_string()));
|
||||
assert!(!canonical.contains(&"WebFetch".to_string()));
|
||||
assert_eq!(
|
||||
registry.allowed_tool_aliases().get("WebFetch"),
|
||||
Some(&"web_fetch".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_tools_extend_registry_definitions_permissions_and_search() {
|
||||
let registry = GlobalToolRegistry::builtin()
|
||||
@@ -8169,7 +8735,7 @@ mod tests {
|
||||
.expect("spawn job should be captured");
|
||||
assert_eq!(captured_job.prompt, "Check tests and outstanding work.");
|
||||
assert!(captured_job.allowed_tools.contains("read_file"));
|
||||
assert!(!captured_job.allowed_tools.contains("Agent"));
|
||||
assert!(!captured_job.allowed_tools.contains("agent"));
|
||||
|
||||
let normalized = execute_tool(
|
||||
"Agent",
|
||||
@@ -8769,7 +9335,7 @@ mod tests {
|
||||
let general = allowed_tools_for_subagent("general-purpose");
|
||||
assert!(general.contains("bash"));
|
||||
assert!(general.contains("write_file"));
|
||||
assert!(!general.contains("Agent"));
|
||||
assert!(!general.contains("agent"));
|
||||
|
||||
let explore = allowed_tools_for_subagent("Explore");
|
||||
assert!(explore.contains("read_file"));
|
||||
@@ -8777,13 +9343,13 @@ mod tests {
|
||||
assert!(!explore.contains("bash"));
|
||||
|
||||
let plan = allowed_tools_for_subagent("Plan");
|
||||
assert!(plan.contains("TodoWrite"));
|
||||
assert!(plan.contains("StructuredOutput"));
|
||||
assert!(!plan.contains("Agent"));
|
||||
assert!(plan.contains("todo_write"));
|
||||
assert!(plan.contains("structured_output"));
|
||||
assert!(!plan.contains("agent"));
|
||||
|
||||
let verification = allowed_tools_for_subagent("Verification");
|
||||
assert!(verification.contains("bash"));
|
||||
assert!(verification.contains("PowerShell"));
|
||||
assert!(verification.contains("power_shell"));
|
||||
assert!(!verification.contains("write_file"));
|
||||
}
|
||||
|
||||
@@ -9867,6 +10433,26 @@ printf 'pwsh:%s' "$1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_workspace_write_enforcer_when_web_tools_then_denied() {
|
||||
let registry = workspace_write_registry();
|
||||
for (tool, input) in [
|
||||
(
|
||||
"WebFetch",
|
||||
json!({"url":"https://example.com", "prompt":"summarize"}),
|
||||
),
|
||||
("WebSearch", json!({"query":"rust language"})),
|
||||
] {
|
||||
let err = registry
|
||||
.execute(tool, &input)
|
||||
.expect_err("network tools should require explicit full access");
|
||||
assert!(
|
||||
err.contains("requires 'danger-full-access'"),
|
||||
"{tool} should require elevated mode: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_workspace_write_enforcer_when_bash_uses_shell_expansion_then_denied() {
|
||||
let registry = workspace_write_registry();
|
||||
|
||||
11
rust/scripts/install.sh
Executable file
11
rust/scripts/install.sh
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Build the release binary
|
||||
cargo build --release
|
||||
|
||||
# Link to ~/.local/bin
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
ln -sf "$(pwd)/target/release/claw" "$HOME/.local/bin/claw"
|
||||
|
||||
echo "✓ Claw installed to ~/.local/bin/claw"
|
||||
@@ -16,6 +16,16 @@ def run(cmd: list[str], cwd: Path) -> int:
|
||||
return subprocess.run(cmd, cwd=str(cwd)).returncode
|
||||
|
||||
|
||||
def run_quiet_until_failure(cmd: list[str], cwd: Path) -> int:
|
||||
result = subprocess.run(cmd, cwd=str(cwd), text=True, capture_output=True)
|
||||
if result.returncode:
|
||||
if result.stdout:
|
||||
print(result.stdout, end="")
|
||||
if result.stderr:
|
||||
print(result.stderr, end="", file=sys.stderr)
|
||||
return result.returncode
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("command", choices=["generate", "validate"])
|
||||
@@ -26,11 +36,13 @@ def main(argv: list[str] | None = None) -> int:
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
repo_root = args.repo_root.resolve()
|
||||
script_root = Path(__file__).resolve().parent
|
||||
tool_root = script_root.parent
|
||||
board_json = repo_root / args.board_json
|
||||
board_md = repo_root / args.board_md
|
||||
generator = repo_root / "scripts" / "generate_cc2_board.py"
|
||||
validator = repo_root / "scripts" / "validate_cc2_board.py"
|
||||
renderer = repo_root / ".omx" / "cc2" / "render_board_md.py"
|
||||
generator = script_root / "generate_cc2_board.py"
|
||||
validator = script_root / "validate_cc2_board.py"
|
||||
renderer = tool_root / ".omx" / "cc2" / "render_board_md.py"
|
||||
|
||||
if args.command == "generate":
|
||||
rc = run([sys.executable, str(generator), "--repo-root", str(repo_root), "--out-dir", str(board_json.parent)], repo_root)
|
||||
@@ -43,7 +55,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
[sys.executable, str(renderer), str(board_json), str(board_md), "--check"],
|
||||
]
|
||||
for cmd in checks:
|
||||
rc = run(cmd, repo_root)
|
||||
rc = run_quiet_until_failure(cmd, repo_root)
|
||||
if rc:
|
||||
return rc
|
||||
print(f"CC2 board validation PASS: {board_json} and {board_md} are canonical and in sync")
|
||||
|
||||
@@ -13,6 +13,24 @@
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
sed -n '2,12p' "$0" | sed 's/^# //; s/^#//'
|
||||
}
|
||||
|
||||
if [[ $# -gt 0 ]]; then
|
||||
case "$1" in
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "error: unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
RUST_DIR="$REPO_ROOT/rust"
|
||||
BINARY="$RUST_DIR/target/debug/claw"
|
||||
@@ -60,8 +78,8 @@ echo " export CLAW=$BINARY" >&2
|
||||
echo "" >&2
|
||||
echo " Dogfood with isolated config (no real user config on stderr):" >&2
|
||||
echo " CLAW_ISOLATED=\$(mktemp -d)" >&2
|
||||
echo " trap 'rm -rf \"\$CLAW_ISOLATED\"' EXIT" >&2
|
||||
echo " CLAW_CONFIG_HOME=\$CLAW_ISOLATED \$CLAW plugins list --output-format json" >&2
|
||||
echo " rm -rf \$CLAW_ISOLATED" >&2
|
||||
echo "" >&2
|
||||
echo " cargo run overhead: ~1s/invocation vs 7ms for pre-built binary." >&2
|
||||
echo " Prefer pre-built binary (\$CLAW) for dogfood loops." >&2
|
||||
|
||||
145
scripts/dogfood-probe.py
Normal file
145
scripts/dogfood-probe.py
Normal file
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProbeResult:
|
||||
kind: str
|
||||
argv: list[str]
|
||||
returncode: int | None
|
||||
stdout: bytes
|
||||
stderr: bytes
|
||||
message: str | None = None
|
||||
|
||||
@property
|
||||
def stdout_text(self) -> str:
|
||||
return self.stdout.decode('utf-8', errors='replace')
|
||||
|
||||
@property
|
||||
def stderr_text(self) -> str:
|
||||
return self.stderr.decode('utf-8', errors='replace')
|
||||
|
||||
def to_json_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
'kind': self.kind,
|
||||
'argv': self.argv,
|
||||
'returncode': self.returncode,
|
||||
'stdout': self.stdout_text,
|
||||
'stderr': self.stderr_text,
|
||||
'message': self.message,
|
||||
}
|
||||
|
||||
|
||||
def run_probe(argv: Sequence[str], *, timeout: float = 10.0, require_stdout_json_byte0: bool = False) -> ProbeResult:
|
||||
explicit_argv = [str(arg) for arg in argv]
|
||||
if not explicit_argv:
|
||||
return ProbeResult(
|
||||
kind='probe_error',
|
||||
argv=[],
|
||||
returncode=None,
|
||||
stdout=b'',
|
||||
stderr=b'',
|
||||
message='argv must contain at least the executable path',
|
||||
)
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
explicit_argv,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
return ProbeResult(
|
||||
kind='timeout',
|
||||
argv=explicit_argv,
|
||||
returncode=None,
|
||||
stdout=exc.stdout or b'',
|
||||
stderr=exc.stderr or b'',
|
||||
message=f'probe timed out after {timeout:g}s',
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
return ProbeResult(
|
||||
kind='probe_error',
|
||||
argv=explicit_argv,
|
||||
returncode=None,
|
||||
stdout=b'',
|
||||
stderr=b'',
|
||||
message=str(exc),
|
||||
)
|
||||
|
||||
if require_stdout_json_byte0:
|
||||
if not completed.stdout:
|
||||
return ProbeResult(
|
||||
kind='product_error',
|
||||
argv=explicit_argv,
|
||||
returncode=completed.returncode,
|
||||
stdout=completed.stdout,
|
||||
stderr=completed.stderr,
|
||||
message='stdout is empty; expected JSON at byte 0',
|
||||
)
|
||||
if completed.stdout[:1] not in (b'{', b'['):
|
||||
return ProbeResult(
|
||||
kind='product_error',
|
||||
argv=explicit_argv,
|
||||
returncode=completed.returncode,
|
||||
stdout=completed.stdout,
|
||||
stderr=completed.stderr,
|
||||
message='stdout JSON does not start at byte 0',
|
||||
)
|
||||
try:
|
||||
json.loads(completed.stdout.decode('utf-8'))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
return ProbeResult(
|
||||
kind='product_error',
|
||||
argv=explicit_argv,
|
||||
returncode=completed.returncode,
|
||||
stdout=completed.stdout,
|
||||
stderr=completed.stderr,
|
||||
message=f'stdout is not parseable JSON: {exc}',
|
||||
)
|
||||
|
||||
if completed.returncode != 0:
|
||||
return ProbeResult(
|
||||
kind='product_error',
|
||||
argv=explicit_argv,
|
||||
returncode=completed.returncode,
|
||||
stdout=completed.stdout,
|
||||
stderr=completed.stderr,
|
||||
message=f'process exited with code {completed.returncode}',
|
||||
)
|
||||
|
||||
return ProbeResult(
|
||||
kind='ok',
|
||||
argv=explicit_argv,
|
||||
returncode=completed.returncode,
|
||||
stdout=completed.stdout,
|
||||
stderr=completed.stderr,
|
||||
)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description='Run an argv-safe dogfood probe and emit separated channels as JSON.')
|
||||
parser.add_argument('--timeout', type=float, default=10.0)
|
||||
parser.add_argument('--stdout-json-byte0', action='store_true', help='Require stdout to be parseable JSON starting at byte 0.')
|
||||
parser.add_argument('command', nargs=argparse.REMAINDER, help='Executable and arguments to run. Use -- before the target argv.')
|
||||
args = parser.parse_args(argv)
|
||||
command = args.command
|
||||
if command and command[0] == '--':
|
||||
command = command[1:]
|
||||
|
||||
result = run_probe(command, timeout=args.timeout, require_stdout_json_byte0=args.stdout_json_byte0)
|
||||
print(json.dumps(result.to_json_dict(), sort_keys=True))
|
||||
return 0 if result.kind == 'ok' else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -503,8 +503,12 @@ def main() -> int:
|
||||
args = parser.parse_args()
|
||||
repo_root = args.repo_root.resolve()
|
||||
out_dir = args.out_dir or (repo_root / ".omx" / "cc2")
|
||||
try:
|
||||
board = build_board(repo_root)
|
||||
except FileNotFoundError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
board = build_board(repo_root)
|
||||
board_json = out_dir / "board.json"
|
||||
board_md = out_dir / "board.md"
|
||||
board_json.write_text(json.dumps(board, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
|
||||
78
scripts/roadmap-check-ids.sh
Executable file
78
scripts/roadmap-check-ids.sh
Executable file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# roadmap-check-ids.sh — fail when helper-era ROADMAP item ids are duplicated.
|
||||
# Usage: scripts/roadmap-check-ids.sh [--min-id N] [path/to/ROADMAP.md]
|
||||
#
|
||||
# By default this validates ids >= 723, the point where ROADMAP appends started
|
||||
# using scripts/roadmap-next-id.sh. Earlier ROADMAP content contains historical
|
||||
# numbered lists and already-landed duplicate low ids, so the default guard is
|
||||
# intentionally scoped to new helper-era append collisions. Use --min-id 1 for a
|
||||
# strict whole-file audit after legacy numbering is cleaned up.
|
||||
set -euo pipefail
|
||||
|
||||
MIN_ID=723
|
||||
ROADMAP="ROADMAP.md"
|
||||
ROADMAP_PATH_SEEN=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--min-id)
|
||||
if [[ $# -lt 2 || ! "$2" =~ ^[0-9]+$ ]]; then
|
||||
echo "error: --min-id requires a non-negative integer" >&2
|
||||
exit 2
|
||||
fi
|
||||
MIN_ID="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
sed -n '2,9p' "$0" | sed 's/^# //; s/^#//'
|
||||
exit 0
|
||||
;;
|
||||
--*)
|
||||
echo "error: unknown option: $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
*)
|
||||
if [[ "$ROADMAP_PATH_SEEN" -ne 0 ]]; then
|
||||
echo "error: unexpected extra ROADMAP path: $1" >&2
|
||||
exit 2
|
||||
fi
|
||||
ROADMAP="$1"
|
||||
ROADMAP_PATH_SEEN=1
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -f "$ROADMAP" ]]; then
|
||||
echo "error: ROADMAP not found at $ROADMAP" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
awk -v min_id="$MIN_ID" -v path="$ROADMAP" '
|
||||
/^[0-9]+\./ {
|
||||
id = $0
|
||||
sub(/\..*/, "", id)
|
||||
id += 0
|
||||
if (id >= min_id) {
|
||||
count[id]++
|
||||
lines[id] = lines[id] (lines[id] ? ", " : "") FNR
|
||||
}
|
||||
}
|
||||
END {
|
||||
for (id in count) {
|
||||
if (count[id] > 1) {
|
||||
duplicate_count++
|
||||
duplicate_ids[duplicate_count] = id
|
||||
}
|
||||
}
|
||||
if (duplicate_count) {
|
||||
print "error: duplicate ROADMAP numeric id(s) in " path " (min id " min_id "):" > "/dev/stderr"
|
||||
for (i = 1; i <= duplicate_count; i++) {
|
||||
id = duplicate_ids[i]
|
||||
print " - " id " at line(s) " lines[id] > "/dev/stderr"
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
print "roadmap id check passed: no duplicate ids >= " min_id " in " path
|
||||
}
|
||||
' "$ROADMAP"
|
||||
81
scripts/roadmap-next-id.sh
Executable file
81
scripts/roadmap-next-id.sh
Executable file
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# roadmap-next-id.sh — print the next available ROADMAP item id.
|
||||
# Usage: scripts/roadmap-next-id.sh [path/to/ROADMAP.md]
|
||||
#
|
||||
# Designed to be used before appending a new entry so that concurrent
|
||||
# dogfood claws do not accidentally reuse the same id:
|
||||
#
|
||||
# NEXT=$(scripts/roadmap-next-id.sh)
|
||||
# cat >> ROADMAP.md << EOF
|
||||
# ${NEXT}. **...description...**
|
||||
# EOF
|
||||
#
|
||||
# The script first validates helper-era ids with roadmap-check-ids.sh, then
|
||||
# reads the highest numeric id prefix from ROADMAP.md and prints highest+1. It
|
||||
# does not lock the file; callers working in parallel should git-pull
|
||||
# immediately before appending, run scripts/roadmap-check-ids.sh before push,
|
||||
# and resolve any append collision at git-push time.
|
||||
set -euo pipefail
|
||||
|
||||
ROADMAP="ROADMAP.md"
|
||||
ROADMAP_PATH_SEEN=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--help|-h)
|
||||
sed -n '2,15p' "$0" | sed 's/^# //; s/^#//'
|
||||
exit 0
|
||||
;;
|
||||
--*)
|
||||
echo "error: unknown option: $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
*)
|
||||
if [[ "$ROADMAP_PATH_SEEN" -ne 0 ]]; then
|
||||
echo "error: unexpected extra ROADMAP path: $1" >&2
|
||||
exit 2
|
||||
fi
|
||||
ROADMAP="$1"
|
||||
ROADMAP_PATH_SEEN=1
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
CHECKER="$SCRIPT_DIR/roadmap-check-ids.sh"
|
||||
|
||||
if [[ ! -f "$ROADMAP" ]]; then
|
||||
echo "error: ROADMAP not found at $ROADMAP" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$CHECKER" || ! -r "$CHECKER" ]]; then
|
||||
echo "error: required ROADMAP id checker not found or not readable at $CHECKER" >&2
|
||||
echo "error: refusing to print a next id without duplicate-id validation" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! checker_output="$(bash "$CHECKER" "$ROADMAP" 2>&1)"; then
|
||||
printf '%s\n' "$checker_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find the highest leading integer from lines that start with a number + '.'.
|
||||
highest=$(awk '
|
||||
/^[0-9]+\./ {
|
||||
id = $0
|
||||
sub(/\..*/, "", id)
|
||||
id += 0
|
||||
if (id > highest) {
|
||||
highest = id
|
||||
}
|
||||
}
|
||||
END { print highest + 0 }
|
||||
' "$ROADMAP")
|
||||
|
||||
if [[ "$highest" -eq 0 ]]; then
|
||||
echo 1
|
||||
else
|
||||
echo $(( highest + 1 ))
|
||||
fi
|
||||
@@ -44,7 +44,17 @@ def main() -> int:
|
||||
args = parser.parse_args()
|
||||
repo_root = args.repo_root.resolve()
|
||||
board_path = args.board or (repo_root / ".omx" / "cc2" / "board.json")
|
||||
board = json.loads(board_path.read_text(encoding="utf-8"))
|
||||
try:
|
||||
board = json.loads(board_path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
print(f"error: board not found at {board_path}")
|
||||
return 1
|
||||
except IsADirectoryError:
|
||||
print(f"error: board path is a directory: {board_path}")
|
||||
return 1
|
||||
except json.JSONDecodeError as exc:
|
||||
print(f"error: invalid board JSON at {board_path}: {exc}")
|
||||
return 1
|
||||
errors: list[str] = []
|
||||
ids = set()
|
||||
for index, item in enumerate(board.get("items", []), 1):
|
||||
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
45
tests/test_pre_push_hook_contract.py
Normal file
45
tests/test_pre_push_hook_contract.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
PRE_PUSH_HOOK = REPO_ROOT / '.github' / 'hooks' / 'pre-push'
|
||||
|
||||
|
||||
class PrePushHookContractTests(unittest.TestCase):
|
||||
def test_skip_escape_hatch_exits_successfully_with_stderr_notice(self) -> None:
|
||||
env = os.environ.copy()
|
||||
env['SKIP_CLAW_PRE_PUSH_BUILD'] = '1'
|
||||
|
||||
result = subprocess.run(
|
||||
['bash', str(PRE_PUSH_HOOK)],
|
||||
cwd=REPO_ROOT,
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
self.assertEqual('', result.stdout)
|
||||
self.assertIn('SKIP_CLAW_PRE_PUSH_BUILD=1', result.stderr)
|
||||
self.assertIn('skipping cargo workspace build', result.stderr)
|
||||
|
||||
def test_default_build_gate_uses_workspace_locked_cargo_build(self) -> None:
|
||||
hook = PRE_PUSH_HOOK.read_text()
|
||||
|
||||
self.assertIn(
|
||||
'cargo build --manifest-path rust/Cargo.toml --workspace --locked',
|
||||
hook,
|
||||
)
|
||||
self.assertIn(
|
||||
'build_cmd=(cargo build --manifest-path rust/Cargo.toml --workspace --locked)',
|
||||
hook,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
163
tests/test_roadmap_helpers.py
Normal file
163
tests/test_roadmap_helpers.py
Normal file
@@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
NEXT_ID = REPO_ROOT / 'scripts' / 'roadmap-next-id.sh'
|
||||
DOGFOOD_PROBE = REPO_ROOT / 'scripts' / 'dogfood-probe.py'
|
||||
|
||||
|
||||
|
||||
|
||||
def run_next_id(roadmap: Path, script: Path = NEXT_ID) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
['bash', str(script), str(roadmap)],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def run_dogfood_probe(args: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
['python3', str(DOGFOOD_PROBE), *args],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
class RoadmapHelperTests(unittest.TestCase):
|
||||
def test_roadmap_next_id_prints_only_next_id_after_duplicate_check(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
roadmap = Path(temp_dir) / 'ROADMAP.md'
|
||||
roadmap.write_text('721. old\n723. helper era\n724. guard\n')
|
||||
|
||||
result = run_next_id(roadmap)
|
||||
|
||||
self.assertEqual(0, result.returncode)
|
||||
self.assertEqual('725\n', result.stdout)
|
||||
self.assertEqual('', result.stderr)
|
||||
|
||||
def test_roadmap_next_id_fails_fast_on_helper_era_duplicate(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
roadmap = Path(temp_dir) / 'ROADMAP.md'
|
||||
roadmap.write_text('722. legacy\n999. first\n999. duplicate\n')
|
||||
|
||||
result = run_next_id(roadmap)
|
||||
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertEqual('', result.stdout)
|
||||
self.assertIn('duplicate ROADMAP numeric id(s)', result.stderr)
|
||||
self.assertIn('999', result.stderr)
|
||||
self.assertNotIn('1000', result.stdout)
|
||||
|
||||
def test_roadmap_next_id_fails_when_explicit_roadmap_path_is_missing(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
roadmap = Path(temp_dir) / 'missing-ROADMAP.md'
|
||||
|
||||
result = run_next_id(roadmap)
|
||||
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertEqual('', result.stdout)
|
||||
self.assertIn('ROADMAP not found', result.stderr)
|
||||
self.assertIn(str(roadmap), result.stderr)
|
||||
|
||||
def test_roadmap_next_id_fails_closed_when_checker_is_unavailable(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
script_dir = Path(temp_dir) / 'scripts'
|
||||
script_dir.mkdir()
|
||||
copied_next_id = script_dir / 'roadmap-next-id.sh'
|
||||
shutil.copy2(NEXT_ID, copied_next_id)
|
||||
roadmap = Path(temp_dir) / 'ROADMAP.md'
|
||||
roadmap.write_text('724. guard\n')
|
||||
|
||||
result = run_next_id(roadmap, copied_next_id)
|
||||
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertEqual('', result.stdout)
|
||||
self.assertIn('required ROADMAP id checker not found or not readable', result.stderr)
|
||||
self.assertIn('refusing to print a next id', result.stderr)
|
||||
|
||||
def test_dogfood_probe_runs_explicit_argv_and_separates_channels(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
fixture = Path(temp_dir) / 'fixture.py'
|
||||
fixture.write_text(
|
||||
'from __future__ import annotations\n'
|
||||
'import json\n'
|
||||
'import sys\n'
|
||||
'print(json.dumps({"argv": sys.argv[1:]}))\n'
|
||||
'print("diagnostic", file=sys.stderr)\n'
|
||||
)
|
||||
|
||||
result = run_dogfood_probe([
|
||||
'--stdout-json-byte0',
|
||||
'--',
|
||||
'python3',
|
||||
str(fixture),
|
||||
'--output-format',
|
||||
'json',
|
||||
'doctor',
|
||||
'--help',
|
||||
])
|
||||
|
||||
self.assertEqual(0, result.returncode)
|
||||
payload = __import__('json').loads(result.stdout)
|
||||
self.assertEqual('ok', payload['kind'])
|
||||
self.assertEqual([
|
||||
'python3',
|
||||
str(fixture),
|
||||
'--output-format',
|
||||
'json',
|
||||
'doctor',
|
||||
'--help',
|
||||
], payload['argv'])
|
||||
self.assertEqual(0, payload['returncode'])
|
||||
self.assertEqual('{"argv": ["--output-format", "json", "doctor", "--help"]}\n', payload['stdout'])
|
||||
self.assertEqual('diagnostic\n', payload['stderr'])
|
||||
|
||||
def test_dogfood_probe_labels_timeout_separately_from_product_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
fixture = Path(temp_dir) / 'sleep.py'
|
||||
fixture.write_text('import time\ntime.sleep(2)\n')
|
||||
|
||||
result = run_dogfood_probe(['--timeout', '0.1', '--', 'python3', str(fixture)])
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
payload = __import__('json').loads(result.stdout)
|
||||
self.assertEqual('timeout', payload['kind'])
|
||||
self.assertIsNone(payload['returncode'])
|
||||
self.assertIn('timed out', payload['message'])
|
||||
|
||||
def test_dogfood_probe_labels_probe_construction_failure(self) -> None:
|
||||
result = run_dogfood_probe([])
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
payload = __import__('json').loads(result.stdout)
|
||||
self.assertEqual('probe_error', payload['kind'])
|
||||
self.assertEqual([], payload['argv'])
|
||||
self.assertIsNone(payload['returncode'])
|
||||
self.assertIn('argv must contain', payload['message'])
|
||||
|
||||
def test_dogfood_probe_labels_stdout_json_prefix_failure_as_product_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
fixture = Path(temp_dir) / 'prefixed.py'
|
||||
fixture.write_text('print("warning before json")\nprint("{}")\n')
|
||||
|
||||
result = run_dogfood_probe(['--stdout-json-byte0', '--', 'python3', str(fixture)])
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
payload = __import__('json').loads(result.stdout)
|
||||
self.assertEqual('product_error', payload['kind'])
|
||||
self.assertEqual(0, payload['returncode'])
|
||||
self.assertIn('byte 0', payload['message'])
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user