Compare commits
854 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 220631711e | |||
| 4ffbe7a2ed | |||
| b42af1eac2 | |||
| a7414608ed | |||
| dbf5d9ce1f | |||
| 5db008f384 | |||
| cb1e1a3595 | |||
| a36472b55f | |||
| 62d0e34ec9 | |||
| db1bc6a1f8 | |||
| 9b109dc7a8 | |||
| fc24676924 | |||
| bd867a16cd | |||
| 29e7461837 | |||
| 688d47d236 | |||
| 2baeee2834 | |||
| 893e61dc51 | |||
| 64e48163d0 | |||
| 1f0a5f4eda | |||
| 338fa258b3 | |||
| 2114e1a53f | |||
| 0f52076762 | |||
| c44bd6138c | |||
| 0bb0fc429a | |||
| 0161298154 | |||
| f90e756e21 | |||
| ed687d62ae | |||
| ddf2d7c655 | |||
| cbe6a0907c | |||
| 02d9a0d190 | |||
| afc600baed | |||
| 39dec35408 | |||
| d6b38c4236 | |||
| 1b9e7e32bd | |||
| 1acab59fc7 | |||
| bfc37b784e | |||
| 9d42282672 | |||
| 303ff8137d | |||
| 6d04d15ce0 | |||
| 5cd10b594a | |||
| 77be169db4 | |||
| 726ef4fa99 | |||
| d784ec4611 | |||
| 41f4d95597 | |||
| 04b62745e4 | |||
| 78e4e59ac3 | |||
| ae162a72b1 | |||
| 788f76f422 | |||
| 2f91cc0a80 | |||
| 93e9bddc6e | |||
| caaad601af | |||
| 9d8f0dc877 | |||
| 8f8af0874d | |||
| 9ca73b944f | |||
| b4a5965602 | |||
| dce29c181f | |||
| 94a6b0c0f5 | |||
| 683ce31f2b | |||
| 494cc381b5 | |||
| e1863234f0 | |||
| a977a92729 | |||
| 193e1a3cd0 | |||
| f6bceb29a3 | |||
| 979ff00cc3 | |||
| bb0f983708 | |||
| 48d8952591 | |||
| 8d51d306b3 | |||
| 2e65b1be83 | |||
| ccd19a48ce | |||
| 07032df037 | |||
| f334ac6d01 | |||
| f4dbac0dcf | |||
| 293477b02a | |||
| 47a881b11f | |||
| 743d7e69f2 | |||
| 047a904b4f | |||
| 1dba8e9e91 | |||
| 73594a07ca | |||
| ffd22c7fb6 | |||
| 39d7b3a63e | |||
| 9fc72c1fb3 | |||
| 0d1b041d74 | |||
| 9fba52d0fa | |||
| f440047263 | |||
| 2da05c2ad3 | |||
| ac4db35c0b | |||
| 0c0a582559 | |||
| cac4f21746 | |||
| 7616470137 | |||
| 4ae11406d2 | |||
| bc077db0ee | |||
| e901e70c14 | |||
| c71146b1d5 | |||
| 451db2f5d8 | |||
| 68ceb54b36 | |||
| f367a9c010 | |||
| 77b0c43392 | |||
| 3316ee6923 | |||
| 023ca2e4c1 | |||
| 279c496bb2 | |||
| d0507df894 | |||
| 71c877a67f | |||
| a5379d5fff | |||
| 175682f152 | |||
| 5a13616b64 | |||
| e5a6960078 | |||
| 276f5425f0 | |||
| 6ca7311273 | |||
| 9c3dc0ee3a | |||
| b798fa4b7b | |||
| ba6992234f | |||
| dcb4b67e00 | |||
| 329e68e017 | |||
| 4e2f80b79a | |||
| 6421f146a9 | |||
| e556a816e4 | |||
| 8461c996e5 | |||
| 74c98a5acf | |||
| f8190f04b7 | |||
| d002e1517b | |||
| 4b76196e2c | |||
| 6126ede963 | |||
| 9fe678247f | |||
| 15a3560533 | |||
| 2708c834d0 | |||
| 743cd3602b | |||
| 9b4efddd9b | |||
| 16d174e124 | |||
| 610f68adcf | |||
| de3d042d1b | |||
| 4e1ceee62e | |||
| 4c133dc2d9 | |||
| 0da962c4b4 | |||
| ee634dc8db | |||
| b0d3f19a6a | |||
| 12d5421c26 | |||
| 72f30c58e9 | |||
| 235cb11beb | |||
| 74856d3747 | |||
| c36a48cf4b | |||
| e77c4eba3e | |||
| d73897da8e | |||
| 9c97442f7c | |||
| 6375440152 | |||
| 928a27359f | |||
| ba08d52351 | |||
| b1475122da | |||
| ffd30d7db7 | |||
| eb24269651 | |||
| 2b844778ff | |||
| ab019d3f18 | |||
| 7aa2d672ce | |||
| c3f4000817 | |||
| 7fdc9c7b64 | |||
| 7f56ca8cc6 | |||
| f5e779e22e | |||
| e22b4e1eee | |||
| a8d0b03515 | |||
| f32b303d2a | |||
| f1b659e5ef | |||
| ead2dc9699 | |||
| 7bd11181a6 | |||
| 100e576609 | |||
| 2784223ad5 | |||
| 5a2e7795cd | |||
| acbe654674 | |||
| 389f492d8c | |||
| 25ac563406 | |||
| bb14a5c7cc | |||
| bb953b788b | |||
| 75e93b5189 | |||
| 0b84f0ae0a | |||
| d0ff24aa87 | |||
| 51ab3b1385 | |||
| 773a94c414 | |||
| bf6d4fd997 | |||
| e60a687387 | |||
| 7824bc715f | |||
| d3d639cb7d | |||
| 1245f2ddf6 | |||
| d8e7a6129f | |||
| c0fadc5918 | |||
| b52eb58f03 | |||
| 0bb9bedc44 | |||
| dcf21ef11c | |||
| 79f87d151e | |||
| 824e800d70 | |||
| 9ded7933f0 | |||
| 93977bf348 | |||
| d4313b5e5f | |||
| 08fc305d5e | |||
| 8ca89c49ab | |||
| 24382271d6 | |||
| 0425cd4d77 | |||
| ae195831bb | |||
| 93bf871bd2 | |||
| d4d652b455 | |||
| 7b38d437ba | |||
| e7b3654313 | |||
| 448027c02a | |||
| 4e977367c2 | |||
| df9124b824 | |||
| 08283dde61 | |||
| f82fe5a2ec | |||
| 64c3542b91 | |||
| 93f69a98ba | |||
| 04e99a1264 | |||
| f16bade919 | |||
| cbd38dfd28 | |||
| aa1d7c55be | |||
| 036f65b179 | |||
| 69ff6909e1 | |||
| c5c5ea22d6 | |||
| 7db2e7d579 | |||
| 667fc85d54 | |||
| 2e149f44dd | |||
| 0c6ad33a9c | |||
| 0f23535165 | |||
| 6a870cb260 | |||
| d73a0e89b4 | |||
| 4532627f71 | |||
| b8819bdbff | |||
| ea2107e8a9 | |||
| f7e768152e | |||
| 2b2bc26f8e | |||
| 815e43e3ef | |||
| 6d03791929 | |||
| 18d35c7d5d | |||
| 681b2a258b | |||
| b6617a4b17 | |||
| 168b6bec58 | |||
| 080f532d82 | |||
| 34b9d5d6fa | |||
| 2b73978c5f | |||
| 6fbd7e0a3f | |||
| e9f55d776d | |||
| 86917faa9b | |||
| b73caebe6f | |||
| cbae69ad64 | |||
| 83e93ca572 | |||
| 459e78c076 | |||
| 36b9693d31 | |||
| c8bac699fe | |||
| 748ac58dd1 | |||
| 187189ad4a | |||
| d9977715a3 | |||
| 795ec9af05 | |||
| 7788ed4677 | |||
| e58f00b0c1 | |||
| f1fe2db7ac | |||
| 19493140eb | |||
| c6d15da1ea | |||
| 484070736d | |||
| 0e57a446dc | |||
| 491418775b | |||
| 282ebcd956 | |||
| dde61365d4 | |||
| d7d4374617 | |||
| d03d519c6d | |||
| 919e9eb645 | |||
| 01a33bbb61 | |||
| c71cd1eede | |||
| bd88385923 | |||
| 58f634b582 | |||
| bd13092831 | |||
| 9982ee29a8 | |||
| 2aeed8fb3a | |||
| 5b596ed2f0 | |||
| 20d3522069 | |||
| 5e44a99410 | |||
| a9720daa45 | |||
| a2f02e4b18 | |||
| 06023c79fa | |||
| 3e3b6aed90 | |||
| 087e355885 | |||
| 1dc25e7cf5 | |||
| 51eecde01e | |||
| 8b3e502690 | |||
| 7d16764674 | |||
| 8f7eae8b37 | |||
| 862421b146 | |||
| ee29aaa871 | |||
| 330de0c382 | |||
| 296077eabf | |||
| a827d01d7c | |||
| 27db03e5ca | |||
| 3d60385958 | |||
| 9f23ec22d6 | |||
| e32a209683 | |||
| 528c57dda0 | |||
| e6e724a827 | |||
| 718a5e7c75 | |||
| 6ce0306c66 | |||
| 1fc2710999 | |||
| 6a8552a664 | |||
| 7bf6cbe1fa | |||
| 38a498e202 | |||
| 778f939302 | |||
| 84edc462d6 | |||
| f0e6b7aa37 | |||
| 661ce5e311 | |||
| c3e7396a3d | |||
| 29277d4b3b | |||
| 9ec27835cf | |||
| 1175f4a62b | |||
| 15a70ac45c | |||
| 71337b6f52 | |||
| 84e42d6904 | |||
| e8d92e4a36 | |||
| cbd0798a56 | |||
| d8c5183d9a | |||
| bd56e10bb8 | |||
| 7f7b4c430b | |||
| f2a19ab947 | |||
| f3ad5d9305 | |||
| 5b116b093f | |||
| 170ae09606 | |||
| b5ce6209fd | |||
| 849e37cf79 | |||
| 8aa110c02a | |||
| 7fd6772196 | |||
| 4169eb3b72 | |||
| 8dfea249da | |||
| 465baba994 | |||
| f542c92970 | |||
| 6842a41b06 | |||
| b732abf758 | |||
| de2f2eb71b | |||
| b114dcaeb1 | |||
| dad5dcc30f | |||
| e075be6b10 | |||
| bae4342af1 | |||
| 03b97e412e | |||
| 257aa0ff57 | |||
| adf78092da | |||
| 2c446e1e07 | |||
| 415abc8cd4 | |||
| 33ce6ed482 | |||
| 7eba27c3c4 | |||
| 2973b30ad7 | |||
| bbcfeaa361 | |||
| 9ac21c5908 | |||
| 49e61fa07f | |||
| e2a9bb97c7 | |||
| 168b75ae21 | |||
| bef17d6453 | |||
| 82bfe0d9a0 | |||
| 19a01d4264 | |||
| 3a9d1fc6fd | |||
| 31afad6e87 | |||
| 53482a17bc | |||
| 59dee895fc | |||
| c0464bdd5d | |||
| ca9652e120 | |||
| 3957e2cc72 | |||
| bb2167e3f3 | |||
| e0ceea91f6 | |||
| a9c76eca21 | |||
| 79de00f7f3 | |||
| fcab3a1b7c | |||
| f327859cce | |||
| 2095ec8700 | |||
| 963ed07d69 | |||
| cf11ff70c3 | |||
| 9cfa3c3ba6 | |||
| 0f395ce110 | |||
| ff90a65814 | |||
| b90a6d12ea | |||
| c7461f9e96 | |||
| 3b3f95c44c | |||
| 2bf842e460 | |||
| 848f9dd2e9 | |||
| 1a44752dc5 | |||
| 93f391a6bf | |||
| dd54601f2d | |||
| a098dfba84 | |||
| c783bab2d7 | |||
| e4893d27d7 | |||
| 61a31df168 | |||
| 31fcf55297 | |||
| ee02e30992 | |||
| d11f1bc064 | |||
| c36b06a901 | |||
| 6c0798ca3f | |||
| 2d8556205f | |||
| 073cc3f65e | |||
| 4d34824737 | |||
| a995a94990 | |||
| 4125f8ac14 | |||
| b8327462f9 | |||
| 7b3f47128f | |||
| 711685192c | |||
| 345d4fddc9 | |||
| 9440bebca6 | |||
| 187b2c2185 | |||
| ffa65b53ed | |||
| 34b4848214 | |||
| 174c4e5d3b | |||
| 0fb45505bf | |||
| 45582b0b52 | |||
| a5f8b0f98d | |||
| 1154017563 | |||
| f9bfa6b9a8 | |||
| 50b8d9bf83 | |||
| 275c1012f1 | |||
| 010d807e61 | |||
| 803b8bc02f | |||
| 7a1f2aba03 | |||
| cbe92286e9 | |||
| ff0266a40e | |||
| e88df4ff9c | |||
| 5e7545a22a | |||
| 5e1b6a3971 | |||
| 7dc0d02a5e | |||
| 5c6e13e188 | |||
| fd9914dd92 | |||
| 74dfd9364c | |||
| d844bf3683 | |||
| f07a8a89d5 | |||
| 89af3b2511 | |||
| edda02ce67 | |||
| b67d3cfbd8 | |||
| 93757812fc | |||
| cd3f6600ca | |||
| 93f4c4a843 | |||
| 1fc5345857 | |||
| 1ef0553929 | |||
| a4574f72a3 | |||
| e34c4f82e0 | |||
| 42e3aaff35 | |||
| f0c0219c4c | |||
| 9c28870e80 | |||
| e70928cc6f | |||
| e414b82ac3 | |||
| 6ea364e67d | |||
| 4f90909af3 | |||
| 475d377af1 | |||
| 0bb561548f | |||
| 43095543ab | |||
| 27f638e909 | |||
| e23eda5365 | |||
| 1809d04905 | |||
| 026a1339c7 | |||
| 11dec0c80a | |||
| 30155c1c59 | |||
| 465ca0361c | |||
| 62d40a02d4 | |||
| 4d6292ca20 | |||
| 836cbc3066 | |||
| b6951b6925 | |||
| 0e13f6bdec | |||
| ba96f11f90 | |||
| d7c0205052 | |||
| 5d0cf36a18 | |||
| 230942d234 | |||
| e011284d8f | |||
| c6061dd0d7 | |||
| f1cb7cc8f5 | |||
| 60d7ec20a5 | |||
| b646d3b8fe | |||
| 98c78363b3 | |||
| 8d5fc736d6 | |||
| 0c9e4f0658 | |||
| 25ce52715d | |||
| 76cd7f8ad5 | |||
| b5e29ae501 | |||
| 9cbb4ab7ad | |||
| d385491592 | |||
| e2018c4aa7 | |||
| b7f6ab7176 | |||
| 48c04e050d | |||
| e6c05cb4ec | |||
| 9f02a5f33c | |||
| 463a647a33 | |||
| 06be65e2e2 | |||
| 4bdf8f0e1d | |||
| 5db5717fdb | |||
| 9d6a445bb1 | |||
| 5c210e6f15 | |||
| 9503f38ace | |||
| 5db1e94693 | |||
| 1dff5e6903 | |||
| ae94893605 | |||
| 97dec16769 | |||
| ed618e14aa | |||
| 82c78e853b | |||
| 664e23e4fb | |||
| 70c4714988 | |||
| bb2eddc79d | |||
| 155af28841 | |||
| a97d433902 | |||
| 6b1d08f454 | |||
| 3f1ac297d4 | |||
| e4f4afcd4d | |||
| 59babde0cf | |||
| f30f57bfab | |||
| 123b9db6a9 | |||
| dc956f2feb | |||
| 5f50ae5e76 | |||
| 51f8285f93 | |||
| ee03d1247d | |||
| eb307e942b | |||
| 6bd8fec87a | |||
| 77d4716a82 | |||
| 95204dbf17 | |||
| aa9bd69f6e | |||
| 85dfb341a8 | |||
| 3d20976803 | |||
| a10036a7f1 | |||
| 4398e3e070 | |||
| 3b3062abe8 | |||
| adf1a5749d | |||
| ae021ef843 | |||
| 8da0638ee3 | |||
| eee74f3d97 | |||
| be6bf9f6c6 | |||
| 9fb01bc7f8 | |||
| 2ccac1819c | |||
| 1b9445b806 | |||
| 08fa9bb64b | |||
| 6aff5b7ccd | |||
| 9381da29bf | |||
| 2a0efb6e52 | |||
| 94fe54b9f6 | |||
| 74a9dcaa5c | |||
| 7f163658c9 | |||
| fab9603547 | |||
| 4d7a629b79 | |||
| cd48c3bde8 | |||
| 3b498d2e4b | |||
| 11b6b10d59 | |||
| c3631d84ba | |||
| e760cb737c | |||
| b0bcf1d3c9 | |||
| a1f95f02bc | |||
| 8b6cbd9909 | |||
| f2f6987f00 | |||
| fa5ab72022 | |||
| fcc20ec72c | |||
| ff50ffa123 | |||
| dea99da7d9 | |||
| ffbcbea4dc | |||
| d23c24ce72 | |||
| b17cbe5234 | |||
| d921bbb667 | |||
| 6e31f15467 | |||
| 1ef2b6903d | |||
| de11f95b65 | |||
| b23a6b3f54 | |||
| 66d2efc9d1 | |||
| f1ac1a1072 | |||
| ce1619051d | |||
| d805e12a60 | |||
| cf9e0496f7 | |||
| aa3300c1bd | |||
| 69cf9342e1 | |||
| 6ea9636861 | |||
| dd9adf8a04 | |||
| f06173a5e0 | |||
| 2c48cd3461 | |||
| b787131c82 | |||
| 40571996b1 | |||
| 5d5536a1a6 | |||
| cf80ec8382 | |||
| 16d23d8cdc | |||
| 8ed171dbe6 | |||
| 1f9d390a64 | |||
| fddfd56b50 | |||
| 96e312680d | |||
| d77375721a | |||
| 4e3769e989 | |||
| 8e3e517135 | |||
| f81b44bf19 | |||
| 1961aab850 | |||
| e7ee80ff32 | |||
| c3285625b0 | |||
| 02393b3087 | |||
| d1d2155edb | |||
| c7544f7cb9 | |||
| 79df938696 | |||
| 608ec6d329 | |||
| f2985b8bee | |||
| b24c577e38 | |||
| 054b55fdfc | |||
| 7767feb724 | |||
| 2d9517c655 | |||
| 53c6dd3812 | |||
| 8a046e951a | |||
| df17684dd4 | |||
| 6a5a4a5617 | |||
| cff9065084 | |||
| 36f9d20de1 | |||
| d014f3e989 | |||
| 40279c8dde | |||
| 75270c4777 | |||
| 5a8aab8143 | |||
| 310f788f5f | |||
| 7bf4831059 | |||
| 3a61892313 | |||
| 48cba906cd | |||
| 336d5d4c07 | |||
| 3500080abb | |||
| d4e56bc3d5 | |||
| 1e98f86fa9 | |||
| f735b0551c | |||
| 388505d7e0 | |||
| b90c5007f6 | |||
| 14a4983af3 | |||
| be59133ce9 | |||
| d3ba40090b | |||
| 4bc64497e3 | |||
| c786f35b32 | |||
| b150d7d523 | |||
| 30db993993 | |||
| 60a7098fd3 | |||
| fca01583bf | |||
| 4d2b244522 | |||
| 4d84bd90cd | |||
| 5790d3e9dd | |||
| 6f1737eb73 | |||
| 6df5ea170e | |||
| 724cc1bd33 | |||
| d7d2bf69bf | |||
| 1984bb5bbd | |||
| c48954d32d | |||
| 729a878f73 | |||
| 92678d1700 | |||
| 930dd028f1 | |||
| de0364c8ec | |||
| 7868c5811a | |||
| 8ad4b9b497 | |||
| 284ced1f5c | |||
| 7ba8682ac5 | |||
| f7f27e237a | |||
| df4f322f09 | |||
| 2f6f25dc58 | |||
| 809aef87d7 | |||
| 2c317444c5 | |||
| 7eaadfd273 | |||
| a005e5bb70 | |||
| 0432facffc | |||
| dd82794255 | |||
| 04def0f10b | |||
| 482c88cd15 | |||
| 88d754b172 | |||
| 931eee92a0 | |||
| 9107740781 | |||
| 3dfe484f66 | |||
| 4e876ebeee | |||
| c0bb8d6df9 | |||
| e6ea9c4ff3 | |||
| 7c854fe6d7 | |||
| 7a47d7a55c | |||
| 5286464bfc | |||
| e455eb5e67 | |||
| 3cd674e3b8 | |||
| ebcd5645f1 | |||
| 24d6cb5272 | |||
| 9978c9550b | |||
| f901af8cbc | |||
| 520391643b | |||
| 337e43e5a5 | |||
| cf68c91eca | |||
| f71a6ff76c | |||
| e2e3e6d5b0 | |||
| ab93c235ae | |||
| 670b433f1a | |||
| bc0be17e88 | |||
| 6b9ceaa08f | |||
| 1bd144ac13 | |||
| 087e8519c5 | |||
| 073ae4864f | |||
| 4c8526d917 | |||
| 647071d342 | |||
| 92b7687068 | |||
| 650827103d | |||
| f35516c5c9 | |||
| 6148ccc529 | |||
| f81269e77f | |||
| 8490084640 | |||
| 329322075d | |||
| 100720bb74 | |||
| 9e344594a2 | |||
| 827449aff3 | |||
| 1c6586681d | |||
| 8f56cceb07 | |||
| e4b104c0de | |||
| efbe806913 | |||
| 7970e2da15 | |||
| ba1538f31d | |||
| 403ceb39be | |||
| 0fe058254c | |||
| 71134babb9 | |||
| 73a683fd16 | |||
| 544940807f | |||
| 54de9ade7d | |||
| 75cfee46de | |||
| 955d6e70f1 | |||
| ed47d5f7c3 | |||
| 8c44597c3d | |||
| 02da117199 | |||
| 7b4d5d4513 | |||
| 545b7afe41 | |||
| 74a145c291 | |||
| 119cc2e8e1 | |||
| 5a199ec993 | |||
| 998b456b65 | |||
| 2a28198d0f | |||
| 0276554d9c | |||
| 2b3c95b1f1 | |||
| 0e075f7300 | |||
| d08bb02f8f | |||
| fe87376d6a | |||
| a65e0e95d6 | |||
| 57cde73b36 | |||
| 68d182a26e | |||
| bda18f5ee4 | |||
| 50cc7100ce | |||
| af61d0bca7 | |||
| 82d574eb7b | |||
| cff85cfe5c | |||
| 1fd6dd1ffb | |||
| 009a8d702b | |||
| 8a488eeeed | |||
| 736baf2217 | |||
| c9ac19c0cc | |||
| 4f646ef2b8 | |||
| 77d0c67e58 | |||
| 80d9a90c52 | |||
| e71ef3764d | |||
| 71ce21963b | |||
| ffe0289f77 | |||
| bd4317f1f4 | |||
| 876898fec6 | |||
| 5ada0dfed3 | |||
| 16a7da7517 | |||
| 75d86721a3 | |||
| e3cc5b1000 | |||
| d715ff5031 | |||
| c18d8a2ecc | |||
| 9a3ca8e54d | |||
| 276a0cb92c | |||
| 05c65d2fe7 | |||
| bb59518958 | |||
| 38e1fe435a | |||
| 844a4eefc7 | |||
| 583c586db6 | |||
| 9a25fad20a | |||
| 41ebe1e1c7 | |||
| 94fcb25039 | |||
| 7673b626b3 | |||
| cfd3a1b441 | |||
| 54889f21a7 | |||
| a4b5a9eec1 | |||
| ff975abec2 | |||
| 440bc2687e | |||
| 310358d3da | |||
| 532ea4b56c | |||
| 828971d549 | |||
| a8ce992429 | |||
| 24a382bc0a | |||
| 29a161e757 | |||
| 2a6ade0fe4 | |||
| e801ccb674 | |||
| ce311be70b | |||
| 99b189d3fb | |||
| 14a28ae93e | |||
| e931756fee | |||
| 01c2f8d608 | |||
| 8a188cf7fc | |||
| 53404f18ca | |||
| c732e63650 | |||
| 3e2ce06155 | |||
| e73d9d959e | |||
| 08f305d712 | |||
| eb86e10e5c | |||
| f93d2b4533 | |||
| 431a53cbb1 | |||
| 899558bbfa | |||
| 54654d2794 | |||
| 12f4029610 | |||
| 3e9b7ce9c1 | |||
| 578f90855e | |||
| 3611034795 | |||
| c07f5c948f | |||
| affd77f989 | |||
| b6c5f587c9 | |||
| a1e8ee56f0 | |||
| 363861c917 | |||
| 777230dcd1 | |||
| e6ebeaed13 | |||
| 317c70a7b2 | |||
| e20ff43f8b | |||
| c7ea018a73 | |||
| f79469c19d | |||
| f12c09b767 | |||
| cefa140bd2 | |||
| 513537d230 | |||
| 74f2a1513e | |||
| f901218b43 | |||
| 9835e821d7 | |||
| 7bf12c3d5f | |||
| 5e92a38236 | |||
| 61a899cfbc | |||
| 13d4801601 | |||
| 8f460726cc | |||
| 9c31b0ca95 | |||
| f776611e29 | |||
| 3791f06faf | |||
| c639e2c216 | |||
| b4468313e4 | |||
| f8defe3ae1 | |||
| e05d2620e1 | |||
| fcf406bf2e | |||
| e00a3d9017 | |||
| 5bc4fe4dea | |||
| 11a7ee5080 | |||
| 2fec249be1 | |||
| da1fddc4f0 | |||
| a26a7db7d2 | |||
| 12a8590ada | |||
| c63c6449b4 | |||
| 672d11c7d4 | |||
| 3c2d373a5c | |||
| 6b5d7e3fd7 | |||
| 9d761b7f5b | |||
| acd436acfe | |||
| 1236dd9e6d | |||
| ceeae15d8a | |||
| 26fa98c359 | |||
| 1ace296b91 | |||
| 0459deca03 | |||
| 08259d7e9a | |||
| b768dab822 | |||
| 7c1a1c2c1a | |||
| edbdc3bcf1 | |||
| 8034ee7be1 | |||
| 639739cb85 | |||
| efd403242e | |||
| b7f1c2b5fc | |||
| e35906bb14 | |||
| d5fdd5ebd2 | |||
| 9c65d78b07 | |||
| 9c82b0baa2 | |||
| ae23193295 | |||
| de68688c75 | |||
| d5c2bc538a | |||
| 021aa7d6d5 | |||
| 5660b8f24b | |||
| 56fb0dc4e3 | |||
| 7359b2c86c | |||
| 927958e6b3 | |||
| 1c123e0162 | |||
| dc037f0d79 |
@@ -1,3 +1,5 @@
|
|||||||
|
# Do NOT exclude LICENSE or .github — scripts/copydir.go uses them as repo-root anchors
|
||||||
|
# during `go generate`, which runs inside `make build` in the Dockerfile.
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
build/
|
build/
|
||||||
@@ -6,5 +8,4 @@ config/
|
|||||||
.env
|
.env
|
||||||
.env.example
|
.env.example
|
||||||
*.md
|
*.md
|
||||||
LICENSE
|
|
||||||
assets/
|
assets/
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Ensure shell scripts always use LF line endings regardless of OS.
|
||||||
|
*.sh text eol=lf
|
||||||
|
docker/entrypoint.sh text eol=lf
|
||||||
@@ -16,5 +16,5 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: Build
|
- name: Build core binaries
|
||||||
run: make build-all
|
run: make build-all
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
name: Create Tag
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Tag name (required, e.g. v0.2.0)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
commit:
|
||||||
|
description: "Target commit SHA (leave empty for latest main)"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
create-tag:
|
||||||
|
name: Create Git Tag
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- name: Validate commit exists
|
||||||
|
if: ${{ inputs.commit != '' }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if ! git cat-file -t "${{ inputs.commit }}" &>/dev/null; then
|
||||||
|
echo "::error::Commit '${{ inputs.commit }}' does not exist."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check tag does not already exist
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if gh api "repos/${{ github.repository }}/git/ref/tags/${{ inputs.tag }}" --silent 2>/dev/null; then
|
||||||
|
echo "::error::Tag '${{ inputs.tag }}' already exists."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create and push tag
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
TARGET="${{ inputs.commit || 'HEAD' }}"
|
||||||
|
COMMIT_SHA=$(git rev-parse "$TARGET")
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git tag -a "${{ inputs.tag }}" "$COMMIT_SHA" -m "Release ${{ inputs.tag }}"
|
||||||
|
git push origin "${{ inputs.tag }}"
|
||||||
|
echo "### Tag Created" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "- **Tag:** \`${{ inputs.tag }}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "- **Commit:** \`${COMMIT_SHA}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "- **Branch:** \`$(git branch -r --contains "$COMMIT_SHA" | head -1 | xargs)\`" >> "$GITHUB_STEP_SUMMARY"
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
name: Create macOS DMG
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build ${{ matrix.arch }}
|
||||||
|
runs-on: macos-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
# This creates two parallel jobs
|
||||||
|
arch: [arm64, amd64]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
# 1. Install Go from go.mod
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v6
|
||||||
|
with:
|
||||||
|
version: 10.33.0
|
||||||
|
run_install: false
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
||||||
|
|
||||||
|
# 3. Build the application bundle
|
||||||
|
- name: Build with Make
|
||||||
|
run: make build ARCH=${{ matrix.arch }} && make build-macos-app ARCH=${{ matrix.arch }}
|
||||||
|
|
||||||
|
# 4. Apply ad-hoc signing
|
||||||
|
- name: Ad-hoc Sign
|
||||||
|
run: codesign --force --deep --sign - "build/PicoClaw Launcher.app"
|
||||||
|
|
||||||
|
# 5. Install the DMG packaging tool
|
||||||
|
- name: Install create-dmg
|
||||||
|
run: brew install create-dmg
|
||||||
|
|
||||||
|
# 6. Create the DMG
|
||||||
|
- name: Create DMG
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
create-dmg \
|
||||||
|
--volname "PicoClaw Installer" \
|
||||||
|
--window-pos 200 120 \
|
||||||
|
--window-size 800 400 \
|
||||||
|
--icon-size 100 \
|
||||||
|
--icon "PicoClaw Launcher.app" 200 190 \
|
||||||
|
--hide-extension "PicoClaw Launcher.app" \
|
||||||
|
--app-drop-link 600 185 \
|
||||||
|
"dist/picoclaw-${{ matrix.arch }}.dmg" \
|
||||||
|
"build/PicoClaw Launcher.app"
|
||||||
|
|
||||||
|
# 7. Upload the DMG as a GitHub artifact
|
||||||
|
- name: Upload DMG
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: macos-dmg-${{ matrix.arch }}
|
||||||
|
path: dist/*.dmg
|
||||||
@@ -47,16 +47,21 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v6
|
||||||
|
with:
|
||||||
|
version: 10.33.0
|
||||||
|
run_install: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
- name: Setup pnpm
|
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
||||||
run: corepack enable && corepack prepare pnpm@latest --activate
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v4
|
||||||
@@ -75,11 +80,14 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Install zip
|
||||||
|
run: sudo apt-get install -y zip
|
||||||
|
|
||||||
- name: Create local tag for GoReleaser
|
- name: Create local tag for GoReleaser
|
||||||
run: git tag "${{ steps.version.outputs.version }}"
|
run: git tag "${{ steps.version.outputs.version }}"
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v7
|
||||||
with:
|
with:
|
||||||
distribution: goreleaser
|
distribution: goreleaser
|
||||||
version: ~> v2
|
version: ~> v2
|
||||||
@@ -90,6 +98,7 @@ jobs:
|
|||||||
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
||||||
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
||||||
GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }}
|
GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }}
|
||||||
|
INCLUDE_ANDROID_BUNDLE: "true"
|
||||||
NIGHTLY_BUILD: "true"
|
NIGHTLY_BUILD: "true"
|
||||||
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
||||||
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
||||||
@@ -123,7 +132,7 @@ jobs:
|
|||||||
|
|
||||||
# Collect release artifacts from goreleaser dist/
|
# Collect release artifacts from goreleaser dist/
|
||||||
ASSETS=()
|
ASSETS=()
|
||||||
for f in dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt; do
|
for f in dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt build/picoclaw-android-universal.zip; do
|
||||||
[ -f "$f" ] && ASSETS+=("$f")
|
[ -f "$f" ] && ASSETS+=("$f")
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -135,4 +144,3 @@ jobs:
|
|||||||
--prerelease \
|
--prerelease \
|
||||||
--latest=false \
|
--latest=false \
|
||||||
"${ASSETS[@]}"
|
"${ASSETS[@]}"
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,13 @@ jobs:
|
|||||||
uses: golangci/golangci-lint-action@v9
|
uses: golangci/golangci-lint-action@v9
|
||||||
with:
|
with:
|
||||||
version: v2.10.1
|
version: v2.10.1
|
||||||
|
args: --build-tags=goolm,stdjson
|
||||||
|
|
||||||
vuln_check:
|
vuln_check:
|
||||||
name: Security Check
|
name: Security Check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GOFLAGS: -tags=goolm,stdjson
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -38,10 +41,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Install govulncheck
|
||||||
|
run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4
|
||||||
|
|
||||||
- name: Run Govulncheck
|
- name: Run Govulncheck
|
||||||
uses: golang/govulncheck-action@v1
|
run: govulncheck -C . -format text ./...
|
||||||
with:
|
|
||||||
go-package: ./...
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Tests
|
name: Tests
|
||||||
@@ -59,4 +63,4 @@ jobs:
|
|||||||
run: go generate ./...
|
run: go generate ./...
|
||||||
|
|
||||||
- name: Run go test
|
- name: Run go test
|
||||||
run: go test ./...
|
run: go test -tags goolm,stdjson ./...
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
name: Create Tag and Release
|
name: Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
description: "Release tag (required, e.g. v0.2.0)"
|
description: "Existing tag to release (e.g. v0.2.0)"
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
prerelease:
|
prerelease:
|
||||||
@@ -24,35 +24,23 @@ on:
|
|||||||
default: true
|
default: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create-tag:
|
|
||||||
name: Create Git Tag
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Create and push tag
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
RELEASE_TAG: ${{ inputs.tag }}
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG"
|
|
||||||
git push origin "$RELEASE_TAG"
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
name: GoReleaser Release
|
name: GoReleaser Release
|
||||||
needs: create-tag
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
|
- name: Verify tag exists
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if ! gh api "repos/${{ github.repository }}/git/ref/tags/${{ inputs.tag }}" --silent 2>/dev/null; then
|
||||||
|
echo "::error::Tag '${{ inputs.tag }}' does not exist. Create it first using the 'Create Tag' workflow."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Checkout tag
|
- name: Checkout tag
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
@@ -65,16 +53,21 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v6
|
||||||
|
with:
|
||||||
|
version: 10.33.0
|
||||||
|
run_install: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
- name: Setup pnpm
|
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
||||||
run: corepack enable && corepack prepare pnpm@latest --activate
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v4
|
||||||
@@ -93,8 +86,11 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Install zip
|
||||||
|
run: sudo apt-get install -y zip
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v7
|
||||||
with:
|
with:
|
||||||
distribution: goreleaser
|
distribution: goreleaser
|
||||||
version: ~> v2
|
version: ~> v2
|
||||||
@@ -104,6 +100,7 @@ jobs:
|
|||||||
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||||
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
||||||
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
||||||
|
INCLUDE_ANDROID_BUNDLE: "true"
|
||||||
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
||||||
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
||||||
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
|
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
name: Close stale issues and PRs
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Run daily at 03:00 JST (18:00 UTC)
|
||||||
|
- cron: "0 18 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
stale:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Mark and close stale issues and PRs
|
||||||
|
uses: actions/stale@v10
|
||||||
|
with:
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# ── Issue: 7 days inactive → stale; 7 more days → close ──
|
||||||
|
days-before-issue-stale: 7
|
||||||
|
days-before-issue-close: 7
|
||||||
|
stale-issue-label: "stale"
|
||||||
|
stale-issue-message: >
|
||||||
|
This issue has had no activity for 7 days and has been marked as stale.
|
||||||
|
If it is still relevant, please reply or update; otherwise it will be
|
||||||
|
closed automatically in 7 days.
|
||||||
|
close-issue-message: >
|
||||||
|
This issue has been closed after 14 days of inactivity.
|
||||||
|
If it is still needed, feel free to reopen it anytime.
|
||||||
|
close-issue-reason: "not_planned"
|
||||||
|
|
||||||
|
# ── PR: 7 days inactive → stale; 7 more days → close ──
|
||||||
|
days-before-pr-stale: 7
|
||||||
|
days-before-pr-close: 7
|
||||||
|
stale-pr-label: "stale"
|
||||||
|
stale-pr-message: >
|
||||||
|
This PR has had no activity for 7 days and has been marked as stale.
|
||||||
|
If you are still working on it, please push an update or leave a comment;
|
||||||
|
otherwise it will be closed automatically in 7 days.
|
||||||
|
close-pr-message: >
|
||||||
|
This PR has been closed after 14 days of inactivity.
|
||||||
|
If you would like to continue, feel free to reopen it or submit a new PR.
|
||||||
|
|
||||||
|
# ── Protected labels (exempt from stale processing) ──
|
||||||
|
exempt-issue-labels: "pinned,keep-open,wip,do-not-close,type: roadmap"
|
||||||
|
exempt-pr-labels: "pinned,keep-open,wip,do-not-close,type: roadmap"
|
||||||
|
|
||||||
|
# ── Exempt draft PRs ──
|
||||||
|
exempt-draft-pr: true
|
||||||
|
|
||||||
|
# ── Remove stale label when activity resumes ──
|
||||||
|
remove-stale-when-updated: true
|
||||||
|
remove-issue-stale-when-updated: true
|
||||||
|
remove-pr-stale-when-updated: true
|
||||||
|
|
||||||
|
# ── Scan oldest items first so old stale items are not starved ──
|
||||||
|
ascending: true
|
||||||
|
|
||||||
|
# ── Throttle: max operations per run ──
|
||||||
|
operations-per-run: 500
|
||||||
@@ -25,6 +25,9 @@ build/
|
|||||||
# Secrets & Config (keep templates, ignore actual secrets)
|
# Secrets & Config (keep templates, ignore actual secrets)
|
||||||
.env
|
.env
|
||||||
config/config.json
|
config/config.json
|
||||||
|
.security.yml
|
||||||
|
onboard
|
||||||
|
|
||||||
|
|
||||||
# Test
|
# Test
|
||||||
coverage.txt
|
coverage.txt
|
||||||
@@ -40,6 +43,7 @@ tasks/
|
|||||||
|
|
||||||
# Plans
|
# Plans
|
||||||
docs/plans/
|
docs/plans/
|
||||||
|
docs/superpowers/
|
||||||
|
|
||||||
# Editors
|
# Editors
|
||||||
.vscode/
|
.vscode/
|
||||||
@@ -51,8 +55,21 @@ dist/
|
|||||||
|
|
||||||
# Windows Application Icon/Resource
|
# Windows Application Icon/Resource
|
||||||
*.syso
|
*.syso
|
||||||
|
.cache/
|
||||||
|
web/frontend/.pnpm-store/
|
||||||
|
_tmp_*
|
||||||
|
web/frontend/_tmp_*
|
||||||
|
|
||||||
|
# Test telegram integration
|
||||||
|
cmd/telegram/
|
||||||
|
|
||||||
# Keep embedded backend dist directory placeholder in VCS
|
# Keep embedded backend dist directory placeholder in VCS
|
||||||
!web/backend/dist/
|
!web/backend/dist/
|
||||||
web/backend/dist/*
|
web/backend/dist/*
|
||||||
!web/backend/dist/.gitkeep
|
!web/backend/dist/.gitkeep
|
||||||
|
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
docker/data
|
||||||
|
|
||||||
|
.omc/
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ linters:
|
|||||||
- exhaustruct
|
- exhaustruct
|
||||||
- funcorder
|
- funcorder
|
||||||
- gochecknoglobals
|
- gochecknoglobals
|
||||||
|
- gosmopolitan # Project legitimately uses CJK text in tests (FTS5, token counting)
|
||||||
- godot
|
- godot
|
||||||
- intrange
|
- intrange
|
||||||
- ireturn
|
- ireturn
|
||||||
@@ -61,6 +62,9 @@ linters:
|
|||||||
- usestdlibvars
|
- usestdlibvars
|
||||||
- usetesting
|
- usetesting
|
||||||
settings:
|
settings:
|
||||||
|
gomoddirectives:
|
||||||
|
replace-allow-list:
|
||||||
|
- github.com/bwmarrin/discordgo
|
||||||
errcheck:
|
errcheck:
|
||||||
check-type-assertions: true
|
check-type-assertions: true
|
||||||
check-blank: true
|
check-blank: true
|
||||||
|
|||||||
@@ -2,26 +2,31 @@
|
|||||||
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
|
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
|
||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
|
git:
|
||||||
|
ignore_tags:
|
||||||
|
- nightly
|
||||||
|
- ".*-nightly.*"
|
||||||
|
|
||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod tidy
|
|
||||||
- go generate ./...
|
- go generate ./...
|
||||||
- sh -c 'cd web/frontend && pnpm install && pnpm build:backend'
|
- sh -c 'cd web/frontend && CI=true pnpm install --frozen-lockfile && pnpm build:backend'
|
||||||
- go install github.com/tc-hib/go-winres@latest
|
- sh -c 'GOBIN="$(go env GOPATH)/bin"; mkdir -p "$GOBIN"; go install github.com/tc-hib/go-winres@v0.3.3 && "$GOBIN/go-winres" make --in web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }}'
|
||||||
- go-winres make --in web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }}
|
- sh -c 'if [ "${INCLUDE_ANDROID_BUNDLE:-}" = "true" ]; then make build-android-bundle; fi'
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=0
|
- CGO_ENABLED=0
|
||||||
tags:
|
tags:
|
||||||
|
- goolm
|
||||||
- stdjson
|
- stdjson
|
||||||
ldflags:
|
ldflags:
|
||||||
- -s -w
|
- -s -w
|
||||||
- -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
|
- -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
|
||||||
- -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
|
- -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
|
||||||
- -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
|
- -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
|
||||||
- -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ .Env.GOVERSION }}
|
- -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }}
|
||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
- windows
|
- windows
|
||||||
@@ -57,9 +62,14 @@ builds:
|
|||||||
env:
|
env:
|
||||||
- CGO_ENABLED=0
|
- CGO_ENABLED=0
|
||||||
tags:
|
tags:
|
||||||
|
- goolm
|
||||||
- stdjson
|
- stdjson
|
||||||
ldflags:
|
ldflags:
|
||||||
- -s -w
|
- -s -w
|
||||||
|
- -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
|
||||||
|
- -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
|
||||||
|
- -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
|
||||||
|
- -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }}
|
||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
- windows
|
- windows
|
||||||
@@ -90,44 +100,6 @@ builds:
|
|||||||
- goos: netbsd
|
- goos: netbsd
|
||||||
goarch: arm
|
goarch: arm
|
||||||
|
|
||||||
- id: picoclaw-launcher-tui
|
|
||||||
binary: picoclaw-launcher-tui
|
|
||||||
env:
|
|
||||||
- CGO_ENABLED=0
|
|
||||||
tags:
|
|
||||||
- stdjson
|
|
||||||
ldflags:
|
|
||||||
- -s -w
|
|
||||||
goos:
|
|
||||||
- linux
|
|
||||||
- windows
|
|
||||||
- darwin
|
|
||||||
- freebsd
|
|
||||||
- netbsd
|
|
||||||
goarch:
|
|
||||||
- amd64
|
|
||||||
- arm64
|
|
||||||
- riscv64
|
|
||||||
- loong64
|
|
||||||
- arm
|
|
||||||
- s390x
|
|
||||||
- mipsle
|
|
||||||
goarm:
|
|
||||||
- "6"
|
|
||||||
- "7"
|
|
||||||
gomips:
|
|
||||||
- softfloat
|
|
||||||
main: ./cmd/picoclaw-launcher-tui
|
|
||||||
ignore:
|
|
||||||
- goos: windows
|
|
||||||
goarch: arm
|
|
||||||
- goos: netbsd
|
|
||||||
goarch: s390x
|
|
||||||
- goos: netbsd
|
|
||||||
goarch: mips64
|
|
||||||
- goos: netbsd
|
|
||||||
goarch: arm
|
|
||||||
|
|
||||||
dockers_v2:
|
dockers_v2:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
dockerfile: docker/Dockerfile.goreleaser
|
dockerfile: docker/Dockerfile.goreleaser
|
||||||
@@ -151,7 +123,6 @@ dockers_v2:
|
|||||||
ids:
|
ids:
|
||||||
- picoclaw
|
- picoclaw
|
||||||
- picoclaw-launcher
|
- picoclaw-launcher
|
||||||
- picoclaw-launcher-tui
|
|
||||||
images:
|
images:
|
||||||
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
|
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
|
||||||
- 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
|
- 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
|
||||||
@@ -169,7 +140,6 @@ notarize:
|
|||||||
ids:
|
ids:
|
||||||
- picoclaw
|
- picoclaw
|
||||||
- picoclaw-launcher
|
- picoclaw-launcher
|
||||||
- picoclaw-launcher-tui
|
|
||||||
sign:
|
sign:
|
||||||
certificate: "{{.Env.MACOS_SIGN_P12}}"
|
certificate: "{{.Env.MACOS_SIGN_P12}}"
|
||||||
password: "{{.Env.MACOS_SIGN_PASSWORD}}"
|
password: "{{.Env.MACOS_SIGN_PASSWORD}}"
|
||||||
@@ -200,7 +170,6 @@ nfpms:
|
|||||||
ids:
|
ids:
|
||||||
- picoclaw
|
- picoclaw
|
||||||
- picoclaw-launcher
|
- picoclaw-launcher
|
||||||
- picoclaw-launcher-tui
|
|
||||||
package_name: picoclaw
|
package_name: picoclaw
|
||||||
file_name_template: >-
|
file_name_template: >-
|
||||||
{{ .PackageName }}_
|
{{ .PackageName }}_
|
||||||
@@ -237,6 +206,8 @@ changelog:
|
|||||||
|
|
||||||
release:
|
release:
|
||||||
disable: '{{ isEnvSet "NIGHTLY_BUILD" }}'
|
disable: '{{ isEnvSet "NIGHTLY_BUILD" }}'
|
||||||
|
extra_files:
|
||||||
|
- glob: ./build/picoclaw-android-universal.zip
|
||||||
footer: >-
|
footer: >-
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ We are committed to maintaining a welcoming and respectful community. Be kind, c
|
|||||||
|
|
||||||
For substantial new features, please open an issue first to discuss the design before writing code. This prevents wasted effort and ensures alignment with the project's direction.
|
For substantial new features, please open an issue first to discuss the design before writing code. This prevents wasted effort and ensures alignment with the project's direction.
|
||||||
|
|
||||||
|
For documentation contributions, prefer the layout and naming conventions in [`docs/README.md`](docs/README.md). Run `make lint-docs` after adding or moving Markdown files to catch common consistency issues early.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
@@ -64,7 +66,7 @@ For substantial new features, please open an issue first to discuss the design b
|
|||||||
```bash
|
```bash
|
||||||
make build # Build binary (runs go generate first)
|
make build # Build binary (runs go generate first)
|
||||||
make generate # Run go generate only
|
make generate # Run go generate only
|
||||||
make check # Full pre-commit check: deps + fmt + vet + test
|
make check # Full pre-commit check: deps + fmt + vet + test + docs consistency checks
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running Tests
|
### Running Tests
|
||||||
@@ -81,9 +83,10 @@ go test -bench=. -benchmem -run='^$' ./... # Run benchmarks
|
|||||||
make fmt # Format code
|
make fmt # Format code
|
||||||
make vet # Static analysis
|
make vet # Static analysis
|
||||||
make lint # Full linter run
|
make lint # Full linter run
|
||||||
|
make lint-docs # Check common documentation layout and naming conventions
|
||||||
```
|
```
|
||||||
|
|
||||||
All CI checks must pass before a PR can be merged. Run `make check` locally before pushing to catch issues early.
|
All CI checks must pass before a PR can be merged. Run `make check` locally before pushing to catch issues early, including the common docs consistency checks from `make lint-docs`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -108,7 +111,7 @@ Use descriptive branch names, e.g. `fix/telegram-timeout`, `feat/ollama-provider
|
|||||||
- Reference the related issue when relevant: `Fix session leak (#123)`.
|
- Reference the related issue when relevant: `Fix session leak (#123)`.
|
||||||
- Keep commits focused. One logical change per commit is preferred.
|
- Keep commits focused. One logical change per commit is preferred.
|
||||||
- For minor cleanups or typo fixes, squash them into a single commit before opening a PR.
|
- For minor cleanups or typo fixes, squash them into a single commit before opening a PR.
|
||||||
- Refer to https://www.conventionalcommits.org/zh-hans/v1.0.0/
|
- Refer to [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||||
|
|
||||||
### Keeping Up to Date
|
### Keeping Up to Date
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,54 @@
|
|||||||
.PHONY: all build install uninstall clean help test
|
.PHONY: all build install uninstall clean help test build-all lint-docs
|
||||||
|
|
||||||
# Build variables
|
# Build variables
|
||||||
BINARY_NAME=picoclaw
|
BINARY_NAME=picoclaw
|
||||||
BUILD_DIR=build
|
BUILD_DIR=build
|
||||||
CMD_DIR=cmd/$(BINARY_NAME)
|
CMD_DIR=cmd/$(BINARY_NAME)
|
||||||
MAIN_GO=$(CMD_DIR)/main.go
|
MAIN_GO=$(CMD_DIR)/main.go
|
||||||
|
EXT=
|
||||||
|
|
||||||
|
ifeq ($(OS),Windows_NT)
|
||||||
|
POWERSHELL=powershell -NoProfile -Command
|
||||||
|
WINDOWS_GOARCH_RAW:=$(strip $(shell go env GOARCH 2>NUL))
|
||||||
|
endif
|
||||||
|
|
||||||
# Version
|
# Version
|
||||||
VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
ifeq ($(OS),Windows_NT)
|
||||||
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
|
VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>NUL))
|
||||||
BUILD_TIME=$(shell date +%FT%T%z)
|
GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>NUL))
|
||||||
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
|
BUILD_TIME_RAW:=$(strip $(shell powershell -NoProfile -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK'"))
|
||||||
|
GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>NUL))
|
||||||
|
else
|
||||||
|
VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>/dev/null))
|
||||||
|
GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>/dev/null))
|
||||||
|
BUILD_TIME_RAW:=$(strip $(shell date +%FT%T%z))
|
||||||
|
GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>/dev/null))
|
||||||
|
endif
|
||||||
|
VERSION?=$(if $(VERSION_RAW),$(VERSION_RAW),dev)
|
||||||
|
GIT_COMMIT=$(if $(GIT_COMMIT_RAW),$(GIT_COMMIT_RAW),dev)
|
||||||
|
BUILD_TIME=$(if $(BUILD_TIME_RAW),$(BUILD_TIME_RAW),dev)
|
||||||
|
GO_VERSION=$(if $(GO_VERSION_RAW),$(GO_VERSION_RAW),unknown)
|
||||||
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
|
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
|
||||||
LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w
|
LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w
|
||||||
|
|
||||||
# Go variables
|
# Go variables
|
||||||
GO?=CGO_ENABLED=0 go
|
GO?=go
|
||||||
WEB_GO?=$(GO)
|
WEB_GO?=$(GO)
|
||||||
GOFLAGS?=-v -tags stdjson
|
CGO_ENABLED?=0
|
||||||
|
GO_BUILD_TAGS?=goolm,stdjson
|
||||||
|
GOFLAGS?=-v -tags $(GO_BUILD_TAGS)
|
||||||
|
GOCACHE?=$(CURDIR)/.cache/go-build
|
||||||
|
GOMODCACHE?=$(CURDIR)/.cache/go-mod
|
||||||
|
GOTOOLCHAIN?=local
|
||||||
|
export CGO_ENABLED
|
||||||
|
export GOCACHE
|
||||||
|
export GOMODCACHE
|
||||||
|
export GOTOOLCHAIN
|
||||||
|
comma:=,
|
||||||
|
empty:=
|
||||||
|
space:=$(empty) $(empty)
|
||||||
|
GO_BUILD_TAGS_NO_GOOLM:=$(subst $(space),$(comma),$(strip $(filter-out goolm,$(subst $(comma),$(space),$(GO_BUILD_TAGS)))))
|
||||||
|
GOFLAGS_NO_GOOLM?=-v -tags $(GO_BUILD_TAGS_NO_GOOLM)
|
||||||
|
|
||||||
# Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600).
|
# Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600).
|
||||||
#
|
#
|
||||||
@@ -41,6 +72,13 @@ define PATCH_MIPS_FLAGS
|
|||||||
fi
|
fi
|
||||||
endef
|
endef
|
||||||
|
|
||||||
|
# Patch creack/pty for loong64 support (upstream doesn't have ztypes_loong64.go)
|
||||||
|
PTY_PATCH_LOONG64=pty_dir=$$(go env GOMODCACHE)/github.com/creack/pty@v1.1.9; \
|
||||||
|
if [ -d "$$pty_dir" ] && [ ! -f "$$pty_dir/ztypes_loong64.go" ]; then \
|
||||||
|
chmod +w "$$pty_dir" 2>/dev/null || true; \
|
||||||
|
printf '//go:build linux && loong64\npackage pty\ntype (_C_int int32; _C_uint uint32)\n' > "$$pty_dir/ztypes_loong64.go"; \
|
||||||
|
fi
|
||||||
|
|
||||||
# Golangci-lint
|
# Golangci-lint
|
||||||
GOLANGCI_LINT?=golangci-lint
|
GOLANGCI_LINT?=golangci-lint
|
||||||
|
|
||||||
@@ -56,9 +94,24 @@ WORKSPACE_DIR?=$(PICOCLAW_HOME)/workspace
|
|||||||
WORKSPACE_SKILLS_DIR=$(WORKSPACE_DIR)/skills
|
WORKSPACE_SKILLS_DIR=$(WORKSPACE_DIR)/skills
|
||||||
BUILTIN_SKILLS_DIR=$(CURDIR)/skills
|
BUILTIN_SKILLS_DIR=$(CURDIR)/skills
|
||||||
|
|
||||||
|
LNCMD=ln -sf
|
||||||
|
|
||||||
# OS detection
|
# OS detection
|
||||||
UNAME_S:=$(shell uname -s)
|
ifeq ($(OS),Windows_NT)
|
||||||
UNAME_M:=$(shell uname -m)
|
UNAME_S=Windows
|
||||||
|
ifeq ($(WINDOWS_GOARCH_RAW),amd64)
|
||||||
|
UNAME_M=x86_64
|
||||||
|
else ifeq ($(WINDOWS_GOARCH_RAW),arm64)
|
||||||
|
UNAME_M=arm64
|
||||||
|
else ifeq ($(WINDOWS_GOARCH_RAW),386)
|
||||||
|
UNAME_M=x86
|
||||||
|
else
|
||||||
|
UNAME_M=$(if $(WINDOWS_GOARCH_RAW),$(WINDOWS_GOARCH_RAW),x86_64)
|
||||||
|
endif
|
||||||
|
else
|
||||||
|
UNAME_S?=$(shell uname -s)
|
||||||
|
UNAME_M?=$(shell uname -m)
|
||||||
|
endif
|
||||||
|
|
||||||
# Platform-specific settings
|
# Platform-specific settings
|
||||||
ifeq ($(UNAME_S),Linux)
|
ifeq ($(UNAME_S),Linux)
|
||||||
@@ -80,17 +133,54 @@ ifeq ($(UNAME_S),Linux)
|
|||||||
endif
|
endif
|
||||||
else ifeq ($(UNAME_S),Darwin)
|
else ifeq ($(UNAME_S),Darwin)
|
||||||
PLATFORM=darwin
|
PLATFORM=darwin
|
||||||
WEB_GO=CGO_ENABLED=1 go
|
WEB_GO=CGO_LDFLAGS="-mmacosx-version-min=10.11" CGO_CFLAGS="-mmacosx-version-min=10.11" CGO_ENABLED=1 go
|
||||||
ifeq ($(UNAME_M),x86_64)
|
ifeq ($(UNAME_M),x86_64)
|
||||||
ARCH=amd64
|
ARCH?=amd64
|
||||||
else ifeq ($(UNAME_M),arm64)
|
else ifeq ($(UNAME_M),arm64)
|
||||||
ARCH=arm64
|
ARCH?=arm64
|
||||||
else
|
else
|
||||||
ARCH=$(UNAME_M)
|
ARCH?=$(UNAME_M)
|
||||||
endif
|
endif
|
||||||
else
|
else
|
||||||
PLATFORM=$(UNAME_S)
|
PLATFORM=$(UNAME_S)
|
||||||
ARCH=$(UNAME_M)
|
ifeq ($(UNAME_M),x86_64)
|
||||||
|
ARCH?=amd64
|
||||||
|
else
|
||||||
|
ARCH?=$(UNAME_M)
|
||||||
|
endif
|
||||||
|
# Detect Windows (Git Bash / MSYS2)
|
||||||
|
IS_WINDOWS:=$(if $(findstring MINGW,$(UNAME_S)),yes,$(if $(findstring MSYS,$(UNAME_S)),yes,$(if $(findstring CYGWIN,$(UNAME_S)),yes,no)))
|
||||||
|
ifeq ($(IS_WINDOWS),yes)
|
||||||
|
EXT=.exe
|
||||||
|
LNCMD=cp
|
||||||
|
else ifeq ($(UNAME_S),windows) # failsafe for force windows build in other OS using UNAME_S=windows
|
||||||
|
EXT=.exe
|
||||||
|
endif
|
||||||
|
|
||||||
|
endif
|
||||||
|
|
||||||
|
ifeq ($(OS),Windows_NT)
|
||||||
|
PLATFORM=windows
|
||||||
|
ifeq ($(UNAME_M),x86_64)
|
||||||
|
ARCH?=amd64
|
||||||
|
else ifeq ($(UNAME_M),arm64)
|
||||||
|
ARCH?=arm64
|
||||||
|
else
|
||||||
|
ARCH?=$(UNAME_M)
|
||||||
|
endif
|
||||||
|
EXT=.exe
|
||||||
|
endif
|
||||||
|
|
||||||
|
ifneq ($(strip $(GOOS)),)
|
||||||
|
PLATFORM:=$(GOOS)
|
||||||
|
endif
|
||||||
|
|
||||||
|
ifneq ($(strip $(GOARCH)),)
|
||||||
|
ARCH:=$(GOARCH)
|
||||||
|
endif
|
||||||
|
|
||||||
|
ifeq ($(PLATFORM),windows)
|
||||||
|
EXT=.exe
|
||||||
endif
|
endif
|
||||||
|
|
||||||
BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH)
|
BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH)
|
||||||
@@ -101,44 +191,65 @@ all: build
|
|||||||
## generate: Run generate
|
## generate: Run generate
|
||||||
generate:
|
generate:
|
||||||
@echo "Run generate..."
|
@echo "Run generate..."
|
||||||
|
ifeq ($(OS),Windows_NT)
|
||||||
|
@$(POWERSHELL) "if (Test-Path -LiteralPath './$(CMD_DIR)/workspace') { Remove-Item -LiteralPath './$(CMD_DIR)/workspace' -Recurse -Force }"
|
||||||
|
@$(POWERSHELL) "$$env:GOOS=''; $$env:GOARCH=''; $(GO) generate ./..."
|
||||||
|
else
|
||||||
@rm -r ./$(CMD_DIR)/workspace 2>/dev/null || true
|
@rm -r ./$(CMD_DIR)/workspace 2>/dev/null || true
|
||||||
@$(GO) generate ./...
|
@GOOS=$$($(GO) env GOHOSTOS) GOARCH=$$($(GO) env GOHOSTARCH) $(GO) generate ./...
|
||||||
|
endif
|
||||||
@echo "Run generate complete"
|
@echo "Run generate complete"
|
||||||
|
|
||||||
## build: Build the picoclaw binary for current platform
|
## build: Build the picoclaw binary for current platform
|
||||||
build: generate
|
build: generate
|
||||||
@echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..."
|
@echo "Building $(BINARY_NAME)$(EXT) for $(PLATFORM)/$(ARCH)..."
|
||||||
|
ifeq ($(OS),Windows_NT)
|
||||||
|
@$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null"
|
||||||
|
@$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH)$(EXT) ./$(CMD_DIR)
|
||||||
|
@$(POWERSHELL) "Copy-Item -LiteralPath '$(BINARY_PATH)$(EXT)' -Destination '$(BUILD_DIR)/$(BINARY_NAME)$(EXT)' -Force"
|
||||||
|
else
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
@$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR)
|
@GOOS=$(PLATFORM) GOARCH=$(ARCH) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH)$(EXT) ./$(CMD_DIR)
|
||||||
@echo "Build complete: $(BINARY_PATH)"
|
@echo "Build complete: $(BINARY_PATH)$(EXT)"
|
||||||
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
@$(LNCMD) $(BINARY_NAME)-$(PLATFORM)-$(ARCH)$(EXT) $(BUILD_DIR)/$(BINARY_NAME)$(EXT)
|
||||||
|
endif
|
||||||
|
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)$(EXT)"
|
||||||
|
|
||||||
## build-launcher: Build the picoclaw-launcher (web console) binary
|
## build-launcher: Build the picoclaw-launcher (web console) binary
|
||||||
build-launcher:
|
build-launcher:
|
||||||
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
|
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
|
||||||
|
ifeq ($(OS),Windows_NT)
|
||||||
|
@$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null"
|
||||||
|
@$(MAKE) -C web build PLATFORM="$(PLATFORM)" ARCH="$(ARCH)" EXT="$(EXT)" OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)" GO_BUILD_TAGS="$(GO_BUILD_TAGS)"
|
||||||
|
@$(POWERSHELL) "Copy-Item -LiteralPath '$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)' -Destination '$(BUILD_DIR)/picoclaw-launcher$(EXT)' -Force"
|
||||||
|
else
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
@if [ ! -f web/backend/dist/index.html ]; then \
|
@GOOS=$(PLATFORM) GOARCH=$(ARCH) $(MAKE) -C web build \
|
||||||
echo "Building frontend..."; \
|
OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)" \
|
||||||
cd web/frontend && pnpm install && pnpm build:backend; \
|
WEB_GO='$(WEB_GO)' \
|
||||||
fi
|
GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \
|
||||||
@$(WEB_GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH) ./web/backend
|
LDFLAGS='$(LDFLAGS)'
|
||||||
@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher
|
@$(LNCMD) picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT) $(BUILD_DIR)/picoclaw-launcher$(EXT)
|
||||||
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher"
|
endif
|
||||||
|
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher$(EXT)"
|
||||||
|
|
||||||
|
build-launcher-frontend:
|
||||||
|
@$(MAKE) -C web build-frontend
|
||||||
|
|
||||||
## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
|
## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
|
||||||
build-whatsapp-native: generate
|
build-whatsapp-native: generate
|
||||||
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
|
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
|
||||||
@echo "Building for multiple platforms..."
|
@echo "Building for multiple platforms..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
GOOS=linux GOARCH=amd64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=amd64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=loong64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=riscv64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags $(GO_BUILD_TAGS_NO_GOOLM),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||||
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||||
GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
GOOS=darwin GOARCH=arm64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||||
GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
GOOS=windows GOARCH=amd64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||||
## @$(GO) build $(GOFLAGS) -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR)
|
## @$(GO) build $(GOFLAGS) -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||||
@echo "Build complete"
|
@echo "Build complete"
|
||||||
## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||||
@@ -147,45 +258,79 @@ build-whatsapp-native: generate
|
|||||||
build-linux-arm: generate
|
build-linux-arm: generate
|
||||||
@echo "Building for linux/arm (GOARM=7)..."
|
@echo "Building for linux/arm (GOARM=7)..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm"
|
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm"
|
||||||
|
|
||||||
## build-linux-arm64: Build for Linux ARM64 (e.g. Raspberry Pi Zero 2 W 64-bit)
|
## build-linux-arm64: Build for Linux ARM64 (e.g. Raspberry Pi Zero 2 W 64-bit)
|
||||||
build-linux-arm64: generate
|
build-linux-arm64: generate
|
||||||
@echo "Building for linux/arm64..."
|
@echo "Building for linux/arm64..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
GOOS=linux GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
|
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
|
||||||
|
|
||||||
## build-linux-mipsle: Build for Linux MIPS32 LE
|
## build-linux-mipsle: Build for Linux MIPS32 LE
|
||||||
build-linux-mipsle: generate
|
build-linux-mipsle: generate
|
||||||
@echo "Building for linux/mipsle (softfloat)..."
|
@echo "Building for linux/mipsle (softfloat)..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(GOFLAGS_NO_GOOLM) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||||
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle"
|
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle"
|
||||||
|
|
||||||
|
## build-android-arm64: Build core for Android ARM64
|
||||||
|
build-android-arm64: generate
|
||||||
|
@echo "Building for android/arm64..."
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 ./$(CMD_DIR)
|
||||||
|
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-android-arm64"
|
||||||
|
|
||||||
|
## build-launcher-android-arm64: Build launcher for Android ARM64
|
||||||
|
build-launcher-android-arm64:
|
||||||
|
@echo "Building picoclaw-launcher for android/arm64..."
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
@$(MAKE) -C web build-android-arm64 \
|
||||||
|
OUTPUT_ANDROID_ARM64="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-android-arm64" \
|
||||||
|
GO='$(GO)' \
|
||||||
|
LDFLAGS='$(LDFLAGS)'
|
||||||
|
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-android-arm64"
|
||||||
|
|
||||||
|
## build-android-bundle: Build core and launcher for all Android architectures and package as universal zip
|
||||||
|
build-android-bundle: generate
|
||||||
|
@echo "Building core for all Android architectures..."
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 ./$(CMD_DIR)
|
||||||
|
@echo "Building launcher for Android arm64..."
|
||||||
|
@$(MAKE) build-launcher-android-arm64
|
||||||
|
@echo "Staging JNI libs..."
|
||||||
|
@rm -rf $(BUILD_DIR)/android-staging
|
||||||
|
@mkdir -p $(BUILD_DIR)/android-staging/arm64-v8a
|
||||||
|
@cp $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 $(BUILD_DIR)/android-staging/arm64-v8a/libpicoclaw.so
|
||||||
|
@cp $(BUILD_DIR)/picoclaw-launcher-android-arm64 $(BUILD_DIR)/android-staging/arm64-v8a/libpicoclaw-web.so
|
||||||
|
@cd $(BUILD_DIR)/android-staging && zip -r ../picoclaw-android-universal.zip .
|
||||||
|
@rm -rf $(BUILD_DIR)/android-staging
|
||||||
|
@echo "All Android builds complete: $(BUILD_DIR)/picoclaw-android-universal.zip"
|
||||||
|
|
||||||
## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit)
|
## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit)
|
||||||
build-pi-zero: build-linux-arm build-linux-arm64
|
build-pi-zero: build-linux-arm build-linux-arm64
|
||||||
@echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)"
|
@echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)"
|
||||||
|
|
||||||
## build-all: Build picoclaw for all platforms
|
## build-all: Build the picoclaw core binary for all Makefile-managed platforms
|
||||||
build-all: generate
|
build-all: generate
|
||||||
@echo "Building for multiple platforms..."
|
@echo "Building for multiple platforms..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
GOOS=linux GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=loong64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
@$(PTY_PATCH_LOONG64)
|
||||||
GOOS=linux GOARCH=riscv64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=loong64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
GOOS=linux GOARCH=riscv64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(GOFLAGS_NO_GOOLM) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||||
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
|
||||||
GOOS=darwin GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||||
GOOS=windows GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||||
GOOS=netbsd GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR)
|
GOOS=netbsd GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR)
|
||||||
GOOS=netbsd GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR)
|
GOOS=netbsd GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR)
|
||||||
@echo "All builds complete"
|
@echo "Core builds complete"
|
||||||
|
|
||||||
## install: Install picoclaw to system and copy builtin skills
|
## install: Install picoclaw to system and copy builtin skills
|
||||||
install: build
|
install: build
|
||||||
@@ -216,31 +361,40 @@ uninstall-all:
|
|||||||
## clean: Remove build artifacts
|
## clean: Remove build artifacts
|
||||||
clean:
|
clean:
|
||||||
@echo "Cleaning build artifacts..."
|
@echo "Cleaning build artifacts..."
|
||||||
|
ifeq ($(OS),Windows_NT)
|
||||||
|
@$(POWERSHELL) "if (Test-Path -LiteralPath '$(BUILD_DIR)') { Remove-Item -LiteralPath '$(BUILD_DIR)' -Recurse -Force }"
|
||||||
|
else
|
||||||
@rm -rf $(BUILD_DIR)
|
@rm -rf $(BUILD_DIR)
|
||||||
|
endif
|
||||||
@echo "Clean complete"
|
@echo "Clean complete"
|
||||||
|
|
||||||
## vet: Run go vet for static analysis
|
## vet: Run go vet for static analysis
|
||||||
vet: generate
|
vet: generate
|
||||||
@packages="$$(go list ./...)" && \
|
@packages="$$($(GO) list $(GOFLAGS) ./...)" && \
|
||||||
$(GO) vet $$(printf '%s\n' "$$packages" | grep -v '^github.com/sipeed/picoclaw/web/')
|
$(GO) vet $(GOFLAGS) $$(printf '%s\n' "$$packages" | grep -v '^github.com/sipeed/picoclaw/web/')
|
||||||
@cd web/backend && $(WEB_GO) vet ./...
|
@cd web/backend && $(WEB_GO) vet ./...
|
||||||
|
|
||||||
## test: Test Go code
|
## test: Test Go code
|
||||||
test: generate
|
test: generate
|
||||||
@$(GO) test $$(go list ./... | grep -v github.com/sipeed/picoclaw/web/)
|
@$(GO) test $(GOFLAGS) $$($(GO) list $(GOFLAGS) ./... | grep -v github.com/sipeed/picoclaw/web/)
|
||||||
@cd web && make test
|
@cd web && make test
|
||||||
|
|
||||||
## fmt: Format Go code
|
## fmt: Format Go code
|
||||||
fmt:
|
fmt:
|
||||||
@$(GOLANGCI_LINT) fmt
|
@$(GOLANGCI_LINT) fmt
|
||||||
|
|
||||||
|
## lint-docs: Check common documentation layout and naming conventions
|
||||||
|
lint-docs:
|
||||||
|
@./scripts/lint-docs.sh
|
||||||
|
|
||||||
## lint: Run linters
|
## lint: Run linters
|
||||||
lint:
|
lint:
|
||||||
@$(GOLANGCI_LINT) run
|
@$(GOLANGCI_LINT) run --build-tags $(GO_BUILD_TAGS)
|
||||||
|
@./scripts/lint-docs.sh
|
||||||
|
|
||||||
## fix: Fix linting issues
|
## fix: Fix linting issues
|
||||||
fix:
|
fix:
|
||||||
@$(GOLANGCI_LINT) run --fix
|
@$(GOLANGCI_LINT) run --fix --build-tags $(GO_BUILD_TAGS)
|
||||||
|
|
||||||
## deps: Download dependencies
|
## deps: Download dependencies
|
||||||
deps:
|
deps:
|
||||||
@@ -252,8 +406,8 @@ update-deps:
|
|||||||
@$(GO) get -u ./...
|
@$(GO) get -u ./...
|
||||||
@$(GO) mod tidy
|
@$(GO) mod tidy
|
||||||
|
|
||||||
## check: Run vet, fmt, and verify dependencies
|
## check: Run deps, fmt, vet, tests, and docs consistency checks
|
||||||
check: deps fmt vet test
|
check: deps fmt vet test lint-docs
|
||||||
|
|
||||||
## run: Build and run picoclaw
|
## run: Build and run picoclaw
|
||||||
run: build
|
run: build
|
||||||
@@ -297,6 +451,36 @@ docker-clean:
|
|||||||
docker compose -f docker/docker-compose.full.yml down -v
|
docker compose -f docker/docker-compose.full.yml down -v
|
||||||
docker rmi picoclaw:latest picoclaw:full 2>/dev/null || true
|
docker rmi picoclaw:latest picoclaw:full 2>/dev/null || true
|
||||||
|
|
||||||
|
|
||||||
|
## build-macos-app: Build PicoClaw macOS .app bundle (no terminal window)
|
||||||
|
build-macos-app:build-launcher
|
||||||
|
@echo "Building macOS .app bundle..."
|
||||||
|
@if [ "$(UNAME_S)" != "Darwin" ]; then \
|
||||||
|
echo "Error: This target is only available on macOS"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@./scripts/build-macos-app.sh $(PLATFORM)-$(ARCH)
|
||||||
|
@echo "macOS .app bundle created: $(BUILD_DIR)/PicoClaw.app"
|
||||||
|
|
||||||
|
## mem: Build membench, download LOCOMO data (if needed), run benchmark, and show results
|
||||||
|
mem:
|
||||||
|
@echo "Building membench..."
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
@$(GO) build -o $(BUILD_DIR)/membench ./cmd/membench
|
||||||
|
@echo "Build complete: $(BUILD_DIR)/membench"
|
||||||
|
@if [ ! -f $(BUILD_DIR)/memdata/locomo10.json ]; then \
|
||||||
|
echo "Downloading LOCOMO dataset..."; \
|
||||||
|
mkdir -p $(BUILD_DIR)/memdata; \
|
||||||
|
curl -sfL "https://raw.githubusercontent.com/snap-research/locomo/main/data/locomo10.json" \
|
||||||
|
-o $(BUILD_DIR)/memdata/locomo10.json && [ -s $(BUILD_DIR)/memdata/locomo10.json ] || { echo "Error: LOCOMO download failed"; exit 1; }; \
|
||||||
|
echo "Download complete"; \
|
||||||
|
else \
|
||||||
|
echo "LOCOMO dataset already exists, skipping download"; \
|
||||||
|
fi
|
||||||
|
@echo "Running benchmark..."
|
||||||
|
@rm -rf $(BUILD_DIR)/memout
|
||||||
|
@$(BUILD_DIR)/membench run --data $(BUILD_DIR)/memdata --out $(BUILD_DIR)/memout --budget 4000
|
||||||
|
|
||||||
## help: Show this help message
|
## help: Show this help message
|
||||||
help:
|
help:
|
||||||
@echo "picoclaw Makefile"
|
@echo "picoclaw Makefile"
|
||||||
|
|||||||
@@ -1,937 +0,0 @@
|
|||||||
<div align="center">
|
|
||||||
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
|
||||||
|
|
||||||
<h1>PicoClaw: 基于Go语言的超高效 AI 助手</h1>
|
|
||||||
|
|
||||||
<h3>10$硬件 · 10MB内存 · 1秒启动 · 皮皮虾,我们走!</h3>
|
|
||||||
<p>
|
|
||||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
|
||||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
|
||||||
<br>
|
|
||||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
|
||||||
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
|
|
||||||
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
|
||||||
<br>
|
|
||||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
|
||||||
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
|
||||||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
🦐 **PicoClaw** 是一个受 [nanobot](https://github.com/HKUDS/nanobot) 启发的超轻量级个人 AI 助手。它采用 **Go 语言** 从零重构,经历了一个“自举”过程——即由 AI Agent 自身驱动了整个架构迁移和代码优化。
|
|
||||||
|
|
||||||
⚡️ **极致轻量**:可在 **10 美元** 的硬件上运行,内存占用 **<10MB**。这意味着比 OpenClaw 节省 99% 的内存,比 Mac mini 便宜 98%!
|
|
||||||
|
|
||||||
<table align="center">
|
|
||||||
<tr align="center">
|
|
||||||
<td align="center" valign="top">
|
|
||||||
<p align="center">
|
|
||||||
<img src="assets/picoclaw_mem.gif" width="360" height="240">
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
<td align="center" valign="top">
|
|
||||||
<p align="center">
|
|
||||||
<img src="assets/licheervnano.png" width="400" height="240">
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
注意:人手有限,中文文档可能略有滞后,请优先查看英文文档。
|
|
||||||
|
|
||||||
> [!CAUTION]
|
|
||||||
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
|
|
||||||
>
|
|
||||||
> - **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。
|
|
||||||
> - **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
|
|
||||||
> - **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。
|
|
||||||
> - **注意:** picoclaw正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在1.0正式版发布前,请不要将其部署到生产环境中
|
|
||||||
> - **注意:** picoclaw最近合并了大量PRs,近期版本可能内存占用较大(10~20MB),我们将在功能较为收敛后进行资源占用优化.
|
|
||||||
|
|
||||||
## 📢 新闻 (News)
|
|
||||||
|
|
||||||
2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/ROADMAP.md), 期待你的参与!
|
|
||||||
|
|
||||||
2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。
|
|
||||||
🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。
|
|
||||||
|
|
||||||
2026-02-09 🎉 **PicoClaw 正式发布!** 仅用 1 天构建,旨在将 AI Agent 带入 10 美元硬件与 <10MB 内存的世界。🦐 PicoClaw(皮皮虾),我们走!
|
|
||||||
|
|
||||||
## ✨ 特性
|
|
||||||
|
|
||||||
🪶 **超轻量级**: 核心功能内存占用 <10MB — 比 Clawdbot 小 99%。
|
|
||||||
|
|
||||||
💰 **极低成本**: 高效到足以在 10 美元的硬件上运行 — 比 Mac mini 便宜 98%。
|
|
||||||
|
|
||||||
⚡️ **闪电启动**: 启动速度快 400 倍,即使在 0.6GHz 单核处理器上也能在 1 秒内启动。
|
|
||||||
|
|
||||||
🌍 **真正可移植**: 跨 RISC-V、ARM、MIPS 和 x86 架构的单二进制文件,一键运行!
|
|
||||||
|
|
||||||
🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。
|
|
||||||
|
|
||||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
|
||||||
| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
|
|
||||||
| **语言** | TypeScript | Python | **Go** |
|
|
||||||
| **RAM** | >1GB | >100MB | **< 10MB** |
|
|
||||||
| **启动时间**</br>(0.8GHz core) | >500s | >30s | **<1s** |
|
|
||||||
| **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板**</br>**低至 $10** |
|
|
||||||
|
|
||||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
|
||||||
|
|
||||||
## 🦾 演示
|
|
||||||
|
|
||||||
### 🛠️ 标准助手工作流
|
|
||||||
|
|
||||||
<table align="center">
|
|
||||||
<tr align="center">
|
|
||||||
<th><p align="center">🧩 全栈工程师模式</p></th>
|
|
||||||
<th><p align="center">🗂️ 日志与规划管理</p></th>
|
|
||||||
<th><p align="center">🔎 网络搜索与学习</p></th>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
|
||||||
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
|
||||||
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center">开发 • 部署 • 扩展</td>
|
|
||||||
<td align="center">日程 • 自动化 • 记忆</td>
|
|
||||||
<td align="center">发现 • 洞察 • 趋势</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
### 📱 在手机上轻松运行
|
|
||||||
|
|
||||||
picoclaw 可以将你10年前的老旧手机废物利用,变身成为你的AI助理!快速指南:
|
|
||||||
|
|
||||||
1. 先去应用商店下载安装Termux
|
|
||||||
2. 打开后执行指令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 注意: 下面的v0.1.1 可以换为你实际看到的最新版本
|
|
||||||
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
|
|
||||||
chmod +x picoclaw-linux-arm64
|
|
||||||
pkg install proot
|
|
||||||
termux-chroot ./picoclaw-linux-arm64 onboard
|
|
||||||
```
|
|
||||||
|
|
||||||
然后跟随下面的“快速开始”章节继续配置picoclaw即可使用!
|
|
||||||
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
|
||||||
|
|
||||||
### 🐜 创新的低占用部署
|
|
||||||
|
|
||||||
PicoClaw 几乎可以部署在任何 Linux 设备上!
|
|
||||||
|
|
||||||
- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手。
|
|
||||||
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维。
|
|
||||||
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控。
|
|
||||||
|
|
||||||
[https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4](https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4)
|
|
||||||
|
|
||||||
🌟 更多部署案例敬请期待!
|
|
||||||
|
|
||||||
## 📦 安装
|
|
||||||
|
|
||||||
### 使用预编译二进制文件安装
|
|
||||||
|
|
||||||
从 [Release 页面](https://github.com/sipeed/picoclaw/releases) 下载适用于您平台的固件。
|
|
||||||
|
|
||||||
### 从源码安装(获取最新特性,开发推荐)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
|
||||||
|
|
||||||
cd picoclaw
|
|
||||||
make deps
|
|
||||||
|
|
||||||
# 构建(无需安装)
|
|
||||||
make build
|
|
||||||
|
|
||||||
# 为多平台构建
|
|
||||||
make build-all
|
|
||||||
|
|
||||||
# 构建并安装
|
|
||||||
make install
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🐳 Docker Compose
|
|
||||||
|
|
||||||
您也可以使用 Docker Compose 运行 PicoClaw,无需在本地安装任何环境。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 克隆仓库
|
|
||||||
git clone https://github.com/sipeed/picoclaw.git
|
|
||||||
cd picoclaw
|
|
||||||
|
|
||||||
# 2. 首次运行 — 自动生成 docker/data/config.json 后退出
|
|
||||||
docker compose -f docker/docker-compose.yml --profile gateway up
|
|
||||||
# 容器打印 "First-run setup complete." 后自动停止
|
|
||||||
|
|
||||||
# 3. 填写 API Key 等配置
|
|
||||||
vim docker/data/config.json # 设置 provider API key、Bot Token 等
|
|
||||||
|
|
||||||
# 4. 正式启动
|
|
||||||
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
> [!TIP]
|
|
||||||
> **Docker 用户**: 默认情况下, Gateway 监听 `127.0.0.1`,该端口不会暴露到容器外。如果需要通过端口映射访问健康检查接口,请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json`。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 5. 查看日志
|
|
||||||
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
|
||||||
|
|
||||||
# 6. 停止
|
|
||||||
docker compose -f docker/docker-compose.yml --profile gateway down
|
|
||||||
```
|
|
||||||
|
|
||||||
### Agent 模式 (一次性运行)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 提问
|
|
||||||
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2 等于几?"
|
|
||||||
|
|
||||||
# 交互模式
|
|
||||||
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
|
||||||
```
|
|
||||||
|
|
||||||
### 更新镜像
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker/docker-compose.yml pull
|
|
||||||
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🚀 快速开始
|
|
||||||
|
|
||||||
> [!TIP]
|
|
||||||
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。获取 API Key: [火山引擎 (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。网络搜索是 **可选的** — 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)。
|
|
||||||
|
|
||||||
**1. 初始化 (Initialize)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
picoclaw onboard
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. 配置 (Configure)** (`~/.picoclaw/config.json`)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"workspace": "~/.picoclaw/workspace",
|
|
||||||
"model_name": "gpt-5.4",
|
|
||||||
"max_tokens": 8192,
|
|
||||||
"temperature": 0.7,
|
|
||||||
"max_tool_iterations": 20
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"model_list": [
|
|
||||||
{
|
|
||||||
"model_name": "ark-code-latest",
|
|
||||||
"model": "volcengine/ark-code-latest",
|
|
||||||
"api_key": "sk-your-api-key",
|
|
||||||
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model_name": "gpt-5.4",
|
|
||||||
"model": "openai/gpt-5.4",
|
|
||||||
"api_key": "your-api-key",
|
|
||||||
"request_timeout": 300
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model_name": "claude-sonnet-4.6",
|
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
|
||||||
"api_key": "your-anthropic-key"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"tools": {
|
|
||||||
"web": {
|
|
||||||
"brave": {
|
|
||||||
"enabled": false,
|
|
||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
|
||||||
"max_results": 5
|
|
||||||
},
|
|
||||||
"tavily": {
|
|
||||||
"enabled": false,
|
|
||||||
"api_key": "YOUR_TAVILY_API_KEY",
|
|
||||||
"max_results": 5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"cron": {
|
|
||||||
"exec_timeout_minutes": 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。
|
|
||||||
> `request_timeout` 为可选项,单位为秒。若省略或设置为 `<= 0`,PicoClaw 使用默认超时(120 秒)。
|
|
||||||
|
|
||||||
**3. 获取 API Key**
|
|
||||||
|
|
||||||
* **LLM 提供商**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
|
||||||
* **网络搜索** (可选): [Tavily](https://tavily.com) - 专为 AI Agent 优化 (1000 请求/月) · [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月)
|
|
||||||
|
|
||||||
> **注意**: 完整的配置模板请参考 `config.example.json`。
|
|
||||||
|
|
||||||
**4. 对话 (Chat)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
picoclaw agent -m "2+2 等于几?"
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
就是这样!您在 2 分钟内就拥有了一个可工作的 AI 助手。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💬 聊天应用集成 (Chat Apps)
|
|
||||||
|
|
||||||
PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。
|
|
||||||
|
|
||||||
> **注意**: 所有 Webhook 类渠道(LINE、WeCom 等)均挂载在同一个 Gateway HTTP 服务器上(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`),无需为每个渠道单独配置端口。注意:飞书(Feishu)使用 WebSocket/SDK 模式,不通过该共享 HTTP webhook 服务器接收消息。
|
|
||||||
|
|
||||||
### 核心渠道
|
|
||||||
|
|
||||||
| 渠道 | 设置难度 | 特性说明 | 文档链接 |
|
|
||||||
| -------------------- | ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](docs/channels/telegram/README.zh.md) |
|
|
||||||
| **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](docs/channels/discord/README.zh.md) |
|
|
||||||
| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) |
|
|
||||||
| **Matrix** | ⭐⭐ 中等 | 联邦协议,支持自建 homeserver 与公开服务器 | [查看文档](docs/channels/matrix/README.zh.md) |
|
|
||||||
| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) |
|
|
||||||
| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) |
|
|
||||||
| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)、自建应用(API)和智能机器人(AI Bot) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) / [AI Bot 文档](docs/channels/wecom/wecom_aibot/README.zh.md) |
|
|
||||||
| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](docs/channels/feishu/README.zh.md) |
|
|
||||||
| **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) |
|
|
||||||
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
|
|
||||||
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
|
|
||||||
|
|
||||||
### Telegram 命令注册(启动时自动同步)
|
|
||||||
|
|
||||||
PicoClaw 现在使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。
|
|
||||||
Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行统一走 Agent Loop 中的 commands executor。
|
|
||||||
|
|
||||||
如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。
|
|
||||||
|
|
||||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
|
||||||
|
|
||||||
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
|
||||||
|
|
||||||
\*\*阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai](https://clawdchat.ai)
|
|
||||||
|
|
||||||
## ⚙️ 配置详解
|
|
||||||
|
|
||||||
配置文件路径: `~/.picoclaw/config.json`
|
|
||||||
|
|
||||||
### 环境变量
|
|
||||||
|
|
||||||
你可以使用环境变量覆盖默认路径。这对于便携安装、容器化部署或将 picoclaw 作为系统服务运行非常有用。这些变量是独立的,控制不同的路径。
|
|
||||||
|
|
||||||
| 变量 | 描述 | 默认路径 |
|
|
||||||
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
|
||||||
| `PICOCLAW_CONFIG` | 覆盖配置文件的路径。这直接告诉 picoclaw 加载哪个 `config.json`,忽略所有其他位置。 | `~/.picoclaw/config.json` |
|
|
||||||
| `PICOCLAW_HOME` | 覆盖 picoclaw 数据根目录。这会更改 `workspace` 和其他数据目录的默认位置。 | `~/.picoclaw` |
|
|
||||||
|
|
||||||
**示例:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 使用特定的配置文件运行 picoclaw
|
|
||||||
# 工作区路径将从该配置文件中读取
|
|
||||||
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
|
|
||||||
|
|
||||||
# 在 /opt/picoclaw 中存储所有数据运行 picoclaw
|
|
||||||
# 配置将从默认的 ~/.picoclaw/config.json 加载
|
|
||||||
# 工作区将在 /opt/picoclaw/workspace 创建
|
|
||||||
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
|
||||||
|
|
||||||
# 同时使用两者进行完全自定义设置
|
|
||||||
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
|
||||||
```
|
|
||||||
|
|
||||||
### 工作区布局 (Workspace Layout)
|
|
||||||
|
|
||||||
PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`):
|
|
||||||
|
|
||||||
```
|
|
||||||
~/.picoclaw/workspace/
|
|
||||||
├── sessions/ # 对话会话和历史
|
|
||||||
├── memory/ # 长期记忆 (MEMORY.md)
|
|
||||||
├── state/ # 持久化状态 (最后一次频道等)
|
|
||||||
├── cron/ # 定时任务数据库
|
|
||||||
├── skills/ # 自定义技能
|
|
||||||
├── AGENTS.md # Agent 行为指南
|
|
||||||
├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次)
|
|
||||||
├── IDENTITY.md # Agent 身份设定
|
|
||||||
├── SOUL.md # Agent 灵魂/性格
|
|
||||||
└── USER.md # 用户偏好
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
### 技能来源 (Skill Sources)
|
|
||||||
|
|
||||||
默认情况下,技能会按以下顺序加载:
|
|
||||||
|
|
||||||
1. `~/.picoclaw/workspace/skills`(工作区)
|
|
||||||
2. `~/.picoclaw/skills`(全局)
|
|
||||||
3. `<current-working-directory>/skills`(内置)
|
|
||||||
|
|
||||||
在高级/测试场景下,可通过以下环境变量覆盖内置技能目录:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
|
||||||
```
|
|
||||||
|
|
||||||
### 统一命令执行策略
|
|
||||||
|
|
||||||
- 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。
|
|
||||||
- Channel 适配器不再在本地消费通用命令;它们只负责把入站文本转发到 bus/agent 路径。Telegram 仍会在启动时自动注册其支持的命令菜单。
|
|
||||||
- 未注册的斜杠命令(例如 `/foo`)会透传给 LLM 按普通输入处理。
|
|
||||||
- 已注册但当前 channel 不支持的命令(例如 WhatsApp 上的 `/show`)会返回明确的用户可见错误,并停止后续处理。
|
|
||||||
### 心跳 / 周期性任务 (Heartbeat)
|
|
||||||
|
|
||||||
PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# Periodic Tasks
|
|
||||||
|
|
||||||
- Check my email for important messages
|
|
||||||
- Review my calendar for upcoming events
|
|
||||||
- Check the weather forecast
|
|
||||||
```
|
|
||||||
|
|
||||||
Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具执行任务。
|
|
||||||
|
|
||||||
#### 使用 Spawn 的异步任务
|
|
||||||
|
|
||||||
对于耗时较长的任务(网络搜索、API 调用),使用 `spawn` 工具创建一个 **子 Agent (subagent)**:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# Periodic Tasks
|
|
||||||
|
|
||||||
## Quick Tasks (respond directly)
|
|
||||||
|
|
||||||
- Report current time
|
|
||||||
|
|
||||||
## Long Tasks (use spawn for async)
|
|
||||||
|
|
||||||
- Search the web for AI news and summarize
|
|
||||||
- Check email and report important messages
|
|
||||||
```
|
|
||||||
|
|
||||||
**关键行为:**
|
|
||||||
|
|
||||||
| 特性 | 描述 |
|
|
||||||
| ---------------- | ---------------------------------------- |
|
|
||||||
| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 |
|
|
||||||
| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 |
|
|
||||||
| **message tool** | 子 Agent 通过 message 工具直接与用户通信 |
|
|
||||||
| **非阻塞** | spawn 后,心跳继续处理下一个任务 |
|
|
||||||
|
|
||||||
#### 子 Agent 通信原理
|
|
||||||
|
|
||||||
```
|
|
||||||
心跳触发 (Heartbeat triggers)
|
|
||||||
↓
|
|
||||||
Agent 读取 HEARTBEAT.md
|
|
||||||
↓
|
|
||||||
对于长任务: spawn 子 Agent
|
|
||||||
↓ ↓
|
|
||||||
继续下一个任务 子 Agent 独立工作
|
|
||||||
↓ ↓
|
|
||||||
所有任务完成 子 Agent 使用 "message" 工具
|
|
||||||
↓ ↓
|
|
||||||
响应 HEARTBEAT_OK 用户直接收到结果
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
子 Agent 可以访问工具(message, web_search 等),并且无需通过主 Agent 即可独立与用户通信。
|
|
||||||
|
|
||||||
**配置:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"heartbeat": {
|
|
||||||
"enabled": true,
|
|
||||||
"interval": 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| 选项 | 默认值 | 描述 |
|
|
||||||
| ---------- | ------ | ---------------------------- |
|
|
||||||
| `enabled` | `true` | 启用/禁用心跳 |
|
|
||||||
| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) |
|
|
||||||
|
|
||||||
**环境变量:**
|
|
||||||
|
|
||||||
- `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用
|
|
||||||
- `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔
|
|
||||||
|
|
||||||
### 提供商 (Providers)
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,任意渠道的音频消息都将在 Agent 层面自动转录为文字。
|
|
||||||
|
|
||||||
| 提供商 | 用途 | 获取 API Key |
|
|
||||||
| -------------------- | ---------------------------- | -------------------------------------------------------------------- |
|
|
||||||
| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
|
|
||||||
| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) |
|
|
||||||
| `volcengine` | LLM (火山引擎直连) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
|
||||||
| `openrouter` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
|
|
||||||
| `anthropic` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
|
|
||||||
| `openai` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) |
|
|
||||||
| `deepseek` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
|
|
||||||
| `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
|
||||||
| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) |
|
|
||||||
| `cerebras` | LLM (Cerebras 直连) | [cerebras.ai](https://cerebras.ai) |
|
|
||||||
|
|
||||||
### 模型配置 (model_list)
|
|
||||||
|
|
||||||
> **新功能!** PicoClaw 现在采用**以模型为中心**的配置方式。只需使用 `厂商/模型` 格式(如 `zhipu/glm-4.7`)即可添加新的 provider——**无需修改任何代码!**
|
|
||||||
|
|
||||||
该设计同时支持**多 Agent 场景**,提供灵活的 Provider 选择:
|
|
||||||
|
|
||||||
- **不同 Agent 使用不同 Provider**:每个 Agent 可以使用自己的 LLM provider
|
|
||||||
- **模型回退(Fallback)**:配置主模型和备用模型,提高可靠性
|
|
||||||
- **负载均衡**:在多个 API 端点之间分配请求
|
|
||||||
- **集中化配置**:在一个地方管理所有 provider
|
|
||||||
|
|
||||||
#### 📋 所有支持的厂商
|
|
||||||
|
|
||||||
| 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key |
|
|
||||||
| ------------------- | ----------------- | --------------------------------------------------- | --------- | ----------------------------------------------------------------- |
|
|
||||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) |
|
|
||||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) |
|
|
||||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
|
||||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) |
|
|
||||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取密钥](https://aistudio.google.com/api-keys) |
|
|
||||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取密钥](https://console.groq.com) |
|
|
||||||
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取密钥](https://platform.moonshot.cn) |
|
|
||||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) |
|
|
||||||
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) |
|
|
||||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) |
|
|
||||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
|
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
|
|
||||||
| **火山引擎(Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
|
||||||
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
|
||||||
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取密钥](https://www.byteplus.com) |
|
|
||||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) |
|
|
||||||
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [获取 Token](https://modelscope.cn/my/tokens) |
|
|
||||||
| **Azure OpenAI** | `azure/` | `https://{resource}.openai.azure.com` | Azure | [获取密钥](https://portal.azure.com) |
|
|
||||||
| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
|
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
|
||||||
|
|
||||||
#### 基础配置示例
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"model_list": [
|
|
||||||
{
|
|
||||||
"model_name": "ark-code-latest",
|
|
||||||
"model": "volcengine/ark-code-latest",
|
|
||||||
"api_key": "sk-your-api-key"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model_name": "gpt-5.4",
|
|
||||||
"model": "openai/gpt-5.4",
|
|
||||||
"api_key": "sk-your-openai-key"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model_name": "claude-sonnet-4.6",
|
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
|
||||||
"api_key": "sk-ant-your-key"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model_name": "glm-4.7",
|
|
||||||
"model": "zhipu/glm-4.7",
|
|
||||||
"api_key": "your-zhipu-key"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"model": "gpt-5.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 各厂商配置示例
|
|
||||||
|
|
||||||
**OpenAI**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"model_name": "gpt-5.4",
|
|
||||||
"model": "openai/gpt-5.4",
|
|
||||||
"api_key": "sk-..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**火山引擎(Doubao)**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"model_name": "ark-code-latest",
|
|
||||||
"model": "volcengine/ark-code-latest",
|
|
||||||
"api_key": "sk-..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**智谱 AI (GLM)**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"model_name": "glm-4.7",
|
|
||||||
"model": "zhipu/glm-4.7",
|
|
||||||
"api_key": "your-key"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**DeepSeek**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"model_name": "deepseek-chat",
|
|
||||||
"model": "deepseek/deepseek-chat",
|
|
||||||
"api_key": "sk-..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Anthropic (使用 OAuth)**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"model_name": "claude-sonnet-4.6",
|
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
|
||||||
"auth_method": "oauth"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。
|
|
||||||
|
|
||||||
**Anthropic Messages API(原生格式)**
|
|
||||||
|
|
||||||
用于直接访问 Anthropic API 或仅支持 Anthropic 原生消息格式的自定义端点:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"model_name": "claude-opus-4-6",
|
|
||||||
"model": "anthropic-messages/claude-opus-4-6",
|
|
||||||
"api_key": "sk-ant-your-key",
|
|
||||||
"api_base": "https://api.anthropic.com"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> 使用 `anthropic-messages` 协议的场景:
|
|
||||||
> - 使用仅支持 Anthropic 原生 `/v1/messages` 端点的第三方代理(不支持 OpenAI 兼容的 `/v1/chat/completions`)
|
|
||||||
> - 连接到 MiniMax、Synthetic 等需要 Anthropic 原生消息格式的服务
|
|
||||||
> - 现有的 `anthropic` 协议返回 404 错误(说明端点不支持 OpenAI 兼容格式)
|
|
||||||
>
|
|
||||||
> **注意:** `anthropic` 协议使用 OpenAI 兼容格式(`/v1/chat/completions`),而 `anthropic-messages` 使用 Anthropic 原生格式(`/v1/messages`)。请根据端点支持的格式选择。
|
|
||||||
|
|
||||||
**Ollama (本地)**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"model_name": "llama3",
|
|
||||||
"model": "ollama/llama3"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**自定义代理/API**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"model_name": "my-custom-model",
|
|
||||||
"model": "openai/custom-model",
|
|
||||||
"api_base": "https://my-proxy.com/v1",
|
|
||||||
"api_key": "sk-...",
|
|
||||||
"request_timeout": 300
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 负载均衡
|
|
||||||
|
|
||||||
为同一个模型名称配置多个端点——PicoClaw 会自动在它们之间轮询:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"model_list": [
|
|
||||||
{
|
|
||||||
"model_name": "gpt-5.4",
|
|
||||||
"model": "openai/gpt-5.4",
|
|
||||||
"api_base": "https://api1.example.com/v1",
|
|
||||||
"api_key": "sk-key1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model_name": "gpt-5.4",
|
|
||||||
"model": "openai/gpt-5.4",
|
|
||||||
"api_base": "https://api2.example.com/v1",
|
|
||||||
"api_key": "sk-key2"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 从旧的 `providers` 配置迁移
|
|
||||||
|
|
||||||
旧的 `providers` 配置格式**已弃用**,但为向后兼容仍支持。
|
|
||||||
|
|
||||||
**旧配置(已弃用):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": {
|
|
||||||
"zhipu": {
|
|
||||||
"api_key": "your-key",
|
|
||||||
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"provider": "zhipu",
|
|
||||||
"model": "glm-4.7"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**新配置(推荐):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"model_list": [
|
|
||||||
{
|
|
||||||
"model_name": "glm-4.7",
|
|
||||||
"model": "zhipu/glm-4.7",
|
|
||||||
"api_key": "your-key"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"model": "glm-4.7"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
详细的迁移指南请参考 [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md)。
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>智谱 (Zhipu) 配置示例</b></summary>
|
|
||||||
|
|
||||||
**1. 获取 API key 和 base URL**
|
|
||||||
|
|
||||||
- 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
|
|
||||||
|
|
||||||
**2. 配置**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"workspace": "~/.picoclaw/workspace",
|
|
||||||
"model": "glm-4.7",
|
|
||||||
"max_tokens": 8192,
|
|
||||||
"temperature": 0.7,
|
|
||||||
"max_tool_iterations": 20
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"providers": {
|
|
||||||
"zhipu": {
|
|
||||||
"api_key": "Your API Key",
|
|
||||||
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**3. 运行**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
picoclaw agent -m "你好"
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>完整配置示例</b></summary>
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"model": "anthropic/claude-opus-4-5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"session": {
|
|
||||||
"dm_scope": "per-channel-peer",
|
|
||||||
"backlog_limit": 20
|
|
||||||
},
|
|
||||||
"providers": {
|
|
||||||
"openrouter": {
|
|
||||||
"api_key": "sk-or-v1-xxx"
|
|
||||||
},
|
|
||||||
"groq": {
|
|
||||||
"api_key": "gsk_xxx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"channels": {
|
|
||||||
"telegram": {
|
|
||||||
"enabled": true,
|
|
||||||
"token": "123456:ABC...",
|
|
||||||
"allow_from": ["123456789"]
|
|
||||||
},
|
|
||||||
"discord": {
|
|
||||||
"enabled": true,
|
|
||||||
"token": "",
|
|
||||||
"allow_from": [""]
|
|
||||||
},
|
|
||||||
"whatsapp": {
|
|
||||||
"enabled": false
|
|
||||||
},
|
|
||||||
"feishu": {
|
|
||||||
"enabled": false,
|
|
||||||
"app_id": "cli_xxx",
|
|
||||||
"app_secret": "xxx",
|
|
||||||
"encrypt_key": "",
|
|
||||||
"verification_token": "",
|
|
||||||
"allow_from": []
|
|
||||||
},
|
|
||||||
"qq": {
|
|
||||||
"enabled": false,
|
|
||||||
"app_id": "",
|
|
||||||
"app_secret": "",
|
|
||||||
"allow_from": []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tools": {
|
|
||||||
"web": {
|
|
||||||
"brave": {
|
|
||||||
"enabled": false,
|
|
||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
|
||||||
"max_results": 5
|
|
||||||
},
|
|
||||||
"duckduckgo": {
|
|
||||||
"enabled": true,
|
|
||||||
"max_results": 5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"cron": {
|
|
||||||
"exec_timeout_minutes": 5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"heartbeat": {
|
|
||||||
"enabled": true,
|
|
||||||
"interval": 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## CLI 命令行参考
|
|
||||||
|
|
||||||
| 命令 | 描述 |
|
|
||||||
| ------------------------- | ------------------ |
|
|
||||||
| `picoclaw onboard` | 初始化配置和工作区 |
|
|
||||||
| `picoclaw agent -m "..."` | 与 Agent 对话 |
|
|
||||||
| `picoclaw agent` | 交互式聊天模式 |
|
|
||||||
| `picoclaw gateway` | 启动网关 (Gateway) |
|
|
||||||
| `picoclaw status` | 显示状态 |
|
|
||||||
| `picoclaw cron list` | 列出所有定时任务 |
|
|
||||||
| `picoclaw cron add ...` | 添加定时任务 |
|
|
||||||
|
|
||||||
### 定时任务 / 提醒 (Scheduled Tasks)
|
|
||||||
|
|
||||||
PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
|
|
||||||
|
|
||||||
- **一次性提醒**: "Remind me in 10 minutes" (10分钟后提醒我) → 10分钟后触发一次
|
|
||||||
- **重复任务**: "Remind me every 2 hours" (每2小时提醒我) → 每2小时触发
|
|
||||||
- **Cron 表达式**: "Remind me at 9am daily" (每天上午9点提醒我) → 使用 cron 表达式
|
|
||||||
|
|
||||||
任务存储在 `~/.picoclaw/workspace/cron/` 中并自动处理。
|
|
||||||
|
|
||||||
## 🤝 贡献与路线图 (Roadmap)
|
|
||||||
|
|
||||||
欢迎提交 PR!代码库刻意保持小巧和可读。🤗
|
|
||||||
|
|
||||||
路线图即将发布...
|
|
||||||
|
|
||||||
开发者群组正在组建中,入群门槛:至少合并过 1 个 PR。
|
|
||||||
|
|
||||||
用户群组:
|
|
||||||
|
|
||||||
Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
|
||||||
|
|
||||||
<img src="assets/wechat.png" alt="PicoClaw" width="512">
|
|
||||||
|
|
||||||
## 🐛 疑难解答 (Troubleshooting)
|
|
||||||
|
|
||||||
### 网络搜索提示 "API 配置问题"
|
|
||||||
|
|
||||||
如果您尚未配置搜索 API Key,这是正常的。PicoClaw 会提供手动搜索的帮助链接。
|
|
||||||
|
|
||||||
启用网络搜索:
|
|
||||||
|
|
||||||
1. 在 [https://tavily.com](https://tavily.com) (1000 次免费) 或 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (2000 次免费)
|
|
||||||
2. 添加到 `~/.picoclaw/config.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"tools": {
|
|
||||||
"web": {
|
|
||||||
"brave": {
|
|
||||||
"enabled": false,
|
|
||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
|
||||||
"max_results": 5
|
|
||||||
},
|
|
||||||
"duckduckgo": {
|
|
||||||
"enabled": true,
|
|
||||||
"max_results": 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 遇到内容过滤错误 (Content Filtering Errors)
|
|
||||||
|
|
||||||
某些提供商(如智谱)有严格的内容过滤。尝试改写您的问题或使用其他模型。
|
|
||||||
|
|
||||||
### Telegram bot 提示 "Conflict: terminated by other getUpdates"
|
|
||||||
|
|
||||||
这表示有另一个机器人实例正在运行。请确保同一时间只有一个 `picoclaw gateway` 进程在运行。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 API Key 对比
|
|
||||||
|
|
||||||
| 服务 | 免费层级 | 适用场景 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
|
|
||||||
| **火山引擎 CodingPlan** | 9.9 元/首月 | 最适合国内用户,多种 SOTA 模型(豆包、DeepSeek 等) |
|
|
||||||
| **智谱 (Zhipu)** | 200K tokens/月 | 适合中国用户 |
|
|
||||||
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
|
|
||||||
| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
|
|
||||||
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
|
|
||||||
| **LongCat** | 最多 5M tokens/天 | 推理速度快 (免费额度) |
|
|
||||||
| **ModelScope (魔搭)** | 2000 次请求/天 | 免费推理 (Qwen, GLM, DeepSeek 等) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
|
|
||||||
</div>
|
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 449 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 360 KiB |
|
After Width: | Height: | Size: 233 KiB |
@@ -0,0 +1,412 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/seahorse"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EvalResult holds per-sample evaluation results for one mode.
|
||||||
|
type EvalResult struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
SampleID string `json:"sampleId"`
|
||||||
|
QAResults []QAResult `json:"qaResults"`
|
||||||
|
Agg AggMetrics `json:"aggregated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QAResult holds metrics for a single QA pair.
|
||||||
|
type QAResult struct {
|
||||||
|
Question string `json:"question"`
|
||||||
|
Category int `json:"category"`
|
||||||
|
GoldAnswer string `json:"goldAnswer"`
|
||||||
|
TokenF1 float64 `json:"tokenF1"`
|
||||||
|
HitRate float64 `json:"hitRate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggMetrics holds aggregated evaluation metrics.
|
||||||
|
type AggMetrics struct {
|
||||||
|
OverallF1 float64 `json:"overallF1"`
|
||||||
|
OverallHitRate float64 `json:"overallHitRate"`
|
||||||
|
ByCategory map[int]*CatMetrics `json:"byCategory"`
|
||||||
|
TotalQuestions int `json:"totalQuestions"`
|
||||||
|
ValidF1Count int `json:"validF1Count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatMetrics holds metrics for a single category.
|
||||||
|
type CatMetrics struct {
|
||||||
|
F1 float64 `json:"f1"`
|
||||||
|
HitRate float64 `json:"hitRate"`
|
||||||
|
QuestionCount int `json:"questionCount"`
|
||||||
|
ValidF1Count int `json:"validF1Count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvalLegacy evaluates using legacy session store (raw history + budget truncation).
|
||||||
|
func EvalLegacy(
|
||||||
|
ctx context.Context,
|
||||||
|
samples []LocomoSample,
|
||||||
|
legacy *LegacyStore,
|
||||||
|
budgetTokens int,
|
||||||
|
) []EvalResult {
|
||||||
|
results := make([]EvalResult, 0, len(samples))
|
||||||
|
for si := range samples {
|
||||||
|
sample := &samples[si]
|
||||||
|
history := legacy.GetHistory(sample.SampleID)
|
||||||
|
|
||||||
|
// Convert messages to content strings
|
||||||
|
allContent := make([]string, 0, len(history))
|
||||||
|
for _, msg := range history {
|
||||||
|
allContent = append(allContent, msg.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
qaResults := make([]QAResult, 0, len(sample.QA))
|
||||||
|
for qi := range sample.QA {
|
||||||
|
qa := &sample.QA[qi]
|
||||||
|
// Budget truncate the full history
|
||||||
|
truncated, _ := BudgetTruncate(allContent, budgetTokens)
|
||||||
|
context := StringListToContent(truncated)
|
||||||
|
|
||||||
|
f1 := TokenOverlapF1(context, qa.AnswerString())
|
||||||
|
hitRate := RecallHitRate(qa.Evidence, sample, context)
|
||||||
|
|
||||||
|
qaResults = append(qaResults, QAResult{
|
||||||
|
Question: qa.Question,
|
||||||
|
Category: qa.Category,
|
||||||
|
GoldAnswer: qa.AnswerString(),
|
||||||
|
TokenF1: f1,
|
||||||
|
HitRate: hitRate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, EvalResult{
|
||||||
|
Mode: "legacy",
|
||||||
|
SampleID: sample.SampleID,
|
||||||
|
QAResults: qaResults,
|
||||||
|
Agg: aggregateMetrics(qaResults),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvalSeahorse evaluates using seahorse short memory (per-keyword search + expand).
|
||||||
|
func EvalSeahorse(
|
||||||
|
ctx context.Context,
|
||||||
|
samples []LocomoSample,
|
||||||
|
ir *SeahorseIngestResult,
|
||||||
|
budgetTokens int,
|
||||||
|
) []EvalResult {
|
||||||
|
store := ir.Engine.GetRetrieval().Store()
|
||||||
|
retrieval := ir.Engine.GetRetrieval()
|
||||||
|
|
||||||
|
results := make([]EvalResult, 0, len(samples))
|
||||||
|
for si := range samples {
|
||||||
|
sample := &samples[si]
|
||||||
|
convID, ok := ir.ConvMap[sample.SampleID]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("WARN: no conversation ID for sample %s", sample.SampleID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
qaResults := make([]QAResult, 0, len(sample.QA))
|
||||||
|
for qi := range sample.QA {
|
||||||
|
qa := &sample.QA[qi]
|
||||||
|
keywords := ExtractKeywords(qa.Question)
|
||||||
|
|
||||||
|
// Search each keyword individually and union results,
|
||||||
|
// tracking best BM25 rank per message for relevance sorting.
|
||||||
|
bestRank := map[int64]float64{}
|
||||||
|
for _, kw := range keywords {
|
||||||
|
searchResults, err := store.SearchMessages(ctx, seahorse.SearchInput{
|
||||||
|
Pattern: kw,
|
||||||
|
ConversationID: convID,
|
||||||
|
Limit: 20,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("WARN: search failed for keyword %q: %v", kw, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, sr := range searchResults {
|
||||||
|
if sr.MessageID > 0 {
|
||||||
|
if prev, ok := bestRank[sr.MessageID]; !ok || sr.Rank < prev {
|
||||||
|
bestRank[sr.MessageID] = sr.Rank
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort messageIDs by rank ascending (best/most-negative first).
|
||||||
|
// BudgetTruncate walks from the front, keeping best-ranked messages.
|
||||||
|
// Note: SQLite FTS5 bm25() returns negative values where more
|
||||||
|
// negative = better match.
|
||||||
|
messageIDs := make([]int64, 0, len(bestRank))
|
||||||
|
for id := range bestRank {
|
||||||
|
messageIDs = append(messageIDs, id)
|
||||||
|
}
|
||||||
|
sort.Slice(messageIDs, func(i, j int) bool {
|
||||||
|
return bestRank[messageIDs[i]] < bestRank[messageIDs[j]]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Expand messages to get full content
|
||||||
|
var contentParts []string
|
||||||
|
if len(messageIDs) > 0 {
|
||||||
|
expandResult, err := retrieval.ExpandMessages(ctx, messageIDs)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("WARN: expand failed for sample %s: %v", sample.SampleID, err)
|
||||||
|
} else {
|
||||||
|
for _, msg := range expandResult.Messages {
|
||||||
|
contentParts = append(contentParts, msg.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(contentParts) == 0 {
|
||||||
|
qaResults = append(qaResults, QAResult{
|
||||||
|
Question: qa.Question,
|
||||||
|
Category: qa.Category,
|
||||||
|
GoldAnswer: qa.AnswerString(),
|
||||||
|
TokenF1: 0.0,
|
||||||
|
HitRate: 0.0,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Budget truncate (drop worst-ranked)
|
||||||
|
truncated, _ := BudgetTruncate(contentParts, budgetTokens)
|
||||||
|
context := StringListToContent(truncated)
|
||||||
|
|
||||||
|
f1 := TokenOverlapF1(context, qa.AnswerString())
|
||||||
|
hitRate := RecallHitRate(qa.Evidence, sample, context)
|
||||||
|
|
||||||
|
qaResults = append(qaResults, QAResult{
|
||||||
|
Question: qa.Question,
|
||||||
|
Category: qa.Category,
|
||||||
|
GoldAnswer: qa.AnswerString(),
|
||||||
|
TokenF1: f1,
|
||||||
|
HitRate: hitRate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, EvalResult{
|
||||||
|
Mode: "seahorse",
|
||||||
|
SampleID: sample.SampleID,
|
||||||
|
QAResults: qaResults,
|
||||||
|
Agg: aggregateMetrics(qaResults),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// aggregateMetrics computes overall and per-category metrics.
|
||||||
|
func aggregateMetrics(qaResults []QAResult) AggMetrics {
|
||||||
|
type catAccum struct {
|
||||||
|
f1Sum float64
|
||||||
|
f1Count int
|
||||||
|
hitRateSum float64
|
||||||
|
hitRateCount int
|
||||||
|
}
|
||||||
|
byCatAcc := map[int]*catAccum{}
|
||||||
|
totalF1 := 0.0
|
||||||
|
totalHitRate := 0.0
|
||||||
|
validF1Count := 0
|
||||||
|
for _, qr := range qaResults {
|
||||||
|
// Skip sentinel -1.0 scores (LLM API/parse failures) from F1 averaging.
|
||||||
|
if qr.TokenF1 >= 0 {
|
||||||
|
totalF1 += qr.TokenF1
|
||||||
|
validF1Count++
|
||||||
|
}
|
||||||
|
totalHitRate += qr.HitRate
|
||||||
|
acc, ok := byCatAcc[qr.Category]
|
||||||
|
if !ok {
|
||||||
|
acc = &catAccum{}
|
||||||
|
byCatAcc[qr.Category] = acc
|
||||||
|
}
|
||||||
|
if qr.TokenF1 >= 0 {
|
||||||
|
acc.f1Sum += qr.TokenF1
|
||||||
|
acc.f1Count++
|
||||||
|
}
|
||||||
|
acc.hitRateSum += qr.HitRate
|
||||||
|
acc.hitRateCount++
|
||||||
|
}
|
||||||
|
nHit := len(qaResults)
|
||||||
|
if nHit == 0 {
|
||||||
|
nHit = 1
|
||||||
|
}
|
||||||
|
byCat := map[int]*CatMetrics{}
|
||||||
|
for cat, acc := range byCatAcc {
|
||||||
|
cm := &CatMetrics{
|
||||||
|
QuestionCount: acc.hitRateCount,
|
||||||
|
ValidF1Count: acc.f1Count,
|
||||||
|
}
|
||||||
|
if acc.f1Count > 0 {
|
||||||
|
cm.F1 = acc.f1Sum / float64(acc.f1Count)
|
||||||
|
}
|
||||||
|
if acc.hitRateCount > 0 {
|
||||||
|
cm.HitRate = acc.hitRateSum / float64(acc.hitRateCount)
|
||||||
|
}
|
||||||
|
byCat[cat] = cm
|
||||||
|
}
|
||||||
|
var overallF1 float64
|
||||||
|
if validF1Count > 0 {
|
||||||
|
overallF1 = totalF1 / float64(validF1Count)
|
||||||
|
}
|
||||||
|
return AggMetrics{
|
||||||
|
OverallF1: overallF1,
|
||||||
|
OverallHitRate: totalHitRate / float64(nHit),
|
||||||
|
ByCategory: byCat,
|
||||||
|
TotalQuestions: len(qaResults),
|
||||||
|
ValidF1Count: validF1Count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveResults writes per-sample eval results to JSON files.
|
||||||
|
func SaveResults(results []EvalResult, outDir string) error {
|
||||||
|
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create output dir: %w", err)
|
||||||
|
}
|
||||||
|
for _, r := range results {
|
||||||
|
path := filepath.Join(outDir, fmt.Sprintf("eval_%s_%s.json", r.Mode, r.SampleID))
|
||||||
|
data, err := json.MarshalIndent(r, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal result: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write result: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveAggregated writes a combined results.json with all modes.
|
||||||
|
func SaveAggregated(results []EvalResult, outDir string) error {
|
||||||
|
byMode := map[string][]EvalResult{}
|
||||||
|
for _, r := range results {
|
||||||
|
byMode[r.Mode] = append(byMode[r.Mode], r)
|
||||||
|
}
|
||||||
|
|
||||||
|
aggMap := map[string]AggMetrics{}
|
||||||
|
for mode, modeResults := range byMode {
|
||||||
|
aggMap[mode] = computeModeAgg(modeResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(aggMap, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(filepath.Join(outDir, "results.json"), data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeModeAgg aggregates results for a single mode using weighted averaging
|
||||||
|
// (weighted by question count per sample). All modes must have the same Mode field.
|
||||||
|
func computeModeAgg(results []EvalResult) AggMetrics {
|
||||||
|
agg := AggMetrics{ByCategory: map[int]*CatMetrics{}}
|
||||||
|
for _, r := range results {
|
||||||
|
// Backward compat: old eval JSON (token mode) without ValidF1Count → use TotalQuestions.
|
||||||
|
// LLM modes may legitimately have ValidF1Count==0 (all failures).
|
||||||
|
vf1 := r.Agg.ValidF1Count
|
||||||
|
if vf1 == 0 && r.Agg.TotalQuestions > 0 && !strings.HasSuffix(r.Mode, "-llm") {
|
||||||
|
vf1 = r.Agg.TotalQuestions
|
||||||
|
}
|
||||||
|
agg.OverallF1 += r.Agg.OverallF1 * float64(vf1)
|
||||||
|
agg.OverallHitRate += r.Agg.OverallHitRate * float64(r.Agg.TotalQuestions)
|
||||||
|
agg.TotalQuestions += r.Agg.TotalQuestions
|
||||||
|
agg.ValidF1Count += vf1
|
||||||
|
for cat, cm := range r.Agg.ByCategory {
|
||||||
|
existing, ok := agg.ByCategory[cat]
|
||||||
|
if !ok {
|
||||||
|
existing = &CatMetrics{}
|
||||||
|
agg.ByCategory[cat] = existing
|
||||||
|
}
|
||||||
|
cvf1 := cm.ValidF1Count
|
||||||
|
if cvf1 == 0 && cm.QuestionCount > 0 && !strings.HasSuffix(r.Mode, "-llm") {
|
||||||
|
cvf1 = cm.QuestionCount
|
||||||
|
}
|
||||||
|
existing.F1 += cm.F1 * float64(cvf1)
|
||||||
|
existing.HitRate += cm.HitRate * float64(cm.QuestionCount)
|
||||||
|
existing.QuestionCount += cm.QuestionCount
|
||||||
|
existing.ValidF1Count += cvf1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if agg.ValidF1Count > 0 {
|
||||||
|
agg.OverallF1 /= float64(agg.ValidF1Count)
|
||||||
|
}
|
||||||
|
if agg.TotalQuestions > 0 {
|
||||||
|
agg.OverallHitRate /= float64(agg.TotalQuestions)
|
||||||
|
}
|
||||||
|
for _, cat := range agg.ByCategory {
|
||||||
|
if cat.ValidF1Count > 0 {
|
||||||
|
cat.F1 /= float64(cat.ValidF1Count)
|
||||||
|
}
|
||||||
|
if cat.QuestionCount > 0 {
|
||||||
|
cat.HitRate /= float64(cat.QuestionCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return agg
|
||||||
|
}
|
||||||
|
|
||||||
|
// printSection prints a single comparison table section.
|
||||||
|
func printSection(title string, results []EvalResult) {
|
||||||
|
fmt.Printf("\n--- %s ---\n", title)
|
||||||
|
byMode := map[string][]EvalResult{}
|
||||||
|
for _, r := range results {
|
||||||
|
byMode[r.Mode] = append(byMode[r.Mode], r)
|
||||||
|
}
|
||||||
|
|
||||||
|
modes := map[string]AggMetrics{}
|
||||||
|
for mode, modeResults := range byMode {
|
||||||
|
modes[mode] = computeModeAgg(modeResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
modeKeys := make([]string, 0, len(modes))
|
||||||
|
for k := range modes {
|
||||||
|
modeKeys = append(modeKeys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(modeKeys)
|
||||||
|
|
||||||
|
// Collect all category keys across modes
|
||||||
|
catSet := map[int]bool{}
|
||||||
|
for _, agg := range modes {
|
||||||
|
for cat := range agg.ByCategory {
|
||||||
|
catSet[cat] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cats := make([]int, 0, len(catSet))
|
||||||
|
for cat := range catSet {
|
||||||
|
cats = append(cats, cat)
|
||||||
|
}
|
||||||
|
sort.Ints(cats)
|
||||||
|
|
||||||
|
fmt.Printf("%-10s %-8s %-8s", "Mode", "HitRate", "F1")
|
||||||
|
for _, cat := range cats {
|
||||||
|
fmt.Printf(" %-7s", fmt.Sprintf("C%d", cat))
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(strings.Repeat("-", 10+8+8+7*len(cats)+8))
|
||||||
|
|
||||||
|
for _, mode := range modeKeys {
|
||||||
|
agg := modes[mode]
|
||||||
|
fmt.Printf("%-10s %-8.4f %-8.4f", mode, agg.OverallHitRate, agg.OverallF1)
|
||||||
|
for _, cat := range cats {
|
||||||
|
if cm, ok := agg.ByCategory[cat]; ok {
|
||||||
|
fmt.Printf(" %-7.4f", cm.HitRate)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" %-7s", "N/A")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintComparison outputs a human-readable comparison table to stdout.
|
||||||
|
func PrintComparison(results []EvalResult, llmResults []EvalResult) {
|
||||||
|
if len(results) > 0 {
|
||||||
|
printSection("No LLM generation", results)
|
||||||
|
}
|
||||||
|
if len(llmResults) > 0 {
|
||||||
|
printSection("With LLM", llmResults)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/seahorse"
|
||||||
|
)
|
||||||
|
|
||||||
|
const answerSystemPrompt = `You are a helpful assistant. Given conversation context, answer the question concisely and accurately. If the answer is not in the context, say "I don't know". Answer in 1-3 sentences maximum.`
|
||||||
|
|
||||||
|
const judgeSystemPrompt = `You are an impartial judge evaluating answer quality.
|
||||||
|
Compare the candidate answer against the reference answer.
|
||||||
|
Consider semantic equivalence — different wording expressing the same meaning should score high.
|
||||||
|
|
||||||
|
Output ONLY a single integer score from 1 to 5:
|
||||||
|
1 = completely wrong or irrelevant
|
||||||
|
2 = partially related but mostly incorrect
|
||||||
|
3 = partially correct, missing key details
|
||||||
|
4 = mostly correct with minor omissions
|
||||||
|
5 = fully correct, semantically equivalent
|
||||||
|
|
||||||
|
Output ONLY the number, nothing else.`
|
||||||
|
|
||||||
|
// generateAnswer asks the LLM to answer a question given retrieved context.
|
||||||
|
func generateAnswer(ctx context.Context, client *LLMClient, contextText, question string) (string, error) {
|
||||||
|
// Truncate context to avoid exceeding model limits while preserving valid UTF-8.
|
||||||
|
contextRunes := []rune(contextText)
|
||||||
|
if len(contextRunes) > 6000 {
|
||||||
|
contextText = string(contextRunes[:6000]) + "\n... [truncated]"
|
||||||
|
}
|
||||||
|
|
||||||
|
userPrompt := fmt.Sprintf("## Conversation Context\n\n%s\n\n## Question\n\n%s", contextText, question)
|
||||||
|
return client.Complete(ctx, answerSystemPrompt, userPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scoreRe matches the first standalone integer 1-5 in the judge response.
|
||||||
|
var scoreRe = regexp.MustCompile(`\b([1-5])\b`)
|
||||||
|
|
||||||
|
// judgeAnswer asks the LLM to score the candidate answer vs the gold answer.
|
||||||
|
// Returns a score from 0.0 to 1.0, or -1.0 on parse failure.
|
||||||
|
func judgeAnswer(
|
||||||
|
ctx context.Context,
|
||||||
|
judgeClient *LLMClient,
|
||||||
|
question, goldAnswer, candidateAnswer string,
|
||||||
|
) (float64, error) {
|
||||||
|
userPrompt := fmt.Sprintf(
|
||||||
|
"Question: %s\n\nReference Answer: %s\n\nCandidate Answer: %s\n\nScore:",
|
||||||
|
question, goldAnswer, candidateAnswer,
|
||||||
|
)
|
||||||
|
|
||||||
|
response, err := judgeClient.Complete(ctx, judgeSystemPrompt, userPrompt)
|
||||||
|
if err != nil {
|
||||||
|
return -1.0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
response = strings.TrimSpace(response)
|
||||||
|
if m := scoreRe.FindStringSubmatch(response); len(m) == 2 {
|
||||||
|
score, _ := strconv.Atoi(m[1])
|
||||||
|
return float64(score-1) / 4.0, nil // Normalize 1-5 to 0.0-1.0
|
||||||
|
}
|
||||||
|
log.Printf("WARNING: could not parse judge score from: %q, returning -1", response)
|
||||||
|
return -1.0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// qaWork describes one QA evaluation unit.
|
||||||
|
type qaWork struct {
|
||||||
|
sampleID string
|
||||||
|
qaIndex int
|
||||||
|
globalIndex int
|
||||||
|
totalQA int
|
||||||
|
qa *LocomoQA
|
||||||
|
contextText string
|
||||||
|
sample *LocomoSample
|
||||||
|
}
|
||||||
|
|
||||||
|
// qaResult collects one QA evaluation output.
|
||||||
|
type qaResultOut struct {
|
||||||
|
index int // position in the flat QA list for ordering
|
||||||
|
result QAResult
|
||||||
|
answer string
|
||||||
|
score float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// evalQAWorker processes a single QA item: generate answer + judge score.
|
||||||
|
func evalQAWorker(
|
||||||
|
ctx context.Context,
|
||||||
|
w qaWork,
|
||||||
|
answerClient, judgeClient *LLMClient,
|
||||||
|
logPrefix string,
|
||||||
|
) qaResultOut {
|
||||||
|
llmAnswer, err := generateAnswer(ctx, answerClient, w.contextText, w.qa.Question)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("WARN: LLM generation failed for sample %s Q%d: %v", w.sampleID, w.qaIndex, err)
|
||||||
|
llmAnswer = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
score := -1.0
|
||||||
|
if llmAnswer != "" {
|
||||||
|
score, err = judgeAnswer(ctx, judgeClient, w.qa.Question, w.qa.AnswerString(), llmAnswer)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("WARN: LLM judge failed for sample %s Q%d: %v", w.sampleID, w.qaIndex, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hitRate := RecallHitRate(w.qa.Evidence, w.sample, w.contextText)
|
||||||
|
|
||||||
|
log.Printf("[%s] sample=%s q=%d/%d score=%.2f answer=%q",
|
||||||
|
logPrefix, w.sampleID, w.globalIndex, w.totalQA, score, truncateStr(llmAnswer, 80))
|
||||||
|
|
||||||
|
return qaResultOut{
|
||||||
|
index: w.globalIndex,
|
||||||
|
result: QAResult{
|
||||||
|
Question: w.qa.Question,
|
||||||
|
Category: w.qa.Category,
|
||||||
|
GoldAnswer: w.qa.AnswerString(),
|
||||||
|
TokenF1: score,
|
||||||
|
HitRate: hitRate,
|
||||||
|
},
|
||||||
|
answer: llmAnswer,
|
||||||
|
score: score,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvalLegacyLLM evaluates legacy store using LLM generation + LLM-as-Judge.
|
||||||
|
func EvalLegacyLLM(
|
||||||
|
ctx context.Context,
|
||||||
|
samples []LocomoSample,
|
||||||
|
legacy *LegacyStore,
|
||||||
|
budgetTokens int,
|
||||||
|
answerClient, judgeClient *LLMClient,
|
||||||
|
concurrency int,
|
||||||
|
) []EvalResult {
|
||||||
|
if concurrency < 1 {
|
||||||
|
concurrency = 1
|
||||||
|
}
|
||||||
|
totalQA := countTotalQA(samples)
|
||||||
|
results := make([]EvalResult, 0, len(samples))
|
||||||
|
|
||||||
|
for si := range samples {
|
||||||
|
sample := &samples[si]
|
||||||
|
history := legacy.GetHistory(sample.SampleID)
|
||||||
|
|
||||||
|
allContent := make([]string, 0, len(history))
|
||||||
|
for _, msg := range history {
|
||||||
|
allContent = append(allContent, msg.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
truncated, _ := BudgetTruncate(allContent, budgetTokens)
|
||||||
|
contextText := StringListToContent(truncated)
|
||||||
|
|
||||||
|
qaResults := make([]QAResult, len(sample.QA))
|
||||||
|
|
||||||
|
if concurrency <= 1 {
|
||||||
|
for qi := range sample.QA {
|
||||||
|
out := evalQAWorker(ctx, qaWork{
|
||||||
|
sampleID: sample.SampleID, qaIndex: qi,
|
||||||
|
globalIndex: si*len(sample.QA) + qi + 1, totalQA: totalQA,
|
||||||
|
qa: &sample.QA[qi], contextText: contextText, sample: sample,
|
||||||
|
}, answerClient, judgeClient, "legacy-llm")
|
||||||
|
qaResults[qi] = out.result
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sem := make(chan struct{}, concurrency)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for qi := range sample.QA {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
sem <- struct{}{}
|
||||||
|
defer func() { <-sem }()
|
||||||
|
out := evalQAWorker(ctx, qaWork{
|
||||||
|
sampleID: sample.SampleID, qaIndex: qi,
|
||||||
|
globalIndex: si*len(sample.QA) + qi + 1, totalQA: totalQA,
|
||||||
|
qa: &sample.QA[qi], contextText: contextText, sample: sample,
|
||||||
|
}, answerClient, judgeClient, "legacy-llm")
|
||||||
|
qaResults[qi] = out.result // safe: each goroutine writes distinct index
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, EvalResult{
|
||||||
|
Mode: "legacy-llm",
|
||||||
|
SampleID: sample.SampleID,
|
||||||
|
QAResults: qaResults,
|
||||||
|
Agg: aggregateMetrics(qaResults),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSeahorseContext retrieves context for a seahorse QA item.
|
||||||
|
func buildSeahorseContext(
|
||||||
|
ctx context.Context,
|
||||||
|
ir *SeahorseIngestResult,
|
||||||
|
sample *LocomoSample,
|
||||||
|
qa *LocomoQA,
|
||||||
|
budgetTokens int,
|
||||||
|
) string {
|
||||||
|
store := ir.Engine.GetRetrieval().Store()
|
||||||
|
retrieval := ir.Engine.GetRetrieval()
|
||||||
|
convID := ir.ConvMap[sample.SampleID]
|
||||||
|
|
||||||
|
keywords := ExtractKeywords(qa.Question)
|
||||||
|
bestRank := map[int64]float64{}
|
||||||
|
for _, kw := range keywords {
|
||||||
|
searchResults, err := store.SearchMessages(ctx, seahorse.SearchInput{
|
||||||
|
Pattern: kw,
|
||||||
|
ConversationID: convID,
|
||||||
|
Limit: 20,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, sr := range searchResults {
|
||||||
|
if sr.MessageID > 0 {
|
||||||
|
if prev, ok := bestRank[sr.MessageID]; !ok || sr.Rank < prev {
|
||||||
|
bestRank[sr.MessageID] = sr.Rank
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messageIDs := make([]int64, 0, len(bestRank))
|
||||||
|
for id := range bestRank {
|
||||||
|
messageIDs = append(messageIDs, id)
|
||||||
|
}
|
||||||
|
sort.Slice(messageIDs, func(i, j int) bool {
|
||||||
|
return bestRank[messageIDs[i]] < bestRank[messageIDs[j]]
|
||||||
|
})
|
||||||
|
|
||||||
|
var contentParts []string
|
||||||
|
if len(messageIDs) > 0 {
|
||||||
|
expandResult, err := retrieval.ExpandMessages(ctx, messageIDs)
|
||||||
|
if err == nil {
|
||||||
|
for _, msg := range expandResult.Messages {
|
||||||
|
contentParts = append(contentParts, msg.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(contentParts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
truncated, _ := BudgetTruncate(contentParts, budgetTokens)
|
||||||
|
return StringListToContent(truncated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvalSeahorseLLM evaluates seahorse retrieval using LLM generation + LLM-as-Judge.
|
||||||
|
func EvalSeahorseLLM(
|
||||||
|
ctx context.Context,
|
||||||
|
samples []LocomoSample,
|
||||||
|
ir *SeahorseIngestResult,
|
||||||
|
budgetTokens int,
|
||||||
|
answerClient, judgeClient *LLMClient,
|
||||||
|
concurrency int,
|
||||||
|
) []EvalResult {
|
||||||
|
if concurrency < 1 {
|
||||||
|
concurrency = 1
|
||||||
|
}
|
||||||
|
totalQA := countTotalQA(samples)
|
||||||
|
results := make([]EvalResult, 0, len(samples))
|
||||||
|
|
||||||
|
for si := range samples {
|
||||||
|
sample := &samples[si]
|
||||||
|
if _, ok := ir.ConvMap[sample.SampleID]; !ok {
|
||||||
|
log.Printf("WARN: no conversation ID for sample %s", sample.SampleID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
qaResults := make([]QAResult, len(sample.QA))
|
||||||
|
|
||||||
|
evalOne := func(qi int) {
|
||||||
|
qa := &sample.QA[qi]
|
||||||
|
contextText := buildSeahorseContext(ctx, ir, sample, qa, budgetTokens)
|
||||||
|
if contextText == "" {
|
||||||
|
qaResults[qi] = QAResult{
|
||||||
|
Question: qa.Question,
|
||||||
|
Category: qa.Category,
|
||||||
|
GoldAnswer: qa.AnswerString(),
|
||||||
|
TokenF1: 0.0,
|
||||||
|
HitRate: 0.0,
|
||||||
|
}
|
||||||
|
log.Printf("[seahorse-llm] sample=%s q=%d/%d score=0.00 answer=(no context)",
|
||||||
|
sample.SampleID, si*len(sample.QA)+qi+1, totalQA)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := evalQAWorker(ctx, qaWork{
|
||||||
|
sampleID: sample.SampleID, qaIndex: qi,
|
||||||
|
globalIndex: si*len(sample.QA) + qi + 1, totalQA: totalQA,
|
||||||
|
qa: qa, contextText: contextText, sample: sample,
|
||||||
|
}, answerClient, judgeClient, "seahorse-llm")
|
||||||
|
qaResults[qi] = out.result
|
||||||
|
}
|
||||||
|
|
||||||
|
if concurrency <= 1 {
|
||||||
|
for qi := range sample.QA {
|
||||||
|
evalOne(qi)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sem := make(chan struct{}, concurrency)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for qi := range sample.QA {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
sem <- struct{}{}
|
||||||
|
defer func() { <-sem }()
|
||||||
|
evalOne(qi)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, EvalResult{
|
||||||
|
Mode: "seahorse-llm",
|
||||||
|
SampleID: sample.SampleID,
|
||||||
|
QAResults: qaResults,
|
||||||
|
Agg: aggregateMetrics(qaResults),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func countTotalQA(samples []LocomoSample) int {
|
||||||
|
n := 0
|
||||||
|
for i := range samples {
|
||||||
|
n += len(samples[i].QA)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateStr(s string, maxLen int) string {
|
||||||
|
s = strings.ReplaceAll(s, "\n", " ")
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) > maxLen {
|
||||||
|
return string(runes[:maxLen]) + "..."
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestComputeModeAggAllCategories(t *testing.T) {
|
||||||
|
results := []EvalResult{
|
||||||
|
{
|
||||||
|
Mode: "test",
|
||||||
|
SampleID: "s1",
|
||||||
|
QAResults: []QAResult{
|
||||||
|
{Category: 1, TokenF1: 0.5, HitRate: 0.8},
|
||||||
|
{Category: 2, TokenF1: 0.3, HitRate: 0.6},
|
||||||
|
{Category: 3, TokenF1: 0.1, HitRate: 0.4},
|
||||||
|
{Category: 4, TokenF1: 0.7, HitRate: 0.9},
|
||||||
|
{Category: 5, TokenF1: 0.2, HitRate: 0.1},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for i := range results {
|
||||||
|
results[i].Agg = aggregateMetrics(results[i].QAResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := computeModeAgg(results)
|
||||||
|
|
||||||
|
// Should have all 5 categories
|
||||||
|
for cat := 1; cat <= 5; cat++ {
|
||||||
|
cm, ok := got.ByCategory[cat]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("ByCategory missing category %d", cat)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cm.QuestionCount != 1 {
|
||||||
|
t.Errorf("ByCategory[%d].QuestionCount = %d, want 1", cat, cm.QuestionCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify specific F1 values per category
|
||||||
|
wantF1 := map[int]float64{1: 0.5, 2: 0.3, 3: 0.1, 4: 0.7, 5: 0.2}
|
||||||
|
for cat, want := range wantF1 {
|
||||||
|
if cm, ok := got.ByCategory[cat]; ok {
|
||||||
|
if math.Abs(cm.F1-want) > 1e-9 {
|
||||||
|
t.Errorf("ByCategory[%d].F1 = %.4f, want %.4f", cat, cm.F1, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeModeAgg(t *testing.T) {
|
||||||
|
// Two samples with different question counts:
|
||||||
|
// sample-a: 2 questions, F1 = [0.4, 0.6] → avg 0.5
|
||||||
|
// sample-b: 8 questions, F1 = [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1] → avg 0.1
|
||||||
|
//
|
||||||
|
// Unweighted (PrintComparison bug): (0.5 + 0.1) / 2 = 0.3
|
||||||
|
// Weighted (correct): (0.4+0.6 + 0.1*8) / 10 = 1.8 / 10 = 0.18
|
||||||
|
results := []EvalResult{
|
||||||
|
{
|
||||||
|
Mode: "test",
|
||||||
|
SampleID: "sample-a",
|
||||||
|
QAResults: []QAResult{
|
||||||
|
{TokenF1: 0.4, HitRate: 0.5},
|
||||||
|
{TokenF1: 0.6, HitRate: 0.7},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Mode: "test",
|
||||||
|
SampleID: "sample-b",
|
||||||
|
QAResults: []QAResult{
|
||||||
|
{TokenF1: 0.1, HitRate: 0.2},
|
||||||
|
{TokenF1: 0.1, HitRate: 0.2},
|
||||||
|
{TokenF1: 0.1, HitRate: 0.2},
|
||||||
|
{TokenF1: 0.1, HitRate: 0.2},
|
||||||
|
{TokenF1: 0.1, HitRate: 0.2},
|
||||||
|
{TokenF1: 0.1, HitRate: 0.2},
|
||||||
|
{TokenF1: 0.1, HitRate: 0.2},
|
||||||
|
{TokenF1: 0.1, HitRate: 0.2},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// Compute per-sample aggregates
|
||||||
|
for i := range results {
|
||||||
|
results[i].Agg = aggregateMetrics(results[i].QAResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := computeModeAgg(results)
|
||||||
|
|
||||||
|
// Weighted: (0.4+0.6+0.1*8) / 10 = 1.8/10 = 0.18
|
||||||
|
wantF1 := 0.18
|
||||||
|
if math.Abs(got.OverallF1-wantF1) > 1e-9 {
|
||||||
|
t.Errorf("OverallF1 = %.6f, want %.6f (weighted average)", got.OverallF1, wantF1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weighted: (0.5+0.7+0.2*8) / 10 = 2.8/10 = 0.28
|
||||||
|
wantRecall := 0.28
|
||||||
|
if math.Abs(got.OverallHitRate-wantRecall) > 1e-9 {
|
||||||
|
t.Errorf("OverallHitRate = %.6f, want %.6f (weighted average)", got.OverallHitRate, wantRecall)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.TotalQuestions != 10 {
|
||||||
|
t.Errorf("TotalQuestions = %d, want 10", got.TotalQuestions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregateMetricsSentinel(t *testing.T) {
|
||||||
|
qa := []QAResult{
|
||||||
|
{Category: 1, TokenF1: 0.8, HitRate: 0.5},
|
||||||
|
{Category: 1, TokenF1: -1.0, HitRate: 0.3},
|
||||||
|
{Category: 1, TokenF1: 0.4, HitRate: 0.7},
|
||||||
|
}
|
||||||
|
agg := aggregateMetrics(qa)
|
||||||
|
|
||||||
|
if agg.ValidF1Count != 2 {
|
||||||
|
t.Errorf("ValidF1Count = %d, want 2", agg.ValidF1Count)
|
||||||
|
}
|
||||||
|
if agg.TotalQuestions != 3 {
|
||||||
|
t.Errorf("TotalQuestions = %d, want 3", agg.TotalQuestions)
|
||||||
|
}
|
||||||
|
wantF1 := (0.8 + 0.4) / 2.0
|
||||||
|
if math.Abs(agg.OverallF1-wantF1) > 1e-9 {
|
||||||
|
t.Errorf("OverallF1 = %.6f, want %.6f", agg.OverallF1, wantF1)
|
||||||
|
}
|
||||||
|
wantHR := (0.5 + 0.3 + 0.7) / 3.0
|
||||||
|
if math.Abs(agg.OverallHitRate-wantHR) > 1e-9 {
|
||||||
|
t.Errorf("OverallHitRate = %.6f, want %.6f", agg.OverallHitRate, wantHR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregateMetricsAllSentinel(t *testing.T) {
|
||||||
|
qa := []QAResult{
|
||||||
|
{Category: 1, TokenF1: -1.0, HitRate: 0.5},
|
||||||
|
{Category: 1, TokenF1: -1.0, HitRate: 0.3},
|
||||||
|
}
|
||||||
|
agg := aggregateMetrics(qa)
|
||||||
|
|
||||||
|
if agg.ValidF1Count != 0 {
|
||||||
|
t.Errorf("ValidF1Count = %d, want 0", agg.ValidF1Count)
|
||||||
|
}
|
||||||
|
if agg.OverallF1 != 0 {
|
||||||
|
t.Errorf("OverallF1 = %.6f, want 0", agg.OverallF1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeModeAggSentinelWeighting(t *testing.T) {
|
||||||
|
results := []EvalResult{
|
||||||
|
{
|
||||||
|
Mode: "test",
|
||||||
|
SampleID: "s1",
|
||||||
|
QAResults: []QAResult{
|
||||||
|
{Category: 1, TokenF1: 0.8, HitRate: 0.5},
|
||||||
|
{Category: 1, TokenF1: -1.0, HitRate: 0.3},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Mode: "test",
|
||||||
|
SampleID: "s2",
|
||||||
|
QAResults: []QAResult{
|
||||||
|
{Category: 1, TokenF1: 0.4, HitRate: 0.6},
|
||||||
|
{Category: 1, TokenF1: 0.6, HitRate: 0.8},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for i := range results {
|
||||||
|
results[i].Agg = aggregateMetrics(results[i].QAResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := computeModeAgg(results)
|
||||||
|
|
||||||
|
// s1: ValidF1Count=1, F1=0.8; s2: ValidF1Count=2, F1=0.5
|
||||||
|
// Weighted: (0.8*1 + 0.5*2) / 3 = 1.8/3 = 0.6
|
||||||
|
wantF1 := 0.6
|
||||||
|
if math.Abs(got.OverallF1-wantF1) > 1e-9 {
|
||||||
|
t.Errorf("OverallF1 = %.6f, want %.6f", got.OverallF1, wantF1)
|
||||||
|
}
|
||||||
|
if got.ValidF1Count != 3 {
|
||||||
|
t.Errorf("ValidF1Count = %d, want 3", got.ValidF1Count)
|
||||||
|
}
|
||||||
|
if got.TotalQuestions != 4 {
|
||||||
|
t.Errorf("TotalQuestions = %d, want 4", got.TotalQuestions)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/seahorse"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConvMap stores the mapping from sampleID to seahorse ConversationID.
|
||||||
|
type ConvMap map[string]int64
|
||||||
|
|
||||||
|
// SeahorseIngestResult holds the results of ingesting into seahorse.
|
||||||
|
type SeahorseIngestResult struct {
|
||||||
|
Engine *seahorse.Engine
|
||||||
|
ConvMap ConvMap // sampleID → conversationID
|
||||||
|
}
|
||||||
|
|
||||||
|
// IngestSeahorse loads all LOCOMO samples into a seahorse Engine.
|
||||||
|
// Returns the engine and a mapping from sampleID to conversationID for scoped retrieval.
|
||||||
|
func IngestSeahorse(ctx context.Context, samples []LocomoSample, dbPath string) (*SeahorseIngestResult, error) {
|
||||||
|
noopFn := func(ctx context.Context, prompt string, opts seahorse.CompleteOptions) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
engine, err := seahorse.NewEngine(seahorse.Config{
|
||||||
|
DBPath: dbPath,
|
||||||
|
}, noopFn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create seahorse engine: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
store := engine.GetRetrieval().Store()
|
||||||
|
convMap := make(ConvMap)
|
||||||
|
|
||||||
|
for si := range samples {
|
||||||
|
sample := &samples[si]
|
||||||
|
sessionKey := "locomo-" + sample.SampleID
|
||||||
|
|
||||||
|
// Check if conversation already exists (idempotent)
|
||||||
|
existing, _ := store.GetConversationBySessionKey(ctx, sessionKey)
|
||||||
|
if existing != nil {
|
||||||
|
convMap[sample.SampleID] = existing.ConversationID
|
||||||
|
log.Printf("Skipping existing sample %s: convID=%d", sample.SampleID, existing.ConversationID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
turns := GetTurns(sample)
|
||||||
|
|
||||||
|
// Convert turns to seahorse messages
|
||||||
|
msgs := make([]seahorse.Message, 0, len(turns))
|
||||||
|
for _, turn := range turns {
|
||||||
|
content := turn.Speaker + ": " + turn.Text
|
||||||
|
msgs = append(msgs, seahorse.Message{
|
||||||
|
Role: "user",
|
||||||
|
Content: content,
|
||||||
|
TokenCount: len(turn.Text) / 4,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ingest all turns for this sample
|
||||||
|
_, err := engine.Ingest(ctx, sessionKey, msgs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ingest sample %s: %w", sample.SampleID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the conversation ID for scoped retrieval
|
||||||
|
conv, err := store.GetConversationBySessionKey(ctx, sessionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get conversation for %s: %w", sample.SampleID, err)
|
||||||
|
}
|
||||||
|
if conv == nil {
|
||||||
|
return nil, fmt.Errorf("conversation not found for %s after ingest", sample.SampleID)
|
||||||
|
}
|
||||||
|
convMap[sample.SampleID] = conv.ConversationID
|
||||||
|
log.Printf("Ingested sample %s: %d turns, convID=%d", sample.SampleID, len(turns), conv.ConversationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Seahorse ingestion complete: %d samples, %d conversations", len(samples), len(convMap))
|
||||||
|
return &SeahorseIngestResult{
|
||||||
|
Engine: engine,
|
||||||
|
ConvMap: convMap,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/seahorse"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIngestSeahorseIdempotent(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(tmpDir, "test.db")
|
||||||
|
|
||||||
|
// Minimal test data
|
||||||
|
samples := []LocomoSample{
|
||||||
|
{
|
||||||
|
SampleID: "test-1",
|
||||||
|
Conversation: map[string]json.RawMessage{
|
||||||
|
"session_1": json.RawMessage(`[
|
||||||
|
{"speaker":"A","dia_id":"D1:1","text":"hello world this is a test message"},
|
||||||
|
{"speaker":"B","dia_id":"D1:2","text":"another message for testing purposes"}
|
||||||
|
]`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// First ingestion
|
||||||
|
result1, err := IngestSeahorse(ctx, samples, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first ingest failed: %v", err)
|
||||||
|
}
|
||||||
|
convCount1 := len(result1.ConvMap)
|
||||||
|
result1.Engine.Close()
|
||||||
|
|
||||||
|
// Second ingestion on same DB — should reuse existing data
|
||||||
|
result2, err := IngestSeahorse(ctx, samples, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second ingest failed: %v", err)
|
||||||
|
}
|
||||||
|
defer result2.Engine.Close()
|
||||||
|
|
||||||
|
// ConvMap should have same number of entries (no duplicates)
|
||||||
|
if len(result2.ConvMap) != convCount1 {
|
||||||
|
t.Errorf("second ingest convMap has %d entries, want %d (same as first)",
|
||||||
|
len(result2.ConvMap), convCount1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify conversation IDs are the same (reused, not new ones)
|
||||||
|
for id, cid1 := range result1.ConvMap {
|
||||||
|
cid2, ok := result2.ConvMap[id]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("sample %s missing from second ConvMap", id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cid2 != cid1 {
|
||||||
|
t.Errorf("sample %s: second ingest got convID %d, want %d (reused)", id, cid2, cid1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no duplicate messages by counting
|
||||||
|
store := result2.Engine.GetRetrieval().Store()
|
||||||
|
for _, convID := range result2.ConvMap {
|
||||||
|
msgs, err := store.SearchMessages(ctx, seahorse.SearchInput{
|
||||||
|
Pattern: "test",
|
||||||
|
ConversationID: convID,
|
||||||
|
Limit: 100,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search failed: %v", err)
|
||||||
|
}
|
||||||
|
// Should find exactly 1 message containing "test" (the first turn)
|
||||||
|
if len(msgs) > 2 {
|
||||||
|
t.Errorf("found %d messages for 'test' in conv %d, expected ≤2 (no duplicates)", len(msgs), convID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LegacyStore wraps session.SessionManager for legacy baseline.
|
||||||
|
type LegacyStore struct {
|
||||||
|
sm *session.SessionManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLegacyStore creates a new in-memory session manager.
|
||||||
|
func NewLegacyStore() *LegacyStore {
|
||||||
|
return &LegacyStore{
|
||||||
|
sm: session.NewSessionManager(""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IngestSample loads all turns from a LOCOMO sample into the legacy session store.
|
||||||
|
func (ls *LegacyStore) IngestSample(sample *LocomoSample) {
|
||||||
|
sessionKey := "locomo-" + sample.SampleID
|
||||||
|
turns := GetTurns(sample)
|
||||||
|
for _, turn := range turns {
|
||||||
|
content := turn.Speaker + ": " + turn.Text
|
||||||
|
ls.sm.AddMessage(sessionKey, "user", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHistory returns all messages for a sample's session.
|
||||||
|
func (ls *LegacyStore) GetHistory(sampleID string) []providers.Message {
|
||||||
|
sessionKey := "locomo-" + sampleID
|
||||||
|
return ls.sm.GetHistory(sessionKey)
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LLMClient wraps an OpenAI-compatible chat completion endpoint.
|
||||||
|
type LLMClient struct {
|
||||||
|
BaseURL string
|
||||||
|
Model string
|
||||||
|
APIKey string
|
||||||
|
NoThinking bool // send chat_template_kwargs to disable thinking (llama.cpp specific)
|
||||||
|
MaxRetries int // max retry attempts for transient errors (0 = no retry)
|
||||||
|
Client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// LLMClientOptions configures the LLM client.
|
||||||
|
type LLMClientOptions struct {
|
||||||
|
BaseURL string
|
||||||
|
Model string
|
||||||
|
APIKey string
|
||||||
|
Timeout time.Duration
|
||||||
|
NoThinking bool
|
||||||
|
MaxRetries int // max retry attempts (default 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLLMClient creates a client for an OpenAI-compatible chat completion API.
|
||||||
|
func NewLLMClient(opts LLMClientOptions) *LLMClient {
|
||||||
|
if opts.Timeout == 0 {
|
||||||
|
opts.Timeout = 120 * time.Second
|
||||||
|
}
|
||||||
|
maxRetries := opts.MaxRetries
|
||||||
|
if maxRetries < 0 {
|
||||||
|
maxRetries = 3
|
||||||
|
}
|
||||||
|
return &LLMClient{
|
||||||
|
BaseURL: strings.TrimRight(opts.BaseURL, "/"),
|
||||||
|
Model: opts.Model,
|
||||||
|
APIKey: opts.APIKey,
|
||||||
|
NoThinking: opts.NoThinking,
|
||||||
|
MaxRetries: maxRetries,
|
||||||
|
Client: &http.Client{
|
||||||
|
Timeout: opts.Timeout,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []chatMessage `json:"messages"`
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
ChatTemplateKwargs map[string]any `json:"chat_template_kwargs,omitempty"` // llama.cpp
|
||||||
|
Think *bool `json:"think,omitempty"` // Ollama
|
||||||
|
Thinking map[string]any `json:"thinking,omitempty"` // GLM (智谱)
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete sends a chat completion request and returns the assistant's reply.
|
||||||
|
func (c *LLMClient) Complete(ctx context.Context, systemPrompt, userPrompt string) (string, error) {
|
||||||
|
sysContent := systemPrompt
|
||||||
|
if c.NoThinking && sysContent != "" {
|
||||||
|
// Prepend /no_think tag — works with Ollama /v1 endpoint and
|
||||||
|
// Qwen chat templates where the JSON think field is ignored.
|
||||||
|
sysContent = "/no_think\n" + sysContent
|
||||||
|
}
|
||||||
|
messages := []chatMessage{}
|
||||||
|
if sysContent != "" {
|
||||||
|
messages = append(messages, chatMessage{Role: "system", Content: sysContent})
|
||||||
|
}
|
||||||
|
messages = append(messages, chatMessage{Role: "user", Content: userPrompt})
|
||||||
|
|
||||||
|
body := chatRequest{
|
||||||
|
Model: c.Model,
|
||||||
|
Messages: messages,
|
||||||
|
Temperature: 0.1,
|
||||||
|
MaxTokens: 512,
|
||||||
|
}
|
||||||
|
if c.NoThinking {
|
||||||
|
// llama.cpp: chat_template_kwargs
|
||||||
|
body.ChatTemplateKwargs = map[string]any{
|
||||||
|
"enable_thinking": false,
|
||||||
|
}
|
||||||
|
// Ollama (0.9+): think field
|
||||||
|
thinkFalse := false
|
||||||
|
body.Think = &thinkFalse
|
||||||
|
// GLM (智谱): thinking field
|
||||||
|
body.Thinking = map[string]any{
|
||||||
|
"type": "disabled",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBody, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := strings.TrimRight(c.BaseURL, "/") + "/chat/completions"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(jsonBody))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if c.APIKey != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
var respBody []byte
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt <= c.MaxRetries; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
backoff := time.Duration(1<<(attempt-1)) * time.Second // 1s, 2s, 4s, ...
|
||||||
|
log.Printf("LLM retry %d/%d after %v: %v", attempt, c.MaxRetries, backoff, lastErr)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", ctx.Err()
|
||||||
|
case <-time.After(backoff):
|
||||||
|
}
|
||||||
|
// Rebuild request (body reader is consumed)
|
||||||
|
req, err = http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(jsonBody))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if c.APIKey != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp *http.Response
|
||||||
|
resp, lastErr = c.Client.Do(req)
|
||||||
|
if lastErr != nil {
|
||||||
|
continue // network/timeout error → retry
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody, lastErr = io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if lastErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == 429 || resp.StatusCode >= 500 {
|
||||||
|
lastErr = fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
continue // rate limit or server error → retry
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
lastErr = nil
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
return "", fmt.Errorf("after %d retries: %w", c.MaxRetries, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
var chatResp chatResponse
|
||||||
|
if err := json.Unmarshal(respBody, &chatResp); err != nil {
|
||||||
|
return "", fmt.Errorf("parse response: %w", err)
|
||||||
|
}
|
||||||
|
if len(chatResp.Choices) == 0 {
|
||||||
|
return "", fmt.Errorf("no choices in response")
|
||||||
|
}
|
||||||
|
content := strings.TrimSpace(chatResp.Choices[0].Message.Content)
|
||||||
|
// Strip any residual <think>...</think> blocks
|
||||||
|
if idx := strings.Index(content, "</think>"); idx >= 0 {
|
||||||
|
content = strings.TrimSpace(content[idx+len("</think>"):])
|
||||||
|
}
|
||||||
|
// Fallback: GLM/DeepSeek put thinking output in reasoning_content when thinking is enabled
|
||||||
|
if content == "" && chatResp.Choices[0].Message.ReasoningContent != "" {
|
||||||
|
content = strings.TrimSpace(chatResp.Choices[0].Message.ReasoningContent)
|
||||||
|
}
|
||||||
|
if content == "" {
|
||||||
|
return "", fmt.Errorf("empty LLM response")
|
||||||
|
}
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LocomoSample represents one conversation sample from the LOCOMO dataset.
|
||||||
|
type LocomoSample struct {
|
||||||
|
SampleID string `json:"sample_id"`
|
||||||
|
Conversation map[string]json.RawMessage `json:"conversation"`
|
||||||
|
QA []LocomoQA `json:"qa"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocomoTurn represents a single turn in a conversation.
|
||||||
|
type LocomoTurn struct {
|
||||||
|
Speaker string `json:"speaker"`
|
||||||
|
DiaID string `json:"dia_id"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocomoQA represents a question-answer pair with evidence.
|
||||||
|
type LocomoQA struct {
|
||||||
|
Question string `json:"question"`
|
||||||
|
Answer json.RawMessage `json:"answer"` // can be string or int (category 1-4)
|
||||||
|
AdversarialAnswer string `json:"adversarial_answer"` // category 5 only
|
||||||
|
Evidence []string `json:"evidence"`
|
||||||
|
Category int `json:"category"` // 1=single-hop, 2=multi-hop, 3=open-ended, 5=adversarial
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnswerString returns the answer as a string, handling both string and int types.
|
||||||
|
func (qa *LocomoQA) AnswerString() string {
|
||||||
|
// Prefer answer field (category 1-4)
|
||||||
|
if len(qa.Answer) > 0 {
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(qa.Answer, &s); err == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var n json.Number
|
||||||
|
if err := json.Unmarshal(qa.Answer, &n); err == nil {
|
||||||
|
return n.String()
|
||||||
|
}
|
||||||
|
return strings.Trim(string(qa.Answer), `"`)
|
||||||
|
}
|
||||||
|
// Fallback to adversarial_answer (category 5)
|
||||||
|
return qa.AdversarialAnswer
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadDataset reads all JSON files from dataDir and returns parsed samples.
|
||||||
|
func LoadDataset(dataDir string) ([]LocomoSample, error) {
|
||||||
|
entries, err := os.ReadDir(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read data dir %s: %w", dataDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var samples []LocomoSample
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".json") {
|
||||||
|
path := filepath.Join(dataDir, entry.Name())
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
var batch []LocomoSample
|
||||||
|
if err := json.Unmarshal(data, &batch); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
samples = append(samples, batch...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return samples, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSessionNames returns sorted session keys (session_1, session_2, ...) from conversation.
|
||||||
|
func GetSessionNames(conv map[string]json.RawMessage) []string {
|
||||||
|
var names []string
|
||||||
|
for k := range conv {
|
||||||
|
if strings.HasPrefix(k, "session_") && !strings.Contains(k, "_date_time") {
|
||||||
|
names = append(names, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(names, func(i, j int) bool {
|
||||||
|
ni := sessionNum(names[i])
|
||||||
|
nj := sessionNum(names[j])
|
||||||
|
return ni < nj
|
||||||
|
})
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionNum(key string) int {
|
||||||
|
// "session_1" → 1, "session_10" → 10
|
||||||
|
parts := strings.SplitN(key, "_", 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, _ := strconv.Atoi(parts[1])
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTurns flattens all sessions' turns in chronological order.
|
||||||
|
func GetTurns(sample *LocomoSample) []LocomoTurn {
|
||||||
|
names := GetSessionNames(sample.Conversation)
|
||||||
|
var all []LocomoTurn
|
||||||
|
for _, name := range names {
|
||||||
|
raw, ok := sample.Conversation[name]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var turns []LocomoTurn
|
||||||
|
if err := json.Unmarshal(raw, &turns); err != nil {
|
||||||
|
log.Printf("WARNING: unmarshal failed for session %q in sample %s: %v", name, sample.SampleID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
all = append(all, turns...)
|
||||||
|
}
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTurnByDiaID finds a specific turn by dia_id (e.g. "D1:3").
|
||||||
|
func GetTurnByDiaID(sample *LocomoSample, diaID string) *LocomoTurn {
|
||||||
|
turns := GetTurns(sample)
|
||||||
|
for i := range turns {
|
||||||
|
if turns[i].DiaID == diaID {
|
||||||
|
return &turns[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSpeakers returns the two speaker names from conversation metadata.
|
||||||
|
func GetSpeakers(conv map[string]json.RawMessage) (string, string) {
|
||||||
|
var a, b string
|
||||||
|
json.Unmarshal(conv["speaker_a"], &a)
|
||||||
|
json.Unmarshal(conv["speaker_b"], &b)
|
||||||
|
return a, b
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAnswerString(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
json string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"string answer",
|
||||||
|
`{"question":"Q","answer":"Paris","evidence":[],"category":1}`,
|
||||||
|
"Paris",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"int answer",
|
||||||
|
`{"question":"Q","answer":42,"evidence":[],"category":1}`,
|
||||||
|
"42",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"adversarial answer (category 5)",
|
||||||
|
`{"question":"Q","evidence":[],"category":5,"adversarial_answer":"self-care is important"}`,
|
||||||
|
"self-care is important",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"both answer and adversarial_answer present",
|
||||||
|
`{"question":"Q","answer":"normal","evidence":[],"category":5,"adversarial_answer":"adversarial"}`,
|
||||||
|
"normal",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var qa LocomoQA
|
||||||
|
if err := json.Unmarshal([]byte(tt.json), &qa); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
got := qa.AnswerString()
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("AnswerString() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSessionNames(t *testing.T) {
|
||||||
|
conv := map[string]json.RawMessage{
|
||||||
|
"session_2": {},
|
||||||
|
"session_1": {},
|
||||||
|
"session_10": {},
|
||||||
|
"session_1_date_time": {},
|
||||||
|
"speaker_a": {},
|
||||||
|
}
|
||||||
|
names := GetSessionNames(conv)
|
||||||
|
want := []string{"session_1", "session_2", "session_10"}
|
||||||
|
if len(names) != len(want) {
|
||||||
|
t.Fatalf("got %v, want %v", names, want)
|
||||||
|
}
|
||||||
|
for i, n := range names {
|
||||||
|
if n != want[i] {
|
||||||
|
t.Errorf("names[%d] = %q, want %q", i, n, want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
flagData string
|
||||||
|
flagOut string
|
||||||
|
flagMode string
|
||||||
|
flagBudget int
|
||||||
|
flagEvalMode string
|
||||||
|
flagAPIBase string
|
||||||
|
flagAPIKey string
|
||||||
|
flagModel string
|
||||||
|
flagNoThinking bool
|
||||||
|
flagLimit int
|
||||||
|
flagTimeout int
|
||||||
|
flagRetries int
|
||||||
|
flagJudgeModel string
|
||||||
|
flagJudgeAPIBase string
|
||||||
|
flagJudgeAPIKey string
|
||||||
|
flagConcurrency int
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Suppress seahorse INFO logs during benchmark
|
||||||
|
logger.SetLevel(logger.WARN)
|
||||||
|
|
||||||
|
rootCmd := &cobra.Command{
|
||||||
|
Use: "membench",
|
||||||
|
Short: "Memory benchmark tool for picoclaw",
|
||||||
|
}
|
||||||
|
|
||||||
|
ingestCmd := &cobra.Command{
|
||||||
|
Use: "ingest",
|
||||||
|
Short: "Load LOCOMO data into storage backends",
|
||||||
|
RunE: runIngest,
|
||||||
|
}
|
||||||
|
ingestCmd.Flags().StringVar(&flagData, "data", "", "LOCOMO dataset directory (required)")
|
||||||
|
ingestCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
|
||||||
|
ingestCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to ingest: legacy, seahorse, or all")
|
||||||
|
|
||||||
|
evalCmd := &cobra.Command{
|
||||||
|
Use: "eval",
|
||||||
|
Short: "Run QA evaluation against ingested data",
|
||||||
|
RunE: runEval,
|
||||||
|
}
|
||||||
|
evalCmd.Flags().StringVar(&flagData, "data", "", "LOCOMO dataset directory (required)")
|
||||||
|
evalCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
|
||||||
|
evalCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to evaluate: legacy, seahorse, or all")
|
||||||
|
evalCmd.Flags().IntVar(&flagBudget, "budget", 4000, "token budget for retrieval")
|
||||||
|
evalCmd.Flags().
|
||||||
|
StringVar(&flagEvalMode, "eval-mode", "token", "evaluation mode: token (direct match) or llm (LLM-as-Judge)")
|
||||||
|
evalCmd.Flags().
|
||||||
|
StringVar(&flagAPIBase, "api-base", "", "API base URL with version path, e.g. http://host/v1 (default: http://127.0.0.1:8080/v1, env: MEMBENCH_API_BASE)")
|
||||||
|
evalCmd.Flags().StringVar(&flagAPIKey, "api-key", "", "API key for the LLM endpoint (env: MEMBENCH_API_KEY)")
|
||||||
|
evalCmd.Flags().StringVar(&flagModel, "model", "", "model name for LLM eval (env: MEMBENCH_MODEL)")
|
||||||
|
evalCmd.Flags().
|
||||||
|
BoolVar(&flagNoThinking, "no-thinking", false, "disable thinking mode via chat_template_kwargs (llama.cpp + Qwen)")
|
||||||
|
evalCmd.Flags().IntVar(&flagLimit, "limit", 0, "max QA questions per sample (0 = all)")
|
||||||
|
evalCmd.Flags().IntVar(&flagTimeout, "timeout", 120, "HTTP timeout in seconds for LLM requests")
|
||||||
|
evalCmd.Flags().IntVar(&flagRetries, "retries", 3, "max retry attempts for transient LLM errors (timeout/5xx/429)")
|
||||||
|
evalCmd.Flags().StringVar(&flagJudgeModel, "judge-model", "", "model for judge scoring (defaults to --model)")
|
||||||
|
evalCmd.Flags().
|
||||||
|
StringVar(&flagJudgeAPIBase, "judge-api-base", "", "API base URL for judge model (defaults to --api-base)")
|
||||||
|
evalCmd.Flags().StringVar(&flagJudgeAPIKey, "judge-api-key", "", "API key for judge model (defaults to --api-key)")
|
||||||
|
evalCmd.Flags().IntVar(&flagConcurrency, "concurrency", 1, "number of concurrent QA evaluations")
|
||||||
|
|
||||||
|
reportCmd := &cobra.Command{
|
||||||
|
Use: "report",
|
||||||
|
Short: "Output comparison results from evaluation",
|
||||||
|
RunE: runReport,
|
||||||
|
}
|
||||||
|
reportCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
|
||||||
|
|
||||||
|
runCmd := &cobra.Command{
|
||||||
|
Use: "run",
|
||||||
|
Short: "Convenience: eval + report (ingestion is done inline)",
|
||||||
|
RunE: runAll,
|
||||||
|
}
|
||||||
|
runCmd.Flags().StringVar(&flagData, "data", "", "LOCOMO dataset directory (required)")
|
||||||
|
runCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
|
||||||
|
runCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to run: legacy, seahorse, or all")
|
||||||
|
runCmd.Flags().IntVar(&flagBudget, "budget", 4000, "token budget for retrieval")
|
||||||
|
runCmd.Flags().
|
||||||
|
StringVar(&flagEvalMode, "eval-mode", "token", "evaluation mode: token (direct match) or llm (LLM-as-Judge)")
|
||||||
|
runCmd.Flags().
|
||||||
|
StringVar(&flagAPIBase, "api-base", "", "API base URL with version path, e.g. http://host/v1 (default: http://127.0.0.1:8080/v1, env: MEMBENCH_API_BASE)")
|
||||||
|
runCmd.Flags().StringVar(&flagAPIKey, "api-key", "", "API key for the LLM endpoint (env: MEMBENCH_API_KEY)")
|
||||||
|
runCmd.Flags().StringVar(&flagModel, "model", "", "model name for LLM eval (env: MEMBENCH_MODEL)")
|
||||||
|
runCmd.Flags().
|
||||||
|
BoolVar(&flagNoThinking, "no-thinking", false, "disable thinking mode via chat_template_kwargs (llama.cpp + Qwen)")
|
||||||
|
runCmd.Flags().IntVar(&flagLimit, "limit", 0, "max QA questions per sample (0 = all)")
|
||||||
|
runCmd.Flags().IntVar(&flagTimeout, "timeout", 120, "HTTP timeout in seconds for LLM requests")
|
||||||
|
runCmd.Flags().IntVar(&flagRetries, "retries", 3, "max retry attempts for transient LLM errors (timeout/5xx/429)")
|
||||||
|
runCmd.Flags().StringVar(&flagJudgeModel, "judge-model", "", "model for judge scoring (defaults to --model)")
|
||||||
|
runCmd.Flags().
|
||||||
|
StringVar(&flagJudgeAPIBase, "judge-api-base", "", "API base URL for judge model (defaults to --api-base)")
|
||||||
|
runCmd.Flags().StringVar(&flagJudgeAPIKey, "judge-api-key", "", "API key for judge model (defaults to --api-key)")
|
||||||
|
runCmd.Flags().IntVar(&flagConcurrency, "concurrency", 1, "number of concurrent QA evaluations")
|
||||||
|
|
||||||
|
rootCmd.AddCommand(ingestCmd, evalCmd, reportCmd, runCmd)
|
||||||
|
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func modesFromFlag() []string {
|
||||||
|
switch strings.ToLower(flagMode) {
|
||||||
|
case "all":
|
||||||
|
return []string{"legacy", "seahorse"}
|
||||||
|
default:
|
||||||
|
return []string{strings.ToLower(flagMode)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runIngest(cmd *cobra.Command, args []string) error {
|
||||||
|
if flagData == "" {
|
||||||
|
return fmt.Errorf("--data is required")
|
||||||
|
}
|
||||||
|
modes := modesFromFlag()
|
||||||
|
if len(modes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
samples, err := LoadDataset(flagData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load dataset: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("Loaded %d samples from %s", len(samples), flagData)
|
||||||
|
|
||||||
|
for _, mode := range modes {
|
||||||
|
switch mode {
|
||||||
|
case "legacy":
|
||||||
|
legacy := NewLegacyStore()
|
||||||
|
for i := range samples {
|
||||||
|
legacy.IngestSample(&samples[i])
|
||||||
|
}
|
||||||
|
log.Printf("legacy: ingested %d samples", len(samples))
|
||||||
|
case "seahorse":
|
||||||
|
dbPath := filepath.Join(flagOut, "seahorse.db")
|
||||||
|
if err := os.MkdirAll(flagOut, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create out dir: %w", err)
|
||||||
|
}
|
||||||
|
_, err := IngestSeahorse(ctx, samples, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ingest seahorse: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runEval(cmd *cobra.Command, args []string) error {
|
||||||
|
if flagData == "" {
|
||||||
|
return fmt.Errorf("--data is required")
|
||||||
|
}
|
||||||
|
modes := modesFromFlag()
|
||||||
|
if len(modes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
samples, err := LoadDataset(flagData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load dataset: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("Loaded %d samples", len(samples))
|
||||||
|
|
||||||
|
if flagLimit > 0 {
|
||||||
|
for i := range samples {
|
||||||
|
if len(samples[i].QA) > flagLimit {
|
||||||
|
samples[i].QA = samples[i].QA[:flagLimit]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("Limited to %d QA per sample", flagLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
evalMode := strings.ToLower(strings.TrimSpace(flagEvalMode))
|
||||||
|
var useLLM bool
|
||||||
|
switch evalMode {
|
||||||
|
case "token":
|
||||||
|
useLLM = false
|
||||||
|
case "llm":
|
||||||
|
useLLM = true
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid --eval-mode %q: must be token or llm", flagEvalMode)
|
||||||
|
}
|
||||||
|
var answerClient, judgeClient *LLMClient
|
||||||
|
if useLLM {
|
||||||
|
opts, err := buildLLMOptions()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
answerClient = NewLLMClient(opts)
|
||||||
|
judgeClient = answerClient // default: same client
|
||||||
|
if flagJudgeModel != "" {
|
||||||
|
jOpts := opts // copy base settings
|
||||||
|
jOpts.Model = flagJudgeModel
|
||||||
|
if flagJudgeAPIBase != "" {
|
||||||
|
jOpts.BaseURL = flagJudgeAPIBase
|
||||||
|
}
|
||||||
|
if flagJudgeAPIKey != "" {
|
||||||
|
jOpts.APIKey = flagJudgeAPIKey
|
||||||
|
}
|
||||||
|
judgeClient = NewLLMClient(jOpts)
|
||||||
|
log.Printf("Judge model: model=%s base=%s no-thinking=%v", jOpts.Model, jOpts.BaseURL, jOpts.NoThinking)
|
||||||
|
}
|
||||||
|
log.Printf("LLM eval mode: model=%s base=%s no-thinking=%v concurrency=%d",
|
||||||
|
opts.Model, opts.BaseURL, opts.NoThinking, flagConcurrency)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResults, llmResults []EvalResult
|
||||||
|
|
||||||
|
for _, mode := range modes {
|
||||||
|
switch mode {
|
||||||
|
case "legacy":
|
||||||
|
legacy := NewLegacyStore()
|
||||||
|
for i := range samples {
|
||||||
|
legacy.IngestSample(&samples[i])
|
||||||
|
}
|
||||||
|
if useLLM {
|
||||||
|
results := EvalLegacyLLM(ctx, samples, legacy, flagBudget, answerClient, judgeClient, flagConcurrency)
|
||||||
|
llmResults = append(llmResults, results...)
|
||||||
|
log.Printf("legacy-llm: evaluated %d samples", len(results))
|
||||||
|
} else {
|
||||||
|
results := EvalLegacy(ctx, samples, legacy, flagBudget)
|
||||||
|
tokenResults = append(tokenResults, results...)
|
||||||
|
log.Printf("legacy: evaluated %d samples", len(results))
|
||||||
|
}
|
||||||
|
case "seahorse":
|
||||||
|
dbPath := filepath.Join(flagOut, "seahorse.db")
|
||||||
|
ir, err := IngestSeahorse(ctx, samples, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ingest seahorse: %w", err)
|
||||||
|
}
|
||||||
|
if useLLM {
|
||||||
|
results := EvalSeahorseLLM(ctx, samples, ir, flagBudget, answerClient, judgeClient, flagConcurrency)
|
||||||
|
llmResults = append(llmResults, results...)
|
||||||
|
log.Printf("seahorse-llm: evaluated %d samples", len(results))
|
||||||
|
} else {
|
||||||
|
results := EvalSeahorse(ctx, samples, ir, flagBudget)
|
||||||
|
tokenResults = append(tokenResults, results...)
|
||||||
|
log.Printf("seahorse: evaluated %d samples", len(results))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allResults := append(tokenResults, llmResults...)
|
||||||
|
if err := SaveResults(allResults, flagOut); err != nil {
|
||||||
|
return fmt.Errorf("save results: %w", err)
|
||||||
|
}
|
||||||
|
if err := SaveAggregated(allResults, flagOut); err != nil {
|
||||||
|
return fmt.Errorf("save aggregated: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
PrintComparison(tokenResults, llmResults)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runReport(cmd *cobra.Command, args []string) error {
|
||||||
|
entries, err := os.ReadDir(flagOut)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read out dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var allResults []EvalResult
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() && strings.HasPrefix(entry.Name(), "eval_") && strings.HasSuffix(entry.Name(), ".json") {
|
||||||
|
path := filepath.Join(flagOut, entry.Name())
|
||||||
|
var r EvalResult
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("WARN: read %s: %v", path, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &r); err != nil {
|
||||||
|
log.Printf("WARN: parse %s: %v", path, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allResults = append(allResults, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allResults) == 0 {
|
||||||
|
return fmt.Errorf("no eval results found in %s", flagOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResults, llmResults []EvalResult
|
||||||
|
for _, r := range allResults {
|
||||||
|
if strings.HasSuffix(r.Mode, "-llm") {
|
||||||
|
llmResults = append(llmResults, r)
|
||||||
|
} else {
|
||||||
|
tokenResults = append(tokenResults, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PrintComparison(tokenResults, llmResults)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAll(cmd *cobra.Command, args []string) error {
|
||||||
|
return runEval(cmd, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// envOrFlag returns the flag value if non-empty, otherwise falls back to the
|
||||||
|
// environment variable.
|
||||||
|
func envOrFlag(flag, envKey string) string {
|
||||||
|
if flag != "" {
|
||||||
|
return flag
|
||||||
|
}
|
||||||
|
return os.Getenv(envKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildLLMOptions resolves LLM client configuration from flags and environment
|
||||||
|
// variables. Flag values take precedence over environment variables.
|
||||||
|
//
|
||||||
|
// Environment variables:
|
||||||
|
//
|
||||||
|
// MEMBENCH_API_BASE – OpenAI-compatible base URL (default http://127.0.0.1:8080/v1)
|
||||||
|
// MEMBENCH_API_KEY – Bearer token for the endpoint
|
||||||
|
// MEMBENCH_MODEL – Model name to send in the request
|
||||||
|
func buildLLMOptions() (LLMClientOptions, error) {
|
||||||
|
base := envOrFlag(flagAPIBase, "MEMBENCH_API_BASE")
|
||||||
|
if base == "" {
|
||||||
|
base = "http://127.0.0.1:8080/v1"
|
||||||
|
}
|
||||||
|
model := envOrFlag(flagModel, "MEMBENCH_MODEL")
|
||||||
|
if model == "" {
|
||||||
|
return LLMClientOptions{}, fmt.Errorf(
|
||||||
|
"--model or MEMBENCH_MODEL is required for LLM eval mode",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
apiKey := envOrFlag(flagAPIKey, "MEMBENCH_API_KEY")
|
||||||
|
|
||||||
|
if flagTimeout <= 0 {
|
||||||
|
return LLMClientOptions{}, fmt.Errorf("--timeout must be > 0, got %d", flagTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
return LLMClientOptions{
|
||||||
|
BaseURL: base,
|
||||||
|
Model: model,
|
||||||
|
APIKey: apiKey,
|
||||||
|
NoThinking: flagNoThinking,
|
||||||
|
Timeout: time.Duration(flagTimeout) * time.Second,
|
||||||
|
MaxRetries: flagRetries,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// diaIDRe matches valid dia_id patterns like "D1:3", "D30:5".
|
||||||
|
var diaIDRe = regexp.MustCompile(`^D(\d+):(\d+)$`)
|
||||||
|
|
||||||
|
// SplitEvidenceIDs splits an evidence string that may contain multiple
|
||||||
|
// semicolon-separated or space-separated dia_ids. Only returns valid IDs.
|
||||||
|
// Example: "D8:6; D9:17" → ["D8:6", "D9:17"]
|
||||||
|
// Example: "D9:1 D4:4 D4:6" → ["D9:1", "D4:4", "D4:6"]
|
||||||
|
func SplitEvidenceIDs(evidence string) []string {
|
||||||
|
if evidence == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Split on semicolons first, then spaces
|
||||||
|
parts := strings.Split(evidence, ";")
|
||||||
|
var ids []string
|
||||||
|
for _, part := range parts {
|
||||||
|
for _, token := range strings.Fields(strings.TrimSpace(part)) {
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if diaIDRe.MatchString(token) {
|
||||||
|
ids = append(ids, NormalizeDiaID(token))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeDiaID strips leading zeros from the number parts of a dia_id.
|
||||||
|
// "D30:05" → "D30:5", "D10:003" → "D10:3"
|
||||||
|
func NormalizeDiaID(id string) string {
|
||||||
|
m := diaIDRe.FindStringSubmatch(id)
|
||||||
|
if m == nil {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
session, _ := strconv.Atoi(m[1])
|
||||||
|
turn, _ := strconv.Atoi(m[2])
|
||||||
|
return fmt.Sprintf("D%d:%d", session, turn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopwords is a fixed English stopword list for deterministic keyword extraction.
|
||||||
|
var stopwords = map[string]struct{}{
|
||||||
|
"a": {}, "an": {}, "the": {},
|
||||||
|
"is": {}, "are": {}, "was": {}, "were": {},
|
||||||
|
"did": {}, "does": {}, "do": {},
|
||||||
|
"when": {}, "where": {}, "what": {}, "who": {},
|
||||||
|
"how": {}, "why": {},
|
||||||
|
"to": {}, "of": {}, "in": {}, "on": {}, "at": {},
|
||||||
|
"for": {}, "and": {}, "or": {}, "but": {}, "not": {},
|
||||||
|
"it": {}, "this": {}, "that": {}, "with": {},
|
||||||
|
"from": {}, "by": {}, "as": {},
|
||||||
|
"if": {}, "then": {}, "than": {}, "so": {},
|
||||||
|
"no": {}, "yes": {},
|
||||||
|
"all": {}, "any": {}, "each": {}, "every": {},
|
||||||
|
"some": {}, "such": {},
|
||||||
|
"about": {}, "into": {}, "over": {},
|
||||||
|
"after": {}, "before": {}, "between": {},
|
||||||
|
"through": {}, "during": {}, "until": {},
|
||||||
|
"would": {}, "could": {}, "should": {},
|
||||||
|
"may": {}, "might": {}, "can": {},
|
||||||
|
"will": {}, "shall": {}, "must": {},
|
||||||
|
"have": {}, "has": {}, "had": {},
|
||||||
|
"been": {}, "being": {}, "be": {},
|
||||||
|
"go": {}, "went": {}, "gone": {},
|
||||||
|
"i": {}, "you": {}, "me": {}, "my": {}, "your": {},
|
||||||
|
"we": {}, "they": {}, "them": {}, "our": {},
|
||||||
|
"its": {}, "their": {}, "he": {}, "she": {},
|
||||||
|
"his": {}, "her": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractKeywords removes stopwords and punctuation, returns individual keywords.
|
||||||
|
// Deterministic: uses fixed stopword list, no LLM.
|
||||||
|
func ExtractKeywords(question string) []string {
|
||||||
|
// Lowercase and split on whitespace/punctuation
|
||||||
|
lower := strings.ToLower(question)
|
||||||
|
words := strings.FieldsFunc(lower, func(r rune) bool {
|
||||||
|
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||||
|
})
|
||||||
|
|
||||||
|
var keywords []string
|
||||||
|
for _, w := range words {
|
||||||
|
if w == "" || len(w) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := stopwords[w]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keywords = append(keywords, w)
|
||||||
|
if len(keywords) >= 6 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keywords
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenOverlapF1 computes token-level F1 between prediction and reference.
|
||||||
|
// Both strings are lowercased and split on whitespace.
|
||||||
|
// NOTE: This metric underestimates quality for multi-hop (cat 2) and
|
||||||
|
// open-ended (cat 3) questions where the gold answer uses different phrasing
|
||||||
|
// than the source text. LLM-Judge scoring is a v2 follow-up.
|
||||||
|
func TokenOverlapF1(prediction, reference string) float64 {
|
||||||
|
predTokens := tokenize(prediction)
|
||||||
|
refTokens := tokenize(reference)
|
||||||
|
|
||||||
|
if len(predTokens) == 0 && len(refTokens) == 0 {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
if len(predTokens) == 0 || len(refTokens) == 0 {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count matches
|
||||||
|
refCount := map[string]int{}
|
||||||
|
for _, t := range refTokens {
|
||||||
|
refCount[t]++
|
||||||
|
}
|
||||||
|
|
||||||
|
predCount := map[string]int{}
|
||||||
|
for _, t := range predTokens {
|
||||||
|
predCount[t]++
|
||||||
|
}
|
||||||
|
|
||||||
|
var matches float64
|
||||||
|
for token, pc := range predCount {
|
||||||
|
if rc, ok := refCount[token]; ok {
|
||||||
|
matches += float64(min(pc, rc))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
precision := matches / float64(len(predTokens))
|
||||||
|
recall := matches / float64(len(refTokens))
|
||||||
|
|
||||||
|
if precision+recall == 0 {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
return 2 * precision * recall / (precision + recall)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenize(s string) []string {
|
||||||
|
lower := strings.ToLower(s)
|
||||||
|
return strings.Fields(lower)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecallHitRate computes fraction of evidence IDs found in retrieved content.
|
||||||
|
// For each evidence dia_id, looks up the turn text and checks substring match.
|
||||||
|
// Logs a warning for turns with text < 20 chars (higher false-positive risk).
|
||||||
|
func RecallHitRate(evidenceIDs []string, sample *LocomoSample, retrievedContent string) float64 {
|
||||||
|
if len(evidenceIDs) == 0 {
|
||||||
|
return 1.0 // no evidence required = perfect
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand any multi-ID evidence entries (e.g. "D8:6; D9:17" or "D9:1 D4:4")
|
||||||
|
var expanded []string
|
||||||
|
for _, id := range evidenceIDs {
|
||||||
|
split := SplitEvidenceIDs(id)
|
||||||
|
if split != nil {
|
||||||
|
expanded = append(expanded, split...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(expanded) == 0 {
|
||||||
|
log.Printf("WARNING: no valid dia_ids after expanding evidence %v", evidenceIDs)
|
||||||
|
return float64(0) / float64(len(evidenceIDs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build turn index once (avoids re-parsing JSON per ID)
|
||||||
|
turns := GetTurns(sample)
|
||||||
|
turnMap := make(map[string]*LocomoTurn, len(turns))
|
||||||
|
for i := range turns {
|
||||||
|
turnMap[turns[i].DiaID] = &turns[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
lowerRetrieved := strings.ToLower(retrievedContent)
|
||||||
|
found := 0
|
||||||
|
resolvable := 0
|
||||||
|
for _, diaID := range expanded {
|
||||||
|
turn, ok := turnMap[diaID]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("WARNING: dia_id %q not found in sample %s", diaID, sample.SampleID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resolvable++
|
||||||
|
if len(turn.Text) < 20 {
|
||||||
|
log.Printf("WARNING: short turn text (%d chars) for dia_id %s: %q",
|
||||||
|
len(turn.Text), diaID, turn.Text)
|
||||||
|
}
|
||||||
|
if strings.Contains(lowerRetrieved, strings.ToLower(turn.Text)) {
|
||||||
|
found++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resolvable == 0 {
|
||||||
|
return 0.0 // no resolvable evidence = can't evaluate
|
||||||
|
}
|
||||||
|
return float64(found) / float64(resolvable)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BudgetTruncate truncates messages to fit within a token budget.
|
||||||
|
// Returns the truncated messages and total token count.
|
||||||
|
func BudgetTruncate(messages []string, budgetTokens int) ([]string, int) {
|
||||||
|
var result []string
|
||||||
|
total := 0
|
||||||
|
// Walk from the front (best first) and keep until budget exhausted.
|
||||||
|
for i := 0; i < len(messages); i++ {
|
||||||
|
tokens := len(messages[i]) / 4
|
||||||
|
if total+tokens > budgetTokens && len(result) > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
result = append(result, messages[i])
|
||||||
|
total += tokens
|
||||||
|
}
|
||||||
|
return result, total
|
||||||
|
}
|
||||||
|
|
||||||
|
// StringListToContent joins a list of strings into a single content string.
|
||||||
|
func StringListToContent(parts []string) string {
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSplitEvidenceIDs(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"D1:3", []string{"D1:3"}},
|
||||||
|
{"D8:6; D9:17", []string{"D8:6", "D9:17"}},
|
||||||
|
{"D9:1 D4:4 D4:6", []string{"D9:1", "D4:4", "D4:6"}},
|
||||||
|
{"D22:1 D22:2 D9:10 D9:11", []string{"D22:1", "D22:2", "D9:10", "D9:11"}},
|
||||||
|
{"D21:18 D21:22 D11:15 D11:19", []string{"D21:18", "D21:22", "D11:15", "D11:19"}},
|
||||||
|
{"D30:05", []string{"D30:5"}},
|
||||||
|
{"D", nil},
|
||||||
|
{"D:", nil},
|
||||||
|
{"", nil},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
got := SplitEvidenceIDs(tt.input)
|
||||||
|
if len(got) != len(tt.want) {
|
||||||
|
t.Fatalf("SplitEvidenceIDs(%q) = %v, want %v", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tt.want[i] {
|
||||||
|
t.Errorf("[%d] = %q, want %q", i, got[i], tt.want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeDiaID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"D1:3", "D1:3"},
|
||||||
|
{"D30:05", "D30:5"},
|
||||||
|
{"D10:003", "D10:3"},
|
||||||
|
{"D1:0", "D1:0"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := NormalizeDiaID(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("NormalizeDiaID(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenOverlapF1(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prediction string
|
||||||
|
reference string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"exact match", "hello world", "hello world", 1.0},
|
||||||
|
{"no overlap", "foo bar", "baz qux", 0.0},
|
||||||
|
{"empty both", "", "", 1.0},
|
||||||
|
{"empty prediction", "", "hello", 0.0},
|
||||||
|
{"empty reference", "hello", "", 0.0},
|
||||||
|
{"partial overlap", "the cat sat on the mat", "the cat on the floor", 8.0 / 11.0},
|
||||||
|
{"case insensitive", "Hello World", "hello world", 1.0},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := TokenOverlapF1(tt.prediction, tt.reference)
|
||||||
|
if math.Abs(got-tt.want) > 1e-9 {
|
||||||
|
t.Errorf("TokenOverlapF1(%q, %q) = %.4f, want %.4f",
|
||||||
|
tt.prediction, tt.reference, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBudgetTruncate(t *testing.T) {
|
||||||
|
t.Run("within budget returns all", func(t *testing.T) {
|
||||||
|
msgs := []string{"short", "message", "here"}
|
||||||
|
result, total := BudgetTruncate(msgs, 1000)
|
||||||
|
if len(result) != 3 {
|
||||||
|
t.Errorf("expected 3 messages, got %d", len(result))
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
t.Error("expected non-zero token count")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("over budget keeps best first", func(t *testing.T) {
|
||||||
|
msgs := []string{
|
||||||
|
"best message that is quite long and takes up tokens",
|
||||||
|
"good message also fairly long content",
|
||||||
|
"worst short",
|
||||||
|
}
|
||||||
|
result, _ := BudgetTruncate(msgs, 5) // very small budget
|
||||||
|
if len(result) == 0 {
|
||||||
|
t.Fatal("expected at least one message")
|
||||||
|
}
|
||||||
|
// Best-ranked (first) should be kept
|
||||||
|
if result[0] != "best message that is quite long and takes up tokens" {
|
||||||
|
t.Errorf("expected best message kept first, got %q", result[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("over budget keeps best ranked first", func(t *testing.T) {
|
||||||
|
// Messages are sorted by bm25 rank ascending (best/most-negative first).
|
||||||
|
// When budget is insufficient, BudgetTruncate must keep the front
|
||||||
|
// (best-ranked) messages, not the tail (worst-ranked).
|
||||||
|
msgs := []string{
|
||||||
|
"best ranked message with some content here",
|
||||||
|
"second best message also has content",
|
||||||
|
"third message here too",
|
||||||
|
"worst ranked short",
|
||||||
|
}
|
||||||
|
// Budget only fits ~1 message (~10 tokens per message, budget=12)
|
||||||
|
result, _ := BudgetTruncate(msgs, 12)
|
||||||
|
if len(result) == 0 {
|
||||||
|
t.Fatal("expected at least one message")
|
||||||
|
}
|
||||||
|
if result[0] != "best ranked message with some content here" {
|
||||||
|
t.Errorf("expected best-ranked (first) message kept, got %q", result[0])
|
||||||
|
}
|
||||||
|
// Worst-ranked (last) must NOT appear
|
||||||
|
for _, m := range result {
|
||||||
|
if m == "worst ranked short" {
|
||||||
|
t.Error("worst-ranked message should have been truncated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("preserves original order", func(t *testing.T) {
|
||||||
|
msgs := []string{"alpha", "beta", "gamma"}
|
||||||
|
result, _ := BudgetTruncate(msgs, 100)
|
||||||
|
for i, got := range result {
|
||||||
|
if got != msgs[i] {
|
||||||
|
t.Errorf("result[%d] = %q, want %q", i, got, msgs[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty input", func(t *testing.T) {
|
||||||
|
result, total := BudgetTruncate(nil, 100)
|
||||||
|
if len(result) != 0 {
|
||||||
|
t.Errorf("expected 0 messages, got %d", len(result))
|
||||||
|
}
|
||||||
|
if total != 0 {
|
||||||
|
t.Errorf("expected 0 tokens, got %d", total)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecallHitRate(t *testing.T) {
|
||||||
|
// Build a sample with known turns
|
||||||
|
sample := &LocomoSample{
|
||||||
|
SampleID: "test-sample",
|
||||||
|
Conversation: map[string]json.RawMessage{
|
||||||
|
"session_1": json.RawMessage(`[
|
||||||
|
{"speaker":"A","dia_id":"D1:1","text":"hello world this is a test message with enough length"},
|
||||||
|
{"speaker":"B","dia_id":"D1:2","text":"another message for testing recall computation purposes here"},
|
||||||
|
{"speaker":"A","dia_id":"D1:3","text":"third turn with some more content to test"}
|
||||||
|
]`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("all evidence found", func(t *testing.T) {
|
||||||
|
retrieved := "hello world this is a test message with enough length another message for testing recall computation purposes here"
|
||||||
|
got := RecallHitRate([]string{"D1:1", "D1:2"}, sample, retrieved)
|
||||||
|
if math.Abs(got-1.0) > 1e-9 {
|
||||||
|
t.Errorf("RecallHitRate all found = %.4f, want 1.0", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("partial evidence found", func(t *testing.T) {
|
||||||
|
retrieved := "hello world this is a test message with enough length"
|
||||||
|
got := RecallHitRate([]string{"D1:1", "D1:2"}, sample, retrieved)
|
||||||
|
if math.Abs(got-0.5) > 1e-9 {
|
||||||
|
t.Errorf("RecallHitRate partial = %.4f, want 0.5", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no evidence required", func(t *testing.T) {
|
||||||
|
got := RecallHitRate(nil, sample, "anything")
|
||||||
|
if got != 1.0 {
|
||||||
|
t.Errorf("RecallHitRate no evidence = %.4f, want 1.0", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing turn excluded from denominator", func(t *testing.T) {
|
||||||
|
// D1:1 is found, D99:1 does not exist in sample
|
||||||
|
// Should only count resolvable turns in denominator
|
||||||
|
retrieved := "hello world this is a test message with enough length"
|
||||||
|
got := RecallHitRate([]string{"D1:1", "D99:1"}, sample, retrieved)
|
||||||
|
if math.Abs(got-1.0) > 1e-9 {
|
||||||
|
t.Errorf("RecallHitRate missing turn = %.4f, want 1.0 (unresolvable excluded)", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractKeywords(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"simple", "What is the capital of France", []string{"capital", "france"}},
|
||||||
|
{
|
||||||
|
"stops removed",
|
||||||
|
"Who is the president of the United States",
|
||||||
|
[]string{"president", "united", "states"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"max 6 keywords",
|
||||||
|
"one two three four five six seven eight nine ten",
|
||||||
|
[]string{"one", "two", "three", "four", "five", "six"},
|
||||||
|
},
|
||||||
|
{"short words filtered", "I am a go to the store", []string{"am", "store"}},
|
||||||
|
{"empty", "", nil},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := ExtractKeywords(tt.input)
|
||||||
|
if len(got) != len(tt.want) {
|
||||||
|
t.Fatalf("ExtractKeywords(%q) = %v (len %d), want %v (len %d)",
|
||||||
|
tt.input, got, len(got), tt.want, len(tt.want))
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tt.want[i] {
|
||||||
|
t.Errorf("[%d] = %q, want %q", i, got[i], tt.want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package configstore
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
configDirName = ".picoclaw"
|
|
||||||
configFileName = "config.json"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ConfigPath() (string, error) {
|
|
||||||
dir, err := ConfigDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return filepath.Join(dir, configFileName), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ConfigDir() (string, error) {
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return filepath.Join(home, configDirName), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func Load() (*picoclawconfig.Config, error) {
|
|
||||||
path, err := ConfigPath()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return picoclawconfig.LoadConfig(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Save(cfg *picoclawconfig.Config) error {
|
|
||||||
if cfg == nil {
|
|
||||||
return errors.New("config is nil")
|
|
||||||
}
|
|
||||||
path, err := ConfigPath()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return picoclawconfig.SaveConfig(path, cfg)
|
|
||||||
}
|
|
||||||
@@ -1,522 +0,0 @@
|
|||||||
package ui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gdamore/tcell/v2"
|
|
||||||
"github.com/rivo/tview"
|
|
||||||
|
|
||||||
configstore "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/config"
|
|
||||||
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
type appState struct {
|
|
||||||
app *tview.Application
|
|
||||||
pages *tview.Pages
|
|
||||||
stack []string
|
|
||||||
config *picoclawconfig.Config
|
|
||||||
configPath string
|
|
||||||
gatewayCmd *exec.Cmd
|
|
||||||
menus map[string]*Menu
|
|
||||||
original []byte
|
|
||||||
hasOriginal bool
|
|
||||||
backupPath string
|
|
||||||
dirty bool
|
|
||||||
logPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func Run() error {
|
|
||||||
applyStyles()
|
|
||||||
cfg, err := configstore.Load()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
path, err := configstore.ConfigPath()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg == nil {
|
|
||||||
cfg = picoclawconfig.DefaultConfig()
|
|
||||||
}
|
|
||||||
|
|
||||||
originalData, hasOriginal := loadOriginalConfig(path)
|
|
||||||
backupPath := path + ".bak"
|
|
||||||
if hasOriginal {
|
|
||||||
_ = writeBackupConfig(backupPath, originalData)
|
|
||||||
}
|
|
||||||
|
|
||||||
logPath := filepath.Join(filepath.Dir(path), "gateway.log")
|
|
||||||
state := &appState{
|
|
||||||
app: tview.NewApplication(),
|
|
||||||
pages: tview.NewPages(),
|
|
||||||
config: cfg,
|
|
||||||
configPath: path,
|
|
||||||
menus: map[string]*Menu{},
|
|
||||||
original: originalData,
|
|
||||||
hasOriginal: hasOriginal,
|
|
||||||
backupPath: backupPath,
|
|
||||||
logPath: logPath,
|
|
||||||
}
|
|
||||||
|
|
||||||
state.push("main", state.mainMenu())
|
|
||||||
|
|
||||||
root := tview.NewFlex().SetDirection(tview.FlexRow)
|
|
||||||
root.AddItem(bannerView(), 6, 0, false)
|
|
||||||
root.AddItem(state.pages, 0, 1, true)
|
|
||||||
root.AddItem(footerView(), 1, 0, false)
|
|
||||||
|
|
||||||
if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) push(name string, primitive tview.Primitive) {
|
|
||||||
s.pages.AddPage(name, primitive, true, true)
|
|
||||||
s.stack = append(s.stack, name)
|
|
||||||
s.pages.SwitchToPage(name)
|
|
||||||
if menu, ok := primitive.(*Menu); ok {
|
|
||||||
s.menus[name] = menu
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) pop() {
|
|
||||||
if len(s.stack) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
last := s.stack[len(s.stack)-1]
|
|
||||||
s.pages.RemovePage(last)
|
|
||||||
s.stack = s.stack[:len(s.stack)-1]
|
|
||||||
if len(s.stack) == 0 {
|
|
||||||
s.app.Stop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
current := s.stack[len(s.stack)-1]
|
|
||||||
s.pages.SwitchToPage(current)
|
|
||||||
if menu, ok := s.menus[current]; ok {
|
|
||||||
s.refreshMenu(current, menu)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) mainMenu() tview.Primitive {
|
|
||||||
menu := NewMenu("Menu", nil)
|
|
||||||
refreshMainMenu(menu, s)
|
|
||||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
||||||
switch event.Key() {
|
|
||||||
case tcell.KeyEsc:
|
|
||||||
s.requestExit()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return event
|
|
||||||
})
|
|
||||||
|
|
||||||
return menu
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) refreshMenu(name string, menu *Menu) {
|
|
||||||
switch name {
|
|
||||||
case "main":
|
|
||||||
refreshMainMenu(menu, s)
|
|
||||||
case "model":
|
|
||||||
refreshModelMenuFromState(menu, s)
|
|
||||||
case "channel":
|
|
||||||
refreshChannelMenuFromState(menu, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) countChannels() (enabled int, total int) {
|
|
||||||
c := s.config.Channels
|
|
||||||
entries := []bool{
|
|
||||||
c.Telegram.Enabled,
|
|
||||||
c.Discord.Enabled,
|
|
||||||
c.QQ.Enabled,
|
|
||||||
c.MaixCam.Enabled,
|
|
||||||
c.WhatsApp.Enabled,
|
|
||||||
c.Feishu.Enabled,
|
|
||||||
c.DingTalk.Enabled,
|
|
||||||
c.Slack.Enabled,
|
|
||||||
c.Matrix.Enabled,
|
|
||||||
c.LINE.Enabled,
|
|
||||||
c.OneBot.Enabled,
|
|
||||||
c.WeCom.Enabled,
|
|
||||||
c.WeComApp.Enabled,
|
|
||||||
}
|
|
||||||
total = len(entries)
|
|
||||||
for _, v := range entries {
|
|
||||||
if v {
|
|
||||||
enabled++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return enabled, total
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshMainMenuIfPresent(s *appState) {
|
|
||||||
if menu, ok := s.menus["main"]; ok {
|
|
||||||
refreshMainMenu(menu, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshMainMenu(menu *Menu, s *appState) {
|
|
||||||
selectedModel := s.selectedModelName()
|
|
||||||
modelReady := selectedModel != ""
|
|
||||||
channelReady := s.hasEnabledChannel()
|
|
||||||
enabledCount, totalChannels := s.countChannels()
|
|
||||||
gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning()
|
|
||||||
|
|
||||||
gatewayLabel := "Start Gateway"
|
|
||||||
gatewayDescription := "Launch gateway for channels"
|
|
||||||
if gatewayRunning {
|
|
||||||
gatewayLabel = "Stop Gateway"
|
|
||||||
gatewayDescription = "Gateway running"
|
|
||||||
}
|
|
||||||
|
|
||||||
items := []MenuItem{
|
|
||||||
{
|
|
||||||
Label: rootModelLabel(selectedModel),
|
|
||||||
Description: rootModelDescription(),
|
|
||||||
Action: func() {
|
|
||||||
s.push("model", s.modelMenu())
|
|
||||||
},
|
|
||||||
MainColor: func() *tcell.Color {
|
|
||||||
if modelReady {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
color := tcell.ColorGray
|
|
||||||
return &color
|
|
||||||
}(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Label: rootChannelLabel(channelReady),
|
|
||||||
Description: fmt.Sprintf("%d/%d enabled", enabledCount, totalChannels),
|
|
||||||
Action: func() {
|
|
||||||
s.push("channel", s.channelMenu())
|
|
||||||
},
|
|
||||||
MainColor: func() *tcell.Color {
|
|
||||||
if channelReady {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
color := tcell.ColorGray
|
|
||||||
return &color
|
|
||||||
}(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Label: "Start Talk",
|
|
||||||
Description: "Open picoclaw agent in terminal",
|
|
||||||
Action: func() {
|
|
||||||
s.requestStartTalk()
|
|
||||||
},
|
|
||||||
Disabled: !modelReady,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Label: gatewayLabel,
|
|
||||||
Description: gatewayDescription,
|
|
||||||
Action: func() {
|
|
||||||
if gatewayRunning {
|
|
||||||
s.stopGateway()
|
|
||||||
} else {
|
|
||||||
s.requestStartGateway()
|
|
||||||
}
|
|
||||||
refreshMainMenu(menu, s)
|
|
||||||
},
|
|
||||||
Disabled: !gatewayRunning && (!modelReady || !channelReady),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Label: "View Gateway Log",
|
|
||||||
Description: "Open gateway.log",
|
|
||||||
Action: func() {
|
|
||||||
s.viewGatewayLog()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Label: "Exit",
|
|
||||||
Description: "Exit the TUI",
|
|
||||||
Action: func() {
|
|
||||||
s.requestExit()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
menu.applyItems(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) applyChangesValidated() bool {
|
|
||||||
if err := s.config.ValidateModelList(); err != nil {
|
|
||||||
s.showMessage("Validation failed", err.Error())
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if err := s.validateAgentModel(); err != nil {
|
|
||||||
s.showMessage("Validation failed", err.Error())
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if err := configstore.Save(s.config); err != nil {
|
|
||||||
s.showMessage("Save failed", err.Error())
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if data, err := os.ReadFile(s.configPath); err == nil {
|
|
||||||
s.original = data
|
|
||||||
s.hasOriginal = true
|
|
||||||
_ = writeBackupConfig(s.backupPath, data)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) requestExit() {
|
|
||||||
if s.dirty {
|
|
||||||
s.confirmApplyOrDiscard(func() {
|
|
||||||
s.app.Stop()
|
|
||||||
}, func() {
|
|
||||||
s.discardChanges()
|
|
||||||
s.app.Stop()
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.app.Stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) requestStartTalk() {
|
|
||||||
if s.dirty {
|
|
||||||
s.confirmApplyOrDiscard(func() {
|
|
||||||
s.startTalk()
|
|
||||||
}, func() {
|
|
||||||
s.startTalk()
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.startTalk()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) requestStartGateway() {
|
|
||||||
if s.dirty {
|
|
||||||
s.confirmApplyOrDiscard(func() {
|
|
||||||
s.startGateway()
|
|
||||||
}, func() {
|
|
||||||
s.startGateway()
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.startGateway()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) viewGatewayLog() {
|
|
||||||
data, err := os.ReadFile(s.logPath)
|
|
||||||
if err != nil {
|
|
||||||
s.showMessage("Log not found", "gateway.log not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
text := tview.NewTextView()
|
|
||||||
text.SetBorder(true).SetTitle("Gateway Log")
|
|
||||||
text.SetText(string(data))
|
|
||||||
text.SetDoneFunc(func(key tcell.Key) {
|
|
||||||
s.pages.RemovePage("log")
|
|
||||||
})
|
|
||||||
text.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
||||||
if event.Key() == tcell.KeyEsc {
|
|
||||||
s.pages.RemovePage("log")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return event
|
|
||||||
})
|
|
||||||
s.pages.AddPage("log", text, true, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) selectedModelName() string {
|
|
||||||
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
|
||||||
if modelName == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if !s.isActiveModelValid() {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return modelName
|
|
||||||
}
|
|
||||||
|
|
||||||
func rootModelLabel(selected string) string {
|
|
||||||
if selected == "" {
|
|
||||||
return "Model (None)"
|
|
||||||
}
|
|
||||||
return "Model (" + selected + ")"
|
|
||||||
}
|
|
||||||
|
|
||||||
func rootModelDescription() string {
|
|
||||||
return "Using SPACE to choose your model"
|
|
||||||
}
|
|
||||||
|
|
||||||
func rootChannelLabel(valid bool) string {
|
|
||||||
if !valid {
|
|
||||||
return "Channel (no channel enabled)"
|
|
||||||
}
|
|
||||||
return "Channel"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) startTalk() {
|
|
||||||
if !s.isActiveModelValid() {
|
|
||||||
s.showMessage("Model required", "Select a valid model before starting talk")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !s.applyChangesValidated() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.app.Suspend(func() {
|
|
||||||
cmd := exec.Command("picoclaw", "agent")
|
|
||||||
cmd.Stdin = os.Stdin
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
_ = cmd.Run()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) startGateway() {
|
|
||||||
if !s.isActiveModelValid() {
|
|
||||||
s.showMessage("Model required", "Select a valid model before starting gateway")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !s.hasEnabledChannel() {
|
|
||||||
s.showMessage("Channel required", "Enable at least one channel before starting gateway")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !s.applyChangesValidated() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = stopGatewayProcess()
|
|
||||||
cmd := exec.Command("picoclaw", "gateway")
|
|
||||||
logFile, err := os.OpenFile(s.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
||||||
if err != nil {
|
|
||||||
s.showMessage("Gateway failed", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cmd.Stdout = logFile
|
|
||||||
cmd.Stderr = logFile
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
s.showMessage("Gateway failed", err.Error())
|
|
||||||
_ = logFile.Close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = logFile.Close()
|
|
||||||
s.gatewayCmd = cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) stopGateway() {
|
|
||||||
_ = stopGatewayProcess()
|
|
||||||
if s.gatewayCmd != nil && s.gatewayCmd.Process != nil {
|
|
||||||
_ = s.gatewayCmd.Process.Kill()
|
|
||||||
}
|
|
||||||
s.gatewayCmd = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) isGatewayRunning() bool {
|
|
||||||
return isGatewayProcessRunning()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) validateAgentModel() error {
|
|
||||||
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
|
||||||
if modelName == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
_, err := s.config.GetModelConfig(modelName)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) isActiveModelValid() bool {
|
|
||||||
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
|
||||||
if modelName == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
cfg, err := s.config.GetModelConfig(modelName)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
hasKey := strings.TrimSpace(cfg.APIKey) != "" || strings.TrimSpace(cfg.AuthMethod) == "oauth"
|
|
||||||
hasModel := strings.TrimSpace(cfg.Model) != ""
|
|
||||||
return hasKey && hasModel
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) hasEnabledChannel() bool {
|
|
||||||
c := s.config.Channels
|
|
||||||
return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled ||
|
|
||||||
c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled ||
|
|
||||||
c.Matrix.Enabled || c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) confirmApplyOrDiscard(onApply func(), onDiscard func()) {
|
|
||||||
if s.pages.HasPage("apply") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
modal := tview.NewModal().
|
|
||||||
SetText("Apply changes or discard before continuing?").
|
|
||||||
AddButtons([]string{"Cancel", "Discard", "Apply"}).
|
|
||||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
|
||||||
s.pages.RemovePage("apply")
|
|
||||||
switch buttonLabel {
|
|
||||||
case "Discard":
|
|
||||||
s.discardChanges()
|
|
||||||
if onDiscard != nil {
|
|
||||||
onDiscard()
|
|
||||||
}
|
|
||||||
case "Apply":
|
|
||||||
if s.applyChangesValidated() {
|
|
||||||
s.dirty = false
|
|
||||||
if onApply != nil {
|
|
||||||
onApply()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
modal.SetBorder(true)
|
|
||||||
s.pages.AddPage("apply", modal, true, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) discardChanges() {
|
|
||||||
if s.hasOriginal {
|
|
||||||
_ = writeOriginalConfig(s.configPath, s.original)
|
|
||||||
} else {
|
|
||||||
_ = os.Remove(s.configPath)
|
|
||||||
}
|
|
||||||
_ = os.Remove(s.backupPath)
|
|
||||||
if cfg, err := configstore.Load(); err == nil && cfg != nil {
|
|
||||||
s.config = cfg
|
|
||||||
}
|
|
||||||
s.dirty = false
|
|
||||||
refreshMainMenuIfPresent(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) showMessage(title, message string) {
|
|
||||||
if s.pages.HasPage("message") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
modal := tview.NewModal().
|
|
||||||
SetText(strings.TrimSpace(message)).
|
|
||||||
AddButtons([]string{"OK"}).
|
|
||||||
SetDoneFunc(func(_ int, _ string) {
|
|
||||||
s.pages.RemovePage("message")
|
|
||||||
})
|
|
||||||
modal.SetTitle(title).SetBorder(true)
|
|
||||||
modal.SetBackgroundColor(tview.Styles.ContrastBackgroundColor)
|
|
||||||
modal.SetTextColor(tview.Styles.PrimaryTextColor)
|
|
||||||
modal.SetButtonBackgroundColor(tcell.NewRGBColor(112, 102, 255))
|
|
||||||
modal.SetButtonTextColor(tview.Styles.PrimaryTextColor)
|
|
||||||
s.pages.AddPage("message", modal, true, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadOriginalConfig(path string) ([]byte, bool) {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return data, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeOriginalConfig(path string, data []byte) error {
|
|
||||||
return os.WriteFile(path, data, 0o600)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeBackupConfig(path string, data []byte) error {
|
|
||||||
return os.WriteFile(path, data, 0o600)
|
|
||||||
}
|
|
||||||
@@ -1,433 +0,0 @@
|
|||||||
package ui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gdamore/tcell/v2"
|
|
||||||
"github.com/rivo/tview"
|
|
||||||
|
|
||||||
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *appState) buildChannelMenuItems() []MenuItem {
|
|
||||||
return []MenuItem{
|
|
||||||
channelItem(
|
|
||||||
"Telegram",
|
|
||||||
"Telegram bot settings",
|
|
||||||
s.config.Channels.Telegram.Enabled,
|
|
||||||
func() { s.push("channel-telegram", s.telegramForm()) },
|
|
||||||
),
|
|
||||||
channelItem(
|
|
||||||
"Discord",
|
|
||||||
"Discord bot settings",
|
|
||||||
s.config.Channels.Discord.Enabled,
|
|
||||||
func() { s.push("channel-discord", s.discordForm()) },
|
|
||||||
),
|
|
||||||
channelItem(
|
|
||||||
"QQ",
|
|
||||||
"QQ bot settings",
|
|
||||||
s.config.Channels.QQ.Enabled,
|
|
||||||
func() { s.push("channel-qq", s.qqForm()) },
|
|
||||||
),
|
|
||||||
channelItem(
|
|
||||||
"MaixCam",
|
|
||||||
"MaixCam gateway",
|
|
||||||
s.config.Channels.MaixCam.Enabled,
|
|
||||||
func() { s.push("channel-maixcam", s.maixcamForm()) },
|
|
||||||
),
|
|
||||||
channelItem(
|
|
||||||
"WhatsApp",
|
|
||||||
"WhatsApp bridge",
|
|
||||||
s.config.Channels.WhatsApp.Enabled,
|
|
||||||
func() { s.push("channel-whatsapp", s.whatsappForm()) },
|
|
||||||
),
|
|
||||||
channelItem(
|
|
||||||
"Feishu",
|
|
||||||
"Feishu bot settings",
|
|
||||||
s.config.Channels.Feishu.Enabled,
|
|
||||||
func() { s.push("channel-feishu", s.feishuForm()) },
|
|
||||||
),
|
|
||||||
channelItem(
|
|
||||||
"DingTalk",
|
|
||||||
"DingTalk bot settings",
|
|
||||||
s.config.Channels.DingTalk.Enabled,
|
|
||||||
func() { s.push("channel-dingtalk", s.dingtalkForm()) },
|
|
||||||
),
|
|
||||||
channelItem(
|
|
||||||
"Slack",
|
|
||||||
"Slack bot settings",
|
|
||||||
s.config.Channels.Slack.Enabled,
|
|
||||||
func() { s.push("channel-slack", s.slackForm()) },
|
|
||||||
),
|
|
||||||
channelItem(
|
|
||||||
"Matrix",
|
|
||||||
"Matrix bot settings",
|
|
||||||
s.config.Channels.Matrix.Enabled,
|
|
||||||
func() { s.push("channel-matrix", s.matrixForm()) },
|
|
||||||
),
|
|
||||||
channelItem(
|
|
||||||
"LINE",
|
|
||||||
"LINE bot settings",
|
|
||||||
s.config.Channels.LINE.Enabled,
|
|
||||||
func() { s.push("channel-line", s.lineForm()) },
|
|
||||||
),
|
|
||||||
channelItem(
|
|
||||||
"OneBot",
|
|
||||||
"OneBot settings",
|
|
||||||
s.config.Channels.OneBot.Enabled,
|
|
||||||
func() { s.push("channel-onebot", s.onebotForm()) },
|
|
||||||
),
|
|
||||||
channelItem(
|
|
||||||
"WeCom",
|
|
||||||
"WeCom bot settings",
|
|
||||||
s.config.Channels.WeCom.Enabled,
|
|
||||||
func() { s.push("channel-wecom", s.wecomForm()) },
|
|
||||||
),
|
|
||||||
channelItem(
|
|
||||||
"WeCom App",
|
|
||||||
"WeCom App settings",
|
|
||||||
s.config.Channels.WeComApp.Enabled,
|
|
||||||
func() { s.push("channel-wecomapp", s.wecomAppForm()) },
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) channelMenu() tview.Primitive {
|
|
||||||
menu := NewMenu("Channels", s.buildChannelMenuItems())
|
|
||||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
||||||
if event.Key() == tcell.KeyEsc {
|
|
||||||
s.pop()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return event
|
|
||||||
})
|
|
||||||
return menu
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshChannelMenuFromState(menu *Menu, s *appState) {
|
|
||||||
menu.applyItems(s.buildChannelMenuItems())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) telegramForm() tview.Primitive {
|
|
||||||
cfg := &s.config.Channels.Telegram
|
|
||||||
form := baseChannelForm("Telegram", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
|
||||||
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
|
||||||
cfg.Token = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Proxy", cfg.Proxy, 128, nil, func(text string) {
|
|
||||||
cfg.Proxy = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
|
||||||
return wrapWithBack(form, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) discordForm() tview.Primitive {
|
|
||||||
cfg := &s.config.Channels.Discord
|
|
||||||
form := baseChannelForm("Discord", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
|
||||||
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
|
||||||
cfg.Token = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddCheckbox("Mention Only", cfg.MentionOnly, func(checked bool) {
|
|
||||||
cfg.MentionOnly = checked
|
|
||||||
})
|
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
|
||||||
return wrapWithBack(form, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) qqForm() tview.Primitive {
|
|
||||||
cfg := &s.config.Channels.QQ
|
|
||||||
form := baseChannelForm("QQ", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
|
||||||
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
|
|
||||||
cfg.AppID = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
|
|
||||||
cfg.AppSecret = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
|
||||||
return wrapWithBack(form, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) maixcamForm() tview.Primitive {
|
|
||||||
cfg := &s.config.Channels.MaixCam
|
|
||||||
form := baseChannelForm("MaixCam", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
|
||||||
form.AddInputField("Host", cfg.Host, 64, nil, func(text string) {
|
|
||||||
cfg.Host = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addIntField(form, "Port", cfg.Port, func(value int) { cfg.Port = value })
|
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
|
||||||
return wrapWithBack(form, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) whatsappForm() tview.Primitive {
|
|
||||||
cfg := &s.config.Channels.WhatsApp
|
|
||||||
form := baseChannelForm("WhatsApp", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
|
||||||
form.AddInputField("Bridge URL", cfg.BridgeURL, 128, nil, func(text string) {
|
|
||||||
cfg.BridgeURL = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
|
||||||
return wrapWithBack(form, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) feishuForm() tview.Primitive {
|
|
||||||
cfg := &s.config.Channels.Feishu
|
|
||||||
form := baseChannelForm("Feishu", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
|
||||||
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
|
|
||||||
cfg.AppID = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
|
|
||||||
cfg.AppSecret = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Encrypt Key", cfg.EncryptKey, 128, nil, func(text string) {
|
|
||||||
cfg.EncryptKey = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Verification Token", cfg.VerificationToken, 128, nil, func(text string) {
|
|
||||||
cfg.VerificationToken = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
|
||||||
return wrapWithBack(form, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) dingtalkForm() tview.Primitive {
|
|
||||||
cfg := &s.config.Channels.DingTalk
|
|
||||||
form := baseChannelForm("DingTalk", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
|
||||||
form.AddInputField("Client ID", cfg.ClientID, 64, nil, func(text string) {
|
|
||||||
cfg.ClientID = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Client Secret", cfg.ClientSecret, 128, nil, func(text string) {
|
|
||||||
cfg.ClientSecret = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
|
||||||
return wrapWithBack(form, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) slackForm() tview.Primitive {
|
|
||||||
cfg := &s.config.Channels.Slack
|
|
||||||
form := baseChannelForm("Slack", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
|
||||||
form.AddInputField("Bot Token", cfg.BotToken, 128, nil, func(text string) {
|
|
||||||
cfg.BotToken = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("App Token", cfg.AppToken, 128, nil, func(text string) {
|
|
||||||
cfg.AppToken = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
|
||||||
return wrapWithBack(form, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) lineForm() tview.Primitive {
|
|
||||||
cfg := &s.config.Channels.LINE
|
|
||||||
form := baseChannelForm("LINE", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
|
||||||
form.AddInputField("Channel Secret", cfg.ChannelSecret, 128, nil, func(text string) {
|
|
||||||
cfg.ChannelSecret = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Channel Access Token", cfg.ChannelAccessToken, 128, nil, func(text string) {
|
|
||||||
cfg.ChannelAccessToken = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
|
||||||
cfg.WebhookHost = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
|
|
||||||
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
|
|
||||||
cfg.WebhookPath = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
|
||||||
return wrapWithBack(form, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) matrixForm() tview.Primitive {
|
|
||||||
cfg := &s.config.Channels.Matrix
|
|
||||||
form := baseChannelForm("Matrix", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
|
||||||
form.AddInputField("Homeserver", cfg.Homeserver, 128, nil, func(text string) {
|
|
||||||
cfg.Homeserver = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("User ID", cfg.UserID, 128, nil, func(text string) {
|
|
||||||
cfg.UserID = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
|
|
||||||
cfg.AccessToken = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Device ID", cfg.DeviceID, 128, nil, func(text string) {
|
|
||||||
cfg.DeviceID = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddCheckbox("Join On Invite", cfg.JoinOnInvite, func(checked bool) {
|
|
||||||
cfg.JoinOnInvite = checked
|
|
||||||
})
|
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
|
||||||
return wrapWithBack(form, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) onebotForm() tview.Primitive {
|
|
||||||
cfg := &s.config.Channels.OneBot
|
|
||||||
form := baseChannelForm("OneBot", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
|
||||||
form.AddInputField("WS URL", cfg.WSUrl, 128, nil, func(text string) {
|
|
||||||
cfg.WSUrl = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
|
|
||||||
cfg.AccessToken = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addIntField(
|
|
||||||
form,
|
|
||||||
"Reconnect Interval",
|
|
||||||
cfg.ReconnectInterval,
|
|
||||||
func(value int) { cfg.ReconnectInterval = value },
|
|
||||||
)
|
|
||||||
form.AddInputField(
|
|
||||||
"Group Trigger Prefix",
|
|
||||||
strings.Join(cfg.GroupTriggerPrefix, ","),
|
|
||||||
128,
|
|
||||||
nil,
|
|
||||||
func(text string) {
|
|
||||||
cfg.GroupTriggerPrefix = splitCSV(text)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
|
||||||
return wrapWithBack(form, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) wecomForm() tview.Primitive {
|
|
||||||
cfg := &s.config.Channels.WeCom
|
|
||||||
form := baseChannelForm("WeCom", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
|
||||||
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
|
||||||
cfg.Token = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
|
|
||||||
cfg.EncodingAESKey = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Webhook URL", cfg.WebhookURL, 128, nil, func(text string) {
|
|
||||||
cfg.WebhookURL = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
|
||||||
cfg.WebhookHost = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
|
|
||||||
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
|
|
||||||
cfg.WebhookPath = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
|
||||||
addIntField(
|
|
||||||
form,
|
|
||||||
"Reply Timeout",
|
|
||||||
cfg.ReplyTimeout,
|
|
||||||
func(value int) { cfg.ReplyTimeout = value },
|
|
||||||
)
|
|
||||||
return wrapWithBack(form, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) wecomAppForm() tview.Primitive {
|
|
||||||
cfg := &s.config.Channels.WeComApp
|
|
||||||
form := baseChannelForm("WeCom App", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
|
||||||
form.AddInputField("Corp ID", cfg.CorpID, 64, nil, func(text string) {
|
|
||||||
cfg.CorpID = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Corp Secret", cfg.CorpSecret, 128, nil, func(text string) {
|
|
||||||
cfg.CorpSecret = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addInt64Field(form, "Agent ID", cfg.AgentID, func(value int64) { cfg.AgentID = value })
|
|
||||||
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
|
||||||
cfg.Token = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
|
|
||||||
cfg.EncodingAESKey = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
|
||||||
cfg.WebhookHost = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
|
|
||||||
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
|
|
||||||
cfg.WebhookPath = strings.TrimSpace(text)
|
|
||||||
})
|
|
||||||
addAllowFromField(form, &cfg.AllowFrom)
|
|
||||||
addIntField(
|
|
||||||
form,
|
|
||||||
"Reply Timeout",
|
|
||||||
cfg.ReplyTimeout,
|
|
||||||
func(value int) { cfg.ReplyTimeout = value },
|
|
||||||
)
|
|
||||||
return wrapWithBack(form, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) makeChannelOnEnabled(enabledPtr *bool) func(bool) {
|
|
||||||
return func(v bool) {
|
|
||||||
*enabledPtr = v
|
|
||||||
s.dirty = true
|
|
||||||
refreshMainMenuIfPresent(s)
|
|
||||||
if menu, ok := s.menus["channel"]; ok {
|
|
||||||
refreshChannelMenuFromState(menu, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func addAllowFromField(form *tview.Form, allowFrom *picoclawconfig.FlexibleStringSlice) {
|
|
||||||
form.AddInputField("Allow From", strings.Join(*allowFrom, ","), 128, nil, func(text string) {
|
|
||||||
*allowFrom = splitCSV(text)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func baseChannelForm(title string, enabled bool, onEnabled func(bool)) *tview.Form {
|
|
||||||
form := tview.NewForm()
|
|
||||||
form.SetBorder(true).SetTitle(fmt.Sprintf("Channel: %s", title))
|
|
||||||
form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
|
|
||||||
form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
|
|
||||||
form.AddCheckbox("Enabled", enabled, func(checked bool) {
|
|
||||||
onEnabled(checked)
|
|
||||||
})
|
|
||||||
return form
|
|
||||||
}
|
|
||||||
|
|
||||||
func wrapWithBack(form *tview.Form, s *appState) tview.Primitive {
|
|
||||||
form.AddButton("Back", func() {
|
|
||||||
s.pop()
|
|
||||||
})
|
|
||||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
||||||
if event.Key() == tcell.KeyEsc {
|
|
||||||
s.pop()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return event
|
|
||||||
})
|
|
||||||
return form
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitCSV(input string) picoclawconfig.FlexibleStringSlice {
|
|
||||||
parts := strings.Split(strings.TrimSpace(input), ",")
|
|
||||||
cleaned := make([]string, 0, len(parts))
|
|
||||||
for _, part := range parts {
|
|
||||||
value := strings.TrimSpace(part)
|
|
||||||
if value == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cleaned = append(cleaned, value)
|
|
||||||
}
|
|
||||||
return cleaned
|
|
||||||
}
|
|
||||||
|
|
||||||
func addIntField(form *tview.Form, label string, value int, onChange func(int)) {
|
|
||||||
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
|
|
||||||
var parsed int
|
|
||||||
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
|
|
||||||
onChange(parsed)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func addInt64Field(form *tview.Form, label string, value int64, onChange func(int64)) {
|
|
||||||
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
|
|
||||||
var parsed int64
|
|
||||||
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
|
|
||||||
onChange(parsed)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func channelItem(label, description string, enabled bool, action MenuAction) MenuItem {
|
|
||||||
item := MenuItem{
|
|
||||||
Label: label,
|
|
||||||
Description: description,
|
|
||||||
Action: action,
|
|
||||||
}
|
|
||||||
if !enabled {
|
|
||||||
color := tcell.ColorGray
|
|
||||||
item.MainColor = &color
|
|
||||||
}
|
|
||||||
return item
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
//go:build !windows
|
|
||||||
// +build !windows
|
|
||||||
|
|
||||||
package ui
|
|
||||||
|
|
||||||
import "os/exec"
|
|
||||||
|
|
||||||
func isGatewayProcessRunning() bool {
|
|
||||||
cmd := exec.Command("sh", "-c", "pgrep -f 'picoclaw\\s+gateway' >/dev/null 2>&1")
|
|
||||||
return cmd.Run() == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func stopGatewayProcess() error {
|
|
||||||
cmd := exec.Command("sh", "-c", "pkill -f 'picoclaw\\s+gateway' >/dev/null 2>&1")
|
|
||||||
return cmd.Run()
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
// +build windows
|
|
||||||
|
|
||||||
package ui
|
|
||||||
|
|
||||||
import "os/exec"
|
|
||||||
|
|
||||||
func isGatewayProcessRunning() bool {
|
|
||||||
cmd := exec.Command("tasklist", "/FI", "IMAGENAME eq picoclaw.exe")
|
|
||||||
return cmd.Run() == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func stopGatewayProcess() error {
|
|
||||||
cmd := exec.Command("taskkill", "/F", "/IM", "picoclaw.exe")
|
|
||||||
return cmd.Run()
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
package ui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gdamore/tcell/v2"
|
|
||||||
"github.com/rivo/tview"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MenuAction func()
|
|
||||||
|
|
||||||
type MenuItem struct {
|
|
||||||
Label string
|
|
||||||
Description string
|
|
||||||
Action MenuAction
|
|
||||||
Disabled bool
|
|
||||||
MainColor *tcell.Color
|
|
||||||
DescColor *tcell.Color
|
|
||||||
}
|
|
||||||
|
|
||||||
type Menu struct {
|
|
||||||
*tview.Table
|
|
||||||
items []MenuItem
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewMenu(title string, items []MenuItem) *Menu {
|
|
||||||
table := tview.NewTable().SetSelectable(true, false)
|
|
||||||
table.SetBorder(true).SetTitle(title)
|
|
||||||
table.SetBorders(false)
|
|
||||||
menu := &Menu{Table: table, items: items}
|
|
||||||
menu.applyItems(items)
|
|
||||||
menu.SetSelectedFunc(func(row, _ int) {
|
|
||||||
if row < 0 || row >= len(menu.items) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item := menu.items[row]
|
|
||||||
if item.Disabled || item.Action == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item.Action()
|
|
||||||
})
|
|
||||||
menu.SetSelectedStyle(
|
|
||||||
tcell.StyleDefault.Foreground(tview.Styles.InverseTextColor).
|
|
||||||
Background(tcell.NewRGBColor(189, 147, 249)),
|
|
||||||
)
|
|
||||||
return menu
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Menu) applyItems(items []MenuItem) {
|
|
||||||
m.items = items
|
|
||||||
m.Clear()
|
|
||||||
for row, item := range items {
|
|
||||||
label := item.Label
|
|
||||||
if item.Disabled && label != "" {
|
|
||||||
label = label + " (disabled)"
|
|
||||||
}
|
|
||||||
left := tview.NewTableCell(label)
|
|
||||||
right := tview.NewTableCell(item.Description).SetAlign(tview.AlignRight)
|
|
||||||
if item.MainColor != nil {
|
|
||||||
left.SetTextColor(*item.MainColor)
|
|
||||||
}
|
|
||||||
if item.DescColor != nil {
|
|
||||||
right.SetTextColor(*item.DescColor)
|
|
||||||
} else {
|
|
||||||
right.SetTextColor(tview.Styles.TertiaryTextColor)
|
|
||||||
}
|
|
||||||
if item.Disabled {
|
|
||||||
left.SetTextColor(tcell.ColorGray)
|
|
||||||
right.SetTextColor(tcell.ColorGray)
|
|
||||||
}
|
|
||||||
m.SetCell(row, 0, left)
|
|
||||||
m.SetCell(row, 1, right)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
package ui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gdamore/tcell/v2"
|
|
||||||
"github.com/rivo/tview"
|
|
||||||
|
|
||||||
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *appState) modelMenu() tview.Primitive {
|
|
||||||
items := make([]MenuItem, 0, 1+len(s.config.ModelList))
|
|
||||||
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
|
||||||
for i := range s.config.ModelList {
|
|
||||||
index := i
|
|
||||||
model := s.config.ModelList[i]
|
|
||||||
isValid := isModelValid(model)
|
|
||||||
desc := model.APIBase
|
|
||||||
if desc == "" {
|
|
||||||
desc = model.AuthMethod
|
|
||||||
}
|
|
||||||
if desc == "" {
|
|
||||||
desc = "api_key required"
|
|
||||||
}
|
|
||||||
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
|
||||||
if model.ModelName == currentModel && currentModel != "" {
|
|
||||||
label = "* " + label
|
|
||||||
}
|
|
||||||
isSelected := model.ModelName == currentModel && currentModel != ""
|
|
||||||
items = append(items, MenuItem{
|
|
||||||
Label: label,
|
|
||||||
Description: desc,
|
|
||||||
MainColor: modelStatusColor(isValid, isSelected),
|
|
||||||
Action: func() {
|
|
||||||
s.push(fmt.Sprintf("model-%d", index), s.modelForm(index))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// Add model entry appended at the end so the models map to rows 1..N
|
|
||||||
items = append(items,
|
|
||||||
MenuItem{
|
|
||||||
Label: "**Add model**",
|
|
||||||
Description: "Append a new model entry",
|
|
||||||
Action: func() {
|
|
||||||
newName := s.nextAvailableModelName("new-model")
|
|
||||||
s.addModel(
|
|
||||||
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
|
|
||||||
)
|
|
||||||
s.push(
|
|
||||||
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
|
||||||
s.modelForm(len(s.config.ModelList)-1),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
menu := NewMenu("Models", items)
|
|
||||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
||||||
if event.Key() == tcell.KeyEsc {
|
|
||||||
s.pop()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if event.Rune() == ' ' {
|
|
||||||
row, _ := menu.GetSelection()
|
|
||||||
if row >= 0 && row < len(s.config.ModelList) {
|
|
||||||
model := s.config.ModelList[row]
|
|
||||||
if !isModelValid(model) {
|
|
||||||
s.showMessage(
|
|
||||||
"Invalid model",
|
|
||||||
"Select a model with api_key or oauth auth_method",
|
|
||||||
)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
s.config.Agents.Defaults.Model = model.ModelName
|
|
||||||
s.dirty = true
|
|
||||||
refreshModelMenu(menu, s.config.Agents.Defaults.Model, s.config.ModelList)
|
|
||||||
refreshMainMenuIfPresent(s)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return event
|
|
||||||
})
|
|
||||||
return menu
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) modelForm(index int) tview.Primitive {
|
|
||||||
model := &s.config.ModelList[index]
|
|
||||||
form := tview.NewForm()
|
|
||||||
form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
|
|
||||||
|
|
||||||
addInput(form, "Model Name", model.ModelName, func(value string) {
|
|
||||||
if value == "" {
|
|
||||||
s.showMessage("Invalid model name", "Model Name cannot be empty")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if s.modelNameExists(value, index) {
|
|
||||||
s.showMessage("Duplicate model name", fmt.Sprintf("Model Name '%s' already exists", value))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
oldName := model.ModelName
|
|
||||||
model.ModelName = value
|
|
||||||
if s.config.Agents.Defaults.Model == oldName {
|
|
||||||
s.config.Agents.Defaults.Model = value
|
|
||||||
}
|
|
||||||
s.dirty = true
|
|
||||||
form.SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
|
|
||||||
refreshMainMenuIfPresent(s)
|
|
||||||
if menu, ok := s.menus["model"]; ok {
|
|
||||||
refreshModelMenuFromState(menu, s)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
addInput(form, "Model", model.Model, func(value string) {
|
|
||||||
model.Model = value
|
|
||||||
s.dirty = true
|
|
||||||
refreshMainMenuIfPresent(s)
|
|
||||||
if menu, ok := s.menus["model"]; ok {
|
|
||||||
refreshModelMenuFromState(menu, s)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
addInput(form, "API Base", model.APIBase, func(value string) {
|
|
||||||
model.APIBase = value
|
|
||||||
s.dirty = true
|
|
||||||
refreshMainMenuIfPresent(s)
|
|
||||||
if menu, ok := s.menus["model"]; ok {
|
|
||||||
refreshModelMenuFromState(menu, s)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
addInput(form, "API Key", model.APIKey, func(value string) {
|
|
||||||
model.APIKey = value
|
|
||||||
s.dirty = true
|
|
||||||
refreshMainMenuIfPresent(s)
|
|
||||||
if menu, ok := s.menus["model"]; ok {
|
|
||||||
refreshModelMenuFromState(menu, s)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
addInput(form, "Proxy", model.Proxy, func(value string) {
|
|
||||||
model.Proxy = value
|
|
||||||
})
|
|
||||||
addInput(form, "Auth Method", model.AuthMethod, func(value string) {
|
|
||||||
model.AuthMethod = value
|
|
||||||
s.dirty = true
|
|
||||||
refreshMainMenuIfPresent(s)
|
|
||||||
if menu, ok := s.menus["model"]; ok {
|
|
||||||
refreshModelMenuFromState(menu, s)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
addInput(form, "Connect Mode", model.ConnectMode, func(value string) {
|
|
||||||
model.ConnectMode = value
|
|
||||||
})
|
|
||||||
addInput(form, "Workspace", model.Workspace, func(value string) {
|
|
||||||
model.Workspace = value
|
|
||||||
})
|
|
||||||
addInput(form, "Max Tokens Field", model.MaxTokensField, func(value string) {
|
|
||||||
model.MaxTokensField = value
|
|
||||||
})
|
|
||||||
addIntInput(form, "RPM", model.RPM, func(value int) {
|
|
||||||
model.RPM = value
|
|
||||||
})
|
|
||||||
addIntInput(form, "Request Timeout", model.RequestTimeout, func(value int) {
|
|
||||||
model.RequestTimeout = value
|
|
||||||
})
|
|
||||||
|
|
||||||
form.AddButton("Delete", func() {
|
|
||||||
pageName := "confirm-delete-model"
|
|
||||||
if s.pages.HasPage(pageName) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
modal := tview.NewModal().
|
|
||||||
SetText("Are you sure you want to delete this model?").
|
|
||||||
AddButtons([]string{"Cancel", "Delete"}).
|
|
||||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
|
||||||
s.pages.RemovePage(pageName)
|
|
||||||
if buttonLabel == "Delete" {
|
|
||||||
s.deleteModel(index)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
modal.SetTitle("Confirm Delete").SetBorder(true)
|
|
||||||
s.pages.AddPage(pageName, modal, true, true)
|
|
||||||
})
|
|
||||||
form.AddButton("Test", func() {
|
|
||||||
s.testModel(model)
|
|
||||||
})
|
|
||||||
form.AddButton("Back", func() {
|
|
||||||
s.pop()
|
|
||||||
})
|
|
||||||
|
|
||||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
||||||
if event.Key() == tcell.KeyEsc {
|
|
||||||
s.pop()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return event
|
|
||||||
})
|
|
||||||
return form
|
|
||||||
}
|
|
||||||
|
|
||||||
func addInput(form *tview.Form, label, value string, onChange func(string)) {
|
|
||||||
form.AddInputField(label, value, 128, nil, func(text string) {
|
|
||||||
onChange(strings.TrimSpace(text))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func addIntInput(form *tview.Form, label string, value int, onChange func(int)) {
|
|
||||||
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
|
|
||||||
var parsed int
|
|
||||||
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
|
|
||||||
onChange(parsed)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) addModel(model picoclawconfig.ModelConfig) {
|
|
||||||
s.config.ModelList = append(s.config.ModelList, model)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) deleteModel(index int) {
|
|
||||||
if index < 0 || index >= len(s.config.ModelList) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.config.ModelList = append(s.config.ModelList[:index], s.config.ModelList[index+1:]...)
|
|
||||||
s.pop()
|
|
||||||
}
|
|
||||||
|
|
||||||
func modelStatusColor(valid bool, selected bool) *tcell.Color {
|
|
||||||
if valid {
|
|
||||||
color := tview.Styles.PrimaryTextColor
|
|
||||||
return &color
|
|
||||||
}
|
|
||||||
color := tcell.ColorGray
|
|
||||||
return &color
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {
|
|
||||||
for i, model := range models {
|
|
||||||
row := i
|
|
||||||
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
|
||||||
isValid := isModelValid(model)
|
|
||||||
if model.ModelName == currentModel && currentModel != "" {
|
|
||||||
label = "* " + label
|
|
||||||
}
|
|
||||||
cell := menu.GetCell(row, 0)
|
|
||||||
if cell != nil {
|
|
||||||
cell.SetText(label)
|
|
||||||
isSelected := model.ModelName == currentModel && currentModel != ""
|
|
||||||
color := modelStatusColor(isValid, isSelected)
|
|
||||||
if color != nil {
|
|
||||||
cell.SetTextColor(*color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshModelMenuFromState(menu *Menu, s *appState) {
|
|
||||||
items := make([]MenuItem, 0, 1+len(s.config.ModelList))
|
|
||||||
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
|
||||||
for i := range s.config.ModelList {
|
|
||||||
index := i
|
|
||||||
model := s.config.ModelList[i]
|
|
||||||
isValid := isModelValid(model)
|
|
||||||
desc := model.APIBase
|
|
||||||
if desc == "" {
|
|
||||||
desc = model.AuthMethod
|
|
||||||
}
|
|
||||||
if desc == "" {
|
|
||||||
desc = "api_key required"
|
|
||||||
}
|
|
||||||
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
|
||||||
if model.ModelName == currentModel && currentModel != "" {
|
|
||||||
label = "* " + label
|
|
||||||
}
|
|
||||||
isSelected := model.ModelName == currentModel && currentModel != ""
|
|
||||||
items = append(items, MenuItem{
|
|
||||||
Label: label,
|
|
||||||
Description: desc,
|
|
||||||
MainColor: modelStatusColor(isValid, isSelected),
|
|
||||||
Action: func() {
|
|
||||||
s.push(fmt.Sprintf("model-%d", index), s.modelForm(index))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
items = append(items,
|
|
||||||
MenuItem{
|
|
||||||
Label: "**Add Model**",
|
|
||||||
Description: "Append a new model entry",
|
|
||||||
Action: func() {
|
|
||||||
newName := s.nextAvailableModelName("new-model")
|
|
||||||
s.addModel(
|
|
||||||
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
|
|
||||||
)
|
|
||||||
s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1))
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
menu.applyItems(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isModelValid(model picoclawconfig.ModelConfig) bool {
|
|
||||||
hasKey := strings.TrimSpace(model.APIKey) != "" ||
|
|
||||||
strings.TrimSpace(model.AuthMethod) == "oauth"
|
|
||||||
hasModel := strings.TrimSpace(model.Model) != ""
|
|
||||||
return hasKey && hasModel
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) modelNameExists(name string, excludeIndex int) bool {
|
|
||||||
target := strings.TrimSpace(name)
|
|
||||||
if target == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for i := range s.config.ModelList {
|
|
||||||
if i == excludeIndex {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(s.config.ModelList[i].ModelName) == target {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) nextAvailableModelName(base string) string {
|
|
||||||
name := strings.TrimSpace(base)
|
|
||||||
if name == "" {
|
|
||||||
name = "new-model"
|
|
||||||
}
|
|
||||||
if !s.modelNameExists(name, -1) {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
for i := 2; ; i++ {
|
|
||||||
candidate := fmt.Sprintf("%s-%d", name, i)
|
|
||||||
if !s.modelNameExists(candidate, -1) {
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
|
|
||||||
if model == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(model.APIKey) == "" {
|
|
||||||
s.showMessage("Missing API Key", "Set api_key before testing")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
base := strings.TrimSpace(model.APIBase)
|
|
||||||
if base == "" {
|
|
||||||
s.showMessage("Missing API Base", "Set api_base before testing")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
modelID := strings.TrimSpace(model.Model)
|
|
||||||
if modelID == "" {
|
|
||||||
s.showMessage("Missing Model", "Set model before testing")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(modelID, "openai/") {
|
|
||||||
s.showMessage("Unsupported model", "Only openai/* models are supported for test")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
modelName := strings.TrimPrefix(modelID, "openai/")
|
|
||||||
endpoint := strings.TrimRight(base, "/") + "/chat/completions"
|
|
||||||
|
|
||||||
payload := fmt.Sprintf(
|
|
||||||
`{"model":"%s","messages":[{"role":"user","content":"ping"}],"max_tokens":1}`,
|
|
||||||
modelName,
|
|
||||||
)
|
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
|
||||||
request, err := http.NewRequest("POST", endpoint, strings.NewReader(payload))
|
|
||||||
if err != nil {
|
|
||||||
s.showMessage("Test failed", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
request.Header.Set("Content-Type", "application/json")
|
|
||||||
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(model.APIKey))
|
|
||||||
|
|
||||||
resp, err := client.Do(request)
|
|
||||||
if err != nil {
|
|
||||||
s.showMessage("Test failed", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
||||||
s.showMessage("Test OK", resp.Status)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
||||||
if err != nil {
|
|
||||||
s.showMessage("Test failed", fmt.Sprintf("failed to read response: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.showMessage(
|
|
||||||
"Test failed",
|
|
||||||
fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package ui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gdamore/tcell/v2"
|
|
||||||
"github.com/rivo/tview"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
colorBlue = "[#3e5db9]"
|
|
||||||
colorRed = "[#d54646]"
|
|
||||||
banner = "\r\n[::b]" +
|
|
||||||
colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" +
|
|
||||||
colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" +
|
|
||||||
colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" +
|
|
||||||
colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" +
|
|
||||||
colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
|
|
||||||
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
|
|
||||||
"[:]"
|
|
||||||
)
|
|
||||||
|
|
||||||
func applyStyles() {
|
|
||||||
tview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(12, 13, 22)
|
|
||||||
tview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(34, 19, 53)
|
|
||||||
tview.Styles.MoreContrastBackgroundColor = tcell.NewRGBColor(18, 18, 32)
|
|
||||||
tview.Styles.BorderColor = tcell.NewRGBColor(112, 102, 255)
|
|
||||||
tview.Styles.TitleColor = tcell.NewRGBColor(255, 121, 198)
|
|
||||||
tview.Styles.GraphicsColor = tcell.NewRGBColor(139, 233, 253)
|
|
||||||
tview.Styles.PrimaryTextColor = tcell.NewRGBColor(241, 250, 255)
|
|
||||||
tview.Styles.SecondaryTextColor = tcell.NewRGBColor(80, 250, 123)
|
|
||||||
tview.Styles.TertiaryTextColor = tcell.NewRGBColor(139, 233, 253)
|
|
||||||
tview.Styles.InverseTextColor = tcell.NewRGBColor(12, 13, 22)
|
|
||||||
tview.Styles.ContrastSecondaryTextColor = tcell.NewRGBColor(189, 147, 249)
|
|
||||||
}
|
|
||||||
|
|
||||||
func bannerView() *tview.TextView {
|
|
||||||
text := tview.NewTextView()
|
|
||||||
text.SetDynamicColors(true)
|
|
||||||
text.SetTextAlign(tview.AlignCenter)
|
|
||||||
text.SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
|
|
||||||
text.SetText(banner)
|
|
||||||
text.SetBorder(false)
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
const footerText = "Esc: Back/Exit | Enter: Enter | ←↓↑→ : Move | Space: Select | Tab/Shift+Tab: Switch"
|
|
||||||
|
|
||||||
func footerView() *tview.TextView {
|
|
||||||
text := tview.NewTextView()
|
|
||||||
text.SetTextAlign(tview.AlignCenter)
|
|
||||||
text.SetText(footerText)
|
|
||||||
text.SetBackgroundColor(tview.Styles.MoreContrastBackgroundColor)
|
|
||||||
text.SetTextColor(tview.Styles.PrimaryTextColor)
|
|
||||||
text.SetBorder(false)
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/ui"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
if err := ui.Run(); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// 仅在 /etc/resolv.conf 不存在时才覆盖(即 Android 环境)
|
||||||
|
if _, err := os.Stat("/etc/resolv.conf"); err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从环境变量获取 DNS server 列表,多个用 ; 隔开
|
||||||
|
// 例如: PICOCLAW_DNS_SERVER="8.8.8.8:53;1.1.1.1:53;223.5.5.5:53"
|
||||||
|
dnsEnv := os.Getenv("PICOCLAW_DNS_SERVER")
|
||||||
|
if dnsEnv == "" {
|
||||||
|
dnsEnv = "8.8.8.8:53;1.1.1.1:53"
|
||||||
|
}
|
||||||
|
|
||||||
|
var dnsServers []string
|
||||||
|
for _, s := range strings.Split(dnsEnv, ";") {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s != "" {
|
||||||
|
// 如果没有带端口号,自动补上 :53
|
||||||
|
if _, _, err := net.SplitHostPort(s); err != nil {
|
||||||
|
s = s + ":53"
|
||||||
|
}
|
||||||
|
dnsServers = append(dnsServers, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 轮询索引,在多个 DNS 服务器之间轮转
|
||||||
|
var idx uint64
|
||||||
|
|
||||||
|
customResolver := &net.Resolver{
|
||||||
|
PreferGo: true,
|
||||||
|
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
d := net.Dialer{Timeout: 5 * time.Second}
|
||||||
|
// Round-robin: 依次尝试不同的 DNS 服务器
|
||||||
|
server := dnsServers[atomic.AddUint64(&idx, 1)%uint64(len(dnsServers))]
|
||||||
|
return d.DialContext(ctx, "udp", server)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 覆盖全局 DefaultResolver
|
||||||
|
net.DefaultResolver = customResolver
|
||||||
|
|
||||||
|
// 覆盖 http.DefaultTransport 使用自定义 DNS 解析的 DialContext
|
||||||
|
dialer := &net.Dialer{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
Resolver: customResolver,
|
||||||
|
}
|
||||||
|
|
||||||
|
if tr, ok := http.DefaultTransport.(*http.Transport); ok {
|
||||||
|
tr.DialContext = dialer.DialContext
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,16 +23,18 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
|
|||||||
sessionKey = "cli:default"
|
sessionKey = "cli:default"
|
||||||
}
|
}
|
||||||
|
|
||||||
if debug {
|
|
||||||
logger.SetLevel(logger.DEBUG)
|
|
||||||
fmt.Println("🔍 Debug mode enabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := internal.LoadConfig()
|
cfg, err := internal.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error loading config: %w", err)
|
return fmt.Errorf("error loading config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.ConfigureFromEnv()
|
||||||
|
|
||||||
|
if debug {
|
||||||
|
logger.SetLevel(logger.DEBUG)
|
||||||
|
fmt.Println("🔍 Debug mode enabled")
|
||||||
|
}
|
||||||
|
|
||||||
if model != "" {
|
if model != "" {
|
||||||
cfg.Agents.Defaults.ModelName = model
|
cfg.Agents.Defaults.ModelName = model
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ func NewAuthCommand() *cobra.Command {
|
|||||||
newLogoutCommand(),
|
newLogoutCommand(),
|
||||||
newStatusCommand(),
|
newStatusCommand(),
|
||||||
newModelsCommand(),
|
newModelsCommand(),
|
||||||
|
newWeixinCommand(),
|
||||||
|
newWeComCommand(),
|
||||||
)
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ func TestNewAuthCommand(t *testing.T) {
|
|||||||
"logout",
|
"logout",
|
||||||
"status",
|
"status",
|
||||||
"models",
|
"models",
|
||||||
|
"weixin",
|
||||||
|
"wecom",
|
||||||
}
|
}
|
||||||
|
|
||||||
subcommands := cmd.Commands()
|
subcommands := cmd.Commands()
|
||||||
|
|||||||
@@ -17,24 +17,24 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
|
supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity, antigravity"
|
||||||
defaultAnthropicModel = "claude-sonnet-4.6"
|
defaultAnthropicModel = "claude-sonnet-4.6"
|
||||||
)
|
)
|
||||||
|
|
||||||
func authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error {
|
func authLoginCmd(provider string, useDeviceCode bool, useOauth bool, noBrowser bool) error {
|
||||||
switch provider {
|
switch provider {
|
||||||
case "openai":
|
case "openai":
|
||||||
return authLoginOpenAI(useDeviceCode)
|
return authLoginOpenAI(useDeviceCode, noBrowser)
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
return authLoginAnthropic(useOauth)
|
return authLoginAnthropic(useOauth)
|
||||||
case "google-antigravity", "antigravity":
|
case "google-antigravity", "antigravity":
|
||||||
return authLoginGoogleAntigravity()
|
return authLoginGoogleAntigravity(noBrowser)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg)
|
return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func authLoginOpenAI(useDeviceCode bool) error {
|
func authLoginOpenAI(useDeviceCode bool, noBrowser bool) error {
|
||||||
cfg := auth.OpenAIOAuthConfig()
|
cfg := auth.OpenAIOAuthConfig()
|
||||||
|
|
||||||
var cred *auth.AuthCredential
|
var cred *auth.AuthCredential
|
||||||
@@ -43,7 +43,7 @@ func authLoginOpenAI(useDeviceCode bool) error {
|
|||||||
if useDeviceCode {
|
if useDeviceCode {
|
||||||
cred, err = auth.LoginDeviceCode(cfg)
|
cred, err = auth.LoginDeviceCode(cfg)
|
||||||
} else {
|
} else {
|
||||||
cred, err = auth.LoginBrowser(cfg)
|
cred, err = auth.LoginBrowserWithOptions(cfg, auth.LoginBrowserOptions{NoBrowser: noBrowser})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -56,13 +56,10 @@ func authLoginOpenAI(useDeviceCode bool) error {
|
|||||||
|
|
||||||
appCfg, err := internal.LoadConfig()
|
appCfg, err := internal.LoadConfig()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Update Providers (legacy format)
|
|
||||||
appCfg.Providers.OpenAI.AuthMethod = "oauth"
|
|
||||||
|
|
||||||
// Update or add openai in ModelList
|
// Update or add openai in ModelList
|
||||||
foundOpenAI := false
|
foundOpenAI := false
|
||||||
for i := range appCfg.ModelList {
|
for i := range appCfg.ModelList {
|
||||||
if isOpenAIModel(appCfg.ModelList[i].Model) {
|
if isOpenAIModel(appCfg.ModelList[i]) {
|
||||||
appCfg.ModelList[i].AuthMethod = "oauth"
|
appCfg.ModelList[i].AuthMethod = "oauth"
|
||||||
foundOpenAI = true
|
foundOpenAI = true
|
||||||
break
|
break
|
||||||
@@ -71,7 +68,7 @@ func authLoginOpenAI(useDeviceCode bool) error {
|
|||||||
|
|
||||||
// If no openai in ModelList, add it
|
// If no openai in ModelList, add it
|
||||||
if !foundOpenAI {
|
if !foundOpenAI {
|
||||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||||
ModelName: "gpt-5.4",
|
ModelName: "gpt-5.4",
|
||||||
Model: "openai/gpt-5.4",
|
Model: "openai/gpt-5.4",
|
||||||
AuthMethod: "oauth",
|
AuthMethod: "oauth",
|
||||||
@@ -95,10 +92,10 @@ func authLoginOpenAI(useDeviceCode bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func authLoginGoogleAntigravity() error {
|
func authLoginGoogleAntigravity(noBrowser bool) error {
|
||||||
cfg := auth.GoogleAntigravityOAuthConfig()
|
cfg := auth.GoogleAntigravityOAuthConfig()
|
||||||
|
|
||||||
cred, err := auth.LoginBrowser(cfg)
|
cred, err := auth.LoginBrowserWithOptions(cfg, auth.LoginBrowserOptions{NoBrowser: noBrowser})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("login failed: %w", err)
|
return fmt.Errorf("login failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -130,13 +127,10 @@ func authLoginGoogleAntigravity() error {
|
|||||||
|
|
||||||
appCfg, err := internal.LoadConfig()
|
appCfg, err := internal.LoadConfig()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Update Providers (legacy format, for backward compatibility)
|
|
||||||
appCfg.Providers.Antigravity.AuthMethod = "oauth"
|
|
||||||
|
|
||||||
// Update or add antigravity in ModelList
|
// Update or add antigravity in ModelList
|
||||||
foundAntigravity := false
|
foundAntigravity := false
|
||||||
for i := range appCfg.ModelList {
|
for i := range appCfg.ModelList {
|
||||||
if isAntigravityModel(appCfg.ModelList[i].Model) {
|
if isAntigravityModel(appCfg.ModelList[i]) {
|
||||||
appCfg.ModelList[i].AuthMethod = "oauth"
|
appCfg.ModelList[i].AuthMethod = "oauth"
|
||||||
foundAntigravity = true
|
foundAntigravity = true
|
||||||
break
|
break
|
||||||
@@ -145,7 +139,7 @@ func authLoginGoogleAntigravity() error {
|
|||||||
|
|
||||||
// If no antigravity in ModelList, add it
|
// If no antigravity in ModelList, add it
|
||||||
if !foundAntigravity {
|
if !foundAntigravity {
|
||||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||||
ModelName: "gemini-flash",
|
ModelName: "gemini-flash",
|
||||||
Model: "antigravity/gemini-3-flash",
|
Model: "antigravity/gemini-3-flash",
|
||||||
AuthMethod: "oauth",
|
AuthMethod: "oauth",
|
||||||
@@ -210,18 +204,16 @@ func authLoginAnthropicSetupToken() error {
|
|||||||
|
|
||||||
appCfg, err := internal.LoadConfig()
|
appCfg, err := internal.LoadConfig()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
appCfg.Providers.Anthropic.AuthMethod = "oauth"
|
|
||||||
|
|
||||||
found := false
|
found := false
|
||||||
for i := range appCfg.ModelList {
|
for i := range appCfg.ModelList {
|
||||||
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
if isAnthropicModel(appCfg.ModelList[i]) {
|
||||||
appCfg.ModelList[i].AuthMethod = "oauth"
|
appCfg.ModelList[i].AuthMethod = "oauth"
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||||
ModelName: defaultAnthropicModel,
|
ModelName: defaultAnthropicModel,
|
||||||
Model: "anthropic/" + defaultAnthropicModel,
|
Model: "anthropic/" + defaultAnthropicModel,
|
||||||
AuthMethod: "oauth",
|
AuthMethod: "oauth",
|
||||||
@@ -287,18 +279,17 @@ func authLoginPasteToken(provider string) error {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
switch provider {
|
switch provider {
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
appCfg.Providers.Anthropic.AuthMethod = "token"
|
|
||||||
// Update ModelList
|
// Update ModelList
|
||||||
found := false
|
found := false
|
||||||
for i := range appCfg.ModelList {
|
for i := range appCfg.ModelList {
|
||||||
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
if isAnthropicModel(appCfg.ModelList[i]) {
|
||||||
appCfg.ModelList[i].AuthMethod = "token"
|
appCfg.ModelList[i].AuthMethod = "token"
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||||
ModelName: defaultAnthropicModel,
|
ModelName: defaultAnthropicModel,
|
||||||
Model: "anthropic/" + defaultAnthropicModel,
|
Model: "anthropic/" + defaultAnthropicModel,
|
||||||
AuthMethod: "token",
|
AuthMethod: "token",
|
||||||
@@ -306,18 +297,17 @@ func authLoginPasteToken(provider string) error {
|
|||||||
appCfg.Agents.Defaults.ModelName = defaultAnthropicModel
|
appCfg.Agents.Defaults.ModelName = defaultAnthropicModel
|
||||||
}
|
}
|
||||||
case "openai":
|
case "openai":
|
||||||
appCfg.Providers.OpenAI.AuthMethod = "token"
|
|
||||||
// Update ModelList
|
// Update ModelList
|
||||||
found := false
|
found := false
|
||||||
for i := range appCfg.ModelList {
|
for i := range appCfg.ModelList {
|
||||||
if isOpenAIModel(appCfg.ModelList[i].Model) {
|
if isOpenAIModel(appCfg.ModelList[i]) {
|
||||||
appCfg.ModelList[i].AuthMethod = "token"
|
appCfg.ModelList[i].AuthMethod = "token"
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||||
ModelName: "gpt-5.4",
|
ModelName: "gpt-5.4",
|
||||||
Model: "openai/gpt-5.4",
|
Model: "openai/gpt-5.4",
|
||||||
AuthMethod: "token",
|
AuthMethod: "token",
|
||||||
@@ -352,28 +342,19 @@ func authLogoutCmd(provider string) error {
|
|||||||
for i := range appCfg.ModelList {
|
for i := range appCfg.ModelList {
|
||||||
switch provider {
|
switch provider {
|
||||||
case "openai":
|
case "openai":
|
||||||
if isOpenAIModel(appCfg.ModelList[i].Model) {
|
if isOpenAIModel(appCfg.ModelList[i]) {
|
||||||
appCfg.ModelList[i].AuthMethod = ""
|
appCfg.ModelList[i].AuthMethod = ""
|
||||||
}
|
}
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
if isAnthropicModel(appCfg.ModelList[i]) {
|
||||||
appCfg.ModelList[i].AuthMethod = ""
|
appCfg.ModelList[i].AuthMethod = ""
|
||||||
}
|
}
|
||||||
case "google-antigravity", "antigravity":
|
case "google-antigravity", "antigravity":
|
||||||
if isAntigravityModel(appCfg.ModelList[i].Model) {
|
if isAntigravityModel(appCfg.ModelList[i]) {
|
||||||
appCfg.ModelList[i].AuthMethod = ""
|
appCfg.ModelList[i].AuthMethod = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Clear AuthMethod in Providers (legacy)
|
|
||||||
switch provider {
|
|
||||||
case "openai":
|
|
||||||
appCfg.Providers.OpenAI.AuthMethod = ""
|
|
||||||
case "anthropic":
|
|
||||||
appCfg.Providers.Anthropic.AuthMethod = ""
|
|
||||||
case "google-antigravity", "antigravity":
|
|
||||||
appCfg.Providers.Antigravity.AuthMethod = ""
|
|
||||||
}
|
|
||||||
config.SaveConfig(internal.GetConfigPath(), appCfg)
|
config.SaveConfig(internal.GetConfigPath(), appCfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,10 +373,6 @@ func authLogoutCmd(provider string) error {
|
|||||||
for i := range appCfg.ModelList {
|
for i := range appCfg.ModelList {
|
||||||
appCfg.ModelList[i].AuthMethod = ""
|
appCfg.ModelList[i].AuthMethod = ""
|
||||||
}
|
}
|
||||||
// Clear all AuthMethods in Providers (legacy)
|
|
||||||
appCfg.Providers.OpenAI.AuthMethod = ""
|
|
||||||
appCfg.Providers.Anthropic.AuthMethod = ""
|
|
||||||
appCfg.Providers.Antigravity.AuthMethod = ""
|
|
||||||
config.SaveConfig(internal.GetConfigPath(), appCfg)
|
config.SaveConfig(internal.GetConfigPath(), appCfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,22 +484,20 @@ func authModelsCmd() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isAntigravityModel checks if a model string belongs to antigravity provider
|
// isAntigravityModel checks if a model config belongs to an Antigravity provider.
|
||||||
func isAntigravityModel(model string) bool {
|
func isAntigravityModel(modelCfg *config.ModelConfig) bool {
|
||||||
return model == "antigravity" ||
|
protocol, _ := providers.ExtractProtocol(modelCfg)
|
||||||
model == "google-antigravity" ||
|
return protocol == "antigravity" || protocol == "google-antigravity"
|
||||||
strings.HasPrefix(model, "antigravity/") ||
|
|
||||||
strings.HasPrefix(model, "google-antigravity/")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// isOpenAIModel checks if a model string belongs to openai provider
|
// isOpenAIModel checks if a model config belongs to the OpenAI provider.
|
||||||
func isOpenAIModel(model string) bool {
|
func isOpenAIModel(modelCfg *config.ModelConfig) bool {
|
||||||
return model == "openai" ||
|
protocol, _ := providers.ExtractProtocol(modelCfg)
|
||||||
strings.HasPrefix(model, "openai/")
|
return protocol == "openai"
|
||||||
}
|
}
|
||||||
|
|
||||||
// isAnthropicModel checks if a model string belongs to anthropic provider
|
// isAnthropicModel checks if a model config belongs to the Anthropic provider.
|
||||||
func isAnthropicModel(model string) bool {
|
func isAnthropicModel(modelCfg *config.ModelConfig) bool {
|
||||||
return model == "anthropic" ||
|
protocol, _ := providers.ExtractProtocol(modelCfg)
|
||||||
strings.HasPrefix(model, "anthropic/")
|
return protocol == "anthropic"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ func newLoginCommand() *cobra.Command {
|
|||||||
provider string
|
provider string
|
||||||
useDeviceCode bool
|
useDeviceCode bool
|
||||||
useOauth bool
|
useOauth bool
|
||||||
|
noBrowser bool
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
@@ -14,12 +15,15 @@ func newLoginCommand() *cobra.Command {
|
|||||||
Short: "Login via OAuth or paste token",
|
Short: "Login via OAuth or paste token",
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
return authLoginCmd(provider, useDeviceCode, useOauth)
|
return authLoginCmd(provider, useDeviceCode, useOauth, noBrowser)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)")
|
cmd.Flags().StringVarP(
|
||||||
|
&provider, "provider", "p", "", "Provider to login with (openai, anthropic, google-antigravity, antigravity)",
|
||||||
|
)
|
||||||
cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)")
|
cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)")
|
||||||
|
cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "Do not auto-open a browser during OAuth login")
|
||||||
cmd.Flags().BoolVar(
|
cmd.Flags().BoolVar(
|
||||||
&useOauth, "setup-token", false,
|
&useOauth, "setup-token", false,
|
||||||
"Use setup-token flow for Anthropic (from `claude setup-token`)",
|
"Use setup-token flow for Anthropic (from `claude setup-token`)",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ func TestNewLoginSubCommand(t *testing.T) {
|
|||||||
assert.True(t, cmd.HasFlags())
|
assert.True(t, cmd.HasFlags())
|
||||||
|
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("device-code"))
|
assert.NotNil(t, cmd.Flags().Lookup("device-code"))
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("no-browser"))
|
||||||
|
|
||||||
providerFlag := cmd.Flags().Lookup("provider")
|
providerFlag := cmd.Flags().Lookup("provider")
|
||||||
require.NotNil(t, providerFlag)
|
require.NotNil(t, providerFlag)
|
||||||
|
|||||||
@@ -1,12 +1,53 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
pkgauth "github.com/sipeed/picoclaw/pkg/auth"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func captureAuthStdout(t *testing.T, fn func()) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
oldStdout := os.Stdout
|
||||||
|
r, w, err := os.Pipe()
|
||||||
|
require.NoError(t, err)
|
||||||
|
os.Stdout = w
|
||||||
|
t.Cleanup(func() {
|
||||||
|
os.Stdout = oldStdout
|
||||||
|
})
|
||||||
|
|
||||||
|
fn()
|
||||||
|
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
os.Stdout = oldStdout
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_, err = io.Copy(&buf, r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, r.Close())
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setAuthStatusTestHome(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
t.Setenv(config.EnvHome, filepath.Join(tmpDir, ".picoclaw"))
|
||||||
|
return tmpDir
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewStatusSubcommand(t *testing.T) {
|
func TestNewStatusSubcommand(t *testing.T) {
|
||||||
cmd := newStatusCommand()
|
cmd := newStatusCommand()
|
||||||
|
|
||||||
@@ -16,3 +57,47 @@ func TestNewStatusSubcommand(t *testing.T) {
|
|||||||
|
|
||||||
assert.False(t, cmd.HasFlags())
|
assert.False(t, cmd.HasFlags())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthStatusCmdShowsCanonicalGoogleAntigravityAfterLegacyRefresh(t *testing.T) {
|
||||||
|
tmpDir := setAuthStatusTestHome(t)
|
||||||
|
|
||||||
|
legacyExpiry := time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC)
|
||||||
|
legacyStore := map[string]any{
|
||||||
|
"credentials": map[string]any{
|
||||||
|
"antigravity": map[string]any{
|
||||||
|
"access_token": "legacy-token",
|
||||||
|
"expires_at": legacyExpiry.Format(time.RFC3339),
|
||||||
|
"provider": "antigravity",
|
||||||
|
"auth_method": "oauth",
|
||||||
|
"project_id": "legacy-project",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(legacyStore)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
authPath := filepath.Join(tmpDir, ".picoclaw", "auth.json")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(authPath), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(authPath, data, 0o600))
|
||||||
|
|
||||||
|
refreshedExpiry := time.Date(2026, 4, 16, 12, 30, 0, 0, time.UTC)
|
||||||
|
err = pkgauth.SetCredential("google-antigravity", &pkgauth.AuthCredential{
|
||||||
|
AccessToken: "fresh-token",
|
||||||
|
ExpiresAt: refreshedExpiry,
|
||||||
|
Provider: "google-antigravity",
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
ProjectID: "fresh-project",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
output := captureAuthStdout(t, func() {
|
||||||
|
require.NoError(t, authStatusCmd())
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Contains(t, output, "\nAuthenticated Providers:")
|
||||||
|
assert.Contains(t, output, "\n google-antigravity:\n")
|
||||||
|
assert.NotContains(t, output, "\n antigravity:\n")
|
||||||
|
assert.Contains(t, output, " Project: fresh-project")
|
||||||
|
assert.Contains(t, output, " Expires: 2026-04-16 12:30")
|
||||||
|
assert.Equal(t, 1, strings.Count(output, ":\n Method: oauth"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mdp/qrterminal/v3"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
wecomQRSourceID = "picoclaw"
|
||||||
|
wecomQRGenerateEndpoint = "https://work.weixin.qq.com/ai/qc/generate"
|
||||||
|
wecomQRQueryEndpoint = "https://work.weixin.qq.com/ai/qc/query_result"
|
||||||
|
wecomQRPageEndpoint = "https://work.weixin.qq.com/ai/qc/gen"
|
||||||
|
wecomQRHTTPTimeout = 15 * time.Second
|
||||||
|
wecomQRPollInterval = 3 * time.Second
|
||||||
|
wecomQRPollTimeout = 5 * time.Minute
|
||||||
|
wecomDefaultWebSocketURL = "wss://openws.work.weixin.qq.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
type wecomQRScanner func(context.Context, wecomQRFlowOptions) (wecomQRBotInfo, error)
|
||||||
|
|
||||||
|
type wecomQRFlowOptions struct {
|
||||||
|
HTTPClient *http.Client
|
||||||
|
GenerateURL string
|
||||||
|
QueryURL string
|
||||||
|
QRCodePageURL string
|
||||||
|
SourceID string
|
||||||
|
PollInterval time.Duration
|
||||||
|
PollTimeout time.Duration
|
||||||
|
Writer io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
type wecomQRBotInfo struct {
|
||||||
|
BotID string
|
||||||
|
Secret string
|
||||||
|
}
|
||||||
|
|
||||||
|
type wecomQRSession struct {
|
||||||
|
SCode string
|
||||||
|
AuthURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type wecomQRGenerateResponse struct {
|
||||||
|
ErrCode int `json:"errcode,omitempty"`
|
||||||
|
ErrMsg string `json:"errmsg,omitempty"`
|
||||||
|
Data struct {
|
||||||
|
SCode string `json:"scode"`
|
||||||
|
AuthURL string `json:"auth_url"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wecomQRQueryResponse struct {
|
||||||
|
ErrCode int `json:"errcode,omitempty"`
|
||||||
|
ErrMsg string `json:"errmsg,omitempty"`
|
||||||
|
Data struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
BotInfo struct {
|
||||||
|
BotID string `json:"botid"`
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
} `json:"bot_info"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWeComCommand() *cobra.Command {
|
||||||
|
var timeout time.Duration
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "wecom",
|
||||||
|
Short: "Scan a WeCom QR code and configure channels.wecom",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
return authWeComCmd(timeout)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().DurationVar(&timeout, "timeout", wecomQRPollTimeout, "How long to wait for QR confirmation")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func authWeComCmd(timeout time.Duration) error {
|
||||||
|
return authWeComCmdWithScanner(context.Background(), os.Stdout, timeout, scanWeComQRCodeInteractive)
|
||||||
|
}
|
||||||
|
|
||||||
|
func authWeComCmdWithScanner(
|
||||||
|
ctx context.Context,
|
||||||
|
writer io.Writer,
|
||||||
|
timeout time.Duration,
|
||||||
|
scanner wecomQRScanner,
|
||||||
|
) error {
|
||||||
|
if scanner == nil {
|
||||||
|
return fmt.Errorf("wecom QR scanner is nil")
|
||||||
|
}
|
||||||
|
if writer == nil {
|
||||||
|
writer = os.Stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := internal.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := defaultWeComQRFlowOptions(timeout)
|
||||||
|
opts.Writer = writer
|
||||||
|
|
||||||
|
botInfo, err := scanner(ctx, opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
applyWeComAuthResult(cfg, botInfo)
|
||||||
|
|
||||||
|
if saveErr := config.SaveConfig(internal.GetConfigPath(), cfg); saveErr != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", saveErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(writer)
|
||||||
|
fmt.Fprintln(writer, "WeCom connected.")
|
||||||
|
fmt.Fprintf(writer, "Bot ID: %s\n", botInfo.BotID)
|
||||||
|
fmt.Fprintf(writer, "Config: %s\n", internal.GetConfigPath())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultWeComQRFlowOptions(timeout time.Duration) wecomQRFlowOptions {
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = wecomQRPollTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
return wecomQRFlowOptions{
|
||||||
|
HTTPClient: &http.Client{Timeout: wecomQRHTTPTimeout},
|
||||||
|
GenerateURL: wecomQRGenerateEndpoint,
|
||||||
|
QueryURL: wecomQRQueryEndpoint,
|
||||||
|
QRCodePageURL: wecomQRPageEndpoint,
|
||||||
|
SourceID: wecomQRSourceID,
|
||||||
|
PollInterval: wecomQRPollInterval,
|
||||||
|
PollTimeout: timeout,
|
||||||
|
Writer: os.Stdout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyWeComAuthResult(cfg *config.Config, botInfo wecomQRBotInfo) {
|
||||||
|
bc := cfg.Channels.GetByType(config.ChannelWeCom)
|
||||||
|
if bc == nil {
|
||||||
|
bc = &config.Channel{Type: config.ChannelWeCom}
|
||||||
|
cfg.Channels["wecom"] = bc
|
||||||
|
}
|
||||||
|
bc.Enabled = true
|
||||||
|
|
||||||
|
decoded, err := bc.GetDecoded()
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("wecom", "failed to decode WeCom settings", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wecomCfg, ok := decoded.(*config.WeComSettings)
|
||||||
|
if !ok {
|
||||||
|
logger.ErrorCF("wecom", "unexpected WeCom settings type", map[string]any{
|
||||||
|
"got": fmt.Sprintf("%T", decoded),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wecomCfg.BotID = botInfo.BotID
|
||||||
|
wecomCfg.Secret = *config.NewSecureString(botInfo.Secret)
|
||||||
|
if strings.TrimSpace(wecomCfg.WebSocketURL) == "" {
|
||||||
|
wecomCfg.WebSocketURL = wecomDefaultWebSocketURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanWeComQRCodeInteractive(ctx context.Context, opts wecomQRFlowOptions) (wecomQRBotInfo, error) {
|
||||||
|
opts = normalizeWeComQRFlowOptions(opts)
|
||||||
|
|
||||||
|
fmt.Fprintln(opts.Writer, "Requesting WeCom QR code...")
|
||||||
|
|
||||||
|
session, err := fetchWeComQRCode(ctx, opts)
|
||||||
|
if err != nil {
|
||||||
|
return wecomQRBotInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(opts.Writer)
|
||||||
|
fmt.Fprintln(opts.Writer, "=======================================================")
|
||||||
|
fmt.Fprintln(opts.Writer, "Please scan the following QR code with WeCom:")
|
||||||
|
fmt.Fprintln(opts.Writer, "=======================================================")
|
||||||
|
fmt.Fprintln(opts.Writer)
|
||||||
|
|
||||||
|
qrterminal.GenerateWithConfig(session.AuthURL, qrterminal.Config{
|
||||||
|
Level: qrterminal.L,
|
||||||
|
Writer: opts.Writer,
|
||||||
|
HalfBlocks: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
pageURL, err := buildWeComQRCodePageURL(opts.QRCodePageURL, opts.SourceID, session.SCode)
|
||||||
|
if err != nil {
|
||||||
|
return wecomQRBotInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(opts.Writer)
|
||||||
|
fmt.Fprintf(opts.Writer, "QR Code Link: %s\n", pageURL)
|
||||||
|
fmt.Fprintln(opts.Writer)
|
||||||
|
fmt.Fprintln(opts.Writer, "Waiting for scan...")
|
||||||
|
|
||||||
|
return pollWeComQRCodeResult(ctx, opts, session.SCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeWeComQRFlowOptions(opts wecomQRFlowOptions) wecomQRFlowOptions {
|
||||||
|
if opts.HTTPClient == nil {
|
||||||
|
opts.HTTPClient = &http.Client{Timeout: wecomQRHTTPTimeout}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(opts.GenerateURL) == "" {
|
||||||
|
opts.GenerateURL = wecomQRGenerateEndpoint
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(opts.QueryURL) == "" {
|
||||||
|
opts.QueryURL = wecomQRQueryEndpoint
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(opts.QRCodePageURL) == "" {
|
||||||
|
opts.QRCodePageURL = wecomQRPageEndpoint
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(opts.SourceID) == "" {
|
||||||
|
opts.SourceID = wecomQRSourceID
|
||||||
|
}
|
||||||
|
if opts.PollInterval <= 0 {
|
||||||
|
opts.PollInterval = wecomQRPollInterval
|
||||||
|
}
|
||||||
|
if opts.PollTimeout <= 0 {
|
||||||
|
opts.PollTimeout = wecomQRPollTimeout
|
||||||
|
}
|
||||||
|
if opts.Writer == nil {
|
||||||
|
opts.Writer = os.Stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchWeComQRCode(ctx context.Context, opts wecomQRFlowOptions) (wecomQRSession, error) {
|
||||||
|
generateURL, err := buildWeComQRGenerateURL(opts.GenerateURL, opts.SourceID, wecomPlatformCode())
|
||||||
|
if err != nil {
|
||||||
|
return wecomQRSession{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp wecomQRGenerateResponse
|
||||||
|
if err := doWeComJSONGet(ctx, opts.HTTPClient, generateURL, &resp); err != nil {
|
||||||
|
return wecomQRSession{}, fmt.Errorf("failed to get WeCom QR code: %w", err)
|
||||||
|
}
|
||||||
|
if resp.ErrCode != 0 {
|
||||||
|
return wecomQRSession{}, fmt.Errorf(
|
||||||
|
"failed to get WeCom QR code: errcode=%d errmsg=%s",
|
||||||
|
resp.ErrCode,
|
||||||
|
resp.ErrMsg,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if resp.Data.SCode == "" || resp.Data.AuthURL == "" {
|
||||||
|
return wecomQRSession{}, fmt.Errorf("failed to get WeCom QR code: response missing scode or auth_url")
|
||||||
|
}
|
||||||
|
|
||||||
|
return wecomQRSession{
|
||||||
|
SCode: resp.Data.SCode,
|
||||||
|
AuthURL: resp.Data.AuthURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pollWeComQRCodeResult(ctx context.Context, opts wecomQRFlowOptions, scode string) (wecomQRBotInfo, error) {
|
||||||
|
if strings.TrimSpace(scode) == "" {
|
||||||
|
return wecomQRBotInfo{}, fmt.Errorf("missing WeCom QR scode")
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutCtx, cancel := context.WithTimeout(ctx, opts.PollTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var scannedPrinted bool
|
||||||
|
|
||||||
|
for {
|
||||||
|
status, err := queryWeComQRCodeStatus(timeoutCtx, opts, scode)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) {
|
||||||
|
return wecomQRBotInfo{}, fmt.Errorf("WeCom QR scan timed out after %s", opts.PollTimeout)
|
||||||
|
}
|
||||||
|
return wecomQRBotInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(status.Data.Status) {
|
||||||
|
case "success":
|
||||||
|
if status.Data.BotInfo.BotID == "" || status.Data.BotInfo.Secret == "" {
|
||||||
|
return wecomQRBotInfo{}, fmt.Errorf("WeCom QR scan succeeded but bot credentials are missing")
|
||||||
|
}
|
||||||
|
return wecomQRBotInfo{
|
||||||
|
BotID: status.Data.BotInfo.BotID,
|
||||||
|
Secret: status.Data.BotInfo.Secret,
|
||||||
|
}, nil
|
||||||
|
case "expired":
|
||||||
|
return wecomQRBotInfo{}, fmt.Errorf("WeCom QR code expired, please retry")
|
||||||
|
case "scaned", "scanned":
|
||||||
|
if !scannedPrinted {
|
||||||
|
fmt.Fprintln(opts.Writer, "QR code scanned. Confirm the login in WeCom.")
|
||||||
|
scannedPrinted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-timeoutCtx.Done():
|
||||||
|
if errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) {
|
||||||
|
return wecomQRBotInfo{}, fmt.Errorf("WeCom QR scan timed out after %s", opts.PollTimeout)
|
||||||
|
}
|
||||||
|
return wecomQRBotInfo{}, timeoutCtx.Err()
|
||||||
|
case <-time.After(opts.PollInterval):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryWeComQRCodeStatus(ctx context.Context, opts wecomQRFlowOptions, scode string) (wecomQRQueryResponse, error) {
|
||||||
|
queryURL, err := buildWeComQRQueryURL(opts.QueryURL, scode)
|
||||||
|
if err != nil {
|
||||||
|
return wecomQRQueryResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp wecomQRQueryResponse
|
||||||
|
if err := doWeComJSONGet(ctx, opts.HTTPClient, queryURL, &resp); err != nil {
|
||||||
|
return wecomQRQueryResponse{}, fmt.Errorf("failed to query WeCom QR result: %w", err)
|
||||||
|
}
|
||||||
|
if resp.ErrCode != 0 {
|
||||||
|
return wecomQRQueryResponse{}, fmt.Errorf(
|
||||||
|
"failed to query WeCom QR result: errcode=%d errmsg=%s",
|
||||||
|
resp.ErrCode,
|
||||||
|
resp.ErrMsg,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWeComQRGenerateURL(baseURL, sourceID string, platformCode int) (string, error) {
|
||||||
|
u, err := url.Parse(baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid WeCom QR generate URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := u.Query()
|
||||||
|
query.Set("source", sourceID)
|
||||||
|
query.Set("sourceID", sourceID)
|
||||||
|
query.Set("plat", strconv.Itoa(platformCode))
|
||||||
|
u.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWeComQRQueryURL(baseURL, scode string) (string, error) {
|
||||||
|
u, err := url.Parse(baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid WeCom QR query URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := u.Query()
|
||||||
|
query.Set("scode", scode)
|
||||||
|
u.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWeComQRCodePageURL(baseURL, sourceID, scode string) (string, error) {
|
||||||
|
u, err := url.Parse(baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid WeCom QR page URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := u.Query()
|
||||||
|
query.Set("source", sourceID)
|
||||||
|
query.Set("sourceID", sourceID)
|
||||||
|
query.Set("scode", scode)
|
||||||
|
u.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func doWeComJSONGet(ctx context.Context, client *http.Client, targetURL string, out any) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||||
|
if readErr != nil {
|
||||||
|
return fmt.Errorf("unexpected status %s", resp.Status)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("unexpected status %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||||
|
return fmt.Errorf("decode JSON response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func wecomPlatformCode() int {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
return 1
|
||||||
|
case "windows":
|
||||||
|
return 2
|
||||||
|
case "linux":
|
||||||
|
return 3
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newIPv4TestServer(t *testing.T, handler http.Handler) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
server := httptest.NewUnstartedServer(handler)
|
||||||
|
listener, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
server.Listener = listener
|
||||||
|
server.Start()
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewWeComCommand(t *testing.T) {
|
||||||
|
cmd := newWeComCommand()
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
assert.Equal(t, "wecom", cmd.Use)
|
||||||
|
assert.Equal(t, "Scan a WeCom QR code and configure channels.wecom", cmd.Short)
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("timeout"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildWeComQRGenerateURL(t *testing.T) {
|
||||||
|
rawURL, err := buildWeComQRGenerateURL("https://example.com/ai/qc/generate", wecomQRSourceID, 3)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, wecomQRSourceID, parsed.Query().Get("source"))
|
||||||
|
assert.Equal(t, wecomQRSourceID, parsed.Query().Get("sourceID"))
|
||||||
|
assert.Equal(t, "3", parsed.Query().Get("plat"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildWeComQRCodePageURL(t *testing.T) {
|
||||||
|
rawURL, err := buildWeComQRCodePageURL("https://example.com/ai/qc/gen", wecomQRSourceID, "scode-1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, wecomQRSourceID, parsed.Query().Get("source"))
|
||||||
|
assert.Equal(t, wecomQRSourceID, parsed.Query().Get("sourceID"))
|
||||||
|
assert.Equal(t, "scode-1", parsed.Query().Get("scode"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchWeComQRCode(t *testing.T) {
|
||||||
|
server := newIPv4TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/generate", r.URL.Path)
|
||||||
|
assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("source"))
|
||||||
|
assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("sourceID"))
|
||||||
|
assert.Equal(t, strconv.Itoa(wecomPlatformCode()), r.URL.Query().Get("plat"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"scode":"scode-1","auth_url":"https://example.com/qr"}}`))
|
||||||
|
}))
|
||||||
|
|
||||||
|
opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{
|
||||||
|
HTTPClient: server.Client(),
|
||||||
|
GenerateURL: server.URL + "/generate",
|
||||||
|
Writer: bytes.NewBuffer(nil),
|
||||||
|
})
|
||||||
|
|
||||||
|
session, err := fetchWeComQRCode(context.Background(), opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "scode-1", session.SCode)
|
||||||
|
assert.Equal(t, "https://example.com/qr", session.AuthURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPollWeComQRCodeResult(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
|
||||||
|
server := newIPv4TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
call := calls.Add(1)
|
||||||
|
assert.Equal(t, "/query", r.URL.Path)
|
||||||
|
assert.Equal(t, "scode-1", r.URL.Query().Get("scode"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch call {
|
||||||
|
case 1:
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"status":"wait"}}`))
|
||||||
|
case 2:
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"status":"scaned"}}`))
|
||||||
|
default:
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"status":"success","bot_info":{"botid":"bot-1","secret":"secret-1"}}}`))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
var output bytes.Buffer
|
||||||
|
opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{
|
||||||
|
HTTPClient: server.Client(),
|
||||||
|
QueryURL: server.URL + "/query",
|
||||||
|
PollInterval: time.Millisecond,
|
||||||
|
PollTimeout: time.Second,
|
||||||
|
Writer: &output,
|
||||||
|
})
|
||||||
|
|
||||||
|
botInfo, err := pollWeComQRCodeResult(context.Background(), opts, "scode-1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "bot-1", botInfo.BotID)
|
||||||
|
assert.Equal(t, "secret-1", botInfo.Secret)
|
||||||
|
assert.Contains(t, output.String(), "QR code scanned. Confirm the login in WeCom.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyWeComAuthResult(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
require.NoError(t, config.InitChannelList(cfg.Channels))
|
||||||
|
wecom := cfg.Channels["wecom"]
|
||||||
|
t.Logf("wecom: %+v", wecom)
|
||||||
|
decoded, err := wecom.GetDecoded()
|
||||||
|
require.NoError(t, err)
|
||||||
|
weCfg := decoded.(*config.WeComSettings)
|
||||||
|
weCfg.WebSocketURL = ""
|
||||||
|
|
||||||
|
applyWeComAuthResult(cfg, wecomQRBotInfo{
|
||||||
|
BotID: "bot-1",
|
||||||
|
Secret: "secret-1",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.True(t, wecom.Enabled)
|
||||||
|
assert.Equal(t, "bot-1", weCfg.BotID)
|
||||||
|
assert.Equal(t, "secret-1", weCfg.Secret.String())
|
||||||
|
assert.Equal(t, wecomDefaultWebSocketURL, weCfg.WebSocketURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthWeComCmdWithScanner(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.json")
|
||||||
|
|
||||||
|
t.Setenv(config.EnvHome, tmpDir)
|
||||||
|
t.Setenv(config.EnvConfig, configPath)
|
||||||
|
|
||||||
|
var output bytes.Buffer
|
||||||
|
err := authWeComCmdWithScanner(
|
||||||
|
context.Background(),
|
||||||
|
&output,
|
||||||
|
time.Second,
|
||||||
|
func(_ context.Context, opts wecomQRFlowOptions) (wecomQRBotInfo, error) {
|
||||||
|
assert.Equal(t, wecomQRSourceID, opts.SourceID)
|
||||||
|
return wecomQRBotInfo{
|
||||||
|
BotID: "bot-1",
|
||||||
|
Secret: "secret-1",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(internal.GetConfigPath())
|
||||||
|
require.NoError(t, err)
|
||||||
|
wecom := cfg.Channels["wecom"]
|
||||||
|
decoded, err := wecom.GetDecoded()
|
||||||
|
require.NoError(t, err)
|
||||||
|
weCfg := decoded.(*config.WeComSettings)
|
||||||
|
assert.True(t, wecom.Enabled)
|
||||||
|
assert.Equal(t, "bot-1", weCfg.BotID)
|
||||||
|
assert.Equal(t, "secret-1", weCfg.Secret.String())
|
||||||
|
assert.Equal(t, wecomDefaultWebSocketURL, weCfg.WebSocketURL)
|
||||||
|
assert.Contains(t, output.String(), "WeCom connected.")
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels/weixin"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newWeixinCommand() *cobra.Command {
|
||||||
|
var baseURL string
|
||||||
|
var proxy string
|
||||||
|
var timeout int
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "weixin",
|
||||||
|
Short: "Connect a WeChat personal account via QR code",
|
||||||
|
Long: `Start the interactive Weixin (WeChat personal) QR code login flow.
|
||||||
|
|
||||||
|
A QR code is displayed in the terminal. Scan it with the WeChat mobile app
|
||||||
|
to authorize your account. On success, the bot token is saved to the picoclaw
|
||||||
|
config so you can start the gateway immediately.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
picoclaw auth weixin`,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
return runWeixinOnboard(baseURL, proxy, time.Duration(timeout)*time.Second)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(&baseURL, "base-url", "https://ilinkai.weixin.qq.com/", "iLink API base URL")
|
||||||
|
cmd.Flags().StringVar(&proxy, "proxy", "", "HTTP proxy URL (e.g. http://localhost:7890)")
|
||||||
|
cmd.Flags().IntVar(&timeout, "timeout", 300, "Login timeout in seconds")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWeixinOnboard(baseURL, proxy string, timeout time.Duration) error {
|
||||||
|
fmt.Println("Starting Weixin (WeChat personal) login...")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
botToken, userID, accountID, returnedBaseURL, err := weixin.PerformLoginInteractive(
|
||||||
|
context.Background(),
|
||||||
|
weixin.AuthFlowOpts{
|
||||||
|
BaseURL: baseURL,
|
||||||
|
Timeout: timeout,
|
||||||
|
Proxy: proxy,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("login failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("✅ Login successful!")
|
||||||
|
fmt.Printf(" Account ID : %s\n", accountID)
|
||||||
|
if userID != "" {
|
||||||
|
fmt.Printf(" User ID : %s\n", userID)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Prefer the server-returned base URL (may be region-specific)
|
||||||
|
effectiveBaseURL := returnedBaseURL
|
||||||
|
if effectiveBaseURL == "" {
|
||||||
|
effectiveBaseURL = baseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := saveWeixinConfig(botToken, effectiveBaseURL, proxy); err != nil {
|
||||||
|
fmt.Printf("⚠️ Could not auto-save to config: %v\n", err)
|
||||||
|
printManualWeixinConfig(botToken, effectiveBaseURL)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("✓ Config updated. Start the gateway with:")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(" picoclaw gateway")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("To restrict which WeChat users can send messages, add their user IDs")
|
||||||
|
fmt.Println("to channels.weixin.allow_from in your config.")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveWeixinConfig patches channels.weixin in the config and saves it.
|
||||||
|
func saveWeixinConfig(token, baseURL, proxy string) error {
|
||||||
|
cfgPath := internal.GetConfigPath()
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bc := cfg.Channels.GetByType(config.ChannelWeixin)
|
||||||
|
if bc == nil {
|
||||||
|
bc = &config.Channel{Type: config.ChannelWeixin}
|
||||||
|
cfg.Channels[config.ChannelWeixin] = bc
|
||||||
|
}
|
||||||
|
bc.Enabled = true
|
||||||
|
|
||||||
|
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
|
||||||
|
if weixinCfg, ok := decoded.(*config.WeixinSettings); ok {
|
||||||
|
weixinCfg.Token = *config.NewSecureString(token)
|
||||||
|
const defaultBase = "https://ilinkai.weixin.qq.com/"
|
||||||
|
if baseURL != "" && baseURL != defaultBase {
|
||||||
|
weixinCfg.BaseURL = baseURL
|
||||||
|
}
|
||||||
|
if proxy != "" {
|
||||||
|
weixinCfg.Proxy = proxy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config.SaveConfig(cfgPath, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printManualWeixinConfig(token, baseURL string) {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Add the following to the channels section of your picoclaw config:")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(` "weixin": {`)
|
||||||
|
fmt.Println(` "enabled": true,`)
|
||||||
|
fmt.Printf(" \"token\": %q,\n", token)
|
||||||
|
const defaultBase = "https://ilinkai.weixin.qq.com/"
|
||||||
|
if baseURL != "" && baseURL != defaultBase {
|
||||||
|
fmt.Printf(" \"base_url\": %q,\n", baseURL)
|
||||||
|
}
|
||||||
|
fmt.Println(` "allow_from": []`)
|
||||||
|
fmt.Println(` }`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// Package cliui renders human-oriented CLI output: bordered panels and columns
|
||||||
|
// on wide interactive terminals. Layout (boxes/columns) is independent of ANSI
|
||||||
|
// color: use --no-color or NO_COLOR to disable colors only; narrow or non-TTY
|
||||||
|
// stdout falls back to plain line-oriented output.
|
||||||
|
package cliui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/muesli/termenv"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Minimum terminal width (columns) for bordered / structured layout.
|
||||||
|
// Below this, plain line-oriented output is used so boxes do not wrap badly.
|
||||||
|
const minWidthFancy = 88
|
||||||
|
|
||||||
|
// Minimum width to lay out some views in two columns (e.g. status providers).
|
||||||
|
const minWidthColumns = 104
|
||||||
|
|
||||||
|
var initMu sync.Mutex
|
||||||
|
|
||||||
|
// Init configures lipgloss for this process. When disableAnsiColors is true
|
||||||
|
// (e.g. --no-color, NO_COLOR, or TERM=dumb), only color is turned off; Unicode
|
||||||
|
// borders still render when UseFancyLayout() is true.
|
||||||
|
func Init(disableAnsiColors bool) {
|
||||||
|
initMu.Lock()
|
||||||
|
defer initMu.Unlock()
|
||||||
|
if disableAnsiColors {
|
||||||
|
lipgloss.SetColorProfile(termenv.Ascii)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lipgloss.SetColorProfile(termenv.EnvColorProfile())
|
||||||
|
}
|
||||||
|
|
||||||
|
// StdoutWidth returns the terminal width or a sane default if unknown.
|
||||||
|
func StdoutWidth() int {
|
||||||
|
w, _, err := term.GetSize(int(os.Stdout.Fd()))
|
||||||
|
if err != nil || w < 20 {
|
||||||
|
return 80
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// UseFancyLayout is true when styled boxes/columns should be used.
|
||||||
|
func UseFancyLayout() bool {
|
||||||
|
if !term.IsTerminal(int(os.Stdout.Fd())) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return StdoutWidth() >= minWidthFancy
|
||||||
|
}
|
||||||
|
|
||||||
|
// UseColumnLayout is true when a second content column is viable.
|
||||||
|
func UseColumnLayout() bool {
|
||||||
|
return UseFancyLayout() && StdoutWidth() >= minWidthColumns
|
||||||
|
}
|
||||||
|
|
||||||
|
// InnerWidth is the target content width inside borders/margins.
|
||||||
|
func InnerWidth() int {
|
||||||
|
w := StdoutWidth()
|
||||||
|
// Rounded border + horizontal padding (lipgloss borders ~= 2 cols each side + padding).
|
||||||
|
const borderBudget = 8
|
||||||
|
if w > borderBudget+48 {
|
||||||
|
return w - borderBudget
|
||||||
|
}
|
||||||
|
return 48
|
||||||
|
}
|
||||||
|
|
||||||
|
// StderrWidth returns stderr terminal width or a sane default.
|
||||||
|
func StderrWidth() int {
|
||||||
|
w, _, err := term.GetSize(int(os.Stderr.Fd()))
|
||||||
|
if err != nil || w < 20 {
|
||||||
|
return 80
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// UseFancyStderr is true when stderr can show boxed errors without ugly wraps.
|
||||||
|
func UseFancyStderr() bool {
|
||||||
|
if !term.IsTerminal(int(os.Stderr.Fd())) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return StderrWidth() >= minWidthFancy
|
||||||
|
}
|
||||||
|
|
||||||
|
// InnerStderrWidth mirrors InnerWidth but for stderr.
|
||||||
|
func InnerStderrWidth() int {
|
||||||
|
w := StderrWidth()
|
||||||
|
const borderBudget = 8
|
||||||
|
if w > borderBudget+48 {
|
||||||
|
return w - borderBudget
|
||||||
|
}
|
||||||
|
return 48
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
accentBlue = lipgloss.Color("#3E5DB9")
|
||||||
|
accentRed = lipgloss.Color("#D54646")
|
||||||
|
colorMuted = lipgloss.Color("#6B6B6B")
|
||||||
|
colorOK = lipgloss.Color("#2E7D32")
|
||||||
|
)
|
||||||
|
|
||||||
|
func borderStyle() lipgloss.Style {
|
||||||
|
return lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(accentBlue).
|
||||||
|
Padding(0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleBarStyle() lipgloss.Style {
|
||||||
|
return lipgloss.NewStyle().
|
||||||
|
Foreground(accentRed).
|
||||||
|
Bold(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mutedStyle() lipgloss.Style {
|
||||||
|
return lipgloss.NewStyle().Foreground(colorMuted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bodyStyle() lipgloss.Style {
|
||||||
|
return lipgloss.NewStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
func kvKeyStyle() lipgloss.Style {
|
||||||
|
return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func kvValStyle() lipgloss.Style {
|
||||||
|
return lipgloss.NewStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
// helpIntroStyle is the top tagline (PicoClaw blue, matches ASCII banner left side).
|
||||||
|
func helpIntroStyle() lipgloss.Style {
|
||||||
|
return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// helpIdentStyle is the left column for commands and flags (blue identifiers).
|
||||||
|
func helpIdentStyle() lipgloss.Style {
|
||||||
|
return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// helpPlaceholderStyle highlights <placeholders> in usage lines (red accent).
|
||||||
|
func helpPlaceholderStyle() lipgloss.Style {
|
||||||
|
return lipgloss.NewStyle().Foreground(accentRed).Bold(true)
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package cliui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
flag "github.com/spf13/pflag"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Disable ANSI colors in tests so output is predictable plain text.
|
||||||
|
Init(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// showErrHint
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestShowErrHint(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
msg string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
// Cobra flag errors — should show hint
|
||||||
|
{"unknown flag: --foo", true},
|
||||||
|
{"unknown shorthand flag: 'f' in -f", true},
|
||||||
|
{"flag needs an argument: --output", true},
|
||||||
|
{"required flag(s) \"model\" not set", true},
|
||||||
|
// Generic invalid-argument errors — should show hint
|
||||||
|
{"invalid argument \"abc\" for --count", true},
|
||||||
|
// required flag errors — should show hint
|
||||||
|
{"required flag(s) \"model\" not set", true},
|
||||||
|
// usage: in message — should show hint
|
||||||
|
{"bad input\nusage: picoclaw ...", true},
|
||||||
|
// Should NOT false-positive on broad words
|
||||||
|
{"connection flagged by remote", false},
|
||||||
|
{"feature flag not set", false},
|
||||||
|
{"invalid API key provided", false},
|
||||||
|
{"authentication required", false},
|
||||||
|
// Unrelated messages — no hint
|
||||||
|
{"something went wrong", false},
|
||||||
|
{"network timeout", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := showErrHint(tc.msg)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("showErrHint(%q) = %v, want %v", tc.msg, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// styleUsageTokens
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestStyleUsageTokensContainsTokens(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
input string
|
||||||
|
contains []string // substrings that must appear in plain output
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"picoclaw agent <message>",
|
||||||
|
[]string{"picoclaw agent", "<message>"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"picoclaw [command] [flags]",
|
||||||
|
[]string{"picoclaw", "[command]", "[flags]"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"picoclaw",
|
||||||
|
[]string{"picoclaw"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cmd <arg1> [--flag]",
|
||||||
|
[]string{"cmd", "<arg1>", "[--flag]"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
out := styleUsageTokens(tc.input)
|
||||||
|
for _, sub := range tc.contains {
|
||||||
|
if !containsStripped(out, sub) {
|
||||||
|
t.Errorf("styleUsageTokens(%q): output %q does not contain %q", tc.input, out, sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsStripped checks whether plain contains sub after stripping ANSI escapes.
|
||||||
|
// Since Init(true) sets Ascii profile, lipgloss emits no escape codes in tests,
|
||||||
|
// so this is just a plain substring check.
|
||||||
|
func containsStripped(plain, sub string) bool {
|
||||||
|
return len(plain) >= len(sub) && findSubstring(plain, sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findSubstring(s, sub string) bool {
|
||||||
|
for i := 0; i <= len(s)-len(sub); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// collectFlagRows
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestCollectFlagRows_Empty(t *testing.T) {
|
||||||
|
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||||
|
rows := collectFlagRows(fs)
|
||||||
|
if len(rows) != 0 {
|
||||||
|
t.Fatalf("expected 0 rows for empty FlagSet, got %d", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectFlagRows_BasicFlags(t *testing.T) {
|
||||||
|
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||||
|
fs.String("output", "", "output file path")
|
||||||
|
fs.Bool("verbose", false, "enable verbose mode")
|
||||||
|
fs.Int("count", 1, "number of items")
|
||||||
|
|
||||||
|
rows := collectFlagRows(fs)
|
||||||
|
|
||||||
|
if len(rows) != 3 {
|
||||||
|
t.Fatalf("expected 3 rows, got %d", len(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rows must be sorted alphabetically by flag name.
|
||||||
|
names := make([]string, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
names = append(names, r[0])
|
||||||
|
}
|
||||||
|
if names[0] > names[1] || names[1] > names[2] {
|
||||||
|
t.Errorf("rows not sorted: %v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectFlagRows_Shorthand(t *testing.T) {
|
||||||
|
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||||
|
fs.StringP("model", "m", "", "model name")
|
||||||
|
|
||||||
|
rows := collectFlagRows(fs)
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("expected 1 row, got %d", len(rows))
|
||||||
|
}
|
||||||
|
left := rows[0][0]
|
||||||
|
if !findSubstring(left, "-m") || !findSubstring(left, "--model") {
|
||||||
|
t.Errorf("expected shorthand and long form in %q", left)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectFlagRows_HiddenFlagsExcluded(t *testing.T) {
|
||||||
|
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||||
|
fs.String("visible", "", "this shows up")
|
||||||
|
hidden := fs.String("hidden", "", "this should not show up")
|
||||||
|
_ = hidden
|
||||||
|
_ = fs.MarkHidden("hidden")
|
||||||
|
|
||||||
|
rows := collectFlagRows(fs)
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("expected 1 row (hidden excluded), got %d", len(rows))
|
||||||
|
}
|
||||||
|
if !findSubstring(rows[0][0], "visible") {
|
||||||
|
t.Errorf("expected visible flag in rows, got %q", rows[0][0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectFlagRows_UsageInRightColumn(t *testing.T) {
|
||||||
|
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||||
|
fs.String("format", "json", "output format: json or text")
|
||||||
|
|
||||||
|
rows := collectFlagRows(fs)
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("expected 1 row, got %d", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0][1] != "output format: json or text" {
|
||||||
|
t.Errorf("expected usage in right column, got %q", rows[0][1])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
package cliui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
flag "github.com/spf13/pflag"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RenderCommandHelp builds Ruff-style sectioned, two-column help when
|
||||||
|
// UseFancyLayout(); otherwise plain Cobra-style text.
|
||||||
|
func RenderCommandHelp(c *cobra.Command) string {
|
||||||
|
if !UseFancyLayout() {
|
||||||
|
return plainCommandHelp(c)
|
||||||
|
}
|
||||||
|
syncFlags(c)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
head, sub := helpIntro(c)
|
||||||
|
if head != "" {
|
||||||
|
b.WriteString(helpIntroStyle().Render(head))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
if sub != "" {
|
||||||
|
b.WriteString(mutedStyle().Render(sub))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
if head != "" || sub != "" {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
inner := InnerWidth()
|
||||||
|
contentW := inner - 6
|
||||||
|
if contentW < 36 {
|
||||||
|
contentW = 36
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
usageBody := bodyStyle().MaxWidth(contentW).Render(styleUsageTokens(c.UseLine()))
|
||||||
|
b.WriteString(sectionPanel("Usage", usageBody, inner))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// Examples
|
||||||
|
if ex := strings.TrimSpace(c.Example); ex != "" {
|
||||||
|
exBody := bodyStyle().Width(contentW).Render(ex)
|
||||||
|
b.WriteString(sectionPanel("Examples", exBody, inner))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subcommands
|
||||||
|
subs := visibleSubcommands(c)
|
||||||
|
if len(subs) > 0 {
|
||||||
|
rows := make([][2]string, 0, len(subs))
|
||||||
|
for _, sub := range subs {
|
||||||
|
left := sub.Name()
|
||||||
|
if a := sub.Aliases; len(a) > 0 {
|
||||||
|
left += " (" + strings.Join(a, ", ") + ")"
|
||||||
|
}
|
||||||
|
rows = append(rows, [2]string{left, sub.Short})
|
||||||
|
}
|
||||||
|
b.WriteString(sectionPanel("Commands", renderTwoColPairs(rows, contentW), inner))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local options
|
||||||
|
local := c.LocalFlags()
|
||||||
|
opts := collectFlagRows(local)
|
||||||
|
if len(opts) > 0 {
|
||||||
|
title := "Options"
|
||||||
|
if !c.HasParent() {
|
||||||
|
title = "Flags"
|
||||||
|
}
|
||||||
|
b.WriteString(sectionPanel(title, renderTwoColPairs(opts, contentW), inner))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global (inherited) options
|
||||||
|
if c.HasAvailableInheritedFlags() {
|
||||||
|
inh := collectFlagRows(c.InheritedFlags())
|
||||||
|
if len(inh) > 0 {
|
||||||
|
b.WriteString(sectionPanel("Global options", renderTwoColPairs(inh, contentW), inner))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderCommandQuickRef prints the same Usage / Flags / Global sections as help,
|
||||||
|
// for embedding after errors (stderr). outerW is typically InnerStderrWidth().
|
||||||
|
func RenderCommandQuickRef(c *cobra.Command, outerW int) string {
|
||||||
|
if c == nil || outerW < 40 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
syncFlags(c)
|
||||||
|
contentW := outerW - 6
|
||||||
|
if contentW < 36 {
|
||||||
|
contentW = 36
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
usageBody := bodyStyle().MaxWidth(contentW).Render(styleUsageTokens(c.UseLine()))
|
||||||
|
b.WriteString(sectionPanel("Usage", usageBody, outerW))
|
||||||
|
b.WriteString("\n")
|
||||||
|
if len(c.Aliases) > 0 {
|
||||||
|
al := "Aliases: " + strings.Join(c.Aliases, ", ")
|
||||||
|
alBody := mutedStyle().MaxWidth(contentW).Render(al)
|
||||||
|
b.WriteString(sectionPanel("Aliases", alBody, outerW))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
opts := collectFlagRows(c.LocalFlags())
|
||||||
|
if len(opts) > 0 {
|
||||||
|
title := "Options"
|
||||||
|
if !c.HasParent() {
|
||||||
|
title = "Flags"
|
||||||
|
}
|
||||||
|
b.WriteString(sectionPanel(title, renderTwoColPairs(opts, contentW), outerW))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
if c.HasAvailableInheritedFlags() {
|
||||||
|
inh := collectFlagRows(c.InheritedFlags())
|
||||||
|
if len(inh) > 0 {
|
||||||
|
b.WriteString(sectionPanel("Global options", renderTwoColPairs(inh, contentW), outerW))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncFlags(c *cobra.Command) {
|
||||||
|
_ = c.LocalFlags()
|
||||||
|
if c.HasAvailableInheritedFlags() {
|
||||||
|
_ = c.InheritedFlags()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func plainCommandHelp(c *cobra.Command) string {
|
||||||
|
desc := c.Long
|
||||||
|
if desc == "" {
|
||||||
|
desc = c.Short
|
||||||
|
}
|
||||||
|
desc = strings.TrimRight(desc, " \t\n\r")
|
||||||
|
var b strings.Builder
|
||||||
|
if desc != "" {
|
||||||
|
fmt.Fprintln(&b, desc)
|
||||||
|
fmt.Fprintln(&b)
|
||||||
|
}
|
||||||
|
if c.Runnable() || c.HasSubCommands() {
|
||||||
|
b.WriteString(c.UsageString())
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func helpIntro(c *cobra.Command) (head, sub string) {
|
||||||
|
head = strings.TrimSpace(c.Short)
|
||||||
|
long := strings.TrimSpace(c.Long)
|
||||||
|
if long == "" || long == head {
|
||||||
|
return head, ""
|
||||||
|
}
|
||||||
|
lines := strings.Split(long, "\n")
|
||||||
|
var rest []string
|
||||||
|
for i, ln := range lines {
|
||||||
|
ln = strings.TrimSpace(ln)
|
||||||
|
if ln == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i == 0 && ln == head {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rest = append(rest, ln)
|
||||||
|
}
|
||||||
|
sub = strings.Join(rest, "\n")
|
||||||
|
return head, sub
|
||||||
|
}
|
||||||
|
|
||||||
|
func visibleSubcommands(c *cobra.Command) []*cobra.Command {
|
||||||
|
var out []*cobra.Command
|
||||||
|
for _, sub := range c.Commands() {
|
||||||
|
if sub.Hidden {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, sub)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sectionPanel(title, body string, width int) string {
|
||||||
|
head := titleBarStyle().Render(title) + "\n\n"
|
||||||
|
return borderStyle().Width(width).Render(head + body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// styleUsageTokens highlights PicoClaw-blue command tokens and red <placeholders>/[groups].
|
||||||
|
func styleUsageTokens(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for len(s) > 0 {
|
||||||
|
ia := strings.Index(s, "<")
|
||||||
|
ib := strings.Index(s, "[")
|
||||||
|
next, kind := -1, 0 // 1 = angle, 2 = bracket
|
||||||
|
switch {
|
||||||
|
case ia >= 0 && (ib < 0 || ia < ib):
|
||||||
|
next, kind = ia, 1
|
||||||
|
case ib >= 0:
|
||||||
|
next, kind = ib, 2
|
||||||
|
}
|
||||||
|
if next < 0 {
|
||||||
|
b.WriteString(helpIdentStyle().Render(s))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if next > 0 {
|
||||||
|
b.WriteString(helpIdentStyle().Render(s[:next]))
|
||||||
|
}
|
||||||
|
s = s[next:]
|
||||||
|
if kind == 1 {
|
||||||
|
j := strings.Index(s, ">")
|
||||||
|
if j < 0 {
|
||||||
|
b.WriteString(helpIdentStyle().Render(s))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
b.WriteString(helpPlaceholderStyle().Render(s[:j+1]))
|
||||||
|
s = s[j+1:]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
j := strings.Index(s, "]")
|
||||||
|
if j < 0 {
|
||||||
|
b.WriteString(helpIdentStyle().Render(s))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
b.WriteString(helpPlaceholderStyle().Render(s[:j+1]))
|
||||||
|
s = s[j+1:]
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectFlagRows(fs *flag.FlagSet) [][2]string {
|
||||||
|
var names []string
|
||||||
|
seen := map[string][2]string{}
|
||||||
|
fs.VisitAll(func(f *flag.Flag) {
|
||||||
|
if f.Hidden {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
left := formatFlagLeft(f)
|
||||||
|
right := f.Usage
|
||||||
|
if f.Deprecated != "" {
|
||||||
|
right += " (deprecated: " + f.Deprecated + ")"
|
||||||
|
}
|
||||||
|
names = append(names, f.Name)
|
||||||
|
seen[f.Name] = [2]string{left, right}
|
||||||
|
})
|
||||||
|
sort.Strings(names)
|
||||||
|
rows := make([][2]string, 0, len(names))
|
||||||
|
for _, n := range names {
|
||||||
|
rows = append(rows, seen[n])
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatFlagLeft(f *flag.Flag) string {
|
||||||
|
if len(f.Shorthand) > 0 {
|
||||||
|
return "-" + f.Shorthand + ", --" + f.Name
|
||||||
|
}
|
||||||
|
return "--" + f.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderTwoColPairs(rows [][2]string, contentW int) string {
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
leftW := 0
|
||||||
|
for _, r := range rows {
|
||||||
|
if w := lipgloss.Width(r[0]); w > leftW {
|
||||||
|
leftW = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const minLeft, maxLeft = 16, 34
|
||||||
|
if leftW < minLeft {
|
||||||
|
leftW = minLeft
|
||||||
|
}
|
||||||
|
if leftW > maxLeft {
|
||||||
|
leftW = maxLeft
|
||||||
|
}
|
||||||
|
gap := " "
|
||||||
|
rightW := contentW - leftW - lipgloss.Width(gap)
|
||||||
|
if rightW < 24 {
|
||||||
|
rightW = 24
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range rows {
|
||||||
|
left := helpIdentStyle().Width(leftW).Align(lipgloss.Left).Render(r[0])
|
||||||
|
right := bodyStyle().Width(rightW).Render(strings.TrimSpace(r[1]))
|
||||||
|
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, left, gap, right))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
return strings.TrimRight(b.String(), "\n")
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package cliui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormatCLIError formats errors with the same boxed sections as help. When ctx
|
||||||
|
// is the command that was running when the error occurred, Usage / Flags panels
|
||||||
|
// are appended so styling matches picoclaw -h.
|
||||||
|
func FormatCLIError(msg string, ctx *cobra.Command) string {
|
||||||
|
msg = strings.TrimRight(msg, "\n")
|
||||||
|
if !UseFancyStderr() {
|
||||||
|
s := "Error: " + msg + "\n"
|
||||||
|
if ctx != nil && showErrHint(msg) {
|
||||||
|
s += "\n" + plainCommandHelp(ctx)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
w := InnerStderrWidth()
|
||||||
|
contentW := w - 6
|
||||||
|
if contentW < 36 {
|
||||||
|
contentW = 36
|
||||||
|
}
|
||||||
|
|
||||||
|
title := titleBarStyle().Render("Error") + "\n\n"
|
||||||
|
|
||||||
|
paras := strings.Split(msg, "\n")
|
||||||
|
var body strings.Builder
|
||||||
|
for i, p := range paras {
|
||||||
|
p = strings.TrimRight(p, " ")
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
st := bodyStyle().Width(contentW)
|
||||||
|
if i > 0 {
|
||||||
|
body.WriteString("\n")
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
body.WriteString(st.Render(p))
|
||||||
|
} else {
|
||||||
|
body.WriteString(mutedStyle().Width(contentW).Render(p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foot := ""
|
||||||
|
if showErrHint(msg) {
|
||||||
|
if ctx != nil {
|
||||||
|
foot = "\n\n" + mutedStyle().Width(contentW).
|
||||||
|
Render("Full command help: "+ctx.CommandPath()+" --help")
|
||||||
|
} else {
|
||||||
|
foot = "\n\n" + mutedStyle().Width(contentW).
|
||||||
|
Render("Tip: picoclaw --help · picoclaw <command> --help")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := borderStyle().Width(w).Render(title+body.String()+foot) + "\n"
|
||||||
|
if ctx != nil && showErrHint(msg) {
|
||||||
|
if ref := RenderCommandQuickRef(ctx, w); ref != "" {
|
||||||
|
out += "\n" + ref
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func showErrHint(msg string) bool {
|
||||||
|
m := strings.ToLower(msg)
|
||||||
|
return strings.Contains(m, "unknown flag") ||
|
||||||
|
strings.Contains(m, "unknown shorthand flag") ||
|
||||||
|
strings.Contains(m, "flag needs an argument") ||
|
||||||
|
strings.Contains(m, "invalid argument") ||
|
||||||
|
strings.Contains(m, "required flag") ||
|
||||||
|
strings.Contains(m, "usage:")
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
package cliui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MCPShowServer holds the server metadata for PrintMCPShow.
|
||||||
|
type MCPShowServer struct {
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
Target string
|
||||||
|
Enabled bool
|
||||||
|
EffectiveDeferred bool // resolved value (per-server override or global default)
|
||||||
|
DeferredExplicit bool // true = per-server override set, false = inherited from global
|
||||||
|
EnvKeys []string // sorted env var names (values intentionally omitted)
|
||||||
|
EnvFile string
|
||||||
|
Headers []string // sorted header names
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCPShowTool holds one tool's info for PrintMCPShow.
|
||||||
|
type MCPShowTool struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Parameters []MCPShowParam
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCPShowParam is one parameter entry.
|
||||||
|
type MCPShowParam struct {
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
Description string
|
||||||
|
Required bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintMCPShow renders the mcp show output (plain or fancy).
|
||||||
|
// w is where the output is written; pass cmd.OutOrStdout() from cobra commands.
|
||||||
|
func PrintMCPShow(w io.Writer, server MCPShowServer, tools []MCPShowTool, disabled bool) {
|
||||||
|
if !UseFancyLayout() {
|
||||||
|
printMCPShowPlain(w, server, tools, disabled)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
printMCPShowFancy(w, server, tools, disabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── plain (narrow / non-TTY) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func printMCPShowPlain(w io.Writer, server MCPShowServer, tools []MCPShowTool, disabled bool) {
|
||||||
|
fmt.Fprintf(w, "Server: %s\n", server.Name)
|
||||||
|
fmt.Fprintf(w, "Type: %s\n", server.Type)
|
||||||
|
fmt.Fprintf(w, "Target: %s\n", server.Target)
|
||||||
|
fmt.Fprintf(w, "Enabled: %s\n", boolWord(server.Enabled))
|
||||||
|
deferredLabel := boolWord(server.EffectiveDeferred)
|
||||||
|
if !server.DeferredExplicit {
|
||||||
|
deferredLabel += " (default)"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "Deferred: %s\n", deferredLabel)
|
||||||
|
if len(server.EnvKeys) > 0 {
|
||||||
|
fmt.Fprintf(w, "Env vars: %s\n", strings.Join(server.EnvKeys, ", "))
|
||||||
|
}
|
||||||
|
if server.EnvFile != "" {
|
||||||
|
fmt.Fprintf(w, "Env file: %s\n", server.EnvFile)
|
||||||
|
}
|
||||||
|
if len(server.Headers) > 0 {
|
||||||
|
fmt.Fprintf(w, "Headers: %s\n", strings.Join(server.Headers, ", "))
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
|
||||||
|
if disabled {
|
||||||
|
fmt.Fprintln(w, "Server is disabled; skipping tool discovery.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(tools) == 0 {
|
||||||
|
fmt.Fprintln(w, "No tools exposed by this server.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(w, "Tools (%d):\n", len(tools))
|
||||||
|
for _, tool := range tools {
|
||||||
|
fmt.Fprintf(w, " %s\n", tool.Name)
|
||||||
|
if tool.Description != "" {
|
||||||
|
fmt.Fprintf(w, " %s\n", truncateDescription(tool.Description, 120))
|
||||||
|
}
|
||||||
|
if len(tool.Parameters) == 0 {
|
||||||
|
fmt.Fprintln(w, " Parameters: none")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, p := range tool.Parameters {
|
||||||
|
line := fmt.Sprintf(" - %s", p.Name)
|
||||||
|
if p.Type != "" {
|
||||||
|
line += fmt.Sprintf(" (%s", p.Type)
|
||||||
|
if p.Required {
|
||||||
|
line += ", required"
|
||||||
|
}
|
||||||
|
line += ")"
|
||||||
|
} else if p.Required {
|
||||||
|
line += " (required)"
|
||||||
|
}
|
||||||
|
if p.Description != "" {
|
||||||
|
line += ": " + truncateDescription(p.Description, 80)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fancy (wide TTY) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var (
|
||||||
|
mcpToolNameStyle = func() lipgloss.Style {
|
||||||
|
return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
|
||||||
|
}
|
||||||
|
mcpParamNameStyle = func() lipgloss.Style {
|
||||||
|
return lipgloss.NewStyle().Foreground(accentRed).Bold(true)
|
||||||
|
}
|
||||||
|
mcpTagStyle = func() lipgloss.Style {
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
|
||||||
|
}
|
||||||
|
mcpRequiredStyle = func() lipgloss.Style {
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#D54646")).Bold(true)
|
||||||
|
}
|
||||||
|
mcpOptionalStyle = func() lipgloss.Style {
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#6B6B6B"))
|
||||||
|
}
|
||||||
|
mcpDescStyle = func() lipgloss.Style {
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC"))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func printMCPShowFancy(w io.Writer, server MCPShowServer, tools []MCPShowTool, disabled bool) {
|
||||||
|
inner := InnerWidth()
|
||||||
|
box := borderStyle().Width(inner)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
// ── server header ──
|
||||||
|
b.WriteString(titleBarStyle().Render("⬡ " + server.Name))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
keyW := 10
|
||||||
|
writeKV := func(key, val string) {
|
||||||
|
k := kvKeyStyle().Width(keyW).Render(key)
|
||||||
|
b.WriteString(k + " " + val + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
writeKV("Type", server.Type)
|
||||||
|
writeKV("Target", server.Target)
|
||||||
|
writeKV("Enabled", coloredBool(server.Enabled))
|
||||||
|
deferredVal := coloredBool(server.EffectiveDeferred)
|
||||||
|
if !server.DeferredExplicit {
|
||||||
|
deferredVal += " " + mcpTagStyle().Render("(default)")
|
||||||
|
}
|
||||||
|
writeKV("Deferred", deferredVal)
|
||||||
|
if len(server.EnvKeys) > 0 {
|
||||||
|
writeKV("Env vars", mutedStyle().Render(strings.Join(server.EnvKeys, ", ")))
|
||||||
|
}
|
||||||
|
if server.EnvFile != "" {
|
||||||
|
writeKV("Env file", mutedStyle().Render(server.EnvFile))
|
||||||
|
}
|
||||||
|
if len(server.Headers) > 0 {
|
||||||
|
writeKV("Headers", mutedStyle().Render(strings.Join(server.Headers, ", ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
if disabled {
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(mutedStyle().Render("Server is disabled; skipping tool discovery."))
|
||||||
|
fmt.Fprintln(w, box.Render(b.String()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tools) == 0 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(mutedStyle().Render("No tools exposed by this server."))
|
||||||
|
fmt.Fprintln(w, box.Render(b.String()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── tools section ──
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(kvKeyStyle().Render(fmt.Sprintf("Tools (%d)", len(tools))))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
contentW := inner - 4 // account for box padding
|
||||||
|
for i, tool := range tools {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteString(strings.Repeat("─", contentW) + "\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// Tool name + index badge
|
||||||
|
badge := mcpTagStyle().Render(fmt.Sprintf("[%d/%d]", i+1, len(tools)))
|
||||||
|
b.WriteString(" " + mcpToolNameStyle().Render(tool.Name) + " " + badge + "\n")
|
||||||
|
|
||||||
|
// Description (wrapped to content width)
|
||||||
|
if tool.Description != "" {
|
||||||
|
desc := truncateDescription(tool.Description, 160)
|
||||||
|
b.WriteString(" " + mcpDescStyle().Render(desc) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameters
|
||||||
|
if len(tool.Parameters) == 0 {
|
||||||
|
b.WriteString(" " + mcpTagStyle().Render("no parameters") + "\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
for _, p := range tool.Parameters {
|
||||||
|
// name
|
||||||
|
pName := mcpParamNameStyle().Render(p.Name)
|
||||||
|
|
||||||
|
// type tag
|
||||||
|
typeTag := ""
|
||||||
|
if p.Type != "" {
|
||||||
|
typeTag = " " + mcpTagStyle().Render("<"+p.Type+">")
|
||||||
|
}
|
||||||
|
|
||||||
|
// required / optional badge
|
||||||
|
var reqBadge string
|
||||||
|
if p.Required {
|
||||||
|
reqBadge = " " + mcpRequiredStyle().Render("required")
|
||||||
|
} else {
|
||||||
|
reqBadge = " " + mcpOptionalStyle().Render("optional")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(" " + pName + typeTag + reqBadge + "\n")
|
||||||
|
|
||||||
|
if p.Description != "" {
|
||||||
|
desc := truncateDescription(p.Description, 120)
|
||||||
|
b.WriteString(" " + mutedStyle().Render(desc) + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(w, box.Render(b.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mcp list ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// MCPListRow is one row in the mcp list output.
|
||||||
|
type MCPListRow struct {
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
Target string
|
||||||
|
Status string // "enabled", "disabled", "ok (N tools)", "error"
|
||||||
|
EffectiveDeferred bool // resolved value (per-server override or global default)
|
||||||
|
DeferredExplicit bool // true = per-server override set, false = inherited from global
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintMCPList renders the mcp list output (plain or fancy).
|
||||||
|
func PrintMCPList(w io.Writer, rows []MCPListRow) {
|
||||||
|
if !UseFancyLayout() {
|
||||||
|
printMCPListPlain(w, rows)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
printMCPListFancy(w, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printMCPListPlain(w io.Writer, rows []MCPListRow) {
|
||||||
|
headers := []string{"Name", "Type", "Command", "Status", "Deferred"}
|
||||||
|
tableRows := make([][]string, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
deferred := boolWord(r.EffectiveDeferred)
|
||||||
|
if !r.DeferredExplicit {
|
||||||
|
deferred += " (default)"
|
||||||
|
}
|
||||||
|
tableRows[i] = []string{r.Name, r.Type, r.Target, r.Status, deferred}
|
||||||
|
}
|
||||||
|
// reuse the ASCII table renderer already in helpers.go via the caller
|
||||||
|
// (list.go still uses renderTable for the plain path)
|
||||||
|
widths := make([]int, len(headers))
|
||||||
|
for i, h := range headers {
|
||||||
|
widths[i] = len(h)
|
||||||
|
}
|
||||||
|
for _, row := range tableRows {
|
||||||
|
for i, cell := range row {
|
||||||
|
if len(cell) > widths[i] {
|
||||||
|
widths[i] = len(cell)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
border := func() {
|
||||||
|
fmt.Fprint(w, "+")
|
||||||
|
for _, width := range widths {
|
||||||
|
fmt.Fprint(w, strings.Repeat("-", width+2)+"+")
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
}
|
||||||
|
writeRow := func(row []string) {
|
||||||
|
fmt.Fprint(w, "|")
|
||||||
|
for i, cell := range row {
|
||||||
|
fmt.Fprintf(w, " %s%s |", cell, strings.Repeat(" ", widths[i]-len(cell)))
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
}
|
||||||
|
border()
|
||||||
|
writeRow(headers)
|
||||||
|
border()
|
||||||
|
for _, row := range tableRows {
|
||||||
|
writeRow(row)
|
||||||
|
}
|
||||||
|
border()
|
||||||
|
}
|
||||||
|
|
||||||
|
func printMCPListFancy(w io.Writer, rows []MCPListRow) {
|
||||||
|
inner := InnerWidth()
|
||||||
|
box := borderStyle().Width(inner)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
title := fmt.Sprintf("MCP Servers (%d)", len(rows))
|
||||||
|
b.WriteString(titleBarStyle().Render(title))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
contentW := inner - 4
|
||||||
|
for i, row := range rows {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteString(strings.Repeat("─", contentW) + "\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
statusBadge := mcpListStatusStyle(row.Status).Render(row.Status)
|
||||||
|
var deferredBadge string
|
||||||
|
if row.EffectiveDeferred {
|
||||||
|
if row.DeferredExplicit {
|
||||||
|
deferredBadge = " " + mcpTagStyle().Render("deferred")
|
||||||
|
} else {
|
||||||
|
deferredBadge = " " + mcpOptionalStyle().Render("deferred (default)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString(" " + mcpToolNameStyle().Render(row.Name) + " " + statusBadge + deferredBadge + "\n")
|
||||||
|
b.WriteString(" " + mcpTagStyle().Render(row.Type+" "+row.Target) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(w, box.Render(b.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func mcpListStatusStyle(status string) lipgloss.Style {
|
||||||
|
switch {
|
||||||
|
case status == "enabled":
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#2E7D32")).Bold(true)
|
||||||
|
case status == "disabled":
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#6B6B6B"))
|
||||||
|
case strings.HasPrefix(status, "ok"):
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#2E7D32")).Bold(true)
|
||||||
|
case status == "error":
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#D54646")).Bold(true)
|
||||||
|
default:
|
||||||
|
return lipgloss.NewStyle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func boolWord(v bool) string {
|
||||||
|
if v {
|
||||||
|
return "yes"
|
||||||
|
}
|
||||||
|
return "no"
|
||||||
|
}
|
||||||
|
|
||||||
|
func coloredBool(v bool) string {
|
||||||
|
if v {
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#2E7D32")).Bold(true).Render("yes")
|
||||||
|
}
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#D54646")).Render("no")
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateDescription strips newlines, collapses whitespace, and caps length.
|
||||||
|
func truncateDescription(s string, maxLen int) string {
|
||||||
|
// collapse newlines and repeated spaces into a single space
|
||||||
|
s = strings.Join(strings.Fields(s), " ")
|
||||||
|
if len(s) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
// cut at last space before maxLen
|
||||||
|
cut := s[:maxLen]
|
||||||
|
if idx := strings.LastIndex(cut, " "); idx > maxLen/2 {
|
||||||
|
cut = cut[:idx]
|
||||||
|
}
|
||||||
|
return cut + "…"
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package cliui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PrintOnboardComplete prints the post-onboard “ready” message and next steps.
|
||||||
|
func PrintOnboardComplete(logo string, encrypt bool, configPath string) {
|
||||||
|
if !UseFancyLayout() {
|
||||||
|
printOnboardPlain(logo, encrypt, configPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
printOnboardFancy(logo, encrypt, configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printOnboardPlain(logo string, encrypt bool, configPath string) {
|
||||||
|
fmt.Printf("\n%s picoclaw is ready!\n", logo)
|
||||||
|
fmt.Println("\nNext steps:")
|
||||||
|
if encrypt {
|
||||||
|
fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:")
|
||||||
|
fmt.Println(" export PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Linux/macOS")
|
||||||
|
fmt.Println(" set PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Windows cmd")
|
||||||
|
fmt.Println("")
|
||||||
|
fmt.Println(" 2. Add your API key to", configPath)
|
||||||
|
} else {
|
||||||
|
fmt.Println(" 1. Add your API key to", configPath)
|
||||||
|
}
|
||||||
|
fmt.Println("")
|
||||||
|
fmt.Println(" Recommended:")
|
||||||
|
fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
|
||||||
|
fmt.Println(" - Ollama: https://ollama.com (local, free)")
|
||||||
|
fmt.Println("")
|
||||||
|
fmt.Println(" See README.md for 17+ supported providers.")
|
||||||
|
fmt.Println("")
|
||||||
|
if encrypt {
|
||||||
|
fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"")
|
||||||
|
} else {
|
||||||
|
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printOnboardFancy(logo string, encrypt bool, configPath string) {
|
||||||
|
inner := InnerWidth()
|
||||||
|
box := borderStyle().MaxWidth(inner + 8)
|
||||||
|
|
||||||
|
ready := titleBarStyle().Render(logo+" picoclaw is ready!") + "\n"
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(box.Width(inner).Render(strings.TrimSpace(ready)))
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
steps := buildOnboardingSteps(encrypt, configPath)
|
||||||
|
rec := recommendedBlock()
|
||||||
|
chat := chatStep(encrypt)
|
||||||
|
|
||||||
|
if UseColumnLayout() {
|
||||||
|
leftW := min(inner/2-2, 52)
|
||||||
|
rightW := inner - leftW - 4
|
||||||
|
if rightW < 36 {
|
||||||
|
rightW = 36
|
||||||
|
}
|
||||||
|
leftBlock := borderStyle().MaxWidth(leftW + 8).Width(leftW).
|
||||||
|
Render(titleBarStyle().Render("Next steps") + "\n\n" + bodyStyle().Width(leftW).Render(steps))
|
||||||
|
rightBlock := borderStyle().MaxWidth(rightW + 8).Width(rightW).
|
||||||
|
Render(mutedStyle().Bold(true).Render("Recommended") + "\n\n" + bodyStyle().Width(rightW).Render(rec))
|
||||||
|
gap := strings.Repeat(" ", 2)
|
||||||
|
fmt.Println(lipgloss.JoinHorizontal(lipgloss.Top, leftBlock, gap, rightBlock))
|
||||||
|
fmt.Println()
|
||||||
|
full := borderStyle().Width(inner).Render(bodyStyle().Width(inner - 4).Render(chat))
|
||||||
|
fmt.Println(full)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same order as plain output: numbered steps → recommended → chat line.
|
||||||
|
next := titleBarStyle().Render("Next steps") + "\n\n" +
|
||||||
|
bodyStyle().Width(inner-4).Render(steps+"\n\n"+rec+"\n\n"+chat)
|
||||||
|
fmt.Println(borderStyle().Width(inner).Render(next))
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildOnboardingSteps(encrypt bool, configPath string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
if encrypt {
|
||||||
|
b.WriteString("1. Set your encryption passphrase before starting picoclaw:\n")
|
||||||
|
b.WriteString(" export PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Linux/macOS\n")
|
||||||
|
b.WriteString(" set PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Windows cmd\n\n")
|
||||||
|
b.WriteString("2. Add your API key to\n ")
|
||||||
|
b.WriteString(configPath)
|
||||||
|
b.WriteString("\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString("1. Add your API key to\n ")
|
||||||
|
b.WriteString(configPath)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func recommendedBlock() string {
|
||||||
|
return "• OpenRouter: https://openrouter.ai/keys\n (access 100+ models)\n\n" +
|
||||||
|
"• Ollama: https://ollama.com\n (local, free)\n\n" +
|
||||||
|
"See README.md for 17+ supported providers."
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatStep(encrypt bool) string {
|
||||||
|
if encrypt {
|
||||||
|
return "3. Chat:\n picoclaw agent -m \"Hello!\""
|
||||||
|
}
|
||||||
|
return "2. Chat:\n picoclaw agent -m \"Hello!\""
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package cliui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProviderRow holds one provider's display name and status value.
|
||||||
|
type ProviderRow struct {
|
||||||
|
Name string
|
||||||
|
Val string
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusReport is a structured status view for PrintStatus.
|
||||||
|
type StatusReport struct {
|
||||||
|
Logo string
|
||||||
|
Version string
|
||||||
|
Build string
|
||||||
|
ConfigPath string
|
||||||
|
ConfigOK bool
|
||||||
|
WorkspacePath string
|
||||||
|
WorkspaceOK bool
|
||||||
|
Model string
|
||||||
|
Providers []ProviderRow
|
||||||
|
OAuthLines []string // each full line "provider (method): state"
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintStatus renders picoclaw status (plain or fancy).
|
||||||
|
func PrintStatus(r StatusReport) {
|
||||||
|
if !UseFancyLayout() {
|
||||||
|
printStatusPlain(r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
printStatusFancy(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printStatusPlain(r StatusReport) {
|
||||||
|
fmt.Printf("%s picoclaw Status\n", r.Logo)
|
||||||
|
fmt.Printf("Version: %s\n", r.Version)
|
||||||
|
if r.Build != "" {
|
||||||
|
fmt.Printf("Build: %s\n", r.Build)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
printPathLine("Config", r.ConfigPath, r.ConfigOK)
|
||||||
|
printPathLine("Workspace", r.WorkspacePath, r.WorkspaceOK)
|
||||||
|
|
||||||
|
if r.ConfigOK {
|
||||||
|
fmt.Printf("Model: %s\n", r.Model)
|
||||||
|
for _, p := range r.Providers {
|
||||||
|
fmt.Printf("%s: %s\n", p.Name, p.Val)
|
||||||
|
}
|
||||||
|
if len(r.OAuthLines) > 0 {
|
||||||
|
fmt.Println("\nOAuth/Token Auth:")
|
||||||
|
for _, line := range r.OAuthLines {
|
||||||
|
fmt.Printf(" %s\n", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printPathLine(label, path string, ok bool) {
|
||||||
|
mark := "✗"
|
||||||
|
if ok {
|
||||||
|
mark = "✓"
|
||||||
|
}
|
||||||
|
fmt.Println(label+":", path, mark)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printStatusFancy(r StatusReport) {
|
||||||
|
inner := InnerWidth()
|
||||||
|
topBox := borderStyle().Width(inner)
|
||||||
|
|
||||||
|
var head strings.Builder
|
||||||
|
head.WriteString(titleBarStyle().Render(r.Logo + " picoclaw Status"))
|
||||||
|
head.WriteString("\n\n")
|
||||||
|
head.WriteString(kvKeyStyle().Render("Version") + " " + kvValStyle().Render(r.Version))
|
||||||
|
if r.Build != "" {
|
||||||
|
head.WriteString("\n")
|
||||||
|
head.WriteString(kvKeyStyle().Render("Build") + " " + kvValStyle().Render(r.Build))
|
||||||
|
}
|
||||||
|
fmt.Println(topBox.Render(head.String()))
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if UseColumnLayout() && len(r.Providers) > 0 && r.ConfigOK {
|
||||||
|
leftW := (inner - 2) / 2
|
||||||
|
rightW := inner - leftW - 2
|
||||||
|
pathsNarrow := pathStatusPanel(r, leftW)
|
||||||
|
prov := providerTablePanel(r, rightW)
|
||||||
|
gap := strings.Repeat(" ", 2)
|
||||||
|
fmt.Println(lipgloss.JoinHorizontal(lipgloss.Top, pathsNarrow, gap, prov))
|
||||||
|
} else {
|
||||||
|
fmt.Println(pathStatusPanel(r, inner))
|
||||||
|
if len(r.Providers) > 0 && r.ConfigOK {
|
||||||
|
fmt.Println(providerTablePanel(r, inner))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(r.OAuthLines) > 0 && r.ConfigOK {
|
||||||
|
var ob strings.Builder
|
||||||
|
ob.WriteString(titleBarStyle().Render("OAuth / token auth") + "\n\n")
|
||||||
|
for _, line := range r.OAuthLines {
|
||||||
|
ob.WriteString(" • " + line + "\n")
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(borderStyle().Width(inner).Render(ob.String()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathStatusPanel(r StatusReport, inner int) string {
|
||||||
|
cfgMark := statusMark(r.ConfigOK)
|
||||||
|
wsMark := statusMark(r.WorkspaceOK)
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(kvKeyStyle().Render("Config") + "\n")
|
||||||
|
b.WriteString(mutedStyle().Render(r.ConfigPath))
|
||||||
|
b.WriteString(" " + cfgMark + "\n\n")
|
||||||
|
b.WriteString(kvKeyStyle().Render("Workspace") + "\n")
|
||||||
|
b.WriteString(mutedStyle().Render(r.WorkspacePath))
|
||||||
|
b.WriteString(" " + wsMark + "\n")
|
||||||
|
if r.ConfigOK {
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(kvKeyStyle().Render("Model") + " " + kvValStyle().Render(r.Model))
|
||||||
|
}
|
||||||
|
return borderStyle().Width(inner).Render(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusMark(ok bool) string {
|
||||||
|
if ok {
|
||||||
|
return lipgloss.NewStyle().Foreground(colorOK).Render("✓")
|
||||||
|
}
|
||||||
|
return lipgloss.NewStyle().Foreground(accentRed).Render("✗")
|
||||||
|
}
|
||||||
|
|
||||||
|
func providerTablePanel(r StatusReport, colW int) string {
|
||||||
|
if len(r.Providers) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
keyW := min(22, colW/3)
|
||||||
|
if keyW < 14 {
|
||||||
|
keyW = 14
|
||||||
|
}
|
||||||
|
valW := colW - keyW - 3
|
||||||
|
if valW < 12 {
|
||||||
|
valW = 12
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(titleBarStyle().Render("Providers & local") + "\n\n")
|
||||||
|
for _, p := range r.Providers {
|
||||||
|
k := lipgloss.NewStyle().Foreground(accentBlue).Bold(true).Width(keyW).Render(p.Name)
|
||||||
|
v := styleProviderVal(p.Val).Width(valW).Render(p.Val)
|
||||||
|
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, k, " ", v))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
return borderStyle().Width(colW).Render(strings.TrimRight(b.String(), "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func styleProviderVal(s string) lipgloss.Style {
|
||||||
|
if s == "✓" || strings.HasPrefix(s, "✓ ") {
|
||||||
|
return lipgloss.NewStyle().Foreground(colorOK)
|
||||||
|
}
|
||||||
|
if s == "not set" {
|
||||||
|
return mutedStyle()
|
||||||
|
}
|
||||||
|
return lipgloss.NewStyle()
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package cliui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PrintVersion prints version, optional build info, and Go toolchain line.
|
||||||
|
func PrintVersion(logo, versionLine string, build, goVer string) {
|
||||||
|
if !UseFancyLayout() {
|
||||||
|
fmt.Printf("%s %s\n", logo, versionLine)
|
||||||
|
if build != "" {
|
||||||
|
fmt.Printf(" Build: %s\n", build)
|
||||||
|
}
|
||||||
|
if goVer != "" {
|
||||||
|
fmt.Printf(" Go: %s\n", goVer)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
inner := InnerWidth()
|
||||||
|
box := borderStyle().Width(inner)
|
||||||
|
|
||||||
|
if UseColumnLayout() {
|
||||||
|
leftCol := kvKeyStyle().Width(12).Align(lipgloss.Right)
|
||||||
|
rightW := inner - 16
|
||||||
|
rightStyle := kvValStyle().Width(rightW)
|
||||||
|
|
||||||
|
rows := [][]string{
|
||||||
|
{leftCol.Render("Version"), rightStyle.Render(versionLine)},
|
||||||
|
}
|
||||||
|
if build != "" {
|
||||||
|
rows = append(rows, []string{leftCol.Render("Build"), rightStyle.Render(build)})
|
||||||
|
}
|
||||||
|
if goVer != "" {
|
||||||
|
rows = append(rows, []string{leftCol.Render("Go"), rightStyle.Render(goVer)})
|
||||||
|
}
|
||||||
|
var body strings.Builder
|
||||||
|
for _, r := range rows {
|
||||||
|
body.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, r[0], " ", r[1]))
|
||||||
|
body.WriteString("\n")
|
||||||
|
}
|
||||||
|
header := titleBarStyle().Render(logo+" picoclaw") + "\n\n"
|
||||||
|
fmt.Println(box.Render(header + body.String()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, titleBarStyle().Render(logo+" picoclaw"))
|
||||||
|
lines = append(lines, "")
|
||||||
|
lines = append(lines, kvKeyStyle().Render("Version")+" "+kvValStyle().Render(versionLine))
|
||||||
|
if build != "" {
|
||||||
|
lines = append(lines, kvKeyStyle().Render("Build")+" "+kvValStyle().Render(build))
|
||||||
|
}
|
||||||
|
if goVer != "" {
|
||||||
|
lines = append(lines, kvKeyStyle().Render("Go")+" "+kvValStyle().Render(goVer))
|
||||||
|
}
|
||||||
|
fmt.Println(box.Render(strings.Join(lines, "\n")))
|
||||||
|
}
|
||||||
@@ -14,7 +14,6 @@ func newAddCommand(storePath func() string) *cobra.Command {
|
|||||||
message string
|
message string
|
||||||
every int64
|
every int64
|
||||||
cronExp string
|
cronExp string
|
||||||
deliver bool
|
|
||||||
channel string
|
channel string
|
||||||
to string
|
to string
|
||||||
)
|
)
|
||||||
@@ -37,7 +36,7 @@ func newAddCommand(storePath func() string) *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cs := cron.NewCronService(storePath(), nil)
|
cs := cron.NewCronService(storePath(), nil)
|
||||||
job, err := cs.AddJob(name, schedule, message, deliver, channel, to)
|
job, err := cs.AddJob(name, schedule, message, channel, to)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error adding job: %w", err)
|
return fmt.Errorf("error adding job: %w", err)
|
||||||
}
|
}
|
||||||
@@ -52,7 +51,6 @@ func newAddCommand(storePath func() string) *cobra.Command {
|
|||||||
cmd.Flags().StringVarP(&message, "message", "m", "", "Message for agent")
|
cmd.Flags().StringVarP(&message, "message", "m", "", "Message for agent")
|
||||||
cmd.Flags().Int64VarP(&every, "every", "e", 0, "Run every N seconds")
|
cmd.Flags().Int64VarP(&every, "every", "e", 0, "Run every N seconds")
|
||||||
cmd.Flags().StringVarP(&cronExp, "cron", "c", "", "Cron expression (e.g. '0 9 * * *')")
|
cmd.Flags().StringVarP(&cronExp, "cron", "c", "", "Cron expression (e.g. '0 9 * * *')")
|
||||||
cmd.Flags().BoolVarP(&deliver, "deliver", "d", false, "Deliver response to channel")
|
|
||||||
cmd.Flags().StringVar(&to, "to", "", "Recipient for delivery")
|
cmd.Flags().StringVar(&to, "to", "", "Recipient for delivery")
|
||||||
cmd.Flags().StringVar(&channel, "channel", "", "Channel for delivery")
|
cmd.Flags().StringVar(&channel, "channel", "", "Channel for delivery")
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ func TestNewAddSubcommand(t *testing.T) {
|
|||||||
|
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("every"))
|
assert.NotNil(t, cmd.Flags().Lookup("every"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("cron"))
|
assert.NotNil(t, cmd.Flags().Lookup("cron"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("deliver"))
|
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("to"))
|
assert.NotNil(t, cmd.Flags().Lookup("to"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("channel"))
|
assert.NotNil(t, cmd.Flags().Lookup("channel"))
|
||||||
|
|
||||||
|
|||||||
@@ -2,19 +2,34 @@ package gateway
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/gateway"
|
"github.com/sipeed/picoclaw/pkg/gateway"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/netbind"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func resolveGatewayHostOverride(explicit bool, host string) (string, error) {
|
||||||
|
if !explicit {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
normalized, err := netbind.NormalizeHostInput(host)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid --host value: %w", err)
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
func NewGatewayCommand() *cobra.Command {
|
func NewGatewayCommand() *cobra.Command {
|
||||||
var debug bool
|
var debug bool
|
||||||
var noTruncate bool
|
var noTruncate bool
|
||||||
var allowEmpty bool
|
var allowEmpty bool
|
||||||
|
var host string
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "gateway",
|
Use: "gateway",
|
||||||
@@ -33,8 +48,26 @@ func NewGatewayCommand() *cobra.Command {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
return gateway.Run(debug, internal.GetConfigPath(), allowEmpty)
|
resolvedHost, err := resolveGatewayHostOverride(cmd.Flags().Changed("host"), host)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resolvedHost != "" {
|
||||||
|
prevHost, hadPrev := os.LookupEnv(config.EnvGatewayHost)
|
||||||
|
if err := os.Setenv(config.EnvGatewayHost, resolvedHost); err != nil {
|
||||||
|
return fmt.Errorf("failed to set %s: %w", config.EnvGatewayHost, err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if hadPrev {
|
||||||
|
_ = os.Setenv(config.EnvGatewayHost, prevHost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.Unsetenv(config.EnvGatewayHost)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
return gateway.Run(debug, internal.GetPicoclawHome(), internal.GetConfigPath(), allowEmpty)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +80,12 @@ func NewGatewayCommand() *cobra.Command {
|
|||||||
false,
|
false,
|
||||||
"Continue starting even when no default model is configured",
|
"Continue starting even when no default model is configured",
|
||||||
)
|
)
|
||||||
|
cmd.Flags().StringVar(
|
||||||
|
&host,
|
||||||
|
"host",
|
||||||
|
"",
|
||||||
|
"Host address for gateway binding (overrides gateway.host for this run)",
|
||||||
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,4 +29,38 @@ func TestNewGatewayCommand(t *testing.T) {
|
|||||||
assert.True(t, cmd.HasFlags())
|
assert.True(t, cmd.HasFlags())
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("debug"))
|
assert.NotNil(t, cmd.Flags().Lookup("debug"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("allow-empty"))
|
assert.NotNil(t, cmd.Flags().Lookup("allow-empty"))
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("host"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveGatewayHostOverride(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
explicit bool
|
||||||
|
host string
|
||||||
|
wantHost string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "implicit empty host is allowed", explicit: false, host: "", wantHost: "", wantErr: false},
|
||||||
|
{name: "explicit empty host rejected", explicit: true, host: " ", wantHost: "", wantErr: true},
|
||||||
|
{name: "explicit localhost kept", explicit: true, host: " localhost ", wantHost: "localhost", wantErr: false},
|
||||||
|
{
|
||||||
|
name: "explicit multi host normalized",
|
||||||
|
explicit: true,
|
||||||
|
host: " [::1] , 127.0.0.1 ",
|
||||||
|
wantHost: "::1,127.0.0.1",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := resolveGatewayHostOverride(tt.explicit, tt.host)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("resolveGatewayHostOverride() err = %v, wantErr %t", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if got != tt.wantHost {
|
||||||
|
t.Fatalf("resolveGatewayHostOverride() host = %q, want %q", got, tt.wantHost)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,30 +4,33 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Logo = "🦞"
|
const Logo = pkg.Logo
|
||||||
|
|
||||||
// GetPicoclawHome returns the picoclaw home directory.
|
// GetPicoclawHome returns the picoclaw home directory.
|
||||||
// Priority: $PICOCLAW_HOME > ~/.picoclaw
|
// Priority: $PICOCLAW_HOME > ~/.picoclaw
|
||||||
func GetPicoclawHome() string {
|
func GetPicoclawHome() string {
|
||||||
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
|
return config.GetHome()
|
||||||
return home
|
|
||||||
}
|
|
||||||
home, _ := os.UserHomeDir()
|
|
||||||
return filepath.Join(home, ".picoclaw")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetConfigPath() string {
|
func GetConfigPath() string {
|
||||||
if configPath := os.Getenv("PICOCLAW_CONFIG"); configPath != "" {
|
if configPath := os.Getenv(config.EnvConfig); configPath != "" {
|
||||||
return configPath
|
return configPath
|
||||||
}
|
}
|
||||||
return filepath.Join(GetPicoclawHome(), "config.json")
|
return filepath.Join(GetPicoclawHome(), "config.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() (*config.Config, error) {
|
func LoadConfig() (*config.Config, error) {
|
||||||
return config.LoadConfig(GetConfigPath())
|
cfg, err := config.LoadConfig(GetConfigPath())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
logger.SetLevelFromString(cfg.Gateway.LogLevel)
|
||||||
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatVersion returns the version string with optional git commit
|
// FormatVersion returns the version string with optional git commit
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetConfigPath(t *testing.T) {
|
func TestGetConfigPath(t *testing.T) {
|
||||||
@@ -20,7 +22,7 @@ func TestGetConfigPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) {
|
func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) {
|
||||||
t.Setenv("PICOCLAW_HOME", "/custom/picoclaw")
|
t.Setenv(config.EnvHome, "/custom/picoclaw")
|
||||||
t.Setenv("HOME", "/tmp/home")
|
t.Setenv("HOME", "/tmp/home")
|
||||||
|
|
||||||
got := GetConfigPath()
|
got := GetConfigPath()
|
||||||
@@ -31,7 +33,7 @@ func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) {
|
func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) {
|
||||||
t.Setenv("PICOCLAW_CONFIG", "/custom/config.json")
|
t.Setenv("PICOCLAW_CONFIG", "/custom/config.json")
|
||||||
t.Setenv("PICOCLAW_HOME", "/custom/picoclaw")
|
t.Setenv(config.EnvHome, "/custom/picoclaw")
|
||||||
t.Setenv("HOME", "/tmp/home")
|
t.Setenv("HOME", "/tmp/home")
|
||||||
|
|
||||||
got := GetConfigPath()
|
got := GetConfigPath()
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type addOptions struct {
|
||||||
|
Env []string
|
||||||
|
EnvFile string
|
||||||
|
Headers []string
|
||||||
|
Transport string
|
||||||
|
Force bool
|
||||||
|
Deferred *bool // nil = not set, true = deferred, false = not deferred
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAddCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "add [flags] <name> <command-or-url> [args...]",
|
||||||
|
Short: "Add or update an MCP server",
|
||||||
|
DisableFlagParsing: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
opts, name, target, targetArgs, showHelp, err := parseAddArgs(args)
|
||||||
|
if showHelp {
|
||||||
|
return cmd.Help()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cfg.Tools.MCP.Servers == nil {
|
||||||
|
cfg.Tools.MCP.Servers = make(map[string]config.MCPServerConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := cfg.Tools.MCP.Servers[name]; exists && !opts.Force {
|
||||||
|
var overwrite bool
|
||||||
|
|
||||||
|
overwrite, err = confirmOverwrite(cmd.InOrStdin(), cmd.OutOrStdout(), name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to confirm overwrite: %w", err)
|
||||||
|
}
|
||||||
|
if !overwrite {
|
||||||
|
return fmt.Errorf("aborted: MCP server %q already exists", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server, err := buildServerConfig(target, targetArgs, opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Tools.MCP.Enabled = true
|
||||||
|
cfg.Tools.MCP.Servers[name] = server
|
||||||
|
|
||||||
|
if err := saveValidatedConfig(cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ MCP server %q saved.\n", name)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
flags := cmd.Flags()
|
||||||
|
flags.StringArrayP("env", "e", nil, "Environment variable in KEY=value format (repeatable, saved to config)")
|
||||||
|
flags.String("env-file", "", "Path to an env file for stdio servers (recommended for secrets)")
|
||||||
|
flags.StringArrayP("header", "H", nil, "HTTP header in 'Name: Value' or 'Name=Value' format (repeatable)")
|
||||||
|
flags.StringP("transport", "t", "stdio", "Transport type: stdio, http, or sse")
|
||||||
|
flags.BoolP("force", "f", false, "Overwrite an existing server without prompting")
|
||||||
|
flags.Bool("deferred", false, "Mark server as deferred (tools hidden until explicitly activated)")
|
||||||
|
flags.Bool("no-deferred", false, "Mark server as non-deferred (tools always active)")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAddArgs(args []string) (addOptions, string, string, []string, bool, error) {
|
||||||
|
opts := addOptions{Transport: "stdio"}
|
||||||
|
var positional []string
|
||||||
|
serverArgs := make([]string, 0)
|
||||||
|
explicitCommand := make([]string, 0)
|
||||||
|
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
arg := args[i]
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case arg == "--help" || arg == "-h":
|
||||||
|
return addOptions{}, "", "", nil, true, nil
|
||||||
|
case arg == "--":
|
||||||
|
if i+1 < len(args) {
|
||||||
|
explicitCommand = append(explicitCommand, args[i+1:]...)
|
||||||
|
}
|
||||||
|
i = len(args)
|
||||||
|
case arg == "--force" || arg == "-f":
|
||||||
|
opts.Force = true
|
||||||
|
case arg == "--deferred":
|
||||||
|
t := true
|
||||||
|
opts.Deferred = &t
|
||||||
|
case arg == "--no-deferred":
|
||||||
|
f := false
|
||||||
|
opts.Deferred = &f
|
||||||
|
case arg == "--transport" || arg == "-t":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
opts.Transport = args[i]
|
||||||
|
case strings.HasPrefix(arg, "--transport="):
|
||||||
|
opts.Transport = strings.TrimPrefix(arg, "--transport=")
|
||||||
|
case arg == "--env" || arg == "-e":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
opts.Env = append(opts.Env, args[i])
|
||||||
|
case arg == "--env-file":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
opts.EnvFile = args[i]
|
||||||
|
case strings.HasPrefix(arg, "--env="):
|
||||||
|
opts.Env = append(opts.Env, strings.TrimPrefix(arg, "--env="))
|
||||||
|
case strings.HasPrefix(arg, "--env-file="):
|
||||||
|
opts.EnvFile = strings.TrimPrefix(arg, "--env-file=")
|
||||||
|
case arg == "--header" || arg == "-H":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
opts.Headers = append(opts.Headers, args[i])
|
||||||
|
case strings.HasPrefix(arg, "--header="):
|
||||||
|
opts.Headers = append(opts.Headers, strings.TrimPrefix(arg, "--header="))
|
||||||
|
case strings.HasPrefix(arg, "-") && len(positional) >= 2:
|
||||||
|
serverArgs = append(serverArgs, args[i:]...)
|
||||||
|
i = len(args)
|
||||||
|
default:
|
||||||
|
positional = append(positional, arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(explicitCommand) > 0 {
|
||||||
|
if len(positional) != 1 {
|
||||||
|
return addOptions{}, "", "", nil, false, fmt.Errorf(
|
||||||
|
"usage: picoclaw mcp add [flags] <name> <command-or-url> [args...] or picoclaw mcp add [flags] <name> -- <command> [args...]",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if len(explicitCommand) == 0 {
|
||||||
|
return addOptions{}, "", "", nil, false, fmt.Errorf("missing stdio command after --")
|
||||||
|
}
|
||||||
|
return opts, positional[0], explicitCommand[0], explicitCommand[1:], false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(positional) < 2 {
|
||||||
|
return addOptions{}, "", "", nil, false, fmt.Errorf(
|
||||||
|
"usage: picoclaw mcp add [flags] <name> <command-or-url> [args...] or picoclaw mcp add [flags] <name> -- <command> [args...]",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetArgs := make([]string, 0, len(positional)-2+len(serverArgs))
|
||||||
|
targetArgs = append(targetArgs, positional[2:]...)
|
||||||
|
targetArgs = append(targetArgs, serverArgs...)
|
||||||
|
|
||||||
|
return opts, positional[0], positional[1], targetArgs, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildServerConfig(target string, args []string, opts addOptions) (config.MCPServerConfig, error) {
|
||||||
|
transport := strings.ToLower(strings.TrimSpace(opts.Transport))
|
||||||
|
if transport == "" {
|
||||||
|
transport = "stdio"
|
||||||
|
}
|
||||||
|
switch transport {
|
||||||
|
case "stdio", "http", "sse":
|
||||||
|
default:
|
||||||
|
return config.MCPServerConfig{}, fmt.Errorf("unsupported transport %q", opts.Transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
env, err := parseEnvAssignments(opts.Env)
|
||||||
|
if err != nil {
|
||||||
|
return config.MCPServerConfig{}, err
|
||||||
|
}
|
||||||
|
headers, err := parseHeaderAssignments(opts.Headers)
|
||||||
|
if err != nil {
|
||||||
|
return config.MCPServerConfig{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
server := config.MCPServerConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Type: transport,
|
||||||
|
Deferred: opts.Deferred,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch transport {
|
||||||
|
case "http", "sse":
|
||||||
|
if len(env) > 0 {
|
||||||
|
return config.MCPServerConfig{}, fmt.Errorf("--env can only be used with stdio transport")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(opts.EnvFile) != "" {
|
||||||
|
return config.MCPServerConfig{}, fmt.Errorf("--env-file can only be used with stdio transport")
|
||||||
|
}
|
||||||
|
if len(args) > 0 {
|
||||||
|
return config.MCPServerConfig{}, fmt.Errorf("%s transport does not accept command arguments", transport)
|
||||||
|
}
|
||||||
|
parsedURL, err := url.ParseRequestURI(target)
|
||||||
|
if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
|
||||||
|
return config.MCPServerConfig{}, fmt.Errorf("invalid MCP URL %q", target)
|
||||||
|
}
|
||||||
|
server.URL = target
|
||||||
|
server.Headers = headers
|
||||||
|
return server, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(headers) > 0 {
|
||||||
|
return config.MCPServerConfig{}, fmt.Errorf("--header can only be used with http or sse transport")
|
||||||
|
}
|
||||||
|
|
||||||
|
if looksLikeRemoteURL(target) {
|
||||||
|
return config.MCPServerConfig{}, fmt.Errorf(
|
||||||
|
"target %q looks like a remote MCP URL, but transport is %q. Use --transport http or --transport sse",
|
||||||
|
target,
|
||||||
|
transport,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
command := target
|
||||||
|
commandArgs := append([]string(nil), args...)
|
||||||
|
|
||||||
|
if err := validateLocalCommandPath(target); err != nil {
|
||||||
|
return config.MCPServerConfig{}, err
|
||||||
|
}
|
||||||
|
if isLocalCommandPath(command) {
|
||||||
|
command = expandHomePath(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
server.Command = command
|
||||||
|
server.Args = commandArgs
|
||||||
|
server.Env = env
|
||||||
|
server.EnvFile = strings.TrimSpace(opts.EnvFile)
|
||||||
|
|
||||||
|
return server, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import "github.com/spf13/cobra"
|
||||||
|
|
||||||
|
func NewMCPCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "mcp",
|
||||||
|
Short: "Manage MCP server configuration",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
return cmd.Help()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(
|
||||||
|
newAddCommand(),
|
||||||
|
newRemoveCommand(),
|
||||||
|
newListCommand(),
|
||||||
|
newEditCommand(),
|
||||||
|
newTestCommand(),
|
||||||
|
newShowCommand(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,619 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewMCPCommand(t *testing.T) {
|
||||||
|
cmd := NewMCPCommand()
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "mcp", cmd.Use)
|
||||||
|
assert.Equal(t, "Manage MCP server configuration", cmd.Short)
|
||||||
|
assert.True(t, cmd.HasSubCommands())
|
||||||
|
|
||||||
|
allowedCommands := []string{
|
||||||
|
"add",
|
||||||
|
"remove",
|
||||||
|
"list",
|
||||||
|
"edit",
|
||||||
|
"test",
|
||||||
|
"show",
|
||||||
|
}
|
||||||
|
|
||||||
|
subcommands := cmd.Commands()
|
||||||
|
assert.Len(t, subcommands, len(allowedCommands))
|
||||||
|
|
||||||
|
for _, subcmd := range subcommands {
|
||||||
|
found := slices.Contains(allowedCommands, subcmd.Name())
|
||||||
|
assert.True(t, found, "unexpected subcommand %q", subcmd.Name())
|
||||||
|
assert.False(t, subcmd.Hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPAddAddsGenericStdioServer(t *testing.T) {
|
||||||
|
configPath := setupMCPConfigEnv(t)
|
||||||
|
|
||||||
|
cmd := NewMCPCommand()
|
||||||
|
output, err := executeCommand(cmd, []string{
|
||||||
|
"add",
|
||||||
|
"sqlite",
|
||||||
|
"npx",
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-sqlite",
|
||||||
|
"--db",
|
||||||
|
"./mydb.db",
|
||||||
|
}, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, output, `MCP server "sqlite" saved`)
|
||||||
|
|
||||||
|
cfg := readMCPConfig(t, configPath)
|
||||||
|
require.True(t, cfg.Tools.MCP.Enabled)
|
||||||
|
|
||||||
|
server, ok := cfg.Tools.MCP.Servers["sqlite"]
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.True(t, server.Enabled)
|
||||||
|
assert.Equal(t, "stdio", server.Type)
|
||||||
|
assert.Equal(t, "npx", server.Command)
|
||||||
|
assert.Equal(t, []string{"-y", "@modelcontextprotocol/server-sqlite", "--db", "./mydb.db"}, server.Args)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPAddSupportsHeadersAfterURL(t *testing.T) {
|
||||||
|
configPath := setupMCPConfigEnv(t)
|
||||||
|
|
||||||
|
cmd := NewMCPCommand()
|
||||||
|
_, err := executeCommand(cmd, []string{
|
||||||
|
"add",
|
||||||
|
"apify",
|
||||||
|
"https://mcp.apify.com/",
|
||||||
|
"-t",
|
||||||
|
"http",
|
||||||
|
"--header",
|
||||||
|
"Authorization: Bearer OMITTED",
|
||||||
|
}, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cfg := readMCPConfig(t, configPath)
|
||||||
|
server := cfg.Tools.MCP.Servers["apify"]
|
||||||
|
assert.Equal(t, "http", server.Type)
|
||||||
|
assert.Equal(t, "https://mcp.apify.com/", server.URL)
|
||||||
|
assert.Equal(t, map[string]string{"Authorization": "Bearer OMITTED"}, server.Headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPAddSupportsTransportBeforeName(t *testing.T) {
|
||||||
|
configPath := setupMCPConfigEnv(t)
|
||||||
|
|
||||||
|
cmd := NewMCPCommand()
|
||||||
|
_, err := executeCommand(cmd, []string{
|
||||||
|
"add",
|
||||||
|
"--transport",
|
||||||
|
"sse",
|
||||||
|
"fiscal-ai",
|
||||||
|
"https://api.fiscal.ai/mcp/sse",
|
||||||
|
}, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cfg := readMCPConfig(t, configPath)
|
||||||
|
server := cfg.Tools.MCP.Servers["fiscal-ai"]
|
||||||
|
assert.Equal(t, "sse", server.Type)
|
||||||
|
assert.Equal(t, "https://api.fiscal.ai/mcp/sse", server.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPAddSupportsExplicitStdioCommandAfterSeparator(t *testing.T) {
|
||||||
|
configPath := setupMCPConfigEnv(t)
|
||||||
|
|
||||||
|
cmd := NewMCPCommand()
|
||||||
|
_, err := executeCommand(cmd, []string{
|
||||||
|
"add",
|
||||||
|
"--transport",
|
||||||
|
"stdio",
|
||||||
|
"--env",
|
||||||
|
"AIRTABLE_API_KEY=YOUR_KEY",
|
||||||
|
"airtable",
|
||||||
|
"--",
|
||||||
|
"npx",
|
||||||
|
"-y",
|
||||||
|
"airtable-mcp-server",
|
||||||
|
}, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cfg := readMCPConfig(t, configPath)
|
||||||
|
server := cfg.Tools.MCP.Servers["airtable"]
|
||||||
|
assert.Equal(t, "stdio", server.Type)
|
||||||
|
assert.Equal(t, "npx", server.Command)
|
||||||
|
assert.Equal(t, []string{"-y", "airtable-mcp-server"}, server.Args)
|
||||||
|
assert.Equal(t, map[string]string{"AIRTABLE_API_KEY": "YOUR_KEY"}, server.Env)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPAddSupportsEnvFileForStdio(t *testing.T) {
|
||||||
|
configPath := setupMCPConfigEnv(t)
|
||||||
|
|
||||||
|
cmd := NewMCPCommand()
|
||||||
|
_, err := executeCommand(cmd, []string{
|
||||||
|
"add",
|
||||||
|
"--env-file",
|
||||||
|
".env.mcp",
|
||||||
|
"filesystem",
|
||||||
|
"npx",
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-filesystem",
|
||||||
|
}, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cfg := readMCPConfig(t, configPath)
|
||||||
|
server := cfg.Tools.MCP.Servers["filesystem"]
|
||||||
|
assert.Equal(t, "stdio", server.Type)
|
||||||
|
assert.Equal(t, "npx", server.Command)
|
||||||
|
assert.Equal(t, []string{"-y", "@modelcontextprotocol/server-filesystem"}, server.Args)
|
||||||
|
assert.Equal(t, ".env.mcp", server.EnvFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPAddRejectsEnvFileForHTTP(t *testing.T) {
|
||||||
|
setupMCPConfigEnv(t)
|
||||||
|
|
||||||
|
cmd := NewMCPCommand()
|
||||||
|
_, err := executeCommand(cmd, []string{
|
||||||
|
"add",
|
||||||
|
"--transport",
|
||||||
|
"http",
|
||||||
|
"--env-file",
|
||||||
|
".env.mcp",
|
||||||
|
"context7",
|
||||||
|
"https://mcp.context7.com/mcp",
|
||||||
|
}, "")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "--env-file can only be used with stdio transport")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPAddRejectsNonExecutableLocalCommand(t *testing.T) {
|
||||||
|
setupMCPConfigEnv(t)
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
localCmd := filepath.Join(tmpDir, "server.sh")
|
||||||
|
require.NoError(t, os.WriteFile(localCmd, []byte("#!/bin/sh\nexit 0\n"), 0o644))
|
||||||
|
|
||||||
|
cmd := NewMCPCommand()
|
||||||
|
_, err := executeCommand(cmd, []string{"add", "local", localCmd}, "")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "not executable")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPAddExpandsHomeInSavedLocalCommand(t *testing.T) {
|
||||||
|
configPath := setupMCPConfigEnv(t)
|
||||||
|
|
||||||
|
homeDir := t.TempDir()
|
||||||
|
t.Setenv("HOME", homeDir)
|
||||||
|
t.Setenv("USERPROFILE", homeDir)
|
||||||
|
|
||||||
|
localCmd := filepath.Join(homeDir, "bin", "my-mcp")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(localCmd), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(localCmd, []byte("#!/bin/sh\nexit 0\n"), 0o755))
|
||||||
|
|
||||||
|
tildeCmd := "~" + string(os.PathSeparator) + filepath.Join("bin", "my-mcp")
|
||||||
|
|
||||||
|
cmd := NewMCPCommand()
|
||||||
|
_, err := executeCommand(cmd, []string{"add", "local-home", tildeCmd}, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cfg := readMCPConfig(t, configPath)
|
||||||
|
server := cfg.Tools.MCP.Servers["local-home"]
|
||||||
|
assert.Equal(t, localCmd, server.Command)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPAddShowsClearErrorForRemoteURLWithoutTransport(t *testing.T) {
|
||||||
|
setupMCPConfigEnv(t)
|
||||||
|
|
||||||
|
cmd := NewMCPCommand()
|
||||||
|
_, err := executeCommand(cmd, []string{"add", "apify", "https://mcp.apify.com/"}, "")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), `looks like a remote MCP URL`)
|
||||||
|
assert.Contains(t, err.Error(), `Use --transport http or --transport sse`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPAddOverwritePromptDecline(t *testing.T) {
|
||||||
|
configPath := setupMCPConfigEnv(t)
|
||||||
|
writeMCPConfig(t, configPath, &config.Config{
|
||||||
|
Tools: config.ToolsConfig{
|
||||||
|
MCP: config.MCPConfig{
|
||||||
|
ToolConfig: config.ToolConfig{Enabled: true},
|
||||||
|
Servers: map[string]config.MCPServerConfig{
|
||||||
|
"filesystem": {
|
||||||
|
Enabled: true,
|
||||||
|
Type: "stdio",
|
||||||
|
Command: "old",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd := NewMCPCommand()
|
||||||
|
output, err := executeCommand(cmd, []string{"add", "filesystem", "new-command"}, "n\n")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, output, `Overwrite? [y/N]:`)
|
||||||
|
assert.Contains(t, err.Error(), "aborted")
|
||||||
|
|
||||||
|
cfg := readMCPConfig(t, configPath)
|
||||||
|
assert.Equal(t, "old", cfg.Tools.MCP.Servers["filesystem"].Command)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPAddOverwriteWithConfirmation(t *testing.T) {
|
||||||
|
configPath := setupMCPConfigEnv(t)
|
||||||
|
writeMCPConfig(t, configPath, &config.Config{
|
||||||
|
Tools: config.ToolsConfig{
|
||||||
|
MCP: config.MCPConfig{
|
||||||
|
ToolConfig: config.ToolConfig{Enabled: true},
|
||||||
|
Servers: map[string]config.MCPServerConfig{
|
||||||
|
"filesystem": {
|
||||||
|
Enabled: true,
|
||||||
|
Type: "stdio",
|
||||||
|
Command: "old",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd := NewMCPCommand()
|
||||||
|
_, err := executeCommand(cmd, []string{"add", "filesystem", "new-command"}, "y\n")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cfg := readMCPConfig(t, configPath)
|
||||||
|
assert.Equal(t, "new-command", cfg.Tools.MCP.Servers["filesystem"].Command)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPAddHTTPServer(t *testing.T) {
|
||||||
|
configPath := setupMCPConfigEnv(t)
|
||||||
|
|
||||||
|
cmd := NewMCPCommand()
|
||||||
|
_, err := executeCommand(cmd, []string{
|
||||||
|
"add",
|
||||||
|
"context7",
|
||||||
|
"--transport",
|
||||||
|
"http",
|
||||||
|
"https://mcp.context7.com/mcp",
|
||||||
|
}, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cfg := readMCPConfig(t, configPath)
|
||||||
|
server := cfg.Tools.MCP.Servers["context7"]
|
||||||
|
assert.Equal(t, "http", server.Type)
|
||||||
|
assert.Equal(t, "https://mcp.context7.com/mcp", server.URL)
|
||||||
|
assert.Empty(t, server.Command)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPRemoveRemovesLastServerAndDisablesMCP(t *testing.T) {
|
||||||
|
configPath := setupMCPConfigEnv(t)
|
||||||
|
writeMCPConfig(t, configPath, &config.Config{
|
||||||
|
Tools: config.ToolsConfig{
|
||||||
|
MCP: config.MCPConfig{
|
||||||
|
ToolConfig: config.ToolConfig{Enabled: true},
|
||||||
|
Servers: map[string]config.MCPServerConfig{
|
||||||
|
"filesystem": {
|
||||||
|
Enabled: true,
|
||||||
|
Type: "stdio",
|
||||||
|
Command: "npx",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd := NewMCPCommand()
|
||||||
|
output, err := executeCommand(cmd, []string{"remove", "filesystem"}, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, output, `MCP server "filesystem" removed`)
|
||||||
|
|
||||||
|
cfg := readMCPConfig(t, configPath)
|
||||||
|
assert.False(t, cfg.Tools.MCP.Enabled)
|
||||||
|
assert.Empty(t, cfg.Tools.MCP.Servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPListPrintsTable(t *testing.T) {
|
||||||
|
configPath := setupMCPConfigEnv(t)
|
||||||
|
writeMCPConfig(t, configPath, &config.Config{
|
||||||
|
Tools: config.ToolsConfig{
|
||||||
|
MCP: config.MCPConfig{
|
||||||
|
ToolConfig: config.ToolConfig{Enabled: true},
|
||||||
|
Servers: map[string]config.MCPServerConfig{
|
||||||
|
"context7": {
|
||||||
|
Enabled: true,
|
||||||
|
Type: "http",
|
||||||
|
URL: "https://mcp.context7.com/mcp",
|
||||||
|
},
|
||||||
|
"filesystem": {
|
||||||
|
Enabled: false,
|
||||||
|
Type: "stdio",
|
||||||
|
Command: "npx",
|
||||||
|
Args: []string{"-y", "@modelcontextprotocol/server-filesystem", "/tmp"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd := NewMCPCommand()
|
||||||
|
output, err := executeCommand(cmd, []string{"list"}, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, output, "| Name")
|
||||||
|
assert.Contains(t, output, "context7")
|
||||||
|
assert.Contains(t, output, "filesystem")
|
||||||
|
assert.Contains(t, output, "https://mcp.context7.com/mcp")
|
||||||
|
assert.Contains(t, output, "disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPListWithStatusUsesProbe(t *testing.T) {
|
||||||
|
configPath := setupMCPConfigEnv(t)
|
||||||
|
writeMCPConfig(t, configPath, &config.Config{
|
||||||
|
Tools: config.ToolsConfig{
|
||||||
|
MCP: config.MCPConfig{
|
||||||
|
ToolConfig: config.ToolConfig{Enabled: true},
|
||||||
|
Servers: map[string]config.MCPServerConfig{
|
||||||
|
"filesystem": {
|
||||||
|
Enabled: true,
|
||||||
|
Type: "stdio",
|
||||||
|
Command: "npx",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
originalProbe := serverProbe
|
||||||
|
defer func() { serverProbe = originalProbe }()
|
||||||
|
serverProbe = func(_ context.Context, name string, server config.MCPServerConfig, workspacePath string) (probeResult, error) {
|
||||||
|
assert.Equal(t, "filesystem", name)
|
||||||
|
assert.Equal(t, readMCPConfig(t, configPath).WorkspacePath(), workspacePath)
|
||||||
|
assert.Equal(t, "npx", server.Command)
|
||||||
|
return probeResult{ToolCount: 3}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := NewMCPCommand()
|
||||||
|
output, err := executeCommand(cmd, []string{"list", "--status"}, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, output, "ok (3 tools)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPEditUsesEditor(t *testing.T) {
|
||||||
|
configPath := setupMCPConfigEnv(t)
|
||||||
|
|
||||||
|
originalEditor := editorCommand
|
||||||
|
defer func() { editorCommand = originalEditor }()
|
||||||
|
|
||||||
|
var gotName string
|
||||||
|
var gotArgs []string
|
||||||
|
editorCommand = func(name string, args ...string) *exec.Cmd {
|
||||||
|
gotName = name
|
||||||
|
gotArgs = append([]string(nil), args...)
|
||||||
|
return exec.Command("sh", "-c", "exit 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("EDITOR", `dummy-editor --wait`)
|
||||||
|
|
||||||
|
cmd := NewMCPCommand()
|
||||||
|
_, err := executeCommand(cmd, []string{"edit"}, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "dummy-editor", gotName)
|
||||||
|
assert.Equal(t, []string{"--wait", configPath}, gotArgs)
|
||||||
|
_, statErr := os.Stat(configPath)
|
||||||
|
assert.NoError(t, statErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPEditRequiresEditor(t *testing.T) {
|
||||||
|
setupMCPConfigEnv(t)
|
||||||
|
t.Setenv("EDITOR", "")
|
||||||
|
|
||||||
|
cmd := NewMCPCommand()
|
||||||
|
_, err := executeCommand(cmd, []string{"edit"}, "")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "$EDITOR is not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPTestUsesProbe(t *testing.T) {
|
||||||
|
configPath := setupMCPConfigEnv(t)
|
||||||
|
writeMCPConfig(t, configPath, &config.Config{
|
||||||
|
Tools: config.ToolsConfig{
|
||||||
|
MCP: config.MCPConfig{
|
||||||
|
ToolConfig: config.ToolConfig{Enabled: true},
|
||||||
|
Servers: map[string]config.MCPServerConfig{
|
||||||
|
"filesystem": {
|
||||||
|
Enabled: false,
|
||||||
|
Type: "stdio",
|
||||||
|
Command: "npx",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
originalProbe := serverProbe
|
||||||
|
defer func() { serverProbe = originalProbe }()
|
||||||
|
serverProbe = func(_ context.Context, name string, _ config.MCPServerConfig, workspacePath string) (probeResult, error) {
|
||||||
|
assert.Equal(t, "filesystem", name)
|
||||||
|
assert.Equal(t, readMCPConfig(t, configPath).WorkspacePath(), workspacePath)
|
||||||
|
return probeResult{ToolCount: 2}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := NewMCPCommand()
|
||||||
|
output, err := executeCommand(cmd, []string{"test", "filesystem"}, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, output, `MCP server "filesystem" reachable (2 tools)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPAddDeferredFlag(t *testing.T) {
|
||||||
|
configPath := setupMCPConfigEnv(t)
|
||||||
|
|
||||||
|
cmd := NewMCPCommand()
|
||||||
|
_, err := executeCommand(cmd, []string{"add", "--deferred", "myserver", "npx", "my-mcp"}, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cfg := readMCPConfig(t, configPath)
|
||||||
|
server := cfg.Tools.MCP.Servers["myserver"]
|
||||||
|
require.NotNil(t, server.Deferred)
|
||||||
|
assert.True(t, *server.Deferred)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPAddNoDeferredFlag(t *testing.T) {
|
||||||
|
configPath := setupMCPConfigEnv(t)
|
||||||
|
|
||||||
|
cmd := NewMCPCommand()
|
||||||
|
_, err := executeCommand(cmd, []string{"add", "--no-deferred", "myserver", "npx", "my-mcp"}, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cfg := readMCPConfig(t, configPath)
|
||||||
|
server := cfg.Tools.MCP.Servers["myserver"]
|
||||||
|
require.NotNil(t, server.Deferred)
|
||||||
|
assert.False(t, *server.Deferred)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPAddNoDeferredByDefault(t *testing.T) {
|
||||||
|
configPath := setupMCPConfigEnv(t)
|
||||||
|
|
||||||
|
cmd := NewMCPCommand()
|
||||||
|
_, err := executeCommand(cmd, []string{"add", "myserver", "npx", "my-mcp"}, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cfg := readMCPConfig(t, configPath)
|
||||||
|
server := cfg.Tools.MCP.Servers["myserver"]
|
||||||
|
assert.Nil(t, server.Deferred)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPShowNotFound(t *testing.T) {
|
||||||
|
configPath := setupMCPConfigEnv(t)
|
||||||
|
writeMCPConfig(t, configPath, nil)
|
||||||
|
|
||||||
|
cmd := NewMCPCommand()
|
||||||
|
_, err := executeCommand(cmd, []string{"show", "missing"}, "")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), `"missing" not found`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPShowDisabledServer(t *testing.T) {
|
||||||
|
configPath := setupMCPConfigEnv(t)
|
||||||
|
writeMCPConfig(t, configPath, &config.Config{
|
||||||
|
Tools: config.ToolsConfig{
|
||||||
|
MCP: config.MCPConfig{
|
||||||
|
ToolConfig: config.ToolConfig{Enabled: true},
|
||||||
|
Servers: map[string]config.MCPServerConfig{
|
||||||
|
"myserver": {
|
||||||
|
Enabled: false,
|
||||||
|
Type: "stdio",
|
||||||
|
Command: "npx",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd := NewMCPCommand()
|
||||||
|
output, err := executeCommand(cmd, []string{"show", "myserver"}, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, output, "myserver")
|
||||||
|
assert.Contains(t, output, "disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPShowUsesProbe(t *testing.T) {
|
||||||
|
configPath := setupMCPConfigEnv(t)
|
||||||
|
writeMCPConfig(t, configPath, &config.Config{
|
||||||
|
Tools: config.ToolsConfig{
|
||||||
|
MCP: config.MCPConfig{
|
||||||
|
ToolConfig: config.ToolConfig{Enabled: true},
|
||||||
|
Servers: map[string]config.MCPServerConfig{
|
||||||
|
"myserver": {
|
||||||
|
Enabled: true,
|
||||||
|
Type: "stdio",
|
||||||
|
Command: "npx",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
original := serverShowProbe
|
||||||
|
defer func() { serverShowProbe = original }()
|
||||||
|
serverShowProbe = func(_ context.Context, name string, _ config.MCPServerConfig, _ string) ([]toolDetail, error) {
|
||||||
|
assert.Equal(t, "myserver", name)
|
||||||
|
return []toolDetail{
|
||||||
|
{
|
||||||
|
Name: "read_file",
|
||||||
|
Description: "Read a file from the filesystem",
|
||||||
|
Parameters: []paramDetail{
|
||||||
|
{Name: "path", Type: "string", Description: "File path", Required: true},
|
||||||
|
{Name: "encoding", Type: "string", Description: "Character encoding", Required: false},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "list_dir",
|
||||||
|
Description: "List directory contents",
|
||||||
|
Parameters: nil,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := NewMCPCommand()
|
||||||
|
output, err := executeCommand(cmd, []string{"show", "myserver"}, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, output, "myserver")
|
||||||
|
assert.Contains(t, output, "read_file")
|
||||||
|
assert.Contains(t, output, "Read a file from the filesystem")
|
||||||
|
assert.Contains(t, output, "path")
|
||||||
|
assert.Contains(t, output, "string")
|
||||||
|
assert.Contains(t, output, "required")
|
||||||
|
assert.Contains(t, output, "list_dir")
|
||||||
|
assert.Contains(t, output, "none")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupMCPConfigEnv(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
t.Setenv(config.EnvConfig, configPath)
|
||||||
|
t.Setenv(config.EnvHome, filepath.Dir(configPath))
|
||||||
|
return configPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMCPConfig(t *testing.T, path string, cfg *config.Config) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = config.DefaultConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, config.SaveConfig(path, cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
func readMCPConfig(t *testing.T, path string) *config.Config {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeCommand(cmd *cobra.Command, args []string, stdin string) (string, error) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
|
cmd.SetArgs(args)
|
||||||
|
cmd.SetOut(&stdout)
|
||||||
|
cmd.SetErr(&stderr)
|
||||||
|
cmd.SetIn(strings.NewReader(stdin))
|
||||||
|
|
||||||
|
err := cmd.Execute()
|
||||||
|
return stdout.String() + stderr.String(), err
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"go.mau.fi/util/shlex"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newEditCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "edit",
|
||||||
|
Short: "Open the PicoClaw config in $EDITOR",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
editor := strings.TrimSpace(os.Getenv("EDITOR"))
|
||||||
|
if editor == "" {
|
||||||
|
return fmt.Errorf("$EDITOR is not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = saveValidatedConfig(cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
editorArgs, err := shlex.Split(editor)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse $EDITOR: %w", err)
|
||||||
|
}
|
||||||
|
if len(editorArgs) == 0 {
|
||||||
|
return fmt.Errorf("$EDITOR is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
editorArgs = append(editorArgs, internal.GetConfigPath())
|
||||||
|
process := editorCommand(editorArgs[0], editorArgs[1:]...)
|
||||||
|
process.Stdin = cmd.InOrStdin()
|
||||||
|
process.Stdout = cmd.OutOrStdout()
|
||||||
|
process.Stderr = cmd.ErrOrStderr()
|
||||||
|
|
||||||
|
if err := process.Run(); err != nil {
|
||||||
|
return fmt.Errorf("failed to start editor: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/google/jsonschema-go/jsonschema"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
picomcp "github.com/sipeed/picoclaw/pkg/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
type probeResult struct {
|
||||||
|
ToolCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
editorCommand = exec.Command
|
||||||
|
serverProbe = defaultServerProbe
|
||||||
|
|
||||||
|
mcpConfigSchemaOnce sync.Once
|
||||||
|
mcpConfigSchema *jsonschema.Resolved
|
||||||
|
errMcpConfigSchema error
|
||||||
|
)
|
||||||
|
|
||||||
|
const mcpConfigSchemaJSON = `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"tools": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"mcp": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": { "type": "boolean" },
|
||||||
|
"discovery": { "type": "object", "additionalProperties": true },
|
||||||
|
"max_inline_text_chars": { "type": "integer" },
|
||||||
|
"servers": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": { "type": "boolean" },
|
||||||
|
"deferred": { "type": "boolean" },
|
||||||
|
"command": { "type": "string" },
|
||||||
|
"args": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" }
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": { "type": "string" }
|
||||||
|
},
|
||||||
|
"env_file": { "type": "string" },
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["stdio", "http", "sse"]
|
||||||
|
},
|
||||||
|
"url": { "type": "string" },
|
||||||
|
"headers": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["enabled"],
|
||||||
|
"anyOf": [
|
||||||
|
{ "required": ["command"] },
|
||||||
|
{ "required": ["url"] }
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["enabled"],
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["mcp"],
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["tools"],
|
||||||
|
"additionalProperties": true
|
||||||
|
}`
|
||||||
|
|
||||||
|
func loadConfig() (*config.Config, error) {
|
||||||
|
cfg, err := config.LoadConfig(internal.GetConfigPath())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveValidatedConfig(cfg *config.Config) error {
|
||||||
|
if cfg == nil {
|
||||||
|
return fmt.Errorf("config is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to serialize config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateConfigDocument(data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.SaveConfig(internal.GetConfigPath(), cfg); err != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateConfigDocument(data []byte) error {
|
||||||
|
var instance map[string]any
|
||||||
|
if err := json.Unmarshal(data, &instance); err != nil {
|
||||||
|
return fmt.Errorf("failed to decode serialized config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
schema, err := loadMCPConfigSchema()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load MCP config schema: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := schema.Validate(instance); err != nil {
|
||||||
|
return fmt.Errorf("config validation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMCPConfigSchema() (*jsonschema.Resolved, error) {
|
||||||
|
mcpConfigSchemaOnce.Do(func() {
|
||||||
|
var schema jsonschema.Schema
|
||||||
|
if err := json.Unmarshal([]byte(mcpConfigSchemaJSON), &schema); err != nil {
|
||||||
|
errMcpConfigSchema = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mcpConfigSchema, errMcpConfigSchema = schema.Resolve(nil)
|
||||||
|
})
|
||||||
|
|
||||||
|
return mcpConfigSchema, errMcpConfigSchema
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferTransportType(server config.MCPServerConfig) string {
|
||||||
|
switch server.Type {
|
||||||
|
case "stdio", "http", "sse":
|
||||||
|
return server.Type
|
||||||
|
}
|
||||||
|
if server.URL != "" {
|
||||||
|
return "sse"
|
||||||
|
}
|
||||||
|
if server.Command != "" {
|
||||||
|
return "stdio"
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderServerTarget(server config.MCPServerConfig) string {
|
||||||
|
transport := inferTransportType(server)
|
||||||
|
if transport == "http" || transport == "sse" {
|
||||||
|
if server.URL == "" {
|
||||||
|
return "<missing url>"
|
||||||
|
}
|
||||||
|
return server.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := append([]string{server.Command}, server.Args...)
|
||||||
|
rendered := strings.TrimSpace(strings.Join(parts, " "))
|
||||||
|
if rendered == "" {
|
||||||
|
return "<missing command>"
|
||||||
|
}
|
||||||
|
return rendered
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedServerNames(servers map[string]config.MCPServerConfig) []string {
|
||||||
|
names := make([]string, 0, len(servers))
|
||||||
|
for name := range servers {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEnvAssignments(values []string) (map[string]string, error) {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
env := make(map[string]string, len(values))
|
||||||
|
for _, entry := range values {
|
||||||
|
key, value, found := strings.Cut(entry, "=")
|
||||||
|
if !found {
|
||||||
|
return nil, fmt.Errorf("invalid env assignment %q: expected KEY=value", entry)
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" {
|
||||||
|
return nil, fmt.Errorf("invalid env assignment %q: key cannot be empty", entry)
|
||||||
|
}
|
||||||
|
env[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return env, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseHeaderAssignments(values []string) (map[string]string, error) {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := make(map[string]string, len(values))
|
||||||
|
for _, entry := range values {
|
||||||
|
key, value, found := strings.Cut(entry, ":")
|
||||||
|
if !found {
|
||||||
|
key, value, found = strings.Cut(entry, "=")
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil, fmt.Errorf("invalid header %q: expected 'Name: Value' or 'Name=Value'", entry)
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if key == "" {
|
||||||
|
return nil, fmt.Errorf("invalid header %q: name cannot be empty", entry)
|
||||||
|
}
|
||||||
|
headers[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeRemoteURL(target string) bool {
|
||||||
|
parsedURL, err := url.ParseRequestURI(target)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if parsedURL.Host == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch strings.ToLower(parsedURL.Scheme) {
|
||||||
|
case "http", "https":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLocalCommandPath(command string) bool {
|
||||||
|
if command == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if looksLikeRemoteURL(command) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return filepath.IsAbs(command) ||
|
||||||
|
filepath.VolumeName(command) != "" ||
|
||||||
|
strings.HasPrefix(command, "."+string(os.PathSeparator)) ||
|
||||||
|
strings.HasPrefix(command, ".."+string(os.PathSeparator)) ||
|
||||||
|
command == "." ||
|
||||||
|
command == ".." ||
|
||||||
|
strings.ContainsRune(command, os.PathSeparator)
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandHomePath(path string) string {
|
||||||
|
if path == "" || path[0] != '~' {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
if path == "~" {
|
||||||
|
return home
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(path, "~/") || strings.HasPrefix(path, "~\\") {
|
||||||
|
return filepath.Join(home, path[2:])
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateLocalCommandPath(command string) error {
|
||||||
|
if !isLocalCommandPath(command) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
path := expandHomePath(command)
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("local command %q does not exist", command)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to stat local command %q: %w", command, err)
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return fmt.Errorf("local command %q is a directory", command)
|
||||||
|
}
|
||||||
|
if runtime.GOOS != "windows" && info.Mode()&0o111 == 0 {
|
||||||
|
return fmt.Errorf("local command %q is not executable", command)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultServerProbe(
|
||||||
|
ctx context.Context,
|
||||||
|
name string,
|
||||||
|
server config.MCPServerConfig,
|
||||||
|
workspacePath string,
|
||||||
|
) (probeResult, error) {
|
||||||
|
mgr := picomcp.NewManager()
|
||||||
|
defer func() { _ = mgr.Close() }()
|
||||||
|
|
||||||
|
server.Enabled = true
|
||||||
|
mcpCfg := config.MCPConfig{
|
||||||
|
ToolConfig: config.ToolConfig{Enabled: true},
|
||||||
|
Servers: map[string]config.MCPServerConfig{
|
||||||
|
name: server,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mgr.LoadFromMCPConfig(ctx, mcpCfg, workspacePath); err != nil {
|
||||||
|
return probeResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, ok := mgr.GetServer(name)
|
||||||
|
if !ok {
|
||||||
|
return probeResult{}, fmt.Errorf("server %q did not register a connection", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return probeResult{ToolCount: len(conn.Tools)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func confirmOverwrite(r io.Reader, w io.Writer, name string) (bool, error) {
|
||||||
|
if _, err := fmt.Fprintf(w, "MCP server %q already exists. Overwrite? [y/N]: ", name); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var answer string
|
||||||
|
if _, err := fmt.Fscanln(r, &answer); err != nil {
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||||
|
return answer == "y" || answer == "yes", nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newListCommand() *cobra.Command {
|
||||||
|
var (
|
||||||
|
includeStatus bool
|
||||||
|
timeout time.Duration
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List configured MCP servers",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.Tools.MCP.Servers) == 0 {
|
||||||
|
fmt.Fprintln(cmd.OutOrStdout(), "No MCP servers configured.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]cliui.MCPListRow, 0, len(cfg.Tools.MCP.Servers))
|
||||||
|
for _, name := range sortedServerNames(cfg.Tools.MCP.Servers) {
|
||||||
|
server := cfg.Tools.MCP.Servers[name]
|
||||||
|
status := "disabled"
|
||||||
|
if server.Enabled {
|
||||||
|
status = "enabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
if includeStatus && server.Enabled {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
result, probeErr := serverProbe(ctx, name, server, cfg.WorkspacePath())
|
||||||
|
cancel()
|
||||||
|
if probeErr != nil {
|
||||||
|
status = "error"
|
||||||
|
} else {
|
||||||
|
status = fmt.Sprintf("ok (%d tools)", result.ToolCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
effectiveDeferred := cfg.Tools.MCP.Discovery.Enabled
|
||||||
|
deferredExplicit := server.Deferred != nil
|
||||||
|
if deferredExplicit {
|
||||||
|
effectiveDeferred = *server.Deferred
|
||||||
|
}
|
||||||
|
|
||||||
|
rows = append(rows, cliui.MCPListRow{
|
||||||
|
Name: name,
|
||||||
|
Type: inferTransportType(server),
|
||||||
|
Target: renderServerTarget(server),
|
||||||
|
Status: status,
|
||||||
|
EffectiveDeferred: effectiveDeferred,
|
||||||
|
DeferredExplicit: deferredExplicit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
cliui.PrintMCPList(cmd.OutOrStdout(), rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVar(&includeStatus, "status", false, "Ping enabled servers and show live status")
|
||||||
|
cmd.Flags().DurationVar(&timeout, "timeout", 5*time.Second, "Timeout for each live status check")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newRemoveCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "remove <name>",
|
||||||
|
Short: "Remove an MCP server from config",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
name := args[0]
|
||||||
|
if _, exists := cfg.Tools.MCP.Servers[name]; !exists {
|
||||||
|
return fmt.Errorf("MCP server %q not found", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(cfg.Tools.MCP.Servers, name)
|
||||||
|
if len(cfg.Tools.MCP.Servers) == 0 {
|
||||||
|
cfg.Tools.MCP.Servers = nil
|
||||||
|
cfg.Tools.MCP.Enabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := saveValidatedConfig(cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ MCP server %q removed.\n", name)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
picomcp "github.com/sipeed/picoclaw/pkg/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
type toolDetail struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Parameters []paramDetail
|
||||||
|
}
|
||||||
|
|
||||||
|
type paramDetail struct {
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
Description string
|
||||||
|
Required bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var serverShowProbe = defaultServerShowProbe
|
||||||
|
|
||||||
|
func defaultServerShowProbe(
|
||||||
|
ctx context.Context,
|
||||||
|
name string,
|
||||||
|
server config.MCPServerConfig,
|
||||||
|
workspacePath string,
|
||||||
|
) ([]toolDetail, error) {
|
||||||
|
mgr := picomcp.NewManager()
|
||||||
|
defer func() { _ = mgr.Close() }()
|
||||||
|
|
||||||
|
server.Enabled = true
|
||||||
|
mcpCfg := config.MCPConfig{
|
||||||
|
ToolConfig: config.ToolConfig{Enabled: true},
|
||||||
|
Servers: map[string]config.MCPServerConfig{
|
||||||
|
name: server,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mgr.LoadFromMCPConfig(ctx, mcpCfg, workspacePath); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, ok := mgr.GetServer(name)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("server %q did not register a connection", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
details := make([]toolDetail, 0, len(conn.Tools))
|
||||||
|
for _, tool := range conn.Tools {
|
||||||
|
details = append(details, toolDetail{
|
||||||
|
Name: tool.Name,
|
||||||
|
Description: tool.Description,
|
||||||
|
Parameters: extractParameters(tool.InputSchema),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return details, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractParameters(schema any) []paramDetail {
|
||||||
|
schemaMap := normalizeSchema(schema)
|
||||||
|
properties, ok := schemaMap["properties"].(map[string]any)
|
||||||
|
if !ok || len(properties) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
required := make(map[string]struct{})
|
||||||
|
switch raw := schemaMap["required"].(type) {
|
||||||
|
case []string:
|
||||||
|
for _, name := range raw {
|
||||||
|
required[name] = struct{}{}
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, value := range raw {
|
||||||
|
if name, ok := value.(string); ok {
|
||||||
|
required[name] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make([]string, 0, len(properties))
|
||||||
|
for name := range properties {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
params := make([]paramDetail, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
param := paramDetail{Name: name}
|
||||||
|
if propMap, ok := properties[name].(map[string]any); ok {
|
||||||
|
if typeName, ok := propMap["type"].(string); ok {
|
||||||
|
param.Type = strings.TrimSpace(typeName)
|
||||||
|
}
|
||||||
|
if desc, ok := propMap["description"].(string); ok {
|
||||||
|
param.Description = strings.TrimSpace(desc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, param.Required = required[name]
|
||||||
|
params = append(params, param)
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSchema(schema any) map[string]any {
|
||||||
|
if schema == nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
if schemaMap, ok := schema.(map[string]any); ok {
|
||||||
|
return schemaMap
|
||||||
|
}
|
||||||
|
|
||||||
|
var jsonData []byte
|
||||||
|
switch raw := schema.(type) {
|
||||||
|
case json.RawMessage:
|
||||||
|
jsonData = raw
|
||||||
|
case []byte:
|
||||||
|
jsonData = raw
|
||||||
|
default:
|
||||||
|
var err error
|
||||||
|
jsonData, err = json.Marshal(schema)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]any
|
||||||
|
if err := json.Unmarshal(jsonData, &result); err != nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func newShowCommand() *cobra.Command {
|
||||||
|
var timeout time.Duration
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "show <name>",
|
||||||
|
Short: "Show details and tools for a configured MCP server",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
name := args[0]
|
||||||
|
server, exists := cfg.Tools.MCP.Servers[name]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("MCP server %q not found", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
serverInfo := buildServerInfo(name, server, cfg.Tools.MCP.Discovery.Enabled)
|
||||||
|
|
||||||
|
if !server.Enabled {
|
||||||
|
cliui.PrintMCPShow(cmd.OutOrStdout(), serverInfo, nil, true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
details, err := serverShowProbe(ctx, name, server, cfg.WorkspacePath())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to MCP server %q: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tools := make([]cliui.MCPShowTool, 0, len(details))
|
||||||
|
for _, d := range details {
|
||||||
|
params := make([]cliui.MCPShowParam, 0, len(d.Parameters))
|
||||||
|
for _, p := range d.Parameters {
|
||||||
|
params = append(params, cliui.MCPShowParam{
|
||||||
|
Name: p.Name,
|
||||||
|
Type: p.Type,
|
||||||
|
Description: p.Description,
|
||||||
|
Required: p.Required,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
tools = append(tools, cliui.MCPShowTool{
|
||||||
|
Name: d.Name,
|
||||||
|
Description: d.Description,
|
||||||
|
Parameters: params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
cliui.PrintMCPShow(cmd.OutOrStdout(), serverInfo, tools, false)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().DurationVar(&timeout, "timeout", 10*time.Second, "Connection timeout")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildServerInfo(name string, server config.MCPServerConfig, discoveryEnabled bool) cliui.MCPShowServer {
|
||||||
|
effectiveDeferred := discoveryEnabled
|
||||||
|
deferredExplicit := server.Deferred != nil
|
||||||
|
if deferredExplicit {
|
||||||
|
effectiveDeferred = *server.Deferred
|
||||||
|
}
|
||||||
|
info := cliui.MCPShowServer{
|
||||||
|
Name: name,
|
||||||
|
Type: inferTransportType(server),
|
||||||
|
Target: renderServerTarget(server),
|
||||||
|
Enabled: server.Enabled,
|
||||||
|
EffectiveDeferred: effectiveDeferred,
|
||||||
|
DeferredExplicit: deferredExplicit,
|
||||||
|
EnvFile: server.EnvFile,
|
||||||
|
}
|
||||||
|
if len(server.Env) > 0 {
|
||||||
|
keys := make([]string, 0, len(server.Env))
|
||||||
|
for k := range server.Env {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
info.EnvKeys = keys
|
||||||
|
}
|
||||||
|
if len(server.Headers) > 0 {
|
||||||
|
keys := make([]string, 0, len(server.Headers))
|
||||||
|
for k := range server.Headers {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
info.Headers = keys
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestCommand() *cobra.Command {
|
||||||
|
var timeout time.Duration
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "test <name>",
|
||||||
|
Short: "Test connectivity for a configured MCP server",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
name := args[0]
|
||||||
|
server, exists := cfg.Tools.MCP.Servers[name]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("MCP server %q not found", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
result, err := serverProbe(ctx, name, server, cfg.WorkspacePath())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to reach MCP server %q: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ MCP server %q reachable (%d tools).\n", name, result.ToolCount)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().DurationVar(&timeout, "timeout", 5*time.Second, "Connection timeout")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultAliasName = "custom-prefer"
|
||||||
|
|
||||||
|
func newAddCommand() *cobra.Command {
|
||||||
|
var (
|
||||||
|
apiBase string
|
||||||
|
apiKey string
|
||||||
|
modelID string
|
||||||
|
alias string
|
||||||
|
modelType string
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "add",
|
||||||
|
Short: "Add a model from an OpenAI-compatible endpoint",
|
||||||
|
Long: `Add a model entry by querying an OpenAI-compatible endpoint exposing
|
||||||
|
GET <api-base>/models, then setting it as the default model.
|
||||||
|
|
||||||
|
If --model is omitted, the available models are listed and you can pick one
|
||||||
|
interactively. If --model is provided, the entry is written without contacting
|
||||||
|
the server.
|
||||||
|
|
||||||
|
Sample interactive session (key shown masked):
|
||||||
|
|
||||||
|
$ picoclaw model add \
|
||||||
|
-b https://ark.cn-beijing.volces.com/api/v3 \
|
||||||
|
-k 7dff****-****-****-****-********e829
|
||||||
|
|
||||||
|
115 model(s) available:
|
||||||
|
1) doubao-lite-128k-240428 (doubao-lite-128k)
|
||||||
|
2) doubao-pro-128k-240515 (doubao-pro-128k)
|
||||||
|
...
|
||||||
|
48) deepseek-r1-250120 (deepseek-r1)
|
||||||
|
78) kimi-k2-250711 (kimi-k2)
|
||||||
|
...
|
||||||
|
115) doubao-seed3d-2-0-260328 (doubao-seed3d-2-0)
|
||||||
|
Pick a model (number or id): 48
|
||||||
|
✓ Saved model 'custom-prefer' (deepseek-r1-250120) and set as default.`,
|
||||||
|
Example: ` picoclaw model add --api-base https://api.openai.com/v1 --api-key sk-...
|
||||||
|
picoclaw model add -b http://localhost:8000/v1 -k dummy -m my-model -n local`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
return runAdd(addOptions{
|
||||||
|
apiBase: strings.TrimSpace(apiBase),
|
||||||
|
apiKey: strings.TrimSpace(apiKey),
|
||||||
|
modelID: strings.TrimSpace(modelID),
|
||||||
|
alias: strings.TrimSpace(alias),
|
||||||
|
modelType: strings.TrimSpace(modelType),
|
||||||
|
stdin: cmd.InOrStdin(),
|
||||||
|
stdout: cmd.OutOrStdout(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&apiBase, "api-base", "b", "",
|
||||||
|
"API base URL (required), e.g. https://api.openai.com/v1")
|
||||||
|
cmd.Flags().StringVarP(&apiKey, "api-key", "k", "", "API key (required)")
|
||||||
|
cmd.Flags().StringVarP(&modelID, "model", "m", "",
|
||||||
|
"Model id; when set, skips the interactive picker and the network call")
|
||||||
|
cmd.Flags().StringVarP(&alias, "name", "n", defaultAliasName,
|
||||||
|
"Local alias written to model_list and used as the default model name")
|
||||||
|
cmd.Flags().StringVar(&modelType, "type", "openai-compatible",
|
||||||
|
"Endpoint type (only 'openai-compatible' is supported today)")
|
||||||
|
_ = cmd.MarkFlagRequired("api-base")
|
||||||
|
_ = cmd.MarkFlagRequired("api-key")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
type addOptions struct {
|
||||||
|
apiBase string
|
||||||
|
apiKey string
|
||||||
|
modelID string
|
||||||
|
alias string
|
||||||
|
modelType string
|
||||||
|
stdin io.Reader
|
||||||
|
stdout io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAdd(opt addOptions) error {
|
||||||
|
if opt.modelType != "" && opt.modelType != "openai-compatible" {
|
||||||
|
return fmt.Errorf("unsupported --type %q (only 'openai-compatible' is supported)", opt.modelType)
|
||||||
|
}
|
||||||
|
if opt.alias == "" {
|
||||||
|
opt.alias = defaultAliasName
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := opt.modelID
|
||||||
|
if selected == "" {
|
||||||
|
entries, err := fetchOpenAIModels(opt.apiBase, opt.apiKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch models: %w", err)
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return fmt.Errorf("no models returned by %s", opt.apiBase)
|
||||||
|
}
|
||||||
|
selected, err = pickModel(opt.stdin, opt.stdout, entries)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return upsertModelDefault(opt.apiBase, opt.apiKey, opt.alias, selected, opt.stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickModel(stdin io.Reader, stdout io.Writer, entries []modelEntry) (string, error) {
|
||||||
|
fmt.Fprintf(stdout, "\n%d model(s) available:\n", len(entries))
|
||||||
|
for i, m := range entries {
|
||||||
|
line := m.ID
|
||||||
|
if m.Name != "" && m.Name != m.ID {
|
||||||
|
line = fmt.Sprintf("%s (%s)", m.ID, m.Name)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stdout, " %3d) %s\n", i+1, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(stdin)
|
||||||
|
for {
|
||||||
|
fmt.Fprint(stdout, "Pick a model (number or id): ")
|
||||||
|
if !scanner.Scan() {
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return "", fmt.Errorf("read input: %w", err)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no selection provided")
|
||||||
|
}
|
||||||
|
text := strings.TrimSpace(scanner.Text())
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if idx, err := strconv.Atoi(text); err == nil {
|
||||||
|
if idx < 1 || idx > len(entries) {
|
||||||
|
fmt.Fprintf(stdout, "Out of range. Enter 1-%d.\n", len(entries))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return entries[idx-1].ID, nil
|
||||||
|
}
|
||||||
|
for _, m := range entries {
|
||||||
|
if m.ID == text {
|
||||||
|
return m.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintln(stdout, "Not a valid number or model id; try again.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertModelDefault(apiBase, apiKey, alias, modelID string, stdout io.Writer) error {
|
||||||
|
configPath := internal.GetConfigPath()
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secureKeys := config.SimpleSecureStrings(apiKey)
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, m := range cfg.ModelList {
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.ModelName == alias {
|
||||||
|
m.Model = modelID
|
||||||
|
m.APIBase = apiBase
|
||||||
|
m.APIKeys = secureKeys
|
||||||
|
m.Enabled = true
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{
|
||||||
|
ModelName: alias,
|
||||||
|
Model: modelID,
|
||||||
|
APIBase: apiBase,
|
||||||
|
APIKeys: secureKeys,
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Agents.Defaults.ModelName = alias
|
||||||
|
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(stdout, "✓ Saved model '%s' (%s) and set as default.\n", alias, modelID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFetchOpenAIModels_DataEnvelope(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/models", r.URL.Path)
|
||||||
|
assert.Equal(t, "Bearer secret", r.Header.Get("Authorization"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-foo","name":"Foo"},{"id":"gpt-bar"}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
entries, err := fetchOpenAIModels(srv.URL, "secret")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, entries, 2)
|
||||||
|
assert.Equal(t, "gpt-foo", entries[0].ID)
|
||||||
|
assert.Equal(t, "Foo", entries[0].Name)
|
||||||
|
assert.Equal(t, "gpt-bar", entries[1].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchOpenAIModels_BareArray(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[{"id":"a"},{"id":"b"}]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
entries, err := fetchOpenAIModels(srv.URL, "secret")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, entries, 2)
|
||||||
|
assert.Equal(t, "a", entries[0].ID)
|
||||||
|
assert.Equal(t, "b", entries[1].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchOpenAIModels_TrimsTrailingSlash(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
_, _ = w.Write([]byte(`{"data":[{"id":"x"}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
_, err := fetchOpenAIModels(srv.URL+"/", "k")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "/models", gotPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchOpenAIModels_HTTPError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
http.Error(w, "nope", http.StatusUnauthorized)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
_, err := fetchOpenAIModels(srv.URL, "bad")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "HTTP 401")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchOpenAIModels_EmptyDataEnvelope(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"data":[]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
entries, err := fetchOpenAIModels(srv.URL, "k")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchOpenAIModels_EmptyBareArray(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
entries, err := fetchOpenAIModels(srv.URL, "k")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchOpenAIModels_UnrecognizedShape(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"models":"not-supported"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
_, err := fetchOpenAIModels(srv.URL, "k")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "unrecognized shape")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchOpenAIModels_RequiresInputs(t *testing.T) {
|
||||||
|
_, err := fetchOpenAIModels("", "k")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "api base")
|
||||||
|
|
||||||
|
_, err = fetchOpenAIModels("https://example.com", "")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "api key")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickModel_ByIndex(t *testing.T) {
|
||||||
|
entries := []modelEntry{{ID: "a"}, {ID: "b"}, {ID: "c"}}
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
got, err := pickModel(strings.NewReader("2\n"), out, entries)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "b", got)
|
||||||
|
assert.Contains(t, out.String(), "3 model(s) available")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickModel_ByID(t *testing.T) {
|
||||||
|
entries := []modelEntry{{ID: "alpha"}, {ID: "beta"}}
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
got, err := pickModel(strings.NewReader("beta\n"), out, entries)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "beta", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickModel_RetriesOnInvalid(t *testing.T) {
|
||||||
|
entries := []modelEntry{{ID: "x"}}
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
got, err := pickModel(strings.NewReader("\n9\nnot-a-model\nx\n"), out, entries)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "x", got)
|
||||||
|
rendered := out.String()
|
||||||
|
assert.Contains(t, rendered, "Out of range")
|
||||||
|
assert.Contains(t, rendered, "Not a valid number")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAdd_WithExplicitModel_NoNetwork(t *testing.T) {
|
||||||
|
initTest(t)
|
||||||
|
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
err := runAdd(addOptions{
|
||||||
|
apiBase: "https://invalid.invalid/v1",
|
||||||
|
apiKey: "k",
|
||||||
|
modelID: "explicit-model",
|
||||||
|
alias: "myalias",
|
||||||
|
modelType: "openai-compatible",
|
||||||
|
stdout: out,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, out.String(), "Saved model 'myalias' (explicit-model)")
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "myalias", cfg.Agents.Defaults.GetModelName())
|
||||||
|
added := findModelByName(cfg, "myalias")
|
||||||
|
require.NotNil(t, added, "expected model 'myalias' in model_list")
|
||||||
|
assert.Equal(t, "explicit-model", added.Model)
|
||||||
|
assert.Equal(t, "https://invalid.invalid/v1", added.APIBase)
|
||||||
|
assert.True(t, added.Enabled)
|
||||||
|
require.Len(t, added.APIKeys, 1)
|
||||||
|
assert.Equal(t, "k", added.APIKeys[0].String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func findModelByName(cfg *config.Config, name string) *config.ModelConfig {
|
||||||
|
for _, m := range cfg.ModelList {
|
||||||
|
if m != nil && m.ModelName == name {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAdd_FetchAndPick(t *testing.T) {
|
||||||
|
initTest(t)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "Bearer my-key", r.Header.Get("Authorization"))
|
||||||
|
_, _ = w.Write([]byte(`{"data":[{"id":"m1"},{"id":"m2"}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
err := runAdd(addOptions{
|
||||||
|
apiBase: srv.URL,
|
||||||
|
apiKey: "my-key",
|
||||||
|
alias: defaultAliasName,
|
||||||
|
modelType: "openai-compatible",
|
||||||
|
stdin: strings.NewReader("2\n"),
|
||||||
|
stdout: out,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, defaultAliasName, cfg.Agents.Defaults.GetModelName())
|
||||||
|
added := findModelByName(cfg, defaultAliasName)
|
||||||
|
require.NotNil(t, added)
|
||||||
|
assert.Equal(t, "m2", added.Model)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAdd_UpsertsExistingAlias(t *testing.T) {
|
||||||
|
initTest(t)
|
||||||
|
|
||||||
|
first := &bytes.Buffer{}
|
||||||
|
require.NoError(t, runAdd(addOptions{
|
||||||
|
apiBase: "https://a.example/v1",
|
||||||
|
apiKey: "k1",
|
||||||
|
modelID: "m1",
|
||||||
|
alias: "shared",
|
||||||
|
stdout: first,
|
||||||
|
}))
|
||||||
|
|
||||||
|
second := &bytes.Buffer{}
|
||||||
|
require.NoError(t, runAdd(addOptions{
|
||||||
|
apiBase: "https://b.example/v1",
|
||||||
|
apiKey: "k2",
|
||||||
|
modelID: "m2",
|
||||||
|
alias: "shared",
|
||||||
|
stdout: second,
|
||||||
|
}))
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
matches := 0
|
||||||
|
for _, m := range cfg.ModelList {
|
||||||
|
if m != nil && m.ModelName == "shared" {
|
||||||
|
matches++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Equal(t, 1, matches, "alias should be updated, not duplicated")
|
||||||
|
|
||||||
|
updated := findModelByName(cfg, "shared")
|
||||||
|
require.NotNil(t, updated)
|
||||||
|
assert.Equal(t, "m2", updated.Model)
|
||||||
|
assert.Equal(t, "https://b.example/v1", updated.APIBase)
|
||||||
|
assert.Equal(t, "k2", updated.APIKeys[0].String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAdd_RejectsUnsupportedType(t *testing.T) {
|
||||||
|
initTest(t)
|
||||||
|
|
||||||
|
err := runAdd(addOptions{
|
||||||
|
apiBase: "https://x/v1",
|
||||||
|
apiKey: "k",
|
||||||
|
modelID: "m",
|
||||||
|
alias: "a",
|
||||||
|
modelType: "anthropic",
|
||||||
|
stdout: &bytes.Buffer{},
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "unsupported --type")
|
||||||
|
}
|
||||||
@@ -21,11 +21,17 @@ func NewModelCommand() *cobra.Command {
|
|||||||
If no argument is provided, shows the current default model.
|
If no argument is provided, shows the current default model.
|
||||||
If a model name is provided, sets it as the default model.
|
If a model name is provided, sets it as the default model.
|
||||||
|
|
||||||
|
To onboard a model from a custom OpenAI-compatible endpoint (fetch the
|
||||||
|
available list online and pick one), use the 'add' subcommand:
|
||||||
|
|
||||||
|
picoclaw model add --help
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
picoclaw model # Show current default model
|
picoclaw model # Show current default model
|
||||||
picoclaw model gpt-5.2 # Set gpt-5.2 as default
|
picoclaw model gpt-5.2 # Set gpt-5.2 as default
|
||||||
picoclaw model claude-sonnet-4.6 # Set claude-sonnet-4.6 as default
|
picoclaw model claude-sonnet-4.6 # Set claude-sonnet-4.6 as default
|
||||||
picoclaw model local-model # Set local VLLM server as default
|
picoclaw model local-model # Set local VLLM server as default
|
||||||
|
picoclaw model add -b URL -k KEY # Add a model from a custom endpoint
|
||||||
|
|
||||||
Note: 'local-model' is a special value for using a local VLLM server
|
Note: 'local-model' is a special value for using a local VLLM server
|
||||||
(running at localhost:8000 by default) which does not require an API key.`,
|
(running at localhost:8000 by default) which does not require an API key.`,
|
||||||
@@ -51,14 +57,13 @@ Note: 'local-model' is a special value for using a local VLLM server
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(newAddCommand())
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func showCurrentModel(cfg *config.Config) {
|
func showCurrentModel(cfg *config.Config) {
|
||||||
defaultModel := cfg.Agents.Defaults.ModelName
|
defaultModel := cfg.Agents.Defaults.ModelName
|
||||||
if defaultModel == "" {
|
|
||||||
defaultModel = cfg.Agents.Defaults.Model
|
|
||||||
}
|
|
||||||
|
|
||||||
if defaultModel == "" {
|
if defaultModel == "" {
|
||||||
fmt.Println("No default model is currently set.")
|
fmt.Println("No default model is currently set.")
|
||||||
@@ -69,6 +74,9 @@ func showCurrentModel(cfg *config.Config) {
|
|||||||
fmt.Println("\nAvailable models in your config:")
|
fmt.Println("\nAvailable models in your config:")
|
||||||
listAvailableModels(cfg)
|
listAvailableModels(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Println("\nTip: 'picoclaw model add -b URL -k KEY' adds a model from a custom")
|
||||||
|
fmt.Println(" OpenAI-compatible endpoint (see 'picoclaw model add --help').")
|
||||||
}
|
}
|
||||||
|
|
||||||
func listAvailableModels(cfg *config.Config) {
|
func listAvailableModels(cfg *config.Config) {
|
||||||
@@ -78,16 +86,13 @@ func listAvailableModels(cfg *config.Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultModel := cfg.Agents.Defaults.ModelName
|
defaultModel := cfg.Agents.Defaults.ModelName
|
||||||
if defaultModel == "" {
|
|
||||||
defaultModel = cfg.Agents.Defaults.Model
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, model := range cfg.ModelList {
|
for _, model := range cfg.ModelList {
|
||||||
marker := " "
|
marker := " "
|
||||||
if model.ModelName == defaultModel {
|
if model.ModelName == defaultModel {
|
||||||
marker = "> "
|
marker = "> "
|
||||||
}
|
}
|
||||||
if model.APIKey == "" {
|
if !model.Enabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fmt.Printf("%s- %s (%s)\n", marker, model.ModelName, model.Model)
|
fmt.Printf("%s- %s (%s)\n", marker, model.ModelName, model.Model)
|
||||||
@@ -98,7 +103,7 @@ func setDefaultModel(configPath string, cfg *config.Config, modelName string) er
|
|||||||
// Validate that the model exists in model_list
|
// Validate that the model exists in model_list
|
||||||
modelFound := false
|
modelFound := false
|
||||||
for _, model := range cfg.ModelList {
|
for _, model := range cfg.ModelList {
|
||||||
if model.APIKey != "" && model.ModelName == modelName {
|
if model.Enabled && model.ModelName == modelName {
|
||||||
modelFound = true
|
modelFound = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -111,12 +116,8 @@ func setDefaultModel(configPath string, cfg *config.Config, modelName string) er
|
|||||||
// Update the default model
|
// Update the default model
|
||||||
// Clear old model field and set new model_name
|
// Clear old model field and set new model_name
|
||||||
oldModel := cfg.Agents.Defaults.ModelName
|
oldModel := cfg.Agents.Defaults.ModelName
|
||||||
if oldModel == "" {
|
|
||||||
oldModel = cfg.Agents.Defaults.Model
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.Agents.Defaults.ModelName = modelName
|
cfg.Agents.Defaults.ModelName = modelName
|
||||||
cfg.Agents.Defaults.Model = "" // Clear deprecated field
|
|
||||||
|
|
||||||
// Save config back to file
|
// Save config back to file
|
||||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
|||||||
@@ -64,9 +64,19 @@ func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
|
|||||||
ModelName: "gpt-4",
|
ModelName: "gpt-4",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"},
|
{
|
||||||
{ModelName: "claude-3", Model: "anthropic/claude-3", APIKey: "test"},
|
ModelName: "gpt-4",
|
||||||
|
Model: "openai/gpt-4",
|
||||||
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ModelName: "claude-3",
|
||||||
|
Model: "anthropic/claude-3",
|
||||||
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,11 +95,15 @@ func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
|
|||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "",
|
ModelName: "",
|
||||||
Model: "",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"},
|
{
|
||||||
|
ModelName: "gpt-4",
|
||||||
|
Model: "openai/gpt-4",
|
||||||
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,26 +115,9 @@ func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
|
|||||||
assert.Contains(t, output, "Available models in your config:")
|
assert.Contains(t, output, "Available models in your config:")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShowCurrentModel_BackwardCompatibility(t *testing.T) {
|
|
||||||
cfg := &config.Config{
|
|
||||||
Agents: config.AgentsConfig{
|
|
||||||
Defaults: config.AgentDefaults{
|
|
||||||
Model: "legacy-model",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ModelList: []config.ModelConfig{},
|
|
||||||
}
|
|
||||||
|
|
||||||
output := captureStdout(func() {
|
|
||||||
showCurrentModel(cfg)
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Contains(t, output, "Current default model: legacy-model")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListAvailableModels_Empty(t *testing.T) {
|
func TestListAvailableModels_Empty(t *testing.T) {
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
ModelList: []config.ModelConfig{},
|
ModelList: []*config.ModelConfig{},
|
||||||
}
|
}
|
||||||
|
|
||||||
output := captureStdout(func() {
|
output := captureStdout(func() {
|
||||||
@@ -137,10 +134,20 @@ func TestListAvailableModels_WithModels(t *testing.T) {
|
|||||||
ModelName: "gpt-4",
|
ModelName: "gpt-4",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"},
|
{
|
||||||
{ModelName: "claude-3", Model: "anthropic/claude-3", APIKey: "test"},
|
ModelName: "gpt-4",
|
||||||
{ModelName: "no-key-model", Model: "openai/test", APIKey: ""},
|
Model: "openai/gpt-4",
|
||||||
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ModelName: "claude-3",
|
||||||
|
Model: "anthropic/claude-3",
|
||||||
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{ModelName: "no-key-model", Model: "openai/test"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,9 +170,19 @@ func TestSetDefaultModel_ValidModel(t *testing.T) {
|
|||||||
ModelName: "old-model",
|
ModelName: "old-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "new-model", Model: "openai/new-model", APIKey: "test"},
|
{
|
||||||
{ModelName: "old-model", Model: "openai/old-model", APIKey: "test"},
|
ModelName: "new-model",
|
||||||
|
Model: "openai/new-model",
|
||||||
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ModelName: "old-model",
|
||||||
|
Model: "openai/old-model",
|
||||||
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,29 +197,6 @@ func TestSetDefaultModel_ValidModel(t *testing.T) {
|
|||||||
updatedCfg, err := config.LoadConfig(configPath)
|
updatedCfg, err := config.LoadConfig(configPath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "new-model", updatedCfg.Agents.Defaults.ModelName)
|
assert.Equal(t, "new-model", updatedCfg.Agents.Defaults.ModelName)
|
||||||
assert.Empty(t, updatedCfg.Agents.Defaults.Model)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetDefaultModel_LegacyModelField(t *testing.T) {
|
|
||||||
initTest(t)
|
|
||||||
|
|
||||||
cfg := &config.Config{
|
|
||||||
Agents: config.AgentsConfig{
|
|
||||||
Defaults: config.AgentDefaults{
|
|
||||||
Model: "legacy-old",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ModelList: []config.ModelConfig{
|
|
||||||
{ModelName: "new-model", Model: "openai/new-model", APIKey: "test"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
output := captureStdout(func() {
|
|
||||||
err := setDefaultModel(configPath, cfg, "new-model")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Contains(t, output, "Default model changed from 'legacy-old' to 'new-model'")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSetDefaultModel_InvalidModel(t *testing.T) {
|
func TestSetDefaultModel_InvalidModel(t *testing.T) {
|
||||||
@@ -214,8 +208,13 @@ func TestSetDefaultModel_InvalidModel(t *testing.T) {
|
|||||||
ModelName: "existing-model",
|
ModelName: "existing-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "existing-model", Model: "openai/existing", APIKey: "test"},
|
{
|
||||||
|
ModelName: "existing-model",
|
||||||
|
Model: "openai/existing",
|
||||||
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,9 +230,14 @@ func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) {
|
|||||||
ModelName: "existing-model",
|
ModelName: "existing-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "existing-model", Model: "openai/existing", APIKey: "test"},
|
{
|
||||||
{ModelName: "no-key-model", Model: "openai/nokey", APIKey: ""},
|
ModelName: "existing-model",
|
||||||
|
Model: "openai/existing",
|
||||||
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{ModelName: "no-key-model", Model: "openai/nokey"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,8 +254,13 @@ func TestSetDefaultModel_SaveConfigError(t *testing.T) {
|
|||||||
ModelName: "old-model",
|
ModelName: "old-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "new-model", Model: "openai/new-model", APIKey: "test"},
|
{
|
||||||
|
ModelName: "new-model",
|
||||||
|
Model: "openai/new-model",
|
||||||
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,8 +300,13 @@ func TestModelCommandExecution_Show(t *testing.T) {
|
|||||||
ModelName: "test-model",
|
ModelName: "test-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "test-model", Model: "openai/test", APIKey: "test"},
|
{
|
||||||
|
ModelName: "test-model",
|
||||||
|
Model: "openai/test",
|
||||||
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,9 +332,19 @@ func TestModelCommandExecution_Set(t *testing.T) {
|
|||||||
ModelName: "old-model",
|
ModelName: "old-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "old-model", Model: "openai/old", APIKey: "test"},
|
{
|
||||||
{ModelName: "new-model", Model: "openai/new", APIKey: "test"},
|
ModelName: "old-model",
|
||||||
|
Model: "openai/old",
|
||||||
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ModelName: "new-model",
|
||||||
|
Model: "openai/new",
|
||||||
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,10 +376,25 @@ func TestListAvailableModels_MarkerLogic(t *testing.T) {
|
|||||||
ModelName: "middle-model",
|
ModelName: "middle-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "first-model", Model: "openai/first", APIKey: "test"},
|
{
|
||||||
{ModelName: "middle-model", Model: "openai/middle", APIKey: "test"},
|
ModelName: "first-model",
|
||||||
{ModelName: "last-model", Model: "openai/last", APIKey: "test"},
|
Model: "openai/first",
|
||||||
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ModelName: "middle-model",
|
||||||
|
Model: "openai/middle",
|
||||||
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ModelName: "last-model",
|
||||||
|
Model: "openai/last",
|
||||||
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type modelEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type modelsAPIResponse struct {
|
||||||
|
Data []modelEntry `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchOpenAIModels GETs <baseURL>/models with Bearer auth and accepts both the
|
||||||
|
// {data:[…]} envelope and a bare array shape used by various OpenAI-compatible servers.
|
||||||
|
func fetchOpenAIModels(baseURL, apiKey string) ([]modelEntry, error) {
|
||||||
|
if strings.TrimSpace(baseURL) == "" {
|
||||||
|
return nil, fmt.Errorf("api base is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(apiKey) == "" {
|
||||||
|
return nil, fmt.Errorf("api key is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
url := strings.TrimRight(baseURL, "/") + "/models"
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// {"data": [...]} envelope. Distinguish "envelope shape with empty list"
|
||||||
|
// from "object without a data key" via Data being non-nil after unmarshal:
|
||||||
|
// json.Unmarshal sets Data to []modelEntry{} for `{"data":[]}` but leaves
|
||||||
|
// it as nil when "data" is absent or null.
|
||||||
|
var envelope modelsAPIResponse
|
||||||
|
if err := json.Unmarshal(body, &envelope); err == nil && envelope.Data != nil {
|
||||||
|
return envelope.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bare-array shape, including `[]`.
|
||||||
|
var arr []modelEntry
|
||||||
|
if err := json.Unmarshal(body, &arr); err == nil {
|
||||||
|
return arr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
preview := body
|
||||||
|
if len(preview) > 256 {
|
||||||
|
preview = preview[:256]
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("decode response: unrecognized shape: %s", strings.TrimSpace(string(preview)))
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:generate cp -r ../../../../workspace .
|
//go:generate go run ../../../../scripts/copydir.go ../../../../workspace ./workspace
|
||||||
//go:embed workspace
|
//go:embed workspace
|
||||||
var embeddedFiles embed.FS
|
var embeddedFiles embed.FS
|
||||||
|
|
||||||
@@ -17,8 +17,13 @@ func NewOnboardCommand() *cobra.Command {
|
|||||||
Use: "onboard",
|
Use: "onboard",
|
||||||
Aliases: []string{"o"},
|
Aliases: []string{"o"},
|
||||||
Short: "Initialize picoclaw configuration and workspace",
|
Short: "Initialize picoclaw configuration and workspace",
|
||||||
|
// Run without subcommands → original onboard flow
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
if len(args) == 0 {
|
||||||
onboard(encrypt)
|
onboard(encrypt)
|
||||||
|
} else {
|
||||||
|
_ = cmd.Help()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/credential"
|
"github.com/sipeed/picoclaw/pkg/credential"
|
||||||
)
|
)
|
||||||
@@ -79,25 +80,7 @@ func onboard(encrypt bool) {
|
|||||||
workspace := cfg.WorkspacePath()
|
workspace := cfg.WorkspacePath()
|
||||||
createWorkspaceTemplates(workspace)
|
createWorkspaceTemplates(workspace)
|
||||||
|
|
||||||
fmt.Printf("\n%s picoclaw is ready!\n", internal.Logo)
|
cliui.PrintOnboardComplete(internal.Logo, encrypt, configPath)
|
||||||
fmt.Println("\nNext steps:")
|
|
||||||
if encrypt {
|
|
||||||
fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:")
|
|
||||||
fmt.Println(" export PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Linux/macOS")
|
|
||||||
fmt.Println(" set PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Windows cmd")
|
|
||||||
fmt.Println("")
|
|
||||||
fmt.Println(" 2. Add your API key to", configPath)
|
|
||||||
} else {
|
|
||||||
fmt.Println(" 1. Add your API key to", configPath)
|
|
||||||
}
|
|
||||||
fmt.Println("")
|
|
||||||
fmt.Println(" Recommended:")
|
|
||||||
fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
|
|
||||||
fmt.Println(" - Ollama: https://ollama.com (local, free)")
|
|
||||||
fmt.Println("")
|
|
||||||
fmt.Println(" See README.md for 17+ supported providers.")
|
|
||||||
fmt.Println("")
|
|
||||||
fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// promptPassphrase reads the encryption passphrase twice from the terminal
|
// promptPassphrase reads the encryption passphrase twice from the terminal
|
||||||
@@ -189,6 +172,9 @@ func copyEmbeddedToTarget(targetDir string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
|
return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
|
||||||
}
|
}
|
||||||
|
if new_path == "AGENTS.md" || new_path == "IDENTITY.md" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Build target file path
|
// Build target file path
|
||||||
targetPath := filepath.Join(targetDir, new_path)
|
targetPath := filepath.Join(targetDir, new_path)
|
||||||
|
|||||||
@@ -6,20 +6,32 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCopyEmbeddedToTargetUsesAgentsMarkdown(t *testing.T) {
|
func TestCopyEmbeddedToTargetUsesStructuredAgentFiles(t *testing.T) {
|
||||||
targetDir := t.TempDir()
|
targetDir := t.TempDir()
|
||||||
|
|
||||||
if err := copyEmbeddedToTarget(targetDir); err != nil {
|
if err := copyEmbeddedToTarget(targetDir); err != nil {
|
||||||
t.Fatalf("copyEmbeddedToTarget() error = %v", err)
|
t.Fatalf("copyEmbeddedToTarget() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
agentsPath := filepath.Join(targetDir, "AGENTS.md")
|
agentPath := filepath.Join(targetDir, "AGENT.md")
|
||||||
if _, err := os.Stat(agentsPath); err != nil {
|
if _, err := os.Stat(agentPath); err != nil {
|
||||||
t.Fatalf("expected %s to exist: %v", agentsPath, err)
|
t.Fatalf("expected %s to exist: %v", agentPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
legacyPath := filepath.Join(targetDir, "AGENT.md")
|
soulPath := filepath.Join(targetDir, "SOUL.md")
|
||||||
|
if _, err := os.Stat(soulPath); err != nil {
|
||||||
|
t.Fatalf("expected %s to exist: %v", soulPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userPath := filepath.Join(targetDir, "USER.md")
|
||||||
|
if _, err := os.Stat(userPath); err != nil {
|
||||||
|
t.Fatalf("expected %s to exist: %v", userPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, legacyName := range []string{"AGENTS.md", "IDENTITY.md"} {
|
||||||
|
legacyPath := filepath.Join(targetDir, legacyName)
|
||||||
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
|
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
|
||||||
t.Fatalf("expected legacy file %s to be absent, got err=%v", legacyPath, err)
|
t.Fatalf("expected legacy file %s to be absent, got err=%v", legacyPath, err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
|
|
||||||
type deps struct {
|
type deps struct {
|
||||||
workspace string
|
workspace string
|
||||||
installer *skills.SkillInstaller
|
|
||||||
skillsLoader *skills.SkillsLoader
|
skillsLoader *skills.SkillsLoader
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,15 +28,6 @@ func NewSkillsCommand() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
d.workspace = cfg.WorkspacePath()
|
d.workspace = cfg.WorkspacePath()
|
||||||
installer, err := skills.NewSkillInstaller(
|
|
||||||
d.workspace,
|
|
||||||
cfg.Tools.Skills.Github.Token,
|
|
||||||
cfg.Tools.Skills.Github.Proxy,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error creating skills installer: %w", err)
|
|
||||||
}
|
|
||||||
d.installer = installer
|
|
||||||
|
|
||||||
// get global config directory and builtin skills directory
|
// get global config directory and builtin skills directory
|
||||||
globalDir := filepath.Dir(internal.GetConfigPath())
|
globalDir := filepath.Dir(internal.GetConfigPath())
|
||||||
@@ -52,13 +42,6 @@ func NewSkillsCommand() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
installerFn := func() (*skills.SkillInstaller, error) {
|
|
||||||
if d.installer == nil {
|
|
||||||
return nil, fmt.Errorf("skills installer is not initialized")
|
|
||||||
}
|
|
||||||
return d.installer, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
loaderFn := func() (*skills.SkillsLoader, error) {
|
loaderFn := func() (*skills.SkillsLoader, error) {
|
||||||
if d.skillsLoader == nil {
|
if d.skillsLoader == nil {
|
||||||
return nil, fmt.Errorf("skills loader is not initialized")
|
return nil, fmt.Errorf("skills loader is not initialized")
|
||||||
@@ -75,10 +58,10 @@ func NewSkillsCommand() *cobra.Command {
|
|||||||
|
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
newListCommand(loaderFn),
|
newListCommand(loaderFn),
|
||||||
newInstallCommand(installerFn),
|
newInstallCommand(),
|
||||||
newInstallBuiltinCommand(workspaceFn),
|
newInstallBuiltinCommand(workspaceFn),
|
||||||
newListBuiltinCommand(),
|
newListBuiltinCommand(),
|
||||||
newRemoveCommand(installerFn),
|
newRemoveCommand(),
|
||||||
newSearchCommand(),
|
newSearchCommand(),
|
||||||
newShowCommand(loaderFn),
|
newShowCommand(loaderFn),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package skills
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -11,12 +12,23 @@ import (
|
|||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
const skillsSearchMaxResults = 20
|
const skillsSearchMaxResults = 20
|
||||||
|
|
||||||
|
type installedSkillOriginMeta struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
OriginKind string `json:"origin_kind,omitempty"`
|
||||||
|
Registry string `json:"registry,omitempty"`
|
||||||
|
Slug string `json:"slug,omitempty"`
|
||||||
|
RegistryURL string `json:"registry_url,omitempty"`
|
||||||
|
InstalledVersion string `json:"installed_version,omitempty"`
|
||||||
|
InstalledAt int64 `json:"installed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
func skillsListCmd(loader *skills.SkillsLoader) {
|
func skillsListCmd(loader *skills.SkillsLoader) {
|
||||||
allSkills := loader.ListSkills()
|
allSkills := loader.ListSkills()
|
||||||
|
|
||||||
@@ -35,50 +47,32 @@ func skillsListCmd(loader *skills.SkillsLoader) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func skillsInstallCmd(installer *skills.SkillInstaller, repo string) error {
|
|
||||||
fmt.Printf("Installing skill from %s...\n", repo)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
if err := installer.InstallFromGitHub(ctx, repo); err != nil {
|
|
||||||
return fmt.Errorf("failed to install skill: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo))
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).
|
// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).
|
||||||
func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) error {
|
func skillsInstallFromRegistry(cfg *config.Config, registryName, target string) error {
|
||||||
err := utils.ValidateSkillIdentifier(registryName)
|
err := utils.ValidateSkillIdentifier(registryName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("✗ invalid registry name: %w", err)
|
return fmt.Errorf("✗ invalid registry name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = utils.ValidateSkillIdentifier(slug)
|
registryMgr := skills.NewRegistryManagerFromToolsConfig(cfg.Tools.Skills)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("✗ invalid slug: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
|
|
||||||
|
|
||||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
|
||||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
|
||||||
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
|
||||||
})
|
|
||||||
|
|
||||||
registry := registryMgr.GetRegistry(registryName)
|
registry := registryMgr.GetRegistry(registryName)
|
||||||
if registry == nil {
|
if registry == nil {
|
||||||
return fmt.Errorf("✗ registry '%s' not found or not enabled. check your config.json.", registryName)
|
return fmt.Errorf("✗ registry '%s' not found or not enabled. check your config.json.", registryName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dirName, err := registry.ResolveInstallDirName(target)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("✗ invalid install target %q: %w", target, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Installing skill '%s' from %s registry...\n", target, registryName)
|
||||||
|
|
||||||
workspace := cfg.WorkspacePath()
|
workspace := cfg.WorkspacePath()
|
||||||
targetDir := filepath.Join(workspace, "skills", slug)
|
targetDir := filepath.Join(workspace, "skills", dirName)
|
||||||
|
|
||||||
if _, err = os.Stat(targetDir); err == nil {
|
if _, err = os.Stat(targetDir); err == nil {
|
||||||
return fmt.Errorf("\u2717 skill '%s' already installed at %s", slug, targetDir)
|
return fmt.Errorf("\u2717 skill '%s' already installed at %s", dirName, targetDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
@@ -88,7 +82,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er
|
|||||||
return fmt.Errorf("\u2717 failed to create skills directory: %v", err)
|
return fmt.Errorf("\u2717 failed to create skills directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := registry.DownloadAndInstall(ctx, slug, "", targetDir)
|
result, err := registry.DownloadAndInstall(ctx, target, "", targetDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rmErr := os.RemoveAll(targetDir)
|
rmErr := os.RemoveAll(targetDir)
|
||||||
if rmErr != nil {
|
if rmErr != nil {
|
||||||
@@ -103,14 +97,34 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er
|
|||||||
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
|
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug)
|
return fmt.Errorf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", target)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.IsSuspicious {
|
if result.IsSuspicious {
|
||||||
fmt.Printf("\u26a0\ufe0f Warning: skill '%s' is flagged as suspicious.\n", slug)
|
fmt.Printf("\u26a0\ufe0f Warning: skill '%s' is flagged as suspicious.\n", target)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\u2713 Skill '%s' v%s installed successfully!\n", slug, result.Version)
|
if !workspaceHasValidSkillDirectory(workspace, dirName) {
|
||||||
|
_ = os.RemoveAll(targetDir)
|
||||||
|
return fmt.Errorf("✗ failed to install skill: registry archive for %q is not a valid skill", target)
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedSlug, registryURL := skills.BuildInstallMetadataForRegistryInstance(registry, target, result.Version)
|
||||||
|
installedAt := time.Now().UnixMilli()
|
||||||
|
if err := writeInstalledSkillOriginMeta(targetDir, installedSkillOriginMeta{
|
||||||
|
Version: 1,
|
||||||
|
OriginKind: "third_party",
|
||||||
|
Registry: registry.Name(),
|
||||||
|
Slug: normalizedSlug,
|
||||||
|
RegistryURL: registryURL,
|
||||||
|
InstalledVersion: result.Version,
|
||||||
|
InstalledAt: installedAt,
|
||||||
|
}); err != nil {
|
||||||
|
_ = os.RemoveAll(targetDir)
|
||||||
|
return fmt.Errorf("✗ failed to persist skill metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\u2713 Skill '%s' v%s installed successfully!\n", dirName, result.Version)
|
||||||
if result.Summary != "" {
|
if result.Summary != "" {
|
||||||
fmt.Printf(" %s\n", result.Summary)
|
fmt.Printf(" %s\n", result.Summary)
|
||||||
}
|
}
|
||||||
@@ -118,15 +132,51 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
|
func writeInstalledSkillOriginMeta(targetDir string, meta installedSkillOriginMeta) error {
|
||||||
fmt.Printf("Removing skill '%s'...\n", skillName)
|
data, err := json.MarshalIndent(meta, "", " ")
|
||||||
|
if err != nil {
|
||||||
if err := installer.Uninstall(skillName); err != nil {
|
return err
|
||||||
fmt.Printf("✗ Failed to remove skill: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("✓ Skill '%s' removed successfully!\n", skillName)
|
func workspaceHasValidSkillDirectory(workspace, directory string) bool {
|
||||||
|
loader := skills.NewSkillsLoader(workspace, "", "")
|
||||||
|
for _, skill := range loader.ListSkills() {
|
||||||
|
if skill.Source != "workspace" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filepath.Base(filepath.Dir(skill.Path)) == directory {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func skillsRemoveFromWorkspace(workspace string, toolsConfig config.SkillsToolsConfig, skillName string) error {
|
||||||
|
name := strings.TrimSpace(skillName)
|
||||||
|
name = strings.Trim(name, "/")
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("skill name is required")
|
||||||
|
}
|
||||||
|
if strings.Contains(name, "/") {
|
||||||
|
dirName, err := skills.GitHubInstallDirNameFromToolsConfig(toolsConfig, name)
|
||||||
|
if err != nil || dirName == "" {
|
||||||
|
return fmt.Errorf("invalid skill name %q", skillName)
|
||||||
|
}
|
||||||
|
name = dirName
|
||||||
|
}
|
||||||
|
if name == "." || name == ".." {
|
||||||
|
return fmt.Errorf("invalid skill name %q", skillName)
|
||||||
|
}
|
||||||
|
skillDir := filepath.Join(workspace, "skills", name)
|
||||||
|
if _, err := os.Stat(skillDir); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("skill '%s' not found", name)
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(skillDir); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove skill '%s': %w", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func skillsInstallBuiltinCmd(workspace string) {
|
func skillsInstallBuiltinCmd(workspace string) {
|
||||||
@@ -226,10 +276,7 @@ func skillsSearchCmd(query string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
registryMgr := skills.NewRegistryManagerFromToolsConfig(cfg.Tools.Skills)
|
||||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
|
||||||
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
|
||||||
})
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSkillsInstallFromRegistryWritesOriginMetadata(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.Workspace = workspace
|
||||||
|
|
||||||
|
var server *httptest.Server
|
||||||
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v3/repos/foo/bar":
|
||||||
|
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"default_branch": "master"}))
|
||||||
|
case "/api/v3/repos/foo/bar/contents/.agents/skills/pr-review":
|
||||||
|
assert.Equal(t, "ref=master", r.URL.RawQuery)
|
||||||
|
require.NoError(t, json.NewEncoder(w).Encode([]map[string]any{{
|
||||||
|
"type": "file",
|
||||||
|
"name": "SKILL.md",
|
||||||
|
"download_url": server.URL + "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md",
|
||||||
|
}}))
|
||||||
|
case "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md":
|
||||||
|
_, _ = w.Write([]byte("---\nname: pr-review\ndescription: PR review skill\n---\n# PR Review\n"))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
|
||||||
|
require.True(t, ok)
|
||||||
|
githubRegistry.BaseURL = server.URL
|
||||||
|
cfg.Tools.Skills.Registries.Set("github", githubRegistry)
|
||||||
|
|
||||||
|
target := server.URL + "/foo/bar/tree/master/.agents/skills/pr-review"
|
||||||
|
require.NoError(t, skillsInstallFromRegistry(cfg, "github", target))
|
||||||
|
|
||||||
|
metaPath := filepath.Join(workspace, "skills", "pr-review", ".skill-origin.json")
|
||||||
|
data, err := os.ReadFile(metaPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var meta installedSkillOriginMeta
|
||||||
|
require.NoError(t, json.Unmarshal(data, &meta))
|
||||||
|
assert.Equal(t, "third_party", meta.OriginKind)
|
||||||
|
assert.Equal(t, "github", meta.Registry)
|
||||||
|
assert.Equal(t, "foo/bar/.agents/skills/pr-review", meta.Slug)
|
||||||
|
assert.Equal(t, server.URL+"/foo/bar/tree/master/.agents/skills/pr-review", meta.RegistryURL)
|
||||||
|
assert.Equal(t, "master", meta.InstalledVersion)
|
||||||
|
assert.NotZero(t, meta.InstalledAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillsInstallFromRegistryRejectsInvalidSkillArchive(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.Workspace = workspace
|
||||||
|
|
||||||
|
var server *httptest.Server
|
||||||
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v3/repos/foo/bar":
|
||||||
|
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"default_branch": "master"}))
|
||||||
|
case "/api/v3/repos/foo/bar/contents/.agents/skills/pr-review":
|
||||||
|
require.NoError(t, json.NewEncoder(w).Encode([]map[string]any{{
|
||||||
|
"type": "file",
|
||||||
|
"name": "SKILL.md",
|
||||||
|
"download_url": server.URL + "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md",
|
||||||
|
}}))
|
||||||
|
case "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md":
|
||||||
|
_, _ = w.Write([]byte("---\nname: bad_skill\ndescription: Invalid skill name\n---\n# Invalid\n"))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
|
||||||
|
require.True(t, ok)
|
||||||
|
githubRegistry.BaseURL = server.URL
|
||||||
|
cfg.Tools.Skills.Registries.Set("github", githubRegistry)
|
||||||
|
|
||||||
|
target := server.URL + "/foo/bar/tree/master/.agents/skills/pr-review"
|
||||||
|
err := skillsInstallFromRegistry(cfg, "github", target)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "is not a valid skill")
|
||||||
|
_, statErr := os.Stat(filepath.Join(workspace, "skills", "pr-review"))
|
||||||
|
assert.True(t, os.IsNotExist(statErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillsRemoveFromWorkspaceRejectsDotTarget(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
skillsDir := filepath.Join(workspace, "skills")
|
||||||
|
require.NoError(t, os.MkdirAll(skillsDir, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(skillsDir, "keep.txt"), []byte("keep"), 0o644))
|
||||||
|
|
||||||
|
err := skillsRemoveFromWorkspace(workspace, config.DefaultConfig().Tools.Skills, ".")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "invalid skill name")
|
||||||
|
|
||||||
|
_, statErr := os.Stat(skillsDir)
|
||||||
|
assert.NoError(t, statErr)
|
||||||
|
_, fileErr := os.Stat(filepath.Join(skillsDir, "keep.txt"))
|
||||||
|
assert.NoError(t, fileErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillsRemoveFromWorkspaceUsesLastPathSegment(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
targetDir := filepath.Join(workspace, "skills", "pr-review")
|
||||||
|
require.NoError(t, os.MkdirAll(targetDir, 0o755))
|
||||||
|
|
||||||
|
err := skillsRemoveFromWorkspace(
|
||||||
|
workspace,
|
||||||
|
config.DefaultConfig().Tools.Skills,
|
||||||
|
"https://github.com/foo/bar/tree/main/.agents/skills/pr-review",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, statErr := os.Stat(targetDir)
|
||||||
|
assert.True(t, os.IsNotExist(statErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillsRemoveFromWorkspaceSupportsRepoRootGitHubBlobURL(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
targetDir := filepath.Join(workspace, "skills", "bar")
|
||||||
|
require.NoError(t, os.MkdirAll(targetDir, 0o755))
|
||||||
|
|
||||||
|
err := skillsRemoveFromWorkspace(
|
||||||
|
workspace,
|
||||||
|
config.DefaultConfig().Tools.Skills,
|
||||||
|
"https://github.com/foo/bar/blob/feature/skills-registry/SKILL.md",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, statErr := os.Stat(targetDir)
|
||||||
|
assert.True(t, os.IsNotExist(statErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillsRemoveFromWorkspaceSupportsGitHubEnterpriseURL(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
targetDir := filepath.Join(workspace, "skills", "pr-review")
|
||||||
|
require.NoError(t, os.MkdirAll(targetDir, 0o755))
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
|
||||||
|
require.True(t, ok)
|
||||||
|
githubRegistry.BaseURL = "https://ghe.example.com/git"
|
||||||
|
cfg.Tools.Skills.Registries.Set("github", githubRegistry)
|
||||||
|
|
||||||
|
err := skillsRemoveFromWorkspace(
|
||||||
|
workspace,
|
||||||
|
cfg.Tools.Skills,
|
||||||
|
"https://ghe.example.com/git/foo/bar/tree/main/.agents/skills/pr-review",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, statErr := os.Stat(targetDir)
|
||||||
|
assert.True(t, os.IsNotExist(statErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillsRemoveFromWorkspaceDoesNotRequireEnabledGitHubRegistry(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
targetDir := filepath.Join(workspace, "skills", "pr-review")
|
||||||
|
require.NoError(t, os.MkdirAll(targetDir, 0o755))
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
|
||||||
|
require.True(t, ok)
|
||||||
|
githubRegistry.Enabled = false
|
||||||
|
cfg.Tools.Skills.Registries.Set("github", githubRegistry)
|
||||||
|
|
||||||
|
err := skillsRemoveFromWorkspace(
|
||||||
|
workspace,
|
||||||
|
cfg.Tools.Skills,
|
||||||
|
"https://github.com/foo/bar/tree/main/.agents/skills/pr-review",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, statErr := os.Stat(targetDir)
|
||||||
|
assert.True(t, os.IsNotExist(statErr))
|
||||||
|
}
|
||||||
@@ -6,15 +6,14 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newInstallCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
|
func newInstallCommand() *cobra.Command {
|
||||||
var registry string
|
var registry string
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "install",
|
Use: "install",
|
||||||
Short: "Install skill from GitHub",
|
Short: "Install skill from GitHub or a registry",
|
||||||
Example: `
|
Example: `
|
||||||
picoclaw skills install sipeed/picoclaw-skills/weather
|
picoclaw skills install sipeed/picoclaw-skills/weather
|
||||||
picoclaw skills install --registry clawhub github
|
picoclaw skills install --registry clawhub github
|
||||||
@@ -34,21 +33,15 @@ picoclaw skills install --registry clawhub github
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
RunE: func(_ *cobra.Command, args []string) error {
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
installer, err := installerFn()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if registry != "" {
|
|
||||||
cfg, err := internal.LoadConfig()
|
cfg, err := internal.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if registry != "" {
|
||||||
return skillsInstallFromRegistry(cfg, registry, args[0])
|
return skillsInstallFromRegistry(cfg, registry, args[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
return skillsInstallCmd(installer, args[0])
|
return skillsInstallFromRegistry(cfg, "github", args[0])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestNewInstallSubcommand(t *testing.T) {
|
func TestNewInstallSubcommand(t *testing.T) {
|
||||||
cmd := newInstallCommand(nil)
|
cmd := newInstallCommand()
|
||||||
|
|
||||||
require.NotNil(t, cmd)
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
assert.Equal(t, "install", cmd.Use)
|
assert.Equal(t, "install", cmd.Use)
|
||||||
assert.Equal(t, "Install skill from GitHub", cmd.Short)
|
assert.Equal(t, "Install skill from GitHub or a registry", cmd.Short)
|
||||||
|
|
||||||
assert.Nil(t, cmd.Run)
|
assert.Nil(t, cmd.Run)
|
||||||
assert.NotNil(t, cmd.RunE)
|
assert.NotNil(t, cmd.RunE)
|
||||||
@@ -79,7 +79,7 @@ func TestInstallCommandArgs(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
cmd := newInstallCommand(nil)
|
cmd := newInstallCommand()
|
||||||
|
|
||||||
if tt.registry != "" {
|
if tt.registry != "" {
|
||||||
require.NoError(t, cmd.Flags().Set("registry", tt.registry))
|
require.NoError(t, cmd.Flags().Set("registry", tt.registry))
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ package skills
|
|||||||
import (
|
import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newRemoveCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
|
func newRemoveCommand() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "remove",
|
Use: "remove",
|
||||||
Aliases: []string{"rm", "uninstall"},
|
Aliases: []string{"rm", "uninstall"},
|
||||||
@@ -14,12 +14,11 @@ func newRemoveCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra
|
|||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
Example: `picoclaw skills remove weather`,
|
Example: `picoclaw skills remove weather`,
|
||||||
RunE: func(_ *cobra.Command, args []string) error {
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
installer, err := installerFn()
|
cfg, err := internal.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
skillsRemoveCmd(installer, args[0])
|
return skillsRemoveFromWorkspace(cfg.WorkspacePath(), cfg.Tools.Skills, args[0])
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestNewRemoveSubcommand(t *testing.T) {
|
func TestNewRemoveSubcommand(t *testing.T) {
|
||||||
cmd := newRemoveCommand(nil)
|
cmd := newRemoveCommand()
|
||||||
|
|
||||||
require.NotNil(t, cmd)
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
|||||||