mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-23 10:17:50 +00:00
Compare commits
1629 Commits
v0.0.2
...
v0.0.7-nig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a0bdceb5d | ||
|
|
7856f52afb | ||
|
|
e986476fe0 | ||
|
|
cfc1aebee6 | ||
|
|
ef1c8a4bfe | ||
|
|
484292b2ac | ||
|
|
f9659184d4 | ||
|
|
6d5bb1b57c | ||
|
|
fb9f2d292c | ||
|
|
16ea8560b7 | ||
|
|
2655af079a | ||
|
|
807844fb57 | ||
|
|
2202d26ac7 | ||
|
|
58f66ccfc6 | ||
|
|
65c622c0ac | ||
|
|
a3ec2f52c9 | ||
|
|
c96852dc56 | ||
|
|
028a82ebeb | ||
|
|
6b67cd1b57 | ||
|
|
96a9b683b2 | ||
|
|
dcc86699cf | ||
|
|
964509f587 | ||
|
|
e3a5806ae2 | ||
|
|
a45adbdc76 | ||
|
|
41500814b0 | ||
|
|
786832913b | ||
|
|
4807434d9f | ||
|
|
c09abb817f | ||
|
|
b7663950f2 | ||
|
|
8158e82165 | ||
|
|
f8d3571e31 | ||
|
|
6f399c078a | ||
|
|
854c452580 | ||
|
|
f503be14e9 | ||
|
|
5d2a678cb2 | ||
|
|
ce632725b0 | ||
|
|
ea7dcf8347 | ||
|
|
ffc2d27ca3 | ||
|
|
f0c60b90ea | ||
|
|
14a3be7976 | ||
|
|
9ffeacc0f9 | ||
|
|
cd375fefe5 | ||
|
|
42a0336876 | ||
|
|
a7ea4ce0c8 | ||
|
|
d54780edda | ||
|
|
3562ab8f5c | ||
|
|
7b03d057ea | ||
|
|
0895e29c1b | ||
|
|
fb6d9cbd36 | ||
|
|
48fa6f84c8 | ||
|
|
016a263409 | ||
|
|
12fc17bc8c | ||
|
|
8ba12269d5 | ||
|
|
02e44e5db2 | ||
|
|
ca19aa9125 | ||
|
|
16d29e2d6f | ||
|
|
cdbe26b811 | ||
|
|
f1146c4b2e | ||
|
|
0af8b65407 | ||
|
|
db1e358081 | ||
|
|
a28bf81185 | ||
|
|
d1964200f9 | ||
|
|
42ab185890 | ||
|
|
b2bff47fc7 | ||
|
|
f1328b8437 | ||
|
|
54e41e3b31 | ||
|
|
c306cd89fc | ||
|
|
0414768cf8 | ||
|
|
bdfff529aa | ||
|
|
f83c6168ad | ||
|
|
c7d1a28ac6 | ||
|
|
4f69b2d8dc | ||
|
|
0335ce5ecc | ||
|
|
c0b4fc9506 | ||
|
|
ae8a8f6062 | ||
|
|
055c0d8374 | ||
|
|
8d6dfaac19 | ||
|
|
dbc0eb4336 | ||
|
|
a0dbb40dae | ||
|
|
d666bcda0b | ||
|
|
8faf7bfa63 | ||
|
|
23f6ae8513 | ||
|
|
8d5fa18893 | ||
|
|
26a8e1e2f9 | ||
|
|
f50ec186b5 | ||
|
|
321e1e25c7 | ||
|
|
82972e4b03 | ||
|
|
8484730cd6 | ||
|
|
e5ce7d4872 | ||
|
|
786750b1b5 | ||
|
|
e7699ddfb1 | ||
|
|
cab60a38a1 | ||
|
|
a2db3d1b38 | ||
|
|
eafcfcd169 | ||
|
|
0d23195624 | ||
|
|
b69b2ce376 | ||
|
|
138e52b61e | ||
|
|
4db2fc9ed6 | ||
|
|
78435ab0bf | ||
|
|
ef445212f6 | ||
|
|
c1157352b7 | ||
|
|
9397336a15 | ||
|
|
8e6c715b0f | ||
|
|
750e647988 | ||
|
|
150a2568b4 | ||
|
|
598b2cf7f4 | ||
|
|
8be10b4c09 | ||
|
|
0c0881348d | ||
|
|
8550d70a57 | ||
|
|
c80607ac15 | ||
|
|
ceccdf9d2c | ||
|
|
7ca978f3a0 | ||
|
|
54ec18141c | ||
|
|
72af6e077f | ||
|
|
152de2b6d8 | ||
|
|
8b645ff688 | ||
|
|
aad8893322 | ||
|
|
e70d2bf6d5 | ||
|
|
5984eba070 | ||
|
|
3e1b2dc33a | ||
|
|
cb6a2161fe | ||
|
|
8fabce2c04 | ||
|
|
f7c2091389 | ||
|
|
35811d534a | ||
|
|
8378fbf7b2 | ||
|
|
658a7b49df | ||
|
|
f0d80dfe23 | ||
|
|
ed1483b06f | ||
|
|
85a0ed27f6 | ||
|
|
61107ef19d | ||
|
|
0b912e2e09 | ||
|
|
c156fb0e8b | ||
|
|
1a92614c84 | ||
|
|
ed2b4c6aa4 | ||
|
|
32c7070d7f | ||
|
|
a2c3dbd189 | ||
|
|
72d6ef2d3c | ||
|
|
bf7fd08f7e | ||
|
|
c42d3b58e1 | ||
|
|
69c6808b14 | ||
|
|
3091980de2 | ||
|
|
cb39eef7b5 | ||
|
|
40db8cde97 | ||
|
|
787aa624da | ||
|
|
56c2d95a4c | ||
|
|
4b3e407d49 | ||
|
|
f1f0da6dc9 | ||
|
|
4de893da0d | ||
|
|
02bf8c16c7 | ||
|
|
165b29c3b1 | ||
|
|
16322ed0b2 | ||
|
|
e1f9f90660 | ||
|
|
0371f638c0 | ||
|
|
79703c8ecb | ||
|
|
f3ffb00ed0 | ||
|
|
9d07de7a5b | ||
|
|
3a384784d7 | ||
|
|
e7b90f54e6 | ||
|
|
8e983466f8 | ||
|
|
1f013c969f | ||
|
|
c45c14ee0e | ||
|
|
576cebc928 | ||
|
|
9e61b3510c | ||
|
|
3e81359c6b | ||
|
|
771cb229ab | ||
|
|
ca5dd28ab6 | ||
|
|
ad2ef080aa | ||
|
|
b089845f1c | ||
|
|
21fef1620d | ||
|
|
fb751c542b | ||
|
|
f9cfb20897 | ||
|
|
17331001a0 | ||
|
|
fbdc8d5ab3 | ||
|
|
aa71438684 | ||
|
|
23e0dc6960 | ||
|
|
be898710fe | ||
|
|
4c144e616d | ||
|
|
65aabfede8 | ||
|
|
eb65034117 | ||
|
|
de96887789 | ||
|
|
13b0971291 | ||
|
|
d76cedb68f | ||
|
|
91f016d44a | ||
|
|
820105e982 | ||
|
|
7ddbf97634 | ||
|
|
f0e3e6ee8a | ||
|
|
1b8ba5ca6b | ||
|
|
3c16429fc4 | ||
|
|
f379aa833f | ||
|
|
e500eb5562 | ||
|
|
6321442865 | ||
|
|
fb0db2dfd6 | ||
|
|
1d3ad9d075 | ||
|
|
5d4b02ca85 | ||
|
|
1d7eb0d250 | ||
|
|
273e74c09d | ||
|
|
e9ee686ab6 | ||
|
|
f0400912fd | ||
|
|
30b6cf8a00 | ||
|
|
4e376c0447 | ||
|
|
f9930c2d36 | ||
|
|
e05173d9cc | ||
|
|
d254d4ce00 | ||
|
|
3dd6e431df | ||
|
|
52980510c9 | ||
|
|
0ef9c0b792 | ||
|
|
6380bfe35c | ||
|
|
b1e0fb157b | ||
|
|
107ce8afa3 | ||
|
|
e9e2f55144 | ||
|
|
2e28bb90a0 | ||
|
|
e21b5c95aa | ||
|
|
d7a304bcff | ||
|
|
9d3164621a | ||
|
|
209c8783b4 | ||
|
|
4fd7cf9177 | ||
|
|
bbe95f1eaa | ||
|
|
2d1eafae95 | ||
|
|
1de246236b | ||
|
|
7c3a84075d | ||
|
|
67008d4643 | ||
|
|
30c68922a3 | ||
|
|
a00f1bb916 | ||
|
|
487debe525 | ||
|
|
5dce1df5db | ||
|
|
e306b34a6e | ||
|
|
4d653c833a | ||
|
|
258c848909 | ||
|
|
138ff73821 | ||
|
|
9daead63dd | ||
|
|
5f813ef510 | ||
|
|
ffa42a79dd | ||
|
|
5066bc5384 | ||
|
|
97cf26ec53 | ||
|
|
01ea0b8657 | ||
|
|
12765eb775 | ||
|
|
4c3532d2b3 | ||
|
|
dc2ac144b7 | ||
|
|
8be5f8038a | ||
|
|
f7b4e74932 | ||
|
|
b4d00ab4fb | ||
|
|
1969d805f2 | ||
|
|
d7a57d85a3 | ||
|
|
f95674e646 | ||
|
|
74d0f4c79f | ||
|
|
9bdcdf97d8 | ||
|
|
45b764943a | ||
|
|
f4d077cc1f | ||
|
|
5f8fff4db3 | ||
|
|
0f6405e28d | ||
|
|
0996d91f0b | ||
|
|
2a95c8287e | ||
|
|
7a9821607b | ||
|
|
8f85ac7de0 | ||
|
|
a01b1219a3 | ||
|
|
76b935d598 | ||
|
|
c0bfa388c5 | ||
|
|
fe00686c58 | ||
|
|
a464675c4d | ||
|
|
412b78c5ab | ||
|
|
73d5d988f5 | ||
|
|
f650be2c3a | ||
|
|
4dbd9f30b6 | ||
|
|
5b7b6fe608 | ||
|
|
003609239f | ||
|
|
04bbc60b97 | ||
|
|
73745ecd03 | ||
|
|
4915050ad4 | ||
|
|
b5f5ea2c31 | ||
|
|
d7041a6595 | ||
|
|
18c3bf3a42 | ||
|
|
9dadf22958 | ||
|
|
c5761317f4 | ||
|
|
de27ea6095 | ||
|
|
8497176168 | ||
|
|
91c69731c7 | ||
|
|
2f5eecfc49 | ||
|
|
8ade3e7ee2 | ||
|
|
584a50a342 | ||
|
|
ca07b5b0c4 | ||
|
|
5df6c9fb66 | ||
|
|
f0dc9690b7 | ||
|
|
4b8838bea4 | ||
|
|
695afac33e | ||
|
|
5b7bf74d66 | ||
|
|
6aac93ee07 | ||
|
|
761ffc6338 | ||
|
|
12401898f1 | ||
|
|
606a7702de | ||
|
|
4ca471bac6 | ||
|
|
88b5f20943 | ||
|
|
79d36ac0a5 | ||
|
|
fc8c38b2bc | ||
|
|
cbda781f73 | ||
|
|
0d64355be6 | ||
|
|
ac8e98511e | ||
|
|
9ab44ea9d6 | ||
|
|
01e66bb123 | ||
|
|
0c76affe6d | ||
|
|
fbe09cd35e | ||
|
|
ab9eb9377f | ||
|
|
69a8ae6a89 | ||
|
|
f6ee0d182b | ||
|
|
21eb44b242 | ||
|
|
e4ed1aabac | ||
|
|
34c1b5811a | ||
|
|
ddcac4201d | ||
|
|
f4cd0055fd | ||
|
|
cba272082d | ||
|
|
d622e596a1 | ||
|
|
0903421b1a | ||
|
|
b72e3dfb43 | ||
|
|
e88b9362dc | ||
|
|
1d67b41ccd | ||
|
|
ec5e9d1025 | ||
|
|
615748657a | ||
|
|
222e362fc2 | ||
|
|
b61016f2a5 | ||
|
|
bf51de1a4d | ||
|
|
58f1aa6ceb | ||
|
|
03b3917f62 | ||
|
|
8d9dc44b71 | ||
|
|
776d6b6ea0 | ||
|
|
af551f4a41 | ||
|
|
7b49560265 | ||
|
|
40c4070846 | ||
|
|
d3ee9de3c3 | ||
|
|
7effdad3e2 | ||
|
|
b09bc66560 | ||
|
|
97cc1e6418 | ||
|
|
123c3e7c7f | ||
|
|
f5d5213504 | ||
|
|
6e258e2850 | ||
|
|
2862ae7344 | ||
|
|
fefa7ecbea | ||
|
|
e584241141 | ||
|
|
886faa2990 | ||
|
|
734da8b9d2 | ||
|
|
7ffe8038ef | ||
|
|
ff3722a3a7 | ||
|
|
5008aea90d | ||
|
|
9dc812dd4b | ||
|
|
2f1d6234de | ||
|
|
c313c3dee1 | ||
|
|
80c81f2a4c | ||
|
|
fadc477001 | ||
|
|
e9d680e8a4 | ||
|
|
64f1d80b26 | ||
|
|
34bbfa0e15 | ||
|
|
bc4182b9d2 | ||
|
|
c7840966e2 | ||
|
|
3110e8f810 | ||
|
|
8d0a4082a4 | ||
|
|
ef8ec98489 | ||
|
|
b018e2d3ad | ||
|
|
8cf7f530e1 | ||
|
|
b29c02dd34 | ||
|
|
44ef0408f3 | ||
|
|
09a3b7d5e1 | ||
|
|
26a79fec25 | ||
|
|
4442e893c3 | ||
|
|
890982a811 | ||
|
|
b3cbde5cf3 | ||
|
|
642d2e8d51 | ||
|
|
c9e194ec6a | ||
|
|
c4ea17692f | ||
|
|
d89ccf2250 | ||
|
|
82bde57868 | ||
|
|
6ebe97c704 | ||
|
|
5b6608ad84 | ||
|
|
5b5f496436 | ||
|
|
764809753a | ||
|
|
a071f604e3 | ||
|
|
eab47b9131 | ||
|
|
448838dea8 | ||
|
|
4197f30278 | ||
|
|
2826c7a1c6 | ||
|
|
8f12e8a114 | ||
|
|
93284281de | ||
|
|
ed00612cf7 | ||
|
|
23197151c2 | ||
|
|
cdbe2fffd9 | ||
|
|
c9e1e6d3bd | ||
|
|
0151a9e1a3 | ||
|
|
166f5eaa66 | ||
|
|
62ebd51e11 | ||
|
|
e8fd2d6147 | ||
|
|
a634e03177 | ||
|
|
9195a1c026 | ||
|
|
daed8b0f90 | ||
|
|
8d054898af | ||
|
|
8a128d8dc6 | ||
|
|
ab66e3a24e | ||
|
|
75a128e7ee | ||
|
|
870797c16c | ||
|
|
bf508bfd77 | ||
|
|
5ecc13729a | ||
|
|
316c0fa37b | ||
|
|
58607b92df | ||
|
|
b7f8e1360f | ||
|
|
da50a1eefb | ||
|
|
063481faa4 | ||
|
|
6c12f9e0d9 | ||
|
|
017a0a6c86 | ||
|
|
725b23e37a | ||
|
|
b3d3f40115 | ||
|
|
8a6509ffeb | ||
|
|
01e756481f | ||
|
|
d5db4f0b93 | ||
|
|
b0cce95286 | ||
|
|
8f2da86aa5 | ||
|
|
d094026b3b | ||
|
|
95782b7b47 | ||
|
|
0e13b7f0c6 | ||
|
|
c5abd7a302 | ||
|
|
52db1b83a0 | ||
|
|
4d882d9b58 | ||
|
|
5a50958f28 | ||
|
|
d787f1b3de | ||
|
|
b12149be7c | ||
|
|
9089107a8d | ||
|
|
bedecc03f4 | ||
|
|
f6cd65190f | ||
|
|
698ec8172c | ||
|
|
9273e8ddae | ||
|
|
3e157a2331 | ||
|
|
2916753409 | ||
|
|
c8cf954e6e | ||
|
|
0506b40a39 | ||
|
|
27a2d8af14 | ||
|
|
2ed1b378cb | ||
|
|
c0940a194e | ||
|
|
f1647d9e19 | ||
|
|
5c759d48c7 | ||
|
|
4dab31f1c8 | ||
|
|
137ffec3f6 | ||
|
|
0c70a99b56 | ||
|
|
23e3c7d6ec | ||
|
|
f7ad9a7e47 | ||
|
|
a34cc6124c | ||
|
|
ba58e077eb | ||
|
|
a4097ae6f9 | ||
|
|
4e84989d8f | ||
|
|
426b6905da | ||
|
|
48c2aa296a | ||
|
|
357546a2aa | ||
|
|
aa10ccba71 | ||
|
|
6eccb474c7 | ||
|
|
97a472f2fb | ||
|
|
2b135d0e9e | ||
|
|
7cc84cd6af | ||
|
|
8f4046c71a | ||
|
|
229ae03631 | ||
|
|
355fb4ac67 | ||
|
|
aa8e5776eb | ||
|
|
17dfa267d5 | ||
|
|
48ebd728b3 | ||
|
|
524ede52d2 | ||
|
|
97d9386e3f | ||
|
|
bb8f6b376d | ||
|
|
b70fba5b09 | ||
|
|
87a44ec468 | ||
|
|
20825e4114 | ||
|
|
39d4095a4c | ||
|
|
39e8509452 | ||
|
|
da9b1baa6e | ||
|
|
9211905ff1 | ||
|
|
2b8a565f89 | ||
|
|
b564d4a088 | ||
|
|
5c9372372c | ||
|
|
a7256f630c | ||
|
|
4be32d1f73 | ||
|
|
8adc586973 | ||
|
|
4963a1eea8 | ||
|
|
ab96676e36 | ||
|
|
47dc16d243 | ||
|
|
80aad5a42c | ||
|
|
c94fcd1094 | ||
|
|
7aa67f324c | ||
|
|
e90e0015ea | ||
|
|
9ff3592e01 | ||
|
|
806d858c45 | ||
|
|
f4923468dc | ||
|
|
ef736f0d1c | ||
|
|
adc63e6882 | ||
|
|
d43ea268b0 | ||
|
|
32db5ba0e1 | ||
|
|
8d3fec08e5 | ||
|
|
654f8aeb61 | ||
|
|
ab63a5f183 | ||
|
|
64767c52fe | ||
|
|
dcb9381138 | ||
|
|
a68f28e5cc | ||
|
|
5057502e8d | ||
|
|
85a1d814a7 | ||
|
|
b463249729 | ||
|
|
e7b0b49c82 | ||
|
|
82a0ac3d1e | ||
|
|
edd69cb7d4 | ||
|
|
cd069fd436 | ||
|
|
38445f63f0 | ||
|
|
34935d6558 | ||
|
|
dbe88f6e0e | ||
|
|
3492c429b9 | ||
|
|
e94decea39 | ||
|
|
3a995305c0 | ||
|
|
82afc75350 | ||
|
|
383306e17e | ||
|
|
8957ad7fc3 | ||
|
|
0275ab0108 | ||
|
|
63ed8d6499 | ||
|
|
fe125d59b9 | ||
|
|
58b14b7ccf | ||
|
|
2bf8e8b2c7 | ||
|
|
01186e3aff | ||
|
|
a4062cb44a | ||
|
|
1a30b9656f | ||
|
|
e5b1208bd8 | ||
|
|
e10c208fbe | ||
|
|
84355bb447 | ||
|
|
f91927569c | ||
|
|
3587054d32 | ||
|
|
0ca5c07135 | ||
|
|
dbd626054f | ||
|
|
f19b9ed4f8 | ||
|
|
505a5d617b | ||
|
|
36e099ac22 | ||
|
|
09d494d174 | ||
|
|
9794d329d3 | ||
|
|
5c4c833ddd | ||
|
|
f3849627fc | ||
|
|
770f862832 | ||
|
|
0fd602eb43 | ||
|
|
d1eb86581c | ||
|
|
1732e90d52 | ||
|
|
2096f971cd | ||
|
|
f848d35758 | ||
|
|
ada4061a45 | ||
|
|
cdb803b9a4 | ||
|
|
dbe63e7234 | ||
|
|
0cfaeedf03 | ||
|
|
d8d78d73f9 | ||
|
|
19a0276142 | ||
|
|
9ae2595bfd | ||
|
|
65a58c3b03 | ||
|
|
51eba66528 | ||
|
|
91502193ec | ||
|
|
c860dac233 | ||
|
|
87d4fc0560 | ||
|
|
b980a47879 | ||
|
|
e188daab91 | ||
|
|
fc21d1cae3 | ||
|
|
19a9b50aab | ||
|
|
7b86a2015f | ||
|
|
33bfda9879 | ||
|
|
399841b6e0 | ||
|
|
f31b1274bf | ||
|
|
fbb3a2e6f0 | ||
|
|
2769188d30 | ||
|
|
5d3a64d747 | ||
|
|
601d9ba36d | ||
|
|
3518ff7663 | ||
|
|
0d51e4b4b7 | ||
|
|
8c13c1e82a | ||
|
|
9665928860 | ||
|
|
25cdf9b762 | ||
|
|
ad7839ea4c | ||
|
|
2e20effb43 | ||
|
|
221b066900 | ||
|
|
6c4391dda5 | ||
|
|
ac2bc3af7c | ||
|
|
e8d5dec380 | ||
|
|
db115c468a | ||
|
|
150df382f8 | ||
|
|
19d2a0fb35 | ||
|
|
ac24fd27e4 | ||
|
|
d35e7d3eb1 | ||
|
|
8d1d40cc7a | ||
|
|
a2a46c7c67 | ||
|
|
4fbffdf617 | ||
|
|
5fd6664c4b | ||
|
|
3aabb940f5 | ||
|
|
3ebf54f367 | ||
|
|
582b4861a9 | ||
|
|
a943cee45d | ||
|
|
6742a1b7f9 | ||
|
|
bf873a1d85 | ||
|
|
d9892ada7f | ||
|
|
560905154c | ||
|
|
267173c7e8 | ||
|
|
c55b15f705 | ||
|
|
24ccc9c457 | ||
|
|
121bba3464 | ||
|
|
02bd8dfeff | ||
|
|
ee5bf842eb | ||
|
|
891116a6c2 | ||
|
|
dbe217828d | ||
|
|
b8ae12a109 | ||
|
|
759ad4cc96 | ||
|
|
4d4b95a41d | ||
|
|
046c7f84bc | ||
|
|
01ff27709d | ||
|
|
1078a546fe | ||
|
|
a8763abfb7 | ||
|
|
bb797ded7d | ||
|
|
b6b9923dc3 | ||
|
|
79c647d486 | ||
|
|
00b24c917e | ||
|
|
32b2e79b62 | ||
|
|
5aa6b9a84b | ||
|
|
8c5a0b6f88 | ||
|
|
b0cf9bcece | ||
|
|
31f32421a3 | ||
|
|
eacbb3551c | ||
|
|
4b5ca6bc77 | ||
|
|
452b82162b | ||
|
|
aa0e375508 | ||
|
|
6a0b8a733b | ||
|
|
63a7fbc5fd | ||
|
|
3a369ddec3 | ||
|
|
0915bf7d67 | ||
|
|
9897a2b80a | ||
|
|
819507c5e8 | ||
|
|
45b2b382cc | ||
|
|
af4dfd9327 | ||
|
|
21cfe9f680 | ||
|
|
6991ba1387 | ||
|
|
452dca4301 | ||
|
|
852210e108 | ||
|
|
f6c36f75e3 | ||
|
|
a3c46c0d31 | ||
|
|
f78479d7ce | ||
|
|
cfc13fbd58 | ||
|
|
9a093e4b51 | ||
|
|
4cc2b27f1d | ||
|
|
39bfa108b5 | ||
|
|
268d29f05c | ||
|
|
b6ccf12551 | ||
|
|
fbd8725c07 | ||
|
|
2505af8522 | ||
|
|
d45d414c93 | ||
|
|
d8000c9248 | ||
|
|
e3def2dd49 | ||
|
|
e356949d3f | ||
|
|
4bf18da2b0 | ||
|
|
8bd5645dd4 | ||
|
|
1f6fe59def | ||
|
|
418f67086b | ||
|
|
13cff94b1a | ||
|
|
7421bf681b | ||
|
|
db57d38d92 | ||
|
|
5ff00b0c5d | ||
|
|
75ed7aaa06 | ||
|
|
a411c415a8 | ||
|
|
e613cbc448 | ||
|
|
5099f104bc | ||
|
|
b47a4240ff | ||
|
|
a2ed4266aa | ||
|
|
324715ee8b | ||
|
|
9f5a625730 | ||
|
|
d3f13c71ae | ||
|
|
f7caca5f94 | ||
|
|
0abd2a644e | ||
|
|
104f23da90 | ||
|
|
37034045ae | ||
|
|
aca034fdfe | ||
|
|
b3741f7016 | ||
|
|
3012684469 | ||
|
|
335802f4dd | ||
|
|
f741630572 | ||
|
|
8c6545bf9d | ||
|
|
e21dbed8c8 | ||
|
|
160d6a6552 | ||
|
|
b443b5e800 | ||
|
|
58572a6eaa | ||
|
|
e423d20a8d | ||
|
|
fcb8be2fb9 | ||
|
|
1faf53a3af | ||
|
|
fd58d3267e | ||
|
|
dc76bcc433 | ||
|
|
21e6a36cf1 | ||
|
|
98f3a7066e | ||
|
|
4d88054d35 | ||
|
|
d2ae7af487 | ||
|
|
139668c1d1 | ||
|
|
1825105d8f | ||
|
|
3e25c350f2 | ||
|
|
4b7307accb | ||
|
|
631591ce79 | ||
|
|
07880d43d2 | ||
|
|
7b39dd8b28 | ||
|
|
d0800ab917 | ||
|
|
99d521569d | ||
|
|
156feff5b1 | ||
|
|
523aeec544 | ||
|
|
64e1c7df75 | ||
|
|
da128e725d | ||
|
|
d8ecbde9bd | ||
|
|
c9bea8e646 | ||
|
|
b05b8673cd | ||
|
|
b831ffc1b3 | ||
|
|
6a1c62731b | ||
|
|
3c656cfbc6 | ||
|
|
cb76b08e31 | ||
|
|
4cfab0a893 | ||
|
|
c9950b3cb2 | ||
|
|
5cf8dc4f07 | ||
|
|
383212034c | ||
|
|
0779697da6 | ||
|
|
63f6a497cb | ||
|
|
e20171e7dd | ||
|
|
47780e984c | ||
|
|
076f81f130 | ||
|
|
e255eb6124 | ||
|
|
99a6dc0267 | ||
|
|
03af6235a9 | ||
|
|
f9b2a33732 | ||
|
|
52afcb3a12 | ||
|
|
1d32313a30 | ||
|
|
4acb870959 | ||
|
|
3283f55e7e | ||
|
|
ddb32a3614 | ||
|
|
7c8a1da8fe | ||
|
|
7c4af82da4 | ||
|
|
71f1dcf39a | ||
|
|
fefe97a1db | ||
|
|
639f8e70d2 | ||
|
|
ef54e4ffbc | ||
|
|
4e69ba3bbe | ||
|
|
4d9e258a1e | ||
|
|
787c319e87 | ||
|
|
fbbb6f2611 | ||
|
|
05b1c8101f | ||
|
|
b179424161 | ||
|
|
6c67618624 | ||
|
|
ea63a8401e | ||
|
|
7a419282c8 | ||
|
|
0c9b138f5e | ||
|
|
2f1fc3f359 | ||
|
|
4059a3e8ee | ||
|
|
2e5e9d736b | ||
|
|
04518b52c0 | ||
|
|
c48fcaa8c3 | ||
|
|
1e5689e054 | ||
|
|
c1486c47ee | ||
|
|
619da70070 | ||
|
|
0125547215 | ||
|
|
b0bc7c3d99 | ||
|
|
10a83a6395 | ||
|
|
0ebac0b896 | ||
|
|
43203926b8 | ||
|
|
8e7fa7e233 | ||
|
|
3621ea0cb6 | ||
|
|
b49d55584e | ||
|
|
8bc3b415c9 | ||
|
|
b96fbd913e | ||
|
|
cc89830b2a | ||
|
|
c7a422ccdd | ||
|
|
fbc79c34c9 | ||
|
|
589a7b59c6 | ||
|
|
c4c444d378 | ||
|
|
4662b058e8 | ||
|
|
332512853e | ||
|
|
30d1662128 | ||
|
|
3453b977b8 | ||
|
|
5b2cea8eda | ||
|
|
cd03d96b34 | ||
|
|
9b34762d97 | ||
|
|
0da4fd9d11 | ||
|
|
3e49206935 | ||
|
|
63fbc8ce18 | ||
|
|
7f189f4d5f | ||
|
|
f3c1cbbabf | ||
|
|
443465a805 | ||
|
|
87053d9317 | ||
|
|
e12c60fcab | ||
|
|
e7c4801c79 | ||
|
|
7a99f0b5a2 | ||
|
|
708ba8902a | ||
|
|
e59c872b3d | ||
|
|
c3971754bf | ||
|
|
bc3fa71234 | ||
|
|
137baa0a07 | ||
|
|
b3e26de862 | ||
|
|
98093e604a | ||
|
|
a7e45d47cd | ||
|
|
11f524c125 | ||
|
|
42329e0258 | ||
|
|
bf62c3b21a | ||
|
|
df938d6ee8 | ||
|
|
a600588c20 | ||
|
|
7e6fb7e874 | ||
|
|
6af7a5c589 | ||
|
|
dd679a6cdb | ||
|
|
5d4f4f421c | ||
|
|
742caa5dd8 | ||
|
|
cc7459e403 | ||
|
|
40fbb61a1b | ||
|
|
197704c630 | ||
|
|
f00b9f2727 | ||
|
|
ffc48b16d4 | ||
|
|
a6c16ab08d | ||
|
|
bedff2ca79 | ||
|
|
7f06ad40c5 | ||
|
|
e772dc2b85 | ||
|
|
5c8e49a0e3 | ||
|
|
4463671284 | ||
|
|
101b6fe767 | ||
|
|
714421c2da | ||
|
|
4421ef126f | ||
|
|
b67806ae9a | ||
|
|
6d772a30c0 | ||
|
|
6959663646 | ||
|
|
123ad20e9b | ||
|
|
b3d89a1075 | ||
|
|
da09431be9 | ||
|
|
7352cb403c | ||
|
|
2b799cbbf0 | ||
|
|
12c33c1fe6 | ||
|
|
ab932ffaa5 | ||
|
|
e717c51aa1 | ||
|
|
53753f0455 | ||
|
|
32dd298351 | ||
|
|
e30e650a77 | ||
|
|
e544b940f1 | ||
|
|
d0b77d9fa0 | ||
|
|
2c6aae863a | ||
|
|
93909a2dd3 | ||
|
|
3bcb3c3666 | ||
|
|
4873fce791 | ||
|
|
e6d5477168 | ||
|
|
643bdf31d5 | ||
|
|
d5c6bb9740 | ||
|
|
1452bb4ca4 | ||
|
|
defb0fac2c | ||
|
|
9954779739 | ||
|
|
31b28ade01 | ||
|
|
8eb505fbba | ||
|
|
209381f06f | ||
|
|
a2fe3d2ad0 | ||
|
|
bb67d31739 | ||
|
|
54f0d9d0e5 | ||
|
|
1fa41af918 | ||
|
|
491e367f7c | ||
|
|
1cefe21d2a | ||
|
|
084b58a50e | ||
|
|
1f63f3331f | ||
|
|
daceb9963f | ||
|
|
8e804c9fa1 | ||
|
|
9d04e04bc0 | ||
|
|
2a1ad1f5d9 | ||
|
|
34e0d9c0b6 | ||
|
|
c886f08525 | ||
|
|
d25459d815 | ||
|
|
1fcbdef994 | ||
|
|
ff478781ad | ||
|
|
7bcc60e996 | ||
|
|
f8a31f29aa | ||
|
|
95e4a60a83 | ||
|
|
b20c8389f3 | ||
|
|
8bb6eca915 | ||
|
|
28e656f882 | ||
|
|
1c7774e35b | ||
|
|
431ee839a0 | ||
|
|
9a11567f73 | ||
|
|
181abde2ff | ||
|
|
3c3da655b0 | ||
|
|
dc378e8d60 | ||
|
|
b7daa7c702 | ||
|
|
61d0cc39fd | ||
|
|
6723c72fa5 | ||
|
|
f8863f4d00 | ||
|
|
32da693b91 | ||
|
|
6bb3f27f6c | ||
|
|
e1d8a356b0 | ||
|
|
ad2e47dc20 | ||
|
|
a9e56ee460 | ||
|
|
af247a6cbd | ||
|
|
47ce39c46f | ||
|
|
f2ab6d08c4 | ||
|
|
9072a4e5ee | ||
|
|
041e7c177c | ||
|
|
6fc7028031 | ||
|
|
89f682f081 | ||
|
|
1ef68e0612 | ||
|
|
dd53e5c96a | ||
|
|
e02a035ab4 | ||
|
|
fb67ee5445 | ||
|
|
2dc79b3bd0 | ||
|
|
6ecdecbdcc | ||
|
|
122678cc09 | ||
|
|
7a72d255d8 | ||
|
|
4160d904da | ||
|
|
24c61147b8 | ||
|
|
c0580eaf4b | ||
|
|
5586ad5f8a | ||
|
|
e0f4f428fc | ||
|
|
f75c48323c | ||
|
|
03bc1f3141 | ||
|
|
9237e95f11 | ||
|
|
d96af8bacd | ||
|
|
9c5b5ff823 | ||
|
|
1d7090b8ac | ||
|
|
e2d689ff2f | ||
|
|
7ba2b13870 | ||
|
|
9d992b32e4 | ||
|
|
00c4527a1b | ||
|
|
5bab5a7378 | ||
|
|
3f5ac384cc | ||
|
|
d1e23b7c71 | ||
|
|
95fdc66e7d | ||
|
|
fb6e2927f7 | ||
|
|
3372fd8df8 | ||
|
|
97e08fc804 | ||
|
|
e92b7dfd74 | ||
|
|
b92fa78a1e | ||
|
|
8e0d5076d6 | ||
|
|
d6b6d5976d | ||
|
|
36f58a34b4 | ||
|
|
d79dafc577 | ||
|
|
4e84431df3 | ||
|
|
916cfee08d | ||
|
|
e73d4752df | ||
|
|
fa27bc832f | ||
|
|
f0f7a30d9f | ||
|
|
6f4226fde1 | ||
|
|
9c3f34890f | ||
|
|
04e2fe0bff | ||
|
|
83660ec016 | ||
|
|
e38d2078cc | ||
|
|
895c1f132f | ||
|
|
1e3abf96b5 | ||
|
|
749c010a0d | ||
|
|
c7e82965b1 | ||
|
|
5673c5f267 | ||
|
|
7f1252d364 | ||
|
|
6484dc9008 | ||
|
|
5c9e526f0e | ||
|
|
f11414a424 | ||
|
|
6e5332f716 | ||
|
|
a8a7f67d81 | ||
|
|
3b943c1582 | ||
|
|
2182a1cd2c | ||
|
|
0613062fc8 | ||
|
|
87474e52d7 | ||
|
|
c55a1d9012 | ||
|
|
a2fee6bdd3 | ||
|
|
a3d11e8fef | ||
|
|
37edbd8c18 | ||
|
|
ccdd1df039 | ||
|
|
72fa01f62d | ||
|
|
d061419452 | ||
|
|
8f993a6200 | ||
|
|
7e73f57556 | ||
|
|
d62dad5575 | ||
|
|
152af28a34 | ||
|
|
31c14ea78f | ||
|
|
f2ea78d0e4 | ||
|
|
7868ef8229 | ||
|
|
9104ac02f7 | ||
|
|
394312b9c2 | ||
|
|
241c404573 | ||
|
|
9efca40dae | ||
|
|
584286cfd9 | ||
|
|
d0b78225a1 | ||
|
|
5e01713803 | ||
|
|
84678c6448 | ||
|
|
569c977408 | ||
|
|
b46f220931 | ||
|
|
6e4b84a60d | ||
|
|
f11eb41383 | ||
|
|
389907ce65 | ||
|
|
e95a6086fc | ||
|
|
dd08582f81 | ||
|
|
ab44824e07 | ||
|
|
18d6a11c04 | ||
|
|
d6cf4d5b0b | ||
|
|
10b52ac4e8 | ||
|
|
27fdd1b6e6 | ||
|
|
51cd5ffd91 | ||
|
|
f1a4e5d4d3 | ||
|
|
28ff62e7b1 | ||
|
|
6ea4479064 | ||
|
|
680f4cdd61 | ||
|
|
63757d6a7a | ||
|
|
dcaecde844 | ||
|
|
0c86874677 | ||
|
|
76ec9122c0 | ||
|
|
e94a10023d | ||
|
|
2f54aa888a | ||
|
|
21acdee0a0 | ||
|
|
89aca349cf | ||
|
|
b4a6b16227 | ||
|
|
8c28250bb3 | ||
|
|
4e9d365407 | ||
|
|
9ad615c2a4 | ||
|
|
4262f5b0de | ||
|
|
c80ff146d2 | ||
|
|
368e9ab4d8 | ||
|
|
2f51c22141 | ||
|
|
8a0a2523ca | ||
|
|
2285bba66e | ||
|
|
d3a1026ae3 | ||
|
|
1ffe027d8a | ||
|
|
e59e18251b | ||
|
|
2ebf2fbc82 | ||
|
|
d3e43437a0 | ||
|
|
e02868bb1a | ||
|
|
422c763a55 | ||
|
|
1d20cedf03 | ||
|
|
4d4cf0f2f9 | ||
|
|
a8ac9b1fac | ||
|
|
78b2a28fb6 | ||
|
|
aa386d135b | ||
|
|
822803d9d6 | ||
|
|
77afd37c2e | ||
|
|
d99d132cdf | ||
|
|
fdc8bd8ed9 | ||
|
|
13b55c6e68 | ||
|
|
4192cfb092 | ||
|
|
a2f03636a5 | ||
|
|
a14aada945 | ||
|
|
44aff769a3 | ||
|
|
afc30e314f | ||
|
|
d179b3aae4 | ||
|
|
4b2af10b04 | ||
|
|
7de790fbf2 | ||
|
|
c313762ba0 | ||
|
|
d85f09ac51 | ||
|
|
54eb1419a8 | ||
|
|
7108921691 | ||
|
|
80a445c7ae | ||
|
|
12059eb4ca | ||
|
|
9df94103ac | ||
|
|
00a9f654a3 | ||
|
|
e96fd677ee | ||
|
|
546e033081 | ||
|
|
fa5b616a10 | ||
|
|
080af01715 | ||
|
|
c5099e9025 | ||
|
|
e190249732 | ||
|
|
72f5ec3725 | ||
|
|
d967752833 | ||
|
|
fffa06f0b1 | ||
|
|
e9d43b9388 | ||
|
|
c71d6ddc3b | ||
|
|
5f6f6a95a2 | ||
|
|
8ab74ef1bb | ||
|
|
cf84f1af68 | ||
|
|
cf3e1a07c1 | ||
|
|
74801e9004 | ||
|
|
447826ab40 | ||
|
|
2ab7e3da71 | ||
|
|
91fa770196 | ||
|
|
8563e46ade | ||
|
|
6fb07f0b50 | ||
|
|
6020c760b5 | ||
|
|
e428707e07 | ||
|
|
1dcf0a4cbd | ||
|
|
51949f3121 | ||
|
|
8365c8f954 | ||
|
|
7f20425c98 | ||
|
|
59b6267b2f | ||
|
|
58597c29d3 | ||
|
|
0795e55f0e | ||
|
|
42bedbc3d3 | ||
|
|
33052018a2 | ||
|
|
c5869db080 | ||
|
|
467dec4edf | ||
|
|
4a455a053a | ||
|
|
34b81abd9c | ||
|
|
27ba28ef76 | ||
|
|
6d417186cb | ||
|
|
d009267801 | ||
|
|
2828fc6d66 | ||
|
|
c51d6cc9d3 | ||
|
|
f7a2442fac | ||
|
|
f2a8d39f42 | ||
|
|
edc12e416d | ||
|
|
9dae07784b | ||
|
|
c414512f19 | ||
|
|
b1d693786c | ||
|
|
b228923446 | ||
|
|
53bf778497 | ||
|
|
cbc1614b84 | ||
|
|
c350fbef7f | ||
|
|
1a84d8f674 | ||
|
|
ae8e2106bb | ||
|
|
76cee17417 | ||
|
|
0dbd12e295 | ||
|
|
190e6be800 | ||
|
|
bda7ec94df | ||
|
|
1468047081 | ||
|
|
7012c86336 | ||
|
|
98699a0ab5 | ||
|
|
21fba832d1 | ||
|
|
c81148a0cc | ||
|
|
2e57989aec | ||
|
|
a60e51f44d | ||
|
|
01768d7759 | ||
|
|
3291ffbe09 | ||
|
|
4225567303 | ||
|
|
1a5fd2ccb2 | ||
|
|
0869fd168f | ||
|
|
cf82b6e127 | ||
|
|
a0ba65944f | ||
|
|
31a7affb74 | ||
|
|
9f85f8ed29 | ||
|
|
816cc08a8f | ||
|
|
8c46108a85 | ||
|
|
c5608869c0 | ||
|
|
9537ff4762 | ||
|
|
7c4a5464f6 | ||
|
|
a3b557222a | ||
|
|
094b9dc474 | ||
|
|
8935a248f6 | ||
|
|
b0aeeb53b1 | ||
|
|
2582c20e2a | ||
|
|
7468d3cddf | ||
|
|
1c066548b4 | ||
|
|
fe049c286f | ||
|
|
2db5d83023 | ||
|
|
dab7517622 | ||
|
|
f21abdd1f0 | ||
|
|
4b7248fc46 | ||
|
|
5dbc83fabc | ||
|
|
4b4ba85313 | ||
|
|
dc94a03f39 | ||
|
|
6a1b94529b | ||
|
|
d74c0f581b | ||
|
|
c413988ae0 | ||
|
|
9ee5eadac0 | ||
|
|
fd6f6b02ea | ||
|
|
0d99398689 | ||
|
|
00805cb2cd | ||
|
|
05a49702d8 | ||
|
|
98dcf43214 | ||
|
|
27a773d5b2 | ||
|
|
f2f2ecf9d8 | ||
|
|
bfeaac8441 | ||
|
|
0d5f7686d7 | ||
|
|
c1395a8808 | ||
|
|
d4ae1ede39 | ||
|
|
16c16127e7 | ||
|
|
0c5673875b | ||
|
|
4e3c539f5e | ||
|
|
9595e98db8 | ||
|
|
3511e84dc3 | ||
|
|
b3f52e215a | ||
|
|
9e1cfca53f | ||
|
|
c92d4edb89 | ||
|
|
597dc86a9c | ||
|
|
480549e02e | ||
|
|
02503a3248 | ||
|
|
70d469ccd3 | ||
|
|
8440b971f5 | ||
|
|
7408c78dbb | ||
|
|
3281cbc835 | ||
|
|
5097b5a656 | ||
|
|
1a5fe16b22 | ||
|
|
c181fc1cf3 | ||
|
|
48781272ee | ||
|
|
2e3eeaf920 | ||
|
|
ceb25c8350 | ||
|
|
24da7b3ca6 | ||
|
|
fa4a04157f | ||
|
|
068b505d5e | ||
|
|
e297b56390 | ||
|
|
b4c16d1f56 | ||
|
|
1c3d9d7623 | ||
|
|
7a3a9066f9 | ||
|
|
30080b9f4e | ||
|
|
2a2d041dcd | ||
|
|
6247cb8ddd | ||
|
|
1af19c57de | ||
|
|
221370acc5 | ||
|
|
4a6833ef49 | ||
|
|
197c5b2bdf | ||
|
|
1ff083af27 | ||
|
|
635666dec9 | ||
|
|
8590efd229 | ||
|
|
8f266f9652 | ||
|
|
91ee02898a | ||
|
|
e993181628 | ||
|
|
a96ff934ea | ||
|
|
a008d81780 | ||
|
|
f8c4276e69 | ||
|
|
70277591c4 | ||
|
|
564a213ebe | ||
|
|
7c3591f641 | ||
|
|
01971741e0 | ||
|
|
1d0856dcc8 | ||
|
|
6d3af7b97f | ||
|
|
2eb4b34aa7 | ||
|
|
3787aa78cd | ||
|
|
581709df80 | ||
|
|
0c192555bb | ||
|
|
7eaf850489 | ||
|
|
fb1d13d600 | ||
|
|
a8bfdf2d56 | ||
|
|
174fdce7d8 | ||
|
|
4e3ba687a6 | ||
|
|
02eec5c8ca | ||
|
|
efee7c6cce | ||
|
|
d74b0ac81d | ||
|
|
43da8bc747 | ||
|
|
a0761f0c41 | ||
|
|
00ab1905e0 | ||
|
|
01dbc61d1c | ||
|
|
01c28df8b2 | ||
|
|
e1a64b41e8 | ||
|
|
2ad666a484 | ||
|
|
8a70b98d1d | ||
|
|
c9de822930 | ||
|
|
e30dc716b4 | ||
|
|
2080af029b | ||
|
|
cd13c5881b | ||
|
|
bda472f147 | ||
|
|
7fd7c1a539 | ||
|
|
ba7f1e1e3c | ||
|
|
872f308536 | ||
|
|
02ab0c234c | ||
|
|
937f473651 | ||
|
|
17e28036fa | ||
|
|
716f7875a2 | ||
|
|
4002e980d9 | ||
|
|
8b20d16ba8 | ||
|
|
26add7b078 | ||
|
|
93e89215e3 | ||
|
|
d1210f2e0a | ||
|
|
ee702c3139 | ||
|
|
6ca446bded | ||
|
|
9c72a3ae12 | ||
|
|
28acb8d495 | ||
|
|
f2a60f729f | ||
|
|
323b1298f9 | ||
|
|
a756489f86 | ||
|
|
96387aba83 | ||
|
|
e1e59bf0cd | ||
|
|
750649eb64 | ||
|
|
2a3c3d00ea | ||
|
|
8b8fa6c1ae | ||
|
|
3d74a7061e | ||
|
|
cd1dc7ec59 | ||
|
|
db93ea736b | ||
|
|
6cc0087105 | ||
|
|
e4d978da7c | ||
|
|
0d4e0fe647 | ||
|
|
3aaeb44739 | ||
|
|
a0eb8e67c7 | ||
|
|
f0b9199a77 | ||
|
|
5bddf40fd1 | ||
|
|
aca27709df | ||
|
|
13a6a9a690 | ||
|
|
9749fcb425 | ||
|
|
3bf0304e31 | ||
|
|
76cf5e9fc1 | ||
|
|
4de4822219 | ||
|
|
324040032a | ||
|
|
feb9dee4b1 | ||
|
|
5dcdbe64ab | ||
|
|
58e0224061 | ||
|
|
e486d84d6a | ||
|
|
e0b88dc8da | ||
|
|
0e25fdd56e | ||
|
|
c09bad9393 | ||
|
|
7f7f2cd47e | ||
|
|
1bdec55fe1 | ||
|
|
d9bd2b0e14 | ||
|
|
e158a0d59f | ||
|
|
48c3470303 | ||
|
|
8af970061e | ||
|
|
8b959c2060 | ||
|
|
1728bf3f44 | ||
|
|
c692a0c583 | ||
|
|
968e09f0b5 | ||
|
|
dce7d2c4f7 | ||
|
|
458fd86429 | ||
|
|
609757f911 | ||
|
|
7d818b46bc | ||
|
|
9c46acc793 | ||
|
|
33743d347b | ||
|
|
601a61ed31 | ||
|
|
6cb6f47b56 | ||
|
|
59e8fcb409 | ||
|
|
58ef39e2a9 | ||
|
|
3674fb0c7e | ||
|
|
9862cf3204 | ||
|
|
8d9e1118c6 | ||
|
|
11e76cef26 | ||
|
|
62455ade9d | ||
|
|
28c3c3241d | ||
|
|
9efcb7741b | ||
|
|
6cd8f66a76 | ||
|
|
4cc1dde625 | ||
|
|
f3d9a499dd | ||
|
|
46e955897e | ||
|
|
c6bca64499 | ||
|
|
39d57ead1a | ||
|
|
5c6e601026 | ||
|
|
5b4c9e8e43 | ||
|
|
aec6c0861e | ||
|
|
ff36c93733 | ||
|
|
8ca2390fbf | ||
|
|
a5f5d7b33a | ||
|
|
416813452e | ||
|
|
89aa1cad41 | ||
|
|
521708e294 | ||
|
|
1245fe4885 | ||
|
|
1fa40405ea | ||
|
|
bfda4295c9 | ||
|
|
7116ab9c29 | ||
|
|
d3303fd3a0 | ||
|
|
c4c11f1d65 | ||
|
|
e665d4f198 | ||
|
|
c4fb1ad04b | ||
|
|
4a0f5476c0 | ||
|
|
3be8b6dc34 | ||
|
|
0ae59056d1 | ||
|
|
0e61a15438 | ||
|
|
17cce0adf4 | ||
|
|
e2c3611c63 | ||
|
|
8da7a71d9a | ||
|
|
61ccd4f33a | ||
|
|
7d8392bab4 | ||
|
|
3217576743 | ||
|
|
5ec254253f | ||
|
|
884d6ebfd8 | ||
|
|
df74594b8f | ||
|
|
8537aabba4 | ||
|
|
2970f0a06c | ||
|
|
4d5f0dc080 | ||
|
|
cf91f72c5c | ||
|
|
dcb67c32a5 | ||
|
|
d159a1507e | ||
|
|
c0eab31c02 | ||
|
|
6b518dc9e4 | ||
|
|
090198a7d6 | ||
|
|
28f9a2adfa | ||
|
|
4a6d0717a1 | ||
|
|
e9274b2ab2 | ||
|
|
92c1279de6 | ||
|
|
b8fa38a6e8 | ||
|
|
c58f879026 | ||
|
|
b35a3856a2 | ||
|
|
baa26e9e2e | ||
|
|
41b82ce796 | ||
|
|
1c486a4050 | ||
|
|
4741c9a6eb | ||
|
|
a685597b70 | ||
|
|
6b0ac084b8 | ||
|
|
b1c449d11c | ||
|
|
3b025883b6 | ||
|
|
5db1b7622a | ||
|
|
06e5dfd538 | ||
|
|
b59a940057 | ||
|
|
448a24746c | ||
|
|
6989032414 | ||
|
|
327bd5f836 | ||
|
|
13eadcea45 | ||
|
|
d524309e3c | ||
|
|
34fe142894 | ||
|
|
43c707b4e8 | ||
|
|
95ab38e8d6 | ||
|
|
0a7f461d39 | ||
|
|
358281f0fd | ||
|
|
6b3ef9f939 | ||
|
|
4649026312 | ||
|
|
f5b31fcd29 | ||
|
|
ed0b90644a | ||
|
|
49b5db29b3 | ||
|
|
739654bb25 | ||
|
|
5344853344 | ||
|
|
a588d5cd10 | ||
|
|
782686bcf3 | ||
|
|
201eb38479 | ||
|
|
7d13f24288 | ||
|
|
adeda6a5b3 | ||
|
|
e26c436d5c | ||
|
|
c5182d5ca4 | ||
|
|
6812235cfa | ||
|
|
792cc145b1 | ||
|
|
77688c1358 | ||
|
|
b8b3a288c7 | ||
|
|
9742f6e4a2 | ||
|
|
6b6eef5b80 | ||
|
|
2cd976987e | ||
|
|
bb52149a06 | ||
|
|
415b757d4a | ||
|
|
a0bed3e716 | ||
|
|
74f8f5eaa9 | ||
|
|
2b309a8abb | ||
|
|
ae96b8914e | ||
|
|
cfdbea4dc2 | ||
|
|
3a1abb07bf | ||
|
|
0556358560 | ||
|
|
69d1c644d9 | ||
|
|
cc838fad44 | ||
|
|
f237082c37 | ||
|
|
b9da7290e1 | ||
|
|
b809953890 | ||
|
|
539ab947a4 | ||
|
|
a7679db6e9 | ||
|
|
53ac7952c7 | ||
|
|
ca53565240 | ||
|
|
a386841947 | ||
|
|
7e8f379dfb | ||
|
|
a18eea8c23 | ||
|
|
b27aae26c8 | ||
|
|
339d598295 | ||
|
|
976333f654 | ||
|
|
2616e965a7 | ||
|
|
5f5edb4c9b | ||
|
|
68a3020044 | ||
|
|
3aef883f4b | ||
|
|
3ec00d1689 | ||
|
|
9f20c5f95e | ||
|
|
28fc2d0de3 | ||
|
|
cb8a7f01ae | ||
|
|
fb23321514 | ||
|
|
bf659f1977 | ||
|
|
19bdc441d6 | ||
|
|
889200d400 | ||
|
|
c1b23c008a | ||
|
|
e85db8aa3c | ||
|
|
28767b369f | ||
|
|
4793e86f04 | ||
|
|
4cb7386ec6 | ||
|
|
28518aee0a | ||
|
|
825cecc089 | ||
|
|
e0de69f384 | ||
|
|
df44ffbcff | ||
|
|
051ab58c50 | ||
|
|
0d849bf58e | ||
|
|
3073c67861 | ||
|
|
cd1ddcb4f1 | ||
|
|
57ceadb7d8 | ||
|
|
30b04295d2 | ||
|
|
7ad6556623 | ||
|
|
304d1f2712 | ||
|
|
dfa46df474 | ||
|
|
6703b37a93 | ||
|
|
ebc0df6cbe | ||
|
|
64910527de | ||
|
|
491f8b28b4 | ||
|
|
491a9da80b | ||
|
|
a8f679ccb5 | ||
|
|
a9dc2772dd | ||
|
|
a6e9bcb52d | ||
|
|
6d32405d74 | ||
|
|
74dd7fca98 | ||
|
|
c09292efd1 | ||
|
|
00840f75a1 | ||
|
|
9de2e82b8f | ||
|
|
7828e813a8 | ||
|
|
688b2d0da7 | ||
|
|
5be89befef | ||
|
|
aa65a4a1fc | ||
|
|
d051c0fd0f | ||
|
|
a5ba681f8d | ||
|
|
ed12a2e133 | ||
|
|
4ce897d19d | ||
|
|
34f100d6ff | ||
|
|
7087c0508e | ||
|
|
1a64268bb0 | ||
|
|
86c3a3234f | ||
|
|
415ec91c6d | ||
|
|
320f54e205 | ||
|
|
eea524f6bb | ||
|
|
b65442a88c | ||
|
|
39cdba06a6 | ||
|
|
f34ac6272c | ||
|
|
08463e6114 | ||
|
|
b1b9735889 | ||
|
|
cbba8007b2 | ||
|
|
a94a9ce3bf | ||
|
|
7ea3dff49c | ||
|
|
8cf3e1611e | ||
|
|
133f39494e | ||
|
|
0510d06ecf | ||
|
|
4dc98b3c7e | ||
|
|
deb11efa89 | ||
|
|
d394a9f39f | ||
|
|
d97d2a4f7b | ||
|
|
19ed2ed630 | ||
|
|
5790a5d7cf | ||
|
|
d8c0587346 | ||
|
|
aed42a726a | ||
|
|
30bdef9bf5 | ||
|
|
fb1c67219d | ||
|
|
bf6e809abf | ||
|
|
31045e6086 | ||
|
|
c24dc0ec77 | ||
|
|
cf89c030d0 | ||
|
|
105c20146c | ||
|
|
09fabe3021 | ||
|
|
cf92ffab34 | ||
|
|
d771dcbdb9 | ||
|
|
095163bbed | ||
|
|
94c04955c8 | ||
|
|
72d0b73598 | ||
|
|
4c951ea435 | ||
|
|
7f3798e180 | ||
|
|
60bee4b137 | ||
|
|
acc655d35f | ||
|
|
f90dcf663e | ||
|
|
4c2a5045a0 | ||
|
|
e163e02499 | ||
|
|
ffe368afed | ||
|
|
9bc9c6e6c5 | ||
|
|
ef7dcdb49e | ||
|
|
05c568126f | ||
|
|
8cfd915960 | ||
|
|
93458727e8 | ||
|
|
a792c4a159 | ||
|
|
5e34d9e276 | ||
|
|
60fc979332 | ||
|
|
8e0fb9ee2f | ||
|
|
3db2a796ec | ||
|
|
5c5c470671 | ||
|
|
80b04dc505 | ||
|
|
1ed9743ad4 | ||
|
|
a7fba66832 | ||
|
|
79710375e3 | ||
|
|
98fa8d2b51 | ||
|
|
1eeadcd85c | ||
|
|
f9c4014e28 | ||
|
|
1a167b2ea5 | ||
|
|
cacf0cc0ef | ||
|
|
dd81be1b9b | ||
|
|
843d7c1fe3 | ||
|
|
1340c7a792 | ||
|
|
319f211211 | ||
|
|
7663ccf0bd | ||
|
|
3f048bce0f | ||
|
|
dec9726083 | ||
|
|
09973956ae | ||
|
|
ddaa21c750 | ||
|
|
d4614619b4 | ||
|
|
baf39042c8 | ||
|
|
2571e07175 | ||
|
|
53a5728009 | ||
|
|
dea0782c89 | ||
|
|
651a543403 | ||
|
|
738c2692fb | ||
|
|
618f8a43cf | ||
|
|
0a531f732b | ||
|
|
7f95c594c0 | ||
|
|
fad526c63f | ||
|
|
7588aef07c | ||
|
|
81f0f618f7 | ||
|
|
e351baf10f | ||
|
|
39bdedab9c | ||
|
|
bfb064024e | ||
|
|
ce0f2dd868 | ||
|
|
d668600672 | ||
|
|
63f864cdd7 | ||
|
|
c095091853 | ||
|
|
d55168f51f | ||
|
|
305ed41b88 | ||
|
|
ef909f6335 | ||
|
|
c80800a3ee | ||
|
|
9d608135e3 | ||
|
|
8180ed9a68 | ||
|
|
d6556c5246 | ||
|
|
a76d9b4dcf | ||
|
|
f480ef4bbc | ||
|
|
a66ad2e2af | ||
|
|
99f5ed9ecb | ||
|
|
044ccc6dd7 | ||
|
|
f7edf71190 | ||
|
|
3fce6cea27 | ||
|
|
0c9e1ef61b | ||
|
|
d9ad2a74ae | ||
|
|
ce1c83da89 | ||
|
|
75ecb4a81f | ||
|
|
2f5f6baf0f | ||
|
|
d2ef83bc60 | ||
|
|
24371a3954 | ||
|
|
65e8e3ed1f | ||
|
|
4354458cad | ||
|
|
e75f0722e7 | ||
|
|
3adc0dfbaf | ||
|
|
f3669f20a9 | ||
|
|
e5a50d0154 | ||
|
|
6e4d4fc604 | ||
|
|
40e11e053c | ||
|
|
2a850ed051 | ||
|
|
383b917784 | ||
|
|
fa264e4286 | ||
|
|
23b43ff651 | ||
|
|
e7fa39112a | ||
|
|
dfae3f6284 | ||
|
|
52683dafc3 | ||
|
|
f51ca774cf | ||
|
|
482aeaff10 | ||
|
|
3ed61f1ff2 | ||
|
|
56d4a35d05 | ||
|
|
abb60a4d10 | ||
|
|
328846c6e3 | ||
|
|
3afaa8033b | ||
|
|
e1fac40256 | ||
|
|
7cd3b95317 | ||
|
|
93fd6a9160 | ||
|
|
f6a4a5c44d | ||
|
|
bb95c8c45a | ||
|
|
dbf4c3a37c | ||
|
|
f330a87e50 | ||
|
|
f72aa8c840 | ||
|
|
999d0568fa | ||
|
|
7878f54043 | ||
|
|
97db77997f | ||
|
|
3829ac6353 | ||
|
|
1bfc62dcc2 | ||
|
|
e0339993ae | ||
|
|
cb30351403 | ||
|
|
b56d9c8639 | ||
|
|
00d29aa162 | ||
|
|
cfc697a96d | ||
|
|
7928c1727f | ||
|
|
d3ee91ff92 | ||
|
|
a280727248 | ||
|
|
d970882428 | ||
|
|
ece8630c33 | ||
|
|
81ba61df7f | ||
|
|
898a83031c | ||
|
|
f10aaf7e7e | ||
|
|
123c3050dc | ||
|
|
add233c504 |
@@ -5,19 +5,19 @@ steps:
|
||||
entrypoint: 'npm'
|
||||
args: ['install']
|
||||
|
||||
# Step 4: Authenticate for Docker (so we can push images to the artifact registry)
|
||||
# Step 2: Authenticate for Docker (so we can push images to the artifact registry)
|
||||
- name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder'
|
||||
id: 'Authenticate docker'
|
||||
entrypoint: 'npm'
|
||||
args: ['run', 'auth']
|
||||
|
||||
# Step 5: Build workspace packages
|
||||
# Step 3: Build workspace packages
|
||||
- name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder'
|
||||
id: 'Build packages'
|
||||
entrypoint: 'npm'
|
||||
args: ['run', 'build:packages']
|
||||
|
||||
# Step 6: Determine Docker Image Tag
|
||||
# Step 4: Determine Docker Image Tag
|
||||
- name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder'
|
||||
id: 'Determine Docker Image Tag'
|
||||
entrypoint: 'bash'
|
||||
@@ -39,7 +39,7 @@ steps:
|
||||
echo "Determined image tag: $$FINAL_TAG"
|
||||
echo "$$FINAL_TAG" > /workspace/image_tag.txt
|
||||
|
||||
# Step 7: Build sandbox container image
|
||||
# Step 5: Build sandbox container image
|
||||
- name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder'
|
||||
id: 'Build sandbox Docker image'
|
||||
entrypoint: 'bash'
|
||||
@@ -48,7 +48,7 @@ steps:
|
||||
- |
|
||||
export GEMINI_SANDBOX_IMAGE_TAG=$$(cat /workspace/image_tag.txt)
|
||||
echo "Using Docker image tag for build: $$GEMINI_SANDBOX_IMAGE_TAG"
|
||||
npm run build:sandbox
|
||||
npm run build:sandbox -- --output-file /workspace/final_image_uri.txt
|
||||
env:
|
||||
- 'GEMINI_SANDBOX=$_CONTAINER_TOOL'
|
||||
|
||||
|
||||
10
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
10
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,5 +1,5 @@
|
||||
name: Bug Report
|
||||
description: Report a bug to help us improve Gemini CLI
|
||||
description: Report a bug to help us improve Qwen Code
|
||||
labels: ['kind/bug', 'status/need-triage']
|
||||
body:
|
||||
- type: markdown
|
||||
@@ -8,7 +8,7 @@ body:
|
||||
> [!IMPORTANT]
|
||||
> Thanks for taking the time to fill out this bug report!
|
||||
>
|
||||
> Please search **[existing issues](https://github.com/google-gemini/gemini-cli/issues)** to see if an issue already exists for the bug you encountered.
|
||||
> Please search **[existing issues](https://github.com/QwenLM/qwen-code/issues)** to see if an issue already exists for the bug you encountered.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
@@ -29,12 +29,12 @@ body:
|
||||
id: info
|
||||
attributes:
|
||||
label: Client information
|
||||
description: Please paste the full text from the `/about` command run from Gemini CLI. Also include which platform (MacOS, Windows, Linux).
|
||||
description: Please paste the full text from the `/about` command run from Qwen Code. Also include which platform (macOS, Windows, Linux).
|
||||
value: |
|
||||
<details>
|
||||
|
||||
```console
|
||||
$ gemini /about
|
||||
$ qwen /about
|
||||
# paste output here
|
||||
```
|
||||
|
||||
@@ -46,7 +46,7 @@ body:
|
||||
id: login-info
|
||||
attributes:
|
||||
label: Login information
|
||||
description: Describe how you are logging in (e.g., Google Account, API key).
|
||||
description: Describe how you are logging in (e.g., API Config).
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -8,7 +8,7 @@ body:
|
||||
> [!IMPORTANT]
|
||||
> Thanks for taking the time to suggest an enhancement!
|
||||
>
|
||||
> Please search **[existing issues](https://github.com/google-gemini/gemini-cli/issues)** to see if a similar feature has already been requested.
|
||||
> Please search **[existing issues](https://github.com/QwenLM/qwen-code/issues)** to see if a similar feature has already been requested.
|
||||
|
||||
- type: textarea
|
||||
id: feature
|
||||
|
||||
@@ -17,6 +17,9 @@ inputs:
|
||||
node_version:
|
||||
description: 'Node.js version for context in messages'
|
||||
required: true
|
||||
os:
|
||||
description: 'The os for context in messages'
|
||||
required: true
|
||||
github_token:
|
||||
description: 'GitHub token for posting comments'
|
||||
required: true
|
||||
@@ -91,7 +94,7 @@ runs:
|
||||
echo "</details>" >> "$comment_file"
|
||||
echo "" >> "$comment_file"
|
||||
|
||||
echo "_For detailed HTML reports, please see the 'coverage-reports-${{ inputs.node_version }}' artifact from the main CI run._" >> "$comment_file"
|
||||
echo "_For detailed HTML reports, please see the 'coverage-reports-${{ inputs.node_version }}-${{ inputs.os }}' artifact from the main CI run._" >> "$comment_file"
|
||||
|
||||
- name: Post Coverage Comment
|
||||
uses: thollander/actions-comment-pull-request@v3
|
||||
|
||||
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -4,7 +4,7 @@
|
||||
|
||||
## Dive Deeper
|
||||
|
||||
<!-- more thoughts and in depth discussion here -->
|
||||
<!-- more thoughts and in-depth discussion here -->
|
||||
|
||||
## Reviewer Test Plan
|
||||
|
||||
|
||||
2
.github/scripts/pr-triage.sh
vendored
2
.github/scripts/pr-triage.sh
vendored
@@ -24,7 +24,7 @@ process_pr() {
|
||||
ISSUE_NUMBER=$(echo "$PR_BODY" | grep -oE '#[0-9]+' | head -1 | sed 's/#//' 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
# Pattern 2: Closes/Fixes/Resolves patterns (case insensitive)
|
||||
# Pattern 2: Closes/Fixes/Resolves patterns (case-insensitive)
|
||||
if [ -z "$ISSUE_NUMBER" ]; then
|
||||
ISSUE_NUMBER=$(echo "$PR_BODY" | grep -iE '(closes?|fixes?|resolves?) #[0-9]+' | grep -oE '#[0-9]+' | head -1 | sed 's/#//' 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
75
.github/workflows/ci.yml
vendored
75
.github/workflows/ci.yml
vendored
@@ -1,6 +1,6 @@
|
||||
# .github/workflows/ci.yml
|
||||
|
||||
name: Gemini CLI CI
|
||||
name: Qwen Code CI
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -10,22 +10,19 @@ on:
|
||||
merge_group:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and Lint
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # For checkout
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x, 22.x, 24.x]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Set up Node.js ${{ matrix.node-version }}
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -45,24 +42,18 @@ jobs:
|
||||
- name: Run type check
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: build-artifacts-${{ matrix.node-version }}
|
||||
path: |
|
||||
packages/*/dist
|
||||
package-lock.json # Only upload dist and lockfile
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
needs: build # This job depends on the 'build' job
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs: lint
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write
|
||||
pull-requests: write
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x, 22.x, 24.x] # Should match the build job's matrix
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
node-version: [20.x, 22.x, 24.x]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
@@ -73,26 +64,20 @@ jobs:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: build-artifacts-${{ matrix.node-version }}
|
||||
path: . # Download to the root, this will include package-lock.json and packages/*/dist
|
||||
|
||||
# Restore/create package structure for dist folders if necessary.
|
||||
# The download-artifact action with path: . should place them correctly if the
|
||||
# upload paths were relative to the workspace root.
|
||||
# Example: if uploaded `packages/cli/dist`, it will be at `./packages/cli/dist`.
|
||||
- name: Build project
|
||||
run: npm run build
|
||||
|
||||
- name: Install dependencies for testing
|
||||
run: npm ci # Install fresh dependencies using the downloaded package-lock.json
|
||||
|
||||
- name: Run tests and generate reports
|
||||
run: NO_COLOR=true npm run test:ci
|
||||
run: npm run test:ci
|
||||
env:
|
||||
NO_COLOR: true
|
||||
|
||||
- name: Publish Test Report (for non-forks)
|
||||
if: always() && (github.event.pull_request.head.repo.full_name == github.repository)
|
||||
uses: dorny/test-reporter@890a17cecf52a379fc869ab770a71657660be727 # v2
|
||||
uses: dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3 # v2
|
||||
with:
|
||||
name: Test Results (Node ${{ matrix.node-version }})
|
||||
path: packages/*/junit.xml
|
||||
@@ -103,14 +88,14 @@ jobs:
|
||||
if: always() && (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: test-results-fork-${{ matrix.node-version }}
|
||||
name: test-results-fork-${{ matrix.node-version }}-${{ matrix.os }}
|
||||
path: packages/*/junit.xml
|
||||
|
||||
- name: Upload coverage reports
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
if: always()
|
||||
with:
|
||||
name: coverage-reports-${{ matrix.node-version }}
|
||||
name: coverage-reports-${{ matrix.node-version }}-${{ matrix.os }}
|
||||
path: packages/*/coverage
|
||||
|
||||
post_coverage_comment:
|
||||
@@ -124,7 +109,9 @@ jobs:
|
||||
pull-requests: write # For commenting
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22.x] # Reduce noise by only posting the comment once
|
||||
# Reduce noise by only posting the comment once
|
||||
os: [ubuntu-latest]
|
||||
node-version: [22.x]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
@@ -132,7 +119,7 @@ jobs:
|
||||
- name: Download coverage reports artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: coverage-reports-${{ matrix.node-version }}
|
||||
name: coverage-reports-${{ matrix.node-version }}-${{ matrix.os }}
|
||||
path: coverage_artifact # Download to a specific directory
|
||||
|
||||
- name: Post Coverage Comment using Composite Action
|
||||
@@ -143,4 +130,24 @@ jobs:
|
||||
cli_full_text_summary_file: coverage_artifact/cli/coverage/full-text-summary.txt
|
||||
core_full_text_summary_file: coverage_artifact/core/coverage/full-text-summary.txt
|
||||
node_version: ${{ matrix.node-version }}
|
||||
os: ${{ matrix.os }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
codeql:
|
||||
name: CodeQL
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3
|
||||
with:
|
||||
languages: javascript
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3
|
||||
|
||||
35
.github/workflows/e2e.yml
vendored
35
.github/workflows/e2e.yml
vendored
@@ -8,20 +8,21 @@ on:
|
||||
merge_group:
|
||||
|
||||
jobs:
|
||||
e2e-test:
|
||||
name: E2E Test - ${{ matrix.sandbox }}
|
||||
e2e-test-linux:
|
||||
name: E2E Test (Linux) - ${{ matrix.sandbox }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
sandbox: [sandbox:none, sandbox:docker]
|
||||
node-version: [20.x, 22.x, 24.x]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Set up Node.js
|
||||
- name: Set up Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -48,3 +49,29 @@ jobs:
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}
|
||||
run: npm run test:integration:${{ matrix.sandbox }} -- --verbose --keep-output
|
||||
|
||||
e2e-test-macos:
|
||||
name: E2E Test - macOS
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build project
|
||||
run: npm run build
|
||||
|
||||
- name: Run E2E tests
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}
|
||||
run: npm run test:e2e
|
||||
|
||||
125
.github/workflows/gemini-automated-issue-triage.yml
vendored
125
.github/workflows/gemini-automated-issue-triage.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Gemini Automated Issue Triage
|
||||
name: Qwen Automated Issue Triage
|
||||
|
||||
on:
|
||||
issues:
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
jobs:
|
||||
triage-issue:
|
||||
timeout-minutes: 5
|
||||
if: ${{ github.repository == 'google-gemini/gemini-cli' }}
|
||||
if: ${{ github.repository == 'QwenLM/qwen-code' }}
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
@@ -17,22 +17,15 @@ jobs:
|
||||
cancel-in-progress: true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: generate_token
|
||||
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
|
||||
- name: Run Gemini Issue Triage
|
||||
uses: google-gemini/gemini-cli-action@df3f890f003d28c60a2a09d2c29e0126e4d1e2ff
|
||||
- name: Run Qwen Issue Triage
|
||||
uses: QwenLM/qwen-code-action@5fd6818d04d64e87d255ee4d5f77995e32fbf4c2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
with:
|
||||
version: 0.1.8-rc.0
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
OTLP_GCP_WIF_PROVIDER: ${{ secrets.OTLP_GCP_WIF_PROVIDER }}
|
||||
OTLP_GOOGLE_CLOUD_PROJECT: ${{ secrets.OTLP_GOOGLE_CLOUD_PROJECT }}
|
||||
version: 0.0.6
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
settings_json: |
|
||||
{
|
||||
"coreTools": [
|
||||
@@ -40,24 +33,100 @@ jobs:
|
||||
"run_shell_command(gh issue edit)",
|
||||
"run_shell_command(gh issue list)"
|
||||
],
|
||||
"telemetry": {
|
||||
"enabled": true,
|
||||
"target": "gcp"
|
||||
},
|
||||
"sandbox": false
|
||||
}
|
||||
prompt: |
|
||||
You are an issue triage assistant. Analyze the current GitHub issue and apply the most appropriate existing labels.
|
||||
|
||||
You are an issue triage assistant. Analyze the current GitHub issues apply the most appropriate existing labels. Do not remove labels titled help wanted or good first issue.
|
||||
Steps:
|
||||
1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels.
|
||||
2. Review the issue title and body provided in the environment variables.
|
||||
3. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, and priority/*.
|
||||
4. Apply the selected labels to this issue using: `gh issue edit ${{ github.event.issue.number }} --repo ${{ github.repository }} --add-label "label1,label2"`
|
||||
5. If the issue has a "status/need-triage" label, remove it after applying the appropriate labels: `gh issue edit ${{ github.event.issue.number }} --repo ${{ github.repository }} --remove-label "status/need-triage"`
|
||||
|
||||
2. Review the issue title, body and any comments provided in the environment variables.
|
||||
3. Ignore any existing priorities or tags on the issue. Just report your findings.
|
||||
4. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, sub-area/* and priority/*. For area/* and kind/* limit yourself to only the single most applicable label in each case.
|
||||
6. Apply the selected labels to this issue using: `gh issue edit ${{ github.event.issue.number }} --repo ${{ github.repository }} --add-label "label1,label2"`
|
||||
7. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5 for anything more than 6 versions older than the most recent should add the status/need-retesting label
|
||||
8. If you see that the issue doesn’t look like it has sufficient information recommend the status/need-information label
|
||||
9. Use Area definitions mentioned below to help you narrow down issues
|
||||
Guidelines:
|
||||
- Only use labels that already exist in the repository.
|
||||
- Do not add comments or modify the issue content.
|
||||
- Triage only the current issue.
|
||||
- Assign all applicable kind/*, area/*, and priority/* labels based on the issue content.
|
||||
- Apply only one area/ label
|
||||
- Apply only one kind/ label
|
||||
- Apply all applicable sub-area/* and priority/* labels based on the issue content. It's ok to have multiple of these.
|
||||
- Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario.
|
||||
Categorization Guidelines:
|
||||
P0: Critical / Blocker
|
||||
- A P0 bug is a catastrophic failure that demands immediate attention. It represents a complete showstopper for a significant portion of users or for the development process itself.
|
||||
Impact:
|
||||
- Blocks development or testing for the entire team.
|
||||
- Major security vulnerability that could compromise user data or system integrity.
|
||||
- Causes data loss or corruption with no workaround.
|
||||
- Crashes the application or makes a core feature completely unusable for all or most users in a production environment. Will it cause severe quality degration? Is it preventing contributors from contributing to the repository or is it a release blocker?
|
||||
Qualifier: Is the main function of the software broken?
|
||||
Example: The gemini auth login command fails with an unrecoverable error, preventing any user from authenticating and using the rest of the CLI.
|
||||
P1: High
|
||||
- A P1 bug is a serious issue that significantly degrades the user experience or impacts a core feature. While not a complete blocker, it's a major problem that needs a fast resolution. Feature requests are almost never P1.
|
||||
Impact:
|
||||
- A core feature is broken or behaving incorrectly for a large number of users or large number of use cases.
|
||||
- Review the bug details and comments to try figure out if this issue affects a large set of use cases or if it's a narrow set of use cases.
|
||||
- Severe performance degradation making the application frustratingly slow.
|
||||
- No straightforward workaround exists, or the workaround is difficult and non-obvious.
|
||||
Qualifier: Is a key feature unusable or giving very wrong results?
|
||||
Example: The gemini -p "..." command consistently returns a malformed JSON response or an empty result, making the CLI's primary generation feature unreliable.
|
||||
P2: Medium
|
||||
- A P2 bug is a moderately impactful issue. It's a noticeable problem but doesn't prevent the use of the software's main functionality.
|
||||
Impact:
|
||||
- Affects a non-critical feature or a smaller, specific subset of users.
|
||||
- An inconvenient but functional workaround is available and easy to execute.
|
||||
- Noticeable UI/UX problems that don't break functionality but look unprofessional (e.g., elements are misaligned or overlapping).
|
||||
Qualifier: Is it an annoying but non-blocking problem?
|
||||
Example: An error message is unclear or contains a typo, causing user confusion but not halting their workflow.
|
||||
P3: Low
|
||||
- A P3 bug is a minor, low-impact issue that is trivial or cosmetic. It has little to no effect on the overall functionality of the application.
|
||||
Impact:
|
||||
- Minor cosmetic issues like color inconsistencies, typos in documentation, or slight alignment problems on a non-critical page.
|
||||
- An edge-case bug that is very difficult to reproduce and affects a tiny fraction of users.
|
||||
Qualifier: Is it a "nice-to-fix" issue?
|
||||
Example: Spelling mistakes etc.
|
||||
Things you should know:
|
||||
- If users are talking about issues where the model gets downgraded from pro to flash then i want you to categorize that as a performance issue
|
||||
- This product is designed to use different models eg.. using pro, downgrading to flash etc. when users report that they dont expect the model to change those would be categorized as feature requests.
|
||||
Definition of Areas
|
||||
area/ux:
|
||||
- Issues concerning user-facing elements like command usability, interactive features, help docs, and perceived performance.
|
||||
- I am seeing my screen flicker when using Gemini CLI
|
||||
- I am seeing the output malformed
|
||||
- Theme changes aren't taking effect
|
||||
- My keyboard inputs arent' being recognzied
|
||||
area/platform:
|
||||
- Issues related to installation, packaging, OS compatibility (Windows, macOS, Linux), and the underlying CLI framework.
|
||||
area/background: Issues related to long-running background tasks, daemons, and autonomous or proactive agent features.
|
||||
area/models:
|
||||
- i am not getting a response that is reasonable or expected. this can include things like
|
||||
- I am calling a tool and the tool is not performing as expected.
|
||||
- i am expecting a tool to be called and it is not getting called ,
|
||||
- Including experience when using
|
||||
- built-in tools (e.g., web search, code interpreter, read file, writefile, etc..),
|
||||
- Function calling issues should be under this area
|
||||
- i am getting responses from the model that are malformed.
|
||||
- Issues concerning Gemini quality of response and inference,
|
||||
- Issues talking about unnecessary token consumption.
|
||||
- Issues talking about Model getting stuck in a loop be watchful as this could be the root cause for issues that otherwise seem like model performance issues.
|
||||
- Memory compression
|
||||
- unexpected responses,
|
||||
- poor quality of generated code
|
||||
area/tools:
|
||||
- These are primarily issues related to Model Context Protocol
|
||||
- These are issues that mention MCP support
|
||||
- feature requests asking for support for new tools.
|
||||
area/core: Issues with fundamental components like command parsing, configuration management, session state, and the main API client logic. Introducing multi-modality
|
||||
area/contribution: Issues related to improving the developer contribution experience, such as CI/CD pipelines, build scripts, and test automation infrastructure.
|
||||
area/authentication: Issues related to user identity, login flows, API key handling, credential storage, and access token management, unable to sign in selecting wrong authentication path etc..
|
||||
area/security-privacy: Issues concerning vulnerability patching, dependency security, data sanitization, privacy controls, and preventing unauthorized data access.
|
||||
area/extensibility: Issues related to the plugin system, extension APIs, or making the CLI's functionality available in other applications, github actions, ide support etc..
|
||||
area/performance: Issues focused on model performance
|
||||
- Issues with running out of capacity,
|
||||
- 429 errors etc..
|
||||
- could also pertain to latency,
|
||||
- other general software performance like, memory usage, CPU consumption, and algorithmic efficiency.
|
||||
- Switching models from one to the other unexpectedly.
|
||||
|
||||
178
.github/workflows/gemini-scheduled-issue-triage.yml
vendored
178
.github/workflows/gemini-scheduled-issue-triage.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Gemini Scheduled Issue Triage
|
||||
name: Qwen Scheduled Issue Triage
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -8,24 +8,17 @@ on:
|
||||
jobs:
|
||||
triage-issues:
|
||||
timeout-minutes: 10
|
||||
if: ${{ github.repository == 'google-gemini/gemini-cli' }}
|
||||
if: ${{ github.repository == 'QwenLM/qwen-code' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
issues: write
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: generate_token
|
||||
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
|
||||
- name: Find untriaged issues
|
||||
id: find_issues
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
echo "🔍 Finding issues without labels..."
|
||||
NO_LABEL_ISSUES=$(gh issue list --repo ${{ github.repository }} --search "is:open is:issue no:label" --json number,title,body)
|
||||
@@ -41,60 +34,147 @@ jobs:
|
||||
|
||||
echo "✅ Found $(echo "$ISSUES" | jq 'length') issues to triage! 🎯"
|
||||
|
||||
- name: Run Gemini Issue Triage
|
||||
- name: Run Qwen Issue Triage
|
||||
if: steps.find_issues.outputs.issues_to_triage != '[]'
|
||||
uses: google-gemini/gemini-cli-action@df3f890f003d28c60a2a09d2c29e0126e4d1e2ff
|
||||
uses: QwenLM/qwen-code-action@5fd6818d04d64e87d255ee4d5f77995e32fbf4c2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUES_TO_TRIAGE: ${{ steps.find_issues.outputs.issues_to_triage }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
with:
|
||||
version: 0.1.8-rc.0
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
OTLP_GCP_WIF_PROVIDER: ${{ secrets.OTLP_GCP_WIF_PROVIDER }}
|
||||
OTLP_GOOGLE_CLOUD_PROJECT: ${{ secrets.OTLP_GOOGLE_CLOUD_PROJECT }}
|
||||
version: 0.0.6
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}
|
||||
settings_json: |
|
||||
{
|
||||
"coreTools": [
|
||||
"run_shell_command(echo)",
|
||||
"run_shell_command(gh label list)",
|
||||
"run_shell_command(gh issue edit)",
|
||||
"run_shell_command(gh issue list)"
|
||||
"run_shell_command(gh issue list)",
|
||||
"run_shell_command(gh issue view)"
|
||||
],
|
||||
"telemetry": {
|
||||
"enabled": true,
|
||||
"target": "gcp"
|
||||
},
|
||||
"sandbox": false
|
||||
}
|
||||
prompt: |
|
||||
You are an issue triage assistant. Analyze issues and apply appropriate labels ONE AT A TIME.
|
||||
|
||||
Repository: ${{ github.repository }}
|
||||
|
||||
You are an issue triage assistant. Analyze the current GitHub issues apply the most appropriate existing labels.
|
||||
Steps:
|
||||
1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to see available labels
|
||||
1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels.
|
||||
2. Check environment variable for issues to triage: $ISSUES_TO_TRIAGE (JSON array of issues)
|
||||
3. Parse the JSON array from step 2 and for EACH INDIVIDUAL issue, apply appropriate labels using separate commands:
|
||||
- `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --add-label "label1"`
|
||||
- `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --add-label "label2"`
|
||||
- Continue for each label separately
|
||||
|
||||
IMPORTANT: Label each issue individually, one command per issue, one label at a time if needed.
|
||||
|
||||
Guidelines:
|
||||
- Only use existing repository labels from step 1
|
||||
- Do not add comments to issues
|
||||
- Triage each issue independently based on title and body content
|
||||
- Focus on applying: kind/* (bug/enhancement/documentation), area/* (core/cli/testing/windows), and priority/* labels
|
||||
- If an issue has insufficient information, consider applying "status/need-information"
|
||||
- After applying appropriate labels to an issue, remove the "status/need-triage" label if present: `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "status/need-triage"`
|
||||
- Execute one `gh issue edit` command per issue, wait for success before proceeding to the next
|
||||
|
||||
Example triage logic:
|
||||
- Issues with "bug", "error", "broken" → kind/bug
|
||||
- Issues with "feature", "enhancement", "improve" → kind/enhancement
|
||||
- Issues about Windows/performance → area/windows, area/performance
|
||||
- Critical bugs → priority/p0, other bugs → priority/p1, enhancements → priority/p2
|
||||
|
||||
3. Review the issue title, body and any comments provided in the environment variables.
|
||||
4. Ignore any existing priorities or tags on the issue.
|
||||
5. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, sub-area/* and priority/*.
|
||||
6. Get the list of labels already on the issue using `gh issue view ISSUE_NUMBER --repo ${{ github.repository }} --json labels -t '{{range .labels}}{{.name}}{{"\n"}}{{end}}'
|
||||
7. For area/* and kind/* limit yourself to only the single most applicable label in each case.
|
||||
8. Give me a single short paragraph about why you are selecting each label in the process. use the format Issue ID: , Title, Label applied:, Label removed, ovearll explanation
|
||||
9. Parse the JSON array from step 2 and for EACH INDIVIDUAL issue, apply appropriate labels using separate commands:
|
||||
- `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --add-label "label1"`
|
||||
- `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --add-label "label2"`
|
||||
- Continue for each label separately
|
||||
- IMPORTANT: Label each issue individually, one command per issue, one label at a time if needed.
|
||||
- Make sure after you apply labels there is only one area/* and one kind/* label per issue.
|
||||
- To do this look for labels found in step 6 that no longer apply remove them one at a time using
|
||||
- `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "label-name1"`
|
||||
- `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "label-name2"`
|
||||
- IMPORTANT: Remove each label one at a time, one command per issue if needed.
|
||||
10. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5
|
||||
- Anything more than 6 versions older than the most recent should add the status/need-retesting label
|
||||
11. If you see that the issue doesn’t look like it has sufficient information recommend the status/need-information label
|
||||
- After applying appropriate labels to an issue, remove the "status/need-triage" label if present: `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "status/need-triage"`
|
||||
- Execute one `gh issue edit` command per issue, wait for success before proceeding to the next
|
||||
Process each issue sequentially and confirm each labeling operation before moving to the next issue.
|
||||
Guidelines:
|
||||
- Only use labels that already exist in the repository.
|
||||
- Do not add comments or modify the issue content.
|
||||
- Do not remove labels titled help wanted or good first issue.
|
||||
- Triage only the current issue.
|
||||
- Apply only one area/ label
|
||||
- Apply only one kind/ label (Do not apply kind/duplicate or kind/parent-issue)
|
||||
- Apply all applicable sub-area/* and priority/* labels based on the issue content. It's ok to have multiple of these.
|
||||
- Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario.
|
||||
Categorization Guidelines:
|
||||
P0: Critical / Blocker
|
||||
- A P0 bug is a catastrophic failure that demands immediate attention. It represents a complete showstopper for a significant portion of users or for the development process itself.
|
||||
Impact:
|
||||
- Blocks development or testing for the entire team.
|
||||
- Major security vulnerability that could compromise user data or system integrity.
|
||||
- Causes data loss or corruption with no workaround.
|
||||
- Crashes the application or makes a core feature completely unusable for all or most users in a production environment. Will it cause severe quality degration?
|
||||
- Is it preventing contributors from contributing to the repository or is it a release blocker?
|
||||
Qualifier: Is the main function of the software broken?
|
||||
Example: The gemini auth login command fails with an unrecoverable error, preventing any user from authenticating and using the rest of the CLI.
|
||||
P1: High
|
||||
- A P1 bug is a serious issue that significantly degrades the user experience or impacts a core feature. While not a complete blocker, it's a major problem that needs a fast resolution.
|
||||
- Feature requests are almost never P1.
|
||||
Impact:
|
||||
- A core feature is broken or behaving incorrectly for a large number of users or large number of use cases.
|
||||
- Review the bug details and comments to try figure out if this issue affects a large set of use cases or if it's a narrow set of use cases.
|
||||
- Severe performance degradation making the application frustratingly slow.
|
||||
- No straightforward workaround exists, or the workaround is difficult and non-obvious.
|
||||
Qualifier: Is a key feature unusable or giving very wrong results?
|
||||
Example: The gemini -p "..." command consistently returns a malformed JSON response or an empty result, making the CLI's primary generation feature unreliable.
|
||||
P2: Medium
|
||||
- A P2 bug is a moderately impactful issue. It's a noticeable problem but doesn't prevent the use of the software's main functionality.
|
||||
Impact:
|
||||
- Affects a non-critical feature or a smaller, specific subset of users.
|
||||
- An inconvenient but functional workaround is available and easy to execute.
|
||||
- Noticeable UI/UX problems that don't break functionality but look unprofessional (e.g., elements are misaligned or overlapping).
|
||||
Qualifier: Is it an annoying but non-blocking problem?
|
||||
Example: An error message is unclear or contains a typo, causing user confusion but not halting their workflow.
|
||||
P3: Low
|
||||
- A P3 bug is a minor, low-impact issue that is trivial or cosmetic. It has little to no effect on the overall functionality of the application.
|
||||
Impact:
|
||||
- Minor cosmetic issues like color inconsistencies, typos in documentation, or slight alignment problems on a non-critical page.
|
||||
- An edge-case bug that is very difficult to reproduce and affects a tiny fraction of users.
|
||||
Qualifier: Is it a "nice-to-fix" issue?
|
||||
Example: Spelling mistakes etc.
|
||||
Additional Context:
|
||||
- If users are talking about issues where the model gets downgraded from pro to flash then i want you to categorize that as a performance issue
|
||||
- This product is designed to use different models eg.. using pro, downgrading to flash etc.
|
||||
- When users report that they dont expect the model to change those would be categorized as feature requests.
|
||||
Definition of Areas
|
||||
area/ux:
|
||||
- Issues concerning user-facing elements like command usability, interactive features, help docs, and perceived performance.
|
||||
- I am seeing my screen flicker when using Gemini CLI
|
||||
- I am seeing the output malformed
|
||||
- Theme changes aren't taking effect
|
||||
- My keyboard inputs arent' being recognzied
|
||||
area/platform:
|
||||
- Issues related to installation, packaging, OS compatibility (Windows, macOS, Linux), and the underlying CLI framework.
|
||||
area/background: Issues related to long-running background tasks, daemons, and autonomous or proactive agent features.
|
||||
area/models:
|
||||
- i am not getting a response that is reasonable or expected. this can include things like
|
||||
- I am calling a tool and the tool is not performing as expected.
|
||||
- i am expecting a tool to be called and it is not getting called ,
|
||||
- Including experience when using
|
||||
- built-in tools (e.g., web search, code interpreter, read file, writefile, etc..),
|
||||
- Function calling issues should be under this area
|
||||
- i am getting responses from the model that are malformed.
|
||||
- Issues concerning Gemini quality of response and inference,
|
||||
- Issues talking about unnecessary token consumption.
|
||||
- Issues talking about Model getting stuck in a loop be watchful as this could be the root cause for issues that otherwise seem like model performance issues.
|
||||
- Memory compression
|
||||
- unexpected responses,
|
||||
- poor quality of generated code
|
||||
area/tools:
|
||||
- These are primarily issues related to Model Context Protocol
|
||||
- These are issues that mention MCP support
|
||||
- feature requests asking for support for new tools.
|
||||
area/core:
|
||||
- Issues with fundamental components like command parsing, configuration management, session state, and the main API client logic. Introducing multi-modality
|
||||
area/contribution:
|
||||
- Issues related to improving the developer contribution experience, such as CI/CD pipelines, build scripts, and test automation infrastructure.
|
||||
area/authentication:
|
||||
- Issues related to user identity, login flows, API key handling, credential storage, and access token management, unable to sign in selecting wrong authentication path etc..
|
||||
area/security-privacy:
|
||||
- Issues concerning vulnerability patching, dependency security, data sanitization, privacy controls, and preventing unauthorized data access.
|
||||
area/extensibility:
|
||||
- Issues related to the plugin system, extension APIs, or making the CLI's functionality available in other applications, github actions, ide support etc..
|
||||
area/performance:
|
||||
- Issues focused on model performance
|
||||
- Issues with running out of capacity,
|
||||
- 429 errors etc..
|
||||
- could also pertain to latency,
|
||||
- other general software performance like, memory usage, CPU consumption, and algorithmic efficiency.
|
||||
- Switching models from one to the other unexpectedly.
|
||||
|
||||
13
.github/workflows/gemini-scheduled-pr-triage.yml
vendored
13
.github/workflows/gemini-scheduled-pr-triage.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Gemini Scheduled PR Triage 🚀
|
||||
name: Qwen Scheduled PR Triage 🚀
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
jobs:
|
||||
audit-prs:
|
||||
timeout-minutes: 15
|
||||
if: ${{ github.repository == 'google-gemini/gemini-cli' }}
|
||||
if: ${{ github.repository == 'QwenLM/qwen-code' }}
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
@@ -21,16 +21,9 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Generate GitHub App Token
|
||||
id: generate_token
|
||||
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
|
||||
- name: Run PR Triage Script
|
||||
id: run_triage
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: ./.github/scripts/pr-triage.sh
|
||||
|
||||
32
.github/workflows/no-response.yml
vendored
Normal file
32
.github/workflows/no-response.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: No Response
|
||||
|
||||
# Run as a daily cron at 1:45 AM
|
||||
on:
|
||||
schedule:
|
||||
- cron: '45 1 * * *'
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
no-response:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'google-gemini/gemini-cli' }}
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-no-response
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: -1
|
||||
days-before-close: 14
|
||||
stale-issue-label: 'status/need-information'
|
||||
close-issue-message: >
|
||||
This issue was marked as needing more information and has not received a response in 14 days.
|
||||
Closing it for now. If you still face this problem, feel free to reopen with more details. Thank you!
|
||||
stale-pr-label: 'status/need-information'
|
||||
close-pr-message: >
|
||||
This pull request was marked as needing more information and has had no updates in 14 days.
|
||||
Closing it for now. You are welcome to reopen with the required info. Thanks for contributing!
|
||||
191
.github/workflows/qwen-code-pr-review.yml
vendored
Normal file
191
.github/workflows/qwen-code-pr-review.yml
vendored
Normal file
@@ -0,0 +1,191 @@
|
||||
name: 🧐 Qwen Pull Request Review
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to review'
|
||||
required: true
|
||||
type: number
|
||||
|
||||
jobs:
|
||||
review-pr:
|
||||
if: >
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'pull_request' && github.event.action == 'opened') ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '@qwen /review') &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR')) ||
|
||||
(github.event_name == 'pull_request_review_comment' &&
|
||||
contains(github.event.comment.body, '@qwen /review') &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR')) ||
|
||||
(github.event_name == 'pull_request_review' &&
|
||||
contains(github.event.review.body, '@qwen /review') &&
|
||||
(github.event.review.author_association == 'OWNER' ||
|
||||
github.event.review.author_association == 'MEMBER' ||
|
||||
github.event.review.author_association == 'COLLABORATOR'))
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout PR code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get PR details (pull_request & workflow_dispatch)
|
||||
id: get_pr
|
||||
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
PR_NUMBER=${{ github.event.inputs.pr_number }}
|
||||
else
|
||||
PR_NUMBER=${{ github.event.pull_request.number }}
|
||||
fi
|
||||
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
|
||||
# Get PR details
|
||||
PR_DATA=$(gh pr view $PR_NUMBER --json title,body,additions,deletions,changedFiles,baseRefName,headRefName)
|
||||
echo "pr_data=$PR_DATA" >> "$GITHUB_OUTPUT"
|
||||
# Get file changes
|
||||
CHANGED_FILES=$(gh pr diff $PR_NUMBER --name-only)
|
||||
echo "changed_files<<EOF" >> "$GITHUB_OUTPUT"
|
||||
echo "$CHANGED_FILES" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Get PR details (issue_comment)
|
||||
id: get_pr_comment
|
||||
if: github.event_name == 'issue_comment'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
run: |
|
||||
PR_NUMBER=${{ github.event.issue.number }}
|
||||
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
|
||||
# Extract additional instructions from comment
|
||||
ADDITIONAL_INSTRUCTIONS=$(echo "$COMMENT_BODY" | sed 's/.*@qwen \/review//' | xargs)
|
||||
echo "additional_instructions=$ADDITIONAL_INSTRUCTIONS" >> "$GITHUB_OUTPUT"
|
||||
# Get PR details
|
||||
PR_DATA=$(gh pr view $PR_NUMBER --json title,body,additions,deletions,changedFiles,baseRefName,headRefName)
|
||||
echo "pr_data=$PR_DATA" >> "$GITHUB_OUTPUT"
|
||||
# Get file changes
|
||||
CHANGED_FILES=$(gh pr diff $PR_NUMBER --name-only)
|
||||
echo "changed_files<<EOF" >> "$GITHUB_OUTPUT"
|
||||
echo "$CHANGED_FILES" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run Qwen PR Review
|
||||
uses: QwenLM/qwen-code-action@5fd6818d04d64e87d255ee4d5f77995e32fbf4c2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ steps.get_pr.outputs.pr_number || steps.get_pr_comment.outputs.pr_number }}
|
||||
PR_DATA: ${{ steps.get_pr.outputs.pr_data || steps.get_pr_comment.outputs.pr_data }}
|
||||
CHANGED_FILES: ${{ steps.get_pr.outputs.changed_files || steps.get_pr_comment.outputs.changed_files }}
|
||||
ADDITIONAL_INSTRUCTIONS: ${{ steps.get_pr.outputs.additional_instructions || steps.get_pr_comment.outputs.additional_instructions }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
with:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}
|
||||
settings_json: |
|
||||
{
|
||||
"coreTools": [
|
||||
"run_shell_command(echo)",
|
||||
"run_shell_command(gh pr view)",
|
||||
"run_shell_command(gh pr diff)",
|
||||
"run_shell_command(gh pr comment)",
|
||||
"run_shell_command(cat)",
|
||||
"run_shell_command(head)",
|
||||
"run_shell_command(tail)",
|
||||
"run_shell_command(grep)",
|
||||
"write_file"
|
||||
],
|
||||
"sandbox": false
|
||||
}
|
||||
prompt: |
|
||||
You are an expert code reviewer. You have access to shell commands to gather PR information and perform the review.
|
||||
|
||||
IMPORTANT: Use the available shell commands to gather information. Do not ask for information to be provided.
|
||||
|
||||
Start by running these commands to gather the required data:
|
||||
1. Run: echo "$PR_DATA" to get PR details (JSON format)
|
||||
2. Run: echo "$CHANGED_FILES" to get the list of changed files
|
||||
3. Run: echo "$PR_NUMBER" to get the PR number
|
||||
4. Run: echo "$ADDITIONAL_INSTRUCTIONS" to see any specific review instructions from the user
|
||||
5. Run: gh pr diff $PR_NUMBER to see the full diff
|
||||
6. For any specific files, use: cat filename, head -50 filename, or tail -50 filename
|
||||
|
||||
Additional Review Instructions:
|
||||
If ADDITIONAL_INSTRUCTIONS contains text, prioritize those specific areas or focus points in your review.
|
||||
Common instruction examples: "focus on security", "check performance", "review error handling", "check for breaking changes"
|
||||
|
||||
Once you have the information, provide a comprehensive code review by:
|
||||
1. Writing your review to a file: write_file("review.md", "<your detailed review feedback here>")
|
||||
2. Posting the review: gh pr comment $PR_NUMBER --body-file review.md --repo $REPOSITORY
|
||||
|
||||
Review Areas:
|
||||
- **Security**: Authentication, authorization, input validation, data sanitization
|
||||
- **Performance**: Algorithms, database queries, caching, resource usage
|
||||
- **Reliability**: Error handling, logging, testing coverage, edge cases
|
||||
- **Maintainability**: Code structure, documentation, naming conventions
|
||||
- **Functionality**: Logic correctness, requirements fulfillment
|
||||
|
||||
Output Format:
|
||||
Structure your review using this exact format with markdown:
|
||||
|
||||
## 📋 Review Summary
|
||||
Provide a brief 2-3 sentence overview of the PR and overall assessment.
|
||||
|
||||
## 🔍 General Feedback
|
||||
- List general observations about code quality
|
||||
- Mention overall patterns or architectural decisions
|
||||
- Highlight positive aspects of the implementation
|
||||
- Note any recurring themes across files
|
||||
|
||||
## 🎯 Specific Feedback
|
||||
Only include sections below that have actual issues. If there are no issues in a priority category, omit that entire section.
|
||||
|
||||
### 🔴 Critical
|
||||
(Only include this section if there are critical issues)
|
||||
Issues that must be addressed before merging (security vulnerabilities, breaking changes, major bugs):
|
||||
- **File: `filename:line`** - Description of critical issue with specific recommendation
|
||||
|
||||
### 🟡 High
|
||||
(Only include this section if there are high priority issues)
|
||||
Important issues that should be addressed (performance problems, design flaws, significant bugs):
|
||||
- **File: `filename:line`** - Description of high priority issue with suggested fix
|
||||
|
||||
### 🟢 Medium
|
||||
(Only include this section if there are medium priority issues)
|
||||
Improvements that would enhance code quality (style issues, minor optimizations, better practices):
|
||||
- **File: `filename:line`** - Description of medium priority improvement
|
||||
|
||||
### 🔵 Low
|
||||
(Only include this section if there are suggestions)
|
||||
Nice-to-have improvements and suggestions (documentation, naming, minor refactoring):
|
||||
- **File: `filename:line`** - Description of suggestion or enhancement
|
||||
|
||||
**Note**: If no specific issues are found in any category, simply state "No specific issues identified in this review."
|
||||
|
||||
## ✅ Highlights
|
||||
(Only include this section if there are positive aspects to highlight)
|
||||
- Mention specific good practices or implementations
|
||||
- Acknowledge well-written code sections
|
||||
- Note improvements from previous versions
|
||||
38
.github/workflows/stale.yml
vendored
Normal file
38
.github/workflows/stale.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
name: Mark stale issues and pull requests
|
||||
|
||||
# Run as a daily cron at 1:30 AM
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'google-gemini/gemini-cli' }}
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-stale
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: >
|
||||
This issue has been automatically marked as stale due to 60 days of inactivity.
|
||||
It will be closed in 14 days if no further activity occurs.
|
||||
stale-pr-message: >
|
||||
This pull request has been automatically marked as stale due to 60 days of inactivity.
|
||||
It will be closed in 14 days if no further activity occurs.
|
||||
close-issue-message: >
|
||||
This issue has been closed due to 14 additional days of inactivity after being marked as stale.
|
||||
If you believe this is still relevant, feel free to comment or reopen the issue. Thank you!
|
||||
close-pr-message: >
|
||||
This pull request has been closed due to 14 additional days of inactivity after being marked as stale.
|
||||
If this is still relevant, you are welcome to reopen or leave a comment. Thanks for contributing!
|
||||
days-before-stale: 60
|
||||
days-before-close: 14
|
||||
exempt-issue-labels: pinned,security
|
||||
exempt-pr-labels: pinned,security
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -38,11 +38,8 @@ packages/*/coverage/
|
||||
# Generated files
|
||||
packages/cli/src/generated/
|
||||
.integration-tests/
|
||||
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
|
||||
packages/vscode-ide-companion/*.vsix
|
||||
|
||||
# Qwen Code Configs
|
||||
.qwen/
|
||||
.qwen/
|
||||
logs/
|
||||
3
.npmrc
3
.npmrc
@@ -1,2 +1 @@
|
||||
@google:registry=https://wombat-dressing-room.appspot.com
|
||||
@ali:registry=https://registry.anpm.alibaba-inc.com
|
||||
registry=https://registry.npmjs.org
|
||||
|
||||
14
.vscode/launch.json
vendored
14
.vscode/launch.json
vendored
@@ -30,6 +30,18 @@
|
||||
"GEMINI_SANDBOX": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Launch Companion VS Code Extension",
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}/packages/vscode-ide-companion"
|
||||
],
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/packages/vscode-ide-companion/dist/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "npm: build: vscode-ide-companion"
|
||||
},
|
||||
{
|
||||
"name": "Attach",
|
||||
"port": 9229,
|
||||
@@ -38,7 +50,7 @@
|
||||
"type": "node",
|
||||
// fix source mapping when debugging in sandbox using global installation
|
||||
// note this does not interfere when remoteRoot is also ${workspaceFolder}/packages
|
||||
"remoteRoot": "/usr/local/share/npm-global/lib/node_modules/@gemini-cli",
|
||||
"remoteRoot": "/usr/local/share/npm-global/lib/node_modules/@qwen-code",
|
||||
"localRoot": "${workspaceFolder}/packages"
|
||||
},
|
||||
{
|
||||
|
||||
9
.vscode/tasks.json
vendored
9
.vscode/tasks.json
vendored
@@ -11,6 +11,15 @@
|
||||
"problemMatcher": [],
|
||||
"label": "npm: build",
|
||||
"detail": "scripts/build.sh"
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "build",
|
||||
"path": "packages/vscode-ide-companion",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"label": "npm: build: vscode-ide-companion",
|
||||
"detail": "npm run build -w packages/vscode-ide-companion"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ npm run lint
|
||||
|
||||
- Please adhere to the coding style, patterns, and conventions used throughout the existing codebase.
|
||||
- Consult [GEMINI.md](https://github.com/google-gemini/gemini-cli/blob/main/GEMINI.md) (typically found in the project root) for specific instructions related to AI-assisted development, including conventions for React, comments, and Git usage.
|
||||
- **Imports:** Pay special attention to import paths. The project uses `eslint-rules/no-relative-cross-package-imports.js` to enforce restrictions on relative imports between packages.
|
||||
- **Imports:** Pay special attention to import paths. The project uses ESLint to enforce restrictions on relative imports between packages.
|
||||
|
||||
### Project Structure
|
||||
|
||||
@@ -242,6 +242,8 @@ To hit a breakpoint inside the sandbox container run:
|
||||
DEBUG=1 gemini
|
||||
```
|
||||
|
||||
**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect gemini-cli due to automatic exclusion. Use `.gemini/.env` files for gemini-cli specific debug settings.
|
||||
|
||||
### React DevTools
|
||||
|
||||
To debug the CLI's React-based UI, you can use React DevTools. Ink, the library used for the CLI's interface, is compatible with React DevTools version 4.x.
|
||||
@@ -272,19 +274,19 @@ To debug the CLI's React-based UI, you can use React DevTools. Ink, the library
|
||||
|
||||
## Sandboxing
|
||||
|
||||
### MacOS Seatbelt
|
||||
### macOS Seatbelt
|
||||
|
||||
On MacOS, `gemini` uses Seatbelt (`sandbox-exec`) under a `permissive-open` profile (see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) that restricts writes to the project folder but otherwise allows all other operations and outbound network traffic ("open") by default. You can switch to a `restrictive-closed` profile (see `packages/cli/src/utils/sandbox-macos-restrictive-closed.sb`) that declines all operations and outbound network traffic ("closed") by default by setting `SEATBELT_PROFILE=restrictive-closed` in your environment or `.env` file. Available built-in profiles are `{permissive,restrictive}-{open,closed,proxied}` (see below for proxied networking). You can also switch to a custom profile `SEATBELT_PROFILE=<profile>` if you also create a file `.qwen/sandbox-macos-<profile>.sb` under your project settings directory `.gemini`.
|
||||
On macOS, `qwen` uses Seatbelt (`sandbox-exec`) under a `permissive-open` profile (see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) that restricts writes to the project folder but otherwise allows all other operations and outbound network traffic ("open") by default. You can switch to a `restrictive-closed` profile (see `packages/cli/src/utils/sandbox-macos-restrictive-closed.sb`) that declines all operations and outbound network traffic ("closed") by default by setting `SEATBELT_PROFILE=restrictive-closed` in your environment or `.env` file. Available built-in profiles are `{permissive,restrictive}-{open,closed,proxied}` (see below for proxied networking). You can also switch to a custom profile `SEATBELT_PROFILE=<profile>` if you also create a file `.qwen/sandbox-macos-<profile>.sb` under your project settings directory `.qwen`.
|
||||
|
||||
### Container-based Sandboxing (All Platforms)
|
||||
|
||||
For stronger container-based sandboxing on MacOS or other platforms, you can set `GEMINI_SANDBOX=true|docker|podman|<command>` in your environment or `.env` file. The specified command (or if `true` then either `docker` or `podman`) must be installed on the host machine. Once enabled, `npm run build:all` will build a minimal container ("sandbox") image and `npm start` will launch inside a fresh instance of that container. The first build can take 20-30s (mostly due to downloading of the base image) but after that both build and start overhead should be minimal. Default builds (`npm run build`) will not rebuild the sandbox.
|
||||
For stronger container-based sandboxing on macOS or other platforms, you can set `GEMINI_SANDBOX=true|docker|podman|<command>` in your environment or `.env` file. The specified command (or if `true` then either `docker` or `podman`) must be installed on the host machine. Once enabled, `npm run build:all` will build a minimal container ("sandbox") image and `npm start` will launch inside a fresh instance of that container. The first build can take 20-30s (mostly due to downloading of the base image) but after that both build and start overhead should be minimal. Default builds (`npm run build`) will not rebuild the sandbox.
|
||||
|
||||
Container-based sandboxing mounts the project directory (and system temp directory) with read-write access and is started/stopped/removed automatically as you start/stop Gemini CLI. Files created within the sandbox should be automatically mapped to your user/group on host machine. You can easily specify additional mounts, ports, or environment variables by setting `SANDBOX_{MOUNTS,PORTS,ENV}` as needed. You can also fully customize the sandbox for your projects by creating the files `.qwen/sandbox.Dockerfile` and/or `.qwen/sandbox.bashrc` under your project settings directory (`.gemini`) and running `gemini` with `BUILD_SANDBOX=1` to trigger building of your custom sandbox.
|
||||
Container-based sandboxing mounts the project directory (and system temp directory) with read-write access and is started/stopped/removed automatically as you start/stop Gemini CLI. Files created within the sandbox should be automatically mapped to your user/group on host machine. You can easily specify additional mounts, ports, or environment variables by setting `SANDBOX_{MOUNTS,PORTS,ENV}` as needed. You can also fully customize the sandbox for your projects by creating the files `.qwen/sandbox.Dockerfile` and/or `.qwen/sandbox.bashrc` under your project settings directory (`.qwen`) and running `qwen` with `BUILD_SANDBOX=1` to trigger building of your custom sandbox.
|
||||
|
||||
#### Proxied Networking
|
||||
|
||||
All sandboxing methods, including MacOS Seatbelt using `*-proxied` profiles, support restricting outbound network traffic through a custom proxy server that can be specified as `GEMINI_SANDBOX_PROXY_COMMAND=<command>`, where `<command>` must start a proxy server that listens on `:::8877` for relevant requests. See `docs/examples/proxy-script.md` for a minimal proxy that only allows `HTTPS` connections to `example.com:443` (e.g. `curl https://example.com`) and declines all other requests. The proxy is started and stopped automatically alongside the sandbox.
|
||||
All sandboxing methods, including macOS Seatbelt using `*-proxied` profiles, support restricting outbound network traffic through a custom proxy server that can be specified as `GEMINI_SANDBOX_PROXY_COMMAND=<command>`, where `<command>` must start a proxy server that listens on `:::8877` for relevant requests. See `docs/examples/proxy-script.md` for a minimal proxy that only allows `HTTPS` connections to `example.com:443` (e.g. `curl https://example.com`) and declines all other requests. The proxy is started and stopped automatically alongside the sandbox.
|
||||
|
||||
## Manual Publish
|
||||
|
||||
|
||||
1
LICENSE
1
LICENSE
@@ -188,6 +188,7 @@
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2025 Google LLC
|
||||
Copyright 2025 Qwen
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
2
Makefile
2
Makefile
@@ -53,7 +53,7 @@ debug:
|
||||
|
||||
|
||||
run-npx:
|
||||
npx https://github.com/google-gemini/gemini-cli
|
||||
npx https://github.com/QwenLM/qwen-code
|
||||
|
||||
create-alias:
|
||||
scripts/create_alias.sh
|
||||
|
||||
3
QWEN.md
3
QWEN.md
@@ -71,7 +71,8 @@ JavaScript classes, by their nature, are designed to encapsulate internal state
|
||||
- Reduced Boilerplate and Increased Conciseness: Classes often promote the use of constructors, this binding, getters, setters, and other boilerplate that can unnecessarily bloat code. TypeScript interface and type declarations provide powerful static type checking without the runtime overhead or verbosity of class definitions. This allows for more succinct and readable code, aligning with JavaScript's strengths in functional programming.
|
||||
|
||||
- Enhanced Readability and Predictability: Plain objects, especially when their structure is clearly defined by TypeScript interfaces, are often easier to read and understand. Their properties are directly accessible, and there's no hidden internal state or complex inheritance chains to navigate. This predictability leads to fewer bugs and a more maintainable codebase.
|
||||
Simplified Immutability: While not strictly enforced, plain objects encourage an immutable approach to data. When you need to modify an object, you typically create a new one with the desired changes, rather than mutating the original. This pattern aligns perfectly with React's reconciliation process and helps prevent subtle bugs related to shared mutable state.
|
||||
|
||||
- Simplified Immutability: While not strictly enforced, plain objects encourage an immutable approach to data. When you need to modify an object, you typically create a new one with the desired changes, rather than mutating the original. This pattern aligns perfectly with React's reconciliation process and helps prevent subtle bugs related to shared mutable state.
|
||||
|
||||
- Better Serialization and Deserialization: Plain JavaScript objects are naturally easy to serialize to JSON and deserialize back, which is a common requirement in web development (e.g., for API communication or local storage). Classes, with their methods and prototypes, can complicate this process.
|
||||
|
||||
|
||||
63
README.md
63
README.md
@@ -17,10 +17,27 @@
|
||||
|
||||
Qwen Code is a powerful command-line AI workflow tool adapted from [**Gemini CLI**](https://github.com/google-gemini/gemini-cli) ([details](./README.gemini.md)), specifically optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) models. It enhances your development workflow with advanced code understanding, automated tasks, and intelligent assistance.
|
||||
|
||||
## 💡 Free Options Available
|
||||
|
||||
Get started with Qwen Code at no cost using any of these free options:
|
||||
|
||||
### 🔥 Qwen OAuth (Recommended)
|
||||
|
||||
- **2,000 requests per day** with no token limits
|
||||
- **60 requests per minute** rate limit
|
||||
- Simply run `qwen` and authenticate with your qwen.ai account
|
||||
- Automatic credential management and refresh
|
||||
- Use `/auth` command to switch to Qwen OAuth if you have initialized with OpenAI compatible mode
|
||||
|
||||
### 🌏 Regional Free Tiers
|
||||
|
||||
- **Mainland China**: ModelScope offers **2,000 free API calls per day**
|
||||
- **International**: OpenRouter provides **up to 1,000 free API calls per day** worldwide
|
||||
|
||||
For detailed setup instructions, see [Authorization](#authorization).
|
||||
|
||||
> [!WARNING]
|
||||
> **Token Usage Notice**: Qwen Code may issue multiple API calls per cycle, resulting in higher token usage (similar to Claude Code). We're actively optimizing API efficiency.
|
||||
>
|
||||
> 💡 **Free Option**: ModelScope provides **2,000 free API calls per day** for users in mainland China. OpenRouter offers up to **1,000 free API calls per day** worldwide. For setup instructions, see [API Configuration](#api-configuration).
|
||||
|
||||
## Key Features
|
||||
|
||||
@@ -84,15 +101,43 @@ Create or edit `.qwen/settings.json` in your home directory:
|
||||
|
||||
- **`/compress`** - Compress conversation history to continue within token limits
|
||||
- **`/clear`** - Clear all conversation history and start fresh
|
||||
- **`/status`** - Check current token usage and limits
|
||||
- **`/stats`** - Check current token usage and limits
|
||||
|
||||
> 📝 **Note**: Session token limit applies to a single conversation, not cumulative API calls.
|
||||
|
||||
### API Configuration
|
||||
### Authorization
|
||||
|
||||
Qwen Code supports multiple API providers. You can configure your API key through environment variables or a `.env` file in your project root.
|
||||
Choose your preferred authentication method based on your needs:
|
||||
|
||||
#### Configuration Methods
|
||||
#### 1. Qwen OAuth (🚀 Recommended - Start in 30 seconds)
|
||||
|
||||
The easiest way to get started - completely free with generous quotas:
|
||||
|
||||
```bash
|
||||
# Just run this command and follow the browser authentication
|
||||
qwen
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. **Instant Setup**: CLI opens your browser automatically
|
||||
2. **One-Click Login**: Authenticate with your qwen.ai account
|
||||
3. **Automatic Management**: Credentials cached locally for future use
|
||||
4. **No Configuration**: Zero setup required - just start coding!
|
||||
|
||||
**Free Tier Benefits:**
|
||||
|
||||
- ✅ **2,000 requests/day** (no token counting needed)
|
||||
- ✅ **60 requests/minute** rate limit
|
||||
- ✅ **Automatic credential refresh**
|
||||
- ✅ **Zero cost** for individual users
|
||||
- ℹ️ **Note**: Model fallback may occur to maintain service quality
|
||||
|
||||
#### 2. OpenAI-Compatible API
|
||||
|
||||
Use API keys for OpenAI or other compatible providers:
|
||||
|
||||
**Configuration Methods:**
|
||||
|
||||
1. **Environment Variables**
|
||||
|
||||
@@ -110,7 +155,7 @@ Qwen Code supports multiple API providers. You can configure your API key throug
|
||||
OPENAI_MODEL=your_model_choice
|
||||
```
|
||||
|
||||
#### API Provider Options
|
||||
**API Provider Options**
|
||||
|
||||
> ⚠️ **Regional Notice:**
|
||||
>
|
||||
@@ -265,7 +310,7 @@ qwen
|
||||
- `/help` - Display available commands
|
||||
- `/clear` - Clear conversation history
|
||||
- `/compress` - Compress history to save tokens
|
||||
- `/status` - Show current session information
|
||||
- `/stats` - Show current session information
|
||||
- `/exit` or `/quit` - Exit Qwen Code
|
||||
|
||||
### Keyboard Shortcuts
|
||||
@@ -287,6 +332,8 @@ qwen
|
||||
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md) to learn how to contribute to the project.
|
||||
|
||||
For detailed authentication setup, see the [authentication guide](./docs/cli/authentication.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter issues, check the [troubleshooting guide](docs/troubleshooting.md).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Gemini CLI Roadmap
|
||||
# Qwen CLI Roadmap
|
||||
|
||||
The [Official Gemini CLI Roadmap](https://github.com/orgs/google-gemini/projects/11/)
|
||||
|
||||
|
||||
8
SECURITY.md
Normal file
8
SECURITY.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Reporting Security Issues
|
||||
|
||||
To report a security issue, please use [https://g.co/vulnz](https://g.co/vulnz).
|
||||
We use g.co/vulnz for our intake, and do coordination and disclosure here on
|
||||
GitHub (including using GitHub Security Advisory). The Google Security Team will
|
||||
respond within 5 working days of your report on g.co/vulnz.
|
||||
|
||||
[GitHub Security Advisory]: https://github.com/google-gemini/gemini-cli/security/advisories
|
||||
BIN
docs/assets/theme-custom.png
Normal file
BIN
docs/assets/theme-custom.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 259 KiB |
@@ -6,7 +6,7 @@ The Gemini CLI includes a Checkpointing feature that automatically saves a snaps
|
||||
|
||||
When you approve a tool that modifies the file system (like `write_file` or `replace`), the CLI automatically creates a "checkpoint." This checkpoint includes:
|
||||
|
||||
1. **A Git Snapshot:** A commit is made in a special, shadow Git repository located in your home directory (`~/.qwen/history/<project_hash>`). This snapshot captures the complete state of your project files at that moment. It does **not** interfere with your own project's Git repository.
|
||||
1. **A Git Snapshot:** A commit is made in a special, shadow Git repository located in your home directory (`~/.gemini/history/<project_hash>`). This snapshot captures the complete state of your project files at that moment. It does **not** interfere with your own project's Git repository.
|
||||
2. **Conversation History:** The entire conversation you've had with the agent up to that point is saved.
|
||||
3. **The Tool Call:** The specific tool call that was about to be executed is also stored.
|
||||
|
||||
@@ -16,7 +16,7 @@ If you want to undo the change or simply go back, you can use the `/restore` com
|
||||
- Restore the conversation history in the CLI.
|
||||
- Re-propose the original tool call, allowing you to run it again, modify it, or simply ignore it.
|
||||
|
||||
All checkpoint data, including the Git snapshot and conversation history, is stored locally on your machine. The Git snapshot is stored in the shadow repository while the conversation history and tool calls are saved in a JSON file in your project's temporary directory, typically located at `~/.qwen/tmp/<project_hash>/checkpoints`.
|
||||
All checkpoint data, including the Git snapshot and conversation history, is stored locally on your machine. The Git snapshot is stored in the shadow repository while the conversation history and tool calls are saved in a JSON file in your project's temporary directory, typically located at `~/.gemini/tmp/<project_hash>/checkpoints`.
|
||||
|
||||
## Enabling the Feature
|
||||
|
||||
|
||||
@@ -1,84 +1,87 @@
|
||||
# Authentication Setup
|
||||
|
||||
The Qwen Code CLI supports multiple authentication methods. On initial startup you'll need to configure **one** of the following authentication methods:
|
||||
Qwen Code supports two main authentication methods to access AI models. Choose the method that best fits your use case:
|
||||
|
||||
1. **Login with Google (Gemini Code Assist):**
|
||||
- Use this option to log in with your google account.
|
||||
- During initial startup, Gemini CLI will direct you to a webpage for authentication. Once authenticated, your credentials will be cached locally so the web login can be skipped on subsequent runs.
|
||||
- Note that the web login must be done in a browser that can communicate with the machine Gemini CLI is being run from. (Specifically, the browser will be redirected to a localhost url that Gemini CLI will be listening on).
|
||||
- <a id="workspace-gca">Users may have to specify a GOOGLE_CLOUD_PROJECT if:</a>
|
||||
1. You have a Google Workspace account. Google Workspace is a paid service for businesses and organizations that provides a suite of productivity tools, including a custom email domain (e.g. your-name@your-company.com), enhanced security features, and administrative controls. These accounts are often managed by an employer or school.
|
||||
1. You have received a free Code Assist license through the [Google Developer Program](https://developers.google.com/program/plans-and-pricing) (including qualified Google Developer Experts)
|
||||
1. You have been assigned a license to a current Gemini Code Assist standard or enterprise subscription.
|
||||
1. You are using the product outside the [supported regions](https://developers.google.com/gemini-code-assist/resources/available-locations) for free individual usage.
|
||||
1. You are a Google account holder under the age of 18
|
||||
- If you fall into one of these categories, you must first configure a Google Cloud Project Id to use, [enable the Gemini for Cloud API](https://cloud.google.com/gemini/docs/discover/set-up-gemini#enable-api) and [configure access permissions](https://cloud.google.com/gemini/docs/discover/set-up-gemini#grant-iam).
|
||||
1. **Qwen OAuth (Recommended):**
|
||||
- Use this option to log in with your qwen.ai account.
|
||||
- During initial startup, Qwen Code will direct you to the qwen.ai authentication page. Once authenticated, your credentials will be cached locally so the web login can be skipped on subsequent runs.
|
||||
- **Requirements:**
|
||||
- Valid qwen.ai account
|
||||
- Internet connection for initial authentication
|
||||
- **Benefits:**
|
||||
- Seamless access to Qwen models
|
||||
- Automatic credential refresh
|
||||
- No manual API key management required
|
||||
|
||||
You can temporarily set the environment variable in your current shell session using the following command:
|
||||
**Getting Started:**
|
||||
|
||||
```bash
|
||||
export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"
|
||||
```
|
||||
- For repeated use, you can add the environment variable to your [.env file](#persisting-environment-variables-with-env-files) or your shell's configuration file (like `~/.bashrc`, `~/.zshrc`, or `~/.profile`). For example, the following command adds the environment variable to a `~/.bashrc` file:
|
||||
```bash
|
||||
# Start Qwen Code and follow the OAuth flow
|
||||
qwen
|
||||
```
|
||||
|
||||
```bash
|
||||
echo 'export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
The CLI will automatically open your browser and guide you through the authentication process.
|
||||
|
||||
2. **<a id="gemini-api-key"></a>Gemini API key:**
|
||||
- Obtain your API key from Google AI Studio: [https://aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey)
|
||||
- Set the `GEMINI_API_KEY` environment variable. In the following methods, replace `YOUR_GEMINI_API_KEY` with the API key you obtained from Google AI Studio:
|
||||
- You can temporarily set the environment variable in your current shell session using the following command:
|
||||
```bash
|
||||
export GEMINI_API_KEY="YOUR_GEMINI_API_KEY"
|
||||
```
|
||||
- For repeated use, you can add the environment variable to your [.env file](#persisting-environment-variables-with-env-files) or your shell's configuration file (like `~/.bashrc`, `~/.zshrc`, or `~/.profile`). For example, the following command adds the environment variable to a `~/.bashrc` file:
|
||||
```bash
|
||||
echo 'export GEMINI_API_KEY="YOUR_GEMINI_API_KEY"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
**For users who authenticate using their qwen.ai account:**
|
||||
|
||||
3. **Vertex AI:**
|
||||
- Obtain your Google Cloud API key: [Get an API Key](https://cloud.google.com/vertex-ai/generative-ai/docs/start/api-keys?usertype=newuser)
|
||||
- Set the `GOOGLE_API_KEY` environment variable. In the following methods, replace `YOUR_GOOGLE_API_KEY` with your Vertex AI API key:
|
||||
- You can temporarily set these environment variables in your current shell session using the following commands:
|
||||
```bash
|
||||
export GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY"
|
||||
```
|
||||
- For repeated use, you can add the environment variables to your [.env file](#persisting-environment-variables-with-env-files) or your shell's configuration file (like `~/.bashrc`, `~/.zshrc`, or `~/.profile`). For example, the following commands add the environment variables to a `~/.bashrc` file:
|
||||
```bash
|
||||
echo 'export GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
- To use Application Default Credentials (ADC), use the following command:
|
||||
- Ensure you have a Google Cloud project and have enabled the Vertex AI API.
|
||||
```bash
|
||||
gcloud auth application-default login
|
||||
```
|
||||
For more information, see [Set up Application Default Credentials for Google Cloud](https://cloud.google.com/docs/authentication/provide-credentials-adc).
|
||||
- Set the `GOOGLE_CLOUD_PROJECT` and `GOOGLE_CLOUD_LOCATION` environment variables. In the following methods, replace `YOUR_PROJECT_ID` and `YOUR_PROJECT_LOCATION` with the relevant values for your project:
|
||||
- You can temporarily set these environment variables in your current shell session using the following commands:
|
||||
```bash
|
||||
export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"
|
||||
export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION" # e.g., us-central1
|
||||
```
|
||||
- For repeated use, you can add the environment variables to your [.env file](#persisting-environment-variables-with-env-files) or your shell's configuration file (like `~/.bashrc`, `~/.zshrc`, or `~/.profile`). For example, the following commands add the environment variables to a `~/.bashrc` file:
|
||||
```bash
|
||||
echo 'export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"' >> ~/.bashrc
|
||||
echo 'export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
4. **Cloud Shell:**
|
||||
- This option is only available when running in a Google Cloud Shell environment.
|
||||
- It automatically uses the credentials of the logged-in user in the Cloud Shell environment.
|
||||
- This is the default authentication method when running in Cloud Shell and no other method is configured.
|
||||
**Quota:**
|
||||
- 60 requests per minute
|
||||
- 2,000 requests per day
|
||||
- Token usage is not applicable
|
||||
|
||||
**Cost:** Free
|
||||
|
||||
**Notes:** A specific quota for different models is not specified; model fallback may occur to preserve shared experience quality.
|
||||
|
||||
2. **<a id="openai-api"></a>OpenAI-Compatible API:**
|
||||
- Use API keys for OpenAI or other compatible providers.
|
||||
- This method allows you to use various AI models through API keys.
|
||||
|
||||
**Configuration Methods:**
|
||||
|
||||
a) **Environment Variables:**
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your_api_key_here"
|
||||
export OPENAI_BASE_URL="your_api_endpoint" # Optional
|
||||
export OPENAI_MODEL="your_model_choice" # Optional
|
||||
```
|
||||
|
||||
b) **Project `.env` File:**
|
||||
Create a `.env` file in your project root:
|
||||
|
||||
```env
|
||||
OPENAI_API_KEY=your_api_key_here
|
||||
OPENAI_BASE_URL=your_api_endpoint
|
||||
OPENAI_MODEL=your_model_choice
|
||||
```
|
||||
|
||||
**Supported Providers:**
|
||||
- OpenAI (https://platform.openai.com/api-keys)
|
||||
- Alibaba Cloud Bailian
|
||||
- ModelScope
|
||||
- OpenRouter
|
||||
- Azure OpenAI
|
||||
- Any OpenAI-compatible API
|
||||
|
||||
## Switching Authentication Methods
|
||||
|
||||
To switch between authentication methods during a session, use the `/auth` command in the CLI interface:
|
||||
|
||||
```bash
|
||||
# Within the CLI, type:
|
||||
/auth
|
||||
```
|
||||
|
||||
This will allow you to reconfigure your authentication method without restarting the application.
|
||||
|
||||
### Persisting Environment Variables with `.env` Files
|
||||
|
||||
You can create a **`.qwen/.env`** file in your project directory or in your home directory. Creating a plain **`.env`** file also works, but `.qwen/.env` is recommended to keep Gemini variables isolated from other tools.
|
||||
You can create a **`.qwen/.env`** file in your project directory or in your home directory. Creating a plain **`.env`** file also works, but `.qwen/.env` is recommended to keep Qwen Code variables isolated from other tools.
|
||||
|
||||
Gemini CLI automatically loads environment variables from the **first** `.env` file it finds, using the following search order:
|
||||
**Important:** Some environment variables (like `DEBUG` and `DEBUG_MODE`) are automatically excluded from project `.env` files to prevent interference with qwen-code behavior. Use `.qwen/.env` files for qwen-code specific variables.
|
||||
|
||||
Qwen Code automatically loads environment variables from the **first** `.env` file it finds, using the following search order:
|
||||
|
||||
1. Starting in the **current directory** and moving upward toward `/`, for each directory it checks:
|
||||
1. `.qwen/.env`
|
||||
@@ -94,21 +97,47 @@ Gemini CLI automatically loads environment variables from the **first** `.env` f
|
||||
**Project-specific overrides** (take precedence when you are inside the project):
|
||||
|
||||
```bash
|
||||
mkdir -p .gemini
|
||||
echo 'GOOGLE_CLOUD_PROJECT="your-project-id"' >> .qwen/.env
|
||||
mkdir -p .qwen
|
||||
cat >> .qwen/.env <<'EOF'
|
||||
OPENAI_API_KEY="your-api-key"
|
||||
OPENAI_BASE_URL="https://api-inference.modelscope.cn/v1"
|
||||
OPENAI_MODEL="Qwen/Qwen3-Coder-480B-A35B-Instruct"
|
||||
EOF
|
||||
```
|
||||
|
||||
**User-wide settings** (available in every directory):
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.gemini
|
||||
mkdir -p ~/.qwen
|
||||
cat >> ~/.qwen/.env <<'EOF'
|
||||
GOOGLE_CLOUD_PROJECT="your-project-id"
|
||||
GEMINI_API_KEY="your-gemini-api-key"
|
||||
OPENAI_API_KEY="your-api-key"
|
||||
OPENAI_BASE_URL="https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
OPENAI_MODEL="qwen3-coder-plus"
|
||||
EOF
|
||||
```
|
||||
|
||||
5. **OpenAI Authentication:**
|
||||
- Use OpenAI models instead of Google's Gemini models
|
||||
- For detailed setup instructions, see [OpenAI Authentication](./openai-auth.md)
|
||||
- Supports interactive setup, command line arguments, and environment variables
|
||||
## Non-Interactive Mode / Headless Environments
|
||||
|
||||
When running Qwen Code in a non-interactive environment, you cannot use the OAuth login flow.
|
||||
Instead, you must configure authentication using environment variables.
|
||||
|
||||
The CLI will automatically detect if it is running in a non-interactive terminal and will use the
|
||||
OpenAI-compatible API method if configured:
|
||||
|
||||
1. **OpenAI-Compatible API:**
|
||||
- Set the `OPENAI_API_KEY` environment variable.
|
||||
- Optionally set `OPENAI_BASE_URL` and `OPENAI_MODEL` for custom endpoints.
|
||||
- The CLI will use these credentials to authenticate with the API provider.
|
||||
|
||||
**Example for headless environments:**
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your-api-key"
|
||||
export OPENAI_BASE_URL="https://api-inference.modelscope.cn/v1"
|
||||
export OPENAI_MODEL="Qwen/Qwen3-Coder-480B-A35B-Instruct"
|
||||
|
||||
# Run Qwen Code
|
||||
qwen
|
||||
```
|
||||
|
||||
If no API key is set in a non-interactive session, the CLI will exit with an error prompting you to configure authentication.
|
||||
|
||||
@@ -6,6 +6,8 @@ Qwen Code supports several built-in commands to help you manage your session, cu
|
||||
|
||||
Slash commands provide meta-level control over the CLI itself.
|
||||
|
||||
### Built-in Commands
|
||||
|
||||
- **`/bug`**
|
||||
- **Description:** File an issue about Qwen Code. By default, the issue is filed within the GitHub repository for Qwen Code. The string you enter after `/bug` will become the headline for the bug being filed. The default `/bug` behavior can be modified using the `bugCommand` setting in your `.qwen/settings.json` files.
|
||||
|
||||
@@ -15,6 +17,11 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **`save`**
|
||||
- **Description:** Saves the current conversation history. You must add a `<tag>` for identifying the conversation state.
|
||||
- **Usage:** `/chat save <tag>`
|
||||
- **Details on Checkpoint Location:** The default locations for saved chat checkpoints are:
|
||||
- Linux/macOS: `~/.config/google-generative-ai/checkpoints/`
|
||||
- Windows: `C:\Users\<YourUsername>\AppData\Roaming\google-generative-ai\checkpoints\`
|
||||
- When you run `/chat list`, the CLI only scans these specific directories to find available checkpoints.
|
||||
- **Note:** These checkpoints are for manually saving and resuming conversation states. For automatic checkpoints created before file modifications, see the [Checkpointing documentation](../checkpointing.md).
|
||||
- **`resume`**
|
||||
- **Description:** Resumes a conversation from a previous save.
|
||||
- **Usage:** `/chat resume <tag>`
|
||||
@@ -28,6 +35,20 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **`/compress`**
|
||||
- **Description:** Replace the entire chat context with a summary. This saves on tokens used for future tasks while retaining a high level summary of what has happened.
|
||||
|
||||
- **`/copy`**
|
||||
- **Description:** Copies the last output produced by Qwen Code to your clipboard, for easy sharing or reuse.
|
||||
|
||||
- **`/directory`** (or **`/dir`**)
|
||||
- **Description:** Manage workspace directories for multi-directory support.
|
||||
- **Sub-commands:**
|
||||
- **`add`**:
|
||||
- **Description:** Add a directory to the workspace. The path can be absolute or relative to the current working directory. Moreover, the reference from home directory is supported as well.
|
||||
- **Usage:** `/directory add <path1>,<path2>`
|
||||
- **Note:** Disabled in restrictive sandbox profiles. If you're using that, use `--include-directories` when starting the session instead.
|
||||
- **`show`**:
|
||||
- **Description:** Display all directories added by `/directory add` and `--include-directories`.
|
||||
- **Usage:** `/directory show`
|
||||
|
||||
- **`/editor`**
|
||||
- **Description:** Open a dialog for selecting supported editors.
|
||||
|
||||
@@ -49,15 +70,15 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **Keyboard Shortcut:** Press **Ctrl+T** at any time to toggle between showing and hiding tool descriptions.
|
||||
|
||||
- **`/memory`**
|
||||
- **Description:** Manage the AI's instructional context (hierarchical memory loaded from `GEMINI.md` files).
|
||||
- **Description:** Manage the AI's instructional context (hierarchical memory loaded from `QWEN.md` files by default; configurable via `contextFileName`).
|
||||
- **Sub-commands:**
|
||||
- **`add`**:
|
||||
- **Description:** Adds the following text to the AI's memory. Usage: `/memory add <text to remember>`
|
||||
- **`show`**:
|
||||
- **Description:** Display the full, concatenated content of the current hierarchical memory that has been loaded from all `GEMINI.md` files. This lets you inspect the instructional context being provided to the Gemini model.
|
||||
- **Description:** Display the full, concatenated content of the current hierarchical memory that has been loaded from all context files (e.g., `QWEN.md`). This lets you inspect the instructional context being provided to the model.
|
||||
- **`refresh`**:
|
||||
- **Description:** Reload the hierarchical instructional memory from all `GEMINI.md` files found in the configured locations (global, project/ancestors, and sub-directories). This command updates the model with the latest `GEMINI.md` content.
|
||||
- **Note:** For more details on how `GEMINI.md` files contribute to hierarchical memory, see the [CLI Configuration documentation](./configuration.md#4-geminimd-files-hierarchical-instructional-context).
|
||||
- **Description:** Reload the hierarchical instructional memory from all context files (default: `QWEN.md`) found in the configured locations (global, project/ancestors, and sub-directories). This updates the model with the latest context content.
|
||||
- **Note:** For more details on how context files contribute to hierarchical memory, see the [CLI Configuration documentation](./configuration.md#context-files-hierarchical-instructional-context).
|
||||
|
||||
- **`/restore`**
|
||||
- **Description:** Restores the project files to the state they were in just before a tool was executed. This is particularly useful for undoing file edits made by a tool. If run without a tool call ID, it will list available checkpoints to restore from.
|
||||
@@ -90,6 +111,202 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **`/quit`** (or **`/exit`**)
|
||||
- **Description:** Exit Qwen Code.
|
||||
|
||||
- **`/vim`**
|
||||
- **Description:** Toggle vim mode on or off. When vim mode is enabled, the input area supports vim-style navigation and editing commands in both NORMAL and INSERT modes.
|
||||
- **Features:**
|
||||
- **NORMAL mode:** Navigate with `h`, `j`, `k`, `l`; jump by words with `w`, `b`, `e`; go to line start/end with `0`, `$`, `^`; go to specific lines with `G` (or `gg` for first line)
|
||||
- **INSERT mode:** Standard text input with escape to return to NORMAL mode
|
||||
- **Editing commands:** Delete with `x`, change with `c`, insert with `i`, `a`, `o`, `O`; complex operations like `dd`, `cc`, `dw`, `cw`
|
||||
- **Count support:** Prefix commands with numbers (e.g., `3h`, `5w`, `10G`)
|
||||
- **Repeat last command:** Use `.` to repeat the last editing operation
|
||||
- **Persistent setting:** Vim mode preference is saved to `~/.gemini/settings.json` and restored between sessions
|
||||
- **Status indicator:** When enabled, shows `[NORMAL]` or `[INSERT]` in the footer
|
||||
|
||||
- **`/init`**
|
||||
- **Description:** Analyzes the current directory and creates a `QWEN.md` context file by default (or the filename specified by `contextFileName`). If a non-empty file already exists, no changes are made. The command seeds an empty file and prompts the model to populate it with project-specific instructions.
|
||||
|
||||
### Custom Commands
|
||||
|
||||
For a quick start, see the [example](#example-a-pure-function-refactoring-command) below.
|
||||
|
||||
Custom commands allow you to save and reuse your favorite or most frequently used prompts as personal shortcuts within Gemini CLI. You can create commands that are specific to a single project or commands that are available globally across all your projects, streamlining your workflow and ensuring consistency.
|
||||
|
||||
#### File Locations & Precedence
|
||||
|
||||
Gemini CLI discovers commands from two locations, loaded in a specific order:
|
||||
|
||||
1. **User Commands (Global):** Located in `~/.gemini/commands/`. These commands are available in any project you are working on.
|
||||
2. **Project Commands (Local):** Located in `<your-project-root>/.gemini/commands/`. These commands are specific to the current project and can be checked into version control to be shared with your team.
|
||||
|
||||
If a command in the project directory has the same name as a command in the user directory, the **project command will always be used.** This allows projects to override global commands with project-specific versions.
|
||||
|
||||
#### Naming and Namespacing
|
||||
|
||||
The name of a command is determined by its file path relative to its `commands` directory. Subdirectories are used to create namespaced commands, with the path separator (`/` or `\`) being converted to a colon (`:`).
|
||||
|
||||
- A file at `~/.gemini/commands/test.toml` becomes the command `/test`.
|
||||
- A file at `<project>/.gemini/commands/git/commit.toml` becomes the namespaced command `/git:commit`.
|
||||
|
||||
#### TOML File Format (v1)
|
||||
|
||||
Your command definition files must be written in the TOML format and use the `.toml` file extension.
|
||||
|
||||
##### Required Fields
|
||||
|
||||
- `prompt` (String): The prompt that will be sent to the Gemini model when the command is executed. This can be a single-line or multi-line string.
|
||||
|
||||
##### Optional Fields
|
||||
|
||||
- `description` (String): A brief, one-line description of what the command does. This text will be displayed next to your command in the `/help` menu. **If you omit this field, a generic description will be generated from the filename.**
|
||||
|
||||
#### Handling Arguments
|
||||
|
||||
Custom commands support two powerful, low-friction methods for handling arguments. The CLI automatically chooses the correct method based on the content of your command's `prompt`.
|
||||
|
||||
##### 1. Shorthand Injection with `{{args}}`
|
||||
|
||||
If your `prompt` contains the special placeholder `{{args}}`, the CLI will replace that exact placeholder with all the text the user typed after the command name. This is perfect for simple, deterministic commands where you need to inject user input into a specific place in a larger prompt template.
|
||||
|
||||
**Example (`git/fix.toml`):**
|
||||
|
||||
```toml
|
||||
# In: ~/.gemini/commands/git/fix.toml
|
||||
# Invoked via: /git:fix "Button is misaligned on mobile"
|
||||
|
||||
description = "Generates a fix for a given GitHub issue."
|
||||
prompt = "Please analyze the staged git changes and provide a code fix for the issue described here: {{args}}."
|
||||
```
|
||||
|
||||
The model will receive the final prompt: `Please analyze the staged git changes and provide a code fix for the issue described here: "Button is misaligned on mobile".`
|
||||
|
||||
##### 2. Default Argument Handling
|
||||
|
||||
If your `prompt` does **not** contain the special placeholder `{{args}}`, the CLI uses a default behavior for handling arguments.
|
||||
|
||||
If you provide arguments to the command (e.g., `/mycommand arg1`), the CLI will append the full command you typed to the end of the prompt, separated by two newlines. This allows the model to see both the original instructions and the specific arguments you just provided.
|
||||
|
||||
If you do **not** provide any arguments (e.g., `/mycommand`), the prompt is sent to the model exactly as it is, with nothing appended.
|
||||
|
||||
**Example (`changelog.toml`):**
|
||||
|
||||
This example shows how to create a robust command by defining a role for the model, explaining where to find the user's input, and specifying the expected format and behavior.
|
||||
|
||||
```toml
|
||||
# In: <project>/.gemini/commands/changelog.toml
|
||||
# Invoked via: /changelog 1.2.0 added "Support for default argument parsing."
|
||||
|
||||
description = "Adds a new entry to the project's CHANGELOG.md file."
|
||||
prompt = """
|
||||
# Task: Update Changelog
|
||||
|
||||
You are an expert maintainer of this software project. A user has invoked a command to add a new entry to the changelog.
|
||||
|
||||
**The user's raw command is appended below your instructions.**
|
||||
|
||||
Your task is to parse the `<version>`, `<change_type>`, and `<message>` from their input and use the `write_file` tool to correctly update the `CHANGELOG.md` file.
|
||||
|
||||
## Expected Format
|
||||
The command follows this format: `/changelog <version> <type> <message>`
|
||||
- `<type>` must be one of: "added", "changed", "fixed", "removed".
|
||||
|
||||
## Behavior
|
||||
1. Read the `CHANGELOG.md` file.
|
||||
2. Find the section for the specified `<version>`.
|
||||
3. Add the `<message>` under the correct `<type>` heading.
|
||||
4. If the version or type section doesn't exist, create it.
|
||||
5. Adhere strictly to the "Keep a Changelog" format.
|
||||
"""
|
||||
```
|
||||
|
||||
When you run `/changelog 1.2.0 added "New feature"`, the final text sent to the model will be the original prompt followed by two newlines and the command you typed.
|
||||
|
||||
##### 3. Executing Shell Commands with `!{...}`
|
||||
|
||||
You can make your commands dynamic by executing shell commands directly within your `prompt` and injecting their output. This is ideal for gathering context from your local environment, like reading file content or checking the status of Git.
|
||||
|
||||
When a custom command attempts to execute a shell command, Gemini CLI will now prompt you for confirmation before proceeding. This is a security measure to ensure that only intended commands can be run.
|
||||
|
||||
**How It Works:**
|
||||
|
||||
1. **Inject Commands:** Use the `!{...}` syntax in your `prompt` to specify where the command should be run and its output injected.
|
||||
2. **Confirm Execution:** When you run the command, a dialog will appear listing the shell commands the prompt wants to execute.
|
||||
3. **Grant Permission:** You can choose to:
|
||||
- **Allow once:** The command(s) will run this one time.
|
||||
- **Allow always for this session:** The command(s) will be added to a temporary allowlist for the current CLI session and will not require confirmation again.
|
||||
- **No:** Cancel the execution of the shell command(s).
|
||||
|
||||
The CLI still respects the global `excludeTools` and `coreTools` settings. A command will be blocked without a confirmation prompt if it is explicitly disallowed in your configuration.
|
||||
|
||||
**Example (`git/commit.toml`):**
|
||||
|
||||
This command gets the staged git diff and uses it to ask the model to write a commit message.
|
||||
|
||||
````toml
|
||||
# In: <project>/.gemini/commands/git/commit.toml
|
||||
# Invoked via: /git:commit
|
||||
|
||||
description = "Generates a Git commit message based on staged changes."
|
||||
|
||||
# The prompt uses !{...} to execute the command and inject its output.
|
||||
prompt = """
|
||||
Please generate a Conventional Commit message based on the following git diff:
|
||||
|
||||
```diff
|
||||
!{git diff --staged}
|
||||
````
|
||||
|
||||
"""
|
||||
|
||||
````
|
||||
|
||||
When you run `/git:commit`, the CLI first executes `git diff --staged`, then replaces `!{git diff --staged}` with the output of that command before sending the final, complete prompt to the model.
|
||||
|
||||
---
|
||||
|
||||
#### Example: A "Pure Function" Refactoring Command
|
||||
|
||||
Let's create a global command that asks the model to refactor a piece of code.
|
||||
|
||||
**1. Create the file and directories:**
|
||||
|
||||
First, ensure the user commands directory exists, then create a `refactor` subdirectory for organization and the final TOML file.
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.gemini/commands/refactor
|
||||
touch ~/.gemini/commands/refactor/pure.toml
|
||||
````
|
||||
|
||||
**2. Add the content to the file:**
|
||||
|
||||
Open `~/.gemini/commands/refactor/pure.toml` in your editor and add the following content. We are including the optional `description` for best practice.
|
||||
|
||||
```toml
|
||||
# In: ~/.gemini/commands/refactor/pure.toml
|
||||
# This command will be invoked via: /refactor:pure
|
||||
|
||||
description = "Asks the model to refactor the current context into a pure function."
|
||||
|
||||
prompt = """
|
||||
Please analyze the code I've provided in the current context.
|
||||
Refactor it into a pure function.
|
||||
|
||||
Your response should include:
|
||||
1. The refactored, pure function code block.
|
||||
2. A brief explanation of the key changes you made and why they contribute to purity.
|
||||
"""
|
||||
```
|
||||
|
||||
**3. Run the Command:**
|
||||
|
||||
That's it! You can now run your command in the CLI. First, you might add a file to the context, and then invoke your command:
|
||||
|
||||
```
|
||||
> @my-messy-function.js
|
||||
> /refactor:pure
|
||||
```
|
||||
|
||||
Gemini CLI will then execute the multi-line prompt defined in your TOML file.
|
||||
|
||||
## At commands (`@`)
|
||||
|
||||
At commands are used to include the content of files or directories as part of your prompt to Gemini. These commands include git-aware filtering.
|
||||
@@ -119,13 +336,13 @@ At commands are used to include the content of files or directories as part of y
|
||||
|
||||
## Shell mode & passthrough commands (`!`)
|
||||
|
||||
The `!` prefix lets you interact with your system's shell directly from within Qwen Code.
|
||||
The `!` prefix lets you interact with your system's shell directly from within Gemini CLI.
|
||||
|
||||
- **`!<shell_command>`**
|
||||
- **Description:** Execute the given `<shell_command>` in your system's default shell. Any output or errors from the command are displayed in the terminal.
|
||||
- **Description:** Execute the given `<shell_command>` using `bash` on Linux/macOS or `cmd.exe` on Windows. Any output or errors from the command are displayed in the terminal.
|
||||
- **Examples:**
|
||||
- `!ls -la` (executes `ls -la` and returns to Qwen Code)
|
||||
- `!git status` (executes `git status` and returns to Qwen Code)
|
||||
- `!ls -la` (executes `ls -la` and returns to Gemini CLI)
|
||||
- `!git status` (executes `git status` and returns to Gemini CLI)
|
||||
|
||||
- **`!` (Toggle shell mode)**
|
||||
- **Description:** Typing `!` on its own toggles shell mode.
|
||||
@@ -133,6 +350,8 @@ The `!` prefix lets you interact with your system's shell directly from within Q
|
||||
- When active, shell mode uses a different coloring and a "Shell Mode Indicator".
|
||||
- While in shell mode, text you type is interpreted directly as a shell command.
|
||||
- **Exiting shell mode:**
|
||||
- When exited, the UI reverts to its standard appearance and normal Qwen Code behavior resumes.
|
||||
- When exited, the UI reverts to its standard appearance and normal Gemini CLI behavior resumes.
|
||||
|
||||
- **Caution for all `!` usage:** Commands you execute in shell mode have the same permissions and impact as if you ran them directly in your terminal.
|
||||
|
||||
- **Environment Variable:** When a command is executed via `!` or in shell mode, the `GEMINI_CLI=1` environment variable is set in the subprocess's environment. This allows scripts or tools to detect if they are being run from within the Gemini CLI.
|
||||
|
||||
@@ -24,7 +24,7 @@ Gemini CLI uses `settings.json` files for persistent configuration. There are th
|
||||
- **Location:** `.qwen/settings.json` within your project's root directory.
|
||||
- **Scope:** Applies only when running Gemini CLI from that specific project. Project settings override user settings.
|
||||
- **System settings file:**
|
||||
- **Location:** `/etc/gemini-cli/settings.json` (Linux), `C:\ProgramData\gemini-cli\settings.json` (Windows) or `/Library/Application Support/GeminiCli/settings.json` (macOS).
|
||||
- **Location:** `/etc/gemini-cli/settings.json` (Linux), `C:\ProgramData\gemini-cli\settings.json` (Windows) or `/Library/Application Support/GeminiCli/settings.json` (macOS). The path can be overridden using the `GEMINI_CLI_SYSTEM_SETTINGS_PATH` environment variable.
|
||||
- **Scope:** Applies to all Gemini CLI sessions on the system, for all users. System settings override user and project settings. May be useful for system administrators at enterprises to have controls over users' Gemini CLI setups.
|
||||
|
||||
**Note on environment variables in settings:** String values within your `settings.json` files can reference environment variables using either `$VAR_NAME` or `${VAR_NAME}` syntax. These variables will be automatically resolved when the settings are loaded. For example, if you have an environment variable `MY_API_TOKEN`, you could use it in `settings.json` like this: `"apiKey": "$MY_API_TOKEN"`.
|
||||
@@ -38,8 +38,8 @@ In addition to a project settings file, a project's `.gemini` directory can cont
|
||||
### Available settings in `settings.json`:
|
||||
|
||||
- **`contextFileName`** (string or array of strings):
|
||||
- **Description:** Specifies the filename for context files (e.g., `GEMINI.md`, `AGENTS.md`). Can be a single filename or a list of accepted filenames.
|
||||
- **Default:** `GEMINI.md`
|
||||
- **Description:** Specifies the filename for context files (e.g., `QWEN.md`, `AGENTS.md`). Can be a single filename or a list of accepted filenames.
|
||||
- **Default:** `QWEN.md`
|
||||
- **Example:** `"contextFileName": "AGENTS.md"`
|
||||
|
||||
- **`bugCommand`** (object):
|
||||
@@ -81,6 +81,18 @@ In addition to a project settings file, a project's `.gemini` directory can cont
|
||||
`excludeTools` for `run_shell_command` are based on simple string matching and can be easily bypassed. This feature is **not a security mechanism** and should not be relied upon to safely execute untrusted code. It is recommended to use `coreTools` to explicitly select commands
|
||||
that can be executed.
|
||||
|
||||
- **`allowMCPServers`** (array of strings):
|
||||
- **Description:** Allows you to specify a list of MCP server names that should be made available to the model. This can be used to restrict the set of MCP servers to connect to. Note that this will be ignored if `--allowed-mcp-server-names` is set.
|
||||
- **Default:** All MCP servers are available for use by the Gemini model.
|
||||
- **Example:** `"allowMCPServers": ["myPythonServer"]`.
|
||||
- **Security Note:** This uses simple string matching on MCP server names, which can be modified. If you're a system administrator looking to prevent users from bypassing this, consider configuring the `mcpServers` at the system settings level such that the user will not be able to configure any MCP servers of their own. This should not be used as an airtight security mechanism.
|
||||
|
||||
- **`excludeMCPServers`** (array of strings):
|
||||
- **Description:** Allows you to specify a list of MCP server names that should be excluded from the model. A server listed in both `excludeMCPServers` and `allowMCPServers` is excluded. Note that this will be ignored if `--allowed-mcp-server-names` is set.
|
||||
- **Default**: No MCP servers excluded.
|
||||
- **Example:** `"excludeMCPServers": ["myNodeServer"]`.
|
||||
- **Security Note:** This uses simple string matching on MCP server names, which can be modified. If you're a system administrator looking to prevent users from bypassing this, consider configuring the `mcpServers` at the system settings level such that the user will not be able to configure any MCP servers of their own. This should not be used as an airtight security mechanism.
|
||||
|
||||
- **`autoAccept`** (boolean):
|
||||
- **Description:** Controls whether the CLI automatically accepts and executes tool calls that are considered safe (e.g., read-only operations) without explicit user confirmation. If set to `true`, the CLI will bypass the confirmation prompt for tools deemed safe.
|
||||
- **Default:** `false`
|
||||
@@ -91,6 +103,11 @@ In addition to a project settings file, a project's `.gemini` directory can cont
|
||||
- **Default:** `"Default"`
|
||||
- **Example:** `"theme": "GitHub"`
|
||||
|
||||
- **`vimMode`** (boolean):
|
||||
- **Description:** Enables or disables vim mode for input editing. When enabled, the input area supports vim-style navigation and editing commands with NORMAL and INSERT modes. The vim mode status is displayed in the footer and persists between sessions.
|
||||
- **Default:** `false`
|
||||
- **Example:** `"vimMode": true`
|
||||
|
||||
- **`sandbox`** (boolean or string):
|
||||
- **Description:** Controls whether and how to use sandboxing for tool execution. If set to `true`, Gemini CLI uses a pre-built `gemini-cli-sandbox` Docker image. For more information, see [Sandboxing](#sandboxing).
|
||||
- **Default:** `false`
|
||||
@@ -120,6 +137,8 @@ In addition to a project settings file, a project's `.gemini` directory can cont
|
||||
- `cwd` (string, optional): The working directory in which to start the server.
|
||||
- `timeout` (number, optional): Timeout in milliseconds for requests to this MCP server.
|
||||
- `trust` (boolean, optional): Trust this server and bypass all tool call confirmations.
|
||||
- `includeTools` (array of strings, optional): List of tool names to include from this MCP server. When specified, only the tools listed here will be available from this server (whitelist behavior). If not specified, all tools from the server are enabled by default.
|
||||
- `excludeTools` (array of strings, optional): List of tool names to exclude from this MCP server. Tools listed here will not be available to the model, even if they are exposed by the server. **Note:** `excludeTools` takes precedence over `includeTools` - if a tool is in both lists, it will be excluded.
|
||||
- **Example:**
|
||||
```json
|
||||
"mcpServers": {
|
||||
@@ -127,12 +146,14 @@ In addition to a project settings file, a project's `.gemini` directory can cont
|
||||
"command": "python",
|
||||
"args": ["mcp_server.py", "--port", "8080"],
|
||||
"cwd": "./mcp_tools/python",
|
||||
"timeout": 5000
|
||||
"timeout": 5000,
|
||||
"includeTools": ["safe_tool", "file_reader"],
|
||||
},
|
||||
"myNodeServer": {
|
||||
"command": "node",
|
||||
"args": ["mcp_server.js"],
|
||||
"cwd": "./mcp_tools/node"
|
||||
"cwd": "./mcp_tools/node",
|
||||
"excludeTools": ["dangerous_tool", "file_deleter"]
|
||||
},
|
||||
"myDockerServer": {
|
||||
"command": "docker",
|
||||
@@ -206,45 +227,25 @@ In addition to a project settings file, a project's `.gemini` directory can cont
|
||||
"maxSessionTurns": 10
|
||||
```
|
||||
|
||||
- **`enableOpenAILogging`** (boolean):
|
||||
- **Description:** Enables or disables logging of OpenAI API calls for debugging and analysis. When enabled, all requests and responses to the OpenAI API are logged to files in the `~/.qwen/logs/` directory.
|
||||
- **Default:** `false`
|
||||
- **`summarizeToolOutput`** (object):
|
||||
- **Description:** Enables or disables the summarization of tool output. You can specify the token budget for the summarization using the `tokenBudget` setting.
|
||||
- Note: Currently only the `run_shell_command` tool is supported.
|
||||
- **Default:** `{}` (Disabled by default)
|
||||
- **Example:**
|
||||
|
||||
```json
|
||||
"enableOpenAILogging": true
|
||||
"summarizeToolOutput": {
|
||||
"run_shell_command": {
|
||||
"tokenBudget": 2000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **`systemPromptMappings`** (array):
|
||||
- **Description:** Configures custom system prompt templates for specific model names and base URLs. This allows you to use different system prompts for different AI models or API endpoints.
|
||||
- **Default:** `undefined` (uses default system prompt)
|
||||
- **Properties:**
|
||||
- **`baseUrls`** (array of strings, optional): Array of base URLs to exactly match against `OPENAI_BASE_URL` environment variable. If not specified, matches any base URL.
|
||||
- **`modelNames`** (array of strings, optional): Array of model names to exactly match against `OPENAI_MODEL` environment variable. If not specified, matches any model.
|
||||
- **`template`** (string): The system prompt template to use when both baseUrl and modelNames match. Supports placeholders:
|
||||
- `{RUNTIME_VARS_IS_GIT_REPO}`: Replaced with `true` or `false` based on whether the current directory is a git repository
|
||||
- `{RUNTIME_VARS_SANDBOX}`: Replaced with the sandbox type (e.g., `"sandbox-exec"`, `"docker"`, or empty string)
|
||||
- **`excludedProjectEnvVars`** (array of strings):
|
||||
- **Description:** Specifies environment variables that should be excluded from being loaded from project `.env` files. This prevents project-specific environment variables (like `DEBUG=true`) from interfering with gemini-cli behavior. Variables from `.gemini/.env` files are never excluded.
|
||||
- **Default:** `["DEBUG", "DEBUG_MODE"]`
|
||||
- **Example:**
|
||||
|
||||
```json
|
||||
"systemPromptMappings": [
|
||||
{
|
||||
"baseUrls": [
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
|
||||
],
|
||||
"modelNames": ["qwen3-coder-plus"],
|
||||
"template": "SYSTEM_TEMPLATE:{\"name\":\"qwen3_coder\",\"params\":{\"is_git_repository\":{RUNTIME_VARS_IS_GIT_REPO},\"sandbox\":\"{RUNTIME_VARS_SANDBOX}\"}}"
|
||||
},
|
||||
{
|
||||
"modelNames": ["gpt-4"],
|
||||
"template": "You are a helpful AI assistant specialized in coding tasks. Current sandbox: {RUNTIME_VARS_SANDBOX}"
|
||||
},
|
||||
{
|
||||
"baseUrls": ["api.openai.com"],
|
||||
"template": "You are an AI coding assistant. Working in git repository: {RUNTIME_VARS_IS_GIT_REPO}"
|
||||
}
|
||||
]
|
||||
"excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"]
|
||||
```
|
||||
|
||||
### Example `settings.json`:
|
||||
@@ -274,22 +275,12 @@ In addition to a project settings file, a project's `.gemini` directory can cont
|
||||
"hideTips": false,
|
||||
"hideBanner": false,
|
||||
"maxSessionTurns": 10,
|
||||
"enableOpenAILogging": true,
|
||||
"systemPromptMappings": [
|
||||
{
|
||||
"baseUrl": "dashscope",
|
||||
"modelNames": ["qwen3"],
|
||||
"template": "SYSTEM_TEMPLATE:{\"name\":\"qwen3_coder\",\"params\":{\"VARS_IS_GIT_REPO\":{VARS_IS_GIT_REPO},\"sandbox\":\"{sandbox}\"}}"
|
||||
},
|
||||
{
|
||||
"modelNames": ["gpt-4"],
|
||||
"template": "You are a helpful AI assistant specialized in coding tasks. Current sandbox: {sandbox}"
|
||||
},
|
||||
{
|
||||
"baseUrl": "api.openai.com",
|
||||
"template": "You are an AI coding assistant. Working in git repository: {VARS_IS_GIT_REPO}"
|
||||
"summarizeToolOutput": {
|
||||
"run_shell_command": {
|
||||
"tokenBudget": 100
|
||||
}
|
||||
]
|
||||
},
|
||||
"excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -311,6 +302,8 @@ The CLI automatically loads environment variables from an `.env` file. The loadi
|
||||
2. If not found, it searches upwards in parent directories until it finds an `.env` file or reaches the project root (identified by a `.git` folder) or the home directory.
|
||||
3. If still not found, it looks for `~/.env` (in the user's home directory).
|
||||
|
||||
**Environment Variable Exclusion:** Some environment variables (like `DEBUG` and `DEBUG_MODE`) are automatically excluded from being loaded from project `.env` files to prevent interference with gemini-cli behavior. Variables from `.gemini/.env` files are never excluded. You can customize this behavior using the `excludedProjectEnvVars` setting in your `settings.json` file.
|
||||
|
||||
- **`GEMINI_API_KEY`** (Required):
|
||||
- Your API key for the Gemini API.
|
||||
- **Crucial for operation.** The CLI will not function without it.
|
||||
@@ -350,6 +343,7 @@ The CLI automatically loads environment variables from an `.env` file. The loadi
|
||||
- `<profile_name>`: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-<profile_name>.sb` in your project's `.qwen/` directory (e.g., `my-project/.qwen/sandbox-macos-custom.sb`).
|
||||
- **`DEBUG` or `DEBUG_MODE`** (often used by underlying libraries or the CLI itself):
|
||||
- Set to `true` or `1` to enable verbose debug logging, which can be helpful for troubleshooting.
|
||||
- **Note:** These variables are automatically excluded from project `.env` files by default to prevent interference with gemini-cli behavior. Use `.gemini/.env` files if you need to set these for gemini-cli specifically.
|
||||
- **`NO_COLOR`**:
|
||||
- Set to any value to disable all color output in the CLI.
|
||||
- **`CLI_TITLE`**:
|
||||
@@ -367,6 +361,11 @@ Arguments passed directly when running the CLI can override other configurations
|
||||
- Example: `npm start -- --model gemini-1.5-pro-latest`
|
||||
- **`--prompt <your_prompt>`** (**`-p <your_prompt>`**):
|
||||
- Used to pass a prompt directly to the command. This invokes Gemini CLI in a non-interactive mode.
|
||||
- **`--prompt-interactive <your_prompt>`** (**`-i <your_prompt>`**):
|
||||
- Starts an interactive session with the provided prompt as the initial input.
|
||||
- The prompt is processed within the interactive session, not before it.
|
||||
- Cannot be used when piping input from stdin.
|
||||
- Example: `gemini -i "explain this code"`
|
||||
- **`--sandbox`** (**`-s`**):
|
||||
- Enables sandbox mode for this session.
|
||||
- **`--sandbox-image`**:
|
||||
@@ -390,13 +389,21 @@ Arguments passed directly when running the CLI can override other configurations
|
||||
- **`--telemetry-log-prompts`**:
|
||||
- Enables logging of prompts for telemetry. See [telemetry](../telemetry.md) for more information.
|
||||
- **`--checkpointing`**:
|
||||
- Enables [checkpointing](./commands.md#checkpointing-commands).
|
||||
- Enables [checkpointing](../checkpointing.md).
|
||||
- **`--extensions <extension_name ...>`** (**`-e <extension_name ...>`**):
|
||||
- Specifies a list of extensions to use for the session. If not provided, all available extensions are used.
|
||||
- Use the special term `gemini -e none` to disable all extensions.
|
||||
- Example: `gemini -e my-extension -e my-other-extension`
|
||||
- **`--list-extensions`** (**`-l`**):
|
||||
- Lists all available extensions and exits.
|
||||
- **`--proxy`**:
|
||||
- Sets the proxy for the CLI.
|
||||
- Example: `--proxy http://localhost:7890`.
|
||||
- **`--include-directories <dir1,dir2,...>`**:
|
||||
- Includes additional directories in the workspace for multi-directory support.
|
||||
- Can be specified multiple times or as comma-separated values.
|
||||
- 5 directories can be added at maximum.
|
||||
- Example: `--include-directories /path/to/project1,/path/to/project2` or `--include-directories /path/to/project1 --include-directories /path/to/project2`
|
||||
- **`--version`**:
|
||||
- Displays the version of the CLI.
|
||||
- **`--openai-logging`**:
|
||||
@@ -408,7 +415,7 @@ While not strictly configuration for the CLI's _behavior_, context files (defaul
|
||||
|
||||
- **Purpose:** These Markdown files contain instructions, guidelines, or context that you want the Gemini model to be aware of during your interactions. The system is designed to manage this instructional context hierarchically.
|
||||
|
||||
### Example Context File Content (e.g., `GEMINI.md`)
|
||||
### Example Context File Content (e.g., `QWEN.md`)
|
||||
|
||||
Here's a conceptual example of what a context file at the root of a TypeScript project might contain:
|
||||
|
||||
@@ -443,17 +450,18 @@ Here's a conceptual example of what a context file at the root of a TypeScript p
|
||||
|
||||
This example demonstrates how you can provide general project context, specific coding conventions, and even notes about particular files or components. The more relevant and precise your context files are, the better the AI can assist you. Project-specific context files are highly encouraged to establish conventions and context.
|
||||
|
||||
- **Hierarchical Loading and Precedence:** The CLI implements a sophisticated hierarchical memory system by loading context files (e.g., `GEMINI.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is:
|
||||
- **Hierarchical Loading and Precedence:** The CLI implements a sophisticated hierarchical memory system by loading context files (e.g., `QWEN.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is:
|
||||
1. **Global Context File:**
|
||||
- Location: `~/.qwen/<contextFileName>` (e.g., `~/.qwen/GEMINI.md` in your user home directory).
|
||||
- Location: `~/.qwen/<contextFileName>` (e.g., `~/.qwen/QWEN.md` in your user home directory).
|
||||
- Scope: Provides default instructions for all your projects.
|
||||
2. **Project Root & Ancestors Context Files:**
|
||||
- Location: The CLI searches for the configured context file in the current working directory and then in each parent directory up to either the project root (identified by a `.git` folder) or your home directory.
|
||||
- Scope: Provides context relevant to the entire project or a significant portion of it.
|
||||
3. **Sub-directory Context Files (Contextual/Local):**
|
||||
- Location: The CLI also scans for the configured context file in subdirectories _below_ the current working directory (respecting common ignore patterns like `node_modules`, `.git`, etc.).
|
||||
- Location: The CLI also scans for the configured context file in subdirectories _below_ the current working directory (respecting common ignore patterns like `node_modules`, `.git`, etc.). The breadth of this search is limited to 200 directories by default, but can be configured with a `memoryDiscoveryMaxDirs` field in your `settings.json` file.
|
||||
- Scope: Allows for highly specific instructions relevant to a particular component, module, or subsection of your project.
|
||||
- **Concatenation & UI Indication:** The contents of all found context files are concatenated (with separators indicating their origin and path) and provided as part of the system prompt to the Gemini model. The CLI footer displays the count of loaded context files, giving you a quick visual cue about the active instructional context.
|
||||
- **Importing Content:** You can modularize your context files by importing other Markdown files using the `@path/to/file.md` syntax. For more details, see the [Memory Import Processor documentation](../core/memport.md).
|
||||
- **Commands for Memory Management:**
|
||||
- Use `/memory refresh` to force a re-scan and reload of all context files from all configured locations. This updates the AI's instructional context.
|
||||
- Use `/memory show` to display the combined instructional context currently loaded, allowing you to verify the hierarchy and content being used by the AI.
|
||||
@@ -473,7 +481,7 @@ Sandboxing is disabled by default, but you can enable it in a few ways:
|
||||
|
||||
By default, it uses a pre-built `gemini-cli-sandbox` Docker image.
|
||||
|
||||
For project-specific sandboxing needs, you can create a custom Dockerfile at `.qwen/sandbox.Dockerfile` in your project's root directory. This Dockerfile can be based on the base sandbox image:
|
||||
For project-specific sandboxing needs, you can create a custom Dockerfile at `.gemini/sandbox.Dockerfile` in your project's root directory. This Dockerfile can be based on the base sandbox image:
|
||||
|
||||
```dockerfile
|
||||
FROM gemini-cli-sandbox
|
||||
@@ -484,7 +492,7 @@ FROM gemini-cli-sandbox
|
||||
# COPY ./my-config /app/my-config
|
||||
```
|
||||
|
||||
When `.qwen/sandbox.Dockerfile` exists, you can use `BUILD_SANDBOX` environment variable when running Gemini CLI to automatically build the custom sandbox image:
|
||||
When `.gemini/sandbox.Dockerfile` exists, you can use `BUILD_SANDBOX` environment variable when running Gemini CLI to automatically build the custom sandbox image:
|
||||
|
||||
```bash
|
||||
BUILD_SANDBOX=1 gemini -s
|
||||
@@ -515,3 +523,5 @@ You can opt out of usage statistics collection at any time by setting the `usage
|
||||
"usageStatisticsEnabled": false
|
||||
}
|
||||
```
|
||||
|
||||
Note: When usage statistics are enabled, events are sent to an Alibaba Cloud RUM collection endpoint.
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
# Gemini CLI
|
||||
# Qwen Code CLI
|
||||
|
||||
Within Gemini CLI, `packages/cli` is the frontend for users to send and receive prompts with the Gemini AI model and its associated tools. For a general overview of Gemini CLI, see the [main documentation page](../index.md).
|
||||
Within Qwen Code, `packages/cli` is the frontend for users to send and receive prompts with Qwen and other AI models and their associated tools. For a general overview of Qwen Code, see the [main documentation page](../index.md).
|
||||
|
||||
## Navigating this section
|
||||
|
||||
- **[Authentication](./authentication.md):** A guide to setting up authentication with Google's AI services.
|
||||
- **[Commands](./commands.md):** A reference for Gemini CLI commands (e.g., `/help`, `/tools`, `/theme`).
|
||||
- **[Configuration](./configuration.md):** A guide to tailoring Gemini CLI behavior using configuration files.
|
||||
- **[Authentication](./authentication.md):** A guide to setting up authentication with Qwen OAuth and OpenAI-compatible providers.
|
||||
- **[Commands](./commands.md):** A reference for Qwen Code CLI commands (e.g., `/help`, `/tools`, `/theme`).
|
||||
- **[Configuration](./configuration.md):** A guide to tailoring Qwen Code CLI behavior using configuration files.
|
||||
- **[Token Caching](./token-caching.md):** Optimize API costs through token caching.
|
||||
- **[Themes](./themes.md)**: A guide to customizing the CLI's appearance with different themes.
|
||||
- **[Tutorials](tutorials.md)**: A tutorial showing how to use Gemini CLI to automate a development task.
|
||||
- **[Tutorials](tutorials.md)**: A tutorial showing how to use Qwen Code to automate a development task.
|
||||
|
||||
## Non-interactive mode
|
||||
|
||||
Gemini CLI can be run in a non-interactive mode, which is useful for scripting and automation. In this mode, you pipe input to the CLI, it executes the command, and then it exits.
|
||||
Qwen Code can be run in a non-interactive mode, which is useful for scripting and automation. In this mode, you pipe input to the CLI, it executes the command, and then it exits.
|
||||
|
||||
The following example pipes a command to Gemini CLI from your terminal:
|
||||
The following example pipes a command to Qwen Code from your terminal:
|
||||
|
||||
```bash
|
||||
echo "What is fine tuning?" | gemini
|
||||
echo "What is fine tuning?" | qwen
|
||||
```
|
||||
|
||||
Gemini CLI executes the command and prints the output to your terminal. Note that you can achieve the same behavior by using the `--prompt` or `-p` flag. For example:
|
||||
Qwen Code executes the command and prints the output to your terminal. Note that you can achieve the same behavior by using the `--prompt` or `-p` flag. For example:
|
||||
|
||||
```bash
|
||||
gemini -p "What is fine tuning?"
|
||||
qwen -p "What is fine tuning?"
|
||||
```
|
||||
|
||||
@@ -32,6 +32,91 @@ Gemini CLI comes with a selection of pre-defined themes, which you can list usin
|
||||
|
||||
Selected themes are saved in Gemini CLI's [configuration](./configuration.md) so your preference is remembered across sessions.
|
||||
|
||||
---
|
||||
|
||||
## Custom Color Themes
|
||||
|
||||
Gemini CLI allows you to create your own custom color themes by specifying them in your `settings.json` file. This gives you full control over the color palette used in the CLI.
|
||||
|
||||
### How to Define a Custom Theme
|
||||
|
||||
Add a `customThemes` block to your user, project, or system `settings.json` file. Each custom theme is defined as an object with a unique name and a set of color keys. For example:
|
||||
|
||||
```json
|
||||
{
|
||||
"customThemes": {
|
||||
"MyCustomTheme": {
|
||||
"name": "MyCustomTheme",
|
||||
"type": "custom",
|
||||
"Background": "#181818",
|
||||
"Foreground": "#F8F8F2",
|
||||
"LightBlue": "#82AAFF",
|
||||
"AccentBlue": "#61AFEF",
|
||||
"AccentPurple": "#C678DD",
|
||||
"AccentCyan": "#56B6C2",
|
||||
"AccentGreen": "#98C379",
|
||||
"AccentYellow": "#E5C07B",
|
||||
"AccentRed": "#E06C75",
|
||||
"Comment": "#5C6370",
|
||||
"Gray": "#ABB2BF",
|
||||
"DiffAdded": "#A6E3A1",
|
||||
"DiffRemoved": "#F38BA8",
|
||||
"DiffModified": "#89B4FA",
|
||||
"GradientColors": ["#4796E4", "#847ACE", "#C3677F"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Color keys:**
|
||||
|
||||
- `Background`
|
||||
- `Foreground`
|
||||
- `LightBlue`
|
||||
- `AccentBlue`
|
||||
- `AccentPurple`
|
||||
- `AccentCyan`
|
||||
- `AccentGreen`
|
||||
- `AccentYellow`
|
||||
- `AccentRed`
|
||||
- `Comment`
|
||||
- `Gray`
|
||||
- `DiffAdded` (optional, for added lines in diffs)
|
||||
- `DiffRemoved` (optional, for removed lines in diffs)
|
||||
- `DiffModified` (optional, for modified lines in diffs)
|
||||
|
||||
**Required Properties:**
|
||||
|
||||
- `name` (must match the key in the `customThemes` object and be a string)
|
||||
- `type` (must be the string `"custom"`)
|
||||
- `Background`
|
||||
- `Foreground`
|
||||
- `LightBlue`
|
||||
- `AccentBlue`
|
||||
- `AccentPurple`
|
||||
- `AccentCyan`
|
||||
- `AccentGreen`
|
||||
- `AccentYellow`
|
||||
- `AccentRed`
|
||||
- `Comment`
|
||||
- `Gray`
|
||||
|
||||
You can use either hex codes (e.g., `#FF0000`) **or** standard CSS color names (e.g., `coral`, `teal`, `blue`) for any color value. See [CSS color names](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#color_keywords) for a full list of supported names.
|
||||
|
||||
You can define multiple custom themes by adding more entries to the `customThemes` object.
|
||||
|
||||
### Example Custom Theme
|
||||
|
||||
<img src="../assets/theme-custom.png" alt="Custom theme example" width="600" />
|
||||
|
||||
### Using Your Custom Theme
|
||||
|
||||
- Select your custom theme using the `/theme` command in Gemini CLI. Your custom theme will appear in the theme selection dialog.
|
||||
- Or, set it as the default by adding `"theme": "MyCustomTheme"` to your `settings.json`.
|
||||
- Custom themes can be set at the user, project, or system level, and follow the same [configuration precedence](./configuration.md) as other settings.
|
||||
|
||||
---
|
||||
|
||||
## Dark Themes
|
||||
|
||||
### ANSI
|
||||
|
||||
@@ -5,14 +5,14 @@ Gemini CLI's core package (`packages/core`) is the backend portion of Gemini CLI
|
||||
## Navigating this section
|
||||
|
||||
- **[Core tools API](./tools-api.md):** Information on how tools are defined, registered, and used by the core.
|
||||
- **[Memory Import Processor](./memport.md):** Documentation for the modular GEMINI.md import feature using @file.md syntax.
|
||||
- **[Memory Import Processor](./memport.md):** Documentation for the modular QWEN.md import feature using @file.md syntax.
|
||||
|
||||
## Role of the core
|
||||
|
||||
While the `packages/cli` portion of Gemini CLI provides the user interface, `packages/core` is responsible for:
|
||||
|
||||
- **Gemini API interaction:** Securely communicating with the Google Gemini API, sending user prompts, and receiving model responses.
|
||||
- **Prompt engineering:** Constructing effective prompts for the Gemini model, potentially incorporating conversation history, tool definitions, and instructional context from `GEMINI.md` files.
|
||||
- **Prompt engineering:** Constructing effective prompts for the model, potentially incorporating conversation history, tool definitions, and instructional context from context files (e.g., `QWEN.md`).
|
||||
- **Tool management & orchestration:**
|
||||
- Registering available tools (e.g., file system tools, shell command execution).
|
||||
- Interpreting tool use requests from the Gemini model.
|
||||
@@ -48,8 +48,8 @@ The file discovery service is responsible for finding files in the project that
|
||||
|
||||
## Memory discovery service
|
||||
|
||||
The memory discovery service is responsible for finding and loading the `GEMINI.md` files that provide context to the model. It searches for these files in a hierarchical manner, starting from the current working directory and moving up to the project root and the user's home directory. It also searches in subdirectories.
|
||||
The memory discovery service is responsible for finding and loading the context files (default: `QWEN.md`) that provide context to the model. It searches for these files in a hierarchical manner, starting from the current working directory and moving up to the project root and the user's home directory. It also searches in subdirectories.
|
||||
|
||||
This allows you to have global, project-level, and component-level context files, which are all combined to provide the model with the most relevant information.
|
||||
|
||||
You can use the [`/memory` command](../cli/commands.md) to `show`, `add`, and `refresh` the content of loaded `GEMINI.md` files.
|
||||
You can use the [`/memory` command](../cli/commands.md) to `show`, `add`, and `refresh` the content of loaded context files.
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
# Memory Import Processor
|
||||
|
||||
The Memory Import Processor is a feature that allows you to modularize your GEMINI.md files by importing content from other markdown files using the `@file.md` syntax.
|
||||
The Memory Import Processor is a feature that allows you to modularize your context files (e.g., `QWEN.md`) by importing content from other files using the `@file.md` syntax.
|
||||
|
||||
## Overview
|
||||
|
||||
This feature enables you to break down large GEMINI.md files into smaller, more manageable components that can be reused across different contexts. The import processor supports both relative and absolute paths, with built-in safety features to prevent circular imports and ensure file access security.
|
||||
|
||||
## Important Limitations
|
||||
|
||||
**This feature only supports `.md` (markdown) files.** Attempting to import files with other extensions (like `.txt`, `.json`, etc.) will result in a warning and the import will fail.
|
||||
This feature enables you to break down large context files (e.g., `QWEN.md`) into smaller, more manageable components that can be reused across different contexts. The import processor supports both relative and absolute paths, with built-in safety features to prevent circular imports and ensure file access security.
|
||||
|
||||
## Syntax
|
||||
|
||||
Use the `@` symbol followed by the path to the markdown file you want to import:
|
||||
Use the `@` symbol followed by the path to the file you want to import:
|
||||
|
||||
```markdown
|
||||
# Main GEMINI.md file
|
||||
# Main QWEN.md file
|
||||
|
||||
This is the main content.
|
||||
|
||||
@@ -43,7 +39,7 @@ More content here.
|
||||
### Basic Import
|
||||
|
||||
```markdown
|
||||
# My GEMINI.md
|
||||
# My QWEN.md
|
||||
|
||||
Welcome to my project!
|
||||
|
||||
@@ -96,24 +92,10 @@ The `validateImportPath` function ensures that imports are only allowed from spe
|
||||
|
||||
### Maximum Import Depth
|
||||
|
||||
To prevent infinite recursion, there's a configurable maximum import depth (default: 10 levels).
|
||||
To prevent infinite recursion, there's a configurable maximum import depth (default: 5 levels).
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Non-MD File Attempts
|
||||
|
||||
If you try to import a non-markdown file, you'll see a warning:
|
||||
|
||||
```markdown
|
||||
@./instructions.txt <!-- This will show a warning and fail -->
|
||||
```
|
||||
|
||||
Console output:
|
||||
|
||||
```
|
||||
[WARN] [ImportProcessor] Import processor only supports .md files. Attempting to import non-md file: ./instructions.txt. This will fail.
|
||||
```
|
||||
|
||||
### Missing Files
|
||||
|
||||
If a referenced file doesn't exist, the import will fail gracefully with an error comment in the output.
|
||||
@@ -122,11 +104,41 @@ If a referenced file doesn't exist, the import will fail gracefully with an erro
|
||||
|
||||
Permission issues or other file system errors are handled gracefully with appropriate error messages.
|
||||
|
||||
## Code Region Detection
|
||||
|
||||
The import processor uses the `marked` library to detect code blocks and inline code spans, ensuring that `@` imports inside these regions are properly ignored. This provides robust handling of nested code blocks and complex Markdown structures.
|
||||
|
||||
## Import Tree Structure
|
||||
|
||||
The processor returns an import tree that shows the hierarchy of imported files. This helps users debug problems with their context files by showing which files were read and their import relationships.
|
||||
|
||||
Example tree structure:
|
||||
|
||||
```
|
||||
Memory Files
|
||||
L project: QWEN.md
|
||||
L a.md
|
||||
L b.md
|
||||
L c.md
|
||||
L d.md
|
||||
L e.md
|
||||
L f.md
|
||||
L included.md
|
||||
```
|
||||
|
||||
The tree preserves the order that files were imported and shows the complete import chain for debugging purposes.
|
||||
|
||||
## Comparison to Claude Code's `/memory` (`claude.md`) Approach
|
||||
|
||||
Claude Code's `/memory` feature (as seen in `claude.md`) produces a flat, linear document by concatenating all included files, always marking file boundaries with clear comments and path names. It does not explicitly present the import hierarchy, but the LLM receives all file contents and paths, which is sufficient for reconstructing the hierarchy if needed.
|
||||
|
||||
Note: The import tree is mainly for clarity during development and has limited relevance to LLM consumption.
|
||||
|
||||
## API Reference
|
||||
|
||||
### `processImports(content, basePath, debugMode?, importState?)`
|
||||
|
||||
Processes import statements in GEMINI.md content.
|
||||
Processes import statements in context file content.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
@@ -135,7 +147,25 @@ Processes import statements in GEMINI.md content.
|
||||
- `debugMode` (boolean, optional): Whether to enable debug logging (default: false)
|
||||
- `importState` (ImportState, optional): State tracking for circular import prevention
|
||||
|
||||
**Returns:** Promise<string> - Processed content with imports resolved
|
||||
**Returns:** Promise<ProcessImportsResult> - Object containing processed content and import tree
|
||||
|
||||
### `ProcessImportsResult`
|
||||
|
||||
```typescript
|
||||
interface ProcessImportsResult {
|
||||
content: string; // The processed content with imports resolved
|
||||
importTree: MemoryFile; // Tree structure showing the import hierarchy
|
||||
}
|
||||
```
|
||||
|
||||
### `MemoryFile`
|
||||
|
||||
```typescript
|
||||
interface MemoryFile {
|
||||
path: string; // The file path
|
||||
imports?: MemoryFile[]; // Direct imports, in the order they were imported
|
||||
}
|
||||
```
|
||||
|
||||
### `validateImportPath(importPath, basePath, allowedDirectories)`
|
||||
|
||||
@@ -149,6 +179,16 @@ Validates import paths to ensure they are safe and within allowed directories.
|
||||
|
||||
**Returns:** boolean - Whether the import path is valid
|
||||
|
||||
### `findProjectRoot(startDir)`
|
||||
|
||||
Finds the project root by searching for a `.git` directory upwards from the given start directory. Implemented as an **async** function using non-blocking file system APIs to avoid blocking the Node.js event loop.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `startDir` (string): The directory to start searching from
|
||||
|
||||
**Returns:** Promise<string> - The project root directory (or the start directory if no `.git` is found)
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use descriptive file names** for imported components
|
||||
@@ -161,7 +201,7 @@ Validates import paths to ensure they are safe and within allowed directories.
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Import not working**: Check that the file exists and has a `.md` extension
|
||||
1. **Import not working**: Check that the file exists and the path is correct
|
||||
2. **Circular import warnings**: Review your import structure for circular references
|
||||
3. **Permission errors**: Ensure the files are readable and within allowed directories
|
||||
4. **Path resolution issues**: Use absolute paths if relative paths aren't resolving correctly
|
||||
|
||||
@@ -8,7 +8,7 @@ The Gemini CLI core (`packages/core`) features a robust system for defining, reg
|
||||
- `name`: A unique internal name (used in API calls to Gemini).
|
||||
- `displayName`: A user-friendly name.
|
||||
- `description`: A clear explanation of what the tool does, which is provided to the Gemini model.
|
||||
- `parameterSchema`: A JSON schema defining the parameters the tool accepts. This is crucial for the Gemini model to understand how to call the tool correctly.
|
||||
- `parameterSchema`: A JSON schema defining the parameters that the tool accepts. This is crucial for the Gemini model to understand how to call the tool correctly.
|
||||
- `validateToolParams()`: A method to validate incoming parameters.
|
||||
- `getDescription()`: A method to provide a human-readable description of what the tool will do with specific parameters before execution.
|
||||
- `shouldConfirmExecute()`: A method to determine if user confirmation is required before execution (e.g., for potentially destructive operations).
|
||||
|
||||
@@ -6,14 +6,14 @@ Gemini CLI supports extensions that can be used to configure and extend its func
|
||||
|
||||
On startup, Gemini CLI looks for extensions in two locations:
|
||||
|
||||
1. `<workspace>/.qwen/extensions`
|
||||
2. `<home>/.qwen/extensions`
|
||||
1. `<workspace>/.gemini/extensions`
|
||||
2. `<home>/.gemini/extensions`
|
||||
|
||||
Gemini CLI loads all extensions from both locations. If an extension with the same name exists in both locations, the extension in the workspace directory takes precedence.
|
||||
|
||||
Within each location, individual extensions exist as a directory that contains a `gemini-extension.json` file. For example:
|
||||
|
||||
`<workspace>/.qwen/extensions/my-extension/gemini-extension.json`
|
||||
`<workspace>/.gemini/extensions/my-extension/gemini-extension.json`
|
||||
|
||||
### `gemini-extension.json`
|
||||
|
||||
@@ -28,15 +28,49 @@ The `gemini-extension.json` file contains the configuration for the extension. T
|
||||
"command": "node my-server.js"
|
||||
}
|
||||
},
|
||||
"contextFileName": "GEMINI.md",
|
||||
"contextFileName": "QWEN.md",
|
||||
"excludeTools": ["run_shell_command"]
|
||||
}
|
||||
```
|
||||
|
||||
- `name`: The name of the extension. This is used to uniquely identify the extension. This should match the name of your extension directory.
|
||||
- `name`: The name of the extension. This is used to uniquely identify the extension and for conflict resolution when extension commands have the same name as user or project commands.
|
||||
- `version`: The version of the extension.
|
||||
- `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence.
|
||||
- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the workspace. If this property is not used but a `GEMINI.md` file is present in your extension directory, then that file will be loaded.
|
||||
- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the workspace. If this property is not used but a `QWEN.md` file is present in your extension directory, then that file will be loaded.
|
||||
- `excludeTools`: An array of tool names to exclude from the model. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command.
|
||||
|
||||
When Gemini CLI starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence.
|
||||
|
||||
## Extension Commands
|
||||
|
||||
Extensions can provide [custom commands](./cli/commands.md#custom-commands) by placing TOML files in a `commands/` subdirectory within the extension directory. These commands follow the same format as user and project custom commands and use standard naming conventions.
|
||||
|
||||
### Example
|
||||
|
||||
An extension named `gcp` with the following structure:
|
||||
|
||||
```
|
||||
.gemini/extensions/gcp/
|
||||
├── gemini-extension.json
|
||||
└── commands/
|
||||
├── deploy.toml
|
||||
└── gcs/
|
||||
└── sync.toml
|
||||
```
|
||||
|
||||
Would provide these commands:
|
||||
|
||||
- `/deploy` - Shows as `[gcp] Custom command from deploy.toml` in help
|
||||
- `/gcs:sync` - Shows as `[gcp] Custom command from sync.toml` in help
|
||||
|
||||
### Conflict Resolution
|
||||
|
||||
Extension commands have the lowest precedence. When a conflict occurs with user or project commands:
|
||||
|
||||
1. **No conflict**: Extension command uses its natural name (e.g., `/deploy`)
|
||||
2. **With conflict**: Extension command is renamed with the extension prefix (e.g., `/gcp.deploy`)
|
||||
|
||||
For example, if both a user and the `gcp` extension define a `deploy` command:
|
||||
|
||||
- `/deploy` - Executes the user's deploy command
|
||||
- `/gcp.deploy` - Executes the extension's deploy command (marked with `[gcp]` tag)
|
||||
|
||||
59
docs/gemini-ignore.md
Normal file
59
docs/gemini-ignore.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Ignoring Files
|
||||
|
||||
This document provides an overview of the Gemini Ignore (`.geminiignore`) feature of the Gemini CLI.
|
||||
|
||||
The Gemini CLI includes the ability to automatically ignore files, similar to `.gitignore` (used by Git) and `.aiexclude` (used by Gemini Code Assist). Adding paths to your `.geminiignore` file will exclude them from tools that support this feature, although they will still be visible to other services (such as Git).
|
||||
|
||||
## How it works
|
||||
|
||||
When you add a path to your `.geminiignore` file, tools that respect this file will exclude matching files and directories from their operations. For example, when you use the [`read_many_files`](./tools/multi-file.md) command, any paths in your `.geminiignore` file will be automatically excluded.
|
||||
|
||||
For the most part, `.geminiignore` follows the conventions of `.gitignore` files:
|
||||
|
||||
- Blank lines and lines starting with `#` are ignored.
|
||||
- Standard glob patterns are supported (such as `*`, `?`, and `[]`).
|
||||
- Putting a `/` at the end will only match directories.
|
||||
- Putting a `/` at the beginning anchors the path relative to the `.geminiignore` file.
|
||||
- `!` negates a pattern.
|
||||
|
||||
You can update your `.geminiignore` file at any time. To apply the changes, you must restart your Gemini CLI session.
|
||||
|
||||
## How to use `.geminiignore`
|
||||
|
||||
To enable `.geminiignore`:
|
||||
|
||||
1. Create a file named `.geminiignore` in the root of your project directory.
|
||||
|
||||
To add a file or directory to `.geminiignore`:
|
||||
|
||||
1. Open your `.geminiignore` file.
|
||||
2. Add the path or file you want to ignore, for example: `/archive/` or `apikeys.txt`.
|
||||
|
||||
### `.geminiignore` examples
|
||||
|
||||
You can use `.geminiignore` to ignore directories and files:
|
||||
|
||||
```
|
||||
# Exclude your /packages/ directory and all subdirectories
|
||||
/packages/
|
||||
|
||||
# Exclude your apikeys.txt file
|
||||
apikeys.txt
|
||||
```
|
||||
|
||||
You can use wildcards in your `.geminiignore` file with `*`:
|
||||
|
||||
```
|
||||
# Exclude all .md files
|
||||
*.md
|
||||
```
|
||||
|
||||
Finally, you can exclude files and directories from exclusion with `!`:
|
||||
|
||||
```
|
||||
# Exclude all .md files except README.md
|
||||
*.md
|
||||
!README.md
|
||||
```
|
||||
|
||||
To remove paths from your `.geminiignore` file, delete the relevant lines.
|
||||
@@ -132,7 +132,7 @@ This structure makes it easy to locate the artifacts for a specific test run, fi
|
||||
|
||||
## Continuous integration
|
||||
|
||||
To ensure the integration tests are always run, a GitHub Actions workflow is defined in `.github/workflows/e2e.yml`. This workflow automatically runs the integration tests on every pull request and push to the `main` branch.
|
||||
To ensure the integration tests are always run, a GitHub Actions workflow is defined in `.github/workflows/e2e.yml`. This workflow automatically runs the integrations tests for pull requests against the `main` branch, or when a pull request is added to a merge queue.
|
||||
|
||||
The workflow runs the tests in different sandboxing environments to ensure Gemini CLI is tested across each:
|
||||
|
||||
|
||||
84
docs/issue-and-pr-automation.md
Normal file
84
docs/issue-and-pr-automation.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Automation and Triage Processes
|
||||
|
||||
This document provides a detailed overview of the automated processes we use to manage and triage issues and pull requests. Our goal is to provide prompt feedback and ensure that contributions are reviewed and integrated efficiently. Understanding this automation will help you as a contributor know what to expect and how to best interact with our repository bots.
|
||||
|
||||
## Guiding Principle: Issues and Pull Requests
|
||||
|
||||
First and foremost, almost every Pull Request (PR) should be linked to a corresponding Issue. The issue describes the "what" and the "why" (the bug or feature), while the PR is the "how" (the implementation). This separation helps us track work, prioritize features, and maintain clear historical context. Our automation is built around this principle.
|
||||
|
||||
---
|
||||
|
||||
## Detailed Automation Workflows
|
||||
|
||||
Here is a breakdown of the specific automation workflows that run in our repository.
|
||||
|
||||
### 1. When you open an Issue: `Automated Issue Triage`
|
||||
|
||||
This is the first bot you will interact with when you create an issue. Its job is to perform an initial analysis and apply the correct labels.
|
||||
|
||||
- **Workflow File**: `.github/workflows/gemini-automated-issue-triage.yml`
|
||||
- **When it runs**: Immediately after an issue is created or reopened.
|
||||
- **What it does**:
|
||||
- It uses a Gemini model to analyze the issue's title and body against a detailed set of guidelines.
|
||||
- **Applies one `area/*` label**: Categorizes the issue into a functional area of the project (e.g., `area/ux`, `area/models`, `area/platform`).
|
||||
- **Applies one `kind/*` label**: Identifies the type of issue (e.g., `kind/bug`, `kind/enhancement`, `kind/question`).
|
||||
- **Applies one `priority/*` label**: Assigns a priority from P0 (critical) to P3 (low) based on the described impact.
|
||||
- **May apply `status/need-information`**: If the issue lacks critical details (like logs or reproduction steps), it will be flagged for more information.
|
||||
- **May apply `status/need-retesting`**: If the issue references a CLI version that is more than six versions old, it will be flagged for retesting on a current version.
|
||||
- **What you should do**:
|
||||
- Fill out the issue template as completely as possible. The more detail you provide, the more accurate the triage will be.
|
||||
- If the `status/need-information` label is added, please provide the requested details in a comment.
|
||||
|
||||
### 2. When you open a Pull Request: `Continuous Integration (CI)`
|
||||
|
||||
This workflow ensures that all changes meet our quality standards before they can be merged.
|
||||
|
||||
- **Workflow File**: `.github/workflows/ci.yml`
|
||||
- **When it runs**: On every push to a pull request.
|
||||
- **What it does**:
|
||||
- **Lint**: Checks that your code adheres to our project's formatting and style rules.
|
||||
- **Test**: Runs our full suite of automated tests across macOS, Windows, and Linux, and on multiple Node.js versions. This is the most time-consuming part of the CI process.
|
||||
- **Post Coverage Comment**: After all tests have successfully passed, a bot will post a comment on your PR. This comment provides a summary of how well your changes are covered by tests.
|
||||
- **What you should do**:
|
||||
- Ensure all CI checks pass. A green checkmark ✅ will appear next to your commit when everything is successful.
|
||||
- If a check fails (a red "X" ❌), click the "Details" link next to the failed check to view the logs, identify the problem, and push a fix.
|
||||
|
||||
### 3. Ongoing Triage for Pull Requests: `PR Auditing and Label Sync`
|
||||
|
||||
This workflow runs periodically to ensure all open PRs are correctly linked to issues and have consistent labels.
|
||||
|
||||
- **Workflow File**: `.github/workflows/gemini-scheduled-pr-triage.yml`
|
||||
- **When it runs**: Every 15 minutes on all open pull requests.
|
||||
- **What it does**:
|
||||
- **Checks for a linked issue**: The bot scans your PR description for a keyword that links it to an issue (e.g., `Fixes #123`, `Closes #456`).
|
||||
- **Adds `status/need-issue`**: If no linked issue is found, the bot will add the `status/need-issue` label to your PR. This is a clear signal that an issue needs to be created and linked.
|
||||
- **Synchronizes labels**: If an issue _is_ linked, the bot ensures the PR's labels perfectly match the issue's labels. It will add any missing labels and remove any that don't belong, and it will remove the `status/need-issue` label if it was present.
|
||||
- **What you should do**:
|
||||
- **Always link your PR to an issue.** This is the most important step. Add a line like `Resolves #<issue-number>` to your PR description.
|
||||
- This will ensure your PR is correctly categorized and moves through the review process smoothly.
|
||||
|
||||
### 4. Ongoing Triage for Issues: `Scheduled Issue Triage`
|
||||
|
||||
This is a fallback workflow to ensure that no issue gets missed by the triage process.
|
||||
|
||||
- **Workflow File**: `.github/workflows/gemini-scheduled-issue-triage.yml`
|
||||
- **When it runs**: Every hour on all open issues.
|
||||
- **What it does**:
|
||||
- It actively seeks out issues that either have no labels at all or still have the `status/need-triage` label.
|
||||
- It then triggers the same powerful Gemini-based analysis as the initial triage bot to apply the correct labels.
|
||||
- **What you should do**:
|
||||
- You typically don't need to do anything. This workflow is a safety net to ensure every issue is eventually categorized, even if the initial triage fails.
|
||||
|
||||
### 5. Release Automation
|
||||
|
||||
This workflow handles the process of packaging and publishing new versions of the Gemini CLI.
|
||||
|
||||
- **Workflow File**: `.github/workflows/release.yml`
|
||||
- **When it runs**: On a daily schedule for "nightly" releases, and manually for official patch/minor releases.
|
||||
- **What it does**:
|
||||
- Automatically builds the project, bumps the version numbers, and publishes the packages to npm.
|
||||
- Creates a corresponding release on GitHub with generated release notes.
|
||||
- **What you should do**:
|
||||
- As a contributor, you don't need to do anything for this process. You can be confident that once your PR is merged into the `main` branch, your changes will be included in the very next nightly release.
|
||||
|
||||
We hope this detailed overview is helpful. If you have any questions about our automation or processes, please don't hesitate to ask!
|
||||
62
docs/keyboard-shortcuts.md
Normal file
62
docs/keyboard-shortcuts.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Gemini CLI Keyboard Shortcuts
|
||||
|
||||
This document lists the available keyboard shortcuts in the Gemini CLI.
|
||||
|
||||
## General
|
||||
|
||||
| Shortcut | Description |
|
||||
| -------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `Esc` | Close dialogs and suggestions. |
|
||||
| `Ctrl+C` | Exit the application. Press twice to confirm. |
|
||||
| `Ctrl+D` | Exit the application if the input is empty. Press twice to confirm. |
|
||||
| `Ctrl+L` | Clear the screen. |
|
||||
| `Ctrl+O` | Toggle the display of the debug console. |
|
||||
| `Ctrl+S` | Allows long responses to print fully, disabling truncation. Use your terminal's scrollback to view the entire output. |
|
||||
| `Ctrl+T` | Toggle the display of tool descriptions. |
|
||||
| `Ctrl+Y` | Toggle auto-approval (YOLO mode) for all tool calls. |
|
||||
|
||||
## Input Prompt
|
||||
|
||||
| Shortcut | Description |
|
||||
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `!` | Toggle shell mode when the input is empty. |
|
||||
| `\` (at end of line) + `Enter` | Insert a newline. |
|
||||
| `Down Arrow` | Navigate down through the input history. |
|
||||
| `Enter` | Submit the current prompt. |
|
||||
| `Meta+Delete` / `Ctrl+Delete` | Delete the word to the right of the cursor. |
|
||||
| `Tab` | Autocomplete the current suggestion if one exists. |
|
||||
| `Up Arrow` | Navigate up through the input history. |
|
||||
| `Ctrl+A` / `Home` | Move the cursor to the beginning of the line. |
|
||||
| `Ctrl+B` / `Left Arrow` | Move the cursor one character to the left. |
|
||||
| `Ctrl+C` | Clear the input prompt |
|
||||
| `Ctrl+D` / `Delete` | Delete the character to the right of the cursor. |
|
||||
| `Ctrl+E` / `End` | Move the cursor to the end of the line. |
|
||||
| `Ctrl+F` / `Right Arrow` | Move the cursor one character to the right. |
|
||||
| `Ctrl+H` / `Backspace` | Delete the character to the left of the cursor. |
|
||||
| `Ctrl+K` | Delete from the cursor to the end of the line. |
|
||||
| `Ctrl+Left Arrow` / `Meta+Left Arrow` / `Meta+B` | Move the cursor one word to the left. |
|
||||
| `Ctrl+N` | Navigate down through the input history. |
|
||||
| `Ctrl+P` | Navigate up through the input history. |
|
||||
| `Ctrl+Right Arrow` / `Meta+Right Arrow` / `Meta+F` | Move the cursor one word to the right. |
|
||||
| `Ctrl+U` | Delete from the cursor to the beginning of the line. |
|
||||
| `Ctrl+V` | Paste clipboard content. If the clipboard contains an image, it will be saved and a reference to it will be inserted in the prompt. |
|
||||
| `Ctrl+W` / `Meta+Backspace` / `Ctrl+Backspace` | Delete the word to the left of the cursor. |
|
||||
| `Ctrl+X` / `Meta+Enter` | Open the current input in an external editor. |
|
||||
|
||||
## Suggestions
|
||||
|
||||
| Shortcut | Description |
|
||||
| --------------- | -------------------------------------- |
|
||||
| `Down Arrow` | Navigate down through the suggestions. |
|
||||
| `Tab` / `Enter` | Accept the selected suggestion. |
|
||||
| `Up Arrow` | Navigate up through the suggestions. |
|
||||
|
||||
## Radio Button Select
|
||||
|
||||
| Shortcut | Description |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------- |
|
||||
| `Down Arrow` / `j` | Move selection down. |
|
||||
| `Enter` | Confirm selection. |
|
||||
| `Up Arrow` / `k` | Move selection up. |
|
||||
| `1-9` | Select an item by its number. |
|
||||
| (multi-digit) | For items with numbers greater than 9, press the digits in quick succession to select the corresponding item. |
|
||||
@@ -77,6 +77,24 @@ Built-in profiles (set via `SEATBELT_PROFILE` env var):
|
||||
- `restrictive-open`: Strict restrictions, network allowed
|
||||
- `restrictive-closed`: Maximum restrictions
|
||||
|
||||
### Custom Sandbox Flags
|
||||
|
||||
For container-based sandboxing, you can inject custom flags into the `docker` or `podman` command using the `SANDBOX_FLAGS` environment variable. This is useful for advanced configurations, such as disabling security features for specific use cases.
|
||||
|
||||
**Example (Podman)**:
|
||||
|
||||
To disable SELinux labeling for volume mounts, you can set the following:
|
||||
|
||||
```bash
|
||||
export SANDBOX_FLAGS="--security-opt label=disable"
|
||||
```
|
||||
|
||||
Multiple flags can be provided as a space-separated string:
|
||||
|
||||
```bash
|
||||
export SANDBOX_FLAGS="--flag1 --flag2=value"
|
||||
```
|
||||
|
||||
## Linux UID/GID handling
|
||||
|
||||
The sandbox automatically handles user permissions on Linux. Override these permissions with:
|
||||
@@ -111,6 +129,8 @@ export SANDBOX_SET_UID_GID=false # Disable UID/GID mapping
|
||||
DEBUG=1 gemini -s -p "debug command"
|
||||
```
|
||||
|
||||
**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect gemini-cli due to automatic exclusion. Use `.gemini/.env` files for gemini-cli specific debug settings.
|
||||
|
||||
### Inspect sandbox
|
||||
|
||||
```bash
|
||||
|
||||
@@ -8,7 +8,7 @@ Gemini CLI's telemetry system is built on the **[OpenTelemetry] (OTEL)** standar
|
||||
|
||||
## Enabling telemetry
|
||||
|
||||
You can enable telemetry in multiple ways. Configuration is primarily managed via the [`.qwen/settings.json` file](./cli/configuration.md) and environment variables, but CLI flags can override these settings for a specific session.
|
||||
You can enable telemetry in multiple ways. Configuration is primarily managed via the [`.gemini/settings.json` file](./cli/configuration.md) and environment variables, but CLI flags can override these settings for a specific session.
|
||||
|
||||
### Order of precedence
|
||||
|
||||
@@ -19,13 +19,14 @@ The following lists the precedence for applying telemetry settings, with items l
|
||||
- `--telemetry-target <local|gcp>`: Overrides `telemetry.target`.
|
||||
- `--telemetry-otlp-endpoint <URL>`: Overrides `telemetry.otlpEndpoint`.
|
||||
- `--telemetry-log-prompts` / `--no-telemetry-log-prompts`: Overrides `telemetry.logPrompts`.
|
||||
- `--telemetry-outfile <path>`: Redirects telemetry output to a file. See [Exporting to a file](#exporting-to-a-file).
|
||||
|
||||
1. **Environment variables:**
|
||||
- `OTEL_EXPORTER_OTLP_ENDPOINT`: Overrides `telemetry.otlpEndpoint`.
|
||||
|
||||
1. **Workspace settings file (`.qwen/settings.json`):** Values from the `telemetry` object in this project-specific file.
|
||||
1. **Workspace settings file (`.gemini/settings.json`):** Values from the `telemetry` object in this project-specific file.
|
||||
|
||||
1. **User settings file (`~/.qwen/settings.json`):** Values from the `telemetry` object in this global user file.
|
||||
1. **User settings file (`~/.gemini/settings.json`):** Values from the `telemetry` object in this global user file.
|
||||
|
||||
1. **Defaults:** applied if not set by any of the above.
|
||||
- `telemetry.enabled`: `false`
|
||||
@@ -38,7 +39,7 @@ The `--target` argument to this script _only_ overrides the `telemetry.target` f
|
||||
|
||||
### Example settings
|
||||
|
||||
The following code can be added to your workspace (`.qwen/settings.json`) or user (`~/.qwen/settings.json`) settings to enable telemetry and send the output to Google Cloud:
|
||||
The following code can be added to your workspace (`.gemini/settings.json`) or user (`~/.gemini/settings.json`) settings to enable telemetry and send the output to Google Cloud:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -50,6 +51,16 @@ The following code can be added to your workspace (`.qwen/settings.json`) or use
|
||||
}
|
||||
```
|
||||
|
||||
### Exporting to a file
|
||||
|
||||
You can export all telemetry data to a file for local inspection.
|
||||
|
||||
To enable file export, use the `--telemetry-outfile` flag with a path to your desired output file. This must be run using `--telemetry-target=local`.
|
||||
|
||||
```bash
|
||||
gemini --telemetry --telemetry-target=local --telemetry-outfile=/path/to/telemetry.log "your prompt"
|
||||
```
|
||||
|
||||
## Running an OTEL Collector
|
||||
|
||||
An OTEL Collector is a service that receives, processes, and exports telemetry data.
|
||||
@@ -61,7 +72,7 @@ Learn more about OTEL exporter standard configuration in [documentation][otel-co
|
||||
|
||||
### Local
|
||||
|
||||
Use the `npm run telemetry -- --target=local` command to automate the process of setting up a local telemetry pipeline, including configuring the necessary settings in your `.qwen/settings.json` file. The underlying script installs `otelcol-contrib` (the OpenTelemetry Collector) and `jaeger` (The Jaeger UI for viewing traces). To use it:
|
||||
Use the `npm run telemetry -- --target=local` command to automate the process of setting up a local telemetry pipeline, including configuring the necessary settings in your `.gemini/settings.json` file. The underlying script installs `otelcol-contrib` (the OpenTelemetry Collector) and `jaeger` (The Jaeger UI for viewing traces). To use it:
|
||||
|
||||
1. **Run the command**:
|
||||
Execute the command from the root of the repository:
|
||||
@@ -81,14 +92,14 @@ Use the `npm run telemetry -- --target=local` command to automate the process of
|
||||
Open your web browser and navigate to **http://localhost:16686** to access the Jaeger UI. Here you can inspect detailed traces of Gemini CLI operations.
|
||||
|
||||
1. **Inspect logs and metrics**:
|
||||
The script redirects the OTEL collector output (which includes logs and metrics) to `~/.qwen/tmp/<projectHash>/otel/collector.log`. The script will provide links to view and a command to tail your telemetry data (traces, metrics, logs) locally.
|
||||
The script redirects the OTEL collector output (which includes logs and metrics) to `~/.gemini/tmp/<projectHash>/otel/collector.log`. The script will provide links to view and a command to tail your telemetry data (traces, metrics, logs) locally.
|
||||
|
||||
1. **Stop the services**:
|
||||
Press `Ctrl+C` in the terminal where the script is running to stop the OTEL Collector and Jaeger services.
|
||||
|
||||
### Google Cloud
|
||||
|
||||
Use the `npm run telemetry -- --target=gcp` command to automate setting up a local OpenTelemetry collector that forwards data to your Google Cloud project, including configuring the necessary settings in your `.qwen/settings.json` file. The underlying script installs `otelcol-contrib`. To use it:
|
||||
Use the `npm run telemetry -- --target=gcp` command to automate setting up a local OpenTelemetry collector that forwards data to your Google Cloud project, including configuring the necessary settings in your `.gemini/settings.json` file. The underlying script installs `otelcol-contrib`. To use it:
|
||||
|
||||
1. **Prerequisites**:
|
||||
- Have a Google Cloud project ID.
|
||||
@@ -109,7 +120,7 @@ Use the `npm run telemetry -- --target=gcp` command to automate setting up a loc
|
||||
The script will:
|
||||
- Download the `otelcol-contrib` binary if needed.
|
||||
- Start an OTEL collector configured to receive data from Gemini CLI and export it to your specified Google Cloud project.
|
||||
- Automatically enable telemetry and disable sandbox mode in your workspace settings (`.qwen/settings.json`).
|
||||
- Automatically enable telemetry and disable sandbox mode in your workspace settings (`.gemini/settings.json`).
|
||||
- Provide direct links to view traces, metrics, and logs in your Google Cloud Console.
|
||||
- On exit (Ctrl+C), it will attempt to restore your original telemetry and sandbox settings.
|
||||
|
||||
@@ -120,7 +131,7 @@ Use the `npm run telemetry -- --target=gcp` command to automate setting up a loc
|
||||
Use the links provided by the script to navigate to the Google Cloud Console and view your traces, metrics, and logs.
|
||||
|
||||
1. **Inspect local collector logs**:
|
||||
The script redirects the local OTEL collector output to `~/.qwen/tmp/<projectHash>/otel/collector-gcp.log`. The script provides links to view and command to tail your collector logs locally.
|
||||
The script redirects the local OTEL collector output to `~/.gemini/tmp/<projectHash>/otel/collector-gcp.log`. The script provides links to view and command to tail your collector logs locally.
|
||||
|
||||
1. **Stop the service**:
|
||||
Press `Ctrl+C` in the terminal where the script is running to stop the OTEL Collector.
|
||||
@@ -198,6 +209,11 @@ Logs are timestamped records of specific events. The following events are logged
|
||||
- **Attributes**:
|
||||
- `auth_type`
|
||||
|
||||
- `gemini_cli.slash_command`: This event occurs when a user executes a slash command.
|
||||
- **Attributes**:
|
||||
- `command` (string)
|
||||
- `subcommand` (string, if applicable)
|
||||
|
||||
### Metrics
|
||||
|
||||
Metrics are numerical measurements of behavior over time. The following metrics are collected for Gemini CLI:
|
||||
|
||||
@@ -90,7 +90,7 @@ The Gemini CLI provides a comprehensive suite of tools for interacting with the
|
||||
- `path` (string, optional): The absolute path to the directory to search within. Defaults to the current working directory.
|
||||
- `include` (string, optional): A glob pattern to filter which files are searched (e.g., `"*.js"`, `"src/**/*.{ts,tsx}"`). If omitted, searches most files (respecting common ignores).
|
||||
- **Behavior:**
|
||||
- Uses `git grep` if available in a Git repository for speed, otherwise falls back to system `grep` or a JavaScript-based search.
|
||||
- Uses `git grep` if available in a Git repository for speed; otherwise, falls back to system `grep` or a JavaScript-based search.
|
||||
- Returns a list of matching lines, each prefixed with its file path (relative to the search directory) and line number.
|
||||
- **Output (`llmContent`):** A formatted string of matches, e.g.:
|
||||
```
|
||||
|
||||
@@ -51,7 +51,7 @@ The Gemini CLI uses the `mcpServers` configuration in your `settings.json` file
|
||||
|
||||
### Configure the MCP server in settings.json
|
||||
|
||||
You can configure MCP servers at the global level in the `~/.qwen/settings.json` file or in your project's root directory, create or open the `.qwen/settings.json` file. Within the file, add the `mcpServers` configuration block.
|
||||
You can configure MCP servers at the global level in the `~/.gemini/settings.json` file or in your project's root directory, create or open the `.gemini/settings.json` file. Within the file, add the `mcpServers` configuration block.
|
||||
|
||||
### Configuration Structure
|
||||
|
||||
@@ -92,6 +92,114 @@ Each server configuration supports the following properties:
|
||||
- **`cwd`** (string): Working directory for Stdio transport
|
||||
- **`timeout`** (number): Request timeout in milliseconds (default: 600,000ms = 10 minutes)
|
||||
- **`trust`** (boolean): When `true`, bypasses all tool call confirmations for this server (default: `false`)
|
||||
- **`includeTools`** (string[]): List of tool names to include from this MCP server. When specified, only the tools listed here will be available from this server (whitelist behavior). If not specified, all tools from the server are enabled by default.
|
||||
- **`excludeTools`** (string[]): List of tool names to exclude from this MCP server. Tools listed here will not be available to the model, even if they are exposed by the server. **Note:** `excludeTools` takes precedence over `includeTools` - if a tool is in both lists, it will be excluded.
|
||||
|
||||
### OAuth Support for Remote MCP Servers
|
||||
|
||||
The Gemini CLI supports OAuth 2.0 authentication for remote MCP servers using SSE or HTTP transports. This enables secure access to MCP servers that require authentication.
|
||||
|
||||
#### Automatic OAuth Discovery
|
||||
|
||||
For servers that support OAuth discovery, you can omit the OAuth configuration and let the CLI discover it automatically:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"discoveredServer": {
|
||||
"url": "https://api.example.com/sse"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The CLI will automatically:
|
||||
|
||||
- Detect when a server requires OAuth authentication (401 responses)
|
||||
- Discover OAuth endpoints from server metadata
|
||||
- Perform dynamic client registration if supported
|
||||
- Handle the OAuth flow and token management
|
||||
|
||||
#### Authentication Flow
|
||||
|
||||
When connecting to an OAuth-enabled server:
|
||||
|
||||
1. **Initial connection attempt** fails with 401 Unauthorized
|
||||
2. **OAuth discovery** finds authorization and token endpoints
|
||||
3. **Browser opens** for user authentication (requires local browser access)
|
||||
4. **Authorization code** is exchanged for access tokens
|
||||
5. **Tokens are stored** securely for future use
|
||||
6. **Connection retry** succeeds with valid tokens
|
||||
|
||||
#### Browser Redirect Requirements
|
||||
|
||||
**Important:** OAuth authentication requires that your local machine can:
|
||||
|
||||
- Open a web browser for authentication
|
||||
- Receive redirects on `http://localhost:7777/oauth/callback`
|
||||
|
||||
This feature will not work in:
|
||||
|
||||
- Headless environments without browser access
|
||||
- Remote SSH sessions without X11 forwarding
|
||||
- Containerized environments without browser support
|
||||
|
||||
#### Managing OAuth Authentication
|
||||
|
||||
Use the `/mcp auth` command to manage OAuth authentication:
|
||||
|
||||
```bash
|
||||
# List servers requiring authentication
|
||||
/mcp auth
|
||||
|
||||
# Authenticate with a specific server
|
||||
/mcp auth serverName
|
||||
|
||||
# Re-authenticate if tokens expire
|
||||
/mcp auth serverName
|
||||
```
|
||||
|
||||
#### OAuth Configuration Properties
|
||||
|
||||
- **`enabled`** (boolean): Enable OAuth for this server
|
||||
- **`clientId`** (string): OAuth client identifier (optional with dynamic registration)
|
||||
- **`clientSecret`** (string): OAuth client secret (optional for public clients)
|
||||
- **`authorizationUrl`** (string): OAuth authorization endpoint (auto-discovered if omitted)
|
||||
- **`tokenUrl`** (string): OAuth token endpoint (auto-discovered if omitted)
|
||||
- **`scopes`** (string[]): Required OAuth scopes
|
||||
- **`redirectUri`** (string): Custom redirect URI (defaults to `http://localhost:7777/oauth/callback`)
|
||||
- **`tokenParamName`** (string): Query parameter name for tokens in SSE URLs
|
||||
|
||||
#### Token Management
|
||||
|
||||
OAuth tokens are automatically:
|
||||
|
||||
- **Stored securely** in `~/.gemini/mcp-oauth-tokens.json`
|
||||
- **Refreshed** when expired (if refresh tokens are available)
|
||||
- **Validated** before each connection attempt
|
||||
- **Cleaned up** when invalid or expired
|
||||
|
||||
#### Authentication Provider Type
|
||||
|
||||
You can specify the authentication provider type using the `authProviderType` property:
|
||||
|
||||
- **`authProviderType`** (string): Specifies the authentication provider. Can be one of the following:
|
||||
- **`dynamic_discovery`** (default): The CLI will automatically discover the OAuth configuration from the server.
|
||||
- **`google_credentials`**: The CLI will use the Google Application Default Credentials (ADC) to authenticate with the server. When using this provider, you must specify the required scopes.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"googleCloudServer": {
|
||||
"httpUrl": "https://my-gcp-service.run.app/mcp",
|
||||
"authProviderType": "google_credentials",
|
||||
"oauth": {
|
||||
"scopes": ["https://www.googleapis.com/auth/userinfo.email"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example Configurations
|
||||
|
||||
@@ -185,6 +293,22 @@ Each server configuration supports the following properties:
|
||||
}
|
||||
```
|
||||
|
||||
#### MCP Server with Tool Filtering
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"filteredServer": {
|
||||
"command": "python",
|
||||
"args": ["-m", "my_mcp_server"],
|
||||
"includeTools": ["safe_tool", "file_reader", "data_processor"],
|
||||
// "excludeTools": ["dangerous_tool", "file_deleter"],
|
||||
"timeout": 30000
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Discovery Process Deep Dive
|
||||
|
||||
When the Gemini CLI starts, it performs MCP server discovery through the following detailed process:
|
||||
@@ -207,7 +331,8 @@ Upon successful connection:
|
||||
|
||||
1. **Tool listing:** The client calls the MCP server's tool listing endpoint
|
||||
2. **Schema validation:** Each tool's function declaration is validated
|
||||
3. **Name sanitization:** Tool names are cleaned to meet Gemini API requirements:
|
||||
3. **Tool filtering:** Tools are filtered based on `includeTools` and `excludeTools` configuration
|
||||
4. **Name sanitization:** Tool names are cleaned to meet Gemini API requirements:
|
||||
- Invalid characters (non-alphanumeric, underscore, dot, hyphen) are replaced with underscores
|
||||
- Names longer than 63 characters are truncated with middle replacement (`___`)
|
||||
|
||||
@@ -445,3 +570,70 @@ The MCP integration tracks several states:
|
||||
- **Conflict resolution:** Tool name conflicts between servers are resolved through automatic prefixing
|
||||
|
||||
This comprehensive integration makes MCP servers a powerful way to extend the Gemini CLI's capabilities while maintaining security, reliability, and ease of use.
|
||||
|
||||
## MCP Prompts as Slash Commands
|
||||
|
||||
In addition to tools, MCP servers can expose predefined prompts that can be executed as slash commands within the Gemini CLI. This allows you to create shortcuts for common or complex queries that can be easily invoked by name.
|
||||
|
||||
### Defining Prompts on the Server
|
||||
|
||||
Here's a small example of a stdio MCP server that defines prompts:
|
||||
|
||||
```ts
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'prompt-server',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
server.registerPrompt(
|
||||
'poem-writer',
|
||||
{
|
||||
title: 'Poem Writer',
|
||||
description: 'Write a nice haiku',
|
||||
argsSchema: { title: z.string(), mood: z.string().optional() },
|
||||
},
|
||||
({ title, mood }) => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: `Write a haiku${mood ? ` with the mood ${mood}` : ''} called ${title}. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables `,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
```
|
||||
|
||||
This can be included in `settings.json` under `mcpServers` with:
|
||||
|
||||
```json
|
||||
"nodeServer": {
|
||||
"command": "node",
|
||||
"args": ["filename.ts"],
|
||||
}
|
||||
```
|
||||
|
||||
### Invoking Prompts
|
||||
|
||||
Once a prompt is discovered, you can invoke it using its name as a slash command. The CLI will automatically handle parsing arguments.
|
||||
|
||||
```bash
|
||||
/poem-writer --title="Gemini CLI" --mood="reverent"
|
||||
```
|
||||
|
||||
or, using positional arguments:
|
||||
|
||||
```bash
|
||||
/poem-writer "Gemini CLI" reverent
|
||||
```
|
||||
|
||||
When you run this command, the Gemini CLI executes the `prompts/get` method on the MCP server with the provided arguments. The server is responsible for substituting the arguments into the prompt template and returning the final prompt text. The CLI then sends this prompt to the model for execution. This provides a convenient way to automate and share common workflows.
|
||||
|
||||
@@ -4,7 +4,7 @@ This document describes the `save_memory` tool for the Gemini CLI.
|
||||
|
||||
## Description
|
||||
|
||||
Use `save_memory` to save and recall information across your Gemini CLI sessions. With `save_memory`, you can direct the CLI to remember key details across sessions, providing personalized and directed assistance.
|
||||
Use `save_memory` to save and recall information across your Qwen Code sessions. With `save_memory`, you can direct the CLI to remember key details across sessions, providing personalized and directed assistance.
|
||||
|
||||
### Arguments
|
||||
|
||||
@@ -14,9 +14,9 @@ Use `save_memory` to save and recall information across your Gemini CLI sessions
|
||||
|
||||
## How to use `save_memory` with the Gemini CLI
|
||||
|
||||
The tool appends the provided `fact` to a special `GEMINI.md` file located in the user's home directory (`~/.qwen/GEMINI.md`). This file can be configured to have a different name.
|
||||
The tool appends the provided `fact` to your context file in the user's home directory (`~/.qwen/QWEN.md` by default). This filename can be configured via `contextFileName`.
|
||||
|
||||
Once added, the facts are stored under a `## Gemini Added Memories` section. This file is loaded as context in subsequent sessions, allowing the CLI to recall the saved information.
|
||||
Once added, the facts are stored under a `## Qwen Added Memories` section. This file is loaded as context in subsequent sessions, allowing the CLI to recall the saved information.
|
||||
|
||||
Usage:
|
||||
|
||||
|
||||
@@ -11,11 +11,13 @@ Use `read_many_files` to read content from multiple files specified by paths or
|
||||
|
||||
`read_many_files` can be used to perform tasks such as getting an overview of a codebase, finding where specific functionality is implemented, reviewing documentation, or gathering context from multiple configuration files.
|
||||
|
||||
**Note:** `read_many_files` looks for files following the provided paths or glob patterns. A directory path such as `"/docs"` will return an empty result; the tool requires a pattern such as `"/docs/*"` or `"/docs/*.md"` to identify the relevant files.
|
||||
|
||||
### Arguments
|
||||
|
||||
`read_many_files` takes the following arguments:
|
||||
|
||||
- `paths` (list[string], required): An array of glob patterns or paths relative to the tool's target directory (e.g., `["src/**/*.ts"]`, `["README.md", "docs/", "assets/logo.png"]`).
|
||||
- `paths` (list[string], required): An array of glob patterns or paths relative to the tool's target directory (e.g., `["src/**/*.ts"]`, `["README.md", "docs/*", "assets/logo.png"]`).
|
||||
- `exclude` (list[string], optional): Glob patterns for files/directories to exclude (e.g., `["**/*.log", "temp/"]`). These are added to default excludes if `useDefaultExcludes` is true.
|
||||
- `include` (list[string], optional): Additional glob patterns to include. These are merged with `paths` (e.g., `["*.test.ts"]` to specifically add test files if they were broadly excluded, or `["images/*.jpg"]` to include specific image types).
|
||||
- `recursive` (boolean, optional): Whether to search recursively. This is primarily controlled by `**` in glob patterns. Defaults to `true`.
|
||||
|
||||
@@ -60,6 +60,10 @@ run_shell_command(command="npm run dev &", description="Start development server
|
||||
- **Error handling:** Check the `Stderr`, `Error`, and `Exit Code` fields to determine if a command executed successfully.
|
||||
- **Background processes:** When a command is run in the background with `&`, the tool will return immediately and the process will continue to run in the background. The `Background PIDs` field will contain the process ID of the background process.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
When `run_shell_command` executes a command, it sets the `GEMINI_CLI=1` environment variable in the subprocess's environment. This allows scripts or tools to detect if they are being run from within the Gemini CLI.
|
||||
|
||||
## Command Restrictions
|
||||
|
||||
You can restrict the commands that can be executed by the `run_shell_command` tool by using the `coreTools` and `excludeTools` settings in your configuration file.
|
||||
|
||||
@@ -63,6 +63,8 @@ You may opt-out from sending Usage Statistics to Google by following the instruc
|
||||
|
||||
Whether your code, including prompts and answers, is used to train Google's models depends on the type of authentication method you use and your account type.
|
||||
|
||||
By default (if you have not opted out):
|
||||
|
||||
- **Google account with Gemini Code Assist for Individuals**: Yes. When you use your personal Google account, the [Gemini Code Assist Privacy Notice for Individuals](https://developers.google.com/gemini-code-assist/resources/privacy-notice-gemini-code-assist-individuals) applies. Under this notice,
|
||||
your **prompts, answers, and related code are collected** and may be used to improve Google's products, including for model training.
|
||||
- **Google account with Gemini Code Assist for Workspace, Standard, or Enterprise**: No. For these accounts, your data is governed by the [Gemini Code Assist Privacy Notices](https://cloud.google.com/gemini/docs/codeassist/security-privacy-compliance#standard_and_enterprise_data_protection_and_privacy) terms, which treat your inputs as confidential. Your **prompts, answers, and related code are not collected** and are not used to train models.
|
||||
@@ -71,17 +73,21 @@ Whether your code, including prompts and answers, is used to train Google's mode
|
||||
- **Paid services**: No. When you use the Gemini API key via the Gemini Developer API with a paid service, the [Gemini API Terms of Service - Paid Services](https://ai.google.dev/gemini-api/terms#paid-services) terms apply, which treats your inputs as confidential. Your **prompts, answers, and related code are not collected** and are not used to train models.
|
||||
- **Gemini API key via the Vertex AI GenAI API**: No. For these accounts, your data is governed by the [Google Cloud Privacy Notice](https://cloud.google.com/terms/cloud-privacy-notice) terms, which treat your inputs as confidential. Your **prompts, answers, and related code are not collected** and are not used to train models.
|
||||
|
||||
For more information about opting out, refer to the next question.
|
||||
|
||||
### 2. What are Usage Statistics and what does the opt-out control?
|
||||
|
||||
The **Usage Statistics** setting is the single control for all optional data collection in the Gemini CLI.
|
||||
|
||||
The data it collects depends on your account and authentication type:
|
||||
|
||||
- **Google account with Gemini Code Assist for Individuals**: When enabled, this setting allows Google to collect both anonymous telemetry (for example, commands run and performance metrics) and **your prompts and answers** for model improvement.
|
||||
- **Google account with Gemini Code Assist for Workspace, Standard, or Enterprise**: This setting only controls the collection of anonymous telemetry. Your prompts and answers are never collected, regardless of this setting.
|
||||
- **Google account with Gemini Code Assist for Individuals**: When enabled, this setting allows Google to collect both anonymous telemetry (for example, commands run and performance metrics) and **your prompts and answers, including code,** for model improvement.
|
||||
- **Google account with Gemini Code Assist for Workspace, Standard, or Enterprise**: This setting only controls the collection of anonymous telemetry. Your prompts and answers, including code, are never collected, regardless of this setting.
|
||||
- **Gemini API key via the Gemini Developer API**:
|
||||
**Unpaid services**: When enabled, this setting allows Google to collect both anonymous telemetry (like commands run and performance metrics) and **your prompts and answers** for model improvement. When disabled we will use your data as described in [How Google Uses Your Data](https://ai.google.dev/gemini-api/terms#data-use-unpaid).
|
||||
**Unpaid services**: When enabled, this setting allows Google to collect both anonymous telemetry (like commands run and performance metrics) and **your prompts and answers, including code,** for model improvement. When disabled we will use your data as described in [How Google Uses Your Data](https://ai.google.dev/gemini-api/terms#data-use-unpaid).
|
||||
**Paid services**: This setting only controls the collection of anonymous telemetry. Google logs prompts and responses for a limited period of time, solely for the purpose of detecting violations of the Prohibited Use Policy and any required legal or regulatory disclosures.
|
||||
- **Gemini API key via the Vertex AI GenAI API:** This setting only controls the collection of anonymous telemetry. Your prompts and answers are never collected, regardless of this setting.
|
||||
- **Gemini API key via the Vertex AI GenAI API:** This setting only controls the collection of anonymous telemetry. Your prompts and answers, including code, are never collected, regardless of this setting.
|
||||
|
||||
Please refer to the Privacy Notice that applies to your authentication method for more information about what data is collected and how this data is used.
|
||||
|
||||
You can disable Usage Statistics for any account type by following the instructions in the [Usage Statistics Configuration](./cli/configuration.md#usage-statistics) documentation.
|
||||
|
||||
@@ -19,7 +19,7 @@ This guide provides solutions to common issues and debugging tips.
|
||||
- A: If installed globally via npm, update Gemini CLI using the command `npm install -g @google/gemini-cli@latest`. If run from source, pull the latest changes from the repository and rebuild using `npm run build`.
|
||||
|
||||
- **Q: Where are Gemini CLI configuration files stored?**
|
||||
- A: The CLI configuration is stored within two `settings.json` files: one in your home directory and one in your project's root directory. In both locations, `settings.json` is found in the `.qwen/` folder. Refer to [CLI Configuration](./cli/configuration.md) for more details.
|
||||
- A: The CLI configuration is stored within two `settings.json` files: one in your home directory and one in your project's root directory. In both locations, `settings.json` is found in the `.gemini/` folder. Refer to [CLI Configuration](./cli/configuration.md) for more details.
|
||||
|
||||
- **Q: Why don't I see cached token counts in my stats output?**
|
||||
- A: Cached token information is only displayed when cached tokens are being used. This feature is available for API key users (Gemini API key or Vertex AI) but not for OAuth users (Google Personal/Enterprise accounts) at this time, as the Code Assist API does not support cached content creation. You can still view your total token usage with the `/stats` command.
|
||||
@@ -27,7 +27,7 @@ This guide provides solutions to common issues and debugging tips.
|
||||
## Common error messages and solutions
|
||||
|
||||
- **Error: `EADDRINUSE` (Address already in use) when starting an MCP server.**
|
||||
- **Cause:** Another process is already using the port the MCP server is trying to bind to.
|
||||
- **Cause:** Another process is already using the port that the MCP server is trying to bind to.
|
||||
- **Solution:**
|
||||
Either stop the other process that is using the port or configure the MCP server to use a different port.
|
||||
|
||||
@@ -53,6 +53,11 @@ This guide provides solutions to common issues and debugging tips.
|
||||
- **Cause:** The `is-in-ci` package checks for the presence of `CI`, `CONTINUOUS_INTEGRATION`, or any environment variable with a `CI_` prefix. When any of these are found, it signals that the environment is non-interactive, which prevents the CLI from starting in its interactive mode.
|
||||
- **Solution:** If the `CI_` prefixed variable is not needed for the CLI to function, you can temporarily unset it for the command. e.g., `env -u CI_TOKEN gemini`
|
||||
|
||||
- **DEBUG mode not working from project .env file**
|
||||
- **Issue:** Setting `DEBUG=true` in a project's `.env` file doesn't enable debug mode for gemini-cli.
|
||||
- **Cause:** The `DEBUG` and `DEBUG_MODE` variables are automatically excluded from project `.env` files to prevent interference with gemini-cli behavior.
|
||||
- **Solution:** Use a `.gemini/.env` file instead, or configure the `excludedProjectEnvVars` setting in your `settings.json` to exclude fewer variables.
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
- **CLI debugging:**
|
||||
|
||||
@@ -21,11 +21,18 @@ esbuild
|
||||
outfile: 'bundle/gemini.js',
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
external: [],
|
||||
alias: {
|
||||
'is-in-ci': path.resolve(
|
||||
__dirname,
|
||||
'packages/cli/src/patches/is-in-ci.ts',
|
||||
),
|
||||
},
|
||||
define: {
|
||||
'process.env.CLI_VERSION': JSON.stringify(pkg.version),
|
||||
},
|
||||
banner: {
|
||||
js: `import { createRequire as _gcliCreateRequire } from 'module'; const require = _gcliCreateRequire(import.meta.url); globalThis.__filename = require('url').fileURLToPath(import.meta.url); globalThis.__dirname = require('path').dirname(globalThis.__filename);`,
|
||||
js: `import { createRequire } from 'module'; const require = createRequire(import.meta.url); globalThis.__filename = require('url').fileURLToPath(import.meta.url); globalThis.__dirname = require('path').dirname(globalThis.__filename);`,
|
||||
},
|
||||
})
|
||||
.catch(() => process.exit(1));
|
||||
|
||||
@@ -12,7 +12,6 @@ import prettierConfig from 'eslint-config-prettier';
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
import globals from 'globals';
|
||||
import licenseHeader from 'eslint-plugin-license-header';
|
||||
import noRelativeCrossPackageImports from './eslint-rules/no-relative-cross-package-imports.js';
|
||||
import path from 'node:path'; // Use node: prefix for built-ins
|
||||
import url from 'node:url';
|
||||
|
||||
@@ -34,8 +33,8 @@ export default tseslint.config(
|
||||
'packages/core/dist/**',
|
||||
'packages/server/dist/**',
|
||||
'packages/vscode-ide-companion/dist/**',
|
||||
'eslint-rules/*',
|
||||
'bundle/**',
|
||||
'package/bundle/**',
|
||||
],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
@@ -72,6 +71,14 @@ export default tseslint.config(
|
||||
{
|
||||
// General overrides and rules for the project (TS/TSX files)
|
||||
files: ['packages/*/src/**/*.{ts,tsx}'], // Target only TS/TSX in the cli package
|
||||
plugins: {
|
||||
import: importPlugin,
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
node: true,
|
||||
},
|
||||
},
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
@@ -106,6 +113,13 @@ export default tseslint.config(
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'import/no-internal-modules': [
|
||||
'error',
|
||||
{
|
||||
allow: ['react-dom/test-utils', 'memfs/lib/volume.js', 'yargs/**'],
|
||||
},
|
||||
],
|
||||
'import/no-relative-packages': 'error',
|
||||
'no-cond-assign': 'error',
|
||||
'no-debugger': 'error',
|
||||
'no-duplicate-case': 'error',
|
||||
@@ -137,24 +151,6 @@ export default tseslint.config(
|
||||
'default-case': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['./**/*.{tsx,ts,js}'],
|
||||
plugins: {
|
||||
'license-header': licenseHeader,
|
||||
},
|
||||
rules: {
|
||||
'license-header/header': [
|
||||
'error',
|
||||
[
|
||||
'/**',
|
||||
' * @license',
|
||||
' * Copyright 2025 Google LLC',
|
||||
' * SPDX-License-Identifier: Apache-2.0',
|
||||
' */',
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
// extra settings for scripts that we run directly with node
|
||||
{
|
||||
files: ['./scripts/**/*.js', 'esbuild.config.js'],
|
||||
@@ -190,6 +186,21 @@ export default tseslint.config(
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
},
|
||||
},
|
||||
// extra settings for scripts that we run directly with node
|
||||
{
|
||||
files: ['packages/vscode-ide-companion/scripts/**/*.js'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
process: 'readonly',
|
||||
console: 'readonly',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-restricted-syntax': 'off',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
},
|
||||
},
|
||||
// Prettier config must be last
|
||||
prettierConfig,
|
||||
// extra settings for scripts that we run directly with node
|
||||
@@ -213,24 +224,4 @@ export default tseslint.config(
|
||||
],
|
||||
},
|
||||
},
|
||||
// Custom eslint rules for this repo
|
||||
{
|
||||
files: ['packages/**/*.{js,jsx,ts,tsx}'],
|
||||
plugins: {
|
||||
custom: {
|
||||
rules: {
|
||||
'no-relative-cross-package-imports': noRelativeCrossPackageImports,
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Enable and configure your custom rule
|
||||
'custom/no-relative-cross-package-imports': [
|
||||
'error',
|
||||
{
|
||||
root: path.join(projectRoot, 'packages'),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6,25 +6,84 @@
|
||||
|
||||
import { strict as assert } from 'assert';
|
||||
import { test } from 'node:test';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
||||
|
||||
test('reads a file', (t) => {
|
||||
test('should be able to read a file', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup(t.name);
|
||||
await rig.setup('should be able to read a file');
|
||||
rig.createFile('test.txt', 'hello world');
|
||||
|
||||
const output = rig.run(`read the file name test.txt`);
|
||||
const result = await rig.run(
|
||||
`read the file test.txt and show me its contents`,
|
||||
);
|
||||
|
||||
assert.ok(output.toLowerCase().includes('hello'));
|
||||
const foundToolCall = await rig.waitForToolCall('read_file');
|
||||
|
||||
// Add debugging information
|
||||
if (!foundToolCall || !result.includes('hello world')) {
|
||||
printDebugInfo(rig, result, {
|
||||
'Found tool call': foundToolCall,
|
||||
'Contains hello world': result.includes('hello world'),
|
||||
});
|
||||
}
|
||||
|
||||
assert.ok(foundToolCall, 'Expected to find a read_file tool call');
|
||||
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
validateModelOutput(result, 'hello world', 'File read test');
|
||||
});
|
||||
|
||||
test('writes a file', (t) => {
|
||||
test('should be able to write a file', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup(t.name);
|
||||
await rig.setup('should be able to write a file');
|
||||
rig.createFile('test.txt', '');
|
||||
|
||||
rig.run(`edit test.txt to have a hello world message`);
|
||||
const result = await rig.run(`edit test.txt to have a hello world message`);
|
||||
|
||||
// Accept multiple valid tools for editing files
|
||||
const foundToolCall = await rig.waitForAnyToolCall([
|
||||
'write_file',
|
||||
'edit',
|
||||
'replace',
|
||||
]);
|
||||
|
||||
// Add debugging information
|
||||
if (!foundToolCall) {
|
||||
printDebugInfo(rig, result);
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
foundToolCall,
|
||||
'Expected to find a write_file, edit, or replace tool call',
|
||||
);
|
||||
|
||||
// Validate model output - will throw if no output
|
||||
validateModelOutput(result, null, 'File write test');
|
||||
|
||||
const fileContent = rig.readFile('test.txt');
|
||||
assert.ok(fileContent.toLowerCase().includes('hello'));
|
||||
|
||||
// Add debugging for file content
|
||||
if (!fileContent.toLowerCase().includes('hello')) {
|
||||
const writeCalls = rig
|
||||
.readToolLogs()
|
||||
.filter((t) => t.toolRequest.name === 'write_file')
|
||||
.map((t) => t.toolRequest.args);
|
||||
|
||||
printDebugInfo(rig, result, {
|
||||
'File content mismatch': true,
|
||||
'Expected to contain': 'hello',
|
||||
'Actual content': fileContent,
|
||||
'Write tool calls': JSON.stringify(writeCalls),
|
||||
});
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
fileContent.toLowerCase().includes('hello'),
|
||||
'Expected file to contain hello',
|
||||
);
|
||||
|
||||
// Log success info if verbose
|
||||
if (process.env.VERBOSE === 'true') {
|
||||
console.log('File written successfully with hello message.');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,14 +6,69 @@
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'assert';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
||||
|
||||
test('should be able to search the web', async (t) => {
|
||||
test('should be able to search the web', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup(t.name);
|
||||
await rig.setup('should be able to search the web');
|
||||
|
||||
const prompt = `what planet do we live on`;
|
||||
const result = await rig.run(prompt);
|
||||
let result;
|
||||
try {
|
||||
result = await rig.run(`what is the weather in London`);
|
||||
} catch (error) {
|
||||
// Network errors can occur in CI environments
|
||||
if (
|
||||
error.message.includes('network') ||
|
||||
error.message.includes('timeout')
|
||||
) {
|
||||
console.warn('Skipping test due to network error:', error.message);
|
||||
return; // Skip the test
|
||||
}
|
||||
throw error; // Re-throw if not a network error
|
||||
}
|
||||
|
||||
assert.ok(result.toLowerCase().includes('earth'));
|
||||
const foundToolCall = await rig.waitForToolCall('google_web_search');
|
||||
|
||||
// Add debugging information
|
||||
if (!foundToolCall) {
|
||||
const allTools = printDebugInfo(rig, result);
|
||||
|
||||
// Check if the tool call failed due to network issues
|
||||
const failedSearchCalls = allTools.filter(
|
||||
(t) =>
|
||||
t.toolRequest.name === 'google_web_search' && !t.toolRequest.success,
|
||||
);
|
||||
if (failedSearchCalls.length > 0) {
|
||||
console.warn(
|
||||
'google_web_search tool was called but failed, possibly due to network issues',
|
||||
);
|
||||
console.warn(
|
||||
'Failed calls:',
|
||||
failedSearchCalls.map((t) => t.toolRequest.args),
|
||||
);
|
||||
return; // Skip the test if network issues
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(foundToolCall, 'Expected to find a call to google_web_search');
|
||||
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
const hasExpectedContent = validateModelOutput(
|
||||
result,
|
||||
['weather', 'london'],
|
||||
'Google web search test',
|
||||
);
|
||||
|
||||
// If content was missing, log the search queries used
|
||||
if (!hasExpectedContent) {
|
||||
const searchCalls = rig
|
||||
.readToolLogs()
|
||||
.filter((t) => t.toolRequest.name === 'google_web_search');
|
||||
if (searchCalls.length > 0) {
|
||||
console.warn(
|
||||
'Search queries used:',
|
||||
searchCalls.map((t) => t.toolRequest.args),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,19 +6,57 @@
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'assert';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
test('should be able to list a directory', async (t) => {
|
||||
test('should be able to list a directory', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup(t.name);
|
||||
await rig.setup('should be able to list a directory');
|
||||
rig.createFile('file1.txt', 'file 1 content');
|
||||
rig.mkdir('subdir');
|
||||
rig.sync();
|
||||
|
||||
const prompt = `Can you list the files in the current directory. Display them in the style of 'ls'`;
|
||||
const result = rig.run(prompt);
|
||||
// Poll for filesystem changes to propagate in containers
|
||||
await rig.poll(
|
||||
() => {
|
||||
// Check if the files exist in the test directory
|
||||
const file1Path = join(rig.testDir, 'file1.txt');
|
||||
const subdirPath = join(rig.testDir, 'subdir');
|
||||
return existsSync(file1Path) && existsSync(subdirPath);
|
||||
},
|
||||
1000, // 1 second max wait
|
||||
50, // check every 50ms
|
||||
);
|
||||
|
||||
const lines = result.split('\n').filter((line) => line.trim() !== '');
|
||||
assert.ok(lines.some((line) => line.includes('file1.txt')));
|
||||
assert.ok(lines.some((line) => line.includes('subdir')));
|
||||
const prompt = `Can you list the files in the current directory. Display them in the style of 'ls'`;
|
||||
|
||||
const result = await rig.run(prompt);
|
||||
|
||||
const foundToolCall = await rig.waitForToolCall('list_directory');
|
||||
|
||||
// Add debugging information
|
||||
if (
|
||||
!foundToolCall ||
|
||||
!result.includes('file1.txt') ||
|
||||
!result.includes('subdir')
|
||||
) {
|
||||
const allTools = printDebugInfo(rig, result, {
|
||||
'Found tool call': foundToolCall,
|
||||
'Contains file1.txt': result.includes('file1.txt'),
|
||||
'Contains subdir': result.includes('subdir'),
|
||||
});
|
||||
|
||||
console.error(
|
||||
'List directory calls:',
|
||||
allTools
|
||||
.filter((t) => t.toolRequest.name === 'list_directory')
|
||||
.map((t) => t.toolRequest.args),
|
||||
);
|
||||
}
|
||||
|
||||
assert.ok(foundToolCall, 'Expected to find a list_directory tool call');
|
||||
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
validateModelOutput(result, ['file1.txt', 'subdir'], 'List directory test');
|
||||
});
|
||||
|
||||
@@ -6,17 +6,45 @@
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'assert';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
||||
|
||||
test.skip('should be able to read multiple files', async (t) => {
|
||||
test('should be able to read multiple files', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup(t.name);
|
||||
await rig.setup('should be able to read multiple files');
|
||||
rig.createFile('file1.txt', 'file 1 content');
|
||||
rig.createFile('file2.txt', 'file 2 content');
|
||||
|
||||
const prompt = `Read the files in this directory, list them and print them to the screen`;
|
||||
const prompt = `Please use read_many_files to read file1.txt and file2.txt and show me what's in them`;
|
||||
|
||||
const result = await rig.run(prompt);
|
||||
|
||||
assert.ok(result.includes('file 1 content'));
|
||||
assert.ok(result.includes('file 2 content'));
|
||||
// Check for either read_many_files or multiple read_file calls
|
||||
const allTools = rig.readToolLogs();
|
||||
const readManyFilesCall = await rig.waitForToolCall('read_many_files');
|
||||
const readFileCalls = allTools.filter(
|
||||
(t) => t.toolRequest.name === 'read_file',
|
||||
);
|
||||
|
||||
// Accept either read_many_files OR at least 2 read_file calls
|
||||
const foundValidPattern = readManyFilesCall || readFileCalls.length >= 2;
|
||||
|
||||
// Add debugging information
|
||||
if (!foundValidPattern) {
|
||||
printDebugInfo(rig, result, {
|
||||
'read_many_files called': readManyFilesCall,
|
||||
'read_file calls': readFileCalls.length,
|
||||
});
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
foundValidPattern,
|
||||
'Expected to find either read_many_files or multiple read_file tool calls',
|
||||
);
|
||||
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
validateModelOutput(
|
||||
result,
|
||||
['file 1 content', 'file 2 content'],
|
||||
'Read many files test',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,17 +6,61 @@
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'assert';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
||||
|
||||
test('should be able to replace content in a file', async (t) => {
|
||||
test('should be able to replace content in a file', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup(t.name);
|
||||
await rig.setup('should be able to replace content in a file');
|
||||
|
||||
const fileName = 'file_to_replace.txt';
|
||||
rig.createFile(fileName, 'original content');
|
||||
const originalContent = 'original content';
|
||||
const expectedContent = 'replaced content';
|
||||
|
||||
rig.createFile(fileName, originalContent);
|
||||
const prompt = `Can you replace 'original' with 'replaced' in the file 'file_to_replace.txt'`;
|
||||
|
||||
await rig.run(prompt);
|
||||
const result = await rig.run(prompt);
|
||||
|
||||
const foundToolCall = await rig.waitForToolCall('replace');
|
||||
|
||||
// Add debugging information
|
||||
if (!foundToolCall) {
|
||||
printDebugInfo(rig, result);
|
||||
}
|
||||
|
||||
assert.ok(foundToolCall, 'Expected to find a replace tool call');
|
||||
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
validateModelOutput(
|
||||
result,
|
||||
['replaced', 'file_to_replace.txt'],
|
||||
'Replace content test',
|
||||
);
|
||||
|
||||
const newFileContent = rig.readFile(fileName);
|
||||
assert.strictEqual(newFileContent, 'replaced content');
|
||||
|
||||
// Add debugging for file content
|
||||
if (newFileContent !== expectedContent) {
|
||||
console.error('File content mismatch - Debug info:');
|
||||
console.error('Expected:', expectedContent);
|
||||
console.error('Actual:', newFileContent);
|
||||
console.error(
|
||||
'Tool calls:',
|
||||
rig.readToolLogs().map((t) => ({
|
||||
name: t.toolRequest.name,
|
||||
args: t.toolRequest.args,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
assert.strictEqual(
|
||||
newFileContent,
|
||||
expectedContent,
|
||||
'File content should be updated correctly',
|
||||
);
|
||||
|
||||
// Log success info if verbose
|
||||
if (process.env.VERBOSE === 'true') {
|
||||
console.log('File replaced successfully. New content:', newFileContent);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ async function main() {
|
||||
console.log(`\tFound test file: ${testFileName}`);
|
||||
}
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
let allTestsPassed = true;
|
||||
|
||||
for (const testFile of testFiles) {
|
||||
@@ -72,63 +73,98 @@ async function main() {
|
||||
`------------- Running test file: ${testFileName} ------------------------------`,
|
||||
);
|
||||
|
||||
const nodeArgs = ['--test'];
|
||||
if (verbose) {
|
||||
nodeArgs.push('--test-reporter=spec');
|
||||
}
|
||||
nodeArgs.push(testFile);
|
||||
let attempt = 0;
|
||||
let testFilePassed = false;
|
||||
let lastStdout = [];
|
||||
let lastStderr = [];
|
||||
|
||||
const child = spawn('node', nodeArgs, {
|
||||
stdio: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
GEMINI_CLI_INTEGRATION_TEST: 'true',
|
||||
INTEGRATION_TEST_FILE_DIR: testFileDir,
|
||||
KEEP_OUTPUT: keepOutput.toString(),
|
||||
VERBOSE: verbose.toString(),
|
||||
TEST_FILE_NAME: testFileName,
|
||||
},
|
||||
});
|
||||
while (attempt < MAX_RETRIES && !testFilePassed) {
|
||||
attempt++;
|
||||
if (attempt > 1) {
|
||||
console.log(
|
||||
`--- Retrying ${testFileName} (attempt ${attempt} of ${MAX_RETRIES}) ---`,
|
||||
);
|
||||
}
|
||||
|
||||
let outputStream;
|
||||
if (keepOutput) {
|
||||
const outputFile = join(testFileDir, 'output.log');
|
||||
outputStream = createWriteStream(outputFile);
|
||||
console.log(`Output for ${testFileName} written to: ${outputFile}`);
|
||||
}
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
const nodeArgs = ['--test'];
|
||||
if (verbose) {
|
||||
process.stdout.write(data);
|
||||
nodeArgs.push('--test-reporter=spec');
|
||||
}
|
||||
if (outputStream) {
|
||||
outputStream.write(data);
|
||||
}
|
||||
});
|
||||
nodeArgs.push(testFile);
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
if (verbose) {
|
||||
process.stderr.write(data);
|
||||
}
|
||||
if (outputStream) {
|
||||
outputStream.write(data);
|
||||
}
|
||||
});
|
||||
const child = spawn('node', nodeArgs, {
|
||||
stdio: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
GEMINI_CLI_INTEGRATION_TEST: 'true',
|
||||
INTEGRATION_TEST_FILE_DIR: testFileDir,
|
||||
KEEP_OUTPUT: keepOutput.toString(),
|
||||
VERBOSE: verbose.toString(),
|
||||
TEST_FILE_NAME: testFileName,
|
||||
TELEMETRY_LOG_FILE: join(testFileDir, 'telemetry.log'),
|
||||
},
|
||||
});
|
||||
|
||||
const exitCode = await new Promise((resolve) => {
|
||||
child.on('close', (code) => {
|
||||
if (outputStream) {
|
||||
outputStream.end(() => {
|
||||
resolve(code);
|
||||
});
|
||||
let outputStream;
|
||||
if (keepOutput) {
|
||||
const outputFile = join(testFileDir, `output-attempt-${attempt}.log`);
|
||||
outputStream = createWriteStream(outputFile);
|
||||
console.log(`Output for ${testFileName} written to: ${outputFile}`);
|
||||
}
|
||||
|
||||
const stdout = [];
|
||||
const stderr = [];
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
if (verbose) {
|
||||
process.stdout.write(data);
|
||||
} else {
|
||||
resolve(code);
|
||||
stdout.push(data);
|
||||
}
|
||||
if (outputStream) {
|
||||
outputStream.write(data);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (exitCode !== 0) {
|
||||
console.error(`Test file failed: ${testFileName}`);
|
||||
child.stderr.on('data', (data) => {
|
||||
if (verbose) {
|
||||
process.stderr.write(data);
|
||||
} else {
|
||||
stderr.push(data);
|
||||
}
|
||||
if (outputStream) {
|
||||
outputStream.write(data);
|
||||
}
|
||||
});
|
||||
|
||||
const exitCode = await new Promise((resolve) => {
|
||||
child.on('close', (code) => {
|
||||
if (outputStream) {
|
||||
outputStream.end(() => {
|
||||
resolve(code);
|
||||
});
|
||||
} else {
|
||||
resolve(code);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (exitCode === 0) {
|
||||
testFilePassed = true;
|
||||
} else {
|
||||
lastStdout = stdout;
|
||||
lastStderr = stderr;
|
||||
}
|
||||
}
|
||||
|
||||
if (!testFilePassed) {
|
||||
console.error(
|
||||
`Test file failed after ${MAX_RETRIES} attempts: ${testFileName}`,
|
||||
);
|
||||
if (!verbose) {
|
||||
process.stdout.write(Buffer.concat(lastStdout).toString('utf8'));
|
||||
process.stderr.write(Buffer.concat(lastStderr).toString('utf8'));
|
||||
}
|
||||
allTestsPassed = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,26 +6,58 @@
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'assert';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
||||
|
||||
test('should be able to run a shell command', async (t) => {
|
||||
test('should be able to run a shell command', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup(t.name);
|
||||
rig.createFile('blah.txt', 'some content');
|
||||
await rig.setup('should be able to run a shell command');
|
||||
|
||||
const prompt = `Can you use ls to list the contexts of the current folder`;
|
||||
const result = rig.run(prompt);
|
||||
const prompt = `Please run the command "echo hello-world" and show me the output`;
|
||||
|
||||
assert.ok(result.includes('blah.txt'));
|
||||
const result = await rig.run(prompt);
|
||||
|
||||
const foundToolCall = await rig.waitForToolCall('run_shell_command');
|
||||
|
||||
// Add debugging information
|
||||
if (!foundToolCall || !result.includes('hello-world')) {
|
||||
printDebugInfo(rig, result, {
|
||||
'Found tool call': foundToolCall,
|
||||
'Contains hello-world': result.includes('hello-world'),
|
||||
});
|
||||
}
|
||||
|
||||
assert.ok(foundToolCall, 'Expected to find a run_shell_command tool call');
|
||||
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
// Model often reports exit code instead of showing output
|
||||
validateModelOutput(
|
||||
result,
|
||||
['hello-world', 'exit code 0'],
|
||||
'Shell command test',
|
||||
);
|
||||
});
|
||||
|
||||
test('should be able to run a shell command via stdin', async (t) => {
|
||||
test('should be able to run a shell command via stdin', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup(t.name);
|
||||
rig.createFile('blah.txt', 'some content');
|
||||
await rig.setup('should be able to run a shell command via stdin');
|
||||
|
||||
const prompt = `Can you use ls to list the contexts of the current folder`;
|
||||
const result = rig.run({ stdin: prompt });
|
||||
const prompt = `Please run the command "echo test-stdin" and show me what it outputs`;
|
||||
|
||||
assert.ok(result.includes('blah.txt'));
|
||||
const result = await rig.run({ stdin: prompt });
|
||||
|
||||
const foundToolCall = await rig.waitForToolCall('run_shell_command');
|
||||
|
||||
// Add debugging information
|
||||
if (!foundToolCall || !result.includes('test-stdin')) {
|
||||
printDebugInfo(rig, result, {
|
||||
'Test type': 'Stdin test',
|
||||
'Found tool call': foundToolCall,
|
||||
'Contains test-stdin': result.includes('test-stdin'),
|
||||
});
|
||||
}
|
||||
|
||||
assert.ok(foundToolCall, 'Expected to find a run_shell_command tool call');
|
||||
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
validateModelOutput(result, 'test-stdin', 'Shell command stdin test');
|
||||
});
|
||||
|
||||
@@ -6,16 +6,36 @@
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'assert';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
||||
|
||||
test('should be able to save to memory', async (t) => {
|
||||
test('should be able to save to memory', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup(t.name);
|
||||
await rig.setup('should be able to save to memory');
|
||||
|
||||
const prompt = `remember that my favorite color is blue.
|
||||
|
||||
what is my favorite color? tell me that and surround it with $ symbol`;
|
||||
const result = await rig.run(prompt);
|
||||
|
||||
assert.ok(result.toLowerCase().includes('$blue$'));
|
||||
const foundToolCall = await rig.waitForToolCall('save_memory');
|
||||
|
||||
// Add debugging information
|
||||
if (!foundToolCall || !result.toLowerCase().includes('blue')) {
|
||||
const allTools = printDebugInfo(rig, result, {
|
||||
'Found tool call': foundToolCall,
|
||||
'Contains blue': result.toLowerCase().includes('blue'),
|
||||
});
|
||||
|
||||
console.error(
|
||||
'Memory tool calls:',
|
||||
allTools
|
||||
.filter((t) => t.toolRequest.name === 'save_memory')
|
||||
.map((t) => t.toolRequest.args),
|
||||
);
|
||||
}
|
||||
|
||||
assert.ok(foundToolCall, 'Expected to find a save_memory tool call');
|
||||
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
validateModelOutput(result, 'blue', 'Save memory test');
|
||||
});
|
||||
|
||||
@@ -4,67 +4,208 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
/**
|
||||
* This test verifies MCP (Model Context Protocol) server integration.
|
||||
* It uses a minimal MCP server implementation that doesn't require
|
||||
* external dependencies, making it compatible with Docker sandbox mode.
|
||||
*/
|
||||
|
||||
import { test, describe, before } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { spawn } from 'child_process';
|
||||
import { TestRig, validateModelOutput } from './test-helper.js';
|
||||
import { join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { writeFileSync, unlinkSync } from 'fs';
|
||||
import { writeFileSync } from 'fs';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const serverScriptPath = join(__dirname, './temp-server.js');
|
||||
|
||||
const serverScript = `
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
// Create a minimal MCP server that doesn't require external dependencies
|
||||
// This implements the MCP protocol directly using Node.js built-ins
|
||||
const serverScript = `#!/usr/bin/env node
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'addition-server',
|
||||
version: '1.0.0',
|
||||
const readline = require('readline');
|
||||
const fs = require('fs');
|
||||
|
||||
// Debug logging to stderr (only when MCP_DEBUG or VERBOSE is set)
|
||||
const debugEnabled = process.env.MCP_DEBUG === 'true' || process.env.VERBOSE === 'true';
|
||||
function debug(msg) {
|
||||
if (debugEnabled) {
|
||||
fs.writeSync(2, \`[MCP-DEBUG] \${msg}\\n\`);
|
||||
}
|
||||
}
|
||||
|
||||
debug('MCP server starting...');
|
||||
|
||||
// Simple JSON-RPC implementation for MCP
|
||||
class SimpleJSONRPC {
|
||||
constructor() {
|
||||
this.handlers = new Map();
|
||||
this.rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: false
|
||||
});
|
||||
|
||||
this.rl.on('line', (line) => {
|
||||
debug(\`Received line: \${line}\`);
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
debug(\`Parsed message: \${JSON.stringify(message)}\`);
|
||||
this.handleMessage(message);
|
||||
} catch (e) {
|
||||
debug(\`Parse error: \${e.message}\`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
send(message) {
|
||||
const msgStr = JSON.stringify(message);
|
||||
debug(\`Sending message: \${msgStr}\`);
|
||||
process.stdout.write(msgStr + '\\n');
|
||||
}
|
||||
|
||||
async handleMessage(message) {
|
||||
if (message.method && this.handlers.has(message.method)) {
|
||||
try {
|
||||
const result = await this.handlers.get(message.method)(message.params || {});
|
||||
if (message.id !== undefined) {
|
||||
this.send({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
result
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (message.id !== undefined) {
|
||||
this.send({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: error.message
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (message.id !== undefined) {
|
||||
this.send({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: 'Method not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
on(method, handler) {
|
||||
this.handlers.set(method, handler);
|
||||
}
|
||||
}
|
||||
|
||||
// Create MCP server
|
||||
const rpc = new SimpleJSONRPC();
|
||||
|
||||
// Handle initialize
|
||||
rpc.on('initialize', async (params) => {
|
||||
debug('Handling initialize request');
|
||||
return {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {}
|
||||
},
|
||||
serverInfo: {
|
||||
name: 'addition-server',
|
||||
version: '1.0.0'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
server.registerTool(
|
||||
'add',
|
||||
{
|
||||
title: 'Addition Tool',
|
||||
description: 'Add two numbers',
|
||||
inputSchema: { a: z.number(), b: z.number() },
|
||||
},
|
||||
async ({ a, b }) => ({
|
||||
content: [{ type: 'text', text: String(a + b) }],
|
||||
}),
|
||||
);
|
||||
// Handle tools/list
|
||||
rpc.on('tools/list', async () => {
|
||||
debug('Handling tools/list request');
|
||||
return {
|
||||
tools: [{
|
||||
name: 'add',
|
||||
description: 'Add two numbers',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: { type: 'number', description: 'First number' },
|
||||
b: { type: 'number', description: 'Second number' }
|
||||
},
|
||||
required: ['a', 'b']
|
||||
}
|
||||
}]
|
||||
};
|
||||
});
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
// Handle tools/call
|
||||
rpc.on('tools/call', async (params) => {
|
||||
debug(\`Handling tools/call request for tool: \${params.name}\`);
|
||||
if (params.name === 'add') {
|
||||
const { a, b } = params.arguments;
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: String(a + b)
|
||||
}]
|
||||
};
|
||||
}
|
||||
throw new Error('Unknown tool: ' + params.name);
|
||||
});
|
||||
|
||||
// Send initialization notification
|
||||
rpc.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialized'
|
||||
});
|
||||
`;
|
||||
|
||||
describe('simple-mcp-server', () => {
|
||||
const rig = new TestRig();
|
||||
let child;
|
||||
|
||||
before(() => {
|
||||
writeFileSync(serverScriptPath, serverScript);
|
||||
child = spawn('node', [serverScriptPath], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
before(async () => {
|
||||
// Setup test directory with MCP server configuration
|
||||
await rig.setup('simple-mcp-server', {
|
||||
settings: {
|
||||
mcpServers: {
|
||||
'addition-server': {
|
||||
command: 'node',
|
||||
args: ['mcp-server.cjs'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
child.stderr.on('data', (data) => {
|
||||
console.error(`stderr: ${data}`);
|
||||
});
|
||||
// Wait for the server to be ready
|
||||
return new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Create server script in the test directory
|
||||
const testServerPath = join(rig.testDir, 'mcp-server.cjs');
|
||||
writeFileSync(testServerPath, serverScript);
|
||||
|
||||
// Make the script executable (though running with 'node' should work anyway)
|
||||
if (process.platform !== 'win32') {
|
||||
const { chmodSync } = await import('fs');
|
||||
chmodSync(testServerPath, 0o755);
|
||||
}
|
||||
});
|
||||
|
||||
after(() => {
|
||||
child.kill();
|
||||
unlinkSync(serverScriptPath);
|
||||
});
|
||||
test('should add two numbers', async () => {
|
||||
// Test directory is already set up in before hook
|
||||
// Just run the command - MCP server config is in settings.json
|
||||
const output = await rig.run('add 5 and 10');
|
||||
|
||||
test('should add two numbers', () => {
|
||||
rig.setup('should add two numbers');
|
||||
const output = rig.run('add 5 and 10');
|
||||
assert.ok(output.includes('15'));
|
||||
const foundToolCall = await rig.waitForToolCall('add');
|
||||
|
||||
assert.ok(foundToolCall, 'Expected to find an add tool call');
|
||||
|
||||
// Validate model output - will throw if no output, fail if missing expected content
|
||||
validateModelOutput(output, '15', 'MCP server test');
|
||||
assert.ok(output.includes('15'), 'Expected output to contain the sum (15)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import { parse } from 'shell-quote';
|
||||
import { mkdirSync, writeFileSync, readFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { env } from 'process';
|
||||
import { fileExists } from '../scripts/telemetry_utils.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -19,17 +21,129 @@ function sanitizeTestName(name) {
|
||||
.replace(/-+/g, '-');
|
||||
}
|
||||
|
||||
// Helper to create detailed error messages
|
||||
export function createToolCallErrorMessage(expectedTools, foundTools, result) {
|
||||
const expectedStr = Array.isArray(expectedTools)
|
||||
? expectedTools.join(' or ')
|
||||
: expectedTools;
|
||||
return (
|
||||
`Expected to find ${expectedStr} tool call(s). ` +
|
||||
`Found: ${foundTools.length > 0 ? foundTools.join(', ') : 'none'}. ` +
|
||||
`Output preview: ${result ? result.substring(0, 200) + '...' : 'no output'}`
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to print debug information when tests fail
|
||||
export function printDebugInfo(rig, result, context = {}) {
|
||||
console.error('Test failed - Debug info:');
|
||||
console.error('Result length:', result.length);
|
||||
console.error('Result (first 500 chars):', result.substring(0, 500));
|
||||
console.error(
|
||||
'Result (last 500 chars):',
|
||||
result.substring(result.length - 500),
|
||||
);
|
||||
|
||||
// Print any additional context provided
|
||||
Object.entries(context).forEach(([key, value]) => {
|
||||
console.error(`${key}:`, value);
|
||||
});
|
||||
|
||||
// Check what tools were actually called
|
||||
const allTools = rig.readToolLogs();
|
||||
console.error(
|
||||
'All tool calls found:',
|
||||
allTools.map((t) => t.toolRequest.name),
|
||||
);
|
||||
|
||||
return allTools;
|
||||
}
|
||||
|
||||
// Helper to validate model output and warn about unexpected content
|
||||
export function validateModelOutput(
|
||||
result,
|
||||
expectedContent = null,
|
||||
testName = '',
|
||||
) {
|
||||
// First, check if there's any output at all (this should fail the test if missing)
|
||||
if (!result || result.trim().length === 0) {
|
||||
throw new Error('Expected LLM to return some output');
|
||||
}
|
||||
|
||||
// If expectedContent is provided, check for it and warn if missing
|
||||
if (expectedContent) {
|
||||
const contents = Array.isArray(expectedContent)
|
||||
? expectedContent
|
||||
: [expectedContent];
|
||||
const missingContent = contents.filter((content) => {
|
||||
if (typeof content === 'string') {
|
||||
return !result.toLowerCase().includes(content.toLowerCase());
|
||||
} else if (content instanceof RegExp) {
|
||||
return !content.test(result);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (missingContent.length > 0) {
|
||||
console.warn(
|
||||
`Warning: LLM did not include expected content in response: ${missingContent.join(', ')}.`,
|
||||
'This is not ideal but not a test failure.',
|
||||
);
|
||||
console.warn(
|
||||
'The tool was called successfully, which is the main requirement.',
|
||||
);
|
||||
return false;
|
||||
} else if (process.env.VERBOSE === 'true') {
|
||||
console.log(`${testName}: Model output validated successfully.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export class TestRig {
|
||||
constructor() {
|
||||
this.bundlePath = join(__dirname, '..', 'bundle/gemini.js');
|
||||
this.testDir = null;
|
||||
}
|
||||
|
||||
setup(testName) {
|
||||
// Get timeout based on environment
|
||||
getDefaultTimeout() {
|
||||
if (env.CI) return 60000; // 1 minute in CI
|
||||
if (env.GEMINI_SANDBOX) return 30000; // 30s in containers
|
||||
return 15000; // 15s locally
|
||||
}
|
||||
|
||||
setup(testName, options = {}) {
|
||||
this.testName = testName;
|
||||
const sanitizedName = sanitizeTestName(testName);
|
||||
this.testDir = join(env.INTEGRATION_TEST_FILE_DIR, sanitizedName);
|
||||
mkdirSync(this.testDir, { recursive: true });
|
||||
|
||||
// Create a settings file to point the CLI to the local collector
|
||||
const geminiDir = join(this.testDir, '.qwen');
|
||||
mkdirSync(geminiDir, { recursive: true });
|
||||
// In sandbox mode, use an absolute path for telemetry inside the container
|
||||
// The container mounts the test directory at the same path as the host
|
||||
const telemetryPath =
|
||||
env.GEMINI_SANDBOX && env.GEMINI_SANDBOX !== 'false'
|
||||
? join(this.testDir, 'telemetry.log') // Absolute path in test directory
|
||||
: env.TELEMETRY_LOG_FILE; // Absolute path for non-sandbox
|
||||
|
||||
const settings = {
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
target: 'local',
|
||||
otlpEndpoint: '',
|
||||
outfile: telemetryPath,
|
||||
},
|
||||
sandbox: env.GEMINI_SANDBOX !== 'false' ? env.GEMINI_SANDBOX : false,
|
||||
...options.settings, // Allow tests to override/add settings
|
||||
};
|
||||
writeFileSync(
|
||||
join(geminiDir, 'settings.json'),
|
||||
JSON.stringify(settings, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
createFile(fileName, content) {
|
||||
@@ -39,7 +153,7 @@ export class TestRig {
|
||||
}
|
||||
|
||||
mkdir(dir) {
|
||||
mkdirSync(join(this.testDir, dir));
|
||||
mkdirSync(join(this.testDir, dir), { recursive: true });
|
||||
}
|
||||
|
||||
sync() {
|
||||
@@ -70,19 +184,88 @@ export class TestRig {
|
||||
|
||||
command += ` ${args.join(' ')}`;
|
||||
|
||||
const output = execSync(command, execOptions);
|
||||
const commandArgs = parse(command);
|
||||
const node = commandArgs.shift();
|
||||
|
||||
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
|
||||
const testId = `${env.TEST_FILE_NAME.replace(
|
||||
'.test.js',
|
||||
'',
|
||||
)}:${this.testName.replace(/ /g, '-')}`;
|
||||
console.log(`--- TEST: ${testId} ---`);
|
||||
console.log(output);
|
||||
console.log(`--- END TEST: ${testId} ---`);
|
||||
const child = spawn(node, commandArgs, {
|
||||
cwd: this.testDir,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
// Handle stdin if provided
|
||||
if (execOptions.input) {
|
||||
child.stdin.write(execOptions.input);
|
||||
child.stdin.end();
|
||||
}
|
||||
|
||||
return output;
|
||||
child.stdout.on('data', (data) => {
|
||||
stdout += data;
|
||||
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
|
||||
process.stdout.write(data);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
stderr += data;
|
||||
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
|
||||
process.stderr.write(data);
|
||||
}
|
||||
});
|
||||
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
// Store the raw stdout for Podman telemetry parsing
|
||||
this._lastRunStdout = stdout;
|
||||
|
||||
// Filter out telemetry output when running with Podman
|
||||
// Podman seems to output telemetry to stdout even when writing to file
|
||||
let result = stdout;
|
||||
if (env.GEMINI_SANDBOX === 'podman') {
|
||||
// Remove telemetry JSON objects from output
|
||||
// They are multi-line JSON objects that start with { and contain telemetry fields
|
||||
const lines = result.split('\n');
|
||||
const filteredLines = [];
|
||||
let inTelemetryObject = false;
|
||||
let braceDepth = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (!inTelemetryObject && line.trim() === '{') {
|
||||
// Check if this might be start of telemetry object
|
||||
inTelemetryObject = true;
|
||||
braceDepth = 1;
|
||||
} else if (inTelemetryObject) {
|
||||
// Count braces to track nesting
|
||||
for (const char of line) {
|
||||
if (char === '{') braceDepth++;
|
||||
else if (char === '}') braceDepth--;
|
||||
}
|
||||
|
||||
// Check if we've closed all braces
|
||||
if (braceDepth === 0) {
|
||||
inTelemetryObject = false;
|
||||
// Skip this line (the closing brace)
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Not in telemetry object, keep the line
|
||||
filteredLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
result = filteredLines.join('\n');
|
||||
}
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(new Error(`Process exited with code ${code}:\n${stderr}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
readFile(fileName) {
|
||||
@@ -98,4 +281,312 @@ export class TestRig {
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
async cleanup() {
|
||||
// Clean up test directory
|
||||
if (this.testDir && !env.KEEP_OUTPUT) {
|
||||
try {
|
||||
execSync(`rm -rf ${this.testDir}`);
|
||||
} catch (error) {
|
||||
// Ignore cleanup errors
|
||||
if (env.VERBOSE === 'true') {
|
||||
console.warn('Cleanup warning:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async waitForTelemetryReady() {
|
||||
// In sandbox mode, telemetry is written to a relative path in the test directory
|
||||
const logFilePath =
|
||||
env.GEMINI_SANDBOX && env.GEMINI_SANDBOX !== 'false'
|
||||
? join(this.testDir, 'telemetry.log')
|
||||
: env.TELEMETRY_LOG_FILE;
|
||||
|
||||
if (!logFilePath) return;
|
||||
|
||||
// Wait for telemetry file to exist and have content
|
||||
await this.poll(
|
||||
() => {
|
||||
if (!fileExists(logFilePath)) return false;
|
||||
try {
|
||||
const content = readFileSync(logFilePath, 'utf-8');
|
||||
// Check if file has meaningful content (at least one complete JSON object)
|
||||
return content.includes('"event.name"');
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
2000, // 2 seconds max - reduced since telemetry should flush on exit now
|
||||
100, // check every 100ms
|
||||
);
|
||||
}
|
||||
|
||||
async waitForToolCall(toolName, timeout) {
|
||||
// Use environment-specific timeout
|
||||
if (!timeout) {
|
||||
timeout = this.getDefaultTimeout();
|
||||
}
|
||||
|
||||
// Wait for telemetry to be ready before polling for tool calls
|
||||
await this.waitForTelemetryReady();
|
||||
|
||||
return this.poll(
|
||||
() => {
|
||||
const toolLogs = this.readToolLogs();
|
||||
return toolLogs.some((log) => log.toolRequest.name === toolName);
|
||||
},
|
||||
timeout,
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
async waitForAnyToolCall(toolNames, timeout) {
|
||||
// Use environment-specific timeout
|
||||
if (!timeout) {
|
||||
timeout = this.getDefaultTimeout();
|
||||
}
|
||||
|
||||
// Wait for telemetry to be ready before polling for tool calls
|
||||
await this.waitForTelemetryReady();
|
||||
|
||||
return this.poll(
|
||||
() => {
|
||||
const toolLogs = this.readToolLogs();
|
||||
return toolNames.some((name) =>
|
||||
toolLogs.some((log) => log.toolRequest.name === name),
|
||||
);
|
||||
},
|
||||
timeout,
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
async poll(predicate, timeout, interval) {
|
||||
const startTime = Date.now();
|
||||
let attempts = 0;
|
||||
while (Date.now() - startTime < timeout) {
|
||||
attempts++;
|
||||
const result = predicate();
|
||||
if (env.VERBOSE === 'true' && attempts % 5 === 0) {
|
||||
console.log(
|
||||
`Poll attempt ${attempts}: ${result ? 'success' : 'waiting...'}`,
|
||||
);
|
||||
}
|
||||
if (result) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||
}
|
||||
if (env.VERBOSE === 'true') {
|
||||
console.log(`Poll timed out after ${attempts} attempts`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
_parseToolLogsFromStdout(stdout) {
|
||||
const logs = [];
|
||||
|
||||
// The console output from Podman is JavaScript object notation, not JSON
|
||||
// Look for tool call events in the output
|
||||
// Updated regex to handle tool names with hyphens and underscores
|
||||
const toolCallPattern =
|
||||
/body:\s*'Tool call:\s*([\w-]+)\..*?Success:\s*(\w+)\..*?Duration:\s*(\d+)ms\.'/g;
|
||||
const matches = [...stdout.matchAll(toolCallPattern)];
|
||||
|
||||
for (const match of matches) {
|
||||
const toolName = match[1];
|
||||
const success = match[2] === 'true';
|
||||
const duration = parseInt(match[3], 10);
|
||||
|
||||
// Try to find function_args nearby
|
||||
const matchIndex = match.index || 0;
|
||||
const contextStart = Math.max(0, matchIndex - 500);
|
||||
const contextEnd = Math.min(stdout.length, matchIndex + 500);
|
||||
const context = stdout.substring(contextStart, contextEnd);
|
||||
|
||||
// Look for function_args in the context
|
||||
let args = '{}';
|
||||
const argsMatch = context.match(/function_args:\s*'([^']+)'/);
|
||||
if (argsMatch) {
|
||||
args = argsMatch[1];
|
||||
}
|
||||
|
||||
// Also try to find function_name to double-check
|
||||
// Updated regex to handle tool names with hyphens and underscores
|
||||
const nameMatch = context.match(/function_name:\s*'([\w-]+)'/);
|
||||
const actualToolName = nameMatch ? nameMatch[1] : toolName;
|
||||
|
||||
logs.push({
|
||||
timestamp: Date.now(),
|
||||
toolRequest: {
|
||||
name: actualToolName,
|
||||
args: args,
|
||||
success: success,
|
||||
duration_ms: duration,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// If no matches found with the simple pattern, try the JSON parsing approach
|
||||
// in case the format changes
|
||||
if (logs.length === 0) {
|
||||
const lines = stdout.split('\n');
|
||||
let currentObject = '';
|
||||
let inObject = false;
|
||||
let braceDepth = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (!inObject && line.trim() === '{') {
|
||||
inObject = true;
|
||||
braceDepth = 1;
|
||||
currentObject = line + '\n';
|
||||
} else if (inObject) {
|
||||
currentObject += line + '\n';
|
||||
|
||||
// Count braces
|
||||
for (const char of line) {
|
||||
if (char === '{') braceDepth++;
|
||||
else if (char === '}') braceDepth--;
|
||||
}
|
||||
|
||||
// If we've closed all braces, try to parse the object
|
||||
if (braceDepth === 0) {
|
||||
inObject = false;
|
||||
try {
|
||||
const obj = JSON.parse(currentObject);
|
||||
|
||||
// Check for tool call in different formats
|
||||
if (
|
||||
obj.body &&
|
||||
obj.body.includes('Tool call:') &&
|
||||
obj.attributes
|
||||
) {
|
||||
const bodyMatch = obj.body.match(/Tool call: (\w+)\./);
|
||||
if (bodyMatch) {
|
||||
logs.push({
|
||||
timestamp: obj.timestamp || Date.now(),
|
||||
toolRequest: {
|
||||
name: bodyMatch[1],
|
||||
args: obj.attributes.function_args || '{}',
|
||||
success: obj.attributes.success !== false,
|
||||
duration_ms: obj.attributes.duration_ms || 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
obj.attributes &&
|
||||
obj.attributes['event.name'] === 'gemini_cli.tool_call'
|
||||
) {
|
||||
logs.push({
|
||||
timestamp: obj.attributes['event.timestamp'],
|
||||
toolRequest: {
|
||||
name: obj.attributes.function_name,
|
||||
args: obj.attributes.function_args,
|
||||
success: obj.attributes.success,
|
||||
duration_ms: obj.attributes.duration_ms,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (_e) {
|
||||
// Not valid JSON
|
||||
}
|
||||
currentObject = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return logs;
|
||||
}
|
||||
|
||||
readToolLogs() {
|
||||
// For Podman, first check if telemetry file exists and has content
|
||||
// If not, fall back to parsing from stdout
|
||||
if (env.GEMINI_SANDBOX === 'podman') {
|
||||
// Try reading from file first
|
||||
const logFilePath = join(this.testDir, 'telemetry.log');
|
||||
|
||||
if (fileExists(logFilePath)) {
|
||||
try {
|
||||
const content = readFileSync(logFilePath, 'utf-8');
|
||||
if (content && content.includes('"event.name"')) {
|
||||
// File has content, use normal file parsing
|
||||
// Continue to the normal file parsing logic below
|
||||
} else if (this._lastRunStdout) {
|
||||
// File exists but is empty or doesn't have events, parse from stdout
|
||||
return this._parseToolLogsFromStdout(this._lastRunStdout);
|
||||
}
|
||||
} catch (_e) {
|
||||
// Error reading file, fall back to stdout
|
||||
if (this._lastRunStdout) {
|
||||
return this._parseToolLogsFromStdout(this._lastRunStdout);
|
||||
}
|
||||
}
|
||||
} else if (this._lastRunStdout) {
|
||||
// No file exists, parse from stdout
|
||||
return this._parseToolLogsFromStdout(this._lastRunStdout);
|
||||
}
|
||||
}
|
||||
|
||||
// In sandbox mode, telemetry is written to a relative path in the test directory
|
||||
const logFilePath =
|
||||
env.GEMINI_SANDBOX && env.GEMINI_SANDBOX !== 'false'
|
||||
? join(this.testDir, 'telemetry.log')
|
||||
: env.TELEMETRY_LOG_FILE;
|
||||
|
||||
if (!logFilePath) {
|
||||
console.warn(`TELEMETRY_LOG_FILE environment variable not set`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check if file exists, if not return empty array (file might not be created yet)
|
||||
if (!fileExists(logFilePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const content = readFileSync(logFilePath, 'utf-8');
|
||||
|
||||
// Split the content into individual JSON objects
|
||||
// They are separated by "}\n{" pattern
|
||||
const jsonObjects = content
|
||||
.split(/}\s*\n\s*{/)
|
||||
.map((obj, index, array) => {
|
||||
// Add back the braces we removed during split
|
||||
if (index > 0) obj = '{' + obj;
|
||||
if (index < array.length - 1) obj = obj + '}';
|
||||
return obj.trim();
|
||||
})
|
||||
.filter((obj) => obj);
|
||||
|
||||
const logs = [];
|
||||
|
||||
for (const jsonStr of jsonObjects) {
|
||||
try {
|
||||
const logData = JSON.parse(jsonStr);
|
||||
// Look for tool call logs
|
||||
if (
|
||||
logData.attributes &&
|
||||
logData.attributes['event.name'] === 'qwen-code.tool_call'
|
||||
) {
|
||||
const toolName = logData.attributes.function_name;
|
||||
logs.push({
|
||||
toolRequest: {
|
||||
name: toolName,
|
||||
args: logData.attributes.function_args,
|
||||
success: logData.attributes.success,
|
||||
duration_ms: logData.attributes.duration_ms,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (_e) {
|
||||
// Skip objects that aren't valid JSON
|
||||
if (env.VERBOSE === 'true') {
|
||||
console.error('Failed to parse telemetry object:', _e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return logs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,16 +6,63 @@
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'assert';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import {
|
||||
TestRig,
|
||||
createToolCallErrorMessage,
|
||||
printDebugInfo,
|
||||
validateModelOutput,
|
||||
} from './test-helper.js';
|
||||
|
||||
test('should be able to write a file', async (t) => {
|
||||
test('should be able to write a file', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup(t.name);
|
||||
await rig.setup('should be able to write a file');
|
||||
const prompt = `show me an example of using the write tool. put a dad joke in dad.txt`;
|
||||
|
||||
await rig.run(prompt);
|
||||
const result = await rig.run(prompt);
|
||||
|
||||
const foundToolCall = await rig.waitForToolCall('write_file');
|
||||
|
||||
// Add debugging information
|
||||
if (!foundToolCall) {
|
||||
printDebugInfo(rig, result);
|
||||
}
|
||||
|
||||
const allTools = rig.readToolLogs();
|
||||
assert.ok(
|
||||
foundToolCall,
|
||||
createToolCallErrorMessage(
|
||||
'write_file',
|
||||
allTools.map((t) => t.toolRequest.name),
|
||||
result,
|
||||
),
|
||||
);
|
||||
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
validateModelOutput(result, 'dad.txt', 'Write file test');
|
||||
|
||||
const newFilePath = 'dad.txt';
|
||||
|
||||
const newFileContent = rig.readFile(newFilePath);
|
||||
assert.notEqual(newFileContent, '');
|
||||
|
||||
// Add debugging for file content
|
||||
if (newFileContent === '') {
|
||||
console.error('File was created but is empty');
|
||||
console.error(
|
||||
'Tool calls:',
|
||||
rig.readToolLogs().map((t) => ({
|
||||
name: t.toolRequest.name,
|
||||
args: t.toolRequest.args,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
assert.notEqual(newFileContent, '', 'Expected file to have content');
|
||||
|
||||
// Log success info if verbose
|
||||
if (process.env.VERBOSE === 'true') {
|
||||
console.log(
|
||||
'File created successfully with content:',
|
||||
newFileContent.substring(0, 100) + '...',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
3049
package-lock.json
generated
3049
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.7-nightly.0",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.3"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.7-nightly.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node scripts/start.js",
|
||||
@@ -23,7 +23,8 @@
|
||||
"auth": "npm run auth:npm && npm run auth:docker",
|
||||
"generate": "node scripts/generate-git-commit-info.js",
|
||||
"build": "node scripts/build.js",
|
||||
"build:all": "npm run build && npm run build:sandbox",
|
||||
"build:vscode": "node scripts/build_vscode_companion.js",
|
||||
"build:all": "npm run build && npm run build:sandbox && npm run build:vscode",
|
||||
"build:packages": "npm run build --workspaces",
|
||||
"build:sandbox": "node scripts/build_sandbox.js --skip-npm-install-build",
|
||||
"bundle": "npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js",
|
||||
@@ -56,11 +57,14 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/marked": "^5.0.2",
|
||||
"@types/micromatch": "^4.0.9",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/mock-fs": "^4.13.4",
|
||||
"@types/qrcode-terminal": "^0.12.2",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"concurrently": "^9.2.0",
|
||||
"cross-env": "^7.0.3",
|
||||
@@ -76,10 +80,11 @@
|
||||
"json": "^11.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"memfs": "^4.17.2",
|
||||
"mock-fs": "^5.5.0",
|
||||
"prettier": "^3.5.3",
|
||||
"react-devtools-core": "^4.28.5",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vitest": "^3.2.4",
|
||||
"yargs": "^18.0.0"
|
||||
"yargs": "^17.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.7-nightly.0",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -25,15 +25,16 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.3"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.7-nightly.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "1.9.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@qwen-code/qwen-code-core": "file:../core",
|
||||
"@types/update-notifier": "^6.0.8",
|
||||
"command-exists": "^1.2.9",
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^17.1.0",
|
||||
"gaxios": "^7.1.1",
|
||||
"glob": "^10.4.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "^6.0.1",
|
||||
@@ -45,14 +46,17 @@
|
||||
"lowlight": "^3.3.0",
|
||||
"mime-types": "^3.0.1",
|
||||
"open": "^10.1.2",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"react": "^19.1.0",
|
||||
"read-package-up": "^11.0.0",
|
||||
"shell-quote": "^1.8.3",
|
||||
"string-width": "^7.1.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"tiktoken": "^1.0.21",
|
||||
"update-notifier": "^7.3.1",
|
||||
"yargs": "^18.0.0"
|
||||
"yargs": "^17.7.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/runtime": "^7.27.6",
|
||||
|
||||
464
packages/cli/src/acp/acp.ts
Normal file
464
packages/cli/src/acp/acp.ts
Normal file
@@ -0,0 +1,464 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/* ACP defines a schema for a simple (experimental) JSON-RPC protocol that allows GUI applications to interact with agents. */
|
||||
|
||||
import { Icon } from '@qwen-code/qwen-code-core';
|
||||
import { WritableStream, ReadableStream } from 'node:stream/web';
|
||||
|
||||
export class ClientConnection implements Client {
|
||||
#connection: Connection<Agent>;
|
||||
|
||||
constructor(
|
||||
agent: (client: Client) => Agent,
|
||||
input: WritableStream<Uint8Array>,
|
||||
output: ReadableStream<Uint8Array>,
|
||||
) {
|
||||
this.#connection = new Connection(agent(this), input, output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams part of an assistant response to the client
|
||||
*/
|
||||
async streamAssistantMessageChunk(
|
||||
params: StreamAssistantMessageChunkParams,
|
||||
): Promise<void> {
|
||||
await this.#connection.sendRequest('streamAssistantMessageChunk', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request confirmation before running a tool
|
||||
*
|
||||
* When allowed, the client returns a [`ToolCallId`] which can be used
|
||||
* to update the tool call's `status` and `content` as it runs.
|
||||
*/
|
||||
requestToolCallConfirmation(
|
||||
params: RequestToolCallConfirmationParams,
|
||||
): Promise<RequestToolCallConfirmationResponse> {
|
||||
return this.#connection.sendRequest('requestToolCallConfirmation', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* pushToolCall allows the agent to start a tool call
|
||||
* when it does not need to request permission to do so.
|
||||
*
|
||||
* The returned id can be used to update the UI for the tool
|
||||
* call as needed.
|
||||
*/
|
||||
pushToolCall(params: PushToolCallParams): Promise<PushToolCallResponse> {
|
||||
return this.#connection.sendRequest('pushToolCall', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* updateToolCall allows the agent to update the content and status of the tool call.
|
||||
*
|
||||
* The new content replaces what is currently displayed in the UI.
|
||||
*
|
||||
* The [`ToolCallId`] is included in the response of
|
||||
* `pushToolCall` or `requestToolCallConfirmation` respectively.
|
||||
*/
|
||||
async updateToolCall(params: UpdateToolCallParams): Promise<void> {
|
||||
await this.#connection.sendRequest('updateToolCall', params);
|
||||
}
|
||||
}
|
||||
|
||||
type AnyMessage = AnyRequest | AnyResponse;
|
||||
|
||||
type AnyRequest = {
|
||||
id: number;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
};
|
||||
|
||||
type AnyResponse = { jsonrpc: '2.0'; id: number } & Result<unknown>;
|
||||
|
||||
type Result<T> =
|
||||
| {
|
||||
result: T;
|
||||
}
|
||||
| {
|
||||
error: ErrorResponse;
|
||||
};
|
||||
|
||||
type ErrorResponse = {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: { details?: string };
|
||||
};
|
||||
|
||||
type PendingResponse = {
|
||||
resolve: (response: unknown) => void;
|
||||
reject: (error: ErrorResponse) => void;
|
||||
};
|
||||
|
||||
class Connection<D> {
|
||||
#pendingResponses: Map<number, PendingResponse> = new Map();
|
||||
#nextRequestId: number = 0;
|
||||
#delegate: D;
|
||||
#peerInput: WritableStream<Uint8Array>;
|
||||
#writeQueue: Promise<void> = Promise.resolve();
|
||||
#textEncoder: TextEncoder;
|
||||
|
||||
constructor(
|
||||
delegate: D,
|
||||
peerInput: WritableStream<Uint8Array>,
|
||||
peerOutput: ReadableStream<Uint8Array>,
|
||||
) {
|
||||
this.#peerInput = peerInput;
|
||||
this.#textEncoder = new TextEncoder();
|
||||
|
||||
this.#delegate = delegate;
|
||||
this.#receive(peerOutput);
|
||||
}
|
||||
|
||||
async #receive(output: ReadableStream<Uint8Array>) {
|
||||
let content = '';
|
||||
const decoder = new TextDecoder();
|
||||
for await (const chunk of output) {
|
||||
content += decoder.decode(chunk, { stream: true });
|
||||
const lines = content.split('\n');
|
||||
content = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (trimmedLine) {
|
||||
const message = JSON.parse(trimmedLine);
|
||||
this.#processMessage(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #processMessage(message: AnyMessage) {
|
||||
if ('method' in message) {
|
||||
const response = await this.#tryCallDelegateMethod(
|
||||
message.method,
|
||||
message.params,
|
||||
);
|
||||
|
||||
await this.#sendMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
...response,
|
||||
});
|
||||
} else {
|
||||
this.#handleResponse(message);
|
||||
}
|
||||
}
|
||||
|
||||
async #tryCallDelegateMethod(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
): Promise<Result<unknown>> {
|
||||
const methodName = method as keyof D;
|
||||
if (typeof this.#delegate[methodName] !== 'function') {
|
||||
return RequestError.methodNotFound(method).toResult();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.#delegate[methodName](params);
|
||||
return { result: result ?? null };
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof RequestError) {
|
||||
return error.toResult();
|
||||
}
|
||||
|
||||
let details;
|
||||
|
||||
if (error instanceof Error) {
|
||||
details = error.message;
|
||||
} else if (
|
||||
typeof error === 'object' &&
|
||||
error != null &&
|
||||
'message' in error &&
|
||||
typeof error.message === 'string'
|
||||
) {
|
||||
details = error.message;
|
||||
}
|
||||
|
||||
return RequestError.internalError(details).toResult();
|
||||
}
|
||||
}
|
||||
|
||||
#handleResponse(response: AnyResponse) {
|
||||
const pendingResponse = this.#pendingResponses.get(response.id);
|
||||
if (pendingResponse) {
|
||||
if ('result' in response) {
|
||||
pendingResponse.resolve(response.result);
|
||||
} else if ('error' in response) {
|
||||
pendingResponse.reject(response.error);
|
||||
}
|
||||
this.#pendingResponses.delete(response.id);
|
||||
}
|
||||
}
|
||||
|
||||
async sendRequest<Req, Resp>(method: string, params?: Req): Promise<Resp> {
|
||||
const id = this.#nextRequestId++;
|
||||
const responsePromise = new Promise((resolve, reject) => {
|
||||
this.#pendingResponses.set(id, { resolve, reject });
|
||||
});
|
||||
await this.#sendMessage({ jsonrpc: '2.0', id, method, params });
|
||||
return responsePromise as Promise<Resp>;
|
||||
}
|
||||
|
||||
async #sendMessage(json: AnyMessage) {
|
||||
const content = JSON.stringify(json) + '\n';
|
||||
this.#writeQueue = this.#writeQueue
|
||||
.then(async () => {
|
||||
const writer = this.#peerInput.getWriter();
|
||||
try {
|
||||
await writer.write(this.#textEncoder.encode(content));
|
||||
} finally {
|
||||
writer.releaseLock();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
// Continue processing writes on error
|
||||
console.error('ACP write error:', error);
|
||||
});
|
||||
return this.#writeQueue;
|
||||
}
|
||||
}
|
||||
|
||||
export class RequestError extends Error {
|
||||
data?: { details?: string };
|
||||
|
||||
constructor(
|
||||
public code: number,
|
||||
message: string,
|
||||
details?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'RequestError';
|
||||
if (details) {
|
||||
this.data = { details };
|
||||
}
|
||||
}
|
||||
|
||||
static parseError(details?: string): RequestError {
|
||||
return new RequestError(-32700, 'Parse error', details);
|
||||
}
|
||||
|
||||
static invalidRequest(details?: string): RequestError {
|
||||
return new RequestError(-32600, 'Invalid request', details);
|
||||
}
|
||||
|
||||
static methodNotFound(details?: string): RequestError {
|
||||
return new RequestError(-32601, 'Method not found', details);
|
||||
}
|
||||
|
||||
static invalidParams(details?: string): RequestError {
|
||||
return new RequestError(-32602, 'Invalid params', details);
|
||||
}
|
||||
|
||||
static internalError(details?: string): RequestError {
|
||||
return new RequestError(-32603, 'Internal error', details);
|
||||
}
|
||||
|
||||
toResult<T>(): Result<T> {
|
||||
return {
|
||||
error: {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
data: this.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Protocol types
|
||||
|
||||
export const LATEST_PROTOCOL_VERSION = '0.0.9';
|
||||
|
||||
export type AssistantMessageChunk =
|
||||
| {
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
thought: string;
|
||||
};
|
||||
|
||||
export type ToolCallConfirmation =
|
||||
| {
|
||||
description?: string | null;
|
||||
type: 'edit';
|
||||
}
|
||||
| {
|
||||
description?: string | null;
|
||||
type: 'execute';
|
||||
command: string;
|
||||
rootCommand: string;
|
||||
}
|
||||
| {
|
||||
description?: string | null;
|
||||
type: 'mcp';
|
||||
serverName: string;
|
||||
toolDisplayName: string;
|
||||
toolName: string;
|
||||
}
|
||||
| {
|
||||
description?: string | null;
|
||||
type: 'fetch';
|
||||
urls: string[];
|
||||
}
|
||||
| {
|
||||
description: string;
|
||||
type: 'other';
|
||||
};
|
||||
|
||||
export type ToolCallContent =
|
||||
| {
|
||||
type: 'markdown';
|
||||
markdown: string;
|
||||
}
|
||||
| {
|
||||
type: 'diff';
|
||||
newText: string;
|
||||
oldText: string | null;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type ToolCallStatus = 'running' | 'finished' | 'error';
|
||||
|
||||
export type ToolCallId = number;
|
||||
|
||||
export type ToolCallConfirmationOutcome =
|
||||
| 'allow'
|
||||
| 'alwaysAllow'
|
||||
| 'alwaysAllowMcpServer'
|
||||
| 'alwaysAllowTool'
|
||||
| 'reject'
|
||||
| 'cancel';
|
||||
|
||||
/**
|
||||
* A part in a user message
|
||||
*/
|
||||
export type UserMessageChunk =
|
||||
| {
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
path: string;
|
||||
};
|
||||
|
||||
export interface StreamAssistantMessageChunkParams {
|
||||
chunk: AssistantMessageChunk;
|
||||
}
|
||||
|
||||
export interface RequestToolCallConfirmationParams {
|
||||
confirmation: ToolCallConfirmation;
|
||||
content?: ToolCallContent | null;
|
||||
icon: Icon;
|
||||
label: string;
|
||||
locations?: ToolCallLocation[];
|
||||
}
|
||||
|
||||
export interface ToolCallLocation {
|
||||
line?: number | null;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface PushToolCallParams {
|
||||
content?: ToolCallContent | null;
|
||||
icon: Icon;
|
||||
label: string;
|
||||
locations?: ToolCallLocation[];
|
||||
}
|
||||
|
||||
export interface UpdateToolCallParams {
|
||||
content: ToolCallContent | null;
|
||||
status: ToolCallStatus;
|
||||
toolCallId: ToolCallId;
|
||||
}
|
||||
|
||||
export interface RequestToolCallConfirmationResponse {
|
||||
id: ToolCallId;
|
||||
outcome: ToolCallConfirmationOutcome;
|
||||
}
|
||||
|
||||
export interface PushToolCallResponse {
|
||||
id: ToolCallId;
|
||||
}
|
||||
|
||||
export interface InitializeParams {
|
||||
/**
|
||||
* The version of the protocol that the client supports.
|
||||
* This should be the latest version supported by the client.
|
||||
*/
|
||||
protocolVersion: string;
|
||||
}
|
||||
|
||||
export interface SendUserMessageParams {
|
||||
chunks: UserMessageChunk[];
|
||||
}
|
||||
|
||||
export interface InitializeResponse {
|
||||
/**
|
||||
* Indicates whether the agent is authenticated and
|
||||
* ready to handle requests.
|
||||
*/
|
||||
isAuthenticated: boolean;
|
||||
/**
|
||||
* The version of the protocol that the agent supports.
|
||||
* If the agent supports the requested version, it should respond with the same version.
|
||||
* Otherwise, the agent should respond with the latest version it supports.
|
||||
*/
|
||||
protocolVersion: string;
|
||||
}
|
||||
|
||||
export interface Error {
|
||||
code: number;
|
||||
data?: unknown;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
streamAssistantMessageChunk(
|
||||
params: StreamAssistantMessageChunkParams,
|
||||
): Promise<void>;
|
||||
|
||||
requestToolCallConfirmation(
|
||||
params: RequestToolCallConfirmationParams,
|
||||
): Promise<RequestToolCallConfirmationResponse>;
|
||||
|
||||
pushToolCall(params: PushToolCallParams): Promise<PushToolCallResponse>;
|
||||
|
||||
updateToolCall(params: UpdateToolCallParams): Promise<void>;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
/**
|
||||
* Initializes the agent's state. It should be called before any other method,
|
||||
* and no other methods should be called until it has completed.
|
||||
*
|
||||
* If the agent is not authenticated, then the client should prompt the user to authenticate,
|
||||
* and then call the `authenticate` method.
|
||||
* Otherwise the client can send other messages to the agent.
|
||||
*/
|
||||
initialize(params: InitializeParams): Promise<InitializeResponse>;
|
||||
|
||||
/**
|
||||
* Begins the authentication process.
|
||||
*
|
||||
* This method should only be called if `initialize` indicates the user isn't already authenticated.
|
||||
* The Promise MUST not resolve until authentication is complete.
|
||||
*/
|
||||
authenticate(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Allows the user to send a message to the agent.
|
||||
* This method should complete after the agent is finished, during
|
||||
* which time the agent may update the client by calling
|
||||
* streamAssistantMessageChunk and other methods.
|
||||
*/
|
||||
sendUserMessage(params: SendUserMessageParams): Promise<void>;
|
||||
|
||||
/**
|
||||
* Cancels the current generation.
|
||||
*/
|
||||
cancelSendMessage(): Promise<void>;
|
||||
}
|
||||
674
packages/cli/src/acp/acpPeer.ts
Normal file
674
packages/cli/src/acp/acpPeer.ts
Normal file
@@ -0,0 +1,674 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { WritableStream, ReadableStream } from 'node:stream/web';
|
||||
|
||||
import {
|
||||
AuthType,
|
||||
Config,
|
||||
GeminiChat,
|
||||
ToolRegistry,
|
||||
logToolCall,
|
||||
ToolResult,
|
||||
convertToFunctionResponse,
|
||||
ToolCallConfirmationDetails,
|
||||
ToolConfirmationOutcome,
|
||||
clearCachedCredentialFile,
|
||||
isNodeError,
|
||||
getErrorMessage,
|
||||
isWithinRoot,
|
||||
getErrorStatus,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import * as acp from './acp.js';
|
||||
import { Agent } from './acp.js';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import { Content, Part, FunctionCall, PartListUnion } from '@google/genai';
|
||||
import { LoadedSettings, SettingScope } from '../config/settings.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
export async function runAcpPeer(config: Config, settings: LoadedSettings) {
|
||||
const stdout = Writable.toWeb(process.stdout) as WritableStream;
|
||||
const stdin = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
|
||||
|
||||
// Stdout is used to send messages to the client, so console.log/console.info
|
||||
// messages to stderr so that they don't interfere with ACP.
|
||||
console.log = console.error;
|
||||
console.info = console.error;
|
||||
console.debug = console.error;
|
||||
|
||||
new acp.ClientConnection(
|
||||
(client: acp.Client) => new GeminiAgent(config, settings, client),
|
||||
stdout,
|
||||
stdin,
|
||||
);
|
||||
}
|
||||
|
||||
class GeminiAgent implements Agent {
|
||||
chat?: GeminiChat;
|
||||
pendingSend?: AbortController;
|
||||
|
||||
constructor(
|
||||
private config: Config,
|
||||
private settings: LoadedSettings,
|
||||
private client: acp.Client,
|
||||
) {}
|
||||
|
||||
async initialize(_: acp.InitializeParams): Promise<acp.InitializeResponse> {
|
||||
let isAuthenticated = false;
|
||||
if (this.settings.merged.selectedAuthType) {
|
||||
try {
|
||||
await this.config.refreshAuth(this.settings.merged.selectedAuthType);
|
||||
isAuthenticated = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh auth:', error);
|
||||
}
|
||||
}
|
||||
return { protocolVersion: acp.LATEST_PROTOCOL_VERSION, isAuthenticated };
|
||||
}
|
||||
|
||||
async authenticate(): Promise<void> {
|
||||
await clearCachedCredentialFile();
|
||||
await this.config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
|
||||
this.settings.setValue(
|
||||
SettingScope.User,
|
||||
'selectedAuthType',
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
}
|
||||
|
||||
async cancelSendMessage(): Promise<void> {
|
||||
if (!this.pendingSend) {
|
||||
throw new Error('Not currently generating');
|
||||
}
|
||||
|
||||
this.pendingSend.abort();
|
||||
delete this.pendingSend;
|
||||
}
|
||||
|
||||
async sendUserMessage(params: acp.SendUserMessageParams): Promise<void> {
|
||||
this.pendingSend?.abort();
|
||||
const pendingSend = new AbortController();
|
||||
this.pendingSend = pendingSend;
|
||||
|
||||
if (!this.chat) {
|
||||
const geminiClient = this.config.getGeminiClient();
|
||||
this.chat = await geminiClient.startChat();
|
||||
}
|
||||
|
||||
const promptId = Math.random().toString(16).slice(2);
|
||||
const chat = this.chat!;
|
||||
const toolRegistry: ToolRegistry = await this.config.getToolRegistry();
|
||||
const parts = await this.#resolveUserMessage(params, pendingSend.signal);
|
||||
|
||||
let nextMessage: Content | null = { role: 'user', parts };
|
||||
|
||||
while (nextMessage !== null) {
|
||||
if (pendingSend.signal.aborted) {
|
||||
chat.addHistory(nextMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const functionCalls: FunctionCall[] = [];
|
||||
|
||||
try {
|
||||
const responseStream = await chat.sendMessageStream(
|
||||
{
|
||||
message: nextMessage?.parts ?? [],
|
||||
config: {
|
||||
abortSignal: pendingSend.signal,
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: toolRegistry.getFunctionDeclarations(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
promptId,
|
||||
);
|
||||
nextMessage = null;
|
||||
|
||||
for await (const resp of responseStream) {
|
||||
if (pendingSend.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (resp.candidates && resp.candidates.length > 0) {
|
||||
const candidate = resp.candidates[0];
|
||||
for (const part of candidate.content?.parts ?? []) {
|
||||
if (!part.text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.client.streamAssistantMessageChunk({
|
||||
chunk: part.thought
|
||||
? { thought: part.text }
|
||||
: { text: part.text },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (resp.functionCalls) {
|
||||
functionCalls.push(...resp.functionCalls);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (getErrorStatus(error) === 429) {
|
||||
throw new acp.RequestError(
|
||||
429,
|
||||
'Rate limit exceeded. Try again later.',
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (functionCalls.length > 0) {
|
||||
const toolResponseParts: Part[] = [];
|
||||
|
||||
for (const fc of functionCalls) {
|
||||
const response = await this.#runTool(
|
||||
pendingSend.signal,
|
||||
promptId,
|
||||
fc,
|
||||
);
|
||||
|
||||
const parts = Array.isArray(response) ? response : [response];
|
||||
|
||||
for (const part of parts) {
|
||||
if (typeof part === 'string') {
|
||||
toolResponseParts.push({ text: part });
|
||||
} else if (part) {
|
||||
toolResponseParts.push(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nextMessage = { role: 'user', parts: toolResponseParts };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #runTool(
|
||||
abortSignal: AbortSignal,
|
||||
promptId: string,
|
||||
fc: FunctionCall,
|
||||
): Promise<PartListUnion> {
|
||||
const callId = fc.id ?? `${fc.name}-${Date.now()}`;
|
||||
const args = (fc.args ?? {}) as Record<string, unknown>;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const errorResponse = (error: Error) => {
|
||||
const durationMs = Date.now() - startTime;
|
||||
logToolCall(this.config, {
|
||||
'event.name': 'tool_call',
|
||||
'event.timestamp': new Date().toISOString(),
|
||||
prompt_id: promptId,
|
||||
function_name: fc.name ?? '',
|
||||
function_args: args,
|
||||
duration_ms: durationMs,
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
functionResponse: {
|
||||
id: callId,
|
||||
name: fc.name ?? '',
|
||||
response: { error: error.message },
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
if (!fc.name) {
|
||||
return errorResponse(new Error('Missing function name'));
|
||||
}
|
||||
|
||||
const toolRegistry: ToolRegistry = await this.config.getToolRegistry();
|
||||
const tool = toolRegistry.getTool(fc.name as string);
|
||||
|
||||
if (!tool) {
|
||||
return errorResponse(
|
||||
new Error(`Tool "${fc.name}" not found in registry.`),
|
||||
);
|
||||
}
|
||||
|
||||
let toolCallId;
|
||||
const confirmationDetails = await tool.shouldConfirmExecute(
|
||||
args,
|
||||
abortSignal,
|
||||
);
|
||||
if (confirmationDetails) {
|
||||
let content: acp.ToolCallContent | null = null;
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
content = {
|
||||
type: 'diff',
|
||||
path: confirmationDetails.fileName,
|
||||
oldText: confirmationDetails.originalContent,
|
||||
newText: confirmationDetails.newContent,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await this.client.requestToolCallConfirmation({
|
||||
label: tool.getDescription(args),
|
||||
icon: tool.icon,
|
||||
content,
|
||||
confirmation: toAcpToolCallConfirmation(confirmationDetails),
|
||||
locations: tool.toolLocations(args),
|
||||
});
|
||||
|
||||
await confirmationDetails.onConfirm(toToolCallOutcome(result.outcome));
|
||||
switch (result.outcome) {
|
||||
case 'reject':
|
||||
return errorResponse(
|
||||
new Error(`Tool "${fc.name}" not allowed to run by the user.`),
|
||||
);
|
||||
|
||||
case 'cancel':
|
||||
return errorResponse(
|
||||
new Error(`Tool "${fc.name}" was canceled by the user.`),
|
||||
);
|
||||
case 'allow':
|
||||
case 'alwaysAllow':
|
||||
case 'alwaysAllowMcpServer':
|
||||
case 'alwaysAllowTool':
|
||||
break;
|
||||
default: {
|
||||
const resultOutcome: never = result.outcome;
|
||||
throw new Error(`Unexpected: ${resultOutcome}`);
|
||||
}
|
||||
}
|
||||
|
||||
toolCallId = result.id;
|
||||
} else {
|
||||
const result = await this.client.pushToolCall({
|
||||
icon: tool.icon,
|
||||
label: tool.getDescription(args),
|
||||
locations: tool.toolLocations(args),
|
||||
});
|
||||
|
||||
toolCallId = result.id;
|
||||
}
|
||||
|
||||
try {
|
||||
const toolResult: ToolResult = await tool.execute(args, abortSignal);
|
||||
const toolCallContent = toToolCallContent(toolResult);
|
||||
|
||||
await this.client.updateToolCall({
|
||||
toolCallId,
|
||||
status: 'finished',
|
||||
content: toolCallContent,
|
||||
});
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
logToolCall(this.config, {
|
||||
'event.name': 'tool_call',
|
||||
'event.timestamp': new Date().toISOString(),
|
||||
function_name: fc.name,
|
||||
function_args: args,
|
||||
duration_ms: durationMs,
|
||||
success: true,
|
||||
prompt_id: promptId,
|
||||
});
|
||||
|
||||
return convertToFunctionResponse(fc.name, callId, toolResult.llmContent);
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(String(e));
|
||||
await this.client.updateToolCall({
|
||||
toolCallId,
|
||||
status: 'error',
|
||||
content: { type: 'markdown', markdown: error.message },
|
||||
});
|
||||
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
async #resolveUserMessage(
|
||||
message: acp.SendUserMessageParams,
|
||||
abortSignal: AbortSignal,
|
||||
): Promise<Part[]> {
|
||||
const atPathCommandParts = message.chunks.filter((part) => 'path' in part);
|
||||
|
||||
if (atPathCommandParts.length === 0) {
|
||||
return message.chunks.map((chunk) => {
|
||||
if ('text' in chunk) {
|
||||
return { text: chunk.text };
|
||||
} else {
|
||||
throw new Error('Unexpected chunk type');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get centralized file discovery service
|
||||
const fileDiscovery = this.config.getFileService();
|
||||
const respectGitIgnore = this.config.getFileFilteringRespectGitIgnore();
|
||||
|
||||
const pathSpecsToRead: string[] = [];
|
||||
const atPathToResolvedSpecMap = new Map<string, string>();
|
||||
const contentLabelsForDisplay: string[] = [];
|
||||
const ignoredPaths: string[] = [];
|
||||
|
||||
const toolRegistry = await this.config.getToolRegistry();
|
||||
const readManyFilesTool = toolRegistry.getTool('read_many_files');
|
||||
const globTool = toolRegistry.getTool('glob');
|
||||
|
||||
if (!readManyFilesTool) {
|
||||
throw new Error('Error: read_many_files tool not found.');
|
||||
}
|
||||
|
||||
for (const atPathPart of atPathCommandParts) {
|
||||
const pathName = atPathPart.path;
|
||||
|
||||
// Check if path should be ignored by git
|
||||
if (fileDiscovery.shouldGitIgnoreFile(pathName)) {
|
||||
ignoredPaths.push(pathName);
|
||||
const reason = respectGitIgnore
|
||||
? 'git-ignored and will be skipped'
|
||||
: 'ignored by custom patterns';
|
||||
console.warn(`Path ${pathName} is ${reason}.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let currentPathSpec = pathName;
|
||||
let resolvedSuccessfully = false;
|
||||
|
||||
try {
|
||||
const absolutePath = path.resolve(this.config.getTargetDir(), pathName);
|
||||
if (isWithinRoot(absolutePath, this.config.getTargetDir())) {
|
||||
const stats = await fs.stat(absolutePath);
|
||||
if (stats.isDirectory()) {
|
||||
currentPathSpec = pathName.endsWith('/')
|
||||
? `${pathName}**`
|
||||
: `${pathName}/**`;
|
||||
this.#debug(
|
||||
`Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`,
|
||||
);
|
||||
} else {
|
||||
this.#debug(
|
||||
`Path ${pathName} resolved to file: ${currentPathSpec}`,
|
||||
);
|
||||
}
|
||||
resolvedSuccessfully = true;
|
||||
} else {
|
||||
this.#debug(
|
||||
`Path ${pathName} is outside the project directory. Skipping.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
if (this.config.getEnableRecursiveFileSearch() && globTool) {
|
||||
this.#debug(
|
||||
`Path ${pathName} not found directly, attempting glob search.`,
|
||||
);
|
||||
try {
|
||||
const globResult = await globTool.execute(
|
||||
{
|
||||
pattern: `**/*${pathName}*`,
|
||||
path: this.config.getTargetDir(),
|
||||
},
|
||||
abortSignal,
|
||||
);
|
||||
if (
|
||||
globResult.llmContent &&
|
||||
typeof globResult.llmContent === 'string' &&
|
||||
!globResult.llmContent.startsWith('No files found') &&
|
||||
!globResult.llmContent.startsWith('Error:')
|
||||
) {
|
||||
const lines = globResult.llmContent.split('\n');
|
||||
if (lines.length > 1 && lines[1]) {
|
||||
const firstMatchAbsolute = lines[1].trim();
|
||||
currentPathSpec = path.relative(
|
||||
this.config.getTargetDir(),
|
||||
firstMatchAbsolute,
|
||||
);
|
||||
this.#debug(
|
||||
`Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`,
|
||||
);
|
||||
resolvedSuccessfully = true;
|
||||
} else {
|
||||
this.#debug(
|
||||
`Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.#debug(
|
||||
`Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`,
|
||||
);
|
||||
}
|
||||
} catch (globError) {
|
||||
console.error(
|
||||
`Error during glob search for ${pathName}: ${getErrorMessage(globError)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.#debug(
|
||||
`Glob tool not found. Path ${pathName} will be skipped.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
`Error stating path ${pathName}. Path ${pathName} will be skipped.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedSuccessfully) {
|
||||
pathSpecsToRead.push(currentPathSpec);
|
||||
atPathToResolvedSpecMap.set(pathName, currentPathSpec);
|
||||
contentLabelsForDisplay.push(pathName);
|
||||
}
|
||||
}
|
||||
|
||||
// Construct the initial part of the query for the LLM
|
||||
let initialQueryText = '';
|
||||
for (let i = 0; i < message.chunks.length; i++) {
|
||||
const chunk = message.chunks[i];
|
||||
if ('text' in chunk) {
|
||||
initialQueryText += chunk.text;
|
||||
} else {
|
||||
// type === 'atPath'
|
||||
const resolvedSpec = atPathToResolvedSpecMap.get(chunk.path);
|
||||
if (
|
||||
i > 0 &&
|
||||
initialQueryText.length > 0 &&
|
||||
!initialQueryText.endsWith(' ') &&
|
||||
resolvedSpec
|
||||
) {
|
||||
// Add space if previous part was text and didn't end with space, or if previous was @path
|
||||
const prevPart = message.chunks[i - 1];
|
||||
if (
|
||||
'text' in prevPart ||
|
||||
('path' in prevPart && atPathToResolvedSpecMap.has(prevPart.path))
|
||||
) {
|
||||
initialQueryText += ' ';
|
||||
}
|
||||
}
|
||||
if (resolvedSpec) {
|
||||
initialQueryText += `@${resolvedSpec}`;
|
||||
} else {
|
||||
// If not resolved for reading (e.g. lone @ or invalid path that was skipped),
|
||||
// add the original @-string back, ensuring spacing if it's not the first element.
|
||||
if (
|
||||
i > 0 &&
|
||||
initialQueryText.length > 0 &&
|
||||
!initialQueryText.endsWith(' ') &&
|
||||
!chunk.path.startsWith(' ')
|
||||
) {
|
||||
initialQueryText += ' ';
|
||||
}
|
||||
initialQueryText += `@${chunk.path}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
initialQueryText = initialQueryText.trim();
|
||||
|
||||
// Inform user about ignored paths
|
||||
if (ignoredPaths.length > 0) {
|
||||
const ignoreType = respectGitIgnore ? 'git-ignored' : 'custom-ignored';
|
||||
this.#debug(
|
||||
`Ignored ${ignoredPaths.length} ${ignoreType} files: ${ignoredPaths.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText
|
||||
if (pathSpecsToRead.length === 0) {
|
||||
console.warn('No valid file paths found in @ commands to read.');
|
||||
return [{ text: initialQueryText }];
|
||||
}
|
||||
|
||||
const processedQueryParts: Part[] = [{ text: initialQueryText }];
|
||||
|
||||
const toolArgs = {
|
||||
paths: pathSpecsToRead,
|
||||
respectGitIgnore, // Use configuration setting
|
||||
};
|
||||
|
||||
const toolCall = await this.client.pushToolCall({
|
||||
icon: readManyFilesTool.icon,
|
||||
label: readManyFilesTool.getDescription(toolArgs),
|
||||
});
|
||||
try {
|
||||
const result = await readManyFilesTool.execute(toolArgs, abortSignal);
|
||||
const content = toToolCallContent(result) || {
|
||||
type: 'markdown',
|
||||
markdown: `Successfully read: ${contentLabelsForDisplay.join(', ')}`,
|
||||
};
|
||||
await this.client.updateToolCall({
|
||||
toolCallId: toolCall.id,
|
||||
status: 'finished',
|
||||
content,
|
||||
});
|
||||
|
||||
if (Array.isArray(result.llmContent)) {
|
||||
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
|
||||
processedQueryParts.push({
|
||||
text: '\n--- Content from referenced files ---',
|
||||
});
|
||||
for (const part of result.llmContent) {
|
||||
if (typeof part === 'string') {
|
||||
const match = fileContentRegex.exec(part);
|
||||
if (match) {
|
||||
const filePathSpecInContent = match[1]; // This is a resolved pathSpec
|
||||
const fileActualContent = match[2].trim();
|
||||
processedQueryParts.push({
|
||||
text: `\nContent from @${filePathSpecInContent}:\n`,
|
||||
});
|
||||
processedQueryParts.push({ text: fileActualContent });
|
||||
} else {
|
||||
processedQueryParts.push({ text: part });
|
||||
}
|
||||
} else {
|
||||
// part is a Part object.
|
||||
processedQueryParts.push(part);
|
||||
}
|
||||
}
|
||||
processedQueryParts.push({ text: '\n--- End of content ---' });
|
||||
} else {
|
||||
console.warn(
|
||||
'read_many_files tool returned no content or empty content.',
|
||||
);
|
||||
}
|
||||
|
||||
return processedQueryParts;
|
||||
} catch (error: unknown) {
|
||||
await this.client.updateToolCall({
|
||||
toolCallId: toolCall.id,
|
||||
status: 'error',
|
||||
content: {
|
||||
type: 'markdown',
|
||||
markdown: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
#debug(msg: string) {
|
||||
if (this.config.getDebugMode()) {
|
||||
console.warn(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null {
|
||||
if (toolResult.returnDisplay) {
|
||||
if (typeof toolResult.returnDisplay === 'string') {
|
||||
return {
|
||||
type: 'markdown',
|
||||
markdown: toolResult.returnDisplay,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: 'diff',
|
||||
path: toolResult.returnDisplay.fileName,
|
||||
oldText: toolResult.returnDisplay.originalContent,
|
||||
newText: toolResult.returnDisplay.newContent,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function toAcpToolCallConfirmation(
|
||||
confirmationDetails: ToolCallConfirmationDetails,
|
||||
): acp.ToolCallConfirmation {
|
||||
switch (confirmationDetails.type) {
|
||||
case 'edit':
|
||||
return { type: 'edit' };
|
||||
case 'exec':
|
||||
return {
|
||||
type: 'execute',
|
||||
rootCommand: confirmationDetails.rootCommand,
|
||||
command: confirmationDetails.command,
|
||||
};
|
||||
case 'mcp':
|
||||
return {
|
||||
type: 'mcp',
|
||||
serverName: confirmationDetails.serverName,
|
||||
toolName: confirmationDetails.toolName,
|
||||
toolDisplayName: confirmationDetails.toolDisplayName,
|
||||
};
|
||||
case 'info':
|
||||
return {
|
||||
type: 'fetch',
|
||||
urls: confirmationDetails.urls || [],
|
||||
description: confirmationDetails.urls?.length
|
||||
? null
|
||||
: confirmationDetails.prompt,
|
||||
};
|
||||
default: {
|
||||
const unreachable: never = confirmationDetails;
|
||||
throw new Error(`Unexpected: ${unreachable}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toToolCallOutcome(
|
||||
outcome: acp.ToolCallConfirmationOutcome,
|
||||
): ToolConfirmationOutcome {
|
||||
switch (outcome) {
|
||||
case 'allow':
|
||||
return ToolConfirmationOutcome.ProceedOnce;
|
||||
case 'alwaysAllow':
|
||||
return ToolConfirmationOutcome.ProceedAlways;
|
||||
case 'alwaysAllowMcpServer':
|
||||
return ToolConfirmationOutcome.ProceedAlwaysServer;
|
||||
case 'alwaysAllowTool':
|
||||
return ToolConfirmationOutcome.ProceedAlwaysTool;
|
||||
case 'reject':
|
||||
case 'cancel':
|
||||
return ToolConfirmationOutcome.Cancel;
|
||||
default: {
|
||||
const unreachable: never = outcome;
|
||||
throw new Error(`Unexpected: ${unreachable}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,12 @@ export const validateAuthMethod = (authMethod: string): string | null => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authMethod === AuthType.QWEN_OAUTH) {
|
||||
// Qwen OAuth doesn't require any environment variables for basic setup
|
||||
// The OAuth flow will handle authentication
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'Invalid auth method selected.';
|
||||
};
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('Configuration Integration Tests', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(tmpdir(), 'gemini-cli-test-'));
|
||||
tempDir = fs.mkdtempSync(path.join(tmpdir(), 'qwen-code-test-'));
|
||||
originalEnv = { ...process.env };
|
||||
process.env.GEMINI_API_KEY = 'test-api-key';
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -10,11 +10,6 @@ import { loadCliConfig, parseArguments, CliArgs } from './config.js';
|
||||
import { Settings } from './settings.js';
|
||||
import { Extension } from './extension.js';
|
||||
import * as ServerConfig from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
TelemetryTarget,
|
||||
ConfigParameters,
|
||||
DEFAULT_TELEMETRY_TARGET,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const actualOs = await importOriginal<typeof os>();
|
||||
@@ -40,65 +35,28 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
|
||||
);
|
||||
return {
|
||||
...actualServer,
|
||||
IdeClient: {
|
||||
getInstance: vi.fn().mockReturnValue({
|
||||
getConnectionStatus: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
}),
|
||||
},
|
||||
loadEnvironment: vi.fn(),
|
||||
loadServerHierarchicalMemory: vi.fn(
|
||||
(cwd, debug, fileService, extensionPaths) =>
|
||||
(cwd, debug, fileService, extensionPaths, _maxDirs) =>
|
||||
Promise.resolve({
|
||||
memoryContent: extensionPaths?.join(',') || '',
|
||||
fileCount: extensionPaths?.length || 0,
|
||||
}),
|
||||
),
|
||||
Config: class MockConfig extends actualServer.Config {
|
||||
private enableOpenAILogging: boolean;
|
||||
|
||||
constructor(params: ConfigParameters) {
|
||||
super(params);
|
||||
this.enableOpenAILogging = params.enableOpenAILogging ?? false;
|
||||
}
|
||||
|
||||
getEnableOpenAILogging(): boolean {
|
||||
return this.enableOpenAILogging;
|
||||
}
|
||||
|
||||
// Override other methods to ensure they work correctly
|
||||
getShowMemoryUsage(): boolean {
|
||||
return (
|
||||
(this as unknown as { showMemoryUsage?: boolean }).showMemoryUsage ??
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
getTelemetryEnabled(): boolean {
|
||||
return (
|
||||
(this as unknown as { telemetrySettings?: { enabled?: boolean } })
|
||||
.telemetrySettings?.enabled ?? false
|
||||
);
|
||||
}
|
||||
|
||||
getTelemetryLogPromptsEnabled(): boolean {
|
||||
return (
|
||||
(this as unknown as { telemetrySettings?: { logPrompts?: boolean } })
|
||||
.telemetrySettings?.logPrompts ?? false
|
||||
);
|
||||
}
|
||||
|
||||
getTelemetryOtlpEndpoint(): string {
|
||||
return (
|
||||
(this as unknown as { telemetrySettings?: { otlpEndpoint?: string } })
|
||||
.telemetrySettings?.otlpEndpoint ??
|
||||
'http://tracing-analysis-dc-hz.aliyuncs.com:8090'
|
||||
);
|
||||
}
|
||||
|
||||
getTelemetryTarget(): TelemetryTarget {
|
||||
return (
|
||||
(
|
||||
this as unknown as {
|
||||
telemetrySettings?: { target?: TelemetryTarget };
|
||||
}
|
||||
).telemetrySettings?.target ?? DEFAULT_TELEMETRY_TARGET
|
||||
);
|
||||
}
|
||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: {
|
||||
respectGitIgnore: false,
|
||||
respectGeminiIgnore: true,
|
||||
},
|
||||
DEFAULT_FILE_FILTERING_OPTIONS: {
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -244,6 +202,85 @@ describe('loadCliConfig', () => {
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getShowMemoryUsage()).toBe(true);
|
||||
});
|
||||
|
||||
it(`should leave proxy to empty by default`, async () => {
|
||||
// Clear all proxy environment variables to ensure clean test
|
||||
delete process.env.https_proxy;
|
||||
delete process.env.http_proxy;
|
||||
delete process.env.HTTPS_PROXY;
|
||||
delete process.env.HTTP_PROXY;
|
||||
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {};
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getProxy()).toBeFalsy();
|
||||
});
|
||||
|
||||
const proxy_url = 'http://localhost:7890';
|
||||
const testCases = [
|
||||
{
|
||||
input: {
|
||||
env_name: 'https_proxy',
|
||||
proxy_url,
|
||||
},
|
||||
expected: proxy_url,
|
||||
},
|
||||
{
|
||||
input: {
|
||||
env_name: 'http_proxy',
|
||||
proxy_url,
|
||||
},
|
||||
expected: proxy_url,
|
||||
},
|
||||
{
|
||||
input: {
|
||||
env_name: 'HTTPS_PROXY',
|
||||
proxy_url,
|
||||
},
|
||||
expected: proxy_url,
|
||||
},
|
||||
{
|
||||
input: {
|
||||
env_name: 'HTTP_PROXY',
|
||||
proxy_url,
|
||||
},
|
||||
expected: proxy_url,
|
||||
},
|
||||
];
|
||||
testCases.forEach(({ input, expected }) => {
|
||||
it(`should set proxy to ${expected} according to environment variable [${input.env_name}]`, async () => {
|
||||
// Clear all proxy environment variables first
|
||||
delete process.env.https_proxy;
|
||||
delete process.env.http_proxy;
|
||||
delete process.env.HTTPS_PROXY;
|
||||
delete process.env.HTTP_PROXY;
|
||||
|
||||
process.env[input.env_name] = input.proxy_url;
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {};
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getProxy()).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should set proxy when --proxy flag is present', async () => {
|
||||
process.argv = ['node', 'script.js', '--proxy', 'http://localhost:7890'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {};
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getProxy()).toBe('http://localhost:7890');
|
||||
});
|
||||
|
||||
it('should prioritize CLI flag over environment variable for proxy (CLI http://localhost:7890, environment variable http://localhost:7891)', async () => {
|
||||
process.env['http_proxy'] = 'http://localhost:7891';
|
||||
process.argv = ['node', 'script.js', '--proxy', 'http://localhost:7890'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {};
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getProxy()).toBe('http://localhost:7890');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig telemetry', () => {
|
||||
@@ -350,9 +387,7 @@ describe('loadCliConfig telemetry', () => {
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = { telemetry: { enabled: true } };
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getTelemetryOtlpEndpoint()).toBe(
|
||||
'http://tracing-analysis-dc-hz.aliyuncs.com:8090',
|
||||
);
|
||||
expect(config.getTelemetryOtlpEndpoint()).toBe('http://localhost:4317');
|
||||
});
|
||||
|
||||
it('should use telemetry target from settings if CLI flag is not present', async () => {
|
||||
@@ -411,81 +446,12 @@ describe('loadCliConfig telemetry', () => {
|
||||
expect(config.getTelemetryLogPromptsEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should use default log prompts (false) if no value is provided via CLI or settings', async () => {
|
||||
it('should use default log prompts (true) if no value is provided via CLI or settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = { telemetry: { enabled: true } };
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getTelemetryLogPromptsEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should set enableOpenAILogging to true when --openai-logging flag is present', async () => {
|
||||
const settings: Settings = {};
|
||||
const argv = await parseArguments();
|
||||
argv.openaiLogging = true;
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(
|
||||
(
|
||||
config as unknown as { getEnableOpenAILogging(): boolean }
|
||||
).getEnableOpenAILogging(),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should set enableOpenAILogging to false when --openai-logging flag is not present', async () => {
|
||||
const settings: Settings = {};
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(
|
||||
(
|
||||
config as unknown as { getEnableOpenAILogging(): boolean }
|
||||
).getEnableOpenAILogging(),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should use enableOpenAILogging value from settings if CLI flag is not present (settings true)', async () => {
|
||||
const settings: Settings = { enableOpenAILogging: true };
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(
|
||||
(
|
||||
config as unknown as { getEnableOpenAILogging(): boolean }
|
||||
).getEnableOpenAILogging(),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should use enableOpenAILogging value from settings if CLI flag is not present (settings false)', async () => {
|
||||
const settings: Settings = { enableOpenAILogging: false };
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(
|
||||
(
|
||||
config as unknown as { getEnableOpenAILogging(): boolean }
|
||||
).getEnableOpenAILogging(),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should prioritize --openai-logging CLI flag (true) over settings (false)', async () => {
|
||||
const settings: Settings = { enableOpenAILogging: false };
|
||||
const argv = await parseArguments();
|
||||
argv.openaiLogging = true;
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(
|
||||
(
|
||||
config as unknown as { getEnableOpenAILogging(): boolean }
|
||||
).getEnableOpenAILogging(),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should prioritize --openai-logging CLI flag (false) over settings (true)', async () => {
|
||||
const settings: Settings = { enableOpenAILogging: true };
|
||||
const argv = await parseArguments();
|
||||
argv.openaiLogging = false;
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(
|
||||
(
|
||||
config as unknown as { getEnableOpenAILogging(): boolean }
|
||||
).getEnableOpenAILogging(),
|
||||
).toBe(false);
|
||||
expect(config.getTelemetryLogPromptsEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -540,6 +506,12 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
'/path/to/ext3/context1.md',
|
||||
'/path/to/ext3/context2.md',
|
||||
],
|
||||
'tree',
|
||||
{
|
||||
respectGitIgnore: false,
|
||||
respectGeminiIgnore: true,
|
||||
},
|
||||
undefined, // maxDirs
|
||||
);
|
||||
});
|
||||
|
||||
@@ -597,6 +569,68 @@ describe('mergeMcpServers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig systemPromptMappings', () => {
|
||||
it('should use default systemPromptMappings when not provided in settings', async () => {
|
||||
const mockSettings: Settings = {
|
||||
theme: 'dark',
|
||||
};
|
||||
const mockExtensions: Extension[] = [];
|
||||
const mockSessionId = 'test-session';
|
||||
const mockArgv: CliArgs = {
|
||||
model: 'test-model',
|
||||
} as CliArgs;
|
||||
|
||||
const config = await loadCliConfig(
|
||||
mockSettings,
|
||||
mockExtensions,
|
||||
mockSessionId,
|
||||
mockArgv,
|
||||
);
|
||||
|
||||
expect(config.getSystemPromptMappings()).toEqual([
|
||||
{
|
||||
baseUrls: [
|
||||
'https://dashscope.aliyuncs.com/compatible-mode/v1/',
|
||||
'https://dashscope-intl.aliyuncs.com/compatible-mode/v1/',
|
||||
],
|
||||
modelNames: ['qwen3-coder-plus'],
|
||||
template:
|
||||
'SYSTEM_TEMPLATE:{"name":"qwen3_coder","params":{"is_git_repository":{RUNTIME_VARS_IS_GIT_REPO},"sandbox":"{RUNTIME_VARS_SANDBOX}"}}',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use custom systemPromptMappings when provided in settings', async () => {
|
||||
const customSystemPromptMappings = [
|
||||
{
|
||||
baseUrls: ['https://custom-api.com'],
|
||||
modelNames: ['custom-model'],
|
||||
template: 'Custom template',
|
||||
},
|
||||
];
|
||||
const mockSettings: Settings = {
|
||||
theme: 'dark',
|
||||
systemPromptMappings: customSystemPromptMappings,
|
||||
};
|
||||
const mockExtensions: Extension[] = [];
|
||||
const mockSessionId = 'test-session';
|
||||
const mockArgv: CliArgs = {
|
||||
model: 'test-model',
|
||||
} as CliArgs;
|
||||
|
||||
const config = await loadCliConfig(
|
||||
mockSettings,
|
||||
mockExtensions,
|
||||
mockSessionId,
|
||||
mockArgv,
|
||||
);
|
||||
|
||||
expect(config.getSystemPromptMappings()).toEqual(
|
||||
customSystemPromptMappings,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeExcludeTools', () => {
|
||||
it('should merge excludeTools from settings and extensions', async () => {
|
||||
const settings: Settings = { excludeTools: ['tool1', 'tool2'] };
|
||||
@@ -853,6 +887,66 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
|
||||
const config = await loadCliConfig(baseSettings, [], 'test-session', argv);
|
||||
expect(config.getMcpServers()).toEqual({});
|
||||
});
|
||||
|
||||
it('should read allowMCPServers from settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {
|
||||
...baseSettings,
|
||||
allowMCPServers: ['server1', 'server2'],
|
||||
};
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getMcpServers()).toEqual({
|
||||
server1: { url: 'http://localhost:8080' },
|
||||
server2: { url: 'http://localhost:8081' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should read excludeMCPServers from settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {
|
||||
...baseSettings,
|
||||
excludeMCPServers: ['server1', 'server2'],
|
||||
};
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getMcpServers()).toEqual({
|
||||
server3: { url: 'http://localhost:8082' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should override allowMCPServers with excludeMCPServers if overlapping ', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {
|
||||
...baseSettings,
|
||||
excludeMCPServers: ['server1'],
|
||||
allowMCPServers: ['server1', 'server2'],
|
||||
};
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getMcpServers()).toEqual({
|
||||
server2: { url: 'http://localhost:8081' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should prioritize mcp server flag if set ', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'--allowed-mcp-server-names',
|
||||
'server1',
|
||||
];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {
|
||||
...baseSettings,
|
||||
excludeMCPServers: ['server1'],
|
||||
allowMCPServers: ['server2'],
|
||||
};
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getMcpServers()).toEqual({
|
||||
server1: { url: 'http://localhost:8080' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig extensions', () => {
|
||||
@@ -897,7 +991,69 @@ describe('loadCliConfig extensions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig ideMode', () => {
|
||||
describe('loadCliConfig model selection', () => {
|
||||
it('selects a model from settings.json if provided', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(
|
||||
{
|
||||
model: 'qwen3-coder-plus',
|
||||
},
|
||||
[],
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('qwen3-coder-plus');
|
||||
});
|
||||
|
||||
it('uses the default gemini model if nothing is set', async () => {
|
||||
process.argv = ['node', 'script.js']; // No model set.
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(
|
||||
{
|
||||
// No model set.
|
||||
},
|
||||
[],
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('qwen3-coder-plus');
|
||||
});
|
||||
|
||||
it('always prefers model from argvs', async () => {
|
||||
process.argv = ['node', 'script.js', '--model', 'qwen3-coder-plus'];
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(
|
||||
{
|
||||
model: 'qwen3-coder-plus',
|
||||
},
|
||||
[],
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('qwen3-coder-plus');
|
||||
});
|
||||
|
||||
it('selects the model from argvs if provided', async () => {
|
||||
process.argv = ['node', 'script.js', '--model', 'qwen3-coder-plus'];
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(
|
||||
{
|
||||
// No model provided via settings.
|
||||
},
|
||||
[],
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('qwen3-coder-plus');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig ideModeFeature', () => {
|
||||
const originalArgv = process.argv;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
@@ -905,9 +1061,8 @@ describe('loadCliConfig ideMode', () => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||
process.env.GEMINI_API_KEY = 'test-api-key';
|
||||
// Explicitly delete TERM_PROGRAM and SANDBOX before each test
|
||||
delete process.env.TERM_PROGRAM;
|
||||
delete process.env.SANDBOX;
|
||||
delete process.env.GEMINI_CLI_IDE_SERVER_PORT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -921,156 +1076,16 @@ describe('loadCliConfig ideMode', () => {
|
||||
const settings: Settings = {};
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getIdeMode()).toBe(false);
|
||||
expect(config.getIdeModeFeature()).toBe(false);
|
||||
});
|
||||
|
||||
it('should be false if --ide-mode is true but TERM_PROGRAM is not vscode', async () => {
|
||||
process.argv = ['node', 'script.js', '--ide-mode'];
|
||||
const settings: Settings = {};
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getIdeMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('should be false if settings.ideMode is true but TERM_PROGRAM is not vscode', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = { ideMode: true };
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getIdeMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('should be true when --ide-mode is set and TERM_PROGRAM is vscode', async () => {
|
||||
process.argv = ['node', 'script.js', '--ide-mode'];
|
||||
const argv = await parseArguments();
|
||||
process.env.TERM_PROGRAM = 'vscode';
|
||||
const settings: Settings = {};
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getIdeMode()).toBe(true);
|
||||
});
|
||||
|
||||
it('should be true when settings.ideMode is true and TERM_PROGRAM is vscode', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
process.env.TERM_PROGRAM = 'vscode';
|
||||
const settings: Settings = { ideMode: true };
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getIdeMode()).toBe(true);
|
||||
});
|
||||
|
||||
it('should prioritize --ide-mode (true) over settings (false) when TERM_PROGRAM is vscode', async () => {
|
||||
process.argv = ['node', 'script.js', '--ide-mode'];
|
||||
const argv = await parseArguments();
|
||||
process.env.TERM_PROGRAM = 'vscode';
|
||||
const settings: Settings = { ideMode: false };
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getIdeMode()).toBe(true);
|
||||
});
|
||||
|
||||
it('should prioritize --no-ide-mode (false) over settings (true) even when TERM_PROGRAM is vscode', async () => {
|
||||
process.argv = ['node', 'script.js', '--no-ide-mode'];
|
||||
const argv = await parseArguments();
|
||||
process.env.TERM_PROGRAM = 'vscode';
|
||||
const settings: Settings = { ideMode: true };
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getIdeMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('should be false when --ide-mode is true, TERM_PROGRAM is vscode, but SANDBOX is set', async () => {
|
||||
process.argv = ['node', 'script.js', '--ide-mode'];
|
||||
const argv = await parseArguments();
|
||||
process.env.TERM_PROGRAM = 'vscode';
|
||||
process.env.SANDBOX = 'true';
|
||||
const settings: Settings = {};
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getIdeMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('should be false when settings.ideMode is true, TERM_PROGRAM is vscode, but SANDBOX is set', async () => {
|
||||
it('should be false when settings.ideModeFeature is true, but SANDBOX is set', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
process.env.TERM_PROGRAM = 'vscode';
|
||||
process.env.SANDBOX = 'true';
|
||||
const settings: Settings = { ideMode: true };
|
||||
const settings: Settings = { ideModeFeature: true };
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getIdeMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('should add __ide_server when ideMode is true', async () => {
|
||||
process.argv = ['node', 'script.js', '--ide-mode'];
|
||||
const argv = await parseArguments();
|
||||
process.env.TERM_PROGRAM = 'vscode';
|
||||
const settings: Settings = {};
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getIdeMode()).toBe(true);
|
||||
const mcpServers = config.getMcpServers();
|
||||
expect(mcpServers?.['_ide_server']).toBeDefined();
|
||||
expect(mcpServers?.['_ide_server']?.httpUrl).toBe(
|
||||
'http://localhost:3000/mcp',
|
||||
);
|
||||
expect(mcpServers?.['_ide_server']?.description).toBe('IDE connection');
|
||||
expect(mcpServers?.['_ide_server']?.trust).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig systemPromptMappings', () => {
|
||||
it('should use default systemPromptMappings when not provided in settings', async () => {
|
||||
const mockSettings: Settings = {
|
||||
theme: 'dark',
|
||||
};
|
||||
const mockExtensions: Extension[] = [];
|
||||
const mockSessionId = 'test-session';
|
||||
const mockArgv: CliArgs = {
|
||||
model: 'test-model',
|
||||
} as CliArgs;
|
||||
|
||||
const config = await loadCliConfig(
|
||||
mockSettings,
|
||||
mockExtensions,
|
||||
mockSessionId,
|
||||
mockArgv,
|
||||
);
|
||||
|
||||
expect(config.getSystemPromptMappings()).toEqual([
|
||||
{
|
||||
baseUrls: [
|
||||
'https://dashscope.aliyuncs.com/compatible-mode/v1/',
|
||||
'https://dashscope-intl.aliyuncs.com/compatible-mode/v1/',
|
||||
],
|
||||
modelNames: ['qwen3-coder-plus'],
|
||||
template:
|
||||
'SYSTEM_TEMPLATE:{"name":"qwen3_coder","params":{"is_git_repository":{RUNTIME_VARS_IS_GIT_REPO},"sandbox":"{RUNTIME_VARS_SANDBOX}"}}',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use custom systemPromptMappings when provided in settings', async () => {
|
||||
const customSystemPromptMappings = [
|
||||
{
|
||||
baseUrls: ['https://custom-api.com'],
|
||||
modelNames: ['custom-model'],
|
||||
template: 'Custom template',
|
||||
},
|
||||
];
|
||||
const mockSettings: Settings = {
|
||||
theme: 'dark',
|
||||
systemPromptMappings: customSystemPromptMappings,
|
||||
};
|
||||
const mockExtensions: Extension[] = [];
|
||||
const mockSessionId = 'test-session';
|
||||
const mockArgv: CliArgs = {
|
||||
model: 'test-model',
|
||||
} as CliArgs;
|
||||
|
||||
const config = await loadCliConfig(
|
||||
mockSettings,
|
||||
mockExtensions,
|
||||
mockSessionId,
|
||||
mockArgv,
|
||||
);
|
||||
|
||||
expect(config.getSystemPromptMappings()).toEqual(
|
||||
customSystemPromptMappings,
|
||||
);
|
||||
expect(config.getIdeModeFeature()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { homedir } from 'node:os';
|
||||
import yargs from 'yargs/yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import process from 'node:process';
|
||||
@@ -15,13 +18,15 @@ import {
|
||||
ApprovalMode,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
FileDiscoveryService,
|
||||
TelemetryTarget,
|
||||
MCPServerConfig,
|
||||
FileFilteringOptions,
|
||||
IdeClient,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { Settings } from './settings.js';
|
||||
|
||||
import { Extension, filterActiveExtensions } from './extension.js';
|
||||
import { Extension, annotateActiveExtensions } from './extension.js';
|
||||
import { getCliVersion } from '../utils/version.js';
|
||||
import { loadSandboxConfig } from './sandboxConfig.js';
|
||||
|
||||
@@ -52,13 +57,17 @@ export interface CliArgs {
|
||||
telemetryTarget: string | undefined;
|
||||
telemetryOtlpEndpoint: string | undefined;
|
||||
telemetryLogPrompts: boolean | undefined;
|
||||
telemetryOutfile: string | undefined;
|
||||
allowedMcpServerNames: string[] | undefined;
|
||||
experimentalAcp: boolean | undefined;
|
||||
extensions: string[] | undefined;
|
||||
listExtensions: boolean | undefined;
|
||||
ideMode: boolean | undefined;
|
||||
ideModeFeature: boolean | undefined;
|
||||
openaiLogging: boolean | undefined;
|
||||
openaiApiKey: string | undefined;
|
||||
openaiBaseUrl: string | undefined;
|
||||
proxy: string | undefined;
|
||||
includeDirectories: string[] | undefined;
|
||||
}
|
||||
|
||||
export async function parseArguments(): Promise<CliArgs> {
|
||||
@@ -72,7 +81,7 @@ export async function parseArguments(): Promise<CliArgs> {
|
||||
alias: 'm',
|
||||
type: 'string',
|
||||
description: `Model`,
|
||||
default: process.env.GEMINI_MODEL || DEFAULT_GEMINI_MODEL,
|
||||
default: process.env.GEMINI_MODEL,
|
||||
})
|
||||
.option('prompt', {
|
||||
alias: 'p',
|
||||
@@ -157,12 +166,20 @@ export async function parseArguments(): Promise<CliArgs> {
|
||||
description:
|
||||
'Enable or disable logging of user prompts for telemetry. Overrides settings files.',
|
||||
})
|
||||
.option('telemetry-outfile', {
|
||||
type: 'string',
|
||||
description: 'Redirect all telemetry output to the specified file.',
|
||||
})
|
||||
.option('checkpointing', {
|
||||
alias: 'c',
|
||||
type: 'boolean',
|
||||
description: 'Enables checkpointing of file edits',
|
||||
default: false,
|
||||
})
|
||||
.option('experimental-acp', {
|
||||
type: 'boolean',
|
||||
description: 'Starts the agent in ACP mode',
|
||||
})
|
||||
.option('allowed-mcp-server-names', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
@@ -180,7 +197,7 @@ export async function parseArguments(): Promise<CliArgs> {
|
||||
type: 'boolean',
|
||||
description: 'List all available extensions and exit.',
|
||||
})
|
||||
.option('ide-mode', {
|
||||
.option('ide-mode-feature', {
|
||||
type: 'boolean',
|
||||
description: 'Run in IDE mode?',
|
||||
})
|
||||
@@ -197,7 +214,20 @@ export async function parseArguments(): Promise<CliArgs> {
|
||||
type: 'string',
|
||||
description: 'OpenAI base URL (for custom endpoints)',
|
||||
})
|
||||
|
||||
.option('proxy', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Proxy for gemini client, like schema://user:password@host:port',
|
||||
})
|
||||
.option('include-directories', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
description:
|
||||
'Additional directories to include in the workspace (comma-separated or multiple --include-directories)',
|
||||
coerce: (dirs: string[]) =>
|
||||
// Handle comma-separated values
|
||||
dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())),
|
||||
})
|
||||
.version(await getCliVersion()) // This will enable the --version flag based on package.json
|
||||
.alias('v', 'version')
|
||||
.help()
|
||||
@@ -213,7 +243,11 @@ export async function parseArguments(): Promise<CliArgs> {
|
||||
});
|
||||
|
||||
yargsInstance.wrap(yargsInstance.terminalWidth());
|
||||
return yargsInstance.argv;
|
||||
const result = yargsInstance.parseSync();
|
||||
|
||||
// The import format is now only controlled by settings.memoryImportFormat
|
||||
// We no longer accept it as a CLI argument
|
||||
return result as CliArgs;
|
||||
}
|
||||
|
||||
// This function is now a thin wrapper around the server's implementation.
|
||||
@@ -223,20 +257,35 @@ export async function loadHierarchicalGeminiMemory(
|
||||
currentWorkingDirectory: string,
|
||||
debugMode: boolean,
|
||||
fileService: FileDiscoveryService,
|
||||
settings: Settings,
|
||||
extensionContextFilePaths: string[] = [],
|
||||
memoryImportFormat: 'flat' | 'tree' = 'tree',
|
||||
fileFilteringOptions?: FileFilteringOptions,
|
||||
): Promise<{ memoryContent: string; fileCount: number }> {
|
||||
// FIX: Use real, canonical paths for a reliable comparison to handle symlinks.
|
||||
const realCwd = fs.realpathSync(path.resolve(currentWorkingDirectory));
|
||||
const realHome = fs.realpathSync(path.resolve(homedir()));
|
||||
const isHomeDirectory = realCwd === realHome;
|
||||
|
||||
// If it is the home directory, pass an empty string to the core memory
|
||||
// function to signal that it should skip the workspace search.
|
||||
const effectiveCwd = isHomeDirectory ? '' : currentWorkingDirectory;
|
||||
|
||||
if (debugMode) {
|
||||
logger.debug(
|
||||
`CLI: Delegating hierarchical memory load to server for CWD: ${currentWorkingDirectory}`,
|
||||
`CLI: Delegating hierarchical memory load to server for CWD: ${currentWorkingDirectory} (memoryImportFormat: ${memoryImportFormat})`,
|
||||
);
|
||||
}
|
||||
// Directly call the server function.
|
||||
// The server function will use its own homedir() for the global path.
|
||||
|
||||
// Directly call the server function with the corrected path.
|
||||
return loadServerHierarchicalMemory(
|
||||
currentWorkingDirectory,
|
||||
effectiveCwd,
|
||||
debugMode,
|
||||
fileService,
|
||||
extensionContextFilePaths,
|
||||
memoryImportFormat,
|
||||
fileFilteringOptions,
|
||||
settings.memoryDiscoveryMaxDirs,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -250,18 +299,25 @@ export async function loadCliConfig(
|
||||
argv.debug ||
|
||||
[process.env.DEBUG, process.env.DEBUG_MODE].some(
|
||||
(v) => v === 'true' || v === '1',
|
||||
);
|
||||
) ||
|
||||
false;
|
||||
const memoryImportFormat = settings.memoryImportFormat || 'tree';
|
||||
const ideMode = settings.ideMode ?? false;
|
||||
|
||||
const ideMode =
|
||||
(argv.ideMode ?? settings.ideMode ?? false) &&
|
||||
process.env.TERM_PROGRAM === 'vscode' &&
|
||||
const ideModeFeature =
|
||||
(argv.ideModeFeature ?? settings.ideModeFeature ?? false) &&
|
||||
!process.env.SANDBOX;
|
||||
|
||||
const activeExtensions = filterActiveExtensions(
|
||||
const ideClient = IdeClient.getInstance(ideMode && ideModeFeature);
|
||||
|
||||
const allExtensions = annotateActiveExtensions(
|
||||
extensions,
|
||||
argv.extensions || [],
|
||||
);
|
||||
|
||||
const activeExtensions = extensions.filter(
|
||||
(_, i) => allExtensions[i].isActive,
|
||||
);
|
||||
// Handle OpenAI API key from command line
|
||||
if (argv.openaiApiKey) {
|
||||
process.env.OPENAI_API_KEY = argv.openaiApiKey;
|
||||
@@ -288,53 +344,82 @@ export async function loadCliConfig(
|
||||
);
|
||||
|
||||
const fileService = new FileDiscoveryService(process.cwd());
|
||||
|
||||
const fileFiltering = {
|
||||
...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
...settings.fileFiltering,
|
||||
};
|
||||
|
||||
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
|
||||
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
|
||||
process.cwd(),
|
||||
debugMode,
|
||||
fileService,
|
||||
settings,
|
||||
extensionContextFilePaths,
|
||||
memoryImportFormat,
|
||||
fileFiltering,
|
||||
);
|
||||
|
||||
let mcpServers = mergeMcpServers(settings, activeExtensions);
|
||||
const excludeTools = mergeExcludeTools(settings, activeExtensions);
|
||||
const blockedMcpServers: Array<{ name: string; extensionName: string }> = [];
|
||||
|
||||
if (!argv.allowedMcpServerNames) {
|
||||
if (settings.allowMCPServers) {
|
||||
const allowedNames = new Set(settings.allowMCPServers.filter(Boolean));
|
||||
if (allowedNames.size > 0) {
|
||||
mcpServers = Object.fromEntries(
|
||||
Object.entries(mcpServers).filter(([key]) => allowedNames.has(key)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.excludeMCPServers) {
|
||||
const excludedNames = new Set(settings.excludeMCPServers.filter(Boolean));
|
||||
if (excludedNames.size > 0) {
|
||||
mcpServers = Object.fromEntries(
|
||||
Object.entries(mcpServers).filter(([key]) => !excludedNames.has(key)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (argv.allowedMcpServerNames) {
|
||||
const allowedNames = new Set(argv.allowedMcpServerNames.filter(Boolean));
|
||||
if (allowedNames.size > 0) {
|
||||
mcpServers = Object.fromEntries(
|
||||
Object.entries(mcpServers).filter(([key]) => allowedNames.has(key)),
|
||||
Object.entries(mcpServers).filter(([key, server]) => {
|
||||
const isAllowed = allowedNames.has(key);
|
||||
if (!isAllowed) {
|
||||
blockedMcpServers.push({
|
||||
name: key,
|
||||
extensionName: server.extensionName || '',
|
||||
});
|
||||
}
|
||||
return isAllowed;
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
blockedMcpServers.push(
|
||||
...Object.entries(mcpServers).map(([key, server]) => ({
|
||||
name: key,
|
||||
extensionName: server.extensionName || '',
|
||||
})),
|
||||
);
|
||||
mcpServers = {};
|
||||
}
|
||||
}
|
||||
|
||||
if (ideMode) {
|
||||
mcpServers['_ide_server'] = new MCPServerConfig(
|
||||
undefined, // command
|
||||
undefined, // args
|
||||
undefined, // env
|
||||
undefined, // cwd
|
||||
undefined, // url
|
||||
'http://localhost:3000/mcp', // httpUrl
|
||||
undefined, // headers
|
||||
undefined, // tcp
|
||||
undefined, // timeout
|
||||
false, // trust
|
||||
'IDE connection', // description
|
||||
undefined, // includeTools
|
||||
undefined, // excludeTools
|
||||
);
|
||||
}
|
||||
|
||||
const sandboxConfig = await loadSandboxConfig(settings, argv);
|
||||
const cliVersion = await getCliVersion();
|
||||
|
||||
return new Config({
|
||||
sessionId,
|
||||
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
sandbox: sandboxConfig,
|
||||
targetDir: process.cwd(),
|
||||
includeDirectories: argv.includeDirectories,
|
||||
debugMode,
|
||||
question: argv.promptInteractive || argv.prompt || '',
|
||||
fullContext: argv.allFiles || argv.all_files || false,
|
||||
@@ -362,16 +447,19 @@ export async function loadCliConfig(
|
||||
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ??
|
||||
settings.telemetry?.otlpEndpoint,
|
||||
logPrompts: argv.telemetryLogPrompts ?? settings.telemetry?.logPrompts,
|
||||
outfile: argv.telemetryOutfile ?? settings.telemetry?.outfile,
|
||||
},
|
||||
usageStatisticsEnabled: settings.usageStatisticsEnabled ?? true,
|
||||
// Git-aware file filtering settings
|
||||
fileFiltering: {
|
||||
respectGitIgnore: settings.fileFiltering?.respectGitIgnore,
|
||||
respectGeminiIgnore: settings.fileFiltering?.respectGeminiIgnore,
|
||||
enableRecursiveFileSearch:
|
||||
settings.fileFiltering?.enableRecursiveFileSearch,
|
||||
},
|
||||
checkpointing: argv.checkpointing || settings.checkpointing?.enabled,
|
||||
proxy:
|
||||
argv.proxy ||
|
||||
process.env.HTTPS_PROXY ||
|
||||
process.env.https_proxy ||
|
||||
process.env.HTTP_PROXY ||
|
||||
@@ -379,18 +467,20 @@ export async function loadCliConfig(
|
||||
cwd: process.cwd(),
|
||||
fileDiscoveryService: fileService,
|
||||
bugCommand: settings.bugCommand,
|
||||
model: argv.model!,
|
||||
model: argv.model || settings.model || DEFAULT_GEMINI_MODEL,
|
||||
extensionContextFilePaths,
|
||||
maxSessionTurns: settings.maxSessionTurns ?? -1,
|
||||
sessionTokenLimit: settings.sessionTokenLimit ?? 32000,
|
||||
sessionTokenLimit: settings.sessionTokenLimit ?? -1,
|
||||
maxFolderItems: settings.maxFolderItems ?? 20,
|
||||
experimentalAcp: argv.experimentalAcp || false,
|
||||
listExtensions: argv.listExtensions || false,
|
||||
activeExtensions: activeExtensions.map((e) => ({
|
||||
name: e.config.name,
|
||||
version: e.config.version,
|
||||
})),
|
||||
extensions: allExtensions,
|
||||
blockedMcpServers,
|
||||
noBrowser: !!process.env.NO_BROWSER,
|
||||
summarizeToolOutput: settings.summarizeToolOutput,
|
||||
ideMode,
|
||||
ideModeFeature,
|
||||
ideClient,
|
||||
enableOpenAILogging:
|
||||
(typeof argv.openaiLogging === 'undefined'
|
||||
? settings.enableOpenAILogging
|
||||
@@ -407,6 +497,8 @@ export async function loadCliConfig(
|
||||
'SYSTEM_TEMPLATE:{"name":"qwen3_coder","params":{"is_git_repository":{RUNTIME_VARS_IS_GIT_REPO},"sandbox":"{RUNTIME_VARS_SANDBOX}"}}',
|
||||
},
|
||||
],
|
||||
contentGenerator: settings.contentGenerator,
|
||||
cliVersion,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -421,7 +513,10 @@ function mergeMcpServers(settings: Settings, extensions: Extension[]) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
mcpServers[key] = server;
|
||||
mcpServers[key] = {
|
||||
...server,
|
||||
extensionName: extension.config.name,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import * as path from 'path';
|
||||
import {
|
||||
EXTENSIONS_CONFIG_FILENAME,
|
||||
EXTENSIONS_DIRECTORY_NAME,
|
||||
filterActiveExtensions,
|
||||
annotateActiveExtensions,
|
||||
loadExtensions,
|
||||
} from './extension.js';
|
||||
|
||||
@@ -29,10 +29,10 @@ describe('loadExtensions', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
tempWorkspaceDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
|
||||
path.join(os.tmpdir(), 'qwen-code-test-workspace-'),
|
||||
);
|
||||
tempHomeDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
|
||||
path.join(os.tmpdir(), 'qwen-code-test-home-'),
|
||||
);
|
||||
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
|
||||
});
|
||||
@@ -42,7 +42,82 @@ describe('loadExtensions', () => {
|
||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should load context file path when GEMINI.md is present', () => {
|
||||
it('should include extension path in loaded extension', () => {
|
||||
const workspaceExtensionsDir = path.join(
|
||||
tempWorkspaceDir,
|
||||
EXTENSIONS_DIRECTORY_NAME,
|
||||
);
|
||||
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
|
||||
|
||||
const extensionDir = path.join(workspaceExtensionsDir, 'test-extension');
|
||||
fs.mkdirSync(extensionDir, { recursive: true });
|
||||
|
||||
const config = {
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME),
|
||||
JSON.stringify(config),
|
||||
);
|
||||
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
expect(extensions).toHaveLength(1);
|
||||
expect(extensions[0].path).toBe(extensionDir);
|
||||
expect(extensions[0].config.name).toBe('test-extension');
|
||||
});
|
||||
|
||||
it('should include extension path in loaded extension', () => {
|
||||
const workspaceExtensionsDir = path.join(
|
||||
tempWorkspaceDir,
|
||||
EXTENSIONS_DIRECTORY_NAME,
|
||||
);
|
||||
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
|
||||
|
||||
const extensionDir = path.join(workspaceExtensionsDir, 'test-extension');
|
||||
fs.mkdirSync(extensionDir, { recursive: true });
|
||||
|
||||
const config = {
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME),
|
||||
JSON.stringify(config),
|
||||
);
|
||||
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
expect(extensions).toHaveLength(1);
|
||||
expect(extensions[0].path).toBe(extensionDir);
|
||||
expect(extensions[0].config.name).toBe('test-extension');
|
||||
});
|
||||
|
||||
it('should include extension path in loaded extension', () => {
|
||||
const workspaceExtensionsDir = path.join(
|
||||
tempWorkspaceDir,
|
||||
EXTENSIONS_DIRECTORY_NAME,
|
||||
);
|
||||
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
|
||||
|
||||
const extensionDir = path.join(workspaceExtensionsDir, 'test-extension');
|
||||
fs.mkdirSync(extensionDir, { recursive: true });
|
||||
|
||||
const config = {
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME),
|
||||
JSON.stringify(config),
|
||||
);
|
||||
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
expect(extensions).toHaveLength(1);
|
||||
expect(extensions[0].path).toBe(extensionDir);
|
||||
expect(extensions[0].config.name).toBe('test-extension');
|
||||
});
|
||||
|
||||
it('should load context file path when QWEN.md is present', () => {
|
||||
const workspaceExtensionsDir = path.join(
|
||||
tempWorkspaceDir,
|
||||
EXTENSIONS_DIRECTORY_NAME,
|
||||
@@ -86,42 +161,52 @@ describe('loadExtensions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterActiveExtensions', () => {
|
||||
describe('annotateActiveExtensions', () => {
|
||||
const extensions = [
|
||||
{ config: { name: 'ext1', version: '1.0.0' }, contextFiles: [] },
|
||||
{ config: { name: 'ext2', version: '1.0.0' }, contextFiles: [] },
|
||||
{ config: { name: 'ext3', version: '1.0.0' }, contextFiles: [] },
|
||||
];
|
||||
|
||||
it('should return all extensions if no enabled extensions are provided', () => {
|
||||
const activeExtensions = filterActiveExtensions(extensions, []);
|
||||
it('should mark all extensions as active if no enabled extensions are provided', () => {
|
||||
const activeExtensions = annotateActiveExtensions(extensions, []);
|
||||
expect(activeExtensions).toHaveLength(3);
|
||||
expect(activeExtensions.every((e) => e.isActive)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return only the enabled extensions', () => {
|
||||
const activeExtensions = filterActiveExtensions(extensions, [
|
||||
it('should mark only the enabled extensions as active', () => {
|
||||
const activeExtensions = annotateActiveExtensions(extensions, [
|
||||
'ext1',
|
||||
'ext3',
|
||||
]);
|
||||
expect(activeExtensions).toHaveLength(2);
|
||||
expect(activeExtensions.some((e) => e.config.name === 'ext1')).toBe(true);
|
||||
expect(activeExtensions.some((e) => e.config.name === 'ext3')).toBe(true);
|
||||
expect(activeExtensions).toHaveLength(3);
|
||||
expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe(
|
||||
true,
|
||||
);
|
||||
expect(activeExtensions.find((e) => e.name === 'ext2')?.isActive).toBe(
|
||||
false,
|
||||
);
|
||||
expect(activeExtensions.find((e) => e.name === 'ext3')?.isActive).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return no extensions when "none" is provided', () => {
|
||||
const activeExtensions = filterActiveExtensions(extensions, ['none']);
|
||||
expect(activeExtensions).toHaveLength(0);
|
||||
it('should mark all extensions as inactive when "none" is provided', () => {
|
||||
const activeExtensions = annotateActiveExtensions(extensions, ['none']);
|
||||
expect(activeExtensions).toHaveLength(3);
|
||||
expect(activeExtensions.every((e) => !e.isActive)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle case-insensitivity', () => {
|
||||
const activeExtensions = filterActiveExtensions(extensions, ['EXT1']);
|
||||
expect(activeExtensions).toHaveLength(1);
|
||||
expect(activeExtensions[0].config.name).toBe('ext1');
|
||||
const activeExtensions = annotateActiveExtensions(extensions, ['EXT1']);
|
||||
expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should log an error for unknown extensions', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
filterActiveExtensions(extensions, ['ext4']);
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
annotateActiveExtensions(extensions, ['ext4']);
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { MCPServerConfig } from '@qwen-code/qwen-code-core';
|
||||
import { MCPServerConfig, GeminiCLIExtension } from '@qwen-code/qwen-code-core';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
@@ -13,6 +13,7 @@ export const EXTENSIONS_DIRECTORY_NAME = path.join('.qwen', 'extensions');
|
||||
export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json';
|
||||
|
||||
export interface Extension {
|
||||
path: string;
|
||||
config: ExtensionConfig;
|
||||
contextFiles: string[];
|
||||
}
|
||||
@@ -34,9 +35,6 @@ export function loadExtensions(workspaceDir: string): Extension[] {
|
||||
const uniqueExtensions = new Map<string, Extension>();
|
||||
for (const extension of allExtensions) {
|
||||
if (!uniqueExtensions.has(extension.config.name)) {
|
||||
console.log(
|
||||
`Loading extension: ${extension.config.name} (version: ${extension.config.version})`,
|
||||
);
|
||||
uniqueExtensions.set(extension.config.name, extension);
|
||||
}
|
||||
}
|
||||
@@ -93,6 +91,7 @@ function loadExtension(extensionDir: string): Extension | null {
|
||||
.filter((contextFilePath) => fs.existsSync(contextFilePath));
|
||||
|
||||
return {
|
||||
path: extensionDir,
|
||||
config,
|
||||
contextFiles,
|
||||
};
|
||||
@@ -113,12 +112,19 @@ function getContextFileNames(config: ExtensionConfig): string[] {
|
||||
return config.contextFileName;
|
||||
}
|
||||
|
||||
export function filterActiveExtensions(
|
||||
export function annotateActiveExtensions(
|
||||
extensions: Extension[],
|
||||
enabledExtensionNames: string[],
|
||||
): Extension[] {
|
||||
): GeminiCLIExtension[] {
|
||||
const annotatedExtensions: GeminiCLIExtension[] = [];
|
||||
|
||||
if (enabledExtensionNames.length === 0) {
|
||||
return extensions;
|
||||
return extensions.map((extension) => ({
|
||||
name: extension.config.name,
|
||||
version: extension.config.version,
|
||||
isActive: true,
|
||||
path: extension.path,
|
||||
}));
|
||||
}
|
||||
|
||||
const lowerCaseEnabledExtensions = new Set(
|
||||
@@ -129,31 +135,35 @@ export function filterActiveExtensions(
|
||||
lowerCaseEnabledExtensions.size === 1 &&
|
||||
lowerCaseEnabledExtensions.has('none')
|
||||
) {
|
||||
if (extensions.length > 0) {
|
||||
console.log('All extensions are disabled.');
|
||||
}
|
||||
return [];
|
||||
return extensions.map((extension) => ({
|
||||
name: extension.config.name,
|
||||
version: extension.config.version,
|
||||
isActive: false,
|
||||
path: extension.path,
|
||||
}));
|
||||
}
|
||||
|
||||
const activeExtensions: Extension[] = [];
|
||||
const notFoundNames = new Set(lowerCaseEnabledExtensions);
|
||||
|
||||
for (const extension of extensions) {
|
||||
const lowerCaseName = extension.config.name.toLowerCase();
|
||||
if (lowerCaseEnabledExtensions.has(lowerCaseName)) {
|
||||
console.log(
|
||||
`Activated extension: ${extension.config.name} (version: ${extension.config.version})`,
|
||||
);
|
||||
activeExtensions.push(extension);
|
||||
const isActive = lowerCaseEnabledExtensions.has(lowerCaseName);
|
||||
|
||||
if (isActive) {
|
||||
notFoundNames.delete(lowerCaseName);
|
||||
} else {
|
||||
console.log(`Disabled extension: ${extension.config.name}`);
|
||||
}
|
||||
|
||||
annotatedExtensions.push({
|
||||
name: extension.config.name,
|
||||
version: extension.config.version,
|
||||
isActive,
|
||||
path: extension.path,
|
||||
});
|
||||
}
|
||||
|
||||
for (const requestedName of notFoundNames) {
|
||||
console.log(`Extension not found: ${requestedName}`);
|
||||
console.error(`Extension not found: ${requestedName}`);
|
||||
}
|
||||
|
||||
return activeExtensions;
|
||||
return annotatedExtensions;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ import stripJsonComments from 'strip-json-comments'; // Will be mocked separatel
|
||||
import {
|
||||
loadSettings,
|
||||
USER_SETTINGS_PATH, // This IS the mocked path.
|
||||
SYSTEM_SETTINGS_PATH,
|
||||
getSystemSettingsPath,
|
||||
SETTINGS_DIRECTORY_NAME, // This is from the original module, but used by the mock.
|
||||
SettingScope,
|
||||
} from './settings.js';
|
||||
@@ -59,7 +59,21 @@ const MOCK_WORKSPACE_SETTINGS_PATH = pathActual.join(
|
||||
'settings.json',
|
||||
);
|
||||
|
||||
vi.mock('fs');
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
// Get all the functions from the real 'fs' module
|
||||
const actualFs = await importOriginal<typeof fs>();
|
||||
|
||||
return {
|
||||
...actualFs, // Keep all the real functions
|
||||
// Now, just override the ones we need for the test
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
realpathSync: (p: string) => p,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('strip-json-comments', () => ({
|
||||
default: vi.fn((content) => content),
|
||||
}));
|
||||
@@ -95,13 +109,16 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(settings.system.settings).toEqual({});
|
||||
expect(settings.user.settings).toEqual({});
|
||||
expect(settings.workspace.settings).toEqual({});
|
||||
expect(settings.merged).toEqual({});
|
||||
expect(settings.merged).toEqual({
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
});
|
||||
expect(settings.errors.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should load system settings if only system file exists', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === SYSTEM_SETTINGS_PATH,
|
||||
(p: fs.PathLike) => p === getSystemSettingsPath(),
|
||||
);
|
||||
const systemSettingsContent = {
|
||||
theme: 'system-default',
|
||||
@@ -109,7 +126,7 @@ describe('Settings Loading and Merging', () => {
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === SYSTEM_SETTINGS_PATH)
|
||||
if (p === getSystemSettingsPath())
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -118,13 +135,17 @@ describe('Settings Loading and Merging', () => {
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith(
|
||||
SYSTEM_SETTINGS_PATH,
|
||||
getSystemSettingsPath(),
|
||||
'utf-8',
|
||||
);
|
||||
expect(settings.system.settings).toEqual(systemSettingsContent);
|
||||
expect(settings.user.settings).toEqual({});
|
||||
expect(settings.workspace.settings).toEqual({});
|
||||
expect(settings.merged).toEqual(systemSettingsContent);
|
||||
expect(settings.merged).toEqual({
|
||||
...systemSettingsContent,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should load user settings if only user file exists', () => {
|
||||
@@ -153,7 +174,11 @@ describe('Settings Loading and Merging', () => {
|
||||
);
|
||||
expect(settings.user.settings).toEqual(userSettingsContent);
|
||||
expect(settings.workspace.settings).toEqual({});
|
||||
expect(settings.merged).toEqual(userSettingsContent);
|
||||
expect(settings.merged).toEqual({
|
||||
...userSettingsContent,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should load workspace settings if only workspace file exists', () => {
|
||||
@@ -180,7 +205,11 @@ describe('Settings Loading and Merging', () => {
|
||||
);
|
||||
expect(settings.user.settings).toEqual({});
|
||||
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
|
||||
expect(settings.merged).toEqual(workspaceSettingsContent);
|
||||
expect(settings.merged).toEqual({
|
||||
...workspaceSettingsContent,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should merge user and workspace settings, with workspace taking precedence', () => {
|
||||
@@ -215,6 +244,8 @@ describe('Settings Loading and Merging', () => {
|
||||
sandbox: true,
|
||||
coreTools: ['tool1'],
|
||||
contextFileName: 'WORKSPACE_CONTEXT.md',
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -223,6 +254,7 @@ describe('Settings Loading and Merging', () => {
|
||||
const systemSettingsContent = {
|
||||
theme: 'system-theme',
|
||||
sandbox: false,
|
||||
allowMCPServers: ['server1', 'server2'],
|
||||
telemetry: { enabled: false },
|
||||
};
|
||||
const userSettingsContent = {
|
||||
@@ -234,11 +266,12 @@ describe('Settings Loading and Merging', () => {
|
||||
sandbox: false,
|
||||
coreTools: ['tool1'],
|
||||
contextFileName: 'WORKSPACE_CONTEXT.md',
|
||||
allowMCPServers: ['server1', 'server2', 'server3'],
|
||||
};
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === SYSTEM_SETTINGS_PATH)
|
||||
if (p === getSystemSettingsPath())
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
@@ -259,6 +292,9 @@ describe('Settings Loading and Merging', () => {
|
||||
telemetry: { enabled: false },
|
||||
coreTools: ['tool1'],
|
||||
contextFileName: 'WORKSPACE_CONTEXT.md',
|
||||
allowMCPServers: ['server1', 'server2'],
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -298,6 +334,86 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(settings.merged.contextFileName).toBe('PROJECT_SPECIFIC.md');
|
||||
});
|
||||
|
||||
it('should handle excludedProjectEnvVars correctly when only in user settings', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const userSettingsContent = {
|
||||
excludedProjectEnvVars: ['DEBUG', 'NODE_ENV', 'CUSTOM_VAR'],
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.excludedProjectEnvVars).toEqual([
|
||||
'DEBUG',
|
||||
'NODE_ENV',
|
||||
'CUSTOM_VAR',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle excludedProjectEnvVars correctly when only in workspace settings', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
|
||||
);
|
||||
const workspaceSettingsContent = {
|
||||
excludedProjectEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'],
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.excludedProjectEnvVars).toEqual([
|
||||
'WORKSPACE_DEBUG',
|
||||
'WORKSPACE_VAR',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should merge excludedProjectEnvVars with workspace taking precedence over user', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const userSettingsContent = {
|
||||
excludedProjectEnvVars: ['DEBUG', 'NODE_ENV', 'USER_VAR'],
|
||||
};
|
||||
const workspaceSettingsContent = {
|
||||
excludedProjectEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'],
|
||||
};
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.user.settings.excludedProjectEnvVars).toEqual([
|
||||
'DEBUG',
|
||||
'NODE_ENV',
|
||||
'USER_VAR',
|
||||
]);
|
||||
expect(settings.workspace.settings.excludedProjectEnvVars).toEqual([
|
||||
'WORKSPACE_DEBUG',
|
||||
'WORKSPACE_VAR',
|
||||
]);
|
||||
expect(settings.merged.excludedProjectEnvVars).toEqual([
|
||||
'WORKSPACE_DEBUG',
|
||||
'WORKSPACE_VAR',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should default contextFileName to undefined if not in any settings file', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const userSettingsContent = { theme: 'dark' };
|
||||
@@ -370,6 +486,134 @@ describe('Settings Loading and Merging', () => {
|
||||
(fs.readFileSync as Mock).mockReturnValue('{}');
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.telemetry).toBeUndefined();
|
||||
expect(settings.merged.customThemes).toEqual({});
|
||||
expect(settings.merged.mcpServers).toEqual({});
|
||||
});
|
||||
|
||||
it('should merge MCP servers correctly, with workspace taking precedence', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const userSettingsContent = {
|
||||
mcpServers: {
|
||||
'user-server': {
|
||||
command: 'user-command',
|
||||
args: ['--user-arg'],
|
||||
description: 'User MCP server',
|
||||
},
|
||||
'shared-server': {
|
||||
command: 'user-shared-command',
|
||||
description: 'User shared server config',
|
||||
},
|
||||
},
|
||||
};
|
||||
const workspaceSettingsContent = {
|
||||
mcpServers: {
|
||||
'workspace-server': {
|
||||
command: 'workspace-command',
|
||||
args: ['--workspace-arg'],
|
||||
description: 'Workspace MCP server',
|
||||
},
|
||||
'shared-server': {
|
||||
command: 'workspace-shared-command',
|
||||
description: 'Workspace shared server config',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(settings.user.settings).toEqual(userSettingsContent);
|
||||
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
|
||||
expect(settings.merged.mcpServers).toEqual({
|
||||
'user-server': {
|
||||
command: 'user-command',
|
||||
args: ['--user-arg'],
|
||||
description: 'User MCP server',
|
||||
},
|
||||
'workspace-server': {
|
||||
command: 'workspace-command',
|
||||
args: ['--workspace-arg'],
|
||||
description: 'Workspace MCP server',
|
||||
},
|
||||
'shared-server': {
|
||||
command: 'workspace-shared-command',
|
||||
description: 'Workspace shared server config',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle MCP servers when only in user settings', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const userSettingsContent = {
|
||||
mcpServers: {
|
||||
'user-only-server': {
|
||||
command: 'user-only-command',
|
||||
description: 'User only server',
|
||||
},
|
||||
},
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.mcpServers).toEqual({
|
||||
'user-only-server': {
|
||||
command: 'user-only-command',
|
||||
description: 'User only server',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle MCP servers when only in workspace settings', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
|
||||
);
|
||||
const workspaceSettingsContent = {
|
||||
mcpServers: {
|
||||
'workspace-only-server': {
|
||||
command: 'workspace-only-command',
|
||||
description: 'Workspace only server',
|
||||
},
|
||||
},
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.mcpServers).toEqual({
|
||||
'workspace-only-server': {
|
||||
command: 'workspace-only-command',
|
||||
description: 'Workspace only server',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should have mcpServers as empty object if not in any settings file', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist
|
||||
(fs.readFileSync as Mock).mockReturnValue('{}');
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.mcpServers).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle JSON parsing errors gracefully', () => {
|
||||
@@ -407,7 +651,10 @@ describe('Settings Loading and Merging', () => {
|
||||
// Check that settings are empty due to parsing errors
|
||||
expect(settings.user.settings).toEqual({});
|
||||
expect(settings.workspace.settings).toEqual({});
|
||||
expect(settings.merged).toEqual({});
|
||||
expect(settings.merged).toEqual({
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
});
|
||||
|
||||
// Check that error objects are populated in settings.errors
|
||||
expect(settings.errors).toBeDefined();
|
||||
@@ -448,10 +695,13 @@ describe('Settings Loading and Merging', () => {
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
// @ts-expect-error: dynamic property for test
|
||||
expect(settings.user.settings.apiKey).toBe('user_api_key_from_env');
|
||||
// @ts-expect-error: dynamic property for test
|
||||
expect(settings.user.settings.someUrl).toBe(
|
||||
'https://test.com/user_api_key_from_env',
|
||||
);
|
||||
// @ts-expect-error: dynamic property for test
|
||||
expect(settings.merged.apiKey).toBe('user_api_key_from_env');
|
||||
delete process.env.TEST_API_KEY;
|
||||
});
|
||||
@@ -480,6 +730,7 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(settings.workspace.settings.nested.value).toBe(
|
||||
'workspace_endpoint_from_env',
|
||||
);
|
||||
// @ts-expect-error: dynamic property for test
|
||||
expect(settings.merged.endpoint).toBe('workspace_endpoint_from_env/api');
|
||||
delete process.env.WORKSPACE_ENDPOINT;
|
||||
});
|
||||
@@ -509,13 +760,16 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// @ts-expect-error: dynamic property for test
|
||||
expect(settings.user.settings.configValue).toBe(
|
||||
'user_value_for_user_read',
|
||||
);
|
||||
// @ts-expect-error: dynamic property for test
|
||||
expect(settings.workspace.settings.configValue).toBe(
|
||||
'workspace_value_for_workspace_read',
|
||||
);
|
||||
// Merged should take workspace's resolved value
|
||||
// @ts-expect-error: dynamic property for test
|
||||
expect(settings.merged.configValue).toBe(
|
||||
'workspace_value_for_workspace_read',
|
||||
);
|
||||
@@ -583,7 +837,7 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === SYSTEM_SETTINGS_PATH) {
|
||||
if (p === getSystemSettingsPath()) {
|
||||
process.env.SHARED_VAR = 'system_value_for_system_read'; // Set for system settings read
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
}
|
||||
@@ -597,13 +851,16 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// @ts-expect-error: dynamic property for test
|
||||
expect(settings.system.settings.configValue).toBe(
|
||||
'system_value_for_system_read',
|
||||
);
|
||||
// @ts-expect-error: dynamic property for test
|
||||
expect(settings.workspace.settings.configValue).toBe(
|
||||
'workspace_value_for_workspace_read',
|
||||
);
|
||||
// Merged should take workspace's resolved value
|
||||
// Merged should take system's resolved value
|
||||
// @ts-expect-error: dynamic property for test
|
||||
expect(settings.merged.configValue).toBe('system_value_for_system_read');
|
||||
|
||||
// Restore original environment variable state
|
||||
@@ -614,6 +871,48 @@ describe('Settings Loading and Merging', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should correctly merge dnsResolutionOrder with workspace taking precedence', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const userSettingsContent = {
|
||||
dnsResolutionOrder: 'ipv4first',
|
||||
};
|
||||
const workspaceSettingsContent = {
|
||||
dnsResolutionOrder: 'verbatim',
|
||||
};
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.dnsResolutionOrder).toBe('verbatim');
|
||||
});
|
||||
|
||||
it('should use user dnsResolutionOrder if workspace is not defined', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const userSettingsContent = {
|
||||
dnsResolutionOrder: 'verbatim',
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.dnsResolutionOrder).toBe('verbatim');
|
||||
});
|
||||
|
||||
it('should leave unresolved environment variables as is', () => {
|
||||
const userSettingsContent = { apiKey: '$UNDEFINED_VAR' };
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
@@ -750,6 +1049,50 @@ describe('Settings Loading and Merging', () => {
|
||||
delete process.env.TEST_HOST;
|
||||
delete process.env.TEST_PORT;
|
||||
});
|
||||
|
||||
describe('when GEMINI_CLI_SYSTEM_SETTINGS_PATH is set', () => {
|
||||
const MOCK_ENV_SYSTEM_SETTINGS_PATH = '/mock/env/system/settings.json';
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH =
|
||||
MOCK_ENV_SYSTEM_SETTINGS_PATH;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH;
|
||||
});
|
||||
|
||||
it('should load system settings from the path specified in the environment variable', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === MOCK_ENV_SYSTEM_SETTINGS_PATH,
|
||||
);
|
||||
const systemSettingsContent = {
|
||||
theme: 'env-var-theme',
|
||||
sandbox: true,
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === MOCK_ENV_SYSTEM_SETTINGS_PATH)
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith(
|
||||
MOCK_ENV_SYSTEM_SETTINGS_PATH,
|
||||
'utf-8',
|
||||
);
|
||||
expect(settings.system.path).toBe(MOCK_ENV_SYSTEM_SETTINGS_PATH);
|
||||
expect(settings.system.settings).toEqual(systemSettingsContent);
|
||||
expect(settings.merged).toEqual({
|
||||
...systemSettingsContent,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('LoadedSettings class', () => {
|
||||
@@ -792,4 +1135,140 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(loadedSettings.merged.theme).toBe('ocean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('excludedProjectEnvVars integration', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should exclude DEBUG and DEBUG_MODE from project .env files by default', () => {
|
||||
// Create a workspace settings file with excludedProjectEnvVars
|
||||
const workspaceSettingsContent = {
|
||||
excludedProjectEnvVars: ['DEBUG', 'DEBUG_MODE'],
|
||||
};
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
|
||||
);
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
// Mock findEnvFile to return a project .env file
|
||||
const originalFindEnvFile = (
|
||||
loadSettings as unknown as { findEnvFile: () => string }
|
||||
).findEnvFile;
|
||||
(loadSettings as unknown as { findEnvFile: () => string }).findEnvFile =
|
||||
() => '/mock/project/.env';
|
||||
|
||||
// Mock fs.readFileSync for .env file content
|
||||
const originalReadFileSync = fs.readFileSync;
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === '/mock/project/.env') {
|
||||
return 'DEBUG=true\nDEBUG_MODE=1\nGEMINI_API_KEY=test-key';
|
||||
}
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
// This will call loadEnvironment internally with the merged settings
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// Verify the settings were loaded correctly
|
||||
expect(settings.merged.excludedProjectEnvVars).toEqual([
|
||||
'DEBUG',
|
||||
'DEBUG_MODE',
|
||||
]);
|
||||
|
||||
// Note: We can't directly test process.env changes here because the mocking
|
||||
// prevents the actual file system operations, but we can verify the settings
|
||||
// are correctly merged and passed to loadEnvironment
|
||||
} finally {
|
||||
(loadSettings as unknown as { findEnvFile: () => string }).findEnvFile =
|
||||
originalFindEnvFile;
|
||||
(fs.readFileSync as Mock).mockImplementation(originalReadFileSync);
|
||||
}
|
||||
});
|
||||
|
||||
it('should respect custom excludedProjectEnvVars from user settings', () => {
|
||||
const userSettingsContent = {
|
||||
excludedProjectEnvVars: ['NODE_ENV', 'DEBUG'],
|
||||
};
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.user.settings.excludedProjectEnvVars).toEqual([
|
||||
'NODE_ENV',
|
||||
'DEBUG',
|
||||
]);
|
||||
expect(settings.merged.excludedProjectEnvVars).toEqual([
|
||||
'NODE_ENV',
|
||||
'DEBUG',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should merge excludedProjectEnvVars with workspace taking precedence', () => {
|
||||
const userSettingsContent = {
|
||||
excludedProjectEnvVars: ['DEBUG', 'NODE_ENV', 'USER_VAR'],
|
||||
};
|
||||
const workspaceSettingsContent = {
|
||||
excludedProjectEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'],
|
||||
};
|
||||
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(settings.user.settings.excludedProjectEnvVars).toEqual([
|
||||
'DEBUG',
|
||||
'NODE_ENV',
|
||||
'USER_VAR',
|
||||
]);
|
||||
expect(settings.workspace.settings.excludedProjectEnvVars).toEqual([
|
||||
'WORKSPACE_DEBUG',
|
||||
'WORKSPACE_VAR',
|
||||
]);
|
||||
expect(settings.merged.excludedProjectEnvVars).toEqual([
|
||||
'WORKSPACE_DEBUG',
|
||||
'WORKSPACE_VAR',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,12 +19,17 @@ import {
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
import { DefaultLight } from '../ui/themes/default-light.js';
|
||||
import { DefaultDark } from '../ui/themes/default.js';
|
||||
import { CustomTheme } from '../ui/themes/theme.js';
|
||||
|
||||
export const SETTINGS_DIRECTORY_NAME = '.qwen';
|
||||
export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME);
|
||||
export const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, 'settings.json');
|
||||
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
|
||||
|
||||
function getSystemSettingsPath(): string {
|
||||
export function getSystemSettingsPath(): string {
|
||||
if (process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH) {
|
||||
return process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH;
|
||||
}
|
||||
if (platform() === 'darwin') {
|
||||
return '/Library/Application Support/QwenCode/settings.json';
|
||||
} else if (platform() === 'win32') {
|
||||
@@ -34,7 +39,11 @@ function getSystemSettingsPath(): string {
|
||||
}
|
||||
}
|
||||
|
||||
export const SYSTEM_SETTINGS_PATH = getSystemSettingsPath();
|
||||
export function getWorkspaceSettingsPath(workspaceDir: string): string {
|
||||
return path.join(workspaceDir, SETTINGS_DIRECTORY_NAME, 'settings.json');
|
||||
}
|
||||
|
||||
export type DnsResolutionOrder = 'ipv4first' | 'verbatim';
|
||||
|
||||
export enum SettingScope {
|
||||
User = 'User',
|
||||
@@ -46,13 +55,19 @@ export interface CheckpointingSettings {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SummarizeToolOutputSettings {
|
||||
tokenBudget?: number;
|
||||
}
|
||||
|
||||
export interface AccessibilitySettings {
|
||||
disableLoadingPhrases?: boolean;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
theme?: string;
|
||||
customThemes?: Record<string, CustomTheme>;
|
||||
selectedAuthType?: AuthType;
|
||||
useExternalAuth?: boolean;
|
||||
sandbox?: boolean | string;
|
||||
coreTools?: string[];
|
||||
excludeTools?: string[];
|
||||
@@ -60,6 +75,8 @@ export interface Settings {
|
||||
toolCallCommand?: string;
|
||||
mcpServerCommand?: string;
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
allowMCPServers?: string[];
|
||||
excludeMCPServers?: string[];
|
||||
showMemoryUsage?: boolean;
|
||||
contextFileName?: string | string[];
|
||||
accessibility?: AccessibilitySettings;
|
||||
@@ -69,16 +86,19 @@ export interface Settings {
|
||||
bugCommand?: BugCommandSettings;
|
||||
checkpointing?: CheckpointingSettings;
|
||||
autoConfigureMaxOldSpaceSize?: boolean;
|
||||
/** The model name to use (e.g 'gemini-9.0-pro') */
|
||||
model?: string;
|
||||
enableOpenAILogging?: boolean;
|
||||
|
||||
// Git-aware file filtering settings
|
||||
fileFiltering?: {
|
||||
respectGitIgnore?: boolean;
|
||||
respectGeminiIgnore?: boolean;
|
||||
enableRecursiveFileSearch?: boolean;
|
||||
};
|
||||
|
||||
// UI setting. Does not display the ANSI-controlled terminal title.
|
||||
hideWindowTitle?: boolean;
|
||||
|
||||
hideTips?: boolean;
|
||||
hideBanner?: boolean;
|
||||
|
||||
@@ -91,26 +111,37 @@ export interface Settings {
|
||||
// Setting for maximum number of files and folders to show in folder structure
|
||||
maxFolderItems?: number;
|
||||
|
||||
// Sampling parameters for content generation
|
||||
sampling_params?: {
|
||||
top_p?: number;
|
||||
top_k?: number;
|
||||
repetition_penalty?: number;
|
||||
presence_penalty?: number;
|
||||
frequency_penalty?: number;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
};
|
||||
// A map of tool names to their summarization settings.
|
||||
summarizeToolOutput?: Record<string, SummarizeToolOutputSettings>;
|
||||
|
||||
// System prompt mappings for different base URLs and model names
|
||||
systemPromptMappings?: Array<{
|
||||
baseUrls?: string[];
|
||||
modelNames?: string[];
|
||||
template?: string;
|
||||
}>;
|
||||
vimMode?: boolean;
|
||||
memoryImportFormat?: 'tree' | 'flat';
|
||||
|
||||
// Add other settings here.
|
||||
// Flag to be removed post-launch.
|
||||
ideModeFeature?: boolean;
|
||||
/// IDE mode setting configured via slash command toggle.
|
||||
ideMode?: boolean;
|
||||
|
||||
// Setting for disabling auto-update.
|
||||
disableAutoUpdate?: boolean;
|
||||
|
||||
// Setting for disabling the update nag message.
|
||||
disableUpdateNag?: boolean;
|
||||
|
||||
memoryDiscoveryMaxDirs?: number;
|
||||
// Environment variables to exclude from project .env files
|
||||
excludedProjectEnvVars?: string[];
|
||||
dnsResolutionOrder?: DnsResolutionOrder;
|
||||
sampling_params?: Record<string, unknown>;
|
||||
systemPromptMappings?: Array<{
|
||||
baseUrls: string[];
|
||||
modelNames: string[];
|
||||
template: string;
|
||||
}>;
|
||||
contentGenerator?: {
|
||||
timeout?: number;
|
||||
maxRetries?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SettingsError {
|
||||
@@ -148,10 +179,24 @@ export class LoadedSettings {
|
||||
}
|
||||
|
||||
private computeMergedSettings(): Settings {
|
||||
const system = this.system.settings;
|
||||
const user = this.user.settings;
|
||||
const workspace = this.workspace.settings;
|
||||
|
||||
return {
|
||||
...this.user.settings,
|
||||
...this.workspace.settings,
|
||||
...this.system.settings,
|
||||
...user,
|
||||
...workspace,
|
||||
...system,
|
||||
customThemes: {
|
||||
...(user.customThemes || {}),
|
||||
...(workspace.customThemes || {}),
|
||||
...(system.customThemes || {}),
|
||||
},
|
||||
mcpServers: {
|
||||
...(user.mcpServers || {}),
|
||||
...(workspace.mcpServers || {}),
|
||||
...(system.mcpServers || {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -168,13 +213,12 @@ export class LoadedSettings {
|
||||
}
|
||||
}
|
||||
|
||||
setValue(
|
||||
setValue<K extends keyof Settings>(
|
||||
scope: SettingScope,
|
||||
key: keyof Settings,
|
||||
value: string | Record<string, MCPServerConfig> | undefined,
|
||||
key: K,
|
||||
value: Settings[K],
|
||||
): void {
|
||||
const settingsFile = this.forScope(scope);
|
||||
// @ts-expect-error - value can be string | Record<string, MCPServerConfig>
|
||||
settingsFile.settings[key] = value;
|
||||
this._merged = this.computeMergedSettings();
|
||||
saveSettings(settingsFile);
|
||||
@@ -274,15 +318,61 @@ export function setUpCloudShellEnvironment(envFilePath: string | null): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function loadEnvironment(): void {
|
||||
export function loadEnvironment(settings?: Settings): void {
|
||||
const envFilePath = findEnvFile(process.cwd());
|
||||
|
||||
// Cloud Shell environment variable handling
|
||||
if (process.env.CLOUD_SHELL === 'true') {
|
||||
setUpCloudShellEnvironment(envFilePath);
|
||||
}
|
||||
|
||||
// If no settings provided, try to load workspace settings for exclusions
|
||||
let resolvedSettings = settings;
|
||||
if (!resolvedSettings) {
|
||||
const workspaceSettingsPath = getWorkspaceSettingsPath(process.cwd());
|
||||
try {
|
||||
if (fs.existsSync(workspaceSettingsPath)) {
|
||||
const workspaceContent = fs.readFileSync(
|
||||
workspaceSettingsPath,
|
||||
'utf-8',
|
||||
);
|
||||
const parsedWorkspaceSettings = JSON.parse(
|
||||
stripJsonComments(workspaceContent),
|
||||
) as Settings;
|
||||
resolvedSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore errors loading workspace settings
|
||||
}
|
||||
}
|
||||
|
||||
if (envFilePath) {
|
||||
dotenv.config({ path: envFilePath, quiet: true });
|
||||
// Manually parse and load environment variables to handle exclusions correctly.
|
||||
// This avoids modifying environment variables that were already set from the shell.
|
||||
try {
|
||||
const envFileContent = fs.readFileSync(envFilePath, 'utf-8');
|
||||
const parsedEnv = dotenv.parse(envFileContent);
|
||||
|
||||
const excludedVars =
|
||||
resolvedSettings?.excludedProjectEnvVars || DEFAULT_EXCLUDED_ENV_VARS;
|
||||
const isProjectEnvFile = !envFilePath.includes(GEMINI_DIR);
|
||||
|
||||
for (const key in parsedEnv) {
|
||||
if (Object.hasOwn(parsedEnv, key)) {
|
||||
// If it's a project .env file, skip loading excluded variables.
|
||||
if (isProjectEnvFile && excludedVars.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load variable only if it's not already set in the environment.
|
||||
if (!Object.hasOwn(process.env, key)) {
|
||||
process.env[key] = parsedEnv[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// Errors are ignored to match the behavior of `dotenv.config({ quiet: true })`.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,16 +381,33 @@ export function loadEnvironment(): void {
|
||||
* Project settings override user settings.
|
||||
*/
|
||||
export function loadSettings(workspaceDir: string): LoadedSettings {
|
||||
loadEnvironment();
|
||||
let systemSettings: Settings = {};
|
||||
let userSettings: Settings = {};
|
||||
let workspaceSettings: Settings = {};
|
||||
const settingsErrors: SettingsError[] = [];
|
||||
const systemSettingsPath = getSystemSettingsPath();
|
||||
|
||||
// FIX: Resolve paths to their canonical representation to handle symlinks
|
||||
const resolvedWorkspaceDir = path.resolve(workspaceDir);
|
||||
const resolvedHomeDir = path.resolve(homedir());
|
||||
|
||||
let realWorkspaceDir = resolvedWorkspaceDir;
|
||||
try {
|
||||
// fs.realpathSync gets the "true" path, resolving any symlinks
|
||||
realWorkspaceDir = fs.realpathSync(resolvedWorkspaceDir);
|
||||
} catch (_e) {
|
||||
// This is okay. The path might not exist yet, and that's a valid state.
|
||||
}
|
||||
|
||||
// We expect homedir to always exist and be resolvable.
|
||||
const realHomeDir = fs.realpathSync(resolvedHomeDir);
|
||||
|
||||
const workspaceSettingsPath = getWorkspaceSettingsPath(workspaceDir);
|
||||
|
||||
// Load system settings
|
||||
try {
|
||||
if (fs.existsSync(SYSTEM_SETTINGS_PATH)) {
|
||||
const systemContent = fs.readFileSync(SYSTEM_SETTINGS_PATH, 'utf-8');
|
||||
if (fs.existsSync(systemSettingsPath)) {
|
||||
const systemContent = fs.readFileSync(systemSettingsPath, 'utf-8');
|
||||
const parsedSystemSettings = JSON.parse(
|
||||
stripJsonComments(systemContent),
|
||||
) as Settings;
|
||||
@@ -309,7 +416,7 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
|
||||
} catch (error: unknown) {
|
||||
settingsErrors.push({
|
||||
message: getErrorMessage(error),
|
||||
path: SYSTEM_SETTINGS_PATH,
|
||||
path: systemSettingsPath,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -335,39 +442,37 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
|
||||
});
|
||||
}
|
||||
|
||||
const workspaceSettingsPath = path.join(
|
||||
workspaceDir,
|
||||
SETTINGS_DIRECTORY_NAME,
|
||||
'settings.json',
|
||||
);
|
||||
|
||||
// Load workspace settings
|
||||
try {
|
||||
if (fs.existsSync(workspaceSettingsPath)) {
|
||||
const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
|
||||
const parsedWorkspaceSettings = JSON.parse(
|
||||
stripJsonComments(projectContent),
|
||||
) as Settings;
|
||||
workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
|
||||
if (workspaceSettings.theme && workspaceSettings.theme === 'VS') {
|
||||
workspaceSettings.theme = DefaultLight.name;
|
||||
} else if (
|
||||
workspaceSettings.theme &&
|
||||
workspaceSettings.theme === 'VS2015'
|
||||
) {
|
||||
workspaceSettings.theme = DefaultDark.name;
|
||||
// This comparison is now much more reliable.
|
||||
if (realWorkspaceDir !== realHomeDir) {
|
||||
// Load workspace settings
|
||||
try {
|
||||
if (fs.existsSync(workspaceSettingsPath)) {
|
||||
const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
|
||||
const parsedWorkspaceSettings = JSON.parse(
|
||||
stripJsonComments(projectContent),
|
||||
) as Settings;
|
||||
workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
|
||||
if (workspaceSettings.theme && workspaceSettings.theme === 'VS') {
|
||||
workspaceSettings.theme = DefaultLight.name;
|
||||
} else if (
|
||||
workspaceSettings.theme &&
|
||||
workspaceSettings.theme === 'VS2015'
|
||||
) {
|
||||
workspaceSettings.theme = DefaultDark.name;
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
settingsErrors.push({
|
||||
message: getErrorMessage(error),
|
||||
path: workspaceSettingsPath,
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
settingsErrors.push({
|
||||
message: getErrorMessage(error),
|
||||
path: workspaceSettingsPath,
|
||||
});
|
||||
}
|
||||
|
||||
return new LoadedSettings(
|
||||
// Create LoadedSettings first
|
||||
const loadedSettings = new LoadedSettings(
|
||||
{
|
||||
path: SYSTEM_SETTINGS_PATH,
|
||||
path: systemSettingsPath,
|
||||
settings: systemSettings,
|
||||
},
|
||||
{
|
||||
@@ -380,6 +485,11 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
|
||||
},
|
||||
settingsErrors,
|
||||
);
|
||||
|
||||
// Load environment with merged settings
|
||||
loadEnvironment(loadedSettings.merged);
|
||||
|
||||
return loadedSettings;
|
||||
}
|
||||
|
||||
export function saveSettings(settingsFile: SettingsFile): void {
|
||||
|
||||
@@ -6,12 +6,17 @@
|
||||
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { main } from './gemini.js';
|
||||
import {
|
||||
main,
|
||||
setupUnhandledRejectionHandler,
|
||||
validateDnsResolutionOrder,
|
||||
} from './gemini.js';
|
||||
import {
|
||||
LoadedSettings,
|
||||
SettingsFile,
|
||||
loadSettings,
|
||||
} from './config/settings.js';
|
||||
import { appEvents, AppEvent } from './utils/events.js';
|
||||
|
||||
// Custom error to identify mock process.exit calls
|
||||
class MockProcessExitError extends Error {
|
||||
@@ -55,6 +60,16 @@ vi.mock('update-notifier', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('./utils/events.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./utils/events.js')>();
|
||||
return {
|
||||
...actual,
|
||||
appEvents: {
|
||||
emit: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./utils/sandbox.js', () => ({
|
||||
sandbox_command: vi.fn(() => ''), // Default to no sandbox command
|
||||
start_sandbox: vi.fn(() => Promise.resolve()), // Mock as an async function that resolves
|
||||
@@ -65,6 +80,8 @@ describe('gemini.tsx main function', () => {
|
||||
let loadSettingsMock: ReturnType<typeof vi.mocked<typeof loadSettings>>;
|
||||
let originalEnvGeminiSandbox: string | undefined;
|
||||
let originalEnvSandbox: string | undefined;
|
||||
let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] =
|
||||
[];
|
||||
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
@@ -82,6 +99,8 @@ describe('gemini.tsx main function', () => {
|
||||
delete process.env.SANDBOX;
|
||||
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
initialUnhandledRejectionListeners =
|
||||
process.listeners('unhandledRejection');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -96,6 +115,15 @@ describe('gemini.tsx main function', () => {
|
||||
} else {
|
||||
delete process.env.SANDBOX;
|
||||
}
|
||||
|
||||
const currentListeners = process.listeners('unhandledRejection');
|
||||
const addedListener = currentListeners.find(
|
||||
(listener) => !initialUnhandledRejectionListeners.includes(listener),
|
||||
);
|
||||
|
||||
if (addedListener) {
|
||||
process.removeListener('unhandledRejection', addedListener);
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -109,7 +137,7 @@ describe('gemini.tsx main function', () => {
|
||||
settings: {},
|
||||
};
|
||||
const workspaceSettingsFile: SettingsFile = {
|
||||
path: '/workspace/.qwen/settings.json',
|
||||
path: '/workspace/.gemini/settings.json',
|
||||
settings: {},
|
||||
};
|
||||
const systemSettingsFile: SettingsFile = {
|
||||
@@ -145,7 +173,80 @@ describe('gemini.tsx main function', () => {
|
||||
'Please fix /test/settings.json and try again.',
|
||||
);
|
||||
|
||||
// Verify process.exit was called (indirectly, via the thrown error)
|
||||
// Verify process.exit was called.
|
||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should log unhandled promise rejections and open debug console on first error', async () => {
|
||||
const appEventsMock = vi.mocked(appEvents);
|
||||
const rejectionError = new Error('Test unhandled rejection');
|
||||
|
||||
setupUnhandledRejectionHandler();
|
||||
// Simulate an unhandled rejection.
|
||||
// We are not using Promise.reject here as vitest will catch it.
|
||||
// Instead we will dispatch the event manually.
|
||||
process.emit('unhandledRejection', rejectionError, Promise.resolve());
|
||||
|
||||
// We need to wait for the rejection handler to be called.
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
expect(appEventsMock.emit).toHaveBeenCalledWith(AppEvent.OpenDebugConsole);
|
||||
expect(appEventsMock.emit).toHaveBeenCalledWith(
|
||||
AppEvent.LogError,
|
||||
expect.stringContaining('Unhandled Promise Rejection'),
|
||||
);
|
||||
expect(appEventsMock.emit).toHaveBeenCalledWith(
|
||||
AppEvent.LogError,
|
||||
expect.stringContaining('Please file a bug report using the /bug tool.'),
|
||||
);
|
||||
|
||||
// Simulate a second rejection
|
||||
const secondRejectionError = new Error('Second test unhandled rejection');
|
||||
process.emit('unhandledRejection', secondRejectionError, Promise.resolve());
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
// Ensure emit was only called once for OpenDebugConsole
|
||||
const openDebugConsoleCalls = appEventsMock.emit.mock.calls.filter(
|
||||
(call) => call[0] === AppEvent.OpenDebugConsole,
|
||||
);
|
||||
expect(openDebugConsoleCalls.length).toBe(1);
|
||||
|
||||
// Avoid the process.exit error from being thrown.
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateDnsResolutionOrder', () => {
|
||||
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should return "ipv4first" when the input is "ipv4first"', () => {
|
||||
expect(validateDnsResolutionOrder('ipv4first')).toBe('ipv4first');
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return "verbatim" when the input is "verbatim"', () => {
|
||||
expect(validateDnsResolutionOrder('verbatim')).toBe('verbatim');
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return the default "ipv4first" when the input is undefined', () => {
|
||||
expect(validateDnsResolutionOrder(undefined)).toBe('ipv4first');
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return the default "ipv4first" and log a warning for an invalid string', () => {
|
||||
expect(validateDnsResolutionOrder('invalid-value')).toBe('ipv4first');
|
||||
expect(consoleWarnSpy).toHaveBeenCalledOnce();
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Invalid value for dnsResolutionOrder in settings: "invalid-value". Using default "ipv4first".',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,12 +12,13 @@ import { readStdin } from './utils/readStdin.js';
|
||||
import { basename } from 'node:path';
|
||||
import v8 from 'node:v8';
|
||||
import os from 'node:os';
|
||||
import dns from 'node:dns';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { start_sandbox } from './utils/sandbox.js';
|
||||
import {
|
||||
DnsResolutionOrder,
|
||||
LoadedSettings,
|
||||
loadSettings,
|
||||
USER_SETTINGS_PATH,
|
||||
SettingScope,
|
||||
} from './config/settings.js';
|
||||
import { themeManager } from './ui/themes/theme-manager.js';
|
||||
@@ -40,6 +41,27 @@ import {
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { validateAuthMethod } from './config/auth.js';
|
||||
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
|
||||
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||
import { checkForUpdates } from './ui/utils/updateCheck.js';
|
||||
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
|
||||
import { appEvents, AppEvent } from './utils/events.js';
|
||||
|
||||
export function validateDnsResolutionOrder(
|
||||
order: string | undefined,
|
||||
): DnsResolutionOrder {
|
||||
const defaultValue: DnsResolutionOrder = 'ipv4first';
|
||||
if (order === undefined) {
|
||||
return defaultValue;
|
||||
}
|
||||
if (order === 'ipv4first' || order === 'verbatim') {
|
||||
return order;
|
||||
}
|
||||
// We don't want to throw here, just warn and use the default.
|
||||
console.warn(
|
||||
`Invalid value for dnsResolutionOrder in settings: "${order}". Using default "${defaultValue}".`,
|
||||
);
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
function getNodeMemoryArgs(config: Config): string[] {
|
||||
const totalMemoryMB = os.totalmem() / (1024 * 1024);
|
||||
@@ -84,8 +106,32 @@ async function relaunchWithAdditionalArgs(additionalArgs: string[]) {
|
||||
await new Promise((resolve) => child.on('close', resolve));
|
||||
process.exit(0);
|
||||
}
|
||||
import { runAcpPeer } from './acp/acpPeer.js';
|
||||
|
||||
export function setupUnhandledRejectionHandler() {
|
||||
let unhandledRejectionOccurred = false;
|
||||
process.on('unhandledRejection', (reason, _promise) => {
|
||||
const errorMessage = `=========================================
|
||||
This is an unexpected error. Please file a bug report using the /bug tool.
|
||||
CRITICAL: Unhandled Promise Rejection!
|
||||
=========================================
|
||||
Reason: ${reason}${
|
||||
reason instanceof Error && reason.stack
|
||||
? `
|
||||
Stack trace:
|
||||
${reason.stack}`
|
||||
: ''
|
||||
}`;
|
||||
appEvents.emit(AppEvent.LogError, errorMessage);
|
||||
if (!unhandledRejectionOccurred) {
|
||||
unhandledRejectionOccurred = true;
|
||||
appEvents.emit(AppEvent.OpenDebugConsole);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
setupUnhandledRejectionHandler();
|
||||
const workspaceRoot = process.cwd();
|
||||
const settings = loadSettings(workspaceRoot);
|
||||
|
||||
@@ -111,6 +157,10 @@ export async function main() {
|
||||
argv,
|
||||
);
|
||||
|
||||
dns.setDefaultResultOrder(
|
||||
validateDnsResolutionOrder(settings.merged.dnsResolutionOrder),
|
||||
);
|
||||
|
||||
if (argv.promptInteractive && !process.stdin.isTTY) {
|
||||
console.error(
|
||||
'Error: The --prompt-interactive flag is not supported when piping input from stdin.',
|
||||
@@ -141,6 +191,9 @@ export async function main() {
|
||||
|
||||
await config.initialize();
|
||||
|
||||
// Load custom themes from settings
|
||||
themeManager.loadCustomThemes(settings.merged.customThemes);
|
||||
|
||||
if (settings.merged.theme) {
|
||||
if (!themeManager.setActiveTheme(settings.merged.theme)) {
|
||||
// If the theme is not found during initial load, log a warning and continue.
|
||||
@@ -156,7 +209,10 @@ export async function main() {
|
||||
: [];
|
||||
const sandboxConfig = config.getSandbox();
|
||||
if (sandboxConfig) {
|
||||
if (settings.merged.selectedAuthType) {
|
||||
if (
|
||||
settings.merged.selectedAuthType &&
|
||||
!settings.merged.useExternalAuth
|
||||
) {
|
||||
// Validate authentication here because the sandbox will interfere with the Oauth2 web redirect.
|
||||
try {
|
||||
const err = validateAuthMethod(settings.merged.selectedAuthType);
|
||||
@@ -169,7 +225,7 @@ export async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
await start_sandbox(sandboxConfig, memoryArgs);
|
||||
await start_sandbox(sandboxConfig, memoryArgs, config);
|
||||
process.exit(0);
|
||||
} else {
|
||||
// Not in a sandbox and not entering one, so relaunch with additional
|
||||
@@ -183,12 +239,16 @@ export async function main() {
|
||||
|
||||
if (
|
||||
settings.merged.selectedAuthType === AuthType.LOGIN_WITH_GOOGLE &&
|
||||
config.getNoBrowser()
|
||||
config.isBrowserLaunchSuppressed()
|
||||
) {
|
||||
// Do oauth before app renders to make copying the link possible.
|
||||
await getOauthClient(settings.merged.selectedAuthType, config);
|
||||
}
|
||||
|
||||
if (config.getExperimentalAcp()) {
|
||||
return runAcpPeer(config, settings);
|
||||
}
|
||||
|
||||
let input = config.getQuestion();
|
||||
const startupWarnings = [
|
||||
...(await getStartupWarnings()),
|
||||
@@ -214,6 +274,17 @@ export async function main() {
|
||||
{ exitOnCtrlC: false },
|
||||
);
|
||||
|
||||
checkForUpdates()
|
||||
.then((info) => {
|
||||
handleAutoUpdate(info, settings, config.getProjectRoot());
|
||||
})
|
||||
.catch((err) => {
|
||||
// Silently ignore update check errors.
|
||||
if (config.getDebugMode()) {
|
||||
console.error('Update check failed:', err);
|
||||
}
|
||||
});
|
||||
|
||||
registerCleanup(() => instance.unmount());
|
||||
return;
|
||||
}
|
||||
@@ -264,21 +335,6 @@ function setWindowTitle(title: string, settings: LoadedSettings) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Global Unhandled Rejection Handler ---
|
||||
process.on('unhandledRejection', (reason, _promise) => {
|
||||
// Log other unexpected unhandled rejections as critical errors
|
||||
console.error('=========================================');
|
||||
console.error('CRITICAL: Unhandled Promise Rejection!');
|
||||
console.error('=========================================');
|
||||
console.error('Reason:', reason);
|
||||
console.error('Stack trace may follow:');
|
||||
if (!(reason instanceof Error)) {
|
||||
console.error(reason);
|
||||
}
|
||||
// Exit for genuinely unhandled errors
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
async function loadNonInteractiveConfig(
|
||||
config: Config,
|
||||
extensions: Extension[],
|
||||
@@ -312,51 +368,9 @@ async function loadNonInteractiveConfig(
|
||||
await finalConfig.initialize();
|
||||
}
|
||||
|
||||
return await validateNonInterActiveAuth(
|
||||
return await validateNonInteractiveAuth(
|
||||
settings.merged.selectedAuthType,
|
||||
settings.merged.useExternalAuth,
|
||||
finalConfig,
|
||||
);
|
||||
}
|
||||
|
||||
async function validateNonInterActiveAuth(
|
||||
selectedAuthType: AuthType | undefined,
|
||||
nonInteractiveConfig: Config,
|
||||
) {
|
||||
// making a special case for the cli. many headless environments might not have a settings.json set
|
||||
// so if GEMINI_API_KEY or OPENAI_API_KEY is set, we'll use that. However since the oauth things are interactive anyway, we'll
|
||||
// still expect that exists
|
||||
if (
|
||||
!selectedAuthType &&
|
||||
!process.env.GEMINI_API_KEY &&
|
||||
!process.env.OPENAI_API_KEY
|
||||
) {
|
||||
console.error(
|
||||
`Please set an Auth method in your ${USER_SETTINGS_PATH} OR specify GEMINI_API_KEY or OPENAI_API_KEY env variable before running`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Determine auth type based on available environment variables
|
||||
if (!selectedAuthType) {
|
||||
if (process.env.OPENAI_API_KEY) {
|
||||
selectedAuthType = AuthType.USE_OPENAI;
|
||||
} else if (process.env.GEMINI_API_KEY) {
|
||||
selectedAuthType = AuthType.USE_GEMINI;
|
||||
}
|
||||
}
|
||||
|
||||
// This should never happen due to the check above, but TypeScript needs assurance
|
||||
if (!selectedAuthType) {
|
||||
console.error('No valid authentication method found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const err = validateAuthMethod(selectedAuthType);
|
||||
if (err != null) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await nonInteractiveConfig.refreshAuth(selectedAuthType);
|
||||
return nonInteractiveConfig;
|
||||
}
|
||||
|
||||
@@ -4,196 +4,169 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
Config,
|
||||
executeToolCall,
|
||||
ToolRegistry,
|
||||
ToolErrorType,
|
||||
shutdownTelemetry,
|
||||
GeminiEventType,
|
||||
ServerGeminiStreamEvent,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { Part } from '@google/genai';
|
||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||
import { Config, GeminiClient, ToolRegistry } from '@qwen-code/qwen-code-core';
|
||||
import { GenerateContentResponse, Part, FunctionCall } from '@google/genai';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@qwen-code/qwen-code-core', async () => {
|
||||
const actualCore = await vi.importActual<
|
||||
typeof import('@qwen-code/qwen-code-core')
|
||||
>('@qwen-code/qwen-code-core');
|
||||
// Mock core modules
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...actualCore,
|
||||
GeminiClient: vi.fn(),
|
||||
ToolRegistry: vi.fn(),
|
||||
...original,
|
||||
executeToolCall: vi.fn(),
|
||||
shutdownTelemetry: vi.fn(),
|
||||
isTelemetrySdkInitialized: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
describe('runNonInteractive', () => {
|
||||
let mockConfig: Config;
|
||||
let mockGeminiClient: GeminiClient;
|
||||
let mockToolRegistry: ToolRegistry;
|
||||
let mockChat: {
|
||||
sendMessageStream: ReturnType<typeof vi.fn>;
|
||||
let mockCoreExecuteToolCall: vi.Mock;
|
||||
let mockShutdownTelemetry: vi.Mock;
|
||||
let consoleErrorSpy: vi.SpyInstance;
|
||||
let processExitSpy: vi.SpyInstance;
|
||||
let processStdoutSpy: vi.SpyInstance;
|
||||
let mockGeminiClient: {
|
||||
sendMessageStream: vi.Mock;
|
||||
};
|
||||
let mockProcessStdoutWrite: ReturnType<typeof vi.fn>;
|
||||
let mockProcessExit: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockChat = {
|
||||
sendMessageStream: vi.fn(),
|
||||
};
|
||||
mockGeminiClient = {
|
||||
getChat: vi.fn().mockResolvedValue(mockChat),
|
||||
} as unknown as GeminiClient;
|
||||
mockCoreExecuteToolCall = vi.mocked(executeToolCall);
|
||||
mockShutdownTelemetry = vi.mocked(shutdownTelemetry);
|
||||
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => {}) as (code?: number) => never);
|
||||
processStdoutSpy = vi
|
||||
.spyOn(process.stdout, 'write')
|
||||
.mockImplementation(() => true);
|
||||
|
||||
mockToolRegistry = {
|
||||
getFunctionDeclarations: vi.fn().mockReturnValue([]),
|
||||
getTool: vi.fn(),
|
||||
getFunctionDeclarations: vi.fn().mockReturnValue([]),
|
||||
} as unknown as ToolRegistry;
|
||||
|
||||
vi.mocked(GeminiClient).mockImplementation(() => mockGeminiClient);
|
||||
vi.mocked(ToolRegistry).mockImplementation(() => mockToolRegistry);
|
||||
mockGeminiClient = {
|
||||
sendMessageStream: vi.fn(),
|
||||
};
|
||||
|
||||
mockConfig = {
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
|
||||
getToolRegistry: vi.fn().mockResolvedValue(mockToolRegistry),
|
||||
getMaxSessionTurns: vi.fn().mockReturnValue(10),
|
||||
initialize: vi.fn(),
|
||||
getIdeMode: vi.fn().mockReturnValue(false),
|
||||
getFullContext: vi.fn().mockReturnValue(false),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
|
||||
} as unknown as Config;
|
||||
|
||||
mockProcessStdoutWrite = vi.fn().mockImplementation(() => true);
|
||||
process.stdout.write = mockProcessStdoutWrite as any; // Use any to bypass strict signature matching for mock
|
||||
mockProcessExit = vi
|
||||
.fn()
|
||||
.mockImplementation((_code?: number) => undefined as never);
|
||||
process.exit = mockProcessExit as any; // Use any for process.exit mock
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
// Restore original process methods if they were globally patched
|
||||
// This might require storing the original methods before patching them in beforeEach
|
||||
});
|
||||
|
||||
async function* createStreamFromEvents(
|
||||
events: ServerGeminiStreamEvent[],
|
||||
): AsyncGenerator<ServerGeminiStreamEvent> {
|
||||
for (const event of events) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
|
||||
it('should process input and write text output', async () => {
|
||||
const inputStream = (async function* () {
|
||||
yield {
|
||||
candidates: [{ content: { parts: [{ text: 'Hello' }] } }],
|
||||
} as GenerateContentResponse;
|
||||
yield {
|
||||
candidates: [{ content: { parts: [{ text: ' World' }] } }],
|
||||
} as GenerateContentResponse;
|
||||
})();
|
||||
mockChat.sendMessageStream.mockResolvedValue(inputStream);
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Hello' },
|
||||
{ type: GeminiEventType.Content, value: ' World' },
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
await runNonInteractive(mockConfig, 'Test input', 'prompt-id-1');
|
||||
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
|
||||
{
|
||||
message: [{ text: 'Test input' }],
|
||||
config: {
|
||||
abortSignal: expect.any(AbortSignal),
|
||||
tools: [{ functionDeclarations: [] }],
|
||||
},
|
||||
},
|
||||
expect.any(String),
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
|
||||
[{ text: 'Test input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
);
|
||||
expect(mockProcessStdoutWrite).toHaveBeenCalledWith('Hello');
|
||||
expect(mockProcessStdoutWrite).toHaveBeenCalledWith(' World');
|
||||
expect(mockProcessStdoutWrite).toHaveBeenCalledWith('\n');
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Hello');
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith(' World');
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('\n');
|
||||
expect(mockShutdownTelemetry).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle a single tool call and respond', async () => {
|
||||
const functionCall: FunctionCall = {
|
||||
id: 'fc1',
|
||||
name: 'testTool',
|
||||
args: { p: 'v' },
|
||||
};
|
||||
const toolResponsePart: Part = {
|
||||
functionResponse: {
|
||||
const toolCallEvent: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-1',
|
||||
name: 'testTool',
|
||||
id: 'fc1',
|
||||
response: { result: 'tool success' },
|
||||
args: { arg1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-2',
|
||||
},
|
||||
};
|
||||
const toolResponse: Part[] = [{ text: 'Tool response' }];
|
||||
mockCoreExecuteToolCall.mockResolvedValue({ responseParts: toolResponse });
|
||||
|
||||
const { executeToolCall: mockCoreExecuteToolCall } = await import(
|
||||
'@qwen-code/qwen-code-core'
|
||||
);
|
||||
vi.mocked(mockCoreExecuteToolCall).mockResolvedValue({
|
||||
callId: 'fc1',
|
||||
responseParts: [toolResponsePart],
|
||||
resultDisplay: 'Tool success display',
|
||||
error: undefined,
|
||||
});
|
||||
const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent];
|
||||
const secondCallEvents: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Final answer' },
|
||||
];
|
||||
|
||||
const stream1 = (async function* () {
|
||||
yield { functionCalls: [functionCall] } as GenerateContentResponse;
|
||||
})();
|
||||
const stream2 = (async function* () {
|
||||
yield {
|
||||
candidates: [{ content: { parts: [{ text: 'Final answer' }] } }],
|
||||
} as GenerateContentResponse;
|
||||
})();
|
||||
mockChat.sendMessageStream
|
||||
.mockResolvedValueOnce(stream1)
|
||||
.mockResolvedValueOnce(stream2);
|
||||
mockGeminiClient.sendMessageStream
|
||||
.mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
|
||||
.mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
|
||||
|
||||
await runNonInteractive(mockConfig, 'Use a tool', 'prompt-id-2');
|
||||
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2);
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
|
||||
expect(mockCoreExecuteToolCall).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
expect.objectContaining({ callId: 'fc1', name: 'testTool' }),
|
||||
expect.objectContaining({ name: 'testTool' }),
|
||||
mockToolRegistry,
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(mockChat.sendMessageStream).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
message: [toolResponsePart],
|
||||
}),
|
||||
expect.any(String),
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
[{ text: 'Tool response' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-2',
|
||||
);
|
||||
expect(mockProcessStdoutWrite).toHaveBeenCalledWith('Final answer');
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Final answer');
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('\n');
|
||||
});
|
||||
|
||||
it('should handle error during tool execution', async () => {
|
||||
const functionCall: FunctionCall = {
|
||||
id: 'fcError',
|
||||
name: 'errorTool',
|
||||
args: {},
|
||||
};
|
||||
const errorResponsePart: Part = {
|
||||
functionResponse: {
|
||||
const toolCallEvent: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-1',
|
||||
name: 'errorTool',
|
||||
id: 'fcError',
|
||||
response: { error: 'Tool failed' },
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-3',
|
||||
},
|
||||
};
|
||||
|
||||
const { executeToolCall: mockCoreExecuteToolCall } = await import(
|
||||
'@qwen-code/qwen-code-core'
|
||||
);
|
||||
vi.mocked(mockCoreExecuteToolCall).mockResolvedValue({
|
||||
callId: 'fcError',
|
||||
responseParts: [errorResponsePart],
|
||||
resultDisplay: 'Tool execution failed badly',
|
||||
error: new Error('Tool failed'),
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
error: new Error('Tool execution failed badly'),
|
||||
errorType: ToolErrorType.UNHANDLED_EXCEPTION,
|
||||
});
|
||||
|
||||
const stream1 = (async function* () {
|
||||
yield { functionCalls: [functionCall] } as GenerateContentResponse;
|
||||
})();
|
||||
|
||||
const stream2 = (async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{ content: { parts: [{ text: 'Could not complete request.' }] } },
|
||||
],
|
||||
} as GenerateContentResponse;
|
||||
})();
|
||||
mockChat.sendMessageStream
|
||||
.mockResolvedValueOnce(stream1)
|
||||
.mockResolvedValueOnce(stream2);
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents([toolCallEvent]),
|
||||
);
|
||||
|
||||
await runNonInteractive(mockConfig, 'Trigger tool error', 'prompt-id-3');
|
||||
|
||||
@@ -201,75 +174,48 @@ describe('runNonInteractive', () => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error executing tool errorTool: Tool execution failed badly',
|
||||
);
|
||||
expect(mockChat.sendMessageStream).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
message: [errorResponsePart],
|
||||
}),
|
||||
expect.any(String),
|
||||
);
|
||||
expect(mockProcessStdoutWrite).toHaveBeenCalledWith(
|
||||
'Could not complete request.',
|
||||
);
|
||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should exit with error if sendMessageStream throws initially', async () => {
|
||||
const apiError = new Error('API connection failed');
|
||||
mockChat.sendMessageStream.mockRejectedValue(apiError);
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
mockGeminiClient.sendMessageStream.mockImplementation(() => {
|
||||
throw apiError;
|
||||
});
|
||||
|
||||
await runNonInteractive(mockConfig, 'Initial fail', 'prompt-id-4');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[API Error: API connection failed]',
|
||||
);
|
||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should not exit if a tool is not found, and should send error back to model', async () => {
|
||||
const functionCall: FunctionCall = {
|
||||
id: 'fcNotFound',
|
||||
name: 'nonExistentTool',
|
||||
args: {},
|
||||
};
|
||||
const errorResponsePart: Part = {
|
||||
functionResponse: {
|
||||
name: 'nonExistentTool',
|
||||
id: 'fcNotFound',
|
||||
response: { error: 'Tool "nonExistentTool" not found in registry.' },
|
||||
const toolCallEvent: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-1',
|
||||
name: 'nonexistentTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-5',
|
||||
},
|
||||
};
|
||||
|
||||
const { executeToolCall: mockCoreExecuteToolCall } = await import(
|
||||
'@qwen-code/qwen-code-core'
|
||||
);
|
||||
vi.mocked(mockCoreExecuteToolCall).mockResolvedValue({
|
||||
callId: 'fcNotFound',
|
||||
responseParts: [errorResponsePart],
|
||||
resultDisplay: 'Tool "nonExistentTool" not found in registry.',
|
||||
error: new Error('Tool "nonExistentTool" not found in registry.'),
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
error: new Error('Tool "nonexistentTool" not found in registry.'),
|
||||
resultDisplay: 'Tool "nonexistentTool" not found in registry.',
|
||||
});
|
||||
const finalResponse: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.Content,
|
||||
value: "Sorry, I can't find that tool.",
|
||||
},
|
||||
];
|
||||
|
||||
const stream1 = (async function* () {
|
||||
yield { functionCalls: [functionCall] } as GenerateContentResponse;
|
||||
})();
|
||||
const stream2 = (async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'Unfortunately the tool does not exist.' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as GenerateContentResponse;
|
||||
})();
|
||||
mockChat.sendMessageStream
|
||||
.mockResolvedValueOnce(stream1)
|
||||
.mockResolvedValueOnce(stream2);
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
mockGeminiClient.sendMessageStream
|
||||
.mockReturnValueOnce(createStreamFromEvents([toolCallEvent]))
|
||||
.mockReturnValueOnce(createStreamFromEvents(finalResponse));
|
||||
|
||||
await runNonInteractive(
|
||||
mockConfig,
|
||||
@@ -277,68 +223,22 @@ describe('runNonInteractive', () => {
|
||||
'prompt-id-5',
|
||||
);
|
||||
|
||||
expect(mockCoreExecuteToolCall).toHaveBeenCalled();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error executing tool nonExistentTool: Tool "nonExistentTool" not found in registry.',
|
||||
'Error executing tool nonexistentTool: Tool "nonexistentTool" not found in registry.',
|
||||
);
|
||||
|
||||
expect(mockProcessExit).not.toHaveBeenCalled();
|
||||
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2);
|
||||
expect(mockChat.sendMessageStream).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
message: [errorResponsePart],
|
||||
}),
|
||||
expect.any(String),
|
||||
);
|
||||
|
||||
expect(mockProcessStdoutWrite).toHaveBeenCalledWith(
|
||||
'Unfortunately the tool does not exist.',
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith(
|
||||
"Sorry, I can't find that tool.",
|
||||
);
|
||||
});
|
||||
|
||||
it('should exit when max session turns are exceeded', async () => {
|
||||
const functionCall: FunctionCall = {
|
||||
id: 'fcLoop',
|
||||
name: 'loopTool',
|
||||
args: {},
|
||||
};
|
||||
const toolResponsePart: Part = {
|
||||
functionResponse: {
|
||||
name: 'loopTool',
|
||||
id: 'fcLoop',
|
||||
response: { result: 'still looping' },
|
||||
},
|
||||
};
|
||||
|
||||
// Config with a max turn of 1
|
||||
vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(1);
|
||||
|
||||
const { executeToolCall: mockCoreExecuteToolCall } = await import(
|
||||
'@qwen-code/qwen-code-core'
|
||||
);
|
||||
vi.mocked(mockCoreExecuteToolCall).mockResolvedValue({
|
||||
callId: 'fcLoop',
|
||||
responseParts: [toolResponsePart],
|
||||
resultDisplay: 'Still looping',
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const stream = (async function* () {
|
||||
yield { functionCalls: [functionCall] } as GenerateContentResponse;
|
||||
})();
|
||||
|
||||
mockChat.sendMessageStream.mockResolvedValue(stream);
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await runNonInteractive(mockConfig, 'Trigger loop');
|
||||
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(0);
|
||||
await runNonInteractive(mockConfig, 'Trigger loop', 'prompt-id-6');
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
`
|
||||
Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.`,
|
||||
'\n Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',
|
||||
);
|
||||
expect(mockProcessExit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,116 +11,13 @@ import {
|
||||
ToolRegistry,
|
||||
shutdownTelemetry,
|
||||
isTelemetrySdkInitialized,
|
||||
ToolResultDisplay,
|
||||
GeminiEventType,
|
||||
ToolErrorType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
Content,
|
||||
Part,
|
||||
FunctionCall,
|
||||
GenerateContentResponse,
|
||||
} from '@google/genai';
|
||||
import { Content, Part, FunctionCall } from '@google/genai';
|
||||
|
||||
import { parseAndFormatApiError } from './ui/utils/errorParsing.js';
|
||||
|
||||
function getResponseText(response: GenerateContentResponse): string | null {
|
||||
if (response.candidates && response.candidates.length > 0) {
|
||||
const candidate = response.candidates[0];
|
||||
if (
|
||||
candidate.content &&
|
||||
candidate.content.parts &&
|
||||
candidate.content.parts.length > 0
|
||||
) {
|
||||
// We are running in headless mode so we don't need to return thoughts to STDOUT.
|
||||
const thoughtPart = candidate.content.parts[0];
|
||||
if (thoughtPart?.thought) {
|
||||
return null;
|
||||
}
|
||||
return candidate.content.parts
|
||||
.filter((part) => part.text)
|
||||
.map((part) => part.text)
|
||||
.join('');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper function to format tool call arguments for display
|
||||
function formatToolArgs(args: Record<string, unknown>): string {
|
||||
if (!args || Object.keys(args).length === 0) {
|
||||
return '(no arguments)';
|
||||
}
|
||||
|
||||
const formattedArgs = Object.entries(args)
|
||||
.map(([key, value]) => {
|
||||
if (typeof value === 'string') {
|
||||
return `${key}: "${value}"`;
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
return `${key}: ${JSON.stringify(value)}`;
|
||||
} else {
|
||||
return `${key}: ${value}`;
|
||||
}
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
return `(${formattedArgs})`;
|
||||
}
|
||||
// Helper function to display tool call information
|
||||
function displayToolCallInfo(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
status: 'start' | 'success' | 'error',
|
||||
resultDisplay?: ToolResultDisplay,
|
||||
errorMessage?: string,
|
||||
): void {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const argsStr = formatToolArgs(args);
|
||||
|
||||
switch (status) {
|
||||
case 'start':
|
||||
process.stdout.write(
|
||||
`\n[${timestamp}] 🔧 Executing tool: ${toolName} ${argsStr}\n`,
|
||||
);
|
||||
break;
|
||||
case 'success':
|
||||
if (resultDisplay) {
|
||||
if (typeof resultDisplay === 'string' && resultDisplay.trim()) {
|
||||
process.stdout.write(
|
||||
`[${timestamp}] ✅ Tool ${toolName} completed successfully\n`,
|
||||
);
|
||||
process.stdout.write(`📋 Result:\n${resultDisplay}\n`);
|
||||
} else if (
|
||||
typeof resultDisplay === 'object' &&
|
||||
'fileDiff' in resultDisplay
|
||||
) {
|
||||
process.stdout.write(
|
||||
`[${timestamp}] ✅ Tool ${toolName} completed successfully\n`,
|
||||
);
|
||||
process.stdout.write(`📋 File: ${resultDisplay.fileName}\n`);
|
||||
process.stdout.write(`📋 Diff:\n${resultDisplay.fileDiff}\n`);
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`[${timestamp}] ✅ Tool ${toolName} completed successfully (no output)\n`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`[${timestamp}] ✅ Tool ${toolName} completed successfully (no output)\n`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'error':
|
||||
process.stdout.write(
|
||||
`[${timestamp}] ❌ Tool ${toolName} failed: ${errorMessage}\n`,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
process.stdout.write(
|
||||
`[${timestamp}] ⚠️ Tool ${toolName} reported unknown status: ${status}\n`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runNonInteractive(
|
||||
config: Config,
|
||||
input: string,
|
||||
@@ -138,7 +35,6 @@ export async function runNonInteractive(
|
||||
const geminiClient = config.getGeminiClient();
|
||||
const toolRegistry: ToolRegistry = await config.getToolRegistry();
|
||||
|
||||
const chat = await geminiClient.getChat();
|
||||
const abortController = new AbortController();
|
||||
let currentMessages: Content[] = [{ role: 'user', parts: [{ text: input }] }];
|
||||
let turnCount = 0;
|
||||
@@ -146,7 +42,7 @@ export async function runNonInteractive(
|
||||
while (true) {
|
||||
turnCount++;
|
||||
if (
|
||||
config.getMaxSessionTurns() > 0 &&
|
||||
config.getMaxSessionTurns() >= 0 &&
|
||||
turnCount > config.getMaxSessionTurns()
|
||||
) {
|
||||
console.error(
|
||||
@@ -156,30 +52,28 @@ export async function runNonInteractive(
|
||||
}
|
||||
const functionCalls: FunctionCall[] = [];
|
||||
|
||||
const responseStream = await chat.sendMessageStream(
|
||||
{
|
||||
message: currentMessages[0]?.parts || [], // Ensure parts are always provided
|
||||
config: {
|
||||
abortSignal: abortController.signal,
|
||||
tools: [
|
||||
{ functionDeclarations: toolRegistry.getFunctionDeclarations() },
|
||||
],
|
||||
},
|
||||
},
|
||||
const responseStream = geminiClient.sendMessageStream(
|
||||
currentMessages[0]?.parts || [],
|
||||
abortController.signal,
|
||||
prompt_id,
|
||||
);
|
||||
|
||||
for await (const resp of responseStream) {
|
||||
for await (const event of responseStream) {
|
||||
if (abortController.signal.aborted) {
|
||||
console.error('Operation cancelled.');
|
||||
return;
|
||||
}
|
||||
const textPart = getResponseText(resp);
|
||||
if (textPart) {
|
||||
process.stdout.write(textPart);
|
||||
}
|
||||
if (resp.functionCalls) {
|
||||
functionCalls.push(...resp.functionCalls);
|
||||
|
||||
if (event.type === GeminiEventType.Content) {
|
||||
process.stdout.write(event.value);
|
||||
} else if (event.type === GeminiEventType.ToolCallRequest) {
|
||||
const toolCallRequest = event.value;
|
||||
const fc: FunctionCall = {
|
||||
name: toolCallRequest.name,
|
||||
args: toolCallRequest.args,
|
||||
id: toolCallRequest.callId,
|
||||
};
|
||||
functionCalls.push(fc);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,9 +90,6 @@ export async function runNonInteractive(
|
||||
prompt_id,
|
||||
};
|
||||
|
||||
//Display tool call start information
|
||||
displayToolCallInfo(fc.name as string, fc.args ?? {}, 'start');
|
||||
|
||||
const toolResponse = await executeToolCall(
|
||||
config,
|
||||
requestInfo,
|
||||
@@ -207,37 +98,11 @@ export async function runNonInteractive(
|
||||
);
|
||||
|
||||
if (toolResponse.error) {
|
||||
// Display tool call error information
|
||||
const errorMessage =
|
||||
typeof toolResponse.resultDisplay === 'string'
|
||||
? toolResponse.resultDisplay
|
||||
: toolResponse.error?.message;
|
||||
|
||||
displayToolCallInfo(
|
||||
fc.name as string,
|
||||
fc.args ?? {},
|
||||
'error',
|
||||
undefined,
|
||||
errorMessage,
|
||||
);
|
||||
|
||||
const isToolNotFound = toolResponse.error.message.includes(
|
||||
'not found in registry',
|
||||
);
|
||||
console.error(
|
||||
`Error executing tool ${fc.name}: ${toolResponse.resultDisplay || toolResponse.error.message}`,
|
||||
);
|
||||
if (!isToolNotFound) {
|
||||
if (toolResponse.errorType === ToolErrorType.UNHANDLED_EXCEPTION)
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// Display tool call success information
|
||||
displayToolCallInfo(
|
||||
fc.name as string,
|
||||
fc.args ?? {},
|
||||
'success',
|
||||
toolResponse.resultDisplay,
|
||||
);
|
||||
}
|
||||
|
||||
if (toolResponse.responseParts) {
|
||||
|
||||
17
packages/cli/src/patches/is-in-ci.ts
Normal file
17
packages/cli/src/patches/is-in-ci.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// This is a replacement for the `is-in-ci` package that always returns false.
|
||||
// We are doing this to avoid the issue where `ink` does not render the UI
|
||||
// when it detects that it is running in a CI environment.
|
||||
// This is safe because `ink` (and thus `is-in-ci`) is only used in the
|
||||
// interactive code path of the CLI.
|
||||
// See issue #1563 for more details.
|
||||
|
||||
const isInCi = false;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default isInCi;
|
||||
127
packages/cli/src/services/BuiltinCommandLoader.test.ts
Normal file
127
packages/cli/src/services/BuiltinCommandLoader.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
vi.mock('../ui/commands/aboutCommand.js', async () => {
|
||||
const { CommandKind } = await import('../ui/commands/types.js');
|
||||
return {
|
||||
aboutCommand: {
|
||||
name: 'about',
|
||||
description: 'About the CLI',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../ui/commands/ideCommand.js', () => ({ ideCommand: vi.fn() }));
|
||||
vi.mock('../ui/commands/restoreCommand.js', () => ({
|
||||
restoreCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { BuiltinCommandLoader } from './BuiltinCommandLoader.js';
|
||||
import { Config } from '@qwen-code/qwen-code-core';
|
||||
import { CommandKind } from '../ui/commands/types.js';
|
||||
|
||||
import { ideCommand } from '../ui/commands/ideCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
|
||||
vi.mock('../ui/commands/authCommand.js', () => ({ authCommand: {} }));
|
||||
vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} }));
|
||||
vi.mock('../ui/commands/chatCommand.js', () => ({ chatCommand: {} }));
|
||||
vi.mock('../ui/commands/clearCommand.js', () => ({ clearCommand: {} }));
|
||||
vi.mock('../ui/commands/compressCommand.js', () => ({ compressCommand: {} }));
|
||||
vi.mock('../ui/commands/corgiCommand.js', () => ({ corgiCommand: {} }));
|
||||
vi.mock('../ui/commands/docsCommand.js', () => ({ docsCommand: {} }));
|
||||
vi.mock('../ui/commands/editorCommand.js', () => ({ editorCommand: {} }));
|
||||
vi.mock('../ui/commands/extensionsCommand.js', () => ({
|
||||
extensionsCommand: {},
|
||||
}));
|
||||
vi.mock('../ui/commands/helpCommand.js', () => ({ helpCommand: {} }));
|
||||
vi.mock('../ui/commands/memoryCommand.js', () => ({ memoryCommand: {} }));
|
||||
vi.mock('../ui/commands/privacyCommand.js', () => ({ privacyCommand: {} }));
|
||||
vi.mock('../ui/commands/quitCommand.js', () => ({ quitCommand: {} }));
|
||||
vi.mock('../ui/commands/statsCommand.js', () => ({ statsCommand: {} }));
|
||||
vi.mock('../ui/commands/themeCommand.js', () => ({ themeCommand: {} }));
|
||||
vi.mock('../ui/commands/toolsCommand.js', () => ({ toolsCommand: {} }));
|
||||
vi.mock('../ui/commands/mcpCommand.js', () => ({
|
||||
mcpCommand: {
|
||||
name: 'mcp',
|
||||
description: 'MCP command',
|
||||
kind: 'BUILT_IN',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('BuiltinCommandLoader', () => {
|
||||
let mockConfig: Config;
|
||||
|
||||
const ideCommandMock = ideCommand as Mock;
|
||||
const restoreCommandMock = restoreCommand as Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockConfig = { some: 'config' } as unknown as Config;
|
||||
|
||||
ideCommandMock.mockReturnValue({
|
||||
name: 'ide',
|
||||
description: 'IDE command',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
});
|
||||
restoreCommandMock.mockReturnValue({
|
||||
name: 'restore',
|
||||
description: 'Restore command',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly pass the config object to command factory functions', async () => {
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
await loader.loadCommands(new AbortController().signal);
|
||||
|
||||
expect(ideCommandMock).toHaveBeenCalledTimes(1);
|
||||
expect(ideCommandMock).toHaveBeenCalledWith(mockConfig);
|
||||
expect(restoreCommandMock).toHaveBeenCalledTimes(1);
|
||||
expect(restoreCommandMock).toHaveBeenCalledWith(mockConfig);
|
||||
});
|
||||
|
||||
it('should filter out null command definitions returned by factories', async () => {
|
||||
// Override the mock's behavior for this specific test.
|
||||
ideCommandMock.mockReturnValue(null);
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
|
||||
// The 'ide' command should be filtered out.
|
||||
const ideCmd = commands.find((c) => c.name === 'ide');
|
||||
expect(ideCmd).toBeUndefined();
|
||||
|
||||
// Other commands should still be present.
|
||||
const aboutCmd = commands.find((c) => c.name === 'about');
|
||||
expect(aboutCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle a null config gracefully when calling factories', async () => {
|
||||
const loader = new BuiltinCommandLoader(null);
|
||||
await loader.loadCommands(new AbortController().signal);
|
||||
expect(ideCommandMock).toHaveBeenCalledTimes(1);
|
||||
expect(ideCommandMock).toHaveBeenCalledWith(null);
|
||||
expect(restoreCommandMock).toHaveBeenCalledTimes(1);
|
||||
expect(restoreCommandMock).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('should return a list of all loaded commands', async () => {
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
|
||||
const aboutCmd = commands.find((c) => c.name === 'about');
|
||||
expect(aboutCmd).toBeDefined();
|
||||
expect(aboutCmd?.kind).toBe(CommandKind.BUILT_IN);
|
||||
|
||||
const ideCmd = commands.find((c) => c.name === 'ide');
|
||||
expect(ideCmd).toBeDefined();
|
||||
|
||||
const mcpCmd = commands.find((c) => c.name === 'mcp');
|
||||
expect(mcpCmd).toBeDefined();
|
||||
});
|
||||
});
|
||||
82
packages/cli/src/services/BuiltinCommandLoader.ts
Normal file
82
packages/cli/src/services/BuiltinCommandLoader.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { ICommandLoader } from './types.js';
|
||||
import { SlashCommand } from '../ui/commands/types.js';
|
||||
import { Config } from '@qwen-code/qwen-code-core';
|
||||
import { aboutCommand } from '../ui/commands/aboutCommand.js';
|
||||
import { authCommand } from '../ui/commands/authCommand.js';
|
||||
import { bugCommand } from '../ui/commands/bugCommand.js';
|
||||
import { chatCommand } from '../ui/commands/chatCommand.js';
|
||||
import { clearCommand } from '../ui/commands/clearCommand.js';
|
||||
import { compressCommand } from '../ui/commands/compressCommand.js';
|
||||
import { copyCommand } from '../ui/commands/copyCommand.js';
|
||||
import { corgiCommand } from '../ui/commands/corgiCommand.js';
|
||||
import { docsCommand } from '../ui/commands/docsCommand.js';
|
||||
import { directoryCommand } from '../ui/commands/directoryCommand.js';
|
||||
import { editorCommand } from '../ui/commands/editorCommand.js';
|
||||
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||
import { ideCommand } from '../ui/commands/ideCommand.js';
|
||||
import { initCommand } from '../ui/commands/initCommand.js';
|
||||
import { mcpCommand } from '../ui/commands/mcpCommand.js';
|
||||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { privacyCommand } from '../ui/commands/privacyCommand.js';
|
||||
import { quitCommand } from '../ui/commands/quitCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
import { statsCommand } from '../ui/commands/statsCommand.js';
|
||||
import { themeCommand } from '../ui/commands/themeCommand.js';
|
||||
import { toolsCommand } from '../ui/commands/toolsCommand.js';
|
||||
import { vimCommand } from '../ui/commands/vimCommand.js';
|
||||
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
||||
import { isGitHubRepository } from '../utils/gitUtils.js';
|
||||
|
||||
/**
|
||||
* Loads the core, hard-coded slash commands that are an integral part
|
||||
* of the Gemini CLI application.
|
||||
*/
|
||||
export class BuiltinCommandLoader implements ICommandLoader {
|
||||
constructor(private config: Config | null) {}
|
||||
|
||||
/**
|
||||
* Gathers all raw built-in command definitions, injects dependencies where
|
||||
* needed (e.g., config) and filters out any that are not available.
|
||||
*
|
||||
* @param _signal An AbortSignal (unused for this synchronous loader).
|
||||
* @returns A promise that resolves to an array of `SlashCommand` objects.
|
||||
*/
|
||||
async loadCommands(_signal: AbortSignal): Promise<SlashCommand[]> {
|
||||
const allDefinitions: Array<SlashCommand | null> = [
|
||||
aboutCommand,
|
||||
authCommand,
|
||||
bugCommand,
|
||||
chatCommand,
|
||||
clearCommand,
|
||||
compressCommand,
|
||||
copyCommand,
|
||||
corgiCommand,
|
||||
docsCommand,
|
||||
directoryCommand,
|
||||
editorCommand,
|
||||
extensionsCommand,
|
||||
helpCommand,
|
||||
ideCommand(this.config),
|
||||
initCommand,
|
||||
mcpCommand,
|
||||
memoryCommand,
|
||||
privacyCommand,
|
||||
quitCommand,
|
||||
restoreCommand(this.config),
|
||||
statsCommand,
|
||||
themeCommand,
|
||||
toolsCommand,
|
||||
vimCommand,
|
||||
...(isGitHubRepository() ? [setupGithubCommand] : []),
|
||||
];
|
||||
|
||||
return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null);
|
||||
}
|
||||
}
|
||||
@@ -4,135 +4,349 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { CommandService } from './CommandService.js';
|
||||
import { type SlashCommand } from '../ui/commands/types.js';
|
||||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||
import { clearCommand } from '../ui/commands/clearCommand.js';
|
||||
import { authCommand } from '../ui/commands/authCommand.js';
|
||||
import { themeCommand } from '../ui/commands/themeCommand.js';
|
||||
import { privacyCommand } from '../ui/commands/privacyCommand.js';
|
||||
import { aboutCommand } from '../ui/commands/aboutCommand.js';
|
||||
import { type ICommandLoader } from './types.js';
|
||||
import { CommandKind, type SlashCommand } from '../ui/commands/types.js';
|
||||
|
||||
// Mock the command modules to isolate the service from the command implementations.
|
||||
vi.mock('../ui/commands/memoryCommand.js', () => ({
|
||||
memoryCommand: { name: 'memory', description: 'Mock Memory' },
|
||||
}));
|
||||
vi.mock('../ui/commands/helpCommand.js', () => ({
|
||||
helpCommand: { name: 'help', description: 'Mock Help' },
|
||||
}));
|
||||
vi.mock('../ui/commands/clearCommand.js', () => ({
|
||||
clearCommand: { name: 'clear', description: 'Mock Clear' },
|
||||
}));
|
||||
vi.mock('../ui/commands/authCommand.js', () => ({
|
||||
authCommand: { name: 'auth', description: 'Mock Auth' },
|
||||
}));
|
||||
vi.mock('../ui/commands/themeCommand.js', () => ({
|
||||
themeCommand: { name: 'theme', description: 'Mock Theme' },
|
||||
}));
|
||||
vi.mock('../ui/commands/privacyCommand.js', () => ({
|
||||
privacyCommand: { name: 'privacy', description: 'Mock Privacy' },
|
||||
}));
|
||||
vi.mock('../ui/commands/aboutCommand.js', () => ({
|
||||
aboutCommand: { name: 'about', description: 'Mock About' },
|
||||
}));
|
||||
const createMockCommand = (name: string, kind: CommandKind): SlashCommand => ({
|
||||
name,
|
||||
description: `Description for ${name}`,
|
||||
kind,
|
||||
action: vi.fn(),
|
||||
});
|
||||
|
||||
const mockCommandA = createMockCommand('command-a', CommandKind.BUILT_IN);
|
||||
const mockCommandB = createMockCommand('command-b', CommandKind.BUILT_IN);
|
||||
const mockCommandC = createMockCommand('command-c', CommandKind.FILE);
|
||||
const mockCommandB_Override = createMockCommand('command-b', CommandKind.FILE);
|
||||
|
||||
class MockCommandLoader implements ICommandLoader {
|
||||
private commandsToLoad: SlashCommand[];
|
||||
|
||||
constructor(commandsToLoad: SlashCommand[]) {
|
||||
this.commandsToLoad = commandsToLoad;
|
||||
}
|
||||
|
||||
loadCommands = vi.fn(
|
||||
async (): Promise<SlashCommand[]> => Promise.resolve(this.commandsToLoad),
|
||||
);
|
||||
}
|
||||
|
||||
describe('CommandService', () => {
|
||||
describe('when using default production loader', () => {
|
||||
let commandService: CommandService;
|
||||
|
||||
beforeEach(() => {
|
||||
commandService = new CommandService();
|
||||
});
|
||||
|
||||
it('should initialize with an empty command tree', () => {
|
||||
const tree = commandService.getCommands();
|
||||
expect(tree).toBeInstanceOf(Array);
|
||||
expect(tree.length).toBe(0);
|
||||
});
|
||||
|
||||
describe('loadCommands', () => {
|
||||
it('should load the built-in commands into the command tree', async () => {
|
||||
// Pre-condition check
|
||||
expect(commandService.getCommands().length).toBe(0);
|
||||
|
||||
// Action
|
||||
await commandService.loadCommands();
|
||||
const tree = commandService.getCommands();
|
||||
|
||||
// Post-condition assertions
|
||||
expect(tree.length).toBe(7);
|
||||
|
||||
const commandNames = tree.map((cmd) => cmd.name);
|
||||
expect(commandNames).toContain('auth');
|
||||
expect(commandNames).toContain('memory');
|
||||
expect(commandNames).toContain('help');
|
||||
expect(commandNames).toContain('clear');
|
||||
expect(commandNames).toContain('theme');
|
||||
expect(commandNames).toContain('privacy');
|
||||
expect(commandNames).toContain('about');
|
||||
});
|
||||
|
||||
it('should overwrite any existing commands when called again', async () => {
|
||||
// Load once
|
||||
await commandService.loadCommands();
|
||||
expect(commandService.getCommands().length).toBe(7);
|
||||
|
||||
// Load again
|
||||
await commandService.loadCommands();
|
||||
const tree = commandService.getCommands();
|
||||
|
||||
// Should not append, but overwrite
|
||||
expect(tree.length).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCommandTree', () => {
|
||||
it('should return the current command tree', async () => {
|
||||
const initialTree = commandService.getCommands();
|
||||
expect(initialTree).toEqual([]);
|
||||
|
||||
await commandService.loadCommands();
|
||||
|
||||
const loadedTree = commandService.getCommands();
|
||||
expect(loadedTree.length).toBe(7);
|
||||
expect(loadedTree).toEqual([
|
||||
aboutCommand,
|
||||
authCommand,
|
||||
clearCommand,
|
||||
helpCommand,
|
||||
memoryCommand,
|
||||
privacyCommand,
|
||||
themeCommand,
|
||||
]);
|
||||
});
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe('when initialized with an injected loader function', () => {
|
||||
it('should use the provided loader instead of the built-in one', async () => {
|
||||
// Arrange: Create a set of mock commands.
|
||||
const mockCommands: SlashCommand[] = [
|
||||
{ name: 'injected-test-1', description: 'injected 1' },
|
||||
{ name: 'injected-test-2', description: 'injected 2' },
|
||||
];
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Arrange: Create a mock loader FUNCTION that resolves with our mock commands.
|
||||
const mockLoader = vi.fn().mockResolvedValue(mockCommands);
|
||||
it('should load commands from a single loader', async () => {
|
||||
const mockLoader = new MockCommandLoader([mockCommandA, mockCommandB]);
|
||||
const service = await CommandService.create(
|
||||
[mockLoader],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
// Act: Instantiate the service WITH the injected loader function.
|
||||
const commandService = new CommandService(mockLoader);
|
||||
await commandService.loadCommands();
|
||||
const tree = commandService.getCommands();
|
||||
const commands = service.getCommands();
|
||||
|
||||
// Assert: The tree should contain ONLY our injected commands.
|
||||
expect(mockLoader).toHaveBeenCalled(); // Verify our mock loader was actually called.
|
||||
expect(tree.length).toBe(2);
|
||||
expect(tree).toEqual(mockCommands);
|
||||
expect(mockLoader.loadCommands).toHaveBeenCalledTimes(1);
|
||||
expect(commands).toHaveLength(2);
|
||||
expect(commands).toEqual(
|
||||
expect.arrayContaining([mockCommandA, mockCommandB]),
|
||||
);
|
||||
});
|
||||
|
||||
const commandNames = tree.map((cmd) => cmd.name);
|
||||
expect(commandNames).not.toContain('memory'); // Verify it didn't load production commands.
|
||||
});
|
||||
it('should aggregate commands from multiple loaders', async () => {
|
||||
const loader1 = new MockCommandLoader([mockCommandA]);
|
||||
const loader2 = new MockCommandLoader([mockCommandC]);
|
||||
const service = await CommandService.create(
|
||||
[loader1, loader2],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const commands = service.getCommands();
|
||||
|
||||
expect(loader1.loadCommands).toHaveBeenCalledTimes(1);
|
||||
expect(loader2.loadCommands).toHaveBeenCalledTimes(1);
|
||||
expect(commands).toHaveLength(2);
|
||||
expect(commands).toEqual(
|
||||
expect.arrayContaining([mockCommandA, mockCommandC]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should override commands from earlier loaders with those from later loaders', async () => {
|
||||
const loader1 = new MockCommandLoader([mockCommandA, mockCommandB]);
|
||||
const loader2 = new MockCommandLoader([
|
||||
mockCommandB_Override,
|
||||
mockCommandC,
|
||||
]);
|
||||
const service = await CommandService.create(
|
||||
[loader1, loader2],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const commands = service.getCommands();
|
||||
|
||||
expect(commands).toHaveLength(3); // Should be A, C, and the overridden B.
|
||||
|
||||
// The final list should contain the override from the *last* loader.
|
||||
const commandB = commands.find((cmd) => cmd.name === 'command-b');
|
||||
expect(commandB).toBeDefined();
|
||||
expect(commandB?.kind).toBe(CommandKind.FILE); // Verify it's the overridden version.
|
||||
expect(commandB).toEqual(mockCommandB_Override);
|
||||
|
||||
// Ensure the other commands are still present.
|
||||
expect(commands).toEqual(
|
||||
expect.arrayContaining([
|
||||
mockCommandA,
|
||||
mockCommandC,
|
||||
mockCommandB_Override,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle loaders that return an empty array of commands gracefully', async () => {
|
||||
const loader1 = new MockCommandLoader([mockCommandA]);
|
||||
const emptyLoader = new MockCommandLoader([]);
|
||||
const loader3 = new MockCommandLoader([mockCommandB]);
|
||||
const service = await CommandService.create(
|
||||
[loader1, emptyLoader, loader3],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const commands = service.getCommands();
|
||||
|
||||
expect(emptyLoader.loadCommands).toHaveBeenCalledTimes(1);
|
||||
expect(commands).toHaveLength(2);
|
||||
expect(commands).toEqual(
|
||||
expect.arrayContaining([mockCommandA, mockCommandB]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should load commands from successful loaders even if one fails', async () => {
|
||||
const successfulLoader = new MockCommandLoader([mockCommandA]);
|
||||
const failingLoader = new MockCommandLoader([]);
|
||||
const error = new Error('Loader failed');
|
||||
vi.spyOn(failingLoader, 'loadCommands').mockRejectedValue(error);
|
||||
|
||||
const service = await CommandService.create(
|
||||
[successfulLoader, failingLoader],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const commands = service.getCommands();
|
||||
expect(commands).toHaveLength(1);
|
||||
expect(commands).toEqual([mockCommandA]);
|
||||
expect(console.debug).toHaveBeenCalledWith(
|
||||
'A command loader failed:',
|
||||
error,
|
||||
);
|
||||
});
|
||||
|
||||
it('getCommands should return a readonly array that cannot be mutated', async () => {
|
||||
const service = await CommandService.create(
|
||||
[new MockCommandLoader([mockCommandA])],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const commands = service.getCommands();
|
||||
|
||||
// Expect it to throw a TypeError at runtime because the array is frozen.
|
||||
expect(() => {
|
||||
// @ts-expect-error - Testing immutability is intentional here.
|
||||
commands.push(mockCommandB);
|
||||
}).toThrow();
|
||||
|
||||
// Verify the original array was not mutated.
|
||||
expect(service.getCommands()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should pass the abort signal to all loaders', async () => {
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
|
||||
const loader1 = new MockCommandLoader([mockCommandA]);
|
||||
const loader2 = new MockCommandLoader([mockCommandB]);
|
||||
|
||||
await CommandService.create([loader1, loader2], signal);
|
||||
|
||||
expect(loader1.loadCommands).toHaveBeenCalledTimes(1);
|
||||
expect(loader1.loadCommands).toHaveBeenCalledWith(signal);
|
||||
expect(loader2.loadCommands).toHaveBeenCalledTimes(1);
|
||||
expect(loader2.loadCommands).toHaveBeenCalledWith(signal);
|
||||
});
|
||||
|
||||
it('should rename extension commands when they conflict', async () => {
|
||||
const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN);
|
||||
const userCommand = createMockCommand('sync', CommandKind.FILE);
|
||||
const extensionCommand1 = {
|
||||
...createMockCommand('deploy', CommandKind.FILE),
|
||||
extensionName: 'firebase',
|
||||
description: '[firebase] Deploy to Firebase',
|
||||
};
|
||||
const extensionCommand2 = {
|
||||
...createMockCommand('sync', CommandKind.FILE),
|
||||
extensionName: 'git-helper',
|
||||
description: '[git-helper] Sync with remote',
|
||||
};
|
||||
|
||||
const mockLoader1 = new MockCommandLoader([builtinCommand]);
|
||||
const mockLoader2 = new MockCommandLoader([
|
||||
userCommand,
|
||||
extensionCommand1,
|
||||
extensionCommand2,
|
||||
]);
|
||||
|
||||
const service = await CommandService.create(
|
||||
[mockLoader1, mockLoader2],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const commands = service.getCommands();
|
||||
expect(commands).toHaveLength(4);
|
||||
|
||||
// Built-in command keeps original name
|
||||
const deployBuiltin = commands.find(
|
||||
(cmd) => cmd.name === 'deploy' && !cmd.extensionName,
|
||||
);
|
||||
expect(deployBuiltin).toBeDefined();
|
||||
expect(deployBuiltin?.kind).toBe(CommandKind.BUILT_IN);
|
||||
|
||||
// Extension command conflicting with built-in gets renamed
|
||||
const deployExtension = commands.find(
|
||||
(cmd) => cmd.name === 'firebase.deploy',
|
||||
);
|
||||
expect(deployExtension).toBeDefined();
|
||||
expect(deployExtension?.extensionName).toBe('firebase');
|
||||
|
||||
// User command keeps original name
|
||||
const syncUser = commands.find(
|
||||
(cmd) => cmd.name === 'sync' && !cmd.extensionName,
|
||||
);
|
||||
expect(syncUser).toBeDefined();
|
||||
expect(syncUser?.kind).toBe(CommandKind.FILE);
|
||||
|
||||
// Extension command conflicting with user command gets renamed
|
||||
const syncExtension = commands.find(
|
||||
(cmd) => cmd.name === 'git-helper.sync',
|
||||
);
|
||||
expect(syncExtension).toBeDefined();
|
||||
expect(syncExtension?.extensionName).toBe('git-helper');
|
||||
});
|
||||
|
||||
it('should handle user/project command override correctly', async () => {
|
||||
const builtinCommand = createMockCommand('help', CommandKind.BUILT_IN);
|
||||
const userCommand = createMockCommand('help', CommandKind.FILE);
|
||||
const projectCommand = createMockCommand('deploy', CommandKind.FILE);
|
||||
const userDeployCommand = createMockCommand('deploy', CommandKind.FILE);
|
||||
|
||||
const mockLoader1 = new MockCommandLoader([builtinCommand]);
|
||||
const mockLoader2 = new MockCommandLoader([
|
||||
userCommand,
|
||||
userDeployCommand,
|
||||
projectCommand,
|
||||
]);
|
||||
|
||||
const service = await CommandService.create(
|
||||
[mockLoader1, mockLoader2],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const commands = service.getCommands();
|
||||
expect(commands).toHaveLength(2);
|
||||
|
||||
// User command overrides built-in
|
||||
const helpCommand = commands.find((cmd) => cmd.name === 'help');
|
||||
expect(helpCommand).toBeDefined();
|
||||
expect(helpCommand?.kind).toBe(CommandKind.FILE);
|
||||
|
||||
// Project command overrides user command (last wins)
|
||||
const deployCommand = commands.find((cmd) => cmd.name === 'deploy');
|
||||
expect(deployCommand).toBeDefined();
|
||||
expect(deployCommand?.kind).toBe(CommandKind.FILE);
|
||||
});
|
||||
|
||||
it('should handle secondary conflicts when renaming extension commands', async () => {
|
||||
// User has both /deploy and /gcp.deploy commands
|
||||
const userCommand1 = createMockCommand('deploy', CommandKind.FILE);
|
||||
const userCommand2 = createMockCommand('gcp.deploy', CommandKind.FILE);
|
||||
|
||||
// Extension also has a deploy command that will conflict with user's /deploy
|
||||
const extensionCommand = {
|
||||
...createMockCommand('deploy', CommandKind.FILE),
|
||||
extensionName: 'gcp',
|
||||
description: '[gcp] Deploy to Google Cloud',
|
||||
};
|
||||
|
||||
const mockLoader = new MockCommandLoader([
|
||||
userCommand1,
|
||||
userCommand2,
|
||||
extensionCommand,
|
||||
]);
|
||||
|
||||
const service = await CommandService.create(
|
||||
[mockLoader],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const commands = service.getCommands();
|
||||
expect(commands).toHaveLength(3);
|
||||
|
||||
// Original user command keeps its name
|
||||
const deployUser = commands.find(
|
||||
(cmd) => cmd.name === 'deploy' && !cmd.extensionName,
|
||||
);
|
||||
expect(deployUser).toBeDefined();
|
||||
|
||||
// User's dot notation command keeps its name
|
||||
const gcpDeployUser = commands.find(
|
||||
(cmd) => cmd.name === 'gcp.deploy' && !cmd.extensionName,
|
||||
);
|
||||
expect(gcpDeployUser).toBeDefined();
|
||||
|
||||
// Extension command gets renamed with suffix due to secondary conflict
|
||||
const deployExtension = commands.find(
|
||||
(cmd) => cmd.name === 'gcp.deploy1' && cmd.extensionName === 'gcp',
|
||||
);
|
||||
expect(deployExtension).toBeDefined();
|
||||
expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud');
|
||||
});
|
||||
|
||||
it('should handle multiple secondary conflicts with incrementing suffixes', async () => {
|
||||
// User has /deploy, /gcp.deploy, and /gcp.deploy1
|
||||
const userCommand1 = createMockCommand('deploy', CommandKind.FILE);
|
||||
const userCommand2 = createMockCommand('gcp.deploy', CommandKind.FILE);
|
||||
const userCommand3 = createMockCommand('gcp.deploy1', CommandKind.FILE);
|
||||
|
||||
// Extension has a deploy command
|
||||
const extensionCommand = {
|
||||
...createMockCommand('deploy', CommandKind.FILE),
|
||||
extensionName: 'gcp',
|
||||
description: '[gcp] Deploy to Google Cloud',
|
||||
};
|
||||
|
||||
const mockLoader = new MockCommandLoader([
|
||||
userCommand1,
|
||||
userCommand2,
|
||||
userCommand3,
|
||||
extensionCommand,
|
||||
]);
|
||||
|
||||
const service = await CommandService.create(
|
||||
[mockLoader],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const commands = service.getCommands();
|
||||
expect(commands).toHaveLength(4);
|
||||
|
||||
// Extension command gets renamed with suffix 2 due to multiple conflicts
|
||||
const deployExtension = commands.find(
|
||||
(cmd) => cmd.name === 'gcp.deploy2' && cmd.extensionName === 'gcp',
|
||||
);
|
||||
expect(deployExtension).toBeDefined();
|
||||
expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,40 +5,99 @@
|
||||
*/
|
||||
|
||||
import { SlashCommand } from '../ui/commands/types.js';
|
||||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||
import { clearCommand } from '../ui/commands/clearCommand.js';
|
||||
import { authCommand } from '../ui/commands/authCommand.js';
|
||||
import { themeCommand } from '../ui/commands/themeCommand.js';
|
||||
import { privacyCommand } from '../ui/commands/privacyCommand.js';
|
||||
import { aboutCommand } from '../ui/commands/aboutCommand.js';
|
||||
|
||||
const loadBuiltInCommands = async (): Promise<SlashCommand[]> => [
|
||||
aboutCommand,
|
||||
authCommand,
|
||||
clearCommand,
|
||||
helpCommand,
|
||||
memoryCommand,
|
||||
privacyCommand,
|
||||
themeCommand,
|
||||
];
|
||||
import { ICommandLoader } from './types.js';
|
||||
|
||||
/**
|
||||
* Orchestrates the discovery and loading of all slash commands for the CLI.
|
||||
*
|
||||
* This service operates on a provider-based loader pattern. It is initialized
|
||||
* with an array of `ICommandLoader` instances, each responsible for fetching
|
||||
* commands from a specific source (e.g., built-in code, local files).
|
||||
*
|
||||
* The CommandService is responsible for invoking these loaders, aggregating their
|
||||
* results, and resolving any name conflicts. This architecture allows the command
|
||||
* system to be extended with new sources without modifying the service itself.
|
||||
*/
|
||||
export class CommandService {
|
||||
private commands: SlashCommand[] = [];
|
||||
/**
|
||||
* Private constructor to enforce the use of the async factory.
|
||||
* @param commands A readonly array of the fully loaded and de-duplicated commands.
|
||||
*/
|
||||
private constructor(private readonly commands: readonly SlashCommand[]) {}
|
||||
|
||||
constructor(
|
||||
private commandLoader: () => Promise<SlashCommand[]> = loadBuiltInCommands,
|
||||
) {
|
||||
// The constructor can be used for dependency injection in the future.
|
||||
/**
|
||||
* Asynchronously creates and initializes a new CommandService instance.
|
||||
*
|
||||
* This factory method orchestrates the entire command loading process. It
|
||||
* runs all provided loaders in parallel, aggregates their results, handles
|
||||
* name conflicts for extension commands by renaming them, and then returns a
|
||||
* fully constructed `CommandService` instance.
|
||||
*
|
||||
* Conflict resolution:
|
||||
* - Extension commands that conflict with existing commands are renamed to
|
||||
* `extensionName.commandName`
|
||||
* - Non-extension commands (built-in, user, project) override earlier commands
|
||||
* with the same name based on loader order
|
||||
*
|
||||
* @param loaders An array of objects that conform to the `ICommandLoader`
|
||||
* interface. Built-in commands should come first, followed by FileCommandLoader.
|
||||
* @param signal An AbortSignal to cancel the loading process.
|
||||
* @returns A promise that resolves to a new, fully initialized `CommandService` instance.
|
||||
*/
|
||||
static async create(
|
||||
loaders: ICommandLoader[],
|
||||
signal: AbortSignal,
|
||||
): Promise<CommandService> {
|
||||
const results = await Promise.allSettled(
|
||||
loaders.map((loader) => loader.loadCommands(signal)),
|
||||
);
|
||||
|
||||
const allCommands: SlashCommand[] = [];
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
allCommands.push(...result.value);
|
||||
} else {
|
||||
console.debug('A command loader failed:', result.reason);
|
||||
}
|
||||
}
|
||||
|
||||
const commandMap = new Map<string, SlashCommand>();
|
||||
for (const cmd of allCommands) {
|
||||
let finalName = cmd.name;
|
||||
|
||||
// Extension commands get renamed if they conflict with existing commands
|
||||
if (cmd.extensionName && commandMap.has(cmd.name)) {
|
||||
let renamedName = `${cmd.extensionName}.${cmd.name}`;
|
||||
let suffix = 1;
|
||||
|
||||
// Keep trying until we find a name that doesn't conflict
|
||||
while (commandMap.has(renamedName)) {
|
||||
renamedName = `${cmd.extensionName}.${cmd.name}${suffix}`;
|
||||
suffix++;
|
||||
}
|
||||
|
||||
finalName = renamedName;
|
||||
}
|
||||
|
||||
commandMap.set(finalName, {
|
||||
...cmd,
|
||||
name: finalName,
|
||||
});
|
||||
}
|
||||
|
||||
const finalCommands = Object.freeze(Array.from(commandMap.values()));
|
||||
return new CommandService(finalCommands);
|
||||
}
|
||||
|
||||
async loadCommands(): Promise<void> {
|
||||
// For now, we only load the built-in commands.
|
||||
// File-based and remote commands will be added later.
|
||||
this.commands = await this.commandLoader();
|
||||
}
|
||||
|
||||
getCommands(): SlashCommand[] {
|
||||
/**
|
||||
* Retrieves the currently loaded and de-duplicated list of slash commands.
|
||||
*
|
||||
* This method is a safe accessor for the service's state. It returns a
|
||||
* readonly array, preventing consumers from modifying the service's internal state.
|
||||
*
|
||||
* @returns A readonly, unified array of available `SlashCommand` objects.
|
||||
*/
|
||||
getCommands(): readonly SlashCommand[] {
|
||||
return this.commands;
|
||||
}
|
||||
}
|
||||
|
||||
915
packages/cli/src/services/FileCommandLoader.test.ts
Normal file
915
packages/cli/src/services/FileCommandLoader.test.ts
Normal file
@@ -0,0 +1,915 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
Config,
|
||||
getProjectCommandsDir,
|
||||
getUserCommandsDir,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import mock from 'mock-fs';
|
||||
import { FileCommandLoader } from './FileCommandLoader.js';
|
||||
import { assert, vi } from 'vitest';
|
||||
import { createMockCommandContext } from '../test-utils/mockCommandContext.js';
|
||||
import {
|
||||
SHELL_INJECTION_TRIGGER,
|
||||
SHORTHAND_ARGS_PLACEHOLDER,
|
||||
} from './prompt-processors/types.js';
|
||||
import {
|
||||
ConfirmationRequiredError,
|
||||
ShellProcessor,
|
||||
} from './prompt-processors/shellProcessor.js';
|
||||
import { ShorthandArgumentProcessor } from './prompt-processors/argumentProcessor.js';
|
||||
|
||||
const mockShellProcess = vi.hoisted(() => vi.fn());
|
||||
vi.mock('./prompt-processors/shellProcessor.js', () => ({
|
||||
ShellProcessor: vi.fn().mockImplementation(() => ({
|
||||
process: mockShellProcess,
|
||||
})),
|
||||
ConfirmationRequiredError: class extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public commandsToConfirm: string[],
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ConfirmationRequiredError';
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./prompt-processors/argumentProcessor.js', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<
|
||||
typeof import('./prompt-processors/argumentProcessor.js')
|
||||
>();
|
||||
return {
|
||||
ShorthandArgumentProcessor: vi
|
||||
.fn()
|
||||
.mockImplementation(() => new original.ShorthandArgumentProcessor()),
|
||||
DefaultArgumentProcessor: vi
|
||||
.fn()
|
||||
.mockImplementation(() => new original.DefaultArgumentProcessor()),
|
||||
};
|
||||
});
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...original,
|
||||
isCommandAllowed: vi.fn(),
|
||||
ShellExecutionService: {
|
||||
execute: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('FileCommandLoader', () => {
|
||||
const signal: AbortSignal = new AbortController().signal;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockShellProcess.mockImplementation((prompt) => Promise.resolve(prompt));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('loads a single command from a file', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'test.toml': 'prompt = "This is a test prompt"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
const command = commands[0];
|
||||
expect(command).toBeDefined();
|
||||
expect(command.name).toBe('test');
|
||||
|
||||
const result = await command.action?.(
|
||||
createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/test',
|
||||
name: 'test',
|
||||
args: '',
|
||||
},
|
||||
}),
|
||||
'',
|
||||
);
|
||||
if (result?.type === 'submit_prompt') {
|
||||
expect(result.content).toBe('This is a test prompt');
|
||||
} else {
|
||||
assert.fail('Incorrect action type');
|
||||
}
|
||||
});
|
||||
|
||||
// Symlink creation on Windows requires special permissions that are not
|
||||
// available in the standard CI environment. Therefore, we skip these tests
|
||||
// on Windows to prevent CI failures. The core functionality is still
|
||||
// validated on Linux and macOS.
|
||||
const itif = (condition: boolean) => (condition ? it : it.skip);
|
||||
|
||||
itif(process.platform !== 'win32')(
|
||||
'loads commands from a symlinked directory',
|
||||
async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
const realCommandsDir = '/real/commands';
|
||||
mock({
|
||||
[realCommandsDir]: {
|
||||
'test.toml': 'prompt = "This is a test prompt"',
|
||||
},
|
||||
// Symlink the user commands directory to the real one
|
||||
[userCommandsDir]: mock.symlink({
|
||||
path: realCommandsDir,
|
||||
}),
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
const command = commands[0];
|
||||
expect(command).toBeDefined();
|
||||
expect(command.name).toBe('test');
|
||||
},
|
||||
);
|
||||
|
||||
itif(process.platform !== 'win32')(
|
||||
'loads commands from a symlinked subdirectory',
|
||||
async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
const realNamespacedDir = '/real/namespaced-commands';
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
namespaced: mock.symlink({
|
||||
path: realNamespacedDir,
|
||||
}),
|
||||
},
|
||||
[realNamespacedDir]: {
|
||||
'my-test.toml': 'prompt = "This is a test prompt"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
const command = commands[0];
|
||||
expect(command).toBeDefined();
|
||||
expect(command.name).toBe('namespaced:my-test');
|
||||
},
|
||||
);
|
||||
|
||||
it('loads multiple commands', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'test1.toml': 'prompt = "Prompt 1"',
|
||||
'test2.toml': 'prompt = "Prompt 2"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('creates deeply nested namespaces correctly', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
gcp: {
|
||||
pipelines: {
|
||||
'run.toml': 'prompt = "run pipeline"',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const mockConfig = {
|
||||
getProjectRoot: vi.fn(() => '/path/to/project'),
|
||||
getExtensions: vi.fn(() => []),
|
||||
} as Config;
|
||||
const loader = new FileCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
expect(commands).toHaveLength(1);
|
||||
expect(commands[0]!.name).toBe('gcp:pipelines:run');
|
||||
});
|
||||
|
||||
it('creates namespaces from nested directories', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
git: {
|
||||
'commit.toml': 'prompt = "git commit prompt"',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
const command = commands[0];
|
||||
expect(command).toBeDefined();
|
||||
expect(command.name).toBe('git:commit');
|
||||
});
|
||||
|
||||
it('returns both user and project commands in order', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
const projectCommandsDir = getProjectCommandsDir(process.cwd());
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'test.toml': 'prompt = "User prompt"',
|
||||
},
|
||||
[projectCommandsDir]: {
|
||||
'test.toml': 'prompt = "Project prompt"',
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = {
|
||||
getProjectRoot: vi.fn(() => process.cwd()),
|
||||
getExtensions: vi.fn(() => []),
|
||||
} as Config;
|
||||
const loader = new FileCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(2);
|
||||
const userResult = await commands[0].action?.(
|
||||
createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/test',
|
||||
name: 'test',
|
||||
args: '',
|
||||
},
|
||||
}),
|
||||
'',
|
||||
);
|
||||
if (userResult?.type === 'submit_prompt') {
|
||||
expect(userResult.content).toBe('User prompt');
|
||||
} else {
|
||||
assert.fail('Incorrect action type for user command');
|
||||
}
|
||||
const projectResult = await commands[1].action?.(
|
||||
createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/test',
|
||||
name: 'test',
|
||||
args: '',
|
||||
},
|
||||
}),
|
||||
'',
|
||||
);
|
||||
if (projectResult?.type === 'submit_prompt') {
|
||||
expect(projectResult.content).toBe('Project prompt');
|
||||
} else {
|
||||
assert.fail('Incorrect action type for project command');
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores files with TOML syntax errors', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'invalid.toml': 'this is not valid toml',
|
||||
'good.toml': 'prompt = "This one is fine"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
expect(commands[0].name).toBe('good');
|
||||
});
|
||||
|
||||
it('ignores files that are semantically invalid (missing prompt)', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'no_prompt.toml': 'description = "This file is missing a prompt"',
|
||||
'good.toml': 'prompt = "This one is fine"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
expect(commands[0].name).toBe('good');
|
||||
});
|
||||
|
||||
it('handles filename edge cases correctly', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'test.v1.toml': 'prompt = "Test prompt"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
const command = commands[0];
|
||||
expect(command).toBeDefined();
|
||||
expect(command.name).toBe('test.v1');
|
||||
});
|
||||
|
||||
it('handles file system errors gracefully', async () => {
|
||||
mock({}); // Mock an empty file system
|
||||
const loader = new FileCommandLoader(null);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
expect(commands).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('uses a default description if not provided', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'test.toml': 'prompt = "Test prompt"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
const command = commands[0];
|
||||
expect(command).toBeDefined();
|
||||
expect(command.description).toBe('Custom command from test.toml');
|
||||
});
|
||||
|
||||
it('uses the provided description', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'test.toml': 'prompt = "Test prompt"\ndescription = "My test command"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
const command = commands[0];
|
||||
expect(command).toBeDefined();
|
||||
expect(command.description).toBe('My test command');
|
||||
});
|
||||
|
||||
it('should sanitize colons in filenames to prevent namespace conflicts', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'legacy:command.toml': 'prompt = "This is a legacy command"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
const command = commands[0];
|
||||
expect(command).toBeDefined();
|
||||
|
||||
// Verify that the ':' in the filename was replaced with an '_'
|
||||
expect(command.name).toBe('legacy_command');
|
||||
});
|
||||
|
||||
describe('Extension Command Loading', () => {
|
||||
it('loads commands from active extensions', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
const projectCommandsDir = getProjectCommandsDir(process.cwd());
|
||||
const extensionDir = path.join(
|
||||
process.cwd(),
|
||||
'.gemini/extensions/test-ext',
|
||||
);
|
||||
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'user.toml': 'prompt = "User command"',
|
||||
},
|
||||
[projectCommandsDir]: {
|
||||
'project.toml': 'prompt = "Project command"',
|
||||
},
|
||||
[extensionDir]: {
|
||||
'gemini-extension.json': JSON.stringify({
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
commands: {
|
||||
'ext.toml': 'prompt = "Extension command"',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = {
|
||||
getProjectRoot: vi.fn(() => process.cwd()),
|
||||
getExtensions: vi.fn(() => [
|
||||
{
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: extensionDir,
|
||||
},
|
||||
]),
|
||||
} as Config;
|
||||
const loader = new FileCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(3);
|
||||
const commandNames = commands.map((cmd) => cmd.name);
|
||||
expect(commandNames).toEqual(['user', 'project', 'ext']);
|
||||
|
||||
const extCommand = commands.find((cmd) => cmd.name === 'ext');
|
||||
expect(extCommand?.extensionName).toBe('test-ext');
|
||||
expect(extCommand?.description).toMatch(/^\[test-ext\]/);
|
||||
});
|
||||
|
||||
it('extension commands have extensionName metadata for conflict resolution', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
const projectCommandsDir = getProjectCommandsDir(process.cwd());
|
||||
const extensionDir = path.join(
|
||||
process.cwd(),
|
||||
'.gemini/extensions/test-ext',
|
||||
);
|
||||
|
||||
mock({
|
||||
[extensionDir]: {
|
||||
'gemini-extension.json': JSON.stringify({
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
commands: {
|
||||
'deploy.toml': 'prompt = "Extension deploy command"',
|
||||
},
|
||||
},
|
||||
[userCommandsDir]: {
|
||||
'deploy.toml': 'prompt = "User deploy command"',
|
||||
},
|
||||
[projectCommandsDir]: {
|
||||
'deploy.toml': 'prompt = "Project deploy command"',
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = {
|
||||
getProjectRoot: vi.fn(() => process.cwd()),
|
||||
getExtensions: vi.fn(() => [
|
||||
{
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: extensionDir,
|
||||
},
|
||||
]),
|
||||
} as Config;
|
||||
const loader = new FileCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
// Return all commands, even duplicates
|
||||
expect(commands).toHaveLength(3);
|
||||
|
||||
expect(commands[0].name).toBe('deploy');
|
||||
expect(commands[0].extensionName).toBeUndefined();
|
||||
const result0 = await commands[0].action?.(
|
||||
createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/deploy',
|
||||
name: 'deploy',
|
||||
args: '',
|
||||
},
|
||||
}),
|
||||
'',
|
||||
);
|
||||
expect(result0?.type).toBe('submit_prompt');
|
||||
if (result0?.type === 'submit_prompt') {
|
||||
expect(result0.content).toBe('User deploy command');
|
||||
}
|
||||
|
||||
expect(commands[1].name).toBe('deploy');
|
||||
expect(commands[1].extensionName).toBeUndefined();
|
||||
const result1 = await commands[1].action?.(
|
||||
createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/deploy',
|
||||
name: 'deploy',
|
||||
args: '',
|
||||
},
|
||||
}),
|
||||
'',
|
||||
);
|
||||
expect(result1?.type).toBe('submit_prompt');
|
||||
if (result1?.type === 'submit_prompt') {
|
||||
expect(result1.content).toBe('Project deploy command');
|
||||
}
|
||||
|
||||
expect(commands[2].name).toBe('deploy');
|
||||
expect(commands[2].extensionName).toBe('test-ext');
|
||||
expect(commands[2].description).toMatch(/^\[test-ext\]/);
|
||||
const result2 = await commands[2].action?.(
|
||||
createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/deploy',
|
||||
name: 'deploy',
|
||||
args: '',
|
||||
},
|
||||
}),
|
||||
'',
|
||||
);
|
||||
expect(result2?.type).toBe('submit_prompt');
|
||||
if (result2?.type === 'submit_prompt') {
|
||||
expect(result2.content).toBe('Extension deploy command');
|
||||
}
|
||||
});
|
||||
|
||||
it('only loads commands from active extensions', async () => {
|
||||
const extensionDir1 = path.join(
|
||||
process.cwd(),
|
||||
'.gemini/extensions/active-ext',
|
||||
);
|
||||
const extensionDir2 = path.join(
|
||||
process.cwd(),
|
||||
'.gemini/extensions/inactive-ext',
|
||||
);
|
||||
|
||||
mock({
|
||||
[extensionDir1]: {
|
||||
'gemini-extension.json': JSON.stringify({
|
||||
name: 'active-ext',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
commands: {
|
||||
'active.toml': 'prompt = "Active extension command"',
|
||||
},
|
||||
},
|
||||
[extensionDir2]: {
|
||||
'gemini-extension.json': JSON.stringify({
|
||||
name: 'inactive-ext',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
commands: {
|
||||
'inactive.toml': 'prompt = "Inactive extension command"',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = {
|
||||
getProjectRoot: vi.fn(() => process.cwd()),
|
||||
getExtensions: vi.fn(() => [
|
||||
{
|
||||
name: 'active-ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: extensionDir1,
|
||||
},
|
||||
{
|
||||
name: 'inactive-ext',
|
||||
version: '1.0.0',
|
||||
isActive: false,
|
||||
path: extensionDir2,
|
||||
},
|
||||
]),
|
||||
} as Config;
|
||||
const loader = new FileCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
expect(commands[0].name).toBe('active');
|
||||
expect(commands[0].extensionName).toBe('active-ext');
|
||||
expect(commands[0].description).toMatch(/^\[active-ext\]/);
|
||||
});
|
||||
|
||||
it('handles missing extension commands directory gracefully', async () => {
|
||||
const extensionDir = path.join(
|
||||
process.cwd(),
|
||||
'.gemini/extensions/no-commands',
|
||||
);
|
||||
|
||||
mock({
|
||||
[extensionDir]: {
|
||||
'gemini-extension.json': JSON.stringify({
|
||||
name: 'no-commands',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
// No commands directory
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = {
|
||||
getProjectRoot: vi.fn(() => process.cwd()),
|
||||
getExtensions: vi.fn(() => [
|
||||
{
|
||||
name: 'no-commands',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: extensionDir,
|
||||
},
|
||||
]),
|
||||
} as Config;
|
||||
const loader = new FileCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
expect(commands).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles nested command structure in extensions', async () => {
|
||||
const extensionDir = path.join(process.cwd(), '.gemini/extensions/a');
|
||||
|
||||
mock({
|
||||
[extensionDir]: {
|
||||
'gemini-extension.json': JSON.stringify({
|
||||
name: 'a',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
commands: {
|
||||
b: {
|
||||
'c.toml': 'prompt = "Nested command from extension a"',
|
||||
d: {
|
||||
'e.toml': 'prompt = "Deeply nested command"',
|
||||
},
|
||||
},
|
||||
'simple.toml': 'prompt = "Simple command"',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = {
|
||||
getProjectRoot: vi.fn(() => process.cwd()),
|
||||
getExtensions: vi.fn(() => [
|
||||
{ name: 'a', version: '1.0.0', isActive: true, path: extensionDir },
|
||||
]),
|
||||
} as Config;
|
||||
const loader = new FileCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(3);
|
||||
|
||||
const commandNames = commands.map((cmd) => cmd.name).sort();
|
||||
expect(commandNames).toEqual(['b:c', 'b:d:e', 'simple']);
|
||||
|
||||
const nestedCmd = commands.find((cmd) => cmd.name === 'b:c');
|
||||
expect(nestedCmd?.extensionName).toBe('a');
|
||||
expect(nestedCmd?.description).toMatch(/^\[a\]/);
|
||||
expect(nestedCmd).toBeDefined();
|
||||
const result = await nestedCmd!.action?.(
|
||||
createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/b:c',
|
||||
name: 'b:c',
|
||||
args: '',
|
||||
},
|
||||
}),
|
||||
'',
|
||||
);
|
||||
if (result?.type === 'submit_prompt') {
|
||||
expect(result.content).toBe('Nested command from extension a');
|
||||
} else {
|
||||
assert.fail('Incorrect action type');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shorthand Argument Processor Integration', () => {
|
||||
it('correctly processes a command with {{args}}', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'shorthand.toml':
|
||||
'prompt = "The user wants to: {{args}}"\ndescription = "Shorthand test"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
const command = commands.find((c) => c.name === 'shorthand');
|
||||
expect(command).toBeDefined();
|
||||
|
||||
const result = await command!.action?.(
|
||||
createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/shorthand do something cool',
|
||||
name: 'shorthand',
|
||||
args: 'do something cool',
|
||||
},
|
||||
}),
|
||||
'do something cool',
|
||||
);
|
||||
expect(result?.type).toBe('submit_prompt');
|
||||
if (result?.type === 'submit_prompt') {
|
||||
expect(result.content).toBe('The user wants to: do something cool');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Default Argument Processor Integration', () => {
|
||||
it('correctly processes a command without {{args}}', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'model_led.toml':
|
||||
'prompt = "This is the instruction."\ndescription = "Default processor test"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
const command = commands.find((c) => c.name === 'model_led');
|
||||
expect(command).toBeDefined();
|
||||
|
||||
const result = await command!.action?.(
|
||||
createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/model_led 1.2.0 added "a feature"',
|
||||
name: 'model_led',
|
||||
args: '1.2.0 added "a feature"',
|
||||
},
|
||||
}),
|
||||
'1.2.0 added "a feature"',
|
||||
);
|
||||
expect(result?.type).toBe('submit_prompt');
|
||||
if (result?.type === 'submit_prompt') {
|
||||
const expectedContent =
|
||||
'This is the instruction.\n\n/model_led 1.2.0 added "a feature"';
|
||||
expect(result.content).toBe(expectedContent);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shell Processor Integration', () => {
|
||||
it('instantiates ShellProcessor if the trigger is present', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'shell.toml': `prompt = "Run this: ${SHELL_INJECTION_TRIGGER}echo hello}"`,
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
await loader.loadCommands(signal);
|
||||
|
||||
expect(ShellProcessor).toHaveBeenCalledWith('shell');
|
||||
});
|
||||
|
||||
it('does not instantiate ShellProcessor if trigger is missing', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'regular.toml': `prompt = "Just a regular prompt"`,
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
await loader.loadCommands(signal);
|
||||
|
||||
expect(ShellProcessor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a "submit_prompt" action if shell processing succeeds', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'shell.toml': `prompt = "Run !{echo 'hello'}"`,
|
||||
},
|
||||
});
|
||||
mockShellProcess.mockResolvedValue('Run hello');
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
const command = commands.find((c) => c.name === 'shell');
|
||||
expect(command).toBeDefined();
|
||||
|
||||
const result = await command!.action!(
|
||||
createMockCommandContext({
|
||||
invocation: { raw: '/shell', name: 'shell', args: '' },
|
||||
}),
|
||||
'',
|
||||
);
|
||||
|
||||
expect(result?.type).toBe('submit_prompt');
|
||||
if (result?.type === 'submit_prompt') {
|
||||
expect(result.content).toBe('Run hello');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns a "confirm_shell_commands" action if shell processing requires it', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
const rawInvocation = '/shell rm -rf /';
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'shell.toml': `prompt = "Run !{rm -rf /}"`,
|
||||
},
|
||||
});
|
||||
|
||||
// Mock the processor to throw the specific error
|
||||
const error = new ConfirmationRequiredError('Confirmation needed', [
|
||||
'rm -rf /',
|
||||
]);
|
||||
mockShellProcess.mockRejectedValue(error);
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
const command = commands.find((c) => c.name === 'shell');
|
||||
expect(command).toBeDefined();
|
||||
|
||||
const result = await command!.action!(
|
||||
createMockCommandContext({
|
||||
invocation: { raw: rawInvocation, name: 'shell', args: 'rm -rf /' },
|
||||
}),
|
||||
'rm -rf /',
|
||||
);
|
||||
|
||||
expect(result?.type).toBe('confirm_shell_commands');
|
||||
if (result?.type === 'confirm_shell_commands') {
|
||||
expect(result.commandsToConfirm).toEqual(['rm -rf /']);
|
||||
expect(result.originalInvocation.raw).toBe(rawInvocation);
|
||||
}
|
||||
});
|
||||
|
||||
it('re-throws other errors from the processor', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'shell.toml': `prompt = "Run !{something}"`,
|
||||
},
|
||||
});
|
||||
|
||||
const genericError = new Error('Something else went wrong');
|
||||
mockShellProcess.mockRejectedValue(genericError);
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
const command = commands.find((c) => c.name === 'shell');
|
||||
expect(command).toBeDefined();
|
||||
|
||||
await expect(
|
||||
command!.action!(
|
||||
createMockCommandContext({
|
||||
invocation: { raw: '/shell', name: 'shell', args: '' },
|
||||
}),
|
||||
'',
|
||||
),
|
||||
).rejects.toThrow('Something else went wrong');
|
||||
});
|
||||
|
||||
it('assembles the processor pipeline in the correct order (Shell -> Argument)', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'pipeline.toml': `
|
||||
prompt = "Shell says: ${SHELL_INJECTION_TRIGGER}echo foo} and user says: ${SHORTHAND_ARGS_PLACEHOLDER}"
|
||||
`,
|
||||
},
|
||||
});
|
||||
|
||||
// Mock the process methods to track call order
|
||||
const argProcessMock = vi
|
||||
.fn()
|
||||
.mockImplementation((p) => `${p}-arg-processed`);
|
||||
|
||||
// Redefine the mock for this specific test
|
||||
mockShellProcess.mockImplementation((p) =>
|
||||
Promise.resolve(`${p}-shell-processed`),
|
||||
);
|
||||
|
||||
vi.mocked(ShorthandArgumentProcessor).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
process: argProcessMock,
|
||||
}) as unknown as ShorthandArgumentProcessor,
|
||||
);
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
const command = commands.find((c) => c.name === 'pipeline');
|
||||
expect(command).toBeDefined();
|
||||
|
||||
await command!.action!(
|
||||
createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/pipeline bar',
|
||||
name: 'pipeline',
|
||||
args: 'bar',
|
||||
},
|
||||
}),
|
||||
'bar',
|
||||
);
|
||||
|
||||
// Verify that the shell processor was called before the argument processor
|
||||
expect(mockShellProcess.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
argProcessMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
|
||||
// Also verify the flow of the prompt through the processors
|
||||
expect(mockShellProcess).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(argProcessMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('-shell-processed'), // It receives the output of the shell processor
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
287
packages/cli/src/services/FileCommandLoader.ts
Normal file
287
packages/cli/src/services/FileCommandLoader.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import toml from '@iarna/toml';
|
||||
import { glob } from 'glob';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
Config,
|
||||
getProjectCommandsDir,
|
||||
getUserCommandsDir,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { ICommandLoader } from './types.js';
|
||||
import {
|
||||
CommandContext,
|
||||
CommandKind,
|
||||
SlashCommand,
|
||||
SlashCommandActionReturn,
|
||||
} from '../ui/commands/types.js';
|
||||
import {
|
||||
DefaultArgumentProcessor,
|
||||
ShorthandArgumentProcessor,
|
||||
} from './prompt-processors/argumentProcessor.js';
|
||||
import {
|
||||
IPromptProcessor,
|
||||
SHORTHAND_ARGS_PLACEHOLDER,
|
||||
SHELL_INJECTION_TRIGGER,
|
||||
} from './prompt-processors/types.js';
|
||||
import {
|
||||
ConfirmationRequiredError,
|
||||
ShellProcessor,
|
||||
} from './prompt-processors/shellProcessor.js';
|
||||
|
||||
interface CommandDirectory {
|
||||
path: string;
|
||||
extensionName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the Zod schema for a command definition file. This serves as the
|
||||
* single source of truth for both validation and type inference.
|
||||
*/
|
||||
const TomlCommandDefSchema = z.object({
|
||||
prompt: z.string({
|
||||
required_error: "The 'prompt' field is required.",
|
||||
invalid_type_error: "The 'prompt' field must be a string.",
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Discovers and loads custom slash commands from .toml files in both the
|
||||
* user's global config directory and the current project's directory.
|
||||
*
|
||||
* This loader is responsible for:
|
||||
* - Recursively scanning command directories.
|
||||
* - Parsing and validating TOML files.
|
||||
* - Adapting valid definitions into executable SlashCommand objects.
|
||||
* - Handling file system errors and malformed files gracefully.
|
||||
*/
|
||||
export class FileCommandLoader implements ICommandLoader {
|
||||
private readonly projectRoot: string;
|
||||
|
||||
constructor(private readonly config: Config | null) {
|
||||
this.projectRoot = config?.getProjectRoot() || process.cwd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all commands from user, project, and extension directories.
|
||||
* Returns commands in order: user → project → extensions (alphabetically).
|
||||
*
|
||||
* Order is important for conflict resolution in CommandService:
|
||||
* - User/project commands (without extensionName) use "last wins" strategy
|
||||
* - Extension commands (with extensionName) get renamed if conflicts exist
|
||||
*
|
||||
* @param signal An AbortSignal to cancel the loading process.
|
||||
* @returns A promise that resolves to an array of all loaded SlashCommands.
|
||||
*/
|
||||
async loadCommands(signal: AbortSignal): Promise<SlashCommand[]> {
|
||||
const allCommands: SlashCommand[] = [];
|
||||
const globOptions = {
|
||||
nodir: true,
|
||||
dot: true,
|
||||
signal,
|
||||
follow: true,
|
||||
};
|
||||
|
||||
// Load commands from each directory
|
||||
const commandDirs = this.getCommandDirectories();
|
||||
for (const dirInfo of commandDirs) {
|
||||
try {
|
||||
const files = await glob('**/*.toml', {
|
||||
...globOptions,
|
||||
cwd: dirInfo.path,
|
||||
});
|
||||
|
||||
const commandPromises = files.map((file) =>
|
||||
this.parseAndAdaptFile(
|
||||
path.join(dirInfo.path, file),
|
||||
dirInfo.path,
|
||||
dirInfo.extensionName,
|
||||
),
|
||||
);
|
||||
|
||||
const commands = (await Promise.all(commandPromises)).filter(
|
||||
(cmd): cmd is SlashCommand => cmd !== null,
|
||||
);
|
||||
|
||||
// Add all commands without deduplication
|
||||
allCommands.push(...commands);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.error(
|
||||
`[FileCommandLoader] Error loading commands from ${dirInfo.path}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allCommands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all command directories in order for loading.
|
||||
* User commands → Project commands → Extension commands
|
||||
* This order ensures extension commands can detect all conflicts.
|
||||
*/
|
||||
private getCommandDirectories(): CommandDirectory[] {
|
||||
const dirs: CommandDirectory[] = [];
|
||||
|
||||
// 1. User commands
|
||||
dirs.push({ path: getUserCommandsDir() });
|
||||
|
||||
// 2. Project commands (override user commands)
|
||||
dirs.push({ path: getProjectCommandsDir(this.projectRoot) });
|
||||
|
||||
// 3. Extension commands (processed last to detect all conflicts)
|
||||
if (this.config) {
|
||||
const activeExtensions = this.config
|
||||
.getExtensions()
|
||||
.filter((ext) => ext.isActive)
|
||||
.sort((a, b) => a.name.localeCompare(b.name)); // Sort alphabetically for deterministic loading
|
||||
|
||||
const extensionCommandDirs = activeExtensions.map((ext) => ({
|
||||
path: path.join(ext.path, 'commands'),
|
||||
extensionName: ext.name,
|
||||
}));
|
||||
|
||||
dirs.push(...extensionCommandDirs);
|
||||
}
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a single .toml file and transforms it into a SlashCommand object.
|
||||
* @param filePath The absolute path to the .toml file.
|
||||
* @param baseDir The root command directory for name calculation.
|
||||
* @param extensionName Optional extension name to prefix commands with.
|
||||
* @returns A promise resolving to a SlashCommand, or null if the file is invalid.
|
||||
*/
|
||||
private async parseAndAdaptFile(
|
||||
filePath: string,
|
||||
baseDir: string,
|
||||
extensionName?: string,
|
||||
): Promise<SlashCommand | null> {
|
||||
let fileContent: string;
|
||||
try {
|
||||
fileContent = await fs.readFile(filePath, 'utf-8');
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`[FileCommandLoader] Failed to read file ${filePath}:`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = toml.parse(fileContent);
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`[FileCommandLoader] Failed to parse TOML file ${filePath}:`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const validationResult = TomlCommandDefSchema.safeParse(parsed);
|
||||
|
||||
if (!validationResult.success) {
|
||||
console.error(
|
||||
`[FileCommandLoader] Skipping invalid command file: ${filePath}. Validation errors:`,
|
||||
validationResult.error.flatten(),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const validDef = validationResult.data;
|
||||
|
||||
const relativePathWithExt = path.relative(baseDir, filePath);
|
||||
const relativePath = relativePathWithExt.substring(
|
||||
0,
|
||||
relativePathWithExt.length - 5, // length of '.toml'
|
||||
);
|
||||
const baseCommandName = relativePath
|
||||
.split(path.sep)
|
||||
// Sanitize each path segment to prevent ambiguity. Since ':' is our
|
||||
// namespace separator, we replace any literal colons in filenames
|
||||
// with underscores to avoid naming conflicts.
|
||||
.map((segment) => segment.replaceAll(':', '_'))
|
||||
.join(':');
|
||||
|
||||
// Add extension name tag for extension commands
|
||||
const defaultDescription = `Custom command from ${path.basename(filePath)}`;
|
||||
let description = validDef.description || defaultDescription;
|
||||
if (extensionName) {
|
||||
description = `[${extensionName}] ${description}`;
|
||||
}
|
||||
|
||||
const processors: IPromptProcessor[] = [];
|
||||
|
||||
// Add the Shell Processor if needed.
|
||||
if (validDef.prompt.includes(SHELL_INJECTION_TRIGGER)) {
|
||||
processors.push(new ShellProcessor(baseCommandName));
|
||||
}
|
||||
|
||||
// The presence of '{{args}}' is the switch that determines the behavior.
|
||||
if (validDef.prompt.includes(SHORTHAND_ARGS_PLACEHOLDER)) {
|
||||
processors.push(new ShorthandArgumentProcessor());
|
||||
} else {
|
||||
processors.push(new DefaultArgumentProcessor());
|
||||
}
|
||||
|
||||
return {
|
||||
name: baseCommandName,
|
||||
description,
|
||||
kind: CommandKind.FILE,
|
||||
extensionName,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
_args: string,
|
||||
): Promise<SlashCommandActionReturn> => {
|
||||
if (!context.invocation) {
|
||||
console.error(
|
||||
`[FileCommandLoader] Critical error: Command '${baseCommandName}' was executed without invocation context.`,
|
||||
);
|
||||
return {
|
||||
type: 'submit_prompt',
|
||||
content: validDef.prompt, // Fallback to unprocessed prompt
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
let processedPrompt = validDef.prompt;
|
||||
for (const processor of processors) {
|
||||
processedPrompt = await processor.process(processedPrompt, context);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'submit_prompt',
|
||||
content: processedPrompt,
|
||||
};
|
||||
} catch (e) {
|
||||
// Check if it's our specific error type
|
||||
if (e instanceof ConfirmationRequiredError) {
|
||||
// Halt and request confirmation from the UI layer.
|
||||
return {
|
||||
type: 'confirm_shell_commands',
|
||||
commandsToConfirm: e.commandsToConfirm,
|
||||
originalInvocation: {
|
||||
raw: context.invocation.raw,
|
||||
},
|
||||
};
|
||||
}
|
||||
// Re-throw other errors to be handled by the global error handler.
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
231
packages/cli/src/services/McpPromptLoader.ts
Normal file
231
packages/cli/src/services/McpPromptLoader.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
Config,
|
||||
getErrorMessage,
|
||||
getMCPServerPrompts,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
CommandContext,
|
||||
CommandKind,
|
||||
SlashCommand,
|
||||
SlashCommandActionReturn,
|
||||
} from '../ui/commands/types.js';
|
||||
import { ICommandLoader } from './types.js';
|
||||
import { PromptArgument } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
/**
|
||||
* Discovers and loads executable slash commands from prompts exposed by
|
||||
* Model-Context-Protocol (MCP) servers.
|
||||
*/
|
||||
export class McpPromptLoader implements ICommandLoader {
|
||||
constructor(private readonly config: Config | null) {}
|
||||
|
||||
/**
|
||||
* Loads all available prompts from all configured MCP servers and adapts
|
||||
* them into executable SlashCommand objects.
|
||||
*
|
||||
* @param _signal An AbortSignal (unused for this synchronous loader).
|
||||
* @returns A promise that resolves to an array of loaded SlashCommands.
|
||||
*/
|
||||
loadCommands(_signal: AbortSignal): Promise<SlashCommand[]> {
|
||||
const promptCommands: SlashCommand[] = [];
|
||||
if (!this.config) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
const mcpServers = this.config.getMcpServers() || {};
|
||||
for (const serverName in mcpServers) {
|
||||
const prompts = getMCPServerPrompts(this.config, serverName) || [];
|
||||
for (const prompt of prompts) {
|
||||
const commandName = `${prompt.name}`;
|
||||
const newPromptCommand: SlashCommand = {
|
||||
name: commandName,
|
||||
description: prompt.description || `Invoke prompt ${prompt.name}`,
|
||||
kind: CommandKind.MCP_PROMPT,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'help',
|
||||
description: 'Show help for this prompt',
|
||||
kind: CommandKind.MCP_PROMPT,
|
||||
action: async (): Promise<SlashCommandActionReturn> => {
|
||||
if (!prompt.arguments || prompt.arguments.length === 0) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Prompt "${prompt.name}" has no arguments.`,
|
||||
};
|
||||
}
|
||||
|
||||
let helpMessage = `Arguments for "${prompt.name}":\n\n`;
|
||||
if (prompt.arguments && prompt.arguments.length > 0) {
|
||||
helpMessage += `You can provide arguments by name (e.g., --argName="value") or by position.\n\n`;
|
||||
helpMessage += `e.g., ${prompt.name} ${prompt.arguments?.map((_) => `"foo"`)} is equivalent to ${prompt.name} ${prompt.arguments?.map((arg) => `--${arg.name}="foo"`)}\n\n`;
|
||||
}
|
||||
for (const arg of prompt.arguments) {
|
||||
helpMessage += ` --${arg.name}\n`;
|
||||
if (arg.description) {
|
||||
helpMessage += ` ${arg.description}\n`;
|
||||
}
|
||||
helpMessage += ` (required: ${
|
||||
arg.required ? 'yes' : 'no'
|
||||
})\n\n`;
|
||||
}
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: helpMessage,
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<SlashCommandActionReturn> => {
|
||||
if (!this.config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
};
|
||||
}
|
||||
|
||||
const promptInputs = this.parseArgs(args, prompt.arguments);
|
||||
if (promptInputs instanceof Error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: promptInputs.message,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const mcpServers = this.config.getMcpServers() || {};
|
||||
const mcpServerConfig = mcpServers[serverName];
|
||||
if (!mcpServerConfig) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `MCP server config not found for '${serverName}'.`,
|
||||
};
|
||||
}
|
||||
const result = await prompt.invoke(promptInputs);
|
||||
|
||||
if (result.error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Error invoking prompt: ${result.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!result.messages?.[0]?.content?.text) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content:
|
||||
'Received an empty or invalid prompt response from the server.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'submit_prompt',
|
||||
content: JSON.stringify(result.messages[0].content.text),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Error: ${getErrorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
completion: async (_: CommandContext, partialArg: string) => {
|
||||
if (!prompt || !prompt.arguments) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const suggestions: string[] = [];
|
||||
const usedArgNames = new Set(
|
||||
(partialArg.match(/--([^=]+)/g) || []).map((s) => s.substring(2)),
|
||||
);
|
||||
|
||||
for (const arg of prompt.arguments) {
|
||||
if (!usedArgNames.has(arg.name)) {
|
||||
suggestions.push(`--${arg.name}=""`);
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
},
|
||||
};
|
||||
promptCommands.push(newPromptCommand);
|
||||
}
|
||||
}
|
||||
return Promise.resolve(promptCommands);
|
||||
}
|
||||
|
||||
private parseArgs(
|
||||
userArgs: string,
|
||||
promptArgs: PromptArgument[] | undefined,
|
||||
): Record<string, unknown> | Error {
|
||||
const argValues: { [key: string]: string } = {};
|
||||
const promptInputs: Record<string, unknown> = {};
|
||||
|
||||
// arg parsing: --key="value" or --key=value
|
||||
const namedArgRegex = /--([^=]+)=(?:"((?:\\.|[^"\\])*)"|([^ ]*))/g;
|
||||
let match;
|
||||
const remainingArgs: string[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
while ((match = namedArgRegex.exec(userArgs)) !== null) {
|
||||
const key = match[1];
|
||||
const value = match[2] ?? match[3]; // Quoted or unquoted value
|
||||
argValues[key] = value;
|
||||
// Capture text between matches as potential positional args
|
||||
if (match.index > lastIndex) {
|
||||
remainingArgs.push(userArgs.substring(lastIndex, match.index).trim());
|
||||
}
|
||||
lastIndex = namedArgRegex.lastIndex;
|
||||
}
|
||||
|
||||
// Capture any remaining text after the last named arg
|
||||
if (lastIndex < userArgs.length) {
|
||||
remainingArgs.push(userArgs.substring(lastIndex).trim());
|
||||
}
|
||||
|
||||
const positionalArgs = remainingArgs.join(' ').split(/ +/);
|
||||
|
||||
if (!promptArgs) {
|
||||
return promptInputs;
|
||||
}
|
||||
for (const arg of promptArgs) {
|
||||
if (argValues[arg.name]) {
|
||||
promptInputs[arg.name] = argValues[arg.name];
|
||||
}
|
||||
}
|
||||
|
||||
const unfilledArgs = promptArgs.filter(
|
||||
(arg) => arg.required && !promptInputs[arg.name],
|
||||
);
|
||||
|
||||
const missingArgs: string[] = [];
|
||||
for (let i = 0; i < unfilledArgs.length; i++) {
|
||||
if (positionalArgs.length > i && positionalArgs[i]) {
|
||||
promptInputs[unfilledArgs[i].name] = positionalArgs[i];
|
||||
} else {
|
||||
missingArgs.push(unfilledArgs[i].name);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingArgs.length > 0) {
|
||||
const missingArgNames = missingArgs.map((name) => `--${name}`).join(', ');
|
||||
return new Error(`Missing required argument(s): ${missingArgNames}`);
|
||||
}
|
||||
return promptInputs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
DefaultArgumentProcessor,
|
||||
ShorthandArgumentProcessor,
|
||||
} from './argumentProcessor.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
describe('Argument Processors', () => {
|
||||
describe('ShorthandArgumentProcessor', () => {
|
||||
const processor = new ShorthandArgumentProcessor();
|
||||
|
||||
it('should replace a single {{args}} instance', async () => {
|
||||
const prompt = 'Refactor the following code: {{args}}';
|
||||
const context = createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/refactor make it faster',
|
||||
name: 'refactor',
|
||||
args: 'make it faster',
|
||||
},
|
||||
});
|
||||
const result = await processor.process(prompt, context);
|
||||
expect(result).toBe('Refactor the following code: make it faster');
|
||||
});
|
||||
|
||||
it('should replace multiple {{args}} instances', async () => {
|
||||
const prompt = 'User said: {{args}}. I repeat: {{args}}!';
|
||||
const context = createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/repeat hello world',
|
||||
name: 'repeat',
|
||||
args: 'hello world',
|
||||
},
|
||||
});
|
||||
const result = await processor.process(prompt, context);
|
||||
expect(result).toBe('User said: hello world. I repeat: hello world!');
|
||||
});
|
||||
|
||||
it('should handle an empty args string', async () => {
|
||||
const prompt = 'The user provided no input: {{args}}.';
|
||||
const context = createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/input',
|
||||
name: 'input',
|
||||
args: '',
|
||||
},
|
||||
});
|
||||
const result = await processor.process(prompt, context);
|
||||
expect(result).toBe('The user provided no input: .');
|
||||
});
|
||||
|
||||
it('should not change the prompt if {{args}} is not present', async () => {
|
||||
const prompt = 'This is a static prompt.';
|
||||
const context = createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/static some arguments',
|
||||
name: 'static',
|
||||
args: 'some arguments',
|
||||
},
|
||||
});
|
||||
const result = await processor.process(prompt, context);
|
||||
expect(result).toBe('This is a static prompt.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DefaultArgumentProcessor', () => {
|
||||
const processor = new DefaultArgumentProcessor();
|
||||
|
||||
it('should append the full command if args are provided', async () => {
|
||||
const prompt = 'Parse the command.';
|
||||
const context = createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/mycommand arg1 "arg two"',
|
||||
name: 'mycommand',
|
||||
args: 'arg1 "arg two"',
|
||||
},
|
||||
});
|
||||
const result = await processor.process(prompt, context);
|
||||
expect(result).toBe('Parse the command.\n\n/mycommand arg1 "arg two"');
|
||||
});
|
||||
|
||||
it('should NOT append the full command if no args are provided', async () => {
|
||||
const prompt = 'Parse the command.';
|
||||
const context = createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/mycommand',
|
||||
name: 'mycommand',
|
||||
args: '',
|
||||
},
|
||||
});
|
||||
const result = await processor.process(prompt, context);
|
||||
expect(result).toBe('Parse the command.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { IPromptProcessor, SHORTHAND_ARGS_PLACEHOLDER } from './types.js';
|
||||
import { CommandContext } from '../../ui/commands/types.js';
|
||||
|
||||
/**
|
||||
* Replaces all instances of `{{args}}` in a prompt with the user-provided
|
||||
* argument string.
|
||||
*/
|
||||
export class ShorthandArgumentProcessor implements IPromptProcessor {
|
||||
async process(prompt: string, context: CommandContext): Promise<string> {
|
||||
return prompt.replaceAll(
|
||||
SHORTHAND_ARGS_PLACEHOLDER,
|
||||
context.invocation!.args,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the user's full command invocation to the prompt if arguments are
|
||||
* provided, allowing the model to perform its own argument parsing.
|
||||
*/
|
||||
export class DefaultArgumentProcessor implements IPromptProcessor {
|
||||
async process(prompt: string, context: CommandContext): Promise<string> {
|
||||
if (context.invocation!.args) {
|
||||
return `${prompt}\n\n${context.invocation!.raw}`;
|
||||
}
|
||||
return prompt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ConfirmationRequiredError, ShellProcessor } from './shellProcessor.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { CommandContext } from '../../ui/commands/types.js';
|
||||
import { Config } from '@qwen-code/qwen-code-core';
|
||||
|
||||
const mockCheckCommandPermissions = vi.hoisted(() => vi.fn());
|
||||
const mockShellExecute = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const original = await importOriginal<object>();
|
||||
return {
|
||||
...original,
|
||||
checkCommandPermissions: mockCheckCommandPermissions,
|
||||
ShellExecutionService: {
|
||||
execute: mockShellExecute,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('ShellProcessor', () => {
|
||||
let context: CommandContext;
|
||||
let mockConfig: Partial<Config>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockConfig = {
|
||||
getTargetDir: vi.fn().mockReturnValue('/test/dir'),
|
||||
};
|
||||
|
||||
context = createMockCommandContext({
|
||||
services: {
|
||||
config: mockConfig as Config,
|
||||
},
|
||||
session: {
|
||||
sessionShellAllowlist: new Set(),
|
||||
},
|
||||
});
|
||||
|
||||
mockShellExecute.mockReturnValue({
|
||||
result: Promise.resolve({
|
||||
output: 'default shell output',
|
||||
}),
|
||||
});
|
||||
mockCheckCommandPermissions.mockReturnValue({
|
||||
allAllowed: true,
|
||||
disallowedCommands: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not change the prompt if no shell injections are present', async () => {
|
||||
const processor = new ShellProcessor('test-command');
|
||||
const prompt = 'This is a simple prompt with no injections.';
|
||||
const result = await processor.process(prompt, context);
|
||||
expect(result).toBe(prompt);
|
||||
expect(mockShellExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should process a single valid shell injection if allowed', async () => {
|
||||
const processor = new ShellProcessor('test-command');
|
||||
const prompt = 'The current status is: !{git status}';
|
||||
mockCheckCommandPermissions.mockReturnValue({
|
||||
allAllowed: true,
|
||||
disallowedCommands: [],
|
||||
});
|
||||
mockShellExecute.mockReturnValue({
|
||||
result: Promise.resolve({ output: 'On branch main' }),
|
||||
});
|
||||
|
||||
const result = await processor.process(prompt, context);
|
||||
|
||||
expect(mockCheckCommandPermissions).toHaveBeenCalledWith(
|
||||
'git status',
|
||||
expect.any(Object),
|
||||
context.session.sessionShellAllowlist,
|
||||
);
|
||||
expect(mockShellExecute).toHaveBeenCalledWith(
|
||||
'git status',
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(result).toBe('The current status is: On branch main');
|
||||
});
|
||||
|
||||
it('should process multiple valid shell injections if all are allowed', async () => {
|
||||
const processor = new ShellProcessor('test-command');
|
||||
const prompt = '!{git status} in !{pwd}';
|
||||
mockCheckCommandPermissions.mockReturnValue({
|
||||
allAllowed: true,
|
||||
disallowedCommands: [],
|
||||
});
|
||||
|
||||
mockShellExecute
|
||||
.mockReturnValueOnce({
|
||||
result: Promise.resolve({ output: 'On branch main' }),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
result: Promise.resolve({ output: '/usr/home' }),
|
||||
});
|
||||
|
||||
const result = await processor.process(prompt, context);
|
||||
|
||||
expect(mockCheckCommandPermissions).toHaveBeenCalledTimes(2);
|
||||
expect(mockShellExecute).toHaveBeenCalledTimes(2);
|
||||
expect(result).toBe('On branch main in /usr/home');
|
||||
});
|
||||
|
||||
it('should throw ConfirmationRequiredError if a command is not allowed', async () => {
|
||||
const processor = new ShellProcessor('test-command');
|
||||
const prompt = 'Do something dangerous: !{rm -rf /}';
|
||||
mockCheckCommandPermissions.mockReturnValue({
|
||||
allAllowed: false,
|
||||
disallowedCommands: ['rm -rf /'],
|
||||
});
|
||||
|
||||
await expect(processor.process(prompt, context)).rejects.toThrow(
|
||||
ConfirmationRequiredError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ConfirmationRequiredError with the correct command', async () => {
|
||||
const processor = new ShellProcessor('test-command');
|
||||
const prompt = 'Do something dangerous: !{rm -rf /}';
|
||||
mockCheckCommandPermissions.mockReturnValue({
|
||||
allAllowed: false,
|
||||
disallowedCommands: ['rm -rf /'],
|
||||
});
|
||||
|
||||
try {
|
||||
await processor.process(prompt, context);
|
||||
// Fail if it doesn't throw
|
||||
expect(true).toBe(false);
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ConfirmationRequiredError);
|
||||
if (e instanceof ConfirmationRequiredError) {
|
||||
expect(e.commandsToConfirm).toEqual(['rm -rf /']);
|
||||
}
|
||||
}
|
||||
|
||||
expect(mockShellExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw ConfirmationRequiredError with multiple commands if multiple are disallowed', async () => {
|
||||
const processor = new ShellProcessor('test-command');
|
||||
const prompt = '!{cmd1} and !{cmd2}';
|
||||
mockCheckCommandPermissions.mockImplementation((cmd) => {
|
||||
if (cmd === 'cmd1') {
|
||||
return { allAllowed: false, disallowedCommands: ['cmd1'] };
|
||||
}
|
||||
if (cmd === 'cmd2') {
|
||||
return { allAllowed: false, disallowedCommands: ['cmd2'] };
|
||||
}
|
||||
return { allAllowed: true, disallowedCommands: [] };
|
||||
});
|
||||
|
||||
try {
|
||||
await processor.process(prompt, context);
|
||||
// Fail if it doesn't throw
|
||||
expect(true).toBe(false);
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ConfirmationRequiredError);
|
||||
if (e instanceof ConfirmationRequiredError) {
|
||||
expect(e.commandsToConfirm).toEqual(['cmd1', 'cmd2']);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should not execute any commands if at least one requires confirmation', async () => {
|
||||
const processor = new ShellProcessor('test-command');
|
||||
const prompt = 'First: !{echo "hello"}, Second: !{rm -rf /}';
|
||||
|
||||
mockCheckCommandPermissions.mockImplementation((cmd) => {
|
||||
if (cmd.includes('rm')) {
|
||||
return { allAllowed: false, disallowedCommands: [cmd] };
|
||||
}
|
||||
return { allAllowed: true, disallowedCommands: [] };
|
||||
});
|
||||
|
||||
await expect(processor.process(prompt, context)).rejects.toThrow(
|
||||
ConfirmationRequiredError,
|
||||
);
|
||||
|
||||
// Ensure no commands were executed because the pipeline was halted.
|
||||
expect(mockShellExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should only request confirmation for disallowed commands in a mixed prompt', async () => {
|
||||
const processor = new ShellProcessor('test-command');
|
||||
const prompt = 'Allowed: !{ls -l}, Disallowed: !{rm -rf /}';
|
||||
|
||||
mockCheckCommandPermissions.mockImplementation((cmd) => ({
|
||||
allAllowed: !cmd.includes('rm'),
|
||||
disallowedCommands: cmd.includes('rm') ? [cmd] : [],
|
||||
}));
|
||||
|
||||
try {
|
||||
await processor.process(prompt, context);
|
||||
expect.fail('Should have thrown ConfirmationRequiredError');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ConfirmationRequiredError);
|
||||
if (e instanceof ConfirmationRequiredError) {
|
||||
expect(e.commandsToConfirm).toEqual(['rm -rf /']);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should execute all commands if they are on the session allowlist', async () => {
|
||||
const processor = new ShellProcessor('test-command');
|
||||
const prompt = 'Run !{cmd1} and !{cmd2}';
|
||||
|
||||
// Add commands to the session allowlist
|
||||
context.session.sessionShellAllowlist = new Set(['cmd1', 'cmd2']);
|
||||
|
||||
// checkCommandPermissions should now pass for these
|
||||
mockCheckCommandPermissions.mockReturnValue({
|
||||
allAllowed: true,
|
||||
disallowedCommands: [],
|
||||
});
|
||||
|
||||
mockShellExecute
|
||||
.mockReturnValueOnce({ result: Promise.resolve({ output: 'output1' }) })
|
||||
.mockReturnValueOnce({ result: Promise.resolve({ output: 'output2' }) });
|
||||
|
||||
const result = await processor.process(prompt, context);
|
||||
|
||||
expect(mockCheckCommandPermissions).toHaveBeenCalledWith(
|
||||
'cmd1',
|
||||
expect.any(Object),
|
||||
context.session.sessionShellAllowlist,
|
||||
);
|
||||
expect(mockCheckCommandPermissions).toHaveBeenCalledWith(
|
||||
'cmd2',
|
||||
expect.any(Object),
|
||||
context.session.sessionShellAllowlist,
|
||||
);
|
||||
expect(mockShellExecute).toHaveBeenCalledTimes(2);
|
||||
expect(result).toBe('Run output1 and output2');
|
||||
});
|
||||
|
||||
it('should trim whitespace from the command inside the injection', async () => {
|
||||
const processor = new ShellProcessor('test-command');
|
||||
const prompt = 'Files: !{ ls -l }';
|
||||
mockCheckCommandPermissions.mockReturnValue({
|
||||
allAllowed: true,
|
||||
disallowedCommands: [],
|
||||
});
|
||||
mockShellExecute.mockReturnValue({
|
||||
result: Promise.resolve({ output: 'total 0' }),
|
||||
});
|
||||
|
||||
await processor.process(prompt, context);
|
||||
|
||||
expect(mockCheckCommandPermissions).toHaveBeenCalledWith(
|
||||
'ls -l', // Verifies that the command was trimmed
|
||||
expect.any(Object),
|
||||
context.session.sessionShellAllowlist,
|
||||
);
|
||||
expect(mockShellExecute).toHaveBeenCalledWith(
|
||||
'ls -l',
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle an empty command inside the injection gracefully', async () => {
|
||||
const processor = new ShellProcessor('test-command');
|
||||
const prompt = 'This is weird: !{}';
|
||||
mockCheckCommandPermissions.mockReturnValue({
|
||||
allAllowed: true,
|
||||
disallowedCommands: [],
|
||||
});
|
||||
mockShellExecute.mockReturnValue({
|
||||
result: Promise.resolve({ output: 'empty output' }),
|
||||
});
|
||||
|
||||
const result = await processor.process(prompt, context);
|
||||
|
||||
expect(mockCheckCommandPermissions).toHaveBeenCalledWith(
|
||||
'',
|
||||
expect.any(Object),
|
||||
context.session.sessionShellAllowlist,
|
||||
);
|
||||
expect(mockShellExecute).toHaveBeenCalledWith(
|
||||
'',
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(result).toBe('This is weird: empty output');
|
||||
});
|
||||
});
|
||||
106
packages/cli/src/services/prompt-processors/shellProcessor.ts
Normal file
106
packages/cli/src/services/prompt-processors/shellProcessor.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
checkCommandPermissions,
|
||||
ShellExecutionService,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
import { CommandContext } from '../../ui/commands/types.js';
|
||||
import { IPromptProcessor } from './types.js';
|
||||
|
||||
export class ConfirmationRequiredError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public commandsToConfirm: string[],
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ConfirmationRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all instances of shell command injections (`!{...}`) in a prompt,
|
||||
* executes them, and replaces the injection site with the command's output.
|
||||
*
|
||||
* This processor ensures that only allowlisted commands are executed. If a
|
||||
* disallowed command is found, it halts execution and reports an error.
|
||||
*/
|
||||
export class ShellProcessor implements IPromptProcessor {
|
||||
/**
|
||||
* A regular expression to find all instances of `!{...}`. The inner
|
||||
* capture group extracts the command itself.
|
||||
*/
|
||||
private static readonly SHELL_INJECTION_REGEX = /!\{([^}]*)\}/g;
|
||||
|
||||
/**
|
||||
* @param commandName The name of the custom command being executed, used
|
||||
* for logging and error messages.
|
||||
*/
|
||||
constructor(private readonly commandName: string) {}
|
||||
|
||||
async process(prompt: string, context: CommandContext): Promise<string> {
|
||||
const { config, sessionShellAllowlist } = {
|
||||
...context.services,
|
||||
...context.session,
|
||||
};
|
||||
const commandsToExecute: Array<{ fullMatch: string; command: string }> = [];
|
||||
const commandsToConfirm = new Set<string>();
|
||||
|
||||
const matches = [...prompt.matchAll(ShellProcessor.SHELL_INJECTION_REGEX)];
|
||||
if (matches.length === 0) {
|
||||
return prompt; // No shell commands, nothing to do.
|
||||
}
|
||||
|
||||
// Discover all commands and check permissions.
|
||||
for (const match of matches) {
|
||||
const command = match[1].trim();
|
||||
const { allAllowed, disallowedCommands, blockReason, isHardDenial } =
|
||||
checkCommandPermissions(command, config!, sessionShellAllowlist);
|
||||
|
||||
if (!allAllowed) {
|
||||
// If it's a hard denial, this is a non-recoverable security error.
|
||||
if (isHardDenial) {
|
||||
throw new Error(
|
||||
`${this.commandName} cannot be run. ${blockReason || 'A shell command in this custom command is explicitly blocked in your config settings.'}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Add each soft denial disallowed command to the set for confirmation.
|
||||
disallowedCommands.forEach((uc) => commandsToConfirm.add(uc));
|
||||
}
|
||||
commandsToExecute.push({ fullMatch: match[0], command });
|
||||
}
|
||||
|
||||
// If any commands require confirmation, throw a special error to halt the
|
||||
// pipeline and trigger the UI flow.
|
||||
if (commandsToConfirm.size > 0) {
|
||||
throw new ConfirmationRequiredError(
|
||||
'Shell command confirmation required',
|
||||
Array.from(commandsToConfirm),
|
||||
);
|
||||
}
|
||||
|
||||
// Execute all commands (only runs if no confirmation was needed).
|
||||
let processedPrompt = prompt;
|
||||
for (const { fullMatch, command } of commandsToExecute) {
|
||||
const { result } = ShellExecutionService.execute(
|
||||
command,
|
||||
config!.getTargetDir(),
|
||||
() => {}, // No streaming needed.
|
||||
new AbortController().signal, // For now, we don't support cancellation from here.
|
||||
);
|
||||
|
||||
const executionResult = await result;
|
||||
processedPrompt = processedPrompt.replace(
|
||||
fullMatch,
|
||||
executionResult.output,
|
||||
);
|
||||
}
|
||||
|
||||
return processedPrompt;
|
||||
}
|
||||
}
|
||||
42
packages/cli/src/services/prompt-processors/types.ts
Normal file
42
packages/cli/src/services/prompt-processors/types.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandContext } from '../../ui/commands/types.js';
|
||||
|
||||
/**
|
||||
* Defines the interface for a prompt processor, a module that can transform
|
||||
* a prompt string before it is sent to the model. Processors are chained
|
||||
* together to create a processing pipeline.
|
||||
*/
|
||||
export interface IPromptProcessor {
|
||||
/**
|
||||
* Processes a prompt string, applying a specific transformation as part of a pipeline.
|
||||
*
|
||||
* Each processor in a command's pipeline receives the output of the previous
|
||||
* processor. This method provides the full command context, allowing for
|
||||
* complex transformations that may require access to invocation details,
|
||||
* application services, or UI state.
|
||||
*
|
||||
* @param prompt The current state of the prompt string. This may have been
|
||||
* modified by previous processors in the pipeline.
|
||||
* @param context The full command context, providing access to invocation
|
||||
* details (like `context.invocation.raw` and `context.invocation.args`),
|
||||
* application services, and UI handlers.
|
||||
* @returns A promise that resolves to the transformed prompt string, which
|
||||
* will be passed to the next processor or, if it's the last one, sent to the model.
|
||||
*/
|
||||
process(prompt: string, context: CommandContext): Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The placeholder string for shorthand argument injection in custom commands.
|
||||
*/
|
||||
export const SHORTHAND_ARGS_PLACEHOLDER = '{{args}}';
|
||||
|
||||
/**
|
||||
* The trigger string for shell command injection in custom commands.
|
||||
*/
|
||||
export const SHELL_INJECTION_TRIGGER = '!{';
|
||||
24
packages/cli/src/services/types.ts
Normal file
24
packages/cli/src/services/types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { SlashCommand } from '../ui/commands/types.js';
|
||||
|
||||
/**
|
||||
* Defines the contract for any class that can load and provide slash commands.
|
||||
* This allows the CommandService to be extended with new command sources
|
||||
* (e.g., file-based, remote APIs) without modification.
|
||||
*
|
||||
* Loaders should receive any necessary dependencies (like Config) via their
|
||||
* constructor.
|
||||
*/
|
||||
export interface ICommandLoader {
|
||||
/**
|
||||
* Discovers and returns a list of slash commands from the loader's source.
|
||||
* @param signal An AbortSignal to allow cancellation.
|
||||
* @returns A promise that resolves to an array of SlashCommand objects.
|
||||
*/
|
||||
loadCommands(signal: AbortSignal): Promise<SlashCommand[]>;
|
||||
}
|
||||
63
packages/cli/src/test-utils/customMatchers.ts
Normal file
63
packages/cli/src/test-utils/customMatchers.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/// <reference types="vitest/globals" />
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { expect } from 'vitest';
|
||||
import type { TextBuffer } from '../ui/components/shared/text-buffer.js';
|
||||
|
||||
// RegExp to detect invalid characters: backspace, and ANSI escape codes
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const invalidCharsRegex = /[\b\x1b]/;
|
||||
|
||||
function toHaveOnlyValidCharacters(this: vi.Assertion, buffer: TextBuffer) {
|
||||
const { isNot } = this;
|
||||
let pass = true;
|
||||
const invalidLines: Array<{ line: number; content: string }> = [];
|
||||
|
||||
for (let i = 0; i < buffer.lines.length; i++) {
|
||||
const line = buffer.lines[i];
|
||||
if (line.includes('\n')) {
|
||||
pass = false;
|
||||
invalidLines.push({ line: i, content: line });
|
||||
break; // Fail fast on newlines
|
||||
}
|
||||
if (invalidCharsRegex.test(line)) {
|
||||
pass = false;
|
||||
invalidLines.push({ line: i, content: line });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pass,
|
||||
message: () =>
|
||||
`Expected buffer ${isNot ? 'not ' : ''}to have only valid characters, but found invalid characters in lines:\n${invalidLines
|
||||
.map((l) => ` [${l.line}]: "${l.content}"`) /* This line was changed */
|
||||
.join('\n')}`,
|
||||
actual: buffer.lines,
|
||||
expected: 'Lines with no line breaks, backspaces, or escape codes.',
|
||||
};
|
||||
}
|
||||
|
||||
expect.extend({
|
||||
toHaveOnlyValidCharacters,
|
||||
});
|
||||
|
||||
// Extend Vitest's `expect` interface with the custom matcher's type definition.
|
||||
declare module 'vitest' {
|
||||
interface Assertion<T> {
|
||||
toHaveOnlyValidCharacters(): T;
|
||||
}
|
||||
interface AsymmetricMatchersContaining {
|
||||
toHaveOnlyValidCharacters(): void;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
import { vi } from 'vitest';
|
||||
import { CommandContext } from '../ui/commands/types.js';
|
||||
import { LoadedSettings } from '../config/settings.js';
|
||||
import { GitService } from '@google/gemini-cli-core';
|
||||
import { GitService } from '@qwen-code/qwen-code-core';
|
||||
import { SessionStatsState } from '../ui/contexts/SessionContext.js';
|
||||
|
||||
// A utility type to make all properties of an object, and its nested objects, partial.
|
||||
@@ -28,6 +28,11 @@ export const createMockCommandContext = (
|
||||
overrides: DeepPartial<CommandContext> = {},
|
||||
): CommandContext => {
|
||||
const defaultMocks: CommandContext = {
|
||||
invocation: {
|
||||
raw: '',
|
||||
name: '',
|
||||
args: '',
|
||||
},
|
||||
services: {
|
||||
config: null,
|
||||
settings: { merged: {} } as LoadedSettings,
|
||||
@@ -44,8 +49,14 @@ export const createMockCommandContext = (
|
||||
addItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
setDebugMessage: vi.fn(),
|
||||
pendingItem: null,
|
||||
setPendingItem: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
toggleCorgiMode: vi.fn(),
|
||||
toggleVimEnabled: vi.fn(),
|
||||
},
|
||||
session: {
|
||||
sessionShellAllowlist: new Set<string>(),
|
||||
stats: {
|
||||
sessionStartTime: new Date(),
|
||||
lastPromptTokenCount: 0,
|
||||
@@ -76,15 +87,13 @@ export const createMockCommandContext = (
|
||||
const targetValue = output[key];
|
||||
|
||||
if (
|
||||
sourceValue &&
|
||||
typeof sourceValue === 'object' &&
|
||||
!Array.isArray(sourceValue) &&
|
||||
targetValue &&
|
||||
typeof targetValue === 'object' &&
|
||||
!Array.isArray(targetValue)
|
||||
// We only want to recursivlty merge plain objects
|
||||
Object.prototype.toString.call(sourceValue) === '[object Object]' &&
|
||||
Object.prototype.toString.call(targetValue) === '[object Object]'
|
||||
) {
|
||||
output[key] = merge(targetValue, sourceValue);
|
||||
} else {
|
||||
// If not, we do a direct assignment. This preserves Date objects and others.
|
||||
output[key] = sourceValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,12 +15,18 @@ import {
|
||||
AccessibilitySettings,
|
||||
SandboxConfig,
|
||||
GeminiClient,
|
||||
ideContext,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { LoadedSettings, SettingsFile, Settings } from '../config/settings.js';
|
||||
import process from 'node:process';
|
||||
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||
import { StreamingState } from './types.js';
|
||||
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
||||
import { StreamingState, ConsoleMessageItem } from './types.js';
|
||||
import { Tips } from './components/Tips.js';
|
||||
import { checkForUpdates, UpdateObject } from './utils/updateCheck.js';
|
||||
import { EventEmitter } from 'events';
|
||||
import { updateEventEmitter } from '../utils/updateEventEmitter.js';
|
||||
import * as auth from '../config/auth.js';
|
||||
|
||||
// Define a more complete mock server config based on actual Config
|
||||
interface MockServerConfig {
|
||||
@@ -58,6 +64,12 @@ interface MockServerConfig {
|
||||
getToolCallCommand: Mock<() => string | undefined>;
|
||||
getMcpServerCommand: Mock<() => string | undefined>;
|
||||
getMcpServers: Mock<() => Record<string, MCPServerConfig> | undefined>;
|
||||
getExtensions: Mock<
|
||||
() => Array<{ name: string; version: string; isActive: boolean }>
|
||||
>;
|
||||
getBlockedMcpServers: Mock<
|
||||
() => Array<{ name: string; extensionName: string }>
|
||||
>;
|
||||
getUserAgent: Mock<() => string>;
|
||||
getUserMemory: Mock<() => string>;
|
||||
setUserMemory: Mock<(newUserMemory: string) => void>;
|
||||
@@ -118,6 +130,9 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
getToolCallCommand: vi.fn(() => opts.toolCallCommand),
|
||||
getMcpServerCommand: vi.fn(() => opts.mcpServerCommand),
|
||||
getMcpServers: vi.fn(() => opts.mcpServers),
|
||||
getPromptRegistry: vi.fn(),
|
||||
getExtensions: vi.fn(() => []),
|
||||
getBlockedMcpServers: vi.fn(() => []),
|
||||
getUserAgent: vi.fn(() => opts.userAgent || 'test-agent'),
|
||||
getUserMemory: vi.fn(() => opts.userMemory || ''),
|
||||
setUserMemory: vi.fn(),
|
||||
@@ -129,19 +144,34 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
getShowMemoryUsage: vi.fn(() => opts.showMemoryUsage ?? false),
|
||||
getAccessibility: vi.fn(() => opts.accessibility ?? {}),
|
||||
getProjectRoot: vi.fn(() => opts.targetDir),
|
||||
getGeminiClient: vi.fn(() => ({})),
|
||||
getGeminiClient: vi.fn(() => ({
|
||||
getUserTier: vi.fn(),
|
||||
})),
|
||||
getCheckpointingEnabled: vi.fn(() => opts.checkpointing ?? true),
|
||||
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
|
||||
getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md']),
|
||||
setFlashFallbackHandler: vi.fn(),
|
||||
getSessionId: vi.fn(() => 'test-session-id'),
|
||||
getUserTier: vi.fn().mockResolvedValue(undefined),
|
||||
getIdeModeFeature: vi.fn(() => false),
|
||||
getIdeMode: vi.fn(() => false),
|
||||
getWorkspaceContext: vi.fn(() => ({
|
||||
getDirectories: vi.fn(() => []),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const ideContextMock = {
|
||||
getIdeContext: vi.fn(),
|
||||
subscribeToIdeContext: vi.fn(() => vi.fn()), // subscribe returns an unsubscribe function
|
||||
};
|
||||
|
||||
return {
|
||||
...actualCore,
|
||||
Config: ConfigClassMock,
|
||||
MCPServerConfig: actualCore.MCPServerConfig,
|
||||
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
|
||||
getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md']),
|
||||
ideContext: ideContextMock,
|
||||
isGitRepository: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -172,6 +202,14 @@ vi.mock('./hooks/useLogger', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('./hooks/useConsoleMessages.js', () => ({
|
||||
useConsoleMessages: vi.fn(() => ({
|
||||
consoleMessages: [],
|
||||
handleNewMessage: vi.fn(),
|
||||
clearConsoleMessages: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../config/config.js', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
@@ -191,6 +229,21 @@ vi.mock('./components/Header.js', () => ({
|
||||
Header: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock('./utils/updateCheck.js', () => ({
|
||||
checkForUpdates: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./config/auth.js', () => ({
|
||||
validateAuthMethod: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedCheckForUpdates = vi.mocked(checkForUpdates);
|
||||
const { isGitRepository: mockedIsGitRepository } = vi.mocked(
|
||||
await import('@qwen-code/qwen-code-core'),
|
||||
);
|
||||
|
||||
vi.mock('node:child_process');
|
||||
|
||||
describe('App UI', () => {
|
||||
let mockConfig: MockServerConfig;
|
||||
let mockSettings: LoadedSettings;
|
||||
@@ -213,7 +266,7 @@ describe('App UI', () => {
|
||||
settings: settings.user || {},
|
||||
};
|
||||
const workspaceSettingsFile: SettingsFile = {
|
||||
path: '/workspace/.qwen/settings.json',
|
||||
path: '/workspace/.gemini/settings.json',
|
||||
settings: settings.workspace || {},
|
||||
};
|
||||
return new LoadedSettings(
|
||||
@@ -248,6 +301,14 @@ describe('App UI', () => {
|
||||
|
||||
// Ensure a theme is set so the theme dialog does not appear.
|
||||
mockSettings = createMockSettings({ workspace: { theme: 'Default' } });
|
||||
|
||||
// Ensure getWorkspaceContext is available if not added by the constructor
|
||||
if (!mockConfig.getWorkspaceContext) {
|
||||
mockConfig.getWorkspaceContext = vi.fn(() => ({
|
||||
getDirectories: vi.fn(() => ['/test/dir']),
|
||||
}));
|
||||
}
|
||||
vi.mocked(ideContext.getIdeContext).mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -258,8 +319,283 @@ describe('App UI', () => {
|
||||
vi.clearAllMocks(); // Clear mocks after each test
|
||||
});
|
||||
|
||||
it('should display default "GEMINI.md" in footer when contextFileName is not set and count is 1', async () => {
|
||||
describe('handleAutoUpdate', () => {
|
||||
let spawnEmitter: EventEmitter;
|
||||
|
||||
beforeEach(async () => {
|
||||
const { spawn } = await import('node:child_process');
|
||||
spawnEmitter = new EventEmitter();
|
||||
spawnEmitter.stdout = new EventEmitter();
|
||||
spawnEmitter.stderr = new EventEmitter();
|
||||
(spawn as vi.Mock).mockReturnValue(spawnEmitter);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.GEMINI_CLI_DISABLE_AUTOUPDATER;
|
||||
});
|
||||
|
||||
it('should not start the update process when running from git', async () => {
|
||||
mockedIsGitRepository.mockResolvedValue(true);
|
||||
const info: UpdateObject = {
|
||||
update: {
|
||||
name: '@qwen-code/qwen-code',
|
||||
latest: '1.1.0',
|
||||
current: '1.0.0',
|
||||
},
|
||||
message: 'Qwen Code update available!',
|
||||
};
|
||||
mockedCheckForUpdates.mockResolvedValue(info);
|
||||
const { spawn } = await import('node:child_process');
|
||||
|
||||
const { unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show a success message when update succeeds', async () => {
|
||||
mockedIsGitRepository.mockResolvedValue(false);
|
||||
const info: UpdateObject = {
|
||||
update: {
|
||||
name: '@qwen-code/qwen-code',
|
||||
latest: '1.1.0',
|
||||
current: '1.0.0',
|
||||
},
|
||||
message: 'Update available',
|
||||
};
|
||||
mockedCheckForUpdates.mockResolvedValue(info);
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
|
||||
updateEventEmitter.emit('update-success', info);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(lastFrame()).toContain(
|
||||
'Update successful! The new version will be used on your next run.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should show an error message when update fails', async () => {
|
||||
mockedIsGitRepository.mockResolvedValue(false);
|
||||
const info: UpdateObject = {
|
||||
update: {
|
||||
name: '@qwen-code/qwen-code',
|
||||
latest: '1.1.0',
|
||||
current: '1.0.0',
|
||||
},
|
||||
message: 'Update available',
|
||||
};
|
||||
mockedCheckForUpdates.mockResolvedValue(info);
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
|
||||
updateEventEmitter.emit('update-failed', info);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(lastFrame()).toContain(
|
||||
'Automatic update failed. Please try updating manually',
|
||||
);
|
||||
});
|
||||
|
||||
it('should show an error message when spawn fails', async () => {
|
||||
mockedIsGitRepository.mockResolvedValue(false);
|
||||
const info: UpdateObject = {
|
||||
update: {
|
||||
name: '@qwen-code/qwen-code',
|
||||
latest: '1.1.0',
|
||||
current: '1.0.0',
|
||||
},
|
||||
message: 'Update available',
|
||||
};
|
||||
mockedCheckForUpdates.mockResolvedValue(info);
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
|
||||
// We are testing the App's reaction to an `update-failed` event,
|
||||
// which is what should be emitted when a spawn error occurs elsewhere.
|
||||
updateEventEmitter.emit('update-failed', info);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(lastFrame()).toContain(
|
||||
'Automatic update failed. Please try updating manually',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not auto-update if GEMINI_CLI_DISABLE_AUTOUPDATER is true', async () => {
|
||||
mockedIsGitRepository.mockResolvedValue(false);
|
||||
process.env.GEMINI_CLI_DISABLE_AUTOUPDATER = 'true';
|
||||
const info: UpdateObject = {
|
||||
update: {
|
||||
name: '@qwen-code/qwen-code',
|
||||
latest: '1.1.0',
|
||||
current: '1.0.0',
|
||||
},
|
||||
message: 'Update available',
|
||||
};
|
||||
mockedCheckForUpdates.mockResolvedValue(info);
|
||||
const { spawn } = await import('node:child_process');
|
||||
|
||||
const { unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display active file when available', async () => {
|
||||
vi.mocked(ideContext.getIdeContext).mockReturnValue({
|
||||
workspaceState: {
|
||||
openFiles: [
|
||||
{
|
||||
path: '/path/to/my-file.ts',
|
||||
isActive: true,
|
||||
selectedText: 'hello',
|
||||
timestamp: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain('1 open file (ctrl+e to view)');
|
||||
});
|
||||
|
||||
it('should not display any files when not available', async () => {
|
||||
vi.mocked(ideContext.getIdeContext).mockReturnValue({
|
||||
workspaceState: {
|
||||
openFiles: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).not.toContain('Open File');
|
||||
});
|
||||
|
||||
it('should display active file and other open files', async () => {
|
||||
vi.mocked(ideContext.getIdeContext).mockReturnValue({
|
||||
workspaceState: {
|
||||
openFiles: [
|
||||
{
|
||||
path: '/path/to/my-file.ts',
|
||||
isActive: true,
|
||||
selectedText: 'hello',
|
||||
timestamp: 0,
|
||||
},
|
||||
{
|
||||
path: '/path/to/another-file.ts',
|
||||
isActive: false,
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
path: '/path/to/third-file.ts',
|
||||
isActive: false,
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain('3 open files (ctrl+e to view)');
|
||||
});
|
||||
|
||||
it('should display active file and other context', async () => {
|
||||
vi.mocked(ideContext.getIdeContext).mockReturnValue({
|
||||
workspaceState: {
|
||||
openFiles: [
|
||||
{
|
||||
path: '/path/to/my-file.ts',
|
||||
isActive: true,
|
||||
selectedText: 'hello',
|
||||
timestamp: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md']);
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain(
|
||||
'Using: 1 open file (ctrl+e to view) | 1 QWEN.md file',
|
||||
);
|
||||
});
|
||||
|
||||
it('should display default "QWEN.md" in footer when contextFileName is not set and count is 1', async () => {
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md']);
|
||||
// For this test, ensure showMemoryUsage is false or debugMode is false if it relies on that
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false);
|
||||
@@ -273,11 +609,12 @@ describe('App UI', () => {
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve(); // Wait for any async updates
|
||||
expect(lastFrame()).toContain('Using 1 GEMINI.md file');
|
||||
expect(lastFrame()).toContain('Using: 1 QWEN.md file');
|
||||
});
|
||||
|
||||
it('should display default "GEMINI.md" with plural when contextFileName is not set and count is > 1', async () => {
|
||||
it('should display default "QWEN.md" with plural when contextFileName is not set and count is > 1', async () => {
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md', 'QWEN.md']);
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false);
|
||||
|
||||
@@ -290,7 +627,7 @@ describe('App UI', () => {
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain('Using 2 GEMINI.md files');
|
||||
expect(lastFrame()).toContain('Using: 2 QWEN.md files');
|
||||
});
|
||||
|
||||
it('should display custom contextFileName in footer when set and count is 1', async () => {
|
||||
@@ -298,6 +635,7 @@ describe('App UI', () => {
|
||||
workspace: { contextFileName: 'AGENTS.md', theme: 'Default' },
|
||||
});
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['AGENTS.md']);
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false);
|
||||
|
||||
@@ -310,7 +648,7 @@ describe('App UI', () => {
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain('Using 1 AGENTS.md file');
|
||||
expect(lastFrame()).toContain('Using: 1 AGENTS.md file');
|
||||
});
|
||||
|
||||
it('should display a generic message when multiple context files with different names are provided', async () => {
|
||||
@@ -321,6 +659,10 @@ describe('App UI', () => {
|
||||
},
|
||||
});
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue([
|
||||
'AGENTS.md',
|
||||
'CONTEXT.md',
|
||||
]);
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false);
|
||||
|
||||
@@ -333,7 +675,7 @@ describe('App UI', () => {
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain('Using 2 context files');
|
||||
expect(lastFrame()).toContain('Using: 2 context files');
|
||||
});
|
||||
|
||||
it('should display custom contextFileName with plural when set and count is > 1', async () => {
|
||||
@@ -341,6 +683,11 @@ describe('App UI', () => {
|
||||
workspace: { contextFileName: 'MY_NOTES.TXT', theme: 'Default' },
|
||||
});
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(3);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue([
|
||||
'MY_NOTES.TXT',
|
||||
'MY_NOTES.TXT',
|
||||
'MY_NOTES.TXT',
|
||||
]);
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false);
|
||||
|
||||
@@ -353,7 +700,7 @@ describe('App UI', () => {
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain('Using 3 MY_NOTES.TXT files');
|
||||
expect(lastFrame()).toContain('Using: 3 MY_NOTES.TXT files');
|
||||
});
|
||||
|
||||
it('should not display context file message if count is 0, even if contextFileName is set', async () => {
|
||||
@@ -361,6 +708,7 @@ describe('App UI', () => {
|
||||
workspace: { contextFileName: 'ANY_FILE.MD', theme: 'Default' },
|
||||
});
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(0);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue([]);
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false);
|
||||
|
||||
@@ -376,8 +724,9 @@ describe('App UI', () => {
|
||||
expect(lastFrame()).not.toContain('ANY_FILE.MD');
|
||||
});
|
||||
|
||||
it('should display GEMINI.md and MCP server count when both are present', async () => {
|
||||
it('should display QWEN.md and MCP server count when both are present', async () => {
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md', 'QWEN.md']);
|
||||
mockConfig.getMcpServers.mockReturnValue({
|
||||
server1: {} as MCPServerConfig,
|
||||
});
|
||||
@@ -393,11 +742,12 @@ describe('App UI', () => {
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain('server');
|
||||
expect(lastFrame()).toContain('1 MCP server');
|
||||
});
|
||||
|
||||
it('should display only MCP server count when GEMINI.md count is 0', async () => {
|
||||
it('should display only MCP server count when QWEN.md count is 0', async () => {
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(0);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue([]);
|
||||
mockConfig.getMcpServers.mockReturnValue({
|
||||
server1: {} as MCPServerConfig,
|
||||
server2: {} as MCPServerConfig,
|
||||
@@ -414,7 +764,7 @@ describe('App UI', () => {
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain('Using 2 MCP servers');
|
||||
expect(lastFrame()).toContain('Using: 2 MCP servers (ctrl+t to view)');
|
||||
});
|
||||
|
||||
it('should display Tips component by default', async () => {
|
||||
@@ -527,7 +877,7 @@ describe('App UI', () => {
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
|
||||
expect(lastFrame()).toContain('Select Theme');
|
||||
expect(lastFrame()).toContain("I'm Feeling Lucky (esc to cancel");
|
||||
});
|
||||
|
||||
it('should display a message if NO_COLOR is set', async () => {
|
||||
@@ -542,13 +892,43 @@ describe('App UI', () => {
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
|
||||
expect(lastFrame()).toContain(
|
||||
'Theme configuration unavailable due to NO_COLOR env variable.',
|
||||
);
|
||||
expect(lastFrame()).toContain("I'm Feeling Lucky (esc to cancel");
|
||||
expect(lastFrame()).not.toContain('Select Theme');
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the initial UI correctly', () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render correctly with the prompt input box', () => {
|
||||
vi.mocked(useGeminiStream).mockReturnValue({
|
||||
streamingState: StreamingState.Idle,
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('with initial prompt from --prompt-interactive', () => {
|
||||
it('should submit the initial prompt automatically', async () => {
|
||||
const mockSubmitQuery = vi.fn();
|
||||
@@ -565,6 +945,7 @@ describe('App UI', () => {
|
||||
|
||||
mockConfig.getGeminiClient.mockReturnValue({
|
||||
isInitialized: vi.fn(() => true),
|
||||
getUserTier: vi.fn(),
|
||||
} as unknown as GeminiClient);
|
||||
|
||||
const { unmount, rerender } = render(
|
||||
@@ -592,4 +973,81 @@ describe('App UI', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('errorCount', () => {
|
||||
it('should correctly sum the counts of error messages', async () => {
|
||||
const mockConsoleMessages: ConsoleMessageItem[] = [
|
||||
{ type: 'error', content: 'First error', count: 1 },
|
||||
{ type: 'log', content: 'some log', count: 1 },
|
||||
{ type: 'error', content: 'Second error', count: 3 },
|
||||
{ type: 'warn', content: 'a warning', count: 1 },
|
||||
{ type: 'error', content: 'Third error', count: 1 },
|
||||
];
|
||||
|
||||
vi.mocked(useConsoleMessages).mockReturnValue({
|
||||
consoleMessages: mockConsoleMessages,
|
||||
handleNewMessage: vi.fn(),
|
||||
clearConsoleMessages: vi.fn(),
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
|
||||
// Total error count should be 1 + 3 + 1 = 5
|
||||
expect(lastFrame()).toContain('5 errors');
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth validation', () => {
|
||||
it('should call validateAuthMethod when useExternalAuth is false', async () => {
|
||||
const validateAuthMethodSpy = vi.spyOn(auth, 'validateAuthMethod');
|
||||
mockSettings = createMockSettings({
|
||||
workspace: {
|
||||
selectedAuthType: 'USE_GEMINI' as AuthType,
|
||||
useExternalAuth: false,
|
||||
theme: 'Default',
|
||||
},
|
||||
});
|
||||
|
||||
const { unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
|
||||
expect(validateAuthMethodSpy).toHaveBeenCalledWith('USE_GEMINI');
|
||||
});
|
||||
|
||||
it('should NOT call validateAuthMethod when useExternalAuth is true', async () => {
|
||||
const validateAuthMethodSpy = vi.spyOn(auth, 'validateAuthMethod');
|
||||
mockSettings = createMockSettings({
|
||||
workspace: {
|
||||
selectedAuthType: 'USE_GEMINI' as AuthType,
|
||||
useExternalAuth: true,
|
||||
theme: 'Default',
|
||||
},
|
||||
});
|
||||
|
||||
const { unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
|
||||
expect(validateAuthMethodSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||
import { useThemeCommand } from './hooks/useThemeCommand.js';
|
||||
import { useAuthCommand } from './hooks/useAuthCommand.js';
|
||||
import { useQwenAuth } from './hooks/useQwenAuth.js';
|
||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
|
||||
@@ -35,9 +36,10 @@ import { Footer } from './components/Footer.js';
|
||||
import { ThemeDialog } from './components/ThemeDialog.js';
|
||||
import { AuthDialog } from './components/AuthDialog.js';
|
||||
import { AuthInProgress } from './components/AuthInProgress.js';
|
||||
import { QwenOAuthProgress } from './components/QwenOAuthProgress.js';
|
||||
import { EditorSettingsDialog } from './components/EditorSettingsDialog.js';
|
||||
import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js';
|
||||
import { Colors } from './colors.js';
|
||||
import { Help } from './components/Help.js';
|
||||
import { loadHierarchicalGeminiMemory } from '../config/config.js';
|
||||
import { LoadedSettings } from '../config/settings.js';
|
||||
import { Tips } from './components/Tips.js';
|
||||
@@ -46,6 +48,7 @@ import { registerCleanup } from '../utils/cleanup.js';
|
||||
import { DetailedMessagesDisplay } from './components/DetailedMessagesDisplay.js';
|
||||
import { HistoryItemDisplay } from './components/HistoryItemDisplay.js';
|
||||
import { ContextSummaryDisplay } from './components/ContextSummaryDisplay.js';
|
||||
import { IDEContextDetailDisplay } from './components/IDEContextDetailDisplay.js';
|
||||
import { useHistory } from './hooks/useHistoryManager.js';
|
||||
import process from 'node:process';
|
||||
import {
|
||||
@@ -57,6 +60,9 @@ import {
|
||||
EditorType,
|
||||
FlashFallbackEvent,
|
||||
logFlashFallback,
|
||||
AuthType,
|
||||
type IdeContext,
|
||||
ideContext,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
import { useLogger } from './hooks/useLogger.js';
|
||||
@@ -66,8 +72,11 @@ import {
|
||||
useSessionStats,
|
||||
} from './contexts/SessionContext.js';
|
||||
import { useGitBranchName } from './hooks/useGitBranchName.js';
|
||||
import { useFocus } from './hooks/useFocus.js';
|
||||
import { useBracketedPaste } from './hooks/useBracketedPaste.js';
|
||||
import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||
import { useVimMode, VimModeProvider } from './contexts/VimModeContext.js';
|
||||
import { useVim } from './hooks/vim.js';
|
||||
import * as fs from 'fs';
|
||||
import { UpdateNotification } from './components/UpdateNotification.js';
|
||||
import {
|
||||
@@ -75,11 +84,13 @@ import {
|
||||
isGenericQuotaExceededError,
|
||||
UserTierId,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { checkForUpdates } from './utils/updateCheck.js';
|
||||
import { UpdateObject } from './utils/updateCheck.js';
|
||||
import ansiEscapes from 'ansi-escapes';
|
||||
import { OverflowProvider } from './contexts/OverflowContext.js';
|
||||
import { ShowMoreLines } from './components/ShowMoreLines.js';
|
||||
import { PrivacyNotice } from './privacy/PrivacyNotice.js';
|
||||
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
|
||||
import { appEvents, AppEvent } from '../utils/events.js';
|
||||
|
||||
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
|
||||
|
||||
@@ -92,21 +103,25 @@ interface AppProps {
|
||||
|
||||
export const AppWrapper = (props: AppProps) => (
|
||||
<SessionStatsProvider>
|
||||
<App {...props} />
|
||||
<VimModeProvider settings={props.settings}>
|
||||
<App {...props} />
|
||||
</VimModeProvider>
|
||||
</SessionStatsProvider>
|
||||
);
|
||||
|
||||
const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
const isFocused = useFocus();
|
||||
useBracketedPaste();
|
||||
const [updateMessage, setUpdateMessage] = useState<string | null>(null);
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateObject | null>(null);
|
||||
const { stdout } = useStdout();
|
||||
const nightly = version.includes('nightly');
|
||||
const { history, addItem, clearItems, loadHistory } = useHistory();
|
||||
|
||||
useEffect(() => {
|
||||
checkForUpdates().then(setUpdateMessage);
|
||||
}, []);
|
||||
const cleanup = setUpdateHandler(addItem, setUpdateInfo);
|
||||
return cleanup;
|
||||
}, [addItem]);
|
||||
|
||||
const { history, addItem, clearItems, loadHistory } = useHistory();
|
||||
const {
|
||||
consoleMessages,
|
||||
handleNewMessage,
|
||||
@@ -132,7 +147,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
|
||||
const [geminiMdFileCount, setGeminiMdFileCount] = useState<number>(0);
|
||||
const [debugMessage, setDebugMessage] = useState<string>('');
|
||||
const [showHelp, setShowHelp] = useState<boolean>(false);
|
||||
const [themeError, setThemeError] = useState<string | null>(null);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
const [editorError, setEditorError] = useState<string | null>(null);
|
||||
@@ -143,6 +157,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
const [showErrorDetails, setShowErrorDetails] = useState<boolean>(false);
|
||||
const [showToolDescriptions, setShowToolDescriptions] =
|
||||
useState<boolean>(false);
|
||||
const [showIDEContextDetail, setShowIDEContextDetail] =
|
||||
useState<boolean>(false);
|
||||
const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false);
|
||||
const [quittingMessages, setQuittingMessages] = useState<
|
||||
HistoryItem[] | null
|
||||
@@ -155,6 +171,39 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] =
|
||||
useState<boolean>(false);
|
||||
const [userTier, setUserTier] = useState<UserTierId | undefined>(undefined);
|
||||
const [ideContextState, setIdeContextState] = useState<
|
||||
IdeContext | undefined
|
||||
>();
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = ideContext.subscribeToIdeContext(setIdeContextState);
|
||||
// Set the initial value
|
||||
setIdeContextState(ideContext.getIdeContext());
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const openDebugConsole = () => {
|
||||
setShowErrorDetails(true);
|
||||
setConstrainHeight(false); // Make sure the user sees the full message.
|
||||
};
|
||||
appEvents.on(AppEvent.OpenDebugConsole, openDebugConsole);
|
||||
|
||||
const logErrorHandler = (errorMessage: unknown) => {
|
||||
handleNewMessage({
|
||||
type: 'error',
|
||||
content: String(errorMessage),
|
||||
count: 1,
|
||||
});
|
||||
};
|
||||
appEvents.on(AppEvent.LogError, logErrorHandler);
|
||||
|
||||
return () => {
|
||||
appEvents.off(AppEvent.OpenDebugConsole, openDebugConsole);
|
||||
appEvents.off(AppEvent.LogError, logErrorHandler);
|
||||
};
|
||||
}, [handleNewMessage]);
|
||||
|
||||
const openPrivacyNotice = useCallback(() => {
|
||||
setShowPrivacyNotice(true);
|
||||
@@ -162,7 +211,10 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
const initialPromptSubmitted = useRef(false);
|
||||
|
||||
const errorCount = useMemo(
|
||||
() => consoleMessages.filter((msg) => msg.type === 'error').length,
|
||||
() =>
|
||||
consoleMessages
|
||||
.filter((msg) => msg.type === 'error')
|
||||
.reduce((total, msg) => total + msg.count, 0),
|
||||
[consoleMessages],
|
||||
);
|
||||
|
||||
@@ -181,38 +233,58 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
cancelAuthentication,
|
||||
} = useAuthCommand(settings, setAuthError, config);
|
||||
|
||||
const {
|
||||
isQwenAuthenticating,
|
||||
deviceAuth,
|
||||
isQwenAuth,
|
||||
cancelQwenAuth,
|
||||
authStatus,
|
||||
authMessage,
|
||||
} = useQwenAuth(settings, isAuthenticating);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.merged.selectedAuthType) {
|
||||
if (settings.merged.selectedAuthType && !settings.merged.useExternalAuth) {
|
||||
const error = validateAuthMethod(settings.merged.selectedAuthType);
|
||||
if (error) {
|
||||
setAuthError(error);
|
||||
openAuthDialog();
|
||||
}
|
||||
}
|
||||
}, [settings.merged.selectedAuthType, openAuthDialog, setAuthError]);
|
||||
}, [
|
||||
settings.merged.selectedAuthType,
|
||||
settings.merged.useExternalAuth,
|
||||
openAuthDialog,
|
||||
setAuthError,
|
||||
]);
|
||||
|
||||
// Sync user tier from config when authentication changes
|
||||
useEffect(() => {
|
||||
const syncUserTier = async () => {
|
||||
try {
|
||||
const configUserTier = await config.getUserTier();
|
||||
if (configUserTier !== userTier) {
|
||||
setUserTier(configUserTier);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail - this is not critical functionality
|
||||
// Only log in debug mode to avoid cluttering the console
|
||||
if (config.getDebugMode()) {
|
||||
console.debug('Failed to sync user tier:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Only sync when not currently authenticating
|
||||
if (!isAuthenticating) {
|
||||
syncUserTier();
|
||||
setUserTier(config.getGeminiClient()?.getUserTier());
|
||||
}
|
||||
}, [config, userTier, isAuthenticating]);
|
||||
}, [config, isAuthenticating]);
|
||||
|
||||
// Handle Qwen OAuth timeout
|
||||
useEffect(() => {
|
||||
if (isQwenAuth && authStatus === 'timeout') {
|
||||
setAuthError(
|
||||
authMessage ||
|
||||
'Qwen OAuth authentication timed out. Please try again or select a different authentication method.',
|
||||
);
|
||||
cancelQwenAuth();
|
||||
cancelAuthentication();
|
||||
openAuthDialog();
|
||||
}
|
||||
}, [
|
||||
isQwenAuth,
|
||||
authStatus,
|
||||
authMessage,
|
||||
cancelQwenAuth,
|
||||
cancelAuthentication,
|
||||
openAuthDialog,
|
||||
setAuthError,
|
||||
]);
|
||||
|
||||
const {
|
||||
isEditorDialogOpen,
|
||||
@@ -238,8 +310,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
process.cwd(),
|
||||
config.getDebugMode(),
|
||||
config.getFileService(),
|
||||
settings.merged,
|
||||
config.getExtensionContextFilePaths(),
|
||||
settings.merged.memoryImportFormat || 'tree', // Use setting or default to 'tree'
|
||||
config.getFileFilteringOptions(),
|
||||
);
|
||||
|
||||
config.setUserMemory(memoryContent);
|
||||
config.setGeminiMdFileCount(fileCount);
|
||||
setGeminiMdFileCount(fileCount);
|
||||
@@ -267,7 +343,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
);
|
||||
console.error('Error refreshing memory:', error);
|
||||
}
|
||||
}, [config, addItem]);
|
||||
}, [config, addItem, settings.merged]);
|
||||
|
||||
// Watch for model changes (e.g., from Flash fallback)
|
||||
useEffect(() => {
|
||||
@@ -294,66 +370,73 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
): Promise<boolean> => {
|
||||
let message: string;
|
||||
|
||||
// Use actual user tier if available, otherwise default to FREE tier behavior (safe default)
|
||||
const isPaidTier =
|
||||
userTier === UserTierId.LEGACY || userTier === UserTierId.STANDARD;
|
||||
if (
|
||||
config.getContentGeneratorConfig().authType ===
|
||||
AuthType.LOGIN_WITH_GOOGLE
|
||||
) {
|
||||
// Use actual user tier if available; otherwise, default to FREE tier behavior (safe default)
|
||||
const isPaidTier =
|
||||
userTier === UserTierId.LEGACY || userTier === UserTierId.STANDARD;
|
||||
|
||||
// Check if this is a Pro quota exceeded error
|
||||
if (error && isProQuotaExceededError(error)) {
|
||||
if (isPaidTier) {
|
||||
message = `⚡ You have reached your daily ${currentModel} quota limit.
|
||||
// Check if this is a Pro quota exceeded error
|
||||
if (error && isProQuotaExceededError(error)) {
|
||||
if (isPaidTier) {
|
||||
message = `⚡ You have reached your daily ${currentModel} quota limit.
|
||||
⚡ Automatically switching from ${currentModel} to ${fallbackModel} for the remainder of this session.
|
||||
⚡ To continue accessing the ${currentModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
|
||||
} else {
|
||||
message = `⚡ You have reached your daily ${currentModel} quota limit.
|
||||
} else {
|
||||
message = `⚡ You have reached your daily ${currentModel} quota limit.
|
||||
⚡ Automatically switching from ${currentModel} to ${fallbackModel} for the remainder of this session.
|
||||
⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist
|
||||
⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key
|
||||
⚡ You can switch authentication methods by typing /auth`;
|
||||
}
|
||||
} else if (error && isGenericQuotaExceededError(error)) {
|
||||
if (isPaidTier) {
|
||||
message = `⚡ You have reached your daily quota limit.
|
||||
}
|
||||
} else if (error && isGenericQuotaExceededError(error)) {
|
||||
if (isPaidTier) {
|
||||
message = `⚡ You have reached your daily quota limit.
|
||||
⚡ Automatically switching from ${currentModel} to ${fallbackModel} for the remainder of this session.
|
||||
⚡ To continue accessing the ${currentModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
|
||||
} else {
|
||||
message = `⚡ You have reached your daily quota limit.
|
||||
} else {
|
||||
message = `⚡ You have reached your daily quota limit.
|
||||
⚡ Automatically switching from ${currentModel} to ${fallbackModel} for the remainder of this session.
|
||||
⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist
|
||||
⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key
|
||||
⚡ You can switch authentication methods by typing /auth`;
|
||||
}
|
||||
} else {
|
||||
if (isPaidTier) {
|
||||
// Default fallback message for other cases (like consecutive 429s)
|
||||
message = `⚡ Automatically switching from ${currentModel} to ${fallbackModel} for faster responses for the remainder of this session.
|
||||
}
|
||||
} else {
|
||||
if (isPaidTier) {
|
||||
// Default fallback message for other cases (like consecutive 429s)
|
||||
message = `⚡ Automatically switching from ${currentModel} to ${fallbackModel} for faster responses for the remainder of this session.
|
||||
⚡ Possible reasons for this are that you have received multiple consecutive capacity errors or you have reached your daily ${currentModel} quota limit
|
||||
⚡ To continue accessing the ${currentModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
|
||||
} else {
|
||||
// Default fallback message for other cases (like consecutive 429s)
|
||||
message = `⚡ Automatically switching from ${currentModel} to ${fallbackModel} for faster responses for the remainder of this session.
|
||||
} else {
|
||||
// Default fallback message for other cases (like consecutive 429s)
|
||||
message = `⚡ Automatically switching from ${currentModel} to ${fallbackModel} for faster responses for the remainder of this session.
|
||||
⚡ Possible reasons for this are that you have received multiple consecutive capacity errors or you have reached your daily ${currentModel} quota limit
|
||||
⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist
|
||||
⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key
|
||||
⚡ You can switch authentication methods by typing /auth`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add message to UI history
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: message,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
// Set the flag to prevent tool continuation
|
||||
setModelSwitchedFromQuotaError(true);
|
||||
// Set global quota error flag to prevent Flash model calls
|
||||
config.setQuotaErrorOccurred(true);
|
||||
}
|
||||
|
||||
// Add message to UI history
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: message,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
// Set the flag to prevent tool continuation
|
||||
setModelSwitchedFromQuotaError(true);
|
||||
// Set global quota error flag to prevent Flash model calls
|
||||
config.setQuotaErrorOccurred(true);
|
||||
// Switch model for future use but return false to stop current retry
|
||||
config.setModel(fallbackModel);
|
||||
config.setFallbackMode(true);
|
||||
logFlashFallback(
|
||||
config,
|
||||
new FlashFallbackEvent(config.getContentGeneratorConfig().authType!),
|
||||
@@ -364,34 +447,19 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
config.setFlashFallbackHandler(flashFallbackHandler);
|
||||
}, [config, addItem, userTier]);
|
||||
|
||||
const {
|
||||
handleSlashCommand,
|
||||
slashCommands,
|
||||
pendingHistoryItems: pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
} = useSlashCommandProcessor(
|
||||
config,
|
||||
settings,
|
||||
history,
|
||||
addItem,
|
||||
clearItems,
|
||||
loadHistory,
|
||||
refreshStatic,
|
||||
setShowHelp,
|
||||
setDebugMessage,
|
||||
openThemeDialog,
|
||||
openAuthDialog,
|
||||
openEditorDialog,
|
||||
toggleCorgiMode,
|
||||
showToolDescriptions,
|
||||
setQuittingMessages,
|
||||
openPrivacyNotice,
|
||||
);
|
||||
const pendingHistoryItems = [...pendingSlashCommandHistoryItems];
|
||||
|
||||
// Terminal and UI setup
|
||||
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
|
||||
const isInitialMount = useRef(true);
|
||||
const { stdin, setRawMode } = useStdin();
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
const widthFraction = 0.9;
|
||||
const inputWidth = Math.max(
|
||||
20,
|
||||
Math.floor(terminalWidth * widthFraction) - 3,
|
||||
);
|
||||
const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8));
|
||||
|
||||
// Utility callbacks
|
||||
const isValidPath = useCallback((filePath: string): boolean => {
|
||||
try {
|
||||
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
|
||||
@@ -400,12 +468,83 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const widthFraction = 0.9;
|
||||
const inputWidth = Math.max(
|
||||
20,
|
||||
Math.floor(terminalWidth * widthFraction) - 3,
|
||||
const getPreferredEditor = useCallback(() => {
|
||||
const editorType = settings.merged.preferredEditor;
|
||||
const isValidEditor = isEditorAvailable(editorType);
|
||||
if (!isValidEditor) {
|
||||
openEditorDialog();
|
||||
return;
|
||||
}
|
||||
return editorType as EditorType;
|
||||
}, [settings, openEditorDialog]);
|
||||
|
||||
const onAuthError = useCallback(() => {
|
||||
setAuthError('reauth required');
|
||||
openAuthDialog();
|
||||
}, [openAuthDialog, setAuthError]);
|
||||
|
||||
// Core hooks and processors
|
||||
const {
|
||||
vimEnabled: vimModeEnabled,
|
||||
vimMode,
|
||||
toggleVimEnabled,
|
||||
} = useVimMode();
|
||||
|
||||
const {
|
||||
handleSlashCommand,
|
||||
slashCommands,
|
||||
pendingHistoryItems: pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
shellConfirmationRequest,
|
||||
} = useSlashCommandProcessor(
|
||||
config,
|
||||
settings,
|
||||
addItem,
|
||||
clearItems,
|
||||
loadHistory,
|
||||
refreshStatic,
|
||||
setDebugMessage,
|
||||
openThemeDialog,
|
||||
openAuthDialog,
|
||||
openEditorDialog,
|
||||
toggleCorgiMode,
|
||||
setQuittingMessages,
|
||||
openPrivacyNotice,
|
||||
toggleVimEnabled,
|
||||
setIsProcessing,
|
||||
);
|
||||
|
||||
const {
|
||||
streamingState,
|
||||
submitQuery,
|
||||
initError,
|
||||
pendingHistoryItems: pendingGeminiHistoryItems,
|
||||
thought,
|
||||
} = useGeminiStream(
|
||||
config.getGeminiClient(),
|
||||
history,
|
||||
addItem,
|
||||
config,
|
||||
setDebugMessage,
|
||||
handleSlashCommand,
|
||||
shellModeActive,
|
||||
getPreferredEditor,
|
||||
onAuthError,
|
||||
performMemoryRefresh,
|
||||
modelSwitchedFromQuotaError,
|
||||
setModelSwitchedFromQuotaError,
|
||||
);
|
||||
|
||||
// Input handling
|
||||
const handleFinalSubmit = useCallback(
|
||||
(submittedValue: string) => {
|
||||
const trimmedValue = submittedValue.trim();
|
||||
if (trimmedValue.length > 0) {
|
||||
submitQuery(trimmedValue);
|
||||
}
|
||||
},
|
||||
[submitQuery],
|
||||
);
|
||||
const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8));
|
||||
|
||||
const buffer = useTextBuffer({
|
||||
initialText: '',
|
||||
@@ -416,6 +555,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
shellModeActive,
|
||||
});
|
||||
|
||||
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
|
||||
const pendingHistoryItems = [...pendingSlashCommandHistoryItems];
|
||||
pendingHistoryItems.push(...pendingGeminiHistoryItems);
|
||||
|
||||
const { elapsedTime, currentLoadingPhrase } =
|
||||
useLoadingIndicator(streamingState);
|
||||
const showAutoAcceptIndicator = useAutoAcceptIndicator({ config });
|
||||
|
||||
const handleExit = useCallback(
|
||||
(
|
||||
pressedOnce: boolean,
|
||||
@@ -426,15 +573,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
const quitCommand = slashCommands.find(
|
||||
(cmd) => cmd.name === 'quit' || cmd.altName === 'exit',
|
||||
);
|
||||
if (quitCommand && quitCommand.action) {
|
||||
quitCommand.action(commandContext, '');
|
||||
} else {
|
||||
// This is unlikely to be needed but added for an additional fallback.
|
||||
process.exit(0);
|
||||
}
|
||||
// Directly invoke the central command handler.
|
||||
handleSlashCommand('/quit');
|
||||
} else {
|
||||
setPressedOnce(true);
|
||||
timerRef.current = setTimeout(() => {
|
||||
@@ -443,8 +583,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
}, CTRL_EXIT_PROMPT_DURATION_MS);
|
||||
}
|
||||
},
|
||||
// Add commandContext to the dependency array here!
|
||||
[slashCommands, commandContext],
|
||||
[handleSlashCommand],
|
||||
);
|
||||
|
||||
useInput((input: string, key: InkKeyType) => {
|
||||
@@ -468,6 +607,13 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
if (Object.keys(mcpServers || {}).length > 0) {
|
||||
handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc');
|
||||
}
|
||||
} else if (
|
||||
key.ctrl &&
|
||||
input === 'e' &&
|
||||
config.getIdeMode() &&
|
||||
ideContextState
|
||||
) {
|
||||
setShowIDEContextDetail((prev) => !prev);
|
||||
} else if (key.ctrl && (input === 'c' || input === 'C')) {
|
||||
handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef);
|
||||
} else if (key.ctrl && (input === 'd' || input === 'D')) {
|
||||
@@ -487,57 +633,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const getPreferredEditor = useCallback(() => {
|
||||
const editorType = settings.merged.preferredEditor;
|
||||
const isValidEditor = isEditorAvailable(editorType);
|
||||
if (!isValidEditor) {
|
||||
openEditorDialog();
|
||||
return;
|
||||
}
|
||||
return editorType as EditorType;
|
||||
}, [settings, openEditorDialog]);
|
||||
|
||||
const onAuthError = useCallback(() => {
|
||||
setAuthError('reauth required');
|
||||
openAuthDialog();
|
||||
}, [openAuthDialog, setAuthError]);
|
||||
|
||||
const {
|
||||
streamingState,
|
||||
submitQuery,
|
||||
initError,
|
||||
pendingHistoryItems: pendingGeminiHistoryItems,
|
||||
thought,
|
||||
} = useGeminiStream(
|
||||
config.getGeminiClient(),
|
||||
history,
|
||||
addItem,
|
||||
setShowHelp,
|
||||
config,
|
||||
setDebugMessage,
|
||||
handleSlashCommand,
|
||||
shellModeActive,
|
||||
getPreferredEditor,
|
||||
onAuthError,
|
||||
performMemoryRefresh,
|
||||
modelSwitchedFromQuotaError,
|
||||
setModelSwitchedFromQuotaError,
|
||||
);
|
||||
pendingHistoryItems.push(...pendingGeminiHistoryItems);
|
||||
const { elapsedTime, currentLoadingPhrase } =
|
||||
useLoadingIndicator(streamingState);
|
||||
const showAutoAcceptIndicator = useAutoAcceptIndicator({ config });
|
||||
|
||||
const handleFinalSubmit = useCallback(
|
||||
(submittedValue: string) => {
|
||||
const trimmedValue = submittedValue.trim();
|
||||
if (trimmedValue.length > 0) {
|
||||
submitQuery(trimmedValue);
|
||||
}
|
||||
},
|
||||
[submitQuery],
|
||||
);
|
||||
|
||||
const logger = useLogger();
|
||||
const [userMessages, setUserMessages] = useState<string[]>([]);
|
||||
|
||||
@@ -577,7 +672,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
fetchUserMessages();
|
||||
}, [history, logger]);
|
||||
|
||||
const isInputActive = streamingState === StreamingState.Idle && !initError;
|
||||
const isInputActive =
|
||||
streamingState === StreamingState.Idle && !initError && !isProcessing;
|
||||
|
||||
const handleClearScreen = useCallback(() => {
|
||||
clearItems();
|
||||
@@ -695,12 +791,13 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
// Arbitrary threshold to ensure that items in the static area are large
|
||||
// enough but not too large to make the terminal hard to use.
|
||||
const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100);
|
||||
const placeholder = vimModeEnabled
|
||||
? " Press 'i' for INSERT mode and 'Esc' for NORMAL mode."
|
||||
: ' Type your message or @path/to/file';
|
||||
|
||||
return (
|
||||
<StreamingContext.Provider value={streamingState}>
|
||||
<Box flexDirection="column" marginBottom={1} width="90%">
|
||||
{/* Move UpdateNotification outside Static so it can re-render when updateMessage changes */}
|
||||
{updateMessage && <UpdateNotification message={updateMessage} />}
|
||||
|
||||
<Box flexDirection="column" width="90%">
|
||||
{/*
|
||||
* The Static component is an Ink intrinsic in which there can only be 1 per application.
|
||||
* Because of this restriction we're hacking it slightly by having a 'header' item here to
|
||||
@@ -733,6 +830,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
item={h}
|
||||
isPending={false}
|
||||
config={config}
|
||||
commands={slashCommands}
|
||||
/>
|
||||
)),
|
||||
]}
|
||||
@@ -760,9 +858,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
</Box>
|
||||
</OverflowProvider>
|
||||
|
||||
{showHelp && <Help commands={slashCommands} />}
|
||||
|
||||
<Box flexDirection="column" ref={mainControlsRef}>
|
||||
{/* Move UpdateNotification to render update notification above input area */}
|
||||
{updateInfo && <UpdateNotification message={updateInfo.message} />}
|
||||
{startupWarnings.length > 0 && (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
@@ -779,7 +877,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isThemeDialogOpen ? (
|
||||
{shellConfirmationRequest ? (
|
||||
<ShellConfirmationDialog request={shellConfirmationRequest} />
|
||||
) : isThemeDialogOpen ? (
|
||||
<Box flexDirection="column">
|
||||
{themeError && (
|
||||
<Box marginBottom={1}>
|
||||
@@ -800,13 +900,35 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
</Box>
|
||||
) : isAuthenticating ? (
|
||||
<>
|
||||
<AuthInProgress
|
||||
onTimeout={() => {
|
||||
setAuthError('Authentication timed out. Please try again.');
|
||||
cancelAuthentication();
|
||||
openAuthDialog();
|
||||
}}
|
||||
/>
|
||||
{isQwenAuth && isQwenAuthenticating ? (
|
||||
<QwenOAuthProgress
|
||||
deviceAuth={deviceAuth || undefined}
|
||||
authStatus={authStatus}
|
||||
authMessage={authMessage}
|
||||
onTimeout={() => {
|
||||
setAuthError(
|
||||
'Qwen OAuth authentication timed out. Please try again.',
|
||||
);
|
||||
cancelQwenAuth();
|
||||
cancelAuthentication();
|
||||
openAuthDialog();
|
||||
}}
|
||||
onCancel={() => {
|
||||
setAuthError('Qwen OAuth authentication cancelled.');
|
||||
cancelQwenAuth();
|
||||
cancelAuthentication();
|
||||
openAuthDialog();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<AuthInProgress
|
||||
onTimeout={() => {
|
||||
setAuthError('Authentication timed out. Please try again.');
|
||||
cancelAuthentication();
|
||||
openAuthDialog();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showErrorDetails && (
|
||||
<OverflowProvider>
|
||||
<Box flexDirection="column">
|
||||
@@ -864,6 +986,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
}
|
||||
elapsedTime={elapsedTime}
|
||||
/>
|
||||
|
||||
<Box
|
||||
marginTop={1}
|
||||
display="flex"
|
||||
@@ -884,9 +1007,11 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
</Text>
|
||||
) : (
|
||||
<ContextSummaryDisplay
|
||||
ideContext={ideContextState}
|
||||
geminiMdFileCount={geminiMdFileCount}
|
||||
contextFileNames={contextFileNames}
|
||||
mcpServers={config.getMcpServers()}
|
||||
blockedMcpServers={config.getBlockedMcpServers()}
|
||||
showToolDescriptions={showToolDescriptions}
|
||||
/>
|
||||
)}
|
||||
@@ -901,7 +1026,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
{shellModeActive && <ShellModeIndicator />}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{showIDEContextDetail && (
|
||||
<IDEContextDetailDisplay
|
||||
ideContext={ideContextState}
|
||||
detectedIdeDisplay={config
|
||||
.getIdeClient()
|
||||
.getDetectedIdeDisplayName()}
|
||||
/>
|
||||
)}
|
||||
{showErrorDetails && (
|
||||
<OverflowProvider>
|
||||
<Box flexDirection="column">
|
||||
@@ -930,6 +1062,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
commandContext={commandContext}
|
||||
shellModeActive={shellModeActive}
|
||||
setShellModeActive={setShellModeActive}
|
||||
focus={isFocused}
|
||||
vimHandleInput={vimHandleInput}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -981,6 +1116,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
}
|
||||
promptTokenCount={sessionStats.lastPromptTokenCount}
|
||||
nightly={nightly}
|
||||
vimMode={vimModeEnabled ? vimMode : undefined}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
18
packages/cli/src/ui/__snapshots__/App.test.tsx.snap
Normal file
18
packages/cli/src/ui/__snapshots__/App.test.tsx.snap
Normal file
@@ -0,0 +1,18 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`App UI > should render correctly with the prompt input box 1`] = `
|
||||
"
|
||||
|
||||
╭────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ > Type your message or @path/to/file │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
/test/dir no sandbox (see /docs) model (100% context left)"
|
||||
`;
|
||||
|
||||
exports[`App UI > should render the initial UI correctly 1`] = `
|
||||
"
|
||||
I'm Feeling Lucky (esc to cancel, 0s)
|
||||
|
||||
|
||||
/test/dir no sandbox (see /docs) model (100% context left)"
|
||||
`;
|
||||
@@ -38,6 +38,12 @@ export const Colors: ColorsTheme = {
|
||||
get AccentRed() {
|
||||
return themeManager.getActiveTheme().colors.AccentRed;
|
||||
},
|
||||
get DiffAdded() {
|
||||
return themeManager.getActiveTheme().colors.DiffAdded;
|
||||
},
|
||||
get DiffRemoved() {
|
||||
return themeManager.getActiveTheme().colors.DiffRemoved;
|
||||
},
|
||||
get Comment() {
|
||||
return themeManager.getActiveTheme().colors.Comment;
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user