Compare commits
502 Commits
skills
..
af7ea9b5bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
af7ea9b5bc
|
|||
|
68b51bf10e
|
|||
|
721ca1bc10
|
|||
|
eb102e1374
|
|||
|
bc2174286e
|
|||
|
1b4ab894d4
|
|||
|
1c983fb144
|
|||
|
09fb6634f0
|
|||
|
46ddfdc464
|
|||
|
df6c3c50db
|
|||
|
8c5bed3e34
|
|||
|
d45375a454
|
|||
|
ed8327e9d6
|
|||
|
bc1800db4f
|
|||
|
dd8da58105
|
|||
|
6a0df70777
|
|||
|
5e550a67ce
|
|||
|
b781dd8dc6
|
|||
|
43fbe448cb
|
|||
|
9efd6a2690
|
|||
|
a3f278544a
|
|||
|
e48926f458
|
|||
|
4e616fe7c3
|
|||
|
863b28f01e
|
|||
|
bf97f2261d
|
|||
|
baa44ec5cb
|
|||
|
29af20f316
|
|||
|
960a199cd2
|
|||
|
3a18ffdaf3
|
|||
|
7aa00d52de
|
|||
|
ac58ddc202
|
|||
|
38259642cd
|
|||
|
90e105a171
|
|||
|
a5ece505b7
|
|||
|
fb8633dc75
|
|||
|
a7ebc15b89
|
|||
|
a7a9b6b1cf
|
|||
|
e1c2f0aa42
|
|||
|
6e9b394f73
|
|||
|
747ca0d0fc
|
|||
|
ba665528ed
|
|||
|
1440e23748
|
|||
|
8ff9d84a85
|
|||
|
dc8e831f27
|
|||
|
985ae11fcf
|
|||
|
b758b17dbb
|
|||
|
aef26013cb
|
|||
|
b1fc199a5f
|
|||
|
7e801b80d0
|
|||
|
7cd7abe469
|
|||
|
6a5561edba
|
|||
|
d8a92f4e62
|
|||
|
6330d7dd95
|
|||
|
c63eb0a9f9
|
|||
|
d927a9b99f
|
|||
|
766684615b
|
|||
|
5be12e90dc
|
|||
|
7325ad7b32
|
|||
|
2e224948d4
|
|||
|
fa424bde34
|
|||
|
42c88fa2a3
|
|||
|
84c6f88cf2
|
|||
|
f401c637cc
|
|||
|
3239c5d990
|
|||
|
1dff08893a
|
|||
|
f65ec9e9fe
|
|||
|
3ce5ab4fe7
|
|||
| fd21431c2f | |||
| 61a698f9eb | |||
|
518a39c143
|
|||
|
160ee5d5ae
|
|||
|
e4819ff9db
|
|||
|
ecb3cdfcc2
|
|||
|
364cf29296
|
|||
|
b2fa8ebb71
|
|||
| 48031d592f | |||
|
4c7a0a7a77
|
|||
|
8f960fdbbf
|
|||
|
feefe45ed2
|
|||
| 9958eeee8f | |||
|
aa31db7e07
|
|||
|
8c6dde7d86
|
|||
|
738e29059b
|
|||
|
2eb81c4a8b
|
|||
|
88998d1019
|
|||
|
a1fc099c24
|
|||
|
2fe365bef8
|
|||
|
efb1b7b96b
|
|||
|
d915f9e3c1
|
|||
|
11ebf3c155
|
|||
|
abf5d425fd
|
|||
|
1e3d52482a
|
|||
|
0fb72f8226
|
|||
|
0701c370b4
|
|||
|
0bdaa9441f
|
|||
|
4ba1bd8a24
|
|||
|
b484242e4c
|
|||
|
a756394e30
|
|||
|
0faf7b850d
|
|||
|
eeb9f7083b
|
|||
|
b6a5b340f1
|
|||
|
209257c7b1
|
|||
|
4e88cebe28
|
|||
|
738b600fa6
|
|||
|
f67538e5ab
|
|||
|
18bb3d3440
|
|||
|
04cd3c890b
|
|||
|
ef8f5865e2
|
|||
|
493e9bb2a5
|
|||
|
3eff135349
|
|||
|
7ac753d824
|
|||
|
9add71ff13
|
|||
|
7154c3a652
|
|||
|
36ac924d77
|
|||
|
5e4d3ff011
|
|||
|
fd287b09b0
|
|||
|
07c1f70df3
|
|||
|
8c398b6360
|
|||
|
e43c2e477a
|
|||
|
6078072915
|
|||
|
1902e2d040
|
|||
|
702e6f2f63
|
|||
|
01938a0f28
|
|||
|
5d017fbb48
|
|||
|
a3ed9476ae
|
|||
|
a22faad992
|
|||
|
06fe1f9471
|
|||
|
e2ff2c03f8
|
|||
|
7ca9a19d3b
|
|||
|
f5b69d6b4d
|
|||
|
4f244618ca
|
|||
|
b7a20a000a
|
|||
|
0094be475f
|
|||
|
3e508c9337
|
|||
|
edd3c08247
|
|||
|
bf6b2f718c
|
|||
|
984a073730
|
|||
|
aa4babff56
|
|||
|
7f620d469b
|
|||
|
33782c59a8
|
|||
|
5669830510
|
|||
|
99c6cff068
|
|||
|
9b395a304d
|
|||
|
01912bcef3
|
|||
|
e0b85fc936
|
|||
|
73f6e07e47
|
|||
|
534b9923ae
|
|||
|
c66faa22dc
|
|||
|
cf666eb2c6
|
|||
|
76861508c9
|
|||
|
beebb39050
|
|||
|
3d7ba424f1
|
|||
|
84eb82b355
|
|||
|
1fbdcd66d1
|
|||
|
5b65496684
|
|||
|
ca808b4c08
|
|||
|
84c1753ed5
|
|||
|
c8d9f89d59
|
|||
|
e3531b4dcf
|
|||
|
b939868d28
|
|||
|
7630b3e75c
|
|||
| 3292f8e0a5 | |||
|
cf1c06e632
|
|||
|
49f2932b30
|
|||
|
5fd786dd3d
|
|||
|
f5967c7771
|
|||
|
eee0e86131
|
|||
|
51dfd2a655
|
|||
|
d9cf0c4b08
|
|||
|
b4c65f7a19
|
|||
|
1c0e836a92
|
|||
|
2da196c091
|
|||
|
69648afe27
|
|||
|
454f5c03f3
|
|||
|
406642723e
|
|||
|
2469b713c7
|
|||
|
b6ad7a575d
|
|||
|
f3b410d146
|
|||
|
095d0f3d8a
|
|||
|
5f445e046f
|
|||
|
96ab2bdc1b
|
|||
|
cb175e3b51
|
|||
|
7965b970d9
|
|||
|
0a21f10b04
|
|||
|
49aa9fad41
|
|||
|
8f7d3bd13c
|
|||
|
f7fb249d43
|
|||
|
d9498ffb21
|
|||
|
0177fa6906
|
|||
|
c3f6cb8f46
|
|||
|
7facdce6b6
|
|||
|
c11eb352fe
|
|||
|
0e427dc4ba
|
|||
|
f1914f6bd4
|
|||
|
dba6304f51
|
|||
|
e40a8bba72
|
|||
|
c057249e52
|
|||
|
d906713d7d
|
|||
| ff3419a714 | |||
|
a5899da4fb
|
|||
|
dedcef8ac5
|
|||
|
d658f1d2fe
|
|||
|
6b4a45874f
|
|||
|
7839e1dbd9
|
|||
|
78c3932f36
|
|||
|
11334149b0
|
|||
|
4caa035528
|
|||
|
f30e81af08
|
|||
|
4c75655f58
|
|||
|
f865892c28
|
|||
|
ebeb9c9b7d
|
|||
|
ab2b927fcb
|
|||
|
7e5ff2ba1f
|
|||
|
ed59051f3d
|
|||
| e98bf56a2b | |||
| fb510b1a4f | |||
|
6c17462040
|
|||
|
1536cf384c
|
|||
|
d6842d7e29
|
|||
|
fbc0acda2a
|
|||
|
0327d041b6
|
|||
|
6a01fd4fbd
|
|||
| d822180205 | |||
|
89d0fdce26
|
|||
|
b3ecdce979
|
|||
|
3873821a31
|
|||
|
9c2801b643
|
|||
|
d78820dcd4
|
|||
|
d43c4232a2
|
|||
|
f41c85b703
|
|||
|
9e056bdcf0
|
|||
|
d6022b9f98
|
|||
|
6fc1abf94a
|
|||
|
92ea0f624e
|
|||
|
c3fd8fbc1c
|
|||
|
7fd3f7761c
|
|||
|
05e19098b2
|
|||
|
60067ae757
|
|||
|
c72003b0b6
|
|||
|
7c9d500116
|
|||
|
6b2c87b562
|
|||
|
b2dbdfb4b1
|
|||
|
063e198f96
|
|||
|
73cbe16ec1
|
|||
|
bdea854a9f
|
|||
|
9b4c800597
|
|||
|
eb4d1c02f4
|
|||
|
c428990900
|
|||
|
03b9cc70b9
|
|||
|
3fa0eb832c
|
|||
|
83f66e1061
|
|||
|
741b9c364c
|
|||
|
b6f6f456db
|
|||
|
00a6cf74d7
|
|||
|
d35ca352ca
|
|||
| 57dc1cb252 | |||
|
101a9cdd6e
|
|||
|
c5f52e1efb
|
|||
|
470149b606
|
|||
|
02062c5a50
|
|||
|
e6e99b6926
|
|||
|
15a293204f
|
|||
|
ecf3780aed
|
|||
|
e798747135
|
|||
|
60493728a0
|
|||
|
25d6370b20
|
|||
|
d67f845af5
|
|||
|
920a14cabe
|
|||
|
58bdd2e584
|
|||
|
ce6f53ad05
|
|||
|
96f8007d53
|
|||
|
32a55652fe
|
|||
|
2b92e6c98b
|
|||
|
cfa654bcd8
|
|||
|
d0f5ae39e2
|
|||
|
2bb8cf5f73
|
|||
|
fbac446859
|
|||
|
f91cf2e346
|
|||
|
b6b33ab7e3
|
|||
|
c1902a69d1
|
|||
|
812a8e101c
|
|||
|
655ee2a599
|
|||
|
128a8f9a9c
|
|||
|
b1be9443e7
|
|||
|
7b12c69ebf
|
|||
|
69ad584137
|
|||
|
313058e70a
|
|||
|
ea96d9ba3d
|
|||
|
7884adc7c1
|
|||
|
948466d771
|
|||
|
3894c98b5b
|
|||
|
5e9c31595e
|
|||
|
39d9b25e47
|
|||
|
b86f76ddb9
|
|||
|
7f267a10a1
|
|||
|
cdafdff281
|
|||
|
60ad83d6d9
|
|||
|
44c03ccf4f
|
|||
|
af933bbb29
|
|||
|
1f127ee990
|
|||
|
88a9a7709f
|
|||
| e8d92d1b01 | |||
| ddbfd03e75 | |||
|
d1c7f09015
|
|||
|
d2f8f995f0
|
|||
|
5ef9a397ca
|
|||
|
325ab1f45e
|
|||
|
4cfaa2dc77
|
|||
|
6abe2c5536
|
|||
|
03cfd59962
|
|||
|
4d7d5e5e53
|
|||
|
3779b940ae
|
|||
|
d2e541c5c0
|
|||
|
621c90427c
|
|||
|
486001ee85
|
|||
|
c7a2ec084f
|
|||
|
d4e0d48198
|
|||
|
07f23bab5e
|
|||
|
b11797ea1c
|
|||
|
70c2d411ae
|
|||
|
f82c9aff40
|
|||
|
a935add2a7
|
|||
|
8a37a88ffd
|
|||
|
8f66cac680
|
|||
|
0a40ddd2e4
|
|||
|
d5e0728532
|
|||
|
25c0885dcc
|
|||
|
f56ed7d005
|
|||
|
d79e4b9dff
|
|||
|
cdd829199f
|
|||
|
e3c644b8ca
|
|||
|
5cb8070da1
|
|||
|
66801b5d07
|
|||
|
f2de196e22
|
|||
|
2eba530895
|
|||
| 3baa3102a3 | |||
| 2d4fad596c | |||
| 7259e59d2a | |||
| cec04c4597 | |||
| a7f5677195 | |||
| 6075f0a190 | |||
| 15310a9e2c | |||
| f7df54f2f7 | |||
| 212d4bace4 | |||
| f4b3267c89 | |||
| 9eeeb11871 | |||
| b8db3f689d | |||
| 3b21ce2aa5 | |||
| 9bf4fcd943 | |||
| c1f5cfbbda | |||
| 46517a4e15 | |||
| efbe76e1fc | |||
| 245c567d30 | |||
| cbb3d2c34a | |||
| bddec85fa5 | |||
| 96acbc6bf0 | |||
| 0735a31190 | |||
| 986c64ff13 | |||
| 831426d418 | |||
| b99e3fc030 | |||
| 012734f70a | |||
| f591a9635e | |||
| 7c099bf589 | |||
| 32d3cee907 | |||
| 86539c4bb8 | |||
| 14549afd52 | |||
| 667c843fc0 | |||
| 680a52982c | |||
| 52efb1a775 | |||
| c88931d318 | |||
| 2183ed62d1 | |||
| cc8bd040b9 | |||
| a2a464151f | |||
| c9a3f247e7 | |||
| d167502b7b | |||
| 0d9927bb99 | |||
| c9858ce615 | |||
| cccaa1dbe7 | |||
| acd951e981 | |||
| 10d80d58fd | |||
| f196c375d6 | |||
| cc62c89b05 | |||
| 3266cdeb08 | |||
| 6605c62015 | |||
| 704fdbd145 | |||
| 93e76a65a1 | |||
| b3ca7ebddb | |||
| 091fc0b7b7 | |||
| 874f5ba08e | |||
| 5fdfe94b88 | |||
| c02b168749 | |||
| 6ababd919d | |||
| 86b2b2d772 | |||
| 2aa2c3ccee | |||
| 70645a8431 | |||
| ca4b2f2637 | |||
| 7fce8f9b23 | |||
| e5b3b332f6 | |||
| 3e59762443 | |||
| 2ea8a48f28 | |||
| 3c07471620 | |||
| 23e2c1144f | |||
| 313f5e2dda | |||
| 26c35e55d8 | |||
| 878adc0eb7 | |||
| d353767b2c | |||
| 33baeaa62d | |||
| 591b7a5bf1 | |||
| 0bc993532b | |||
| 09379e7231 | |||
| 1a45ce9dc1 | |||
| 95df054dfb | |||
| 5b49553c6d | |||
| 6508940d11 | |||
| 71d89eaaba | |||
| 9619b7908f | |||
| 304129d793 | |||
| 5df435c21a | |||
| 2719c7320a | |||
| a84bae189c | |||
| d82c7c2535 | |||
| 2bc832ed95 | |||
| b5a0f0635b | |||
| 7426aa4bcb | |||
| ba9649382e | |||
| 9c64e97d8b | |||
| 4b1cd3cf44 | |||
| 4a0f002503 | |||
| c4f8c6e102 | |||
| 421308423f | |||
| 0550de2093 | |||
| dddf72e1da | |||
| e23820adf2 | |||
| fea4411aa6 | |||
| b814a38c59 | |||
| 1a3476e4fb | |||
| ecd4d6587c | |||
| 0938119e99 | |||
| 9f15f01871 | |||
| f09cbd2b32 | |||
| 77c1a06277 | |||
| 600f5d1484 | |||
| 7f71317acd | |||
| 865ef5827b | |||
| e5d5bf6c53 | |||
| 7b08d1ef96 | |||
| 9d363b38c7 | |||
| 2f3586cbbf | |||
| 843abe0621 | |||
| 474c5bc76f | |||
| b49a27f886 | |||
| 6f77b3f46e | |||
| a835012673 | |||
| 3f1e8003f8 | |||
| 8475707e75 | |||
| 8a240b1c3f | |||
| 59a3e3012b | |||
| c13142f971 | |||
| a468ee1154 | |||
| 1b504e211a | |||
| 29536f6291 | |||
| 4ef483126d | |||
| 8d2961f3ee | |||
| f1146bb2b9 | |||
| 2daa014c99 | |||
| ebe642f44a | |||
| 25ad254e84 | |||
| 947a7871c2 | |||
| 6421a677eb | |||
| 950893f4a2 | |||
| a10948614d | |||
| 39fc863e22 | |||
| df8b326d89 | |||
| 591f204b67 | |||
| 316ebd6d25 | |||
| 4e707ae08e | |||
| 1ef554c759 | |||
| 367e7d90fd | |||
| 6e7a89763c | |||
| 9dd3836802 | |||
| f822546971 | |||
| 4bf338f91a | |||
| 16577ddc5e | |||
| 384ae73c80 | |||
| d4c932b8ac | |||
| 743e42d4f8 | |||
| 6be2651106 | |||
| 2a2d20a25c | |||
| 882942385b | |||
| 0aa908c8d3 | |||
| 4c179c9269 | |||
| a4fe91ffda | |||
| dc500207ef | |||
| c1e3c3699b | |||
| 52e9f5fc70 | |||
| c85cddb5b4 | |||
| 477b53124d | |||
| 650dbd92e0 | |||
| 88288a98b6 | |||
| 377ab91af7 | |||
| acfc7685f4 | |||
| 5636010e1e |
@@ -14,21 +14,6 @@ review_attempts=$(echo "$state" | jq -r '.review_attempts // 0')
|
|||||||
max_review_attempts=$(echo "$state" | jq -r '.max_review_attempts // 1')
|
max_review_attempts=$(echo "$state" | jq -r '.max_review_attempts // 1')
|
||||||
review_notes=$(echo "$state" | jq -r '.review_notes // ""')
|
review_notes=$(echo "$state" | jq -r '.review_notes // ""')
|
||||||
|
|
||||||
if [[ "$review_clean" != "true" && "$review_clean" != "false" ]]; then
|
|
||||||
echo "ERROR: review_clean must be boolean ('true'/'false'); got: $review_clean" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! [[ "$review_attempts" =~ ^[0-9]+$ ]]; then
|
|
||||||
echo "ERROR: review_attempts must be a non-negative integer; got: $review_attempts" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! [[ "$max_review_attempts" =~ ^[0-9]+$ ]]; then
|
|
||||||
echo "ERROR: max_review_attempts must be a non-negative integer; got: $max_review_attempts" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$review_clean" == "true" ]]; then
|
if [[ "$review_clean" == "true" ]]; then
|
||||||
jq -nc '{"_next": "end_success"}'
|
jq -nc '{"_next": "end_success"}'
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
name: explore
|
name: explore
|
||||||
description: Fast codebase exploration agent - finds patterns, structures, and relevant files. Designed to be fanned out 2-5 in parallel by orchestrators.
|
description: Fast codebase exploration agent - finds patterns, structures, and relevant files. Designed to be fanned out 2-5 in parallel by orchestrators.
|
||||||
version: 3.0.0
|
version: 2.0.0
|
||||||
|
|
||||||
skills_enabled: true
|
skills_enabled: true
|
||||||
enabled_skills:
|
enabled_skills: []
|
||||||
- ai-slop-remover
|
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
- name: project_dir
|
- name: project_dir
|
||||||
@@ -23,85 +22,64 @@ global_tools:
|
|||||||
instructions: |
|
instructions: |
|
||||||
You are a codebase explorer. Your job: Search, find, report. Nothing else.
|
You are a codebase explorer. Your job: Search, find, report. Nothing else.
|
||||||
|
|
||||||
## Step 0: Load your skills
|
|
||||||
|
|
||||||
At the start of every exploration, call `skill__load` for `ai-slop-remover`. Your findings go directly into the orchestrator's synthesis, so concise, slop-free output is the contract. Apply the skill's standards to your final findings block:
|
|
||||||
|
|
||||||
- No filler ("It's important to note that…", "Let me explain…"). Just the finding.
|
|
||||||
- No flattery, no padding, no status updates about your process.
|
|
||||||
- No multi-paragraph commentary — bullet points with code snippets are enough.
|
|
||||||
|
|
||||||
## You may be one of many parallel explorers
|
## You may be one of many parallel explorers
|
||||||
|
|
||||||
Orchestrators (like Sisyphus) often fan out 2-5 explore agents at once, each covering a different angle of the same question. Assume you are ONE narrow slice of a larger investigation. Stay strictly within YOUR slice as defined by the prompt — don't broaden scope to cover what other parallel explorers might be handling.
|
Orchestrators (like Sisyphus) often fan out 2-5 explore agents at once, each covering a different angle of the same question. Assume you are ONE narrow slice of a larger investigation. Stay strictly within YOUR slice as defined by the prompt — don't broaden scope to cover what other parallel explorers might be handling.
|
||||||
|
|
||||||
If the prompt says "find auth middleware", you find auth middleware. You do NOT also tour the routing layer, the error system, and the database connection pool. Narrow scope is the contract.
|
If the prompt says "find auth middleware", you find auth middleware. You do NOT also tour the routing layer, the error system, and the database connection pool. Narrow scope is the contract.
|
||||||
|
|
||||||
## Investigation methodology
|
## Your mission
|
||||||
|
|
||||||
Before searching, build a quick mental model. Then narrow in. Then read.
|
1. Search for relevant files and patterns within YOUR slice.
|
||||||
|
2. Read key files to understand structure.
|
||||||
|
3. Report findings concisely.
|
||||||
|
4. Signal completion with `EXPLORE_COMPLETE`.
|
||||||
|
|
||||||
1. **Frame the question.** What kind of artifact am I looking for? Symbols (struct/class/function)? File patterns? Configuration? Implementation details? Tests? Different artifact kinds use different tools.
|
## File reading strategy (minimize token usage)
|
||||||
|
|
||||||
2. **Find first, read second.** Never `fs_read` a file without knowing why you're reading it.
|
1. **Find first, read second** — never read a file without knowing why.
|
||||||
|
2. **Use grep to locate** — `fs_grep --pattern "struct User" --include "*.rs"` finds where things are.
|
||||||
3. **Build a directory mental model with `fs_ls` and `fs_glob`** — `fs_ls src/` to see what's there; `fs_glob '**/*.rs' src/` to see which files exist by name.
|
3. **Use glob to discover** — `fs_glob --pattern "*.rs" --path src/` finds files by name.
|
||||||
|
4. **Prefer `fs_read` with offset/limit** — `fs_read --path "src/main.rs" --offset 50 --limit 30` reads lines 50-79 only. `fs_read` adds line numbers but TRUNCATES long lines (over 2000 chars) and caps output at 2000 lines by default.
|
||||||
4. **Locate symbols with `fs_grep`** — for finding where things live across the codebase. `fs_grep --pattern "fn handle_request" --include "*.rs"` is faster than reading files.
|
5. **Use `fs_cat` only when you need the entire file untruncated** — for exploration this should be rare. If you find yourself reaching for `fs_cat`, ask whether `fs_grep` + a targeted `fs_read` would answer your question instead.
|
||||||
|
6. **Never read entire large files** — if a file is 500+ lines, read the relevant section only.
|
||||||
5. **Read targeted sections with `fs_read --offset/--limit`** — `fs_read --path "src/main.rs" --offset 50 --limit 30` reads lines 50-79 only. `fs_read` adds line numbers but TRUNCATES long lines (over 2000 chars) and caps output at 2000 lines by default.
|
|
||||||
|
|
||||||
6. **Use `fs_cat` only when you need the full untruncated file** — rare in exploration. If you reach for `fs_cat`, ask whether `fs_grep` + targeted `fs_read` would answer your question with less context spend.
|
|
||||||
|
|
||||||
7. **Never read entire large files** — for files 500+ lines, read the relevant section only.
|
|
||||||
|
|
||||||
## Available actions
|
## Available actions
|
||||||
|
|
||||||
- `fs_grep --pattern "struct User" --include "*.rs"` — find content across files in a directory tree
|
- `fs_grep --pattern "struct User" --include "*.rs"` — find content across files
|
||||||
- `fs_grep --pattern "TODO" --path "src/main.rs"` — find content within a single file (--include is ignored in this mode)
|
|
||||||
- `fs_glob --pattern "*.rs" --path src/` — find files by name pattern
|
- `fs_glob --pattern "*.rs" --path src/` — find files by name pattern
|
||||||
- `fs_read --path "src/main.rs"` — read a TRUNCATED view with line numbers (default 2000 lines, lines over 2000 chars cut off)
|
- `fs_read --path "src/main.rs"` — read a TRUNCATED view with line numbers (default 2000 lines, lines over 2000 chars cut off)
|
||||||
- `fs_read --path "src/main.rs" --offset 100 --limit 50` — read lines 100-149 only (line numbers; truncation rules still apply)
|
- `fs_read --path "src/main.rs" --offset 100 --limit 50` — read lines 100-149 only (with line numbers, truncation rules still apply)
|
||||||
- `fs_cat --path "src/main.rs"` — read the FULL untruncated file (no line numbers); use only when you actually need every line
|
- `fs_cat --path "src/main.rs"` — read the FULL untruncated file (no line numbers); use only when you actually need every line
|
||||||
- `fs_ls --path "src/"` — list directory contents
|
- `fs_ls --path "src/"` — list directory contents
|
||||||
|
|
||||||
## When to use the web (ddg-search MCP)
|
|
||||||
|
|
||||||
Rarely. You are a CODEBASE explorer, not a web researcher. Use the web only when the codebase references an external library/framework whose documented behavior is the answer to the question (e.g., "how does Tokio's #[tokio::main] expand"), and the answer isn't in the local code. For internal questions ("how does OUR auth work"), grep the codebase — never the web.
|
|
||||||
|
|
||||||
## Output format
|
## Output format
|
||||||
|
|
||||||
Always end your response with a structured findings block. Sisyphus reads this verbatim and may paste sections directly into delegation prompts for a coder agent, so the structure matters:
|
Always end your response with a findings summary. Include actual code snippets when they show the pattern — file paths alone are not enough for the orchestrator to delegate downstream:
|
||||||
|
|
||||||
```
|
```
|
||||||
FINDINGS:
|
FINDINGS:
|
||||||
- [One-line concrete fact about what you found]
|
- [Key finding 1]
|
||||||
- [Another one-line fact]
|
- [Key finding 2]
|
||||||
- Relevant files: [list of paths, no commentary]
|
- Relevant files: [list]
|
||||||
|
|
||||||
Code patterns (paste actual lines):
|
Code patterns (paste actual lines):
|
||||||
- From `path/to/file.ext` lines N-M:
|
- From `path/to/file.ext` lines N-M:
|
||||||
<5-20 lines of actual code that show the pattern>
|
<snippet>
|
||||||
- From `path/to/other.ext` lines N-M:
|
|
||||||
<another snippet>
|
|
||||||
|
|
||||||
Open questions (only if any):
|
|
||||||
- [Anything you couldn't determine and the orchestrator should clarify or delegate elsewhere]
|
|
||||||
|
|
||||||
EXPLORE_COMPLETE
|
EXPLORE_COMPLETE
|
||||||
```
|
```
|
||||||
|
|
||||||
Pasting actual code lines (5-20 per pattern) lets the orchestrator hand snippets directly to a coder agent without re-exploration. That is the entire point of your existence in a parallel research phase. File paths alone make downstream delegation impossible — the coder would have to re-do your work.
|
Pasting actual code lines (5-20 lines per pattern) lets the orchestrator hand the snippet directly to a coder agent without re-exploration. That is the whole point of your existence in a fanned-out research phase.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
1. **Be fast.** Don't read every file, read representative ones.
|
1. **Be fast** — don't read every file, read representative ones.
|
||||||
2. **Stay in your slice.** Narrow scope is the contract.
|
2. **Stay in your slice** — narrow scope is the contract.
|
||||||
3. **Be concise.** Report findings, not your process. Apply the `ai-slop-remover` skill to your output.
|
3. **Be concise** — report findings, not your process.
|
||||||
4. **Never modify files.** You are read-only.
|
4. **Never modify files** — you are read-only.
|
||||||
5. **Limit reads.** Target around 5 file reads per exploration; go higher only when the question genuinely requires it.
|
5. **Limit reads** — max 5 file reads per exploration.
|
||||||
6. **Paste code snippets.** File paths alone make downstream delegation impossible.
|
6. **Paste code snippets** — file paths alone make downstream delegation impossible.
|
||||||
7. **Report what you didn't find.** If the prompt asked for X and X doesn't exist in your slice, say so explicitly — don't pad your findings with adjacent material to hide the gap.
|
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
- Project: {{project_dir}}
|
- Project: {{project_dir}}
|
||||||
|
|||||||
@@ -239,45 +239,6 @@ instructions: |
|
|||||||
|
|
||||||
**No evidence = not complete.** Mark a todo `completed` only after evidence is collected.
|
**No evidence = not complete.** Mark a todo `completed` only after evidence is collected.
|
||||||
|
|
||||||
### Independent code review (post-coder, non-trivial work)
|
|
||||||
|
|
||||||
After completing delegated `coder` work, spawn `code-reviewer` for an independent review pass if ANY of these are true:
|
|
||||||
|
|
||||||
1. **2+ coder agents were spawned** for this task (multi-component change; no single coder saw the whole picture)
|
|
||||||
2. **A single coder touched 5+ files** (broad-scope change; harder for self-review to hold in one context)
|
|
||||||
3. **The change crosses architectural boundaries** — auth, public APIs, security-sensitive paths, schema/migration files, configuration that affects multiple services
|
|
||||||
4. **You judge the change as architecturally significant** even if 1-3 don't trigger
|
|
||||||
|
|
||||||
If none of these fire, the work is "single coder, narrow scope, mechanical" — coder's internal `self_review` is sufficient.
|
|
||||||
|
|
||||||
**Why this matters.** Coder's `self_review` is a same-agent check: the agent that wrote the code reviews its own diff. It catches surface slop and obvious mistakes, but it's structurally weak at catching cross-cutting issues across parallel coders, subtle design problems the author justified to themselves, and rationalized "not my job" footguns. `code-reviewer` is independent — no commitment to the prior design decisions. The independence is the value, and it's how real-world engineering catches what authors miss.
|
|
||||||
|
|
||||||
**Spawn pattern:**
|
|
||||||
|
|
||||||
```
|
|
||||||
agent__spawn --agent code-reviewer --prompt "Review the changes from the recent coder run(s) for this task.
|
|
||||||
|
|
||||||
Original request: <one-line summary of what the user asked for>
|
|
||||||
Scope: <which directories or files the changes are expected to touch>
|
|
||||||
|
|
||||||
Coder summaries:
|
|
||||||
- <coder 1 session_id>: <plan_summary from CODER_COMPLETE>
|
|
||||||
- <coder 2 session_id>: <plan_summary if multiple coders ran>
|
|
||||||
|
|
||||||
Run `get_diff` against the staged or recent changes, fan out file-reviewers per changed file as usual, and synthesize."
|
|
||||||
```
|
|
||||||
|
|
||||||
### Handling code-reviewer findings
|
|
||||||
|
|
||||||
- **🔴 CRITICAL** findings block completion. Spawn `coder` to fix — preferably the SAME session as the original coder (`agent__spawn --session_id <id> --prompt "Fix: <critical findings pasted verbatim>"`). Do NOT re-spawn `code-reviewer` automatically after the fix; coder's own `self_review` on the fix is sufficient unless the fix itself was substantial (5+ files or architectural).
|
|
||||||
- **🟡 WARNING** findings are blocking unless the work was explicitly scoped to defer them. If unsure, ASK the user via `user__ask` whether to fix or accept.
|
|
||||||
- **🟢 SUGGESTION / 💡 NITPICK** findings are informational. Surface them to the user with the final report. Do not block on them.
|
|
||||||
- **`Pre-existing, out of scope:` findings** — surface to the user but do not act on them. They predate this work and aren't the current task's responsibility.
|
|
||||||
|
|
||||||
### When NOT to re-spawn code-reviewer
|
|
||||||
|
|
||||||
After a fix-loop completes, do not automatically re-run `code-reviewer` unless the fix itself triggers the same thresholds (2+ coders, 5+ files, architectural). Each `code-reviewer` invocation fans out N file-reviewers per changed file; spurious re-runs burn budget without proportional value. Trust coder's `self_review` on bounded fixes.
|
|
||||||
|
|
||||||
## File Operations (Direct Edits)
|
## File Operations (Direct Edits)
|
||||||
|
|
||||||
When you write or modify files yourself (rather than delegating to coder):
|
When you write or modify files yourself (rather than delegating to coder):
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ main() {
|
|||||||
local grep_args=(-nH --color=never)
|
local grep_args=(-nH --color=never)
|
||||||
|
|
||||||
if [[ -d "$search_path" ]]; then
|
if [[ -d "$search_path" ]]; then
|
||||||
# Use -r (not -R) so symlinks to directories are NOT followed - this avoids
|
|
||||||
# infinite loops on pathological symlink cycles (e.g. `ln -s . loop`).
|
|
||||||
grep_args+=(-r)
|
grep_args+=(-r)
|
||||||
grep_args+=(
|
grep_args+=(
|
||||||
--exclude-dir='.git'
|
--exclude-dir='.git'
|
||||||
|
|||||||
-105
@@ -3,62 +3,6 @@
|
|||||||
# - https://platform.openai.com/docs/api-reference/chat
|
# - https://platform.openai.com/docs/api-reference/chat
|
||||||
- provider: openai
|
- provider: openai
|
||||||
models:
|
models:
|
||||||
- name: gpt-5.5
|
|
||||||
max_input_tokens: 1050000
|
|
||||||
max_output_tokens: 128000
|
|
||||||
input_price: 5
|
|
||||||
output_price: 30
|
|
||||||
supports_vision: true
|
|
||||||
supports_function_calling: true
|
|
||||||
- name: gpt-5.5-pro
|
|
||||||
max_input_tokens: 1050000
|
|
||||||
max_output_tokens: 128000
|
|
||||||
input_price: 30
|
|
||||||
output_price: 180
|
|
||||||
supports_vision: true
|
|
||||||
supports_function_calling: true
|
|
||||||
- name: gpt-5.4
|
|
||||||
max_input_tokens: 1050000
|
|
||||||
max_output_tokens: 128000
|
|
||||||
input_price: 2.5
|
|
||||||
output_price: 15
|
|
||||||
supports_vision: true
|
|
||||||
supports_function_calling: true
|
|
||||||
- name: gpt-5.4-pro
|
|
||||||
max_input_tokens: 1050000
|
|
||||||
max_output_tokens: 128000
|
|
||||||
input_price: 30
|
|
||||||
output_price: 180
|
|
||||||
supports_vision: true
|
|
||||||
supports_function_calling: true
|
|
||||||
- name: gpt-5.4-mini
|
|
||||||
max_input_tokens: 400000
|
|
||||||
max_output_tokens: 128000
|
|
||||||
input_price: 0.75
|
|
||||||
output_price: 4.5
|
|
||||||
supports_vision: true
|
|
||||||
supports_function_calling: true
|
|
||||||
- name: gpt-5.4-nano
|
|
||||||
max_input_tokens: 400000
|
|
||||||
max_output_tokens: 128000
|
|
||||||
input_price: 0.2
|
|
||||||
output_price: 1.25
|
|
||||||
supports_vision: true
|
|
||||||
supports_function_calling: true
|
|
||||||
- name: gpt-5.3-codex
|
|
||||||
max_input_tokens: 400000
|
|
||||||
max_output_tokens: 128000
|
|
||||||
input_price: 1.75
|
|
||||||
output_price: 14
|
|
||||||
supports_vision: true
|
|
||||||
supports_function_calling: true
|
|
||||||
- name: chat-latest
|
|
||||||
max_input_tokens: 400000
|
|
||||||
max_output_tokens: 128000
|
|
||||||
input_price: 5
|
|
||||||
output_price: 30
|
|
||||||
supports_vision: true
|
|
||||||
supports_function_calling: true
|
|
||||||
- name: gpt-5.2
|
- name: gpt-5.2
|
||||||
max_input_tokens: 400000
|
max_input_tokens: 400000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
@@ -1596,55 +1540,6 @@
|
|||||||
# - https://openrouter.ai/docs/api-reference/chat-completion
|
# - https://openrouter.ai/docs/api-reference/chat-completion
|
||||||
- provider: openrouter
|
- provider: openrouter
|
||||||
models:
|
models:
|
||||||
- name: openai/gpt-5.5
|
|
||||||
max_input_tokens: 1050000
|
|
||||||
max_output_tokens: 128000
|
|
||||||
input_price: 5
|
|
||||||
output_price: 30
|
|
||||||
supports_vision: true
|
|
||||||
supports_function_calling: true
|
|
||||||
- name: openai/gpt-5.5-pro
|
|
||||||
max_input_tokens: 1050000
|
|
||||||
max_output_tokens: 128000
|
|
||||||
input_price: 30
|
|
||||||
output_price: 180
|
|
||||||
supports_vision: true
|
|
||||||
supports_function_calling: true
|
|
||||||
- name: openai/gpt-5.4
|
|
||||||
max_input_tokens: 1050000
|
|
||||||
max_output_tokens: 128000
|
|
||||||
input_price: 2.5
|
|
||||||
output_price: 15
|
|
||||||
supports_vision: true
|
|
||||||
supports_function_calling: true
|
|
||||||
- name: openai/gpt-5.4-pro
|
|
||||||
max_input_tokens: 1050000
|
|
||||||
max_output_tokens: 128000
|
|
||||||
input_price: 30
|
|
||||||
output_price: 180
|
|
||||||
supports_vision: true
|
|
||||||
supports_function_calling: true
|
|
||||||
- name: openai/gpt-5.4-mini
|
|
||||||
max_input_tokens: 400000
|
|
||||||
max_output_tokens: 128000
|
|
||||||
input_price: 0.75
|
|
||||||
output_price: 4.5
|
|
||||||
supports_vision: true
|
|
||||||
supports_function_calling: true
|
|
||||||
- name: openai/gpt-5.4-nano
|
|
||||||
max_input_tokens: 400000
|
|
||||||
max_output_tokens: 128000
|
|
||||||
input_price: 0.2
|
|
||||||
output_price: 1.25
|
|
||||||
supports_vision: true
|
|
||||||
supports_function_calling: true
|
|
||||||
- name: openai/gpt-5.3-codex
|
|
||||||
max_input_tokens: 400000
|
|
||||||
max_output_tokens: 128000
|
|
||||||
input_price: 1.75
|
|
||||||
output_price: 14
|
|
||||||
supports_vision: true
|
|
||||||
supports_function_calling: true
|
|
||||||
- name: openai/gpt-5.2
|
- name: openai/gpt-5.2
|
||||||
max_input_tokens: 400000
|
max_input_tokens: 400000
|
||||||
max_output_tokens: 128000
|
max_output_tokens: 128000
|
||||||
|
|||||||
+7
-10
@@ -137,16 +137,13 @@ pub(super) fn session_completer(current: &OsStr) -> Vec<CompletionCandidate> {
|
|||||||
pub(super) fn secrets_completer(current: &OsStr) -> Vec<CompletionCandidate> {
|
pub(super) fn secrets_completer(current: &OsStr) -> Vec<CompletionCandidate> {
|
||||||
let cur = current.to_string_lossy();
|
let cur = current.to_string_lossy();
|
||||||
match load_app_config_for_completion() {
|
match load_app_config_for_completion() {
|
||||||
Ok(app_config) => match Vault::init(&app_config) {
|
Ok(app_config) => Vault::init(&app_config)
|
||||||
Ok(vault) => vault
|
.list_secrets(false)
|
||||||
.list_secrets(false)
|
.unwrap_or_default()
|
||||||
.unwrap_or_default()
|
.into_iter()
|
||||||
.into_iter()
|
.filter(|s| s.starts_with(&*cur))
|
||||||
.filter(|s| s.starts_with(&*cur))
|
.map(CompletionCandidate::new)
|
||||||
.map(CompletionCandidate::new)
|
.collect(),
|
||||||
.collect(),
|
|
||||||
Err(_) => vec![],
|
|
||||||
},
|
|
||||||
Err(_) => vec![],
|
Err(_) => vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ use clap::ValueHint;
|
|||||||
use clap::{Parser, crate_authors, crate_description, crate_version};
|
use clap::{Parser, crate_authors, crate_description, crate_version};
|
||||||
use clap_complete::ArgValueCompleter;
|
use clap_complete::ArgValueCompleter;
|
||||||
use is_terminal::IsTerminal;
|
use is_terminal::IsTerminal;
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::io::{Read, stdin};
|
use std::io::{Read, stdin};
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
@@ -164,18 +163,6 @@ pub struct Cli {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Cli {
|
impl Cli {
|
||||||
pub fn skills(&self) -> Vec<String> {
|
|
||||||
let mut seen = HashSet::new();
|
|
||||||
let mut out = Vec::with_capacity(self.skill.len());
|
|
||||||
for name in &self.skill {
|
|
||||||
if seen.insert(name.clone()) {
|
|
||||||
out.push(name.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn text(&self) -> Result<Option<String>> {
|
pub fn text(&self) -> Result<Option<String>> {
|
||||||
let mut stdin_text = String::new();
|
let mut stdin_text = String::new();
|
||||||
if !stdin().is_terminal() {
|
if !stdin().is_terminal() {
|
||||||
@@ -336,21 +323,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn skills_method_dedupes_preserving_first_occurrence() {
|
|
||||||
let cli = parse(&[
|
|
||||||
"--skill", "alpha", "--skill", "beta", "--skill", "alpha", "--skill", "gamma",
|
|
||||||
"--skill", "beta",
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert_eq!(cli.skills(), vec!["alpha", "beta", "gamma"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn skills_method_returns_empty_when_no_flags() {
|
|
||||||
assert!(parse(&[]).skills().is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_file_flag_single() {
|
fn parse_file_flag_single() {
|
||||||
let cli = parse(&["-f", "file.txt", "question"]);
|
let cli = parse(&["-f", "file.txt", "question"]);
|
||||||
|
|||||||
@@ -354,9 +354,7 @@ pub async fn create_config(
|
|||||||
"type": client,
|
"type": client,
|
||||||
});
|
});
|
||||||
for (key, desc, help_message, is_secret) in prompts {
|
for (key, desc, help_message, is_secret) in prompts {
|
||||||
let env_name = format!("{client}-{key}")
|
let env_name = format!("{client}_{key}").to_ascii_uppercase();
|
||||||
.to_ascii_uppercase()
|
|
||||||
.replace("_", "-");
|
|
||||||
let required = std::env::var(&env_name).is_err();
|
let required = std::env::var(&env_name).is_err();
|
||||||
let value = if !is_secret {
|
let value = if !is_secret {
|
||||||
prompt_input_string(desc, required, *help_message)?
|
prompt_input_string(desc, required, *help_message)?
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use crate::render::{MarkdownRender, RenderOptions};
|
|||||||
use crate::utils::{IS_STDOUT_TERMINAL, NO_COLOR, decode_bin, get_env_name};
|
use crate::utils::{IS_STDOUT_TERMINAL, NO_COLOR, decode_bin, get_env_name};
|
||||||
|
|
||||||
use super::paths;
|
use super::paths;
|
||||||
use anyhow::{Context, Result, anyhow, bail};
|
use anyhow::{Context, Result, anyhow};
|
||||||
use gman::providers::SupportedProvider;
|
use gman::providers::SupportedProvider;
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -216,7 +216,6 @@ impl AppConfig {
|
|||||||
clients: config.clients,
|
clients: config.clients,
|
||||||
};
|
};
|
||||||
app_config.load_envs();
|
app_config.load_envs();
|
||||||
app_config.validate_visible_skills()?;
|
|
||||||
if let Some(wrap) = app_config.wrap.clone() {
|
if let Some(wrap) = app_config.wrap.clone() {
|
||||||
app_config.set_wrap(&wrap)?;
|
app_config.set_wrap(&wrap)?;
|
||||||
}
|
}
|
||||||
@@ -226,28 +225,11 @@ impl AppConfig {
|
|||||||
Ok(app_config)
|
Ok(app_config)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_visible_skills(&self) -> Result<()> {
|
|
||||||
let Some(skills) = self.visible_skills.as_ref() else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
|
|
||||||
for name in skills {
|
|
||||||
paths::validate_skill_name(name)
|
|
||||||
.map_err(|e| anyhow!("invalid entry in visible_skills: {e}"))?;
|
|
||||||
|
|
||||||
if !paths::has_skill(name) {
|
|
||||||
bail!("visible_skills references skill '{name}' which is not installed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn resolve_model(&mut self) -> Result<()> {
|
pub fn resolve_model(&mut self) -> Result<()> {
|
||||||
if self.model_id.is_empty() {
|
if self.model_id.is_empty() {
|
||||||
let models = list_models(self, crate::client::ModelType::Chat);
|
let models = list_models(self, crate::client::ModelType::Chat);
|
||||||
if models.is_empty() {
|
if models.is_empty() {
|
||||||
bail!("No available model");
|
anyhow::bail!("No available model");
|
||||||
}
|
}
|
||||||
self.model_id = models[0].id();
|
self.model_id = models[0].id();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ impl AppState {
|
|||||||
start_mcp_servers: bool,
|
start_mcp_servers: bool,
|
||||||
abort_signal: AbortSignal,
|
abort_signal: AbortSignal,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let vault = Arc::new(Vault::init(&config)?);
|
let vault = Arc::new(Vault::init(&config));
|
||||||
|
|
||||||
let mcp_registry = McpRegistry::init(
|
let mcp_registry = McpRegistry::init(
|
||||||
log_path,
|
log_path,
|
||||||
|
|||||||
+10
-107
@@ -1,3 +1,10 @@
|
|||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
use inquire::{Confirm, Select};
|
||||||
|
use std::ffi::{OsStr, OsString};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::config::{InstallFilter, paths};
|
use crate::config::{InstallFilter, paths};
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
use crate::function::Language;
|
use crate::function::Language;
|
||||||
@@ -5,13 +12,6 @@ use crate::mcp::{McpServer, McpServersConfig};
|
|||||||
use crate::utils;
|
use crate::utils;
|
||||||
use crate::utils::IS_STDOUT_TERMINAL;
|
use crate::utils::IS_STDOUT_TERMINAL;
|
||||||
use crate::vault::{Vault, create_vault_password_file, interpolate_secrets};
|
use crate::vault::{Vault, create_vault_password_file, interpolate_secrets};
|
||||||
use anyhow::{Context, Result, anyhow, bail};
|
|
||||||
use indexmap::IndexMap;
|
|
||||||
use indoc::formatdoc;
|
|
||||||
use inquire::{Confirm, Select};
|
|
||||||
use std::ffi::{OsStr, OsString};
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
pub fn install_remote(git_url: &str, filter: Option<InstallFilter>, force: bool) -> Result<()> {
|
pub fn install_remote(git_url: &str, filter: Option<InstallFilter>, force: bool) -> Result<()> {
|
||||||
let (url, reference) = parse_url_with_ref(git_url)?;
|
let (url, reference) = parse_url_with_ref(git_url)?;
|
||||||
@@ -418,26 +418,6 @@ fn plan_dir_into(
|
|||||||
let rel = src
|
let rel = src
|
||||||
.strip_prefix(src_dir)
|
.strip_prefix(src_dir)
|
||||||
.expect("walk_files only returns paths under src_dir");
|
.expect("walk_files only returns paths under src_dir");
|
||||||
|
|
||||||
if category == TopCategory::Skills {
|
|
||||||
let skill_name = rel
|
|
||||||
.components()
|
|
||||||
.next()
|
|
||||||
.and_then(|c| c.as_os_str().to_str())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
anyhow!(
|
|
||||||
"remote skill bundle has unparseable path component: {}",
|
|
||||||
rel.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
paths::validate_skill_name(skill_name).with_context(|| {
|
|
||||||
format!(
|
|
||||||
"remote skill '{skill_name}' has an invalid name \
|
|
||||||
(skill names must contain only ASCII alphanumerics, '-', or '_')"
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let dst = dst_dir.join(rel);
|
let dst = dst_dir.join(rel);
|
||||||
let kind = classify_file(&src, &dst)?;
|
let kind = classify_file(&src, &dst)?;
|
||||||
out.push(PlannedFile {
|
out.push(PlannedFile {
|
||||||
@@ -751,21 +731,8 @@ fn merge_mcp_json(
|
|||||||
serde_json::to_string_pretty(&merged).context("failed to serialize merged mcp.json")?;
|
serde_json::to_string_pretty(&merged).context("failed to serialize merged mcp.json")?;
|
||||||
write_atomically(&final_path, &serialized)?;
|
write_atomically(&final_path, &serialized)?;
|
||||||
|
|
||||||
let vault = Vault::init_bare()?;
|
let vault = Vault::init_bare();
|
||||||
let missing = match interpolate_secrets(&serialized, &vault) {
|
let (_parsed, missing) = interpolate_secrets(&serialized, &vault)?;
|
||||||
Ok((_, missing)) => missing,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!(
|
|
||||||
"{}",
|
|
||||||
formatdoc! {"
|
|
||||||
Skipping secret resolution for merged mcp.json: {e:#}
|
|
||||||
Continuing without resolving missing secrets
|
|
||||||
You may need to add any additional missing secrets to the vault manually.
|
|
||||||
"}
|
|
||||||
);
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut deduped: Vec<String> = Vec::new();
|
let mut deduped: Vec<String> = Vec::new();
|
||||||
for s in missing {
|
for s in missing {
|
||||||
if !deduped.contains(&s) {
|
if !deduped.contains(&s) {
|
||||||
@@ -893,7 +860,7 @@ fn handle_missing_secrets(missing: &[String]) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn prompt_for_each_secret(missing: &[String]) -> Result<(Vec<String>, Vec<String>)> {
|
fn prompt_for_each_secret(missing: &[String]) -> Result<(Vec<String>, Vec<String>)> {
|
||||||
let mut vault = Vault::init_bare()?;
|
let mut vault = Vault::init_bare();
|
||||||
let mut password_file_ensured = false;
|
let mut password_file_ensured = false;
|
||||||
let mut added = Vec::new();
|
let mut added = Vec::new();
|
||||||
let mut deferred = Vec::new();
|
let mut deferred = Vec::new();
|
||||||
@@ -947,62 +914,6 @@ fn print_secret_summary(added: &[String], deferred: &[String]) {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::utils::get_env_name;
|
|
||||||
use serial_test::serial;
|
|
||||||
use std::env;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
struct TestVaultConfigGuard {
|
|
||||||
dir_key: String,
|
|
||||||
file_key: String,
|
|
||||||
previous_dir: Option<OsString>,
|
|
||||||
previous_file: Option<OsString>,
|
|
||||||
path: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TestVaultConfigGuard {
|
|
||||||
fn new(label: &str) -> Self {
|
|
||||||
let dir_key = get_env_name("config_dir");
|
|
||||||
let file_key = get_env_name("config_file");
|
|
||||||
let previous_dir = env::var_os(&dir_key);
|
|
||||||
let previous_file = env::var_os(&file_key);
|
|
||||||
let unique = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos();
|
|
||||||
let path = env::temp_dir().join(format!("coyote-vault-test-{label}-{unique}"));
|
|
||||||
fs::create_dir_all(&path).unwrap();
|
|
||||||
let config_path = path.join("config.yaml");
|
|
||||||
fs::write(&config_path, "{}").unwrap();
|
|
||||||
unsafe {
|
|
||||||
env::set_var(&dir_key, &path);
|
|
||||||
env::set_var(&file_key, &config_path);
|
|
||||||
}
|
|
||||||
Self {
|
|
||||||
dir_key,
|
|
||||||
file_key,
|
|
||||||
previous_dir,
|
|
||||||
previous_file,
|
|
||||||
path,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for TestVaultConfigGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
unsafe {
|
|
||||||
match &self.previous_dir {
|
|
||||||
Some(p) => env::set_var(&self.dir_key, p),
|
|
||||||
None => env::remove_var(&self.dir_key),
|
|
||||||
}
|
|
||||||
match &self.previous_file {
|
|
||||||
Some(p) => env::set_var(&self.file_key, p),
|
|
||||||
None => env::remove_var(&self.file_key),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = fs::remove_dir_all(&self.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_url_no_ref() {
|
fn parse_url_no_ref() {
|
||||||
@@ -1342,9 +1253,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
|
||||||
fn merge_into_empty_local_adds_all_remote_servers() {
|
fn merge_into_empty_local_adds_all_remote_servers() {
|
||||||
let _guard = TestVaultConfigGuard::new("merge-empty");
|
|
||||||
let dir = fresh_temp_dir("merge-empty-");
|
let dir = fresh_temp_dir("merge-empty-");
|
||||||
let remote = dir.join("remote.json");
|
let remote = dir.join("remote.json");
|
||||||
let target = dir.join("target.json");
|
let target = dir.join("target.json");
|
||||||
@@ -1361,9 +1270,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
|
||||||
fn merge_force_replaces_local_on_conflict() {
|
fn merge_force_replaces_local_on_conflict() {
|
||||||
let _guard = TestVaultConfigGuard::new("merge-force");
|
|
||||||
let dir = fresh_temp_dir("merge-force-");
|
let dir = fresh_temp_dir("merge-force-");
|
||||||
let remote = dir.join("remote.json");
|
let remote = dir.join("remote.json");
|
||||||
let target = dir.join("target.json");
|
let target = dir.join("target.json");
|
||||||
@@ -1429,9 +1336,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||||
#[serial]
|
|
||||||
async fn merge_detects_missing_secrets_in_output() {
|
async fn merge_detects_missing_secrets_in_output() {
|
||||||
let _guard = TestVaultConfigGuard::new("merge-secret");
|
|
||||||
let dir = fresh_temp_dir("merge-secret-");
|
let dir = fresh_temp_dir("merge-secret-");
|
||||||
let remote = dir.join("remote.json");
|
let remote = dir.join("remote.json");
|
||||||
let target = dir.join("target.json");
|
let target = dir.join("target.json");
|
||||||
@@ -1447,9 +1352,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
|
||||||
fn merge_is_idempotent_on_re_run() {
|
fn merge_is_idempotent_on_re_run() {
|
||||||
let _guard = TestVaultConfigGuard::new("merge-idempotent");
|
|
||||||
let dir = fresh_temp_dir("merge-idempotent-");
|
let dir = fresh_temp_dir("merge-idempotent-");
|
||||||
let remote = dir.join("remote.json");
|
let remote = dir.join("remote.json");
|
||||||
let target = dir.join("target.json");
|
let target = dir.join("target.json");
|
||||||
|
|||||||
+2
-2
@@ -490,7 +490,7 @@ impl Config {
|
|||||||
secrets_provider: config.secrets_provider.clone(),
|
secrets_provider: config.secrets_provider.clone(),
|
||||||
..AppConfig::default()
|
..AppConfig::default()
|
||||||
};
|
};
|
||||||
let vault = Vault::init(&bootstrap_app)?;
|
let vault = Vault::init(&bootstrap_app);
|
||||||
let (parsed_config, missing_secrets) = interpolate_secrets(&content, &vault)?;
|
let (parsed_config, missing_secrets) = interpolate_secrets(&content, &vault)?;
|
||||||
if !missing_secrets.is_empty() && !info_flag {
|
if !missing_secrets.is_empty() && !info_flag {
|
||||||
debug!(
|
debug!(
|
||||||
@@ -685,7 +685,7 @@ pub async fn create_config_file(config_path: &Path) -> Result<()> {
|
|||||||
|
|
||||||
let provider_choice = prompt_provider_choice()?;
|
let provider_choice = prompt_provider_choice()?;
|
||||||
let mut vault = match &provider_choice {
|
let mut vault = match &provider_choice {
|
||||||
None => Vault::default_local(),
|
None => Vault::init_bare(),
|
||||||
Some(provider) => Vault {
|
Some(provider) => Vault {
|
||||||
provider: provider.clone(),
|
provider: provider.clone(),
|
||||||
},
|
},
|
||||||
|
|||||||
+8
-37
@@ -270,7 +270,6 @@ pub fn list_skills() -> Vec<String> {
|
|||||||
&& file_type.is_dir()
|
&& file_type.is_dir()
|
||||||
&& let Some(name) = entry.file_name().to_str()
|
&& let Some(name) = entry.file_name().to_str()
|
||||||
&& entry.path().join("SKILL.md").is_file()
|
&& entry.path().join("SKILL.md").is_file()
|
||||||
&& validate_skill_name(name).is_ok()
|
|
||||||
{
|
{
|
||||||
names.push(name.to_string());
|
names.push(name.to_string());
|
||||||
}
|
}
|
||||||
@@ -282,6 +281,10 @@ pub fn list_skills() -> Vec<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn has_skill(name: &str) -> bool {
|
pub fn has_skill(name: &str) -> bool {
|
||||||
|
if validate_skill_name(name).is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
skill_file(name).is_file()
|
skill_file(name).is_file()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +307,6 @@ pub fn local_models_override() -> Result<Vec<ProviderModels>> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::{fs, time};
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validate_skill_name_accepts_alphanumerics_and_dashes() {
|
fn validate_skill_name_accepts_alphanumerics_and_dashes() {
|
||||||
@@ -341,43 +343,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn has_skill_returns_false_for_missing_paths() {
|
fn has_skill_returns_false_for_invalid_names() {
|
||||||
for absent in ["definitely-not-installed-skill-xyz", "another-missing"] {
|
for bad in ["", "../escape", "foo/bar", ".hidden", "with space"] {
|
||||||
assert!(
|
assert!(
|
||||||
!has_skill(absent),
|
!has_skill(bad),
|
||||||
"has_skill({absent:?}) should be false for a missing skill"
|
"has_skill({bad:?}) should be false for an invalid name"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn list_skills_skips_invalid_directory_names() {
|
|
||||||
let unique = time::SystemTime::now()
|
|
||||||
.duration_since(time::UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos();
|
|
||||||
let root = env::temp_dir().join(format!("coyote-list-skills-test-{unique}"));
|
|
||||||
fs::create_dir_all(&root).unwrap();
|
|
||||||
let prev = env::var_os(get_env_name("skills_dir"));
|
|
||||||
unsafe {
|
|
||||||
env::set_var(get_env_name("skills_dir"), &root);
|
|
||||||
}
|
|
||||||
|
|
||||||
for name in ["valid-skill", "with space", ".hidden", "dot.name"] {
|
|
||||||
let dir = root.join(name);
|
|
||||||
fs::create_dir_all(&dir).unwrap();
|
|
||||||
fs::write(dir.join("SKILL.md"), "body").unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let listed = list_skills();
|
|
||||||
assert_eq!(listed, vec!["valid-skill".to_string()]);
|
|
||||||
|
|
||||||
unsafe {
|
|
||||||
match prev {
|
|
||||||
Some(v) => env::set_var(get_env_name("skills_dir"), v),
|
|
||||||
None => env::remove_var(get_env_name("skills_dir")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = fs::remove_dir_all(&root);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1229,12 +1229,7 @@ impl RequestContext {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if let Some(ref tool_names) = role_filter {
|
if let Some(ref tool_names) = role_filter {
|
||||||
agent_functions.retain(|v| {
|
agent_functions.retain(|v| tool_names.contains(&v.name));
|
||||||
tool_names.contains(&v.name)
|
|
||||||
|| (!matches!(agent.skills_enabled(), Some(false))
|
|
||||||
&& v.name.starts_with(SKILL_FUNCTION_PREFIX))
|
|
||||||
|| v.name.starts_with(USER_FUNCTION_PREFIX)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let tool_names: HashSet<String> = agent_functions
|
let tool_names: HashSet<String> = agent_functions
|
||||||
@@ -1738,26 +1733,6 @@ impl RequestContext {
|
|||||||
"enabled_skills" => {
|
"enabled_skills" => {
|
||||||
let raw: Option<String> = super::parse_value(value)?;
|
let raw: Option<String> = super::parse_value(value)?;
|
||||||
let parsed: Option<Vec<String>> = raw.map(|s| super::csv_to_vec(&s));
|
let parsed: Option<Vec<String>> = raw.map(|s| super::csv_to_vec(&s));
|
||||||
if let Some(names) = parsed.as_ref() {
|
|
||||||
let visible = self.app.config.visible_skills.as_deref();
|
|
||||||
for name in names {
|
|
||||||
paths::validate_skill_name(name)?;
|
|
||||||
match visible {
|
|
||||||
Some(vs) => {
|
|
||||||
if !vs.iter().any(|s| s == name) {
|
|
||||||
bail!(
|
|
||||||
"skill '{name}' is not in the global 'visible_skills' allow-list"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
if !paths::has_skill(name) {
|
|
||||||
bail!("skill '{name}' is not installed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.update_app_config(|app| app.enabled_skills = parsed.clone());
|
self.update_app_config(|app| app.enabled_skills = parsed.clone());
|
||||||
}
|
}
|
||||||
"skills_enabled" => {
|
"skills_enabled" => {
|
||||||
@@ -2671,9 +2646,7 @@ impl RequestContext {
|
|||||||
|
|
||||||
self.skill_registry.insert(skill)?;
|
self.skill_registry.insert(skill)?;
|
||||||
if let Err(e) = self.refresh_tool_scope(abort_signal).await {
|
if let Err(e) = self.refresh_tool_scope(abort_signal).await {
|
||||||
if let Err(unload_err) = self.skill_registry.unload(name) {
|
let _ = self.skill_registry.unload(name);
|
||||||
warn!("Failed to unload skill '{name}' during error recovery: {unload_err}");
|
|
||||||
}
|
|
||||||
bail!("Loaded skill '{name}' but failed to refresh tool scope: {e}");
|
bail!("Loaded skill '{name}' but failed to refresh tool scope: {e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2682,15 +2655,10 @@ impl RequestContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn unload_skill_repl(&mut self, name: &str, abort_signal: AbortSignal) -> Result<()> {
|
pub async fn unload_skill_repl(&mut self, name: &str, abort_signal: AbortSignal) -> Result<()> {
|
||||||
let skill = self.skill_registry.unload(name)?;
|
self.skill_registry.unload(name)?;
|
||||||
|
|
||||||
if let Err(e) = self.refresh_tool_scope(abort_signal).await {
|
if let Err(e) = self.refresh_tool_scope(abort_signal).await {
|
||||||
if let Err(restore_err) = self.skill_registry.insert(skill) {
|
eprintln!("Warning: unloaded skill '{name}' but tool scope refresh failed: {e}");
|
||||||
warn!(
|
|
||||||
"Failed to restore skill '{name}' after tool-scope refresh failure: {restore_err}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
bail!("Unloaded skill '{name}' but failed to refresh tool scope; restored: {e}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("✓ Unloaded skill '{name}'.");
|
println!("✓ Unloaded skill '{name}'.");
|
||||||
|
|||||||
+12
-23
@@ -4,7 +4,7 @@ use super::paths;
|
|||||||
use super::role::Role;
|
use super::role::Role;
|
||||||
use super::session::Session;
|
use super::session::Session;
|
||||||
|
|
||||||
use anyhow::{Result, anyhow, bail};
|
use anyhow::{Result, bail};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -76,24 +76,16 @@ impl SkillPolicy {
|
|||||||
Some(explicit) => {
|
Some(explicit) => {
|
||||||
let set: HashSet<String> = explicit.into_iter().collect();
|
let set: HashSet<String> = explicit.into_iter().collect();
|
||||||
for name in &set {
|
for name in &set {
|
||||||
paths::validate_skill_name(name).map_err(|e| {
|
if !skill_exists(name) {
|
||||||
anyhow!("enabled_skills contains invalid name '{name}': {e}")
|
bail!("enabled_skills references skill '{name}' which is not installed");
|
||||||
})?;
|
}
|
||||||
match &visible {
|
|
||||||
Some(vs) => {
|
if let Some(vs) = &visible
|
||||||
if !vs.contains(name) {
|
&& !vs.contains(name)
|
||||||
bail!(
|
{
|
||||||
"enabled_skills references skill '{name}' which is not in the global 'visible_skills' allow-list"
|
bail!(
|
||||||
);
|
"enabled_skills references skill '{name}' which is not in visible_skills"
|
||||||
}
|
);
|
||||||
}
|
|
||||||
None => {
|
|
||||||
if !skill_exists(name) {
|
|
||||||
bail!(
|
|
||||||
"enabled_skills references skill '{name}' which is not installed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
set
|
set
|
||||||
@@ -277,10 +269,7 @@ mod tests {
|
|||||||
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
|
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
||||||
assert!(
|
assert!(err.to_string().contains("not in visible_skills"));
|
||||||
err.to_string()
|
|
||||||
.contains("not in the global 'visible_skills'")
|
|
||||||
);
|
|
||||||
assert!(err.to_string().contains("beta"));
|
assert!(err.to_string().contains("beta"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-18
@@ -104,13 +104,8 @@ pub async fn handle_skill_tool(
|
|||||||
fn handle_list(ctx: &RequestContext, policy: &SkillPolicy) -> Result<Value> {
|
fn handle_list(ctx: &RequestContext, policy: &SkillPolicy) -> Result<Value> {
|
||||||
let mcp_on = ctx.app.config.mcp_server_support;
|
let mcp_on = ctx.app.config.mcp_server_support;
|
||||||
|
|
||||||
let visible_names: Vec<String> = match ctx.app.config.visible_skills.as_deref() {
|
|
||||||
Some(list) => list.to_vec(),
|
|
||||||
None => paths::list_skills(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
for name in visible_names {
|
for name in paths::list_skills() {
|
||||||
if !policy.allows(&name) {
|
if !policy.allows(&name) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -198,10 +193,7 @@ async fn handle_load(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = ctx.refresh_tool_scope(create_abort_signal()).await {
|
if let Err(e) = ctx.refresh_tool_scope(create_abort_signal()).await {
|
||||||
if let Err(unload_err) = ctx.skill_registry.unload(name) {
|
let _ = ctx.skill_registry.unload(name);
|
||||||
warn!("Failed to unload skill '{name}' during error recovery: {unload_err}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(json!({
|
return Ok(json!({
|
||||||
"error": format!("Loaded skill '{name}' but failed to refresh tool scope: {e}")
|
"error": format!("Loaded skill '{name}' but failed to refresh tool scope: {e}")
|
||||||
}));
|
}));
|
||||||
@@ -220,20 +212,13 @@ async fn handle_unload(ctx: &mut RequestContext, args: &Value) -> Result<Value>
|
|||||||
_ => return Ok(json!({"error": "name is required"})),
|
_ => return Ok(json!({"error": "name is required"})),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = paths::validate_skill_name(name) {
|
|
||||||
return Ok(json!({"error": e.to_string()}));
|
|
||||||
}
|
|
||||||
|
|
||||||
let skill = match ctx.skill_registry.unload(name) {
|
let skill = match ctx.skill_registry.unload(name) {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => return Ok(json!({"error": e.to_string()})),
|
Err(e) => return Ok(json!({"error": e.to_string()})),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = ctx.refresh_tool_scope(create_abort_signal()).await {
|
if let Err(e) = ctx.refresh_tool_scope(create_abort_signal()).await {
|
||||||
if let Err(insert_err) = ctx.skill_registry.insert(skill) {
|
let _ = ctx.skill_registry.insert(skill);
|
||||||
warn!("Failed to restore skill '{name}' after unload recovery: {insert_err}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(json!({
|
return Ok(json!({
|
||||||
"error": format!(
|
"error": format!(
|
||||||
"Unloaded skill '{name}' but failed to refresh tool scope; restored: {e}"
|
"Unloaded skill '{name}' but failed to refresh tool scope; restored: {e}"
|
||||||
|
|||||||
+3
-21
@@ -3,7 +3,6 @@ use super::structured;
|
|||||||
use super::types::LlmNode;
|
use super::types::LlmNode;
|
||||||
use crate::client::{Model, ModelType, call_chat_completions};
|
use crate::client::{Model, ModelType, call_chat_completions};
|
||||||
use crate::config::{Input, RequestContext, Role, RoleLike, SkillPolicy};
|
use crate::config::{Input, RequestContext, Role, RoleLike, SkillPolicy};
|
||||||
use crate::function::skill::skill_function_declarations;
|
|
||||||
use crate::utils::create_abort_signal;
|
use crate::utils::create_abort_signal;
|
||||||
use anyhow::{Context, Error, Result, anyhow, bail};
|
use anyhow::{Context, Error, Result, anyhow, bail};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -106,7 +105,7 @@ async fn run(
|
|||||||
let (regular_tools, mcp_servers) = categorize_tools(node.tools.as_deref());
|
let (regular_tools, mcp_servers) = categorize_tools(node.tools.as_deref());
|
||||||
validate_tools_subset(®ular_tools, &mcp_servers, parent_ctx)?;
|
validate_tools_subset(®ular_tools, &mcp_servers, parent_ctx)?;
|
||||||
|
|
||||||
let mut role = build_inline_role(
|
let role = build_inline_role(
|
||||||
node,
|
node,
|
||||||
instructions.as_deref(),
|
instructions.as_deref(),
|
||||||
®ular_tools,
|
®ular_tools,
|
||||||
@@ -116,29 +115,12 @@ async fn run(
|
|||||||
|
|
||||||
let saved_agent_skill_state = swap_in_node_skill_policy(node, parent_ctx);
|
let saved_agent_skill_state = swap_in_node_skill_policy(node, parent_ctx);
|
||||||
|
|
||||||
let policy = match SkillPolicy::effective(
|
let policy = SkillPolicy::effective(
|
||||||
&parent_ctx.app.config,
|
&parent_ctx.app.config,
|
||||||
parent_ctx.role.as_ref(),
|
parent_ctx.role.as_ref(),
|
||||||
parent_ctx.agent.as_ref(),
|
parent_ctx.agent.as_ref(),
|
||||||
parent_ctx.session.as_ref(),
|
parent_ctx.session.as_ref(),
|
||||||
) {
|
)?;
|
||||||
Ok(p) => p,
|
|
||||||
Err(e) => {
|
|
||||||
restore_agent_skill_policy(parent_ctx, saved_agent_skill_state);
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if policy.skills_enabled {
|
|
||||||
let mut tools = role.enabled_tools().map(|v| v.to_vec()).unwrap_or_default();
|
|
||||||
for decl in skill_function_declarations() {
|
|
||||||
if !tools.contains(&decl.name) {
|
|
||||||
tools.push(decl.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
role.set_enabled_tools(Some(tools));
|
|
||||||
}
|
|
||||||
|
|
||||||
let composed_role = parent_ctx.skill_registry.effective_role(&role, &policy);
|
let composed_role = parent_ctx.skill_registry.effective_role(&role, &policy);
|
||||||
|
|
||||||
let saved_role = parent_ctx.role.clone();
|
let saved_role = parent_ctx.role.clone();
|
||||||
|
|||||||
+1
-68
@@ -93,7 +93,6 @@ impl AgentValidationContext {
|
|||||||
pub struct GraphValidator {
|
pub struct GraphValidator {
|
||||||
base_dir: PathBuf,
|
base_dir: PathBuf,
|
||||||
agent_ctx: Option<AgentValidationContext>,
|
agent_ctx: Option<AgentValidationContext>,
|
||||||
skill_exists: fn(&str) -> bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GraphValidator {
|
impl GraphValidator {
|
||||||
@@ -101,7 +100,6 @@ impl GraphValidator {
|
|||||||
Self {
|
Self {
|
||||||
base_dir: base_dir.into(),
|
base_dir: base_dir.into(),
|
||||||
agent_ctx: None,
|
agent_ctx: None,
|
||||||
skill_exists: paths::has_skill,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,12 +108,6 @@ impl GraphValidator {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub fn with_skill_exists(mut self, f: fn(&str) -> bool) -> Self {
|
|
||||||
self.skill_exists = f;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn validate(&self, graph: &Graph) -> ValidationResult {
|
pub fn validate(&self, graph: &Graph) -> ValidationResult {
|
||||||
let mut result = ValidationResult::default();
|
let mut result = ValidationResult::default();
|
||||||
self.validate_node_references(graph, &mut result);
|
self.validate_node_references(graph, &mut result);
|
||||||
@@ -199,49 +191,6 @@ impl GraphValidator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn validate_llm_skills(&self, graph: &Graph, result: &mut ValidationResult) {
|
fn validate_llm_skills(&self, graph: &Graph, result: &mut ValidationResult) {
|
||||||
let visible_skills = self
|
|
||||||
.agent_ctx
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|c| c.app_config.visible_skills.as_deref());
|
|
||||||
|
|
||||||
let skill_exists = self.skill_exists;
|
|
||||||
let has_agent_ctx = self.agent_ctx.is_some();
|
|
||||||
let check_visibility = |name: &str| -> Option<String> {
|
|
||||||
if !has_agent_ctx {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
match visible_skills {
|
|
||||||
Some(list) if !list.iter().any(|s| s == name) => Some(format!(
|
|
||||||
"'{name}' is not in the global 'visible_skills' allow-list"
|
|
||||||
)),
|
|
||||||
None if !skill_exists(name) => Some(format!("'{name}' is not installed")),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(graph_skills) = &graph.enabled_skills {
|
|
||||||
for name in graph_skills {
|
|
||||||
if name.trim().is_empty() {
|
|
||||||
result.error(ValidationError::new(
|
|
||||||
"graph 'enabled_skills' contains an empty skill name",
|
|
||||||
));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Err(e) = paths::validate_skill_name(name) {
|
|
||||||
result.error(ValidationError::new(format!(
|
|
||||||
"graph 'enabled_skills' contains an invalid skill name: '{name}': {e}"
|
|
||||||
)));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some(reason) = check_visibility(name) {
|
|
||||||
result.error(ValidationError::new(format!(
|
|
||||||
"graph 'enabled_skills': {reason}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (node_id, node) in &graph.nodes {
|
for (node_id, node) in &graph.nodes {
|
||||||
let NodeType::Llm(llm) = &node.node_type else {
|
let NodeType::Llm(llm) = &node.node_type else {
|
||||||
continue;
|
continue;
|
||||||
@@ -258,22 +207,6 @@ impl GraphValidator {
|
|||||||
));
|
));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Err(e) = paths::validate_skill_name(name) {
|
|
||||||
result.error(ValidationError::with_node(
|
|
||||||
node_id,
|
|
||||||
format!(
|
|
||||||
"llm node 'enabled_skills' contains an invalid skill name: '{name}': {e}"
|
|
||||||
)));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some(reason) = check_visibility(name) {
|
|
||||||
result.error(ValidationError::with_node(
|
|
||||||
node_id,
|
|
||||||
format!("llm node 'enabled_skills': {reason}"),
|
|
||||||
));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(graph_skills) = &graph.enabled_skills
|
if let Some(graph_skills) = &graph.enabled_skills
|
||||||
&& !graph_skills.iter().any(|g| g == name)
|
&& !graph_skills.iter().any(|g| g == name)
|
||||||
{
|
{
|
||||||
@@ -1392,7 +1325,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn validator() -> GraphValidator {
|
fn validator() -> GraphValidator {
|
||||||
GraphValidator::new(env::current_dir().unwrap()).with_skill_exists(|_: &str| true)
|
GraphValidator::new(env::current_dir().unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+10
-14
@@ -113,7 +113,7 @@ async fn main() -> Result<()> {
|
|||||||
if vault_flags {
|
if vault_flags {
|
||||||
let cfg = Config::load_with_interpolation(true).await?;
|
let cfg = Config::load_with_interpolation(true).await?;
|
||||||
let app_config = AppConfig::from_config(cfg)?;
|
let app_config = AppConfig::from_config(cfg)?;
|
||||||
let vault = Vault::init(&app_config)?;
|
let vault = Vault::init(&app_config);
|
||||||
return Vault::handle_vault_flags(cli, &vault);
|
return Vault::handle_vault_flags(cli, &vault);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,18 +197,14 @@ async fn run(
|
|||||||
println!("{skills}");
|
println!("{skills}");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let skills = cli.skills();
|
if cli.skill.len() == 1 && !paths::has_skill(&cli.skill[0]) {
|
||||||
if skills.len() == 1 {
|
let name = &cli.skill[0];
|
||||||
let name = &skills[0];
|
let app = Arc::clone(&ctx.app.config);
|
||||||
paths::validate_skill_name(name)?;
|
ctx.upsert_skill(app.as_ref(), name)?;
|
||||||
if !paths::has_skill(name) {
|
return Ok(());
|
||||||
let app = Arc::clone(&ctx.app.config);
|
}
|
||||||
ctx.upsert_skill(app.as_ref(), name)?;
|
if cli.skill.len() > 1 {
|
||||||
return Ok(());
|
for name in &cli.skill {
|
||||||
}
|
|
||||||
} else if skills.len() > 1 {
|
|
||||||
for name in &skills {
|
|
||||||
paths::validate_skill_name(name)?;
|
|
||||||
if !paths::has_skill(name) {
|
if !paths::has_skill(name) {
|
||||||
bail!("Skill '{name}' is not installed");
|
bail!("Skill '{name}' is not installed");
|
||||||
}
|
}
|
||||||
@@ -327,7 +323,7 @@ async fn run(
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
for name in &cli.skills() {
|
for name in &cli.skill {
|
||||||
ctx.load_skill_repl(name, abort_signal.clone()).await?;
|
ctx.load_skill_repl(name, abort_signal.clone()).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -708,8 +708,6 @@ pub async fn run_repl_command(
|
|||||||
let name = s.strip_prefix("skill").unwrap_or("").trim();
|
let name = s.strip_prefix("skill").unwrap_or("").trim();
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
println!("Usage: .edit skill <name>");
|
println!("Usage: .edit skill <name>");
|
||||||
} else if let Err(e) = paths::validate_skill_name(name) {
|
|
||||||
bail!(e);
|
|
||||||
} else if !paths::has_skill(name) {
|
} else if !paths::has_skill(name) {
|
||||||
bail!(
|
bail!(
|
||||||
"Skill '{name}' is not installed (expected at {})",
|
"Skill '{name}' is not installed (expected at {})",
|
||||||
|
|||||||
+14
-52
@@ -1,9 +1,6 @@
|
|||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
use std::fs::read_to_string;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::config::paths;
|
|
||||||
pub use utils::create_vault_password_file;
|
pub use utils::create_vault_password_file;
|
||||||
pub use utils::interpolate_secrets;
|
pub use utils::interpolate_secrets;
|
||||||
pub use utils::prompt_provider_choice;
|
pub use utils::prompt_provider_choice;
|
||||||
@@ -17,13 +14,11 @@ use gman::providers::SecretProvider;
|
|||||||
use gman::providers::SupportedProvider;
|
use gman::providers::SupportedProvider;
|
||||||
use gman::providers::local::LocalProvider;
|
use gman::providers::local::LocalProvider;
|
||||||
use inquire::{Password, PasswordDisplayMode, required};
|
use inquire::{Password, PasswordDisplayMode, required};
|
||||||
use log::warn;
|
|
||||||
use serde_yaml::Value;
|
|
||||||
use std::sync::{Arc, LazyLock};
|
use std::sync::{Arc, LazyLock};
|
||||||
use tokio::runtime::Handle;
|
use tokio::runtime::Handle;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub static SECRET_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{\{([^{}]+)}}").unwrap());
|
pub static SECRET_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{\{(.+)}}").unwrap());
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone)]
|
||||||
pub struct Vault {
|
pub struct Vault {
|
||||||
@@ -33,54 +28,22 @@ pub struct Vault {
|
|||||||
pub type GlobalVault = Arc<Vault>;
|
pub type GlobalVault = Arc<Vault>;
|
||||||
|
|
||||||
impl Vault {
|
impl Vault {
|
||||||
pub fn init_bare() -> Result<Self> {
|
pub fn init_bare() -> Self {
|
||||||
let config_path = paths::config_file();
|
let vault_password_file = AppConfig::default().vault_password_file();
|
||||||
if !config_path.exists() {
|
let local_provider = LocalProvider {
|
||||||
bail!(
|
password_file: Some(vault_password_file),
|
||||||
"Coyote config not found at {}. Run first-run setup before using the vault.",
|
git_branch: None,
|
||||||
config_path.display()
|
..LocalProvider::default()
|
||||||
);
|
|
||||||
}
|
|
||||||
let content = read_to_string(&config_path)
|
|
||||||
.with_context(|| format!("failed to read config at {}", config_path.display()))?;
|
|
||||||
let value: Value = serde_yaml::from_str(&content)
|
|
||||||
.with_context(|| format!("failed to parse config at {}", config_path.display()))?;
|
|
||||||
|
|
||||||
let provider = match value.get("secrets_provider") {
|
|
||||||
Some(v) if !v.is_null() => serde_yaml::from_value::<SupportedProvider>(v.clone())
|
|
||||||
.with_context(|| "failed to parse 'secrets_provider' from config")?,
|
|
||||||
_ => {
|
|
||||||
let password_file = value
|
|
||||||
.get("vault_password_file")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|| AppConfig::default().vault_password_file());
|
|
||||||
SupportedProvider::Local {
|
|
||||||
provider_def: LocalProvider {
|
|
||||||
password_file: Some(password_file),
|
|
||||||
git_branch: None,
|
|
||||||
..LocalProvider::default()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Self { provider })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn default_local() -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
provider: SupportedProvider::Local {
|
provider: SupportedProvider::Local {
|
||||||
provider_def: LocalProvider {
|
provider_def: local_provider,
|
||||||
password_file: Some(AppConfig::default().vault_password_file()),
|
|
||||||
git_branch: None,
|
|
||||||
..LocalProvider::default()
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init(config: &AppConfig) -> Result<Self> {
|
pub fn init(config: &AppConfig) -> Self {
|
||||||
let mut provider = match &config.secrets_provider {
|
let mut provider = match &config.secrets_provider {
|
||||||
Some(p) => p.clone(),
|
Some(p) => p.clone(),
|
||||||
None => SupportedProvider::Local {
|
None => SupportedProvider::Local {
|
||||||
@@ -92,10 +55,11 @@ impl Vault {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let SupportedProvider::Local { provider_def } = &mut provider {
|
if let SupportedProvider::Local { provider_def } = &mut provider {
|
||||||
ensure_password_file_initialized(provider_def)?;
|
ensure_password_file_initialized(provider_def)
|
||||||
|
.expect("Failed to initialize password file");
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Self { provider })
|
Self { provider }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn local_password_file(&self) -> Result<PathBuf> {
|
pub fn local_password_file(&self) -> Result<PathBuf> {
|
||||||
@@ -213,7 +177,7 @@ impl Vault {
|
|||||||
|
|
||||||
pub fn validate_round_trip(&self) -> Result<()> {
|
pub fn validate_round_trip(&self) -> Result<()> {
|
||||||
const PROBE_VALUE: &str = "ok";
|
const PROBE_VALUE: &str = "ok";
|
||||||
let probe_key = format!("coyote-setup-probe-{}", Uuid::new_v4().simple());
|
let probe_key = format!("__coyote_setup_probe_{}__", Uuid::new_v4().simple());
|
||||||
|
|
||||||
let h = Handle::current();
|
let h = Handle::current();
|
||||||
let result: Result<()> = tokio::task::block_in_place(|| {
|
let result: Result<()> = tokio::task::block_in_place(|| {
|
||||||
@@ -228,9 +192,7 @@ impl Vault {
|
|||||||
.await
|
.await
|
||||||
.with_context(|| "vault read probe failed")?;
|
.with_context(|| "vault read probe failed")?;
|
||||||
if got != PROBE_VALUE {
|
if got != PROBE_VALUE {
|
||||||
if let Err(cleanup_err) = self.provider_ref().delete_secret(&probe_key).await {
|
let _ = self.provider_ref().delete_secret(&probe_key).await;
|
||||||
warn!("vault probe cleanup failed for key '{probe_key}': {cleanup_err}");
|
|
||||||
}
|
|
||||||
bail!("vault read probe returned an unexpected value");
|
bail!("vault read probe returned an unexpected value");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-215
@@ -13,8 +13,7 @@ use gman::providers::one_password::OnePasswordProvider;
|
|||||||
use indoc::formatdoc;
|
use indoc::formatdoc;
|
||||||
use inquire::validator::Validation;
|
use inquire::validator::Validation;
|
||||||
use inquire::{Confirm, Password, PasswordDisplayMode, Select, Text, min_length, required};
|
use inquire::{Confirm, Password, PasswordDisplayMode, Select, Text, min_length, required};
|
||||||
use log::debug;
|
use std::path::PathBuf;
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
pub fn ensure_password_file_initialized(local_provider: &mut LocalProvider) -> Result<()> {
|
pub fn ensure_password_file_initialized(local_provider: &mut LocalProvider) -> Result<()> {
|
||||||
@@ -92,7 +91,6 @@ pub fn create_vault_password_file(vault: &mut Vault) -> Result<()> {
|
|||||||
match password {
|
match password {
|
||||||
Ok(pw) => {
|
Ok(pw) => {
|
||||||
std::fs::write(&vault_password_file, pw.as_bytes())?;
|
std::fs::write(&vault_password_file, pw.as_bytes())?;
|
||||||
set_password_file_permissions(&vault_password_file)?;
|
|
||||||
println!(
|
println!(
|
||||||
"✓ Password file '{}' updated.",
|
"✓ Password file '{}' updated.",
|
||||||
vault_password_file.display()
|
vault_password_file.display()
|
||||||
@@ -164,7 +162,6 @@ pub fn create_vault_password_file(vault: &mut Vault) -> Result<()> {
|
|||||||
match password {
|
match password {
|
||||||
Ok(pw) => {
|
Ok(pw) => {
|
||||||
std::fs::write(&password_file, pw.as_bytes())?;
|
std::fs::write(&password_file, pw.as_bytes())?;
|
||||||
set_password_file_permissions(&password_file)?;
|
|
||||||
local_provider.password_file = Some(password_file);
|
local_provider.password_file = Some(password_file);
|
||||||
println!(
|
println!(
|
||||||
"✓ Password file '{}' created.",
|
"✓ Password file '{}' created.",
|
||||||
@@ -355,19 +352,6 @@ fn required_cli_preflight(label: &str, cli: &str, install_url: &str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn interpolate_secrets(content: &str, vault: &Vault) -> Result<(String, Vec<String>)> {
|
pub fn interpolate_secrets(content: &str, vault: &Vault) -> Result<(String, Vec<String>)> {
|
||||||
interpolate_secrets_with(content, vault.auth_hint(), |name| {
|
|
||||||
vault.get_secret(name, false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn interpolate_secrets_with<F>(
|
|
||||||
content: &str,
|
|
||||||
auth_hint: Option<&'static str>,
|
|
||||||
mut get_secret: F,
|
|
||||||
) -> Result<(String, Vec<String>)>
|
|
||||||
where
|
|
||||||
F: FnMut(&str) -> Result<String>,
|
|
||||||
{
|
|
||||||
let mut missing_secrets = vec![];
|
let mut missing_secrets = vec![];
|
||||||
let mut fatal_error: Option<anyhow::Error> = None;
|
let mut fatal_error: Option<anyhow::Error> = None;
|
||||||
|
|
||||||
@@ -385,7 +369,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
let name = caps[1].trim();
|
let name = caps[1].trim();
|
||||||
match get_secret(name) {
|
match vault.get_secret(name, false) {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => match e.downcast_ref::<SecretError>() {
|
Err(e) => match e.downcast_ref::<SecretError>() {
|
||||||
Some(SecretError::NotFound { .. }) => {
|
Some(SecretError::NotFound { .. }) => {
|
||||||
@@ -395,7 +379,7 @@ where
|
|||||||
Some(SecretError::AuthFailed { .. }) => {
|
Some(SecretError::AuthFailed { .. }) => {
|
||||||
let base =
|
let base =
|
||||||
format!("Failed to fetch secret '{name}' from vault: {e}");
|
format!("Failed to fetch secret '{name}' from vault: {e}");
|
||||||
let msg = match auth_hint {
|
let msg = match vault.auth_hint() {
|
||||||
Some(hint) => format!("{base}\n\nHint: {hint}"),
|
Some(hint) => format!("{base}\n\nHint: {hint}"),
|
||||||
None => base,
|
None => base,
|
||||||
};
|
};
|
||||||
@@ -422,199 +406,3 @@ where
|
|||||||
|
|
||||||
Ok((parsed_content, missing_secrets))
|
Ok((parsed_content, missing_secrets))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn set_password_file_permissions(path: &Path) -> Result<()> {
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|e| {
|
|
||||||
anyhow!(
|
|
||||||
"Failed to set 0600 permissions on '{}': {e}",
|
|
||||||
path.display()
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
fn set_password_file_permissions(_path: &Path) -> Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use anyhow::Error;
|
|
||||||
use std::cell::RefCell;
|
|
||||||
|
|
||||||
fn not_found(name: &str) -> Error {
|
|
||||||
Error::new(SecretError::NotFound {
|
|
||||||
key: name.to_string(),
|
|
||||||
provider: "test",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn auth_failed() -> Error {
|
|
||||||
Error::new(SecretError::AuthFailed {
|
|
||||||
provider: "test",
|
|
||||||
source: anyhow!("auth failure"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Calls(RefCell<Vec<String>>);
|
|
||||||
|
|
||||||
impl Calls {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self(RefCell::new(Vec::new()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record(&self, name: &str) {
|
|
||||||
self.0.borrow_mut().push(name.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn snapshot(&self) -> Vec<String> {
|
|
||||||
self.0.borrow().clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn interpolates_single_secret_per_line() {
|
|
||||||
let (out, missing) =
|
|
||||||
interpolate_secrets_with("api_key={{API_KEY}}", None, |name| match name {
|
|
||||||
"API_KEY" => Ok("sk-12345".to_string()),
|
|
||||||
other => panic!("unexpected lookup: {other}"),
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(out, "api_key=sk-12345");
|
|
||||||
assert!(missing.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn regex_matches_each_secret_independently_when_one_per_line() {
|
|
||||||
let calls = Calls::new();
|
|
||||||
let (out, missing) = interpolate_secrets_with("{{ONE}}\nmiddle\n{{TWO}}", None, |name| {
|
|
||||||
calls.record(name);
|
|
||||||
Ok(name.to_lowercase())
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(calls.snapshot(), vec!["ONE".to_string(), "TWO".to_string()]);
|
|
||||||
assert_eq!(out, "one\nmiddle\ntwo");
|
|
||||||
assert!(missing.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn skips_comment_lines() {
|
|
||||||
let calls = Calls::new();
|
|
||||||
|
|
||||||
let (out, missing) =
|
|
||||||
interpolate_secrets_with("# api_key={{NEVER_FETCHED}}\nreal={{S}}", None, |name| {
|
|
||||||
calls.record(name);
|
|
||||||
Ok("v".to_string())
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(out, "# api_key={{NEVER_FETCHED}}\nreal=v");
|
|
||||||
assert!(missing.is_empty());
|
|
||||||
assert_eq!(calls.snapshot(), vec!["S".to_string()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn missing_secrets_become_empty_strings_and_are_reported() {
|
|
||||||
let (out, missing) = interpolate_secrets_with(
|
|
||||||
"a={{HAVE}}\nb={{MISSING_1}}\nc={{MISSING_2}}",
|
|
||||||
None,
|
|
||||||
|name| match name {
|
|
||||||
"HAVE" => Ok("present".to_string()),
|
|
||||||
missing => Err(not_found(missing)),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(out, "a=present\nb=\nc=");
|
|
||||||
assert_eq!(
|
|
||||||
missing,
|
|
||||||
vec!["MISSING_1".to_string(), "MISSING_2".to_string()]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn interpolates_multiple_secrets_on_same_line() {
|
|
||||||
let calls = Calls::new();
|
|
||||||
|
|
||||||
let (out, missing) = interpolate_secrets_with("url={{URL}} key={{KEY}}", None, |name| {
|
|
||||||
calls.record(name);
|
|
||||||
match name {
|
|
||||||
"URL" => Ok("https://example.test".to_string()),
|
|
||||||
"KEY" => Ok("sk-12345".to_string()),
|
|
||||||
other => panic!("unexpected lookup: {other}"),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(calls.snapshot(), vec!["URL".to_string(), "KEY".to_string()]);
|
|
||||||
assert_eq!(out, "url=https://example.test key=sk-12345");
|
|
||||||
assert!(missing.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn regex_rejects_braces_in_secret_names() {
|
|
||||||
let calls = Calls::new();
|
|
||||||
|
|
||||||
let (out, missing) =
|
|
||||||
interpolate_secrets_with("literal {{ {NOT_A_NAME} }} text", None, |name| {
|
|
||||||
calls.record(name);
|
|
||||||
Ok(format!("got-{name}"))
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
calls.snapshot().is_empty(),
|
|
||||||
"name with embedded braces must not match"
|
|
||||||
);
|
|
||||||
assert_eq!(out, "literal {{ {NOT_A_NAME} }} text");
|
|
||||||
assert!(missing.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fatal_failure_short_circuits_remaining_lines() {
|
|
||||||
let calls = Calls::new();
|
|
||||||
|
|
||||||
let result =
|
|
||||||
interpolate_secrets_with("a={{S1}}\nb={{S2}}\nc={{S3}}\nd={{S4}}", None, |name| {
|
|
||||||
calls.record(name);
|
|
||||||
match name {
|
|
||||||
"S1" => Ok("first".to_string()),
|
|
||||||
"S2" => Err(auth_failed()),
|
|
||||||
other => Ok(format!("late-{other}")),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let err = result.unwrap_err().to_string();
|
|
||||||
assert!(
|
|
||||||
err.contains("S2"),
|
|
||||||
"error should name the offending secret, got: {err}"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
calls.snapshot(),
|
|
||||||
vec!["S1".to_string(), "S2".to_string()],
|
|
||||||
"lookups must stop at the failing secret - S3 and S4 should never be fetched"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn auth_failure_appends_hint_when_provided() {
|
|
||||||
let result = interpolate_secrets_with(
|
|
||||||
"k={{K}}",
|
|
||||||
Some("run `coyote --authenticate` to reauth"),
|
|
||||||
|_| Err(auth_failed()),
|
|
||||||
);
|
|
||||||
|
|
||||||
let err = result.unwrap_err().to_string();
|
|
||||||
|
|
||||||
assert!(err.contains("Hint:"), "expected hint in error, got: {err}");
|
|
||||||
assert!(
|
|
||||||
err.contains("coyote --authenticate"),
|
|
||||||
"expected hint contents, got: {err}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user