Compare commits
511 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 | |||
| ae021ef843 | |||
| d805e12a60 | |||
| 6b9ceaa08f | |||
| f81269e77f | |||
| 8f56cceb07 | |||
| e4b104c0de | |||
| efbe806913 | |||
| 7970e2da15 | |||
| ba1538f31d | |||
| 2a28198d0f | |||
| 0276554d9c | |||
| d08bb02f8f | |||
| 26fa98c359 |
@@ -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
|
||||
.gitignore
|
||||
build/
|
||||
@@ -6,5 +8,4 @@ config/
|
||||
.env
|
||||
.env.example
|
||||
*.md
|
||||
LICENSE
|
||||
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:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build
|
||||
- name: Build core binaries
|
||||
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,13 +47,18 @@ jobs:
|
||||
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
|
||||
|
||||
- name: Setup pnpm
|
||||
run: corepack enable && corepack prepare pnpm@latest --activate
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
@@ -75,6 +80,9 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Install zip
|
||||
run: sudo apt-get install -y zip
|
||||
|
||||
- name: Create local tag for GoReleaser
|
||||
run: git tag "${{ steps.version.outputs.version }}"
|
||||
|
||||
@@ -90,6 +98,7 @@ jobs:
|
||||
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
||||
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
||||
GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }}
|
||||
INCLUDE_ANDROID_BUNDLE: "true"
|
||||
NIGHTLY_BUILD: "true"
|
||||
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
||||
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
||||
@@ -123,7 +132,7 @@ jobs:
|
||||
|
||||
# Collect release artifacts from goreleaser dist/
|
||||
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")
|
||||
done
|
||||
|
||||
@@ -135,4 +144,3 @@ jobs:
|
||||
--prerelease \
|
||||
--latest=false \
|
||||
"${ASSETS[@]}"
|
||||
|
||||
|
||||
@@ -41,10 +41,11 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install govulncheck
|
||||
run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4
|
||||
|
||||
- name: Run Govulncheck
|
||||
uses: golang/govulncheck-action@v1
|
||||
with:
|
||||
go-package: ./...
|
||||
run: govulncheck -C . -format text ./...
|
||||
|
||||
test:
|
||||
name: Tests
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
name: Create Tag and Release
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag (required, e.g. v0.2.0)"
|
||||
description: "Existing tag to release (e.g. v0.2.0)"
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
@@ -24,35 +24,23 @@ on:
|
||||
default: true
|
||||
|
||||
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:
|
||||
name: GoReleaser Release
|
||||
needs: create-tag
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
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
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
@@ -65,13 +53,18 @@ jobs:
|
||||
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
|
||||
|
||||
- name: Setup pnpm
|
||||
run: corepack enable && corepack prepare pnpm@latest --activate
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
@@ -93,6 +86,9 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Install zip
|
||||
run: sudo apt-get install -y zip
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
@@ -104,6 +100,7 @@ jobs:
|
||||
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
||||
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
||||
INCLUDE_ANDROID_BUNDLE: "true"
|
||||
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
||||
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
||||
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)
|
||||
.env
|
||||
config/config.json
|
||||
.security.yml
|
||||
onboard
|
||||
|
||||
|
||||
# Test
|
||||
coverage.txt
|
||||
@@ -52,6 +55,10 @@ dist/
|
||||
|
||||
# Windows Application Icon/Resource
|
||||
*.syso
|
||||
.cache/
|
||||
web/frontend/.pnpm-store/
|
||||
_tmp_*
|
||||
web/frontend/_tmp_*
|
||||
|
||||
# Test telegram integration
|
||||
cmd/telegram/
|
||||
@@ -64,3 +71,5 @@ web/backend/dist/*
|
||||
.claude/
|
||||
|
||||
docker/data
|
||||
|
||||
.omc/
|
||||
|
||||
@@ -12,6 +12,7 @@ linters:
|
||||
- exhaustruct
|
||||
- funcorder
|
||||
- gochecknoglobals
|
||||
- gosmopolitan # Project legitimately uses CJK text in tests (FTS5, token counting)
|
||||
- godot
|
||||
- intrange
|
||||
- ireturn
|
||||
@@ -61,6 +62,9 @@ linters:
|
||||
- usestdlibvars
|
||||
- usetesting
|
||||
settings:
|
||||
gomoddirectives:
|
||||
replace-allow-list:
|
||||
- github.com/bwmarrin/discordgo
|
||||
errcheck:
|
||||
check-type-assertions: true
|
||||
check-blank: true
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
|
||||
version: 2
|
||||
|
||||
git:
|
||||
ignore_tags:
|
||||
- nightly
|
||||
- ".*-nightly.*"
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- go generate ./...
|
||||
- sh -c 'cd web/frontend && pnpm install && pnpm build:backend'
|
||||
- go install github.com/tc-hib/go-winres@latest
|
||||
- go-winres make --in web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }}
|
||||
- sh -c 'cd web/frontend && CI=true pnpm install --frozen-lockfile && pnpm build:backend'
|
||||
- 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 }}'
|
||||
- sh -c 'if [ "${INCLUDE_ANDROID_BUNDLE:-}" = "true" ]; then make build-android-bundle; fi'
|
||||
|
||||
builds:
|
||||
- id: picoclaw
|
||||
@@ -22,7 +26,7 @@ builds:
|
||||
- -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={{ .Env.GOVERSION }}
|
||||
- -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }}
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
@@ -62,6 +66,10 @@ builds:
|
||||
- stdjson
|
||||
ldflags:
|
||||
- -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:
|
||||
- linux
|
||||
- windows
|
||||
@@ -92,45 +100,6 @@ builds:
|
||||
- goos: netbsd
|
||||
goarch: arm
|
||||
|
||||
- id: picoclaw-launcher-tui
|
||||
binary: picoclaw-launcher-tui
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
tags:
|
||||
- goolm
|
||||
- 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:
|
||||
- id: picoclaw
|
||||
dockerfile: docker/Dockerfile.goreleaser
|
||||
@@ -154,7 +123,6 @@ dockers_v2:
|
||||
ids:
|
||||
- picoclaw
|
||||
- picoclaw-launcher
|
||||
- picoclaw-launcher-tui
|
||||
images:
|
||||
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
|
||||
- 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
|
||||
@@ -172,7 +140,6 @@ notarize:
|
||||
ids:
|
||||
- picoclaw
|
||||
- picoclaw-launcher
|
||||
- picoclaw-launcher-tui
|
||||
sign:
|
||||
certificate: "{{.Env.MACOS_SIGN_P12}}"
|
||||
password: "{{.Env.MACOS_SIGN_PASSWORD}}"
|
||||
@@ -203,7 +170,6 @@ nfpms:
|
||||
ids:
|
||||
- picoclaw
|
||||
- picoclaw-launcher
|
||||
- picoclaw-launcher-tui
|
||||
package_name: picoclaw
|
||||
file_name_template: >-
|
||||
{{ .PackageName }}_
|
||||
@@ -240,6 +206,8 @@ changelog:
|
||||
|
||||
release:
|
||||
disable: '{{ isEnvSet "NIGHTLY_BUILD" }}'
|
||||
extra_files:
|
||||
- glob: ./build/picoclaw-android-universal.zip
|
||||
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 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
|
||||
@@ -64,7 +66,7 @@ For substantial new features, please open an issue first to discuss the design b
|
||||
```bash
|
||||
make build # Build binary (runs go generate first)
|
||||
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
|
||||
@@ -81,9 +83,10 @@ go test -bench=. -benchmem -run='^$' ./... # Run benchmarks
|
||||
make fmt # Format code
|
||||
make vet # Static analysis
|
||||
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)`.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -1,24 +1,49 @@
|
||||
.PHONY: all build install uninstall clean help test
|
||||
.PHONY: all build install uninstall clean help test build-all lint-docs
|
||||
|
||||
# Build variables
|
||||
BINARY_NAME=picoclaw
|
||||
BUILD_DIR=build
|
||||
CMD_DIR=cmd/$(BINARY_NAME)
|
||||
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?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
|
||||
BUILD_TIME=$(shell date +%FT%T%z)
|
||||
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
|
||||
ifeq ($(OS),Windows_NT)
|
||||
VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>NUL))
|
||||
GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>NUL))
|
||||
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
|
||||
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?=CGO_ENABLED=0 go
|
||||
GO?=go
|
||||
WEB_GO?=$(GO)
|
||||
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)
|
||||
@@ -47,6 +72,13 @@ define PATCH_MIPS_FLAGS
|
||||
fi
|
||||
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
|
||||
|
||||
@@ -62,9 +94,24 @@ WORKSPACE_DIR?=$(PICOCLAW_HOME)/workspace
|
||||
WORKSPACE_SKILLS_DIR=$(WORKSPACE_DIR)/skills
|
||||
BUILTIN_SKILLS_DIR=$(CURDIR)/skills
|
||||
|
||||
LNCMD=ln -sf
|
||||
|
||||
# OS detection
|
||||
UNAME_S:=$(shell uname -s)
|
||||
UNAME_M:=$(shell uname -m)
|
||||
ifeq ($(OS),Windows_NT)
|
||||
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
|
||||
ifeq ($(UNAME_S),Linux)
|
||||
@@ -86,17 +133,54 @@ ifeq ($(UNAME_S),Linux)
|
||||
endif
|
||||
else ifeq ($(UNAME_S),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)
|
||||
ARCH=amd64
|
||||
ARCH?=amd64
|
||||
else ifeq ($(UNAME_M),arm64)
|
||||
ARCH=arm64
|
||||
ARCH?=arm64
|
||||
else
|
||||
ARCH=$(UNAME_M)
|
||||
ARCH?=$(UNAME_M)
|
||||
endif
|
||||
else
|
||||
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
|
||||
|
||||
BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH)
|
||||
@@ -107,37 +191,50 @@ all: build
|
||||
## generate: Run generate
|
||||
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
|
||||
@$(GO) generate ./...
|
||||
@GOOS=$$($(GO) env GOHOSTOS) GOARCH=$$($(GO) env GOHOSTARCH) $(GO) generate ./...
|
||||
endif
|
||||
@echo "Run generate complete"
|
||||
|
||||
## build: Build the picoclaw binary for current platform
|
||||
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)
|
||||
@$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||
@echo "Build complete: $(BINARY_PATH)"
|
||||
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||
@GOOS=$(PLATFORM) GOARCH=$(ARCH) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH)$(EXT) ./$(CMD_DIR)
|
||||
@echo "Build complete: $(BINARY_PATH)$(EXT)"
|
||||
@$(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:
|
||||
@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)
|
||||
@if [ ! -f web/backend/dist/index.html ]; then \
|
||||
echo "Building frontend..."; \
|
||||
cd web/frontend && pnpm install && pnpm build:backend; \
|
||||
fi
|
||||
@$(WEB_GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH) ./web/backend
|
||||
@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher
|
||||
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher"
|
||||
@GOOS=$(PLATFORM) GOARCH=$(ARCH) $(MAKE) -C web build \
|
||||
OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)" \
|
||||
WEB_GO='$(WEB_GO)' \
|
||||
GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \
|
||||
LDFLAGS='$(LDFLAGS)'
|
||||
@$(LNCMD) picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT) $(BUILD_DIR)/picoclaw-launcher$(EXT)
|
||||
endif
|
||||
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher$(EXT)"
|
||||
|
||||
## build-launcher-tui: Build the picoclaw-launcher TUI binary
|
||||
build-launcher-tui:
|
||||
@echo "Building picoclaw-launcher-tui for $(PLATFORM)/$(ARCH)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) ./cmd/picoclaw-launcher-tui
|
||||
@ln -sf picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher-tui
|
||||
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-tui"
|
||||
build-launcher-frontend:
|
||||
@$(MAKE) -C web build-frontend
|
||||
|
||||
## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
|
||||
build-whatsapp-native: generate
|
||||
@@ -179,17 +276,51 @@ build-linux-mipsle: generate
|
||||
$(call PATCH_MIPS_FLAGS,$(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-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)"
|
||||
|
||||
## build-all: Build picoclaw for all platforms
|
||||
## build-all: Build the picoclaw core binary for all Makefile-managed platforms
|
||||
build-all: generate
|
||||
@echo "Building for multiple platforms..."
|
||||
@mkdir -p $(BUILD_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 $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||
@$(PTY_PATCH_LOONG64)
|
||||
GOOS=linux GOARCH=loong64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(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)
|
||||
@@ -199,7 +330,7 @@ build-all: generate
|
||||
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 $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(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: build
|
||||
@@ -230,7 +361,11 @@ uninstall-all:
|
||||
## clean: Remove build artifacts
|
||||
clean:
|
||||
@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)
|
||||
endif
|
||||
@echo "Clean complete"
|
||||
|
||||
## vet: Run go vet for static analysis
|
||||
@@ -248,9 +383,14 @@ test: generate
|
||||
fmt:
|
||||
@$(GOLANGCI_LINT) fmt
|
||||
|
||||
## lint-docs: Check common documentation layout and naming conventions
|
||||
lint-docs:
|
||||
@./scripts/lint-docs.sh
|
||||
|
||||
## lint: Run linters
|
||||
lint:
|
||||
@$(GOLANGCI_LINT) run --build-tags $(GO_BUILD_TAGS)
|
||||
@./scripts/lint-docs.sh
|
||||
|
||||
## fix: Fix linting issues
|
||||
fix:
|
||||
@@ -266,8 +406,8 @@ update-deps:
|
||||
@$(GO) get -u ./...
|
||||
@$(GO) mod tidy
|
||||
|
||||
## check: Run vet, fmt, and verify dependencies
|
||||
check: deps fmt vet test
|
||||
## check: Run deps, fmt, vet, tests, and docs consistency checks
|
||||
check: deps fmt vet test lint-docs
|
||||
|
||||
## run: Build and run picoclaw
|
||||
run: build
|
||||
@@ -313,16 +453,34 @@ docker-clean:
|
||||
|
||||
|
||||
## build-macos-app: Build PicoClaw macOS .app bundle (no terminal window)
|
||||
build-macos-app:
|
||||
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
|
||||
@cd web && $(MAKE) build && cd ..
|
||||
@./scripts/build-macos-app.sh $(BINARY_NAME)-$(PLATFORM)-$(ARCH)
|
||||
@./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:
|
||||
@echo "picoclaw Makefile"
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<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.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **English**
|
||||
[中文](docs/project/README.zh.md) | [日本語](docs/project/README.ja.md) | [한국어](docs/project/README.ko.md) | [Português](docs/project/README.pt-br.md) | [Tiếng Việt](docs/project/README.vi.md) | [Français](docs/project/README.fr.md) | [Italiano](docs/project/README.it.md) | [Bahasa Indonesia](docs/project/README.id.md) | [Malay](docs/project/README.ms.md) | **English**
|
||||
|
||||
</div>
|
||||
|
||||
@@ -56,17 +56,21 @@
|
||||
|
||||
## 📢 News
|
||||
|
||||
2026-03-31 📱 **Android Support!** PicoClaw now runs on Android! Download the APK at [picoclaw.io](https://picoclaw.io/download)
|
||||
|
||||
2026-03-25 🚀 **v0.2.4 Released!** Agent architecture overhaul (SubTurn, Hooks, Steering, EventBus), WeChat/WeCom integration, security hardening (.security.yml, sensitive data filtering), new providers (AWS Bedrock, Azure, Xiaomi MiMo), and 35 bug fixes. PicoClaw has reached **26K Stars**!
|
||||
|
||||
2026-03-17 🚀 **v0.2.3 Released!** System tray UI (Windows & Linux), sub-agent status query (`spawn_status`), experimental Gateway hot-reload, Cron security gating, and 2 security fixes. PicoClaw has reached **25K Stars**!
|
||||
|
||||
2026-03-09 🎉 **v0.2.1 — Biggest update yet!** MCP protocol support, 4 new channels (Matrix/IRC/WeCom/Discord Proxy), 3 new providers (Kimi/Minimax/Avian), vision pipeline, JSONL memory store, model routing.
|
||||
|
||||
2026-02-28 📦 **v0.2.0** released with Docker Compose and Web UI Launcher support.
|
||||
|
||||
2026-02-26 🎉 PicoClaw hits **20K Stars** in just 17 days! Channel auto-orchestration and capability interfaces are live.
|
||||
|
||||
<details>
|
||||
<summary>Earlier news...</summary>
|
||||
|
||||
2026-02-26 🎉 PicoClaw hits **20K Stars** in just 17 days! Channel auto-orchestration and capability interfaces are live.
|
||||
|
||||
2026-02-16 🎉 PicoClaw breaks 12K Stars in one week! Community maintainer roles and [Roadmap](ROADMAP.md) officially launched.
|
||||
|
||||
2026-02-13 🎉 PicoClaw breaks 5000 Stars in 4 days! Project roadmap and developer groups in progress.
|
||||
@@ -108,7 +112,7 @@ _*Recent builds may use 10-20MB due to rapid PR merges. Resource optimization is
|
||||
|
||||
</div>
|
||||
|
||||
> **[Hardware Compatibility List](docs/hardware-compatibility.md)** — See all tested boards, from $5 RISC-V to Raspberry Pi to Android phones. Your board not listed? Submit a PR!
|
||||
> **[Hardware Compatibility List](docs/guides/hardware-compatibility.md)** — See all tested boards, from $5 RISC-V to Raspberry Pi to Android phones. Your board not listed? Submit a PR!
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
||||
@@ -160,22 +164,32 @@ Alternatively, download the binary for your platform from the [GitHub Releases](
|
||||
|
||||
### Build from source (for development)
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Go 1.25+
|
||||
- Node.js 22+ and pnpm 10.33.0+ for Web UI / launcher builds
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# Build core binary
|
||||
# Install frontend dependencies
|
||||
(cd web/frontend && pnpm install --frozen-lockfile)
|
||||
|
||||
# Build the core binary for the current platform
|
||||
make build
|
||||
|
||||
# Build Web UI Launcher (required for WebUI mode)
|
||||
# Build the Web UI Launcher (required for WebUI mode)
|
||||
make build-launcher
|
||||
|
||||
# Build for multiple platforms
|
||||
# Build core binaries for all Makefile-managed platforms
|
||||
make build-all
|
||||
|
||||
# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||
# Build for Raspberry Pi Zero 2 W
|
||||
# 32-bit: make build-linux-arm
|
||||
# 64-bit: make build-linux-arm64
|
||||
make build-pi-zero
|
||||
|
||||
# Build and install
|
||||
@@ -211,7 +225,7 @@ picoclaw-launcher
|
||||
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Getting started:**
|
||||
**Getting started:**
|
||||
|
||||
Open the WebUI, then: **1)** Configure a Provider (add your LLM API key) -> **2)** Configure a Channel (e.g., Telegram) -> **3)** Start the Gateway -> **4)** Chat!
|
||||
|
||||
@@ -254,29 +268,53 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
|
||||
</details>
|
||||
|
||||
### 💻 TUI Launcher (Recommended for Headless / SSH)
|
||||
<details>
|
||||
<summary><b>macOS — First Launch Security Warning</b></summary>
|
||||
|
||||
The TUI (Terminal UI) Launcher provides a full-featured terminal interface for configuration and management. Ideal for servers, Raspberry Pi, and other headless environments.
|
||||
macOS may block `picoclaw-launcher` on first launch because it is downloaded from the internet and not notarized through the Mac App Store.
|
||||
|
||||
```bash
|
||||
picoclaw-launcher-tui
|
||||
```
|
||||
**Step 1:** Double-click `picoclaw-launcher`. You will see a security warning:
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||
<img src="assets/macos-gatekeeper-warning.jpg" alt="macOS Gatekeeper warning" width="400">
|
||||
</p>
|
||||
|
||||
**Getting started:**
|
||||
> *"picoclaw-launcher" Not Opened — Apple could not verify "picoclaw-launcher" is free of malware that may harm your Mac or compromise your privacy.*
|
||||
|
||||
Use the TUI menus to: **1)** Configure a Provider -> **2)** Configure a Channel -> **3)** Start the Gateway -> **4)** Chat!
|
||||
**Step 2:** Open **System Settings** → **Privacy & Security** → scroll down to the **Security** section → click **Open Anyway** → confirm by clicking **Open Anyway** in the dialog.
|
||||
|
||||
For detailed TUI documentation, see [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
<p align="center">
|
||||
<img src="assets/macos-gatekeeper-allow.jpg" alt="macOS Privacy & Security — Open Anyway" width="600">
|
||||
</p>
|
||||
|
||||
After this one-time step, `picoclaw-launcher` will open normally on subsequent launches.
|
||||
|
||||
</details>
|
||||
|
||||
<a id="-run-on-old-android-phones"></a>
|
||||
### 📱 Android
|
||||
|
||||
Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw.
|
||||
|
||||
**Option 1: Termux (available now)**
|
||||
**Option 1: APK Install**
|
||||
|
||||
Preview:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="assets/fui_main_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_web_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_log_page.jpg" width="200"></td>
|
||||
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
Download the APK from [picoclaw.io](https://picoclaw.io/download/) and install directly. No Termux required!
|
||||
|
||||
**Option 2: Termux**
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (for resource-constrained environments)</b></summary>
|
||||
|
||||
1. Install [Termux](https://github.com/termux/termux-app) (download from [GitHub Releases](https://github.com/termux/termux-app/releases), or search in F-Droid / Google Play)
|
||||
2. Run the following commands:
|
||||
@@ -293,13 +331,6 @@ Then follow the Terminal Launcher section below to complete configuration.
|
||||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||
|
||||
**Option 2: APK Install (coming soon)**
|
||||
|
||||
A standalone Android APK with built-in WebUI is in development. Stay tuned!
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (for resource-constrained environments)</b></summary>
|
||||
|
||||
For minimal environments where only the `picoclaw` core binary is available (no Launcher UI), you can configure everything via the command line and a JSON config file.
|
||||
|
||||
**1. Initialize**
|
||||
@@ -330,8 +361,8 @@ This creates `~/.picoclaw/config.json` and the workspace directory.
|
||||
```
|
||||
|
||||
> See `config/config.example.json` in the repo for a complete configuration template with all available options.
|
||||
>
|
||||
> Please note: config.example.json format is version 0, with sensitive codes in it, and will be auto migrated to version 1+, then, the config.json will only store insensitive data, the sensitive codes will be stored in .security.yml, if you need manually modify the codes, please see `docs/security_configuration.md` for more details.
|
||||
>
|
||||
> Please note: config.example.json format is version 0, with sensitive codes in it, and will be auto migrated to version 1+, then, the config.json will only store insensitive data, the sensitive codes will be stored in .security.yml, if you need manually modify the codes, please see `docs/security/security_configuration.md` for more details.
|
||||
|
||||
|
||||
**3. Chat**
|
||||
@@ -370,6 +401,7 @@ PicoClaw supports 30+ LLM providers through the `model_list` configuration. Use
|
||||
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Required | NVIDIA hosted models |
|
||||
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Required | Fast inference |
|
||||
| [Novita AI](https://novita.ai/) | `novita/` | Required | Various open models |
|
||||
| [Xiaomi MiMo](https://platform.xiaomimimo.com/) | `mimo/` | Required | MiMo models |
|
||||
| [Ollama](https://ollama.com/) | `ollama/` | Not needed | Local models, self-hosted |
|
||||
| [vLLM](https://docs.vllm.ai/) | `vllm/` | Not needed | Local deployment, OpenAI-compatible |
|
||||
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Varies | Proxy for 100+ providers |
|
||||
@@ -409,30 +441,29 @@ PicoClaw supports 30+ LLM providers through the `model_list` configuration. Use
|
||||
}
|
||||
```
|
||||
|
||||
For full provider configuration details, see [Providers & Models](docs/providers.md).
|
||||
For full provider configuration details, see [Providers & Models](docs/guides/providers.md).
|
||||
|
||||
</details>
|
||||
|
||||
## 💬 Channels (Chat Apps)
|
||||
|
||||
Talk to your PicoClaw through 17+ messaging platforms:
|
||||
Talk to your PicoClaw through 18+ messaging platforms:
|
||||
|
||||
| Channel | Setup | Protocol | Docs |
|
||||
|---------|-------|----------|------|
|
||||
| **Telegram** | Easy (bot token) | Long polling | [Guide](docs/channels/telegram/README.md) |
|
||||
| **Discord** | Easy (bot token + intents) | WebSocket | [Guide](docs/channels/discord/README.md) |
|
||||
| **WhatsApp** | Easy (QR scan or bridge URL) | Native / Bridge | [Guide](docs/chat-apps.md#whatsapp) |
|
||||
| **Weixin** | Easy (Native QR scan) | iLink API | [Guide](docs/chat-apps.md#weixin) |
|
||||
| **WhatsApp** | Easy (QR scan or bridge URL) | Native / Bridge | [Guide](docs/guides/chat-apps.md#whatsapp) |
|
||||
| **Weixin** | Easy (Native QR scan) | iLink API | [Guide](docs/guides/chat-apps.md#weixin) |
|
||||
| **QQ** | Easy (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.md) |
|
||||
| **Slack** | Easy (bot + app token) | Socket Mode | [Guide](docs/channels/slack/README.md) |
|
||||
| **Matrix** | Medium (homeserver + token) | Sync API | [Guide](docs/channels/matrix/README.md) |
|
||||
| **DingTalk** | Medium (client credentials) | Stream | [Guide](docs/channels/dingtalk/README.md) |
|
||||
| **Feishu / Lark** | Medium (App ID + Secret) | WebSocket/SDK | [Guide](docs/channels/feishu/README.md) |
|
||||
| **LINE** | Medium (credentials + webhook) | Webhook | [Guide](docs/channels/line/README.md) |
|
||||
| **WeCom Bot** | Medium (webhook URL) | Webhook | [Guide](docs/channels/wecom/wecom_bot/README.md) |
|
||||
| **WeCom App** | Medium (corp credentials) | Webhook | [Guide](docs/channels/wecom/wecom_app/README.md) |
|
||||
| **WeCom AI Bot** | Medium (token + AES key) | WebSocket / Webhook | [Guide](docs/channels/wecom/wecom_aibot/README.md) |
|
||||
| **IRC** | Medium (server + nick) | IRC protocol | [Guide](docs/chat-apps.md#irc) |
|
||||
| **WeCom** | Easy (QR login or manual) | WebSocket | [Guide](docs/channels/wecom/README.md) |
|
||||
| **VK** | Easy (group token) | Long Poll | [Guide](docs/channels/vk/README.md) |
|
||||
| **IRC** | Medium (server + nick) | IRC protocol | [Guide](docs/guides/chat-apps.md#irc) |
|
||||
| **OneBot** | Medium (WebSocket URL) | OneBot v11 | [Guide](docs/channels/onebot/README.md) |
|
||||
| **MaixCam** | Easy (enable) | TCP socket | [Guide](docs/channels/maixcam/README.md) |
|
||||
| **Pico** | Easy (enable) | Native protocol | Built-in |
|
||||
@@ -440,7 +471,9 @@ Talk to your PicoClaw through 17+ messaging platforms:
|
||||
|
||||
> All webhook-based channels share a single Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu uses WebSocket/SDK mode and does not use the shared HTTP server.
|
||||
|
||||
For detailed channel setup instructions, see [Chat Apps Configuration](docs/chat-apps.md).
|
||||
> Log verbosity is controlled by `gateway.log_level` (default: `warn`). Supported values: `debug`, `info`, `warn`, `error`, `fatal`. Can also be set via `PICOCLAW_LOG_LEVEL`. See [Configuration](docs/guides/configuration.md#gateway-log-level) for details.
|
||||
|
||||
For detailed channel setup instructions, see [Chat Apps Configuration](docs/guides/chat-apps.md).
|
||||
|
||||
## 🔧 Tools
|
||||
|
||||
@@ -460,7 +493,7 @@ PicoClaw can search the web to provide up-to-date information. Configure in `too
|
||||
|
||||
### ⚙️ Other Tools
|
||||
|
||||
PicoClaw includes built-in tools for file operations, code execution, scheduling, and more. See [Tools Configuration](docs/tools_configuration.md) for details.
|
||||
PicoClaw includes built-in tools for file operations, code execution, scheduling, and more. See [Tools Configuration](docs/reference/tools_configuration.md) for details.
|
||||
|
||||
## 🎯 Skills
|
||||
|
||||
@@ -473,7 +506,7 @@ picoclaw skills search "web scraping"
|
||||
picoclaw skills install <skill-name>
|
||||
```
|
||||
|
||||
**Configure ClawHub token** (optional, for higher rate limits):
|
||||
**Configure skill registries**:
|
||||
|
||||
Add to your `config.json`:
|
||||
```json
|
||||
@@ -483,6 +516,11 @@ Add to your `config.json`:
|
||||
"registries": {
|
||||
"clawhub": {
|
||||
"auth_token": "your-clawhub-token"
|
||||
},
|
||||
"github": {
|
||||
"base_url": "https://github.com",
|
||||
"auth_token": "your-github-token",
|
||||
"proxy": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -490,7 +528,9 @@ Add to your `config.json`:
|
||||
}
|
||||
```
|
||||
|
||||
For more details, see [Tools Configuration - Skills](docs/tools_configuration.md#skills-tool).
|
||||
`tools.skills.github.*` is deprecated. Use `tools.skills.registries.github.*` instead.
|
||||
|
||||
For more details, see [Tools Configuration - Skills](docs/reference/tools_configuration.md#skills-tool).
|
||||
|
||||
## 🔗 MCP (Model Context Protocol)
|
||||
|
||||
@@ -513,7 +553,20 @@ PicoClaw natively supports [MCP](https://modelcontextprotocol.io/) — connect a
|
||||
}
|
||||
```
|
||||
|
||||
For full MCP configuration (stdio, SSE, HTTP transports, Tool Discovery), see [Tools Configuration - MCP](docs/tools_configuration.md#mcp-tool).
|
||||
You can manage common MCP setups directly from the CLI instead of editing JSON by hand:
|
||||
|
||||
```bash
|
||||
picoclaw mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem /tmp
|
||||
picoclaw mcp list
|
||||
picoclaw mcp test filesystem
|
||||
```
|
||||
|
||||
`picoclaw mcp` is a configuration manager: it updates `config.json` under `tools.mcp.servers`, but it does not keep the server process running itself.
|
||||
|
||||
Use `picoclaw mcp edit` when you need advanced fields that are not covered by `picoclaw mcp add`.
|
||||
For example, `picoclaw mcp add` supports `--deferred` and `--env-file`, while `picoclaw mcp edit` is still useful for direct JSON editing and uncommon MCP settings.
|
||||
|
||||
For full MCP configuration (stdio, SSE, HTTP transports, Tool Discovery), see [Tools Configuration - MCP](docs/reference/tools_configuration.md#mcp-tool). For CLI usage and examples, see [MCP Server CLI](docs/reference/mcp-cli.md).
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Join the Agent Social Network
|
||||
|
||||
@@ -533,6 +586,11 @@ Connect PicoClaw to the Agent Social Network simply by sending a single message
|
||||
| `picoclaw status` | Show status |
|
||||
| `picoclaw version` | Show version info |
|
||||
| `picoclaw model` | View or switch the default model |
|
||||
| `picoclaw mcp list` | List configured MCP servers |
|
||||
| `picoclaw mcp add ...` | Add or update an MCP server entry |
|
||||
| `picoclaw mcp test` | Probe a configured MCP server |
|
||||
| `picoclaw mcp edit` | Open config for advanced MCP editing |
|
||||
| `picoclaw mcp remove` | Remove an MCP server entry |
|
||||
| `picoclaw cron list` | List all scheduled jobs |
|
||||
| `picoclaw cron add ...` | Add a scheduled job |
|
||||
| `picoclaw cron disable` | Disable a scheduled job |
|
||||
@@ -550,23 +608,27 @@ PicoClaw supports scheduled reminders and recurring tasks through the `cron` too
|
||||
* **Recurring tasks**: "Remind me every 2 hours" -> triggers every 2 hours
|
||||
* **Cron expressions**: "Remind me at 9am daily" -> uses cron expression
|
||||
|
||||
See [docs/reference/cron.md](docs/reference/cron.md) for current schedule types, execution modes, command-job gates, and persistence details.
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
For detailed guides beyond this README:
|
||||
|
||||
| Topic | Description |
|
||||
|-------|-------------|
|
||||
| [Docker & Quick Start](docs/docker.md) | Docker Compose setup, Launcher/Agent modes |
|
||||
| [Chat Apps](docs/chat-apps.md) | All 17+ channel setup guides |
|
||||
| [Configuration](docs/configuration.md) | Environment variables, workspace layout, security sandbox |
|
||||
| [Providers & Models](docs/providers.md) | 30+ LLM providers, model routing, model_list configuration |
|
||||
| [Spawn & Async Tasks](docs/spawn-tasks.md) | Quick tasks, long tasks with spawn, async sub-agent orchestration |
|
||||
| [Hooks](docs/hooks/README.md) | Event-driven hook system: observers, interceptors, approval hooks |
|
||||
| [Steering](docs/steering.md) | Inject messages into a running agent loop between tool calls |
|
||||
| [SubTurn](docs/subturn.md) | Subagent coordination, concurrency control, lifecycle |
|
||||
| [Troubleshooting](docs/troubleshooting.md) | Common issues and solutions |
|
||||
| [Tools Configuration](docs/tools_configuration.md) | Per-tool enable/disable, exec policies, MCP, Skills |
|
||||
| [Hardware Compatibility](docs/hardware-compatibility.md) | Tested boards, minimum requirements |
|
||||
| [Docker & Quick Start](docs/guides/docker.md) | Docker Compose setup, Launcher/Agent modes |
|
||||
| [Chat Apps](docs/guides/chat-apps.md) | All 17+ channel setup guides |
|
||||
| [Configuration](docs/guides/configuration.md) | Environment variables, workspace layout, security sandbox |
|
||||
| [MCP Server CLI](docs/reference/mcp-cli.md) | Add, list, test, edit, and remove MCP server entries from the CLI |
|
||||
| [Scheduled Tasks and Cron Jobs](docs/reference/cron.md) | Cron schedule types, deliver modes, command gates, job storage |
|
||||
| [Providers & Models](docs/guides/providers.md) | 30+ LLM providers, model routing, model_list configuration |
|
||||
| [Spawn & Async Tasks](docs/guides/spawn-tasks.md) | Quick tasks, long tasks with spawn, async sub-agent orchestration |
|
||||
| [Hooks](docs/architecture/hooks/README.md) | Event-driven hook system: observers, interceptors, approval hooks |
|
||||
| [Steering](docs/architecture/steering.md) | Inject messages into a running agent loop between tool calls |
|
||||
| [SubTurn](docs/architecture/subturn.md) | Subagent coordination, concurrency control, lifecycle |
|
||||
| [Troubleshooting](docs/operations/troubleshooting.md) | Common issues and solutions |
|
||||
| [Tools Configuration](docs/reference/tools_configuration.md) | Per-tool enable/disable, exec policies, MCP, Skills |
|
||||
| [Hardware Compatibility](docs/guides/hardware-compatibility.md) | Tested boards, minimum requirements |
|
||||
|
||||
## 🤝 Contribute & Roadmap
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 449 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 61 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,69 +0,0 @@
|
||||
# Picoclaw Launcher TUI
|
||||
|
||||
This directory contains the terminal-based TUI launcher for `picoclaw`.
|
||||
It provides a lightweight, terminal-native user interface for managing, configuring, and interacting with the core `picoclaw` engine, without requiring a web browser or graphical environment.
|
||||
|
||||
## Architecture
|
||||
|
||||
The TUI launcher is implemented purely in Go with no external runtime dependencies:
|
||||
* **`main.go`**: Application entry point, handles initialization and main event loop
|
||||
* **`ui/`**: TUI interface components built on tview + tcell framework:
|
||||
- `home.go`: Main dashboard with navigation menu
|
||||
- `schemes.go`: AI model scheme management
|
||||
- `users.go`: User and API key management for model providers
|
||||
- `channels.go`: Communication channel (Telegram/Discord/WeChat etc.) configuration editor
|
||||
- `gateway.go`: PicoClaw gateway daemon lifecycle management (start/stop/status)
|
||||
- `app.go`: Core TUI application framework and navigation logic
|
||||
- `models.go`: Data structures and state management
|
||||
* **`config/`**: Configuration management layer, integrates with the core picoclaw configuration system
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
* Go 1.25+
|
||||
* Terminal with 256-color support (most modern terminals are compatible)
|
||||
|
||||
### Development
|
||||
|
||||
Run the TUI launcher directly in development mode:
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
go run ./cmd/picoclaw-launcher-tui
|
||||
|
||||
# Or from this directory
|
||||
go run .
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
Build the standalone TUI launcher binary:
|
||||
|
||||
```bash
|
||||
# From project root (recommended)
|
||||
make build-launcher-tui
|
||||
|
||||
# Output will be at:
|
||||
# build/picoclaw-launcher-tui-<platform>-<arch>
|
||||
# with symlink build/picoclaw-launcher-tui
|
||||
|
||||
# Or build directly from this directory
|
||||
go build -o picoclaw-launcher-tui .
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
* 🖥️ Terminal-native interface - works over SSH, on headless servers, and in low-resource environments
|
||||
* ⚙️ AI model scheme and API key management
|
||||
* 📱 Communication channel configuration editor (Telegram/Discord/WeChat etc.)
|
||||
* 🔄 PicoClaw gateway daemon management (start/stop/status monitoring)
|
||||
* 💬 One-click launch of interactive AI chat session
|
||||
* 🎯 Keyboard-first design with intuitive shortcuts
|
||||
|
||||
### Other Commands
|
||||
|
||||
```bash
|
||||
# Run with custom config file path
|
||||
go run . /path/to/custom/config.json
|
||||
```
|
||||
@@ -1,236 +0,0 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
// Package config provides types and I/O for ~/.picoclaw/tui.toml.
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||
)
|
||||
|
||||
// DefaultConfigPath returns the default path to the tui.toml config file.
|
||||
func DefaultConfigPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
home = "."
|
||||
}
|
||||
return filepath.Join(home, ".picoclaw", "tui.toml")
|
||||
}
|
||||
|
||||
// TUIConfig is the top-level structure of ~/.picoclaw/tui.toml.
|
||||
type TUIConfig struct {
|
||||
Version string `toml:"version"`
|
||||
Model Model `toml:"model"`
|
||||
Provider Provider `toml:"provider"`
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
Type string `toml:"type"` // "provider" (default) | "manual"
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
Schemes []Scheme `toml:"schemes"`
|
||||
Users []User `toml:"users"`
|
||||
Current ProviderCurrent `toml:"current"`
|
||||
}
|
||||
|
||||
type Scheme struct {
|
||||
Name string `toml:"name"` // unique key
|
||||
BaseURL string `toml:"baseURL"` // required
|
||||
Type string `toml:"type"` // "openai-compatible" (default) | "anthropic"
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Name string `toml:"name"`
|
||||
Scheme string `toml:"scheme"` // references Scheme.Name; (Name+Scheme) is unique
|
||||
Type string `toml:"type"` // "key" (default) | "OAuth"
|
||||
Key string `toml:"key"`
|
||||
}
|
||||
|
||||
type ProviderCurrent struct {
|
||||
Scheme string `toml:"scheme"` // references Scheme.Name
|
||||
User string `toml:"user"` // references User.Name where User.Scheme == Scheme
|
||||
Model string `toml:"model"` // from GET <baseURL>/models
|
||||
}
|
||||
|
||||
// DefaultConfig returns a minimal valid TUIConfig.
|
||||
func DefaultConfig() *TUIConfig {
|
||||
return &TUIConfig{
|
||||
Version: "1.0",
|
||||
Model: Model{Type: "provider"},
|
||||
Provider: Provider{
|
||||
Schemes: []Scheme{},
|
||||
Users: []User{},
|
||||
Current: ProviderCurrent{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads the TUI config from path. Returns a default config if the file does not exist.
|
||||
func Load(path string) (*TUIConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return DefaultConfig(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read config file %q: %w", path, err)
|
||||
}
|
||||
|
||||
cfg := DefaultConfig()
|
||||
if _, err := toml.Decode(string(data), cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config file %q: %w", path, err)
|
||||
}
|
||||
|
||||
applyDefaults(cfg)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Save writes cfg to path atomically (safe for flash / SD storage).
|
||||
func Save(path string, cfg *TUIConfig) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
enc := toml.NewEncoder(&buf)
|
||||
if err := enc.Encode(cfg); err != nil {
|
||||
return fmt.Errorf("failed to encode config: %w", err)
|
||||
}
|
||||
if err := fileutil.WriteFileAtomic(path, buf.Bytes(), 0o600); err != nil {
|
||||
return fmt.Errorf("failed to write config file %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyDefaults(cfg *TUIConfig) {
|
||||
if cfg.Version == "" {
|
||||
cfg.Version = "1.0"
|
||||
}
|
||||
if cfg.Model.Type == "" {
|
||||
cfg.Model.Type = "provider"
|
||||
}
|
||||
for i := range cfg.Provider.Schemes {
|
||||
if cfg.Provider.Schemes[i].Type == "" {
|
||||
cfg.Provider.Schemes[i].Type = "openai-compatible"
|
||||
}
|
||||
}
|
||||
for i := range cfg.Provider.Users {
|
||||
if cfg.Provider.Users[i].Type == "" {
|
||||
cfg.Provider.Users[i].Type = "key"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SchemeByName returns the first Scheme whose Name matches, or nil.
|
||||
func (p *Provider) SchemeByName(name string) *Scheme {
|
||||
for i := range p.Schemes {
|
||||
if p.Schemes[i].Name == name {
|
||||
return &p.Schemes[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UsersForScheme returns all users whose Scheme field matches schemeName.
|
||||
func (p *Provider) UsersForScheme(schemeName string) []User {
|
||||
var out []User
|
||||
for _, u := range p.Users {
|
||||
if u.Scheme == schemeName {
|
||||
out = append(out, u)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SyncSelectedModelToMainConfig syncs the currently selected model to ~/.picoclaw/config.json
|
||||
// Adds/replaces a "tui-prefer" model entry and sets it as the default model.
|
||||
// Preserves all other existing fields in the config file unchanged.
|
||||
func SyncSelectedModelToMainConfig(scheme Scheme, user User, modelID string) error {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
home = "."
|
||||
}
|
||||
mainConfigPath := filepath.Join(home, ".picoclaw", "config.json")
|
||||
|
||||
var cfg map[string]any
|
||||
if data, readErr := os.ReadFile(mainConfigPath); readErr == nil {
|
||||
if unmarshalErr := json.Unmarshal(data, &cfg); unmarshalErr != nil {
|
||||
cfg = make(map[string]any)
|
||||
}
|
||||
} else {
|
||||
cfg = make(map[string]any)
|
||||
}
|
||||
|
||||
if _, ok := cfg["agents"]; !ok {
|
||||
cfg["agents"] = make(map[string]any)
|
||||
}
|
||||
agents, ok := cfg["agents"].(map[string]any)
|
||||
if ok {
|
||||
if _, ok := agents["defaults"]; !ok {
|
||||
agents["defaults"] = make(map[string]any)
|
||||
}
|
||||
defaults, ok := agents["defaults"].(map[string]any)
|
||||
if ok {
|
||||
defaults["model"] = "tui-prefer"
|
||||
}
|
||||
}
|
||||
|
||||
tuiModel := map[string]any{
|
||||
"model_name": "tui-prefer",
|
||||
"model": modelID,
|
||||
"api_key": user.Key,
|
||||
"api_base": scheme.BaseURL,
|
||||
}
|
||||
|
||||
modelList := []any{}
|
||||
if ml, ok := cfg["model_list"].([]any); ok {
|
||||
modelList = ml
|
||||
}
|
||||
|
||||
found := false
|
||||
for i, m := range modelList {
|
||||
if entry, ok := m.(map[string]any); ok {
|
||||
if name, ok := entry["model_name"].(string); ok && name == "tui-prefer" {
|
||||
modelList[i] = tuiModel
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
modelList = append(modelList, tuiModel)
|
||||
}
|
||||
cfg["model_list"] = modelList
|
||||
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(mainConfigPath), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(mainConfigPath, data, 0o600)
|
||||
}
|
||||
|
||||
func (cfg *TUIConfig) CurrentModelLabel() string {
|
||||
cur := cfg.Provider.Current
|
||||
if cur.Model == "" {
|
||||
return "(not configured)"
|
||||
}
|
||||
label := cur.Scheme
|
||||
if label != "" {
|
||||
label += " / "
|
||||
}
|
||||
return label + cur.Model
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/ui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configPath := tuicfg.DefaultConfigPath()
|
||||
if len(os.Args) > 1 {
|
||||
configPath = os.Args[1]
|
||||
}
|
||||
|
||||
configDir := filepath.Dir(configPath)
|
||||
if _, err := os.Stat(configDir); os.IsNotExist(err) {
|
||||
cmd := exec.Command("picoclaw", "onboard")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
_ = cmd.Run()
|
||||
}
|
||||
|
||||
cfg, err := tuicfg.Load(configPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
app := ui.New(cfg, configPath)
|
||||
// Bind model selection hook to sync to main config
|
||||
app.OnModelSelected = func(scheme tuicfg.Scheme, user tuicfg.User, modelID string) {
|
||||
_ = tuicfg.SyncSelectedModelToMainConfig(scheme, user, modelID)
|
||||
}
|
||||
if err := app.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||
)
|
||||
|
||||
// App is the root TUI application.
|
||||
type App struct {
|
||||
tapp *tview.Application
|
||||
pages *tview.Pages
|
||||
pageStack []string
|
||||
cfg *tuicfg.TUIConfig
|
||||
configPath string
|
||||
pageRefreshFns map[string]func()
|
||||
headerModelTV *tview.TextView
|
||||
modalOpen map[string]bool
|
||||
|
||||
// OnModelSelected is called when a model is selected in the UI.
|
||||
// Can be nil to disable.
|
||||
OnModelSelected func(scheme tuicfg.Scheme, user tuicfg.User, modelID string)
|
||||
|
||||
modelCache map[string][]modelEntry
|
||||
modelCacheMu sync.RWMutex
|
||||
refreshMu sync.Mutex
|
||||
}
|
||||
|
||||
// cacheKey returns the map key for a (scheme, user) pair.
|
||||
func cacheKey(schemeName, userName string) string {
|
||||
return fmt.Sprintf("%s/%s", schemeName, userName)
|
||||
}
|
||||
|
||||
// cachedModels returns a defensive copy of the cached model list for a user (may be nil).
|
||||
func (a *App) cachedModels(schemeName, userName string) []modelEntry {
|
||||
a.modelCacheMu.RLock()
|
||||
defer a.modelCacheMu.RUnlock()
|
||||
entries := a.modelCache[cacheKey(schemeName, userName)]
|
||||
return append([]modelEntry(nil), entries...)
|
||||
}
|
||||
|
||||
// refreshModelCache fetches models for every user in the config concurrently.
|
||||
// Serialized by refreshMu so concurrent calls don't race on the cache map.
|
||||
// When all fetches complete it calls onDone via QueueUpdateDraw.
|
||||
func (a *App) refreshModelCache(onDone func()) {
|
||||
go func() {
|
||||
a.refreshMu.Lock()
|
||||
defer a.refreshMu.Unlock()
|
||||
|
||||
users := a.cfg.Provider.Users
|
||||
schemes := a.cfg.Provider.Schemes
|
||||
|
||||
schemeURL := make(map[string]string, len(schemes))
|
||||
for _, s := range schemes {
|
||||
schemeURL[s.Name] = s.BaseURL
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, u := range users {
|
||||
baseURL, ok := schemeURL[u.Scheme]
|
||||
if !ok || baseURL == "" {
|
||||
continue
|
||||
}
|
||||
if u.Key == "" {
|
||||
a.modelCacheMu.Lock()
|
||||
if a.modelCache == nil {
|
||||
a.modelCache = make(map[string][]modelEntry)
|
||||
}
|
||||
a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
|
||||
a.modelCacheMu.Unlock()
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
bURL := baseURL
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
entries, err := fetchModels(bURL, u.Key)
|
||||
a.modelCacheMu.Lock()
|
||||
if a.modelCache == nil {
|
||||
a.modelCache = make(map[string][]modelEntry)
|
||||
}
|
||||
if err != nil || len(entries) == 0 {
|
||||
a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
|
||||
} else {
|
||||
a.modelCache[cacheKey(u.Scheme, u.Name)] = entries
|
||||
}
|
||||
a.modelCacheMu.Unlock()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if onDone != nil {
|
||||
a.tapp.QueueUpdateDraw(onDone)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// New creates and wires up the TUI application.
|
||||
func New(cfg *tuicfg.TUIConfig, configPath string) *App {
|
||||
// Cyberpunk Theme Colors
|
||||
// Dark background
|
||||
tview.Styles.PrimitiveBackgroundColor = tcell.NewHexColor(0x050510) // Deep Void
|
||||
tview.Styles.ContrastBackgroundColor = tcell.NewHexColor(0x1a1a2e) // Dark Indigo
|
||||
tview.Styles.MoreContrastBackgroundColor = tcell.NewHexColor(0x2a2a40)
|
||||
|
||||
// Borders and Titles
|
||||
tview.Styles.BorderColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
|
||||
tview.Styles.TitleColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
|
||||
tview.Styles.GraphicsColor = tcell.NewHexColor(0xff00ff) // Neon Magenta
|
||||
|
||||
// Text
|
||||
tview.Styles.PrimaryTextColor = tcell.NewHexColor(0xe0e0e0) // Off-white
|
||||
tview.Styles.SecondaryTextColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
|
||||
tview.Styles.TertiaryTextColor = tcell.NewHexColor(0x39ff14) // Neon Lime
|
||||
tview.Styles.InverseTextColor = tcell.NewHexColor(0x000000) // Black
|
||||
tview.Styles.ContrastSecondaryTextColor = tcell.NewHexColor(0xff00ff) // Neon Magenta
|
||||
|
||||
a := &App{
|
||||
tapp: tview.NewApplication(),
|
||||
pages: tview.NewPages(),
|
||||
pageStack: []string{},
|
||||
cfg: cfg,
|
||||
configPath: configPath,
|
||||
pageRefreshFns: make(map[string]func()),
|
||||
modalOpen: make(map[string]bool),
|
||||
}
|
||||
|
||||
a.tapp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
if len(a.modalOpen) > 0 {
|
||||
return event
|
||||
}
|
||||
return a.goBack()
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
a.buildPages()
|
||||
return a
|
||||
}
|
||||
|
||||
// Run starts the TUI event loop.
|
||||
func (a *App) Run() error {
|
||||
return a.tapp.SetRoot(a.pages, true).EnableMouse(true).Run()
|
||||
}
|
||||
|
||||
func (a *App) buildPages() {
|
||||
a.pages.AddPage("home", a.newHomePage(), true, true)
|
||||
a.pageStack = []string{"home"}
|
||||
}
|
||||
|
||||
func (a *App) navigateTo(name string, page tview.Primitive) {
|
||||
a.pages.RemovePage(name)
|
||||
a.pages.AddPage(name, page, true, false)
|
||||
a.pageStack = append(a.pageStack, name)
|
||||
a.pages.SwitchToPage(name)
|
||||
}
|
||||
|
||||
func (a *App) goBack() *tcell.EventKey {
|
||||
if len(a.pageStack) <= 1 {
|
||||
return nil
|
||||
}
|
||||
popped := a.pageStack[len(a.pageStack)-1]
|
||||
a.pageStack = a.pageStack[:len(a.pageStack)-1]
|
||||
a.pages.RemovePage(popped)
|
||||
prev := a.pageStack[len(a.pageStack)-1]
|
||||
if fn, ok := a.pageRefreshFns[prev]; ok {
|
||||
fn()
|
||||
}
|
||||
if prev == "home" && a.headerModelTV != nil {
|
||||
a.headerModelTV.SetText(a.cfg.CurrentModelLabel() + " ")
|
||||
}
|
||||
a.pages.SwitchToPage(prev)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) showModal(name string, primitive tview.Primitive) {
|
||||
a.modalOpen[name] = true
|
||||
a.pages.AddPage(name, primitive, true, true)
|
||||
}
|
||||
|
||||
func (a *App) hideModal(name string) {
|
||||
delete(a.modalOpen, name)
|
||||
a.pages.HidePage(name)
|
||||
a.pages.RemovePage(name)
|
||||
}
|
||||
|
||||
func (a *App) save() {
|
||||
if err := tuicfg.Save(a.configPath, a.cfg); err != nil {
|
||||
a.showError("save failed: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) showError(msg string) {
|
||||
modal := tview.NewModal().
|
||||
SetText(" [red::b]ERROR[-::-]\n\n" + msg).
|
||||
AddButtons([]string{"OK"}).
|
||||
SetDoneFunc(func(_ int, _ string) {
|
||||
a.hideModal("error")
|
||||
})
|
||||
// Cyberpunk Modal Style
|
||||
modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
|
||||
modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White
|
||||
modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red
|
||||
modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White
|
||||
a.showModal("error", modal)
|
||||
}
|
||||
|
||||
func (a *App) confirmDelete(label string, onConfirm func()) {
|
||||
modal := tview.NewModal().
|
||||
SetText(" [red::b]DELETE WARNING[-::-]\n\nDelete " + label + "?\n[gray]This action cannot be undone.[-]").
|
||||
AddButtons([]string{"Delete", "Cancel"}).
|
||||
SetDoneFunc(func(_ int, buttonLabel string) {
|
||||
a.hideModal("confirm-delete")
|
||||
if buttonLabel == "Delete" {
|
||||
onConfirm()
|
||||
}
|
||||
})
|
||||
// Cyberpunk Modal Style
|
||||
modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
|
||||
modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White
|
||||
modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red for danger
|
||||
modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White
|
||||
a.showModal("confirm-delete", modal)
|
||||
}
|
||||
|
||||
func centeredForm(form *tview.Form, widthPct, height int) tview.Primitive {
|
||||
return tview.NewFlex().
|
||||
AddItem(tview.NewBox(), 0, 1, false).
|
||||
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(tview.NewBox(), 0, 1, false).
|
||||
AddItem(form, height, 1, true).
|
||||
AddItem(tview.NewBox(), 0, 1, false), 0, widthPct, true).
|
||||
AddItem(tview.NewBox(), 0, 1, false)
|
||||
}
|
||||
|
||||
func hintBar(text string) *tview.TextView {
|
||||
tv := tview.NewTextView().
|
||||
SetText(text).
|
||||
SetDynamicColors(true).
|
||||
SetTextAlign(tview.AlignCenter).
|
||||
SetTextColor(tcell.NewHexColor(0x00f0ff)) // Neon Cyan
|
||||
tv.SetBackgroundColor(tcell.NewHexColor(0x2a2a40)) // Darker Indigo
|
||||
return tv
|
||||
}
|
||||
|
||||
func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tview.Primitive {
|
||||
var modelTV *tview.TextView
|
||||
if pageID == "home" {
|
||||
if a.headerModelTV == nil {
|
||||
a.headerModelTV = tview.NewTextView()
|
||||
a.headerModelTV.SetTextAlign(tview.AlignRight).
|
||||
SetTextColor(tcell.NewHexColor(0x39ff14)). // Neon Lime
|
||||
SetDynamicColors(true).
|
||||
SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
}
|
||||
modelTV = a.headerModelTV
|
||||
modelTV.SetText("MODEL: " + a.cfg.CurrentModelLabel() + " ")
|
||||
} else {
|
||||
modelTV = tview.NewTextView()
|
||||
modelTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
}
|
||||
|
||||
headerLeft := tview.NewTextView().
|
||||
SetText(" [#ff00ff::b]///[#00f0ff] PICOCLAW LAUNCHER [#ff00ff]///").
|
||||
SetDynamicColors(true).
|
||||
SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
header := tview.NewFlex().
|
||||
AddItem(headerLeft, 0, 1, false).
|
||||
AddItem(modelTV, 0, 1, false)
|
||||
|
||||
sidebar := tview.NewTextView().
|
||||
SetDynamicColors(true).
|
||||
SetWrap(false)
|
||||
sidebar.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
|
||||
|
||||
// Cyberpunk Sidebar Styling
|
||||
activePrefix := "[#39ff14::b]>> " // Neon Lime arrow
|
||||
activeSuffix := "[-]"
|
||||
inactivePrefix := "[#808080] "
|
||||
inactiveSuffix := "[-]"
|
||||
|
||||
sbText := "\n\n" // Top padding
|
||||
|
||||
menuItem := func(id, label string) string {
|
||||
if pageID == id {
|
||||
return activePrefix + label + activeSuffix + "\n\n"
|
||||
}
|
||||
return inactivePrefix + label + inactiveSuffix + "\n\n"
|
||||
}
|
||||
|
||||
sbText += menuItem("home", "HOME")
|
||||
sbText += menuItem("schemes", "SCHEMES")
|
||||
sbText += menuItem("users", "USERS")
|
||||
sbText += menuItem("models", "MODELS")
|
||||
sbText += menuItem("channels", "CHANNELS")
|
||||
sbText += menuItem("gateway", "GATEWAY")
|
||||
|
||||
sidebar.SetText(sbText)
|
||||
|
||||
footer := hintBar(hint)
|
||||
|
||||
grid := tview.NewGrid().
|
||||
SetRows(1, 0, 1).
|
||||
SetColumns(20, 0). // Slightly wider sidebar
|
||||
AddItem(header, 0, 0, 1, 2, 0, 0, false).
|
||||
AddItem(sidebar, 1, 0, 1, 1, 0, 0, false).
|
||||
AddItem(content, 1, 1, 1, 1, 0, 0, true).
|
||||
AddItem(footer, 2, 0, 1, 2, 0, 0, false)
|
||||
|
||||
// Add a border around the content area if possible, or ensure content has its own border
|
||||
// grid.SetBorders(false) // Grid borders usually look bad, handled by components
|
||||
|
||||
return grid
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
func (a *App) newChannelsPage() tview.Primitive {
|
||||
list := tview.NewList()
|
||||
list.SetBorder(true).
|
||||
SetTitle(" [#00f0ff::b] COMMUNICATION CHANNELS ").
|
||||
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
|
||||
list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
|
||||
list.SetSelectedStyle(
|
||||
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0x050510)),
|
||||
)
|
||||
list.SetHighlightFullLine(true)
|
||||
list.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
rebuild := func() {
|
||||
sel := list.GetCurrentItem()
|
||||
list.Clear()
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
home = "."
|
||||
}
|
||||
configPath := filepath.Join(home, ".picoclaw", "config.json")
|
||||
|
||||
var cfg map[string]any
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
_ = json.Unmarshal(data, &cfg)
|
||||
}
|
||||
|
||||
if chRaw, ok := cfg["channels"].(map[string]any); ok {
|
||||
for name, ch := range chRaw {
|
||||
chMap, ok := ch.(map[string]any)
|
||||
enabled := "disabled"
|
||||
if ok {
|
||||
if e, ok := chMap["enabled"].(bool); ok && e {
|
||||
enabled = "enabled"
|
||||
}
|
||||
}
|
||||
list.AddItem(name, fmt.Sprintf("Status: %s", enabled), 0, func() {
|
||||
a.showChannelEditForm(configPath, name, chMap)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if sel >= 0 && sel < list.GetItemCount() {
|
||||
list.SetCurrentItem(sel)
|
||||
}
|
||||
}
|
||||
rebuild()
|
||||
|
||||
a.pageRefreshFns["channels"] = rebuild
|
||||
|
||||
list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
return a.goBack()
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
return a.buildShell("channels", list, " [#ff00ff]Enter:[-] edit [#ff2a2a]ESC:[-] back ")
|
||||
}
|
||||
|
||||
func (a *App) showChannelEditForm(configPath, channelName string, existing map[string]any) {
|
||||
form := tview.NewForm()
|
||||
form.SetBorder(true).
|
||||
SetTitle(" [::b]EDIT CHANNEL ").
|
||||
SetTitleColor(tcell.NewHexColor(0x39ff14)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
|
||||
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
|
||||
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
|
||||
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
|
||||
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
|
||||
|
||||
fields := make(map[string]*tview.InputField)
|
||||
var nameField *tview.InputField
|
||||
|
||||
if channelName == "" {
|
||||
nameField = tview.NewInputField().
|
||||
SetLabel("Channel Name").
|
||||
SetText("").
|
||||
SetFieldWidth(28)
|
||||
form.AddFormItem(nameField)
|
||||
}
|
||||
|
||||
for k, v := range existing {
|
||||
if reflect.ValueOf(v).Kind() == reflect.Map || reflect.ValueOf(v).Kind() == reflect.Slice {
|
||||
continue
|
||||
}
|
||||
valStr := fmt.Sprintf("%v", v)
|
||||
field := tview.NewInputField().
|
||||
SetLabel(k).
|
||||
SetText(valStr).
|
||||
SetFieldWidth(28)
|
||||
form.AddFormItem(field)
|
||||
fields[k] = field
|
||||
}
|
||||
|
||||
form.AddButton("SAVE", func() {
|
||||
var cfg map[string]any
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
cfg = make(map[string]any)
|
||||
}
|
||||
} else {
|
||||
cfg = make(map[string]any)
|
||||
}
|
||||
|
||||
if _, ok := cfg["channels"]; !ok {
|
||||
cfg["channels"] = make(map[string]any)
|
||||
}
|
||||
channels, ok := cfg["channels"].(map[string]any)
|
||||
if !ok {
|
||||
channels = make(map[string]any)
|
||||
cfg["channels"] = channels
|
||||
}
|
||||
|
||||
finalName := channelName
|
||||
if channelName == "" {
|
||||
if nameField == nil || nameField.GetText() == "" {
|
||||
a.showError("Channel name is required")
|
||||
return
|
||||
}
|
||||
finalName = nameField.GetText()
|
||||
}
|
||||
|
||||
updated := make(map[string]any)
|
||||
if existing != nil {
|
||||
for k, v := range existing {
|
||||
updated[k] = v
|
||||
}
|
||||
}
|
||||
for k, field := range fields {
|
||||
val := field.GetText()
|
||||
if val == "true" {
|
||||
updated[k] = true
|
||||
} else if val == "false" {
|
||||
updated[k] = false
|
||||
} else if num, err := strconv.Atoi(val); err == nil {
|
||||
updated[k] = num
|
||||
} else {
|
||||
updated[k] = val
|
||||
}
|
||||
}
|
||||
|
||||
if channelName != "" && finalName != channelName {
|
||||
delete(channels, channelName)
|
||||
}
|
||||
channels[finalName] = updated
|
||||
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
a.showError(fmt.Sprintf("Failed to save config: %v", err))
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
|
||||
a.showError(fmt.Sprintf("Failed to create config directory: %v", err))
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(configPath, data, 0o600); err != nil {
|
||||
a.showError(fmt.Sprintf("Failed to write config: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
a.hideModal("channel-edit")
|
||||
a.goBack()
|
||||
})
|
||||
|
||||
form.AddButton("CANCEL", func() {
|
||||
a.hideModal("channel-edit")
|
||||
})
|
||||
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
a.hideModal("channel-edit")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
a.showModal("channel-edit", centeredForm(form, 4, 20))
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
const pidFileName = "gateway.pid"
|
||||
|
||||
type gatewayStatus struct {
|
||||
running bool
|
||||
pid int
|
||||
}
|
||||
|
||||
func getPidPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
home = "."
|
||||
}
|
||||
return filepath.Join(home, ".picoclaw", pidFileName)
|
||||
}
|
||||
|
||||
func isProcessRunning(pid int) bool {
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid))
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(output), strconv.Itoa(pid))
|
||||
} else if runtime.GOOS == "darwin" {
|
||||
cmd := exec.Command("ps", "aux")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(output), fmt.Sprintf(" %d ", pid))
|
||||
}
|
||||
// Linux
|
||||
_, err := os.Stat(fmt.Sprintf("/proc/%d", pid))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func getGatewayStatus() gatewayStatus {
|
||||
pidPath := getPidPath()
|
||||
data, err := os.ReadFile(pidPath)
|
||||
if err != nil {
|
||||
return gatewayStatus{running: false}
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
if err != nil {
|
||||
return gatewayStatus{running: false}
|
||||
}
|
||||
if !isProcessRunning(pid) {
|
||||
os.Remove(pidPath)
|
||||
return gatewayStatus{running: false}
|
||||
}
|
||||
return gatewayStatus{
|
||||
running: true,
|
||||
pid: pid,
|
||||
}
|
||||
}
|
||||
|
||||
func startGateway() error {
|
||||
status := getGatewayStatus()
|
||||
if status.running {
|
||||
return fmt.Errorf("gateway is already running (PID: %d)", status.pid)
|
||||
}
|
||||
|
||||
pidPath := getPidPath()
|
||||
var cmd *exec.Cmd
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = exec.Command("cmd", "/C", "start /B picoclaw gateway > NUL 2>&1")
|
||||
} else {
|
||||
cmd = exec.Command("sh", "-c", "nohup picoclaw gateway > /dev/null 2>&1 & echo $! > "+pidPath)
|
||||
}
|
||||
|
||||
err := cmd.Start()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd := exec.Command(
|
||||
"wmic",
|
||||
"process",
|
||||
"where",
|
||||
"name='picoclaw.exe' and commandline like '%gateway%'",
|
||||
"get",
|
||||
"processid",
|
||||
)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get gateway PID: %w", err)
|
||||
}
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines[1:] {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
pid, err := strconv.Atoi(line)
|
||||
if err == nil {
|
||||
os.WriteFile(pidPath, []byte(strconv.Itoa(pid)), 0o600)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
status = getGatewayStatus()
|
||||
if !status.running {
|
||||
return fmt.Errorf("failed to start gateway")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stopGateway() error {
|
||||
status := getGatewayStatus()
|
||||
if !status.running {
|
||||
return fmt.Errorf("gateway is not running")
|
||||
}
|
||||
|
||||
var err error
|
||||
if runtime.GOOS == "windows" {
|
||||
err = exec.Command("taskkill", "/F", "/PID", strconv.Itoa(status.pid)).Run()
|
||||
} else {
|
||||
err = exec.Command("kill", "-9", strconv.Itoa(status.pid)).Run()
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 多次尝试确认进程已停止
|
||||
for i := 0; i < 5; i++ {
|
||||
if !isProcessRunning(status.pid) {
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
os.Remove(getPidPath())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) newGatewayPage() tview.Primitive {
|
||||
flex := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
flex.SetBorder(true).
|
||||
SetTitle(" [#00f0ff::b] GATEWAY MANAGEMENT ").
|
||||
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
flex.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
statusTV := tview.NewTextView().
|
||||
SetDynamicColors(true).
|
||||
SetTextAlign(tview.AlignCenter).
|
||||
SetText("Checking status...")
|
||||
statusTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
var updateStatus func()
|
||||
|
||||
// 使用List作为按钮,保证显示和交互正常
|
||||
buttons := tview.NewList()
|
||||
buttons.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
buttons.SetMainTextColor(tcell.ColorWhite)
|
||||
buttons.SetSelectedBackgroundColor(tcell.NewHexColor(0xff00ff))
|
||||
buttons.SetSelectedTextColor(tcell.ColorBlack)
|
||||
|
||||
buttons.AddItem(" [lime]START[white] ", "", 0, func() {
|
||||
if !getGatewayStatus().running {
|
||||
err := startGateway()
|
||||
if err != nil {
|
||||
a.showError(err.Error())
|
||||
}
|
||||
updateStatus()
|
||||
}
|
||||
})
|
||||
buttons.AddItem(" [red]STOP[white] ", "", 0, func() {
|
||||
if getGatewayStatus().running {
|
||||
err := stopGateway()
|
||||
if err != nil {
|
||||
a.showError(err.Error())
|
||||
}
|
||||
updateStatus()
|
||||
}
|
||||
})
|
||||
|
||||
buttonFlex := tview.NewFlex().SetDirection(tview.FlexColumn)
|
||||
buttonFlex.
|
||||
AddItem(tview.NewBox(), 0, 1, false).
|
||||
AddItem(buttons, 20, 1, true).
|
||||
AddItem(tview.NewBox(), 0, 1, false)
|
||||
|
||||
flex.
|
||||
AddItem(tview.NewBox(), 0, 1, false).
|
||||
AddItem(statusTV, 3, 1, false).
|
||||
AddItem(tview.NewBox(), 0, 1, false).
|
||||
AddItem(buttonFlex, 4, 1, true).
|
||||
AddItem(tview.NewBox(), 0, 1, false)
|
||||
|
||||
updateStatus = func() {
|
||||
status := getGatewayStatus()
|
||||
if status.running {
|
||||
statusTV.SetText(fmt.Sprintf("[#39ff14::b]GATEWAY RUNNING[-]\n\nPID: %d", status.pid))
|
||||
buttons.SetItemText(0, " [gray]START[white] ", "")
|
||||
buttons.SetItemText(1, " [red]STOP[white] ", "")
|
||||
} else {
|
||||
statusTV.SetText("[#ff2a2a::b]GATEWAY STOPPED[-]\n\nPID: N/A")
|
||||
buttons.SetItemText(0, " [lime]START[white] ", "")
|
||||
buttons.SetItemText(1, " [gray]STOP[white] ", "")
|
||||
}
|
||||
}
|
||||
|
||||
updateStatus()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
a.tapp.QueueUpdateDraw(updateStatus)
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
originalInputCapture := flex.GetInputCapture()
|
||||
flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
close(done)
|
||||
return a.goBack()
|
||||
}
|
||||
if originalInputCapture != nil {
|
||||
return originalInputCapture(event)
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
a.pageRefreshFns["gateway"] = updateStatus
|
||||
|
||||
return a.buildShell("gateway", flex, " [#39ff14]Enter:[-] select [#ff2a2a]ESC:[-] back ")
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
func (a *App) newHomePage() tview.Primitive {
|
||||
list := tview.NewList()
|
||||
list.SetBorder(true).
|
||||
SetTitle(" [#00f0ff::b] ACTIVE CONFIGURATION ").
|
||||
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
|
||||
list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
|
||||
list.SetSelectedStyle(
|
||||
tcell.StyleDefault.Background(tcell.NewHexColor(0x39ff14)).Foreground(tcell.NewHexColor(0x050510)),
|
||||
)
|
||||
list.SetHighlightFullLine(true)
|
||||
list.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
rebuildList := func() {
|
||||
sel := list.GetCurrentItem()
|
||||
list.Clear()
|
||||
list.AddItem("MODEL: "+a.cfg.CurrentModelLabel(), "Select to configure AI model", 'm', func() {
|
||||
a.navigateTo("schemes", a.newSchemesPage())
|
||||
})
|
||||
list.AddItem(
|
||||
"CHANNELS: Configure communication channels",
|
||||
"Manage Telegram/Discord/WeChat channels",
|
||||
'n',
|
||||
func() {
|
||||
a.navigateTo("channels", a.newChannelsPage())
|
||||
},
|
||||
)
|
||||
list.AddItem("GATEWAY MANAGEMENT", "Manage PicoClaw gateway daemon", 'g', func() {
|
||||
a.navigateTo("gateway", a.newGatewayPage())
|
||||
})
|
||||
list.AddItem("CHAT: Start AI agent chat", "Launch interactive chat session", 'c', func() {
|
||||
a.tapp.Suspend(func() {
|
||||
cmd := exec.Command("picoclaw", "agent")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
_ = cmd.Run()
|
||||
})
|
||||
})
|
||||
list.AddItem("QUIT SYSTEM", "Exit PicoClaw Launcher", 'q', func() { a.tapp.Stop() })
|
||||
if sel >= 0 && sel < list.GetItemCount() {
|
||||
list.SetCurrentItem(sel)
|
||||
}
|
||||
}
|
||||
rebuildList()
|
||||
|
||||
a.pageRefreshFns["home"] = rebuildList
|
||||
|
||||
return a.buildShell(
|
||||
"home",
|
||||
list,
|
||||
" [#00f0ff]m:[-] model [#00f0ff]n:[-] channels [#00f0ff]g:[-] gateway [#00f0ff]c:[-] chat [#ff2a2a]q:[-] quit ",
|
||||
)
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||
)
|
||||
|
||||
type modelsAPIResponse struct {
|
||||
Data []modelEntry `json:"data"`
|
||||
}
|
||||
|
||||
type modelEntry struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitive {
|
||||
table := tview.NewTable().
|
||||
SetBorders(false).
|
||||
SetSelectable(true, false).
|
||||
SetFixed(0, 0)
|
||||
table.SetBorder(true).
|
||||
SetTitle(fmt.Sprintf(" [#00f0ff::b] MODELS · %s / %s ", schemeName, userName)).
|
||||
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
table.SetSelectedStyle(
|
||||
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
|
||||
)
|
||||
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
var modelIDs []string
|
||||
|
||||
status := tview.NewTextView().
|
||||
SetTextAlign(tview.AlignCenter).
|
||||
SetDynamicColors(true).
|
||||
SetText("[#ffff00]FETCHING MODELS...[-]")
|
||||
status.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
flex := tview.NewFlex().
|
||||
SetDirection(tview.FlexRow).
|
||||
AddItem(status, 1, 0, false).
|
||||
AddItem(table, 0, 1, false)
|
||||
|
||||
apiKey := a.resolveKey(schemeName, userName)
|
||||
|
||||
go func() {
|
||||
var entries []modelEntry
|
||||
var err error
|
||||
if apiKey == "" {
|
||||
err = fmt.Errorf("key is required")
|
||||
} else {
|
||||
entries, err = fetchModels(baseURL, apiKey)
|
||||
}
|
||||
|
||||
a.modelCacheMu.Lock()
|
||||
if a.modelCache == nil {
|
||||
a.modelCache = make(map[string][]modelEntry)
|
||||
}
|
||||
if err == nil && len(entries) > 0 {
|
||||
a.modelCache[cacheKey(schemeName, userName)] = entries
|
||||
} else {
|
||||
a.modelCache[cacheKey(schemeName, userName)] = nil
|
||||
}
|
||||
a.modelCacheMu.Unlock()
|
||||
|
||||
a.tapp.QueueUpdateDraw(func() {
|
||||
if err != nil {
|
||||
status.SetText(fmt.Sprintf("[#ff2a2a]ERROR: %s[-]", err.Error()))
|
||||
table.SetCell(0, 0, tview.NewTableCell(" (failed to load models)"))
|
||||
a.tapp.SetFocus(table)
|
||||
return
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
status.SetText("[#ff2a2a]NO MODELS RETURNED[-]")
|
||||
table.SetCell(0, 0, tview.NewTableCell(" (no models available)"))
|
||||
a.tapp.SetFocus(table)
|
||||
return
|
||||
}
|
||||
|
||||
status.SetText(fmt.Sprintf("[#39ff14]%d MODEL(S) LOADED[-]", len(entries)))
|
||||
for i, m := range entries {
|
||||
modelIDs = append(modelIDs, m.ID)
|
||||
table.SetCell(i, 0,
|
||||
tview.NewTableCell(fmt.Sprintf("%3d", i+1)).
|
||||
SetAlign(tview.AlignRight).
|
||||
SetTextColor(tcell.NewHexColor(0x808080)).
|
||||
SetSelectable(false),
|
||||
)
|
||||
table.SetCell(i, 1,
|
||||
tview.NewTableCell(" "+m.ID).
|
||||
SetAlign(tview.AlignLeft).
|
||||
SetExpansion(1).
|
||||
SetTextColor(tcell.NewHexColor(0xe0e0e0)),
|
||||
)
|
||||
}
|
||||
a.tapp.SetFocus(table)
|
||||
})
|
||||
}()
|
||||
|
||||
table.SetSelectedFunc(func(row, _ int) {
|
||||
if row < 0 || row >= len(modelIDs) {
|
||||
return
|
||||
}
|
||||
a.cfg.Provider.Current = tuicfg.ProviderCurrent{
|
||||
Scheme: schemeName,
|
||||
User: userName,
|
||||
Model: modelIDs[row],
|
||||
}
|
||||
a.save()
|
||||
|
||||
// Trigger model selected callback if set
|
||||
if a.OnModelSelected != nil && a.cfg.Model.Type == "provider" {
|
||||
scheme := a.cfg.Provider.SchemeByName(schemeName)
|
||||
if scheme == nil {
|
||||
a.goBack()
|
||||
return
|
||||
}
|
||||
var user tuicfg.User
|
||||
for _, u := range a.cfg.Provider.Users {
|
||||
if u.Scheme == schemeName && u.Name == userName {
|
||||
user = u
|
||||
break
|
||||
}
|
||||
}
|
||||
a.OnModelSelected(*scheme, user, modelIDs[row])
|
||||
}
|
||||
|
||||
a.goBack()
|
||||
})
|
||||
|
||||
return a.buildShell("models", flex, " [#39ff14]Enter:[-] select [#ff00ff]ESC:[-] back ")
|
||||
}
|
||||
|
||||
func (a *App) resolveKey(schemeName, userName string) string {
|
||||
for _, u := range a.cfg.Provider.Users {
|
||||
if u.Scheme == schemeName && u.Name == userName {
|
||||
return u.Key
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fetchModels(baseURL, apiKey string) ([]modelEntry, error) {
|
||||
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)
|
||||
}
|
||||
if apiKey != "" {
|
||||
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)
|
||||
}
|
||||
|
||||
var result modelsAPIResponse
|
||||
if err := json.Unmarshal(body, &result); err == nil && len(result.Data) > 0 {
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
var arr []modelEntry
|
||||
if err := json.Unmarshal(body, &arr); err == nil {
|
||||
return arr, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"decode response: unrecognized shape: %s",
|
||||
strings.TrimSpace(string(body[:min(len(body), 256)])),
|
||||
)
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||
)
|
||||
|
||||
func (a *App) newSchemesPage() tview.Primitive {
|
||||
table := tview.NewTable().
|
||||
SetBorders(false).
|
||||
SetSelectable(true, false)
|
||||
table.SetBorder(true).
|
||||
SetTitle(" [#00f0ff::b] PROVIDER SCHEMES ").
|
||||
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
table.SetSelectedStyle(
|
||||
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
|
||||
)
|
||||
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
rowToIdx := func(row int) int { return row / 2 }
|
||||
|
||||
selectedSchemeName := func() string {
|
||||
row, _ := table.GetSelection()
|
||||
idx := rowToIdx(row)
|
||||
schemes := a.cfg.Provider.Schemes
|
||||
if idx >= 0 && idx < len(schemes) {
|
||||
return schemes[idx].Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
rebuild := func() {
|
||||
selName := selectedSchemeName()
|
||||
table.Clear()
|
||||
schemes := a.cfg.Provider.Schemes
|
||||
for i, s := range schemes {
|
||||
nameRow := i * 2
|
||||
detailRow := nameRow + 1
|
||||
|
||||
table.SetCell(nameRow, 0,
|
||||
tview.NewTableCell(" "+s.Name).
|
||||
SetTextColor(tcell.NewHexColor(0xe0e0e0)).
|
||||
SetExpansion(1).
|
||||
SetSelectable(true),
|
||||
)
|
||||
|
||||
users := a.cfg.Provider.UsersForScheme(s.Name)
|
||||
n := len(users)
|
||||
m := 0
|
||||
for _, u := range users {
|
||||
if models := a.cachedModels(s.Name, u.Name); len(models) > 0 {
|
||||
m++
|
||||
}
|
||||
}
|
||||
table.SetCell(detailRow, 0,
|
||||
tview.NewTableCell(fmt.Sprintf(" [#808080](%d/%d) %s", m, n, s.BaseURL)).
|
||||
SetTextColor(tcell.NewHexColor(0x808080)).
|
||||
SetExpansion(1).
|
||||
SetSelectable(false),
|
||||
)
|
||||
table.SetCell(detailRow, 1,
|
||||
tview.NewTableCell("[#00f0ff]"+s.Type+" ").
|
||||
SetAlign(tview.AlignRight).
|
||||
SetSelectable(false),
|
||||
)
|
||||
}
|
||||
if selName != "" {
|
||||
for i, s := range schemes {
|
||||
if s.Name == selName {
|
||||
table.Select(i*2, 0)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if table.GetRowCount() > 0 {
|
||||
table.Select(0, 0)
|
||||
}
|
||||
}
|
||||
rebuild()
|
||||
|
||||
a.refreshModelCache(rebuild)
|
||||
a.pageRefreshFns["schemes"] = func() { a.refreshModelCache(rebuild) }
|
||||
|
||||
table.SetSelectedFunc(func(row, _ int) {
|
||||
idx := rowToIdx(row)
|
||||
schemes := a.cfg.Provider.Schemes
|
||||
if idx < 0 || idx >= len(schemes) {
|
||||
return
|
||||
}
|
||||
name := schemes[idx].Name
|
||||
a.navigateTo("users", a.newUsersPage(name))
|
||||
})
|
||||
|
||||
table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
row, _ := table.GetSelection()
|
||||
idx := rowToIdx(row)
|
||||
schemes := a.cfg.Provider.Schemes
|
||||
switch event.Rune() {
|
||||
case 'a':
|
||||
a.showSchemeForm(nil, func(s tuicfg.Scheme) {
|
||||
a.cfg.Provider.Schemes = append(a.cfg.Provider.Schemes, s)
|
||||
a.save()
|
||||
a.refreshModelCache(rebuild)
|
||||
})
|
||||
return nil
|
||||
case 'e':
|
||||
if idx < 0 || idx >= len(schemes) {
|
||||
return nil
|
||||
}
|
||||
origName := schemes[idx].Name
|
||||
orig := schemes[idx]
|
||||
a.showSchemeForm(&orig, func(s tuicfg.Scheme) {
|
||||
current := a.cfg.Provider.Schemes
|
||||
for i, sc := range current {
|
||||
if sc.Name == origName {
|
||||
a.cfg.Provider.Schemes[i] = s
|
||||
break
|
||||
}
|
||||
}
|
||||
a.save()
|
||||
a.refreshModelCache(func() {
|
||||
rebuild()
|
||||
for i, sc := range a.cfg.Provider.Schemes {
|
||||
if sc.Name == s.Name {
|
||||
table.Select(i*2, 0)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
return nil
|
||||
case 'd':
|
||||
if idx < 0 || idx >= len(schemes) {
|
||||
return nil
|
||||
}
|
||||
name := schemes[idx].Name
|
||||
a.confirmDelete(fmt.Sprintf("scheme %q", name), func() {
|
||||
current := a.cfg.Provider.Schemes
|
||||
newSchemes := make([]tuicfg.Scheme, 0, len(current))
|
||||
for _, sc := range current {
|
||||
if sc.Name != name {
|
||||
newSchemes = append(newSchemes, sc)
|
||||
}
|
||||
}
|
||||
a.cfg.Provider.Schemes = newSchemes
|
||||
|
||||
existing := a.cfg.Provider.Users
|
||||
filtered := make([]tuicfg.User, 0, len(existing))
|
||||
for _, u := range existing {
|
||||
if u.Scheme != name {
|
||||
filtered = append(filtered, u)
|
||||
}
|
||||
}
|
||||
a.cfg.Provider.Users = filtered
|
||||
|
||||
a.save()
|
||||
a.refreshModelCache(rebuild)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
return a.buildShell(
|
||||
"schemes",
|
||||
table,
|
||||
" [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] open [#ff00ff]ESC:[-] back ",
|
||||
)
|
||||
}
|
||||
|
||||
func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) {
|
||||
name := ""
|
||||
baseURL := ""
|
||||
schemeType := "openai-compatible"
|
||||
title := " ADD SCHEME "
|
||||
|
||||
if existing != nil {
|
||||
name = existing.Name
|
||||
baseURL = existing.BaseURL
|
||||
schemeType = existing.Type
|
||||
title = " EDIT SCHEME "
|
||||
}
|
||||
|
||||
typeOptions := []string{"openai-compatible", "anthropic"}
|
||||
typeIdx := 0
|
||||
for i, t := range typeOptions {
|
||||
if t == schemeType {
|
||||
typeIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
form := tview.NewForm()
|
||||
|
||||
form.
|
||||
AddInputField("Name", name, 20, nil, func(text string) { name = text }).
|
||||
AddInputField("Base URL", baseURL, 28, nil, func(text string) { baseURL = text }).
|
||||
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }).
|
||||
AddButton("SAVE", func() {
|
||||
if name == "" {
|
||||
a.showError("Name is required")
|
||||
return
|
||||
}
|
||||
if baseURL == "" {
|
||||
a.showError("Base URL is required")
|
||||
return
|
||||
}
|
||||
if existing == nil {
|
||||
for _, s := range a.cfg.Provider.Schemes {
|
||||
if s.Name == name {
|
||||
a.showError(fmt.Sprintf("Scheme name %q already exists", name))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
a.hideModal("scheme-form")
|
||||
onSave(tuicfg.Scheme{Name: name, BaseURL: baseURL, Type: schemeType})
|
||||
}).
|
||||
AddButton("CANCEL", func() {
|
||||
a.hideModal("scheme-form")
|
||||
})
|
||||
|
||||
form.SetBorder(true).
|
||||
SetTitle(" [::b]" + title + " ").
|
||||
SetTitleColor(tcell.NewHexColor(0x39ff14)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
|
||||
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
|
||||
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
|
||||
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
|
||||
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
a.hideModal("scheme-form")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
a.showModal("scheme-form", centeredForm(form, 4, 12))
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||
)
|
||||
|
||||
func (a *App) newUsersPage(schemeName string) tview.Primitive {
|
||||
table := tview.NewTable().
|
||||
SetBorders(false).
|
||||
SetSelectable(true, false)
|
||||
table.SetBorder(true).
|
||||
SetTitle(fmt.Sprintf(" [#00f0ff::b] USERS · %s ", schemeName)).
|
||||
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
table.SetSelectedStyle(
|
||||
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
|
||||
)
|
||||
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
visibleUsers := func() []tuicfg.User {
|
||||
var out []tuicfg.User
|
||||
for _, u := range a.cfg.Provider.Users {
|
||||
if u.Scheme == schemeName {
|
||||
out = append(out, u)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
findUserGlobalIdx := func(userName string) int {
|
||||
for i, u := range a.cfg.Provider.Users {
|
||||
if u.Scheme == schemeName && u.Name == userName {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
rowToVisIdx := func(row int) int { return row / 2 }
|
||||
|
||||
selectedUserName := func() string {
|
||||
row, _ := table.GetSelection()
|
||||
users := visibleUsers()
|
||||
visIdx := rowToVisIdx(row)
|
||||
if visIdx >= 0 && visIdx < len(users) {
|
||||
return users[visIdx].Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
rebuild := func() {
|
||||
selName := selectedUserName()
|
||||
table.Clear()
|
||||
users := visibleUsers()
|
||||
for i, u := range users {
|
||||
nameRow := i * 2
|
||||
detailRow := nameRow + 1
|
||||
|
||||
table.SetCell(nameRow, 0,
|
||||
tview.NewTableCell(" "+u.Name).
|
||||
SetTextColor(tcell.NewHexColor(0xe0e0e0)).
|
||||
SetExpansion(1).
|
||||
SetSelectable(true),
|
||||
)
|
||||
table.SetCell(nameRow, 1,
|
||||
tview.NewTableCell("").
|
||||
SetSelectable(false),
|
||||
)
|
||||
|
||||
models := a.cachedModels(schemeName, u.Name)
|
||||
var detailText string
|
||||
if len(models) > 0 {
|
||||
detailText = fmt.Sprintf(" [#39ff14]%d models available[-]", len(models))
|
||||
} else {
|
||||
detailText = " [#ff2a2a]Inactive / No Access[-]"
|
||||
}
|
||||
table.SetCell(detailRow, 0,
|
||||
tview.NewTableCell(detailText).
|
||||
SetTextColor(tcell.NewHexColor(0x808080)).
|
||||
SetExpansion(1).
|
||||
SetSelectable(false),
|
||||
)
|
||||
table.SetCell(detailRow, 1,
|
||||
tview.NewTableCell("[#00f0ff]"+u.Type+" ").
|
||||
SetAlign(tview.AlignRight).
|
||||
SetSelectable(false),
|
||||
)
|
||||
}
|
||||
if selName != "" {
|
||||
for i, u := range users {
|
||||
if u.Name == selName {
|
||||
table.Select(i*2, 0)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if table.GetRowCount() > 0 {
|
||||
table.Select(0, 0)
|
||||
}
|
||||
}
|
||||
rebuild()
|
||||
|
||||
a.refreshModelCache(rebuild)
|
||||
a.pageRefreshFns["users"] = func() { a.refreshModelCache(rebuild) }
|
||||
|
||||
table.SetSelectedFunc(func(row, _ int) {
|
||||
visIdx := rowToVisIdx(row)
|
||||
users := visibleUsers()
|
||||
if visIdx < 0 || visIdx >= len(users) {
|
||||
return
|
||||
}
|
||||
uName := users[visIdx].Name
|
||||
scheme := a.cfg.Provider.SchemeByName(schemeName)
|
||||
if scheme == nil {
|
||||
a.showError(fmt.Sprintf("Scheme %q not found", schemeName))
|
||||
return
|
||||
}
|
||||
a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL))
|
||||
})
|
||||
|
||||
table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
row, _ := table.GetSelection()
|
||||
visIdx := rowToVisIdx(row)
|
||||
users := visibleUsers()
|
||||
switch event.Rune() {
|
||||
case 'a':
|
||||
a.showUserForm(schemeName, nil, func(u tuicfg.User) {
|
||||
a.cfg.Provider.Users = append(a.cfg.Provider.Users, u)
|
||||
a.save()
|
||||
a.refreshModelCache(rebuild)
|
||||
})
|
||||
return nil
|
||||
case 'e':
|
||||
if visIdx < 0 || visIdx >= len(users) {
|
||||
return nil
|
||||
}
|
||||
origName := users[visIdx].Name
|
||||
orig := a.cfg.Provider.Users[findUserGlobalIdx(origName)]
|
||||
a.showUserForm(schemeName, &orig, func(u tuicfg.User) {
|
||||
cfgIdx := findUserGlobalIdx(origName)
|
||||
if cfgIdx < 0 {
|
||||
a.showError(fmt.Sprintf("User %q no longer exists", origName))
|
||||
return
|
||||
}
|
||||
a.cfg.Provider.Users[cfgIdx] = u
|
||||
a.save()
|
||||
a.refreshModelCache(func() {
|
||||
rebuild()
|
||||
for i, usr := range visibleUsers() {
|
||||
if usr.Name == u.Name {
|
||||
table.Select(i*2, 0)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
return nil
|
||||
case 'd':
|
||||
if visIdx < 0 || visIdx >= len(users) {
|
||||
return nil
|
||||
}
|
||||
uName := users[visIdx].Name
|
||||
a.confirmDelete(fmt.Sprintf("user %q", uName), func() {
|
||||
cfgIdx := findUserGlobalIdx(uName)
|
||||
if cfgIdx < 0 {
|
||||
return
|
||||
}
|
||||
all := a.cfg.Provider.Users
|
||||
a.cfg.Provider.Users = append(all[:cfgIdx], all[cfgIdx+1:]...)
|
||||
a.save()
|
||||
a.refreshModelCache(rebuild)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
return a.buildShell(
|
||||
"users",
|
||||
table,
|
||||
" [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] models [#ff00ff]ESC:[-] back ",
|
||||
)
|
||||
}
|
||||
|
||||
func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) {
|
||||
name := ""
|
||||
userType := "key"
|
||||
key := ""
|
||||
title := " ADD USER "
|
||||
|
||||
if existing != nil {
|
||||
name = existing.Name
|
||||
userType = existing.Type
|
||||
key = existing.Key
|
||||
title = " EDIT USER "
|
||||
}
|
||||
|
||||
typeOptions := []string{"key", "OAuth"}
|
||||
typeIdx := 0
|
||||
for i, t := range typeOptions {
|
||||
if t == userType {
|
||||
typeIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
form := tview.NewForm()
|
||||
form.
|
||||
AddInputField("Name", name, 20, nil, func(text string) { name = text }).
|
||||
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }).
|
||||
AddPasswordField("Key", key, 28, '*', func(text string) { key = text }).
|
||||
AddButton("SAVE", func() {
|
||||
if name == "" {
|
||||
a.showError("Name is required")
|
||||
return
|
||||
}
|
||||
if existing == nil {
|
||||
for _, u := range a.cfg.Provider.Users {
|
||||
if u.Scheme == schemeName && u.Name == name {
|
||||
a.showError(fmt.Sprintf("User name %q already exists for this scheme", name))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
a.hideModal("user-form")
|
||||
onSave(tuicfg.User{Name: name, Scheme: schemeName, Type: userType, Key: key})
|
||||
}).
|
||||
AddButton("CANCEL", func() {
|
||||
a.hideModal("user-form")
|
||||
})
|
||||
|
||||
form.SetBorder(true).
|
||||
SetTitle(" [::b]" + title + " ").
|
||||
SetTitleColor(tcell.NewHexColor(0x39ff14)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
|
||||
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
|
||||
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
|
||||
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
|
||||
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
a.hideModal("user-form")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
a.showModal("user-form", centeredForm(form, 4, 13))
|
||||
}
|
||||
@@ -28,6 +28,8 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
|
||||
return fmt.Errorf("error loading config: %w", err)
|
||||
}
|
||||
|
||||
logger.ConfigureFromEnv()
|
||||
|
||||
if debug {
|
||||
logger.SetLevel(logger.DEBUG)
|
||||
fmt.Println("🔍 Debug mode enabled")
|
||||
|
||||
@@ -17,24 +17,24 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
|
||||
supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity, antigravity"
|
||||
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 {
|
||||
case "openai":
|
||||
return authLoginOpenAI(useDeviceCode)
|
||||
return authLoginOpenAI(useDeviceCode, noBrowser)
|
||||
case "anthropic":
|
||||
return authLoginAnthropic(useOauth)
|
||||
case "google-antigravity", "antigravity":
|
||||
return authLoginGoogleAntigravity()
|
||||
return authLoginGoogleAntigravity(noBrowser)
|
||||
default:
|
||||
return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func authLoginOpenAI(useDeviceCode bool) error {
|
||||
func authLoginOpenAI(useDeviceCode bool, noBrowser bool) error {
|
||||
cfg := auth.OpenAIOAuthConfig()
|
||||
|
||||
var cred *auth.AuthCredential
|
||||
@@ -43,7 +43,7 @@ func authLoginOpenAI(useDeviceCode bool) error {
|
||||
if useDeviceCode {
|
||||
cred, err = auth.LoginDeviceCode(cfg)
|
||||
} else {
|
||||
cred, err = auth.LoginBrowser(cfg)
|
||||
cred, err = auth.LoginBrowserWithOptions(cfg, auth.LoginBrowserOptions{NoBrowser: noBrowser})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -59,7 +59,7 @@ func authLoginOpenAI(useDeviceCode bool) error {
|
||||
// Update or add openai in ModelList
|
||||
foundOpenAI := false
|
||||
for i := range appCfg.ModelList {
|
||||
if isOpenAIModel(appCfg.ModelList[i].Model) {
|
||||
if isOpenAIModel(appCfg.ModelList[i]) {
|
||||
appCfg.ModelList[i].AuthMethod = "oauth"
|
||||
foundOpenAI = true
|
||||
break
|
||||
@@ -92,10 +92,10 @@ func authLoginOpenAI(useDeviceCode bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func authLoginGoogleAntigravity() error {
|
||||
func authLoginGoogleAntigravity(noBrowser bool) error {
|
||||
cfg := auth.GoogleAntigravityOAuthConfig()
|
||||
|
||||
cred, err := auth.LoginBrowser(cfg)
|
||||
cred, err := auth.LoginBrowserWithOptions(cfg, auth.LoginBrowserOptions{NoBrowser: noBrowser})
|
||||
if err != nil {
|
||||
return fmt.Errorf("login failed: %w", err)
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func authLoginGoogleAntigravity() error {
|
||||
// Update or add antigravity in ModelList
|
||||
foundAntigravity := false
|
||||
for i := range appCfg.ModelList {
|
||||
if isAntigravityModel(appCfg.ModelList[i].Model) {
|
||||
if isAntigravityModel(appCfg.ModelList[i]) {
|
||||
appCfg.ModelList[i].AuthMethod = "oauth"
|
||||
foundAntigravity = true
|
||||
break
|
||||
@@ -206,7 +206,7 @@ func authLoginAnthropicSetupToken() error {
|
||||
if err == nil {
|
||||
found := false
|
||||
for i := range appCfg.ModelList {
|
||||
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
||||
if isAnthropicModel(appCfg.ModelList[i]) {
|
||||
appCfg.ModelList[i].AuthMethod = "oauth"
|
||||
found = true
|
||||
break
|
||||
@@ -282,7 +282,7 @@ func authLoginPasteToken(provider string) error {
|
||||
// Update ModelList
|
||||
found := false
|
||||
for i := range appCfg.ModelList {
|
||||
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
||||
if isAnthropicModel(appCfg.ModelList[i]) {
|
||||
appCfg.ModelList[i].AuthMethod = "token"
|
||||
found = true
|
||||
break
|
||||
@@ -300,7 +300,7 @@ func authLoginPasteToken(provider string) error {
|
||||
// Update ModelList
|
||||
found := false
|
||||
for i := range appCfg.ModelList {
|
||||
if isOpenAIModel(appCfg.ModelList[i].Model) {
|
||||
if isOpenAIModel(appCfg.ModelList[i]) {
|
||||
appCfg.ModelList[i].AuthMethod = "token"
|
||||
found = true
|
||||
break
|
||||
@@ -342,15 +342,15 @@ func authLogoutCmd(provider string) error {
|
||||
for i := range appCfg.ModelList {
|
||||
switch provider {
|
||||
case "openai":
|
||||
if isOpenAIModel(appCfg.ModelList[i].Model) {
|
||||
if isOpenAIModel(appCfg.ModelList[i]) {
|
||||
appCfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
case "anthropic":
|
||||
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
||||
if isAnthropicModel(appCfg.ModelList[i]) {
|
||||
appCfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
case "google-antigravity", "antigravity":
|
||||
if isAntigravityModel(appCfg.ModelList[i].Model) {
|
||||
if isAntigravityModel(appCfg.ModelList[i]) {
|
||||
appCfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
}
|
||||
@@ -484,22 +484,20 @@ func authModelsCmd() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// isAntigravityModel checks if a model string belongs to antigravity provider
|
||||
func isAntigravityModel(model string) bool {
|
||||
return model == "antigravity" ||
|
||||
model == "google-antigravity" ||
|
||||
strings.HasPrefix(model, "antigravity/") ||
|
||||
strings.HasPrefix(model, "google-antigravity/")
|
||||
// isAntigravityModel checks if a model config belongs to an Antigravity provider.
|
||||
func isAntigravityModel(modelCfg *config.ModelConfig) bool {
|
||||
protocol, _ := providers.ExtractProtocol(modelCfg)
|
||||
return protocol == "antigravity" || protocol == "google-antigravity"
|
||||
}
|
||||
|
||||
// isOpenAIModel checks if a model string belongs to openai provider
|
||||
func isOpenAIModel(model string) bool {
|
||||
return model == "openai" ||
|
||||
strings.HasPrefix(model, "openai/")
|
||||
// isOpenAIModel checks if a model config belongs to the OpenAI provider.
|
||||
func isOpenAIModel(modelCfg *config.ModelConfig) bool {
|
||||
protocol, _ := providers.ExtractProtocol(modelCfg)
|
||||
return protocol == "openai"
|
||||
}
|
||||
|
||||
// isAnthropicModel checks if a model string belongs to anthropic provider
|
||||
func isAnthropicModel(model string) bool {
|
||||
return model == "anthropic" ||
|
||||
strings.HasPrefix(model, "anthropic/")
|
||||
// isAnthropicModel checks if a model config belongs to the Anthropic provider.
|
||||
func isAnthropicModel(modelCfg *config.ModelConfig) bool {
|
||||
protocol, _ := providers.ExtractProtocol(modelCfg)
|
||||
return protocol == "anthropic"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ func newLoginCommand() *cobra.Command {
|
||||
provider string
|
||||
useDeviceCode bool
|
||||
useOauth bool
|
||||
noBrowser bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -14,12 +15,15 @@ func newLoginCommand() *cobra.Command {
|
||||
Short: "Login via OAuth or paste token",
|
||||
Args: cobra.NoArgs,
|
||||
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(&noBrowser, "no-browser", false, "Do not auto-open a browser during OAuth login")
|
||||
cmd.Flags().BoolVar(
|
||||
&useOauth, "setup-token", false,
|
||||
"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.NotNil(t, cmd.Flags().Lookup("device-code"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("no-browser"))
|
||||
|
||||
providerFlag := cmd.Flags().Lookup("provider")
|
||||
require.NotNil(t, providerFlag)
|
||||
|
||||
@@ -1,12 +1,53 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"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) {
|
||||
cmd := newStatusCommand()
|
||||
|
||||
@@ -16,3 +57,47 @@ func TestNewStatusSubcommand(t *testing.T) {
|
||||
|
||||
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"))
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -155,11 +156,31 @@ func defaultWeComQRFlowOptions(timeout time.Duration) wecomQRFlowOptions {
|
||||
}
|
||||
|
||||
func applyWeComAuthResult(cfg *config.Config, botInfo wecomQRBotInfo) {
|
||||
cfg.Channels.WeCom.Enabled = true
|
||||
cfg.Channels.WeCom.BotID = botInfo.BotID
|
||||
cfg.Channels.WeCom.SetSecret(botInfo.Secret)
|
||||
if strings.TrimSpace(cfg.Channels.WeCom.WebSocketURL) == "" {
|
||||
cfg.Channels.WeCom.WebSocketURL = wecomDefaultWebSocketURL
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package auth
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -19,6 +20,19 @@ import (
|
||||
"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()
|
||||
|
||||
@@ -53,7 +67,7 @@ func TestBuildWeComQRCodePageURL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFetchWeComQRCode(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
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"))
|
||||
@@ -61,7 +75,6 @@ func TestFetchWeComQRCode(t *testing.T) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":{"scode":"scode-1","auth_url":"https://example.com/qr"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{
|
||||
HTTPClient: server.Client(),
|
||||
@@ -78,7 +91,7 @@ func TestFetchWeComQRCode(t *testing.T) {
|
||||
func TestPollWeComQRCodeResult(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
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"))
|
||||
@@ -92,7 +105,6 @@ func TestPollWeComQRCodeResult(t *testing.T) {
|
||||
_, _ = w.Write([]byte(`{"data":{"status":"success","bot_info":{"botid":"bot-1","secret":"secret-1"}}}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var output bytes.Buffer
|
||||
opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{
|
||||
@@ -112,17 +124,23 @@ func TestPollWeComQRCodeResult(t *testing.T) {
|
||||
|
||||
func TestApplyWeComAuthResult(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Channels.WeCom.WebSocketURL = ""
|
||||
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, cfg.Channels.WeCom.Enabled)
|
||||
assert.Equal(t, "bot-1", cfg.Channels.WeCom.BotID)
|
||||
assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret())
|
||||
assert.Equal(t, wecomDefaultWebSocketURL, cfg.Channels.WeCom.WebSocketURL)
|
||||
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) {
|
||||
@@ -149,9 +167,13 @@ func TestAuthWeComCmdWithScanner(t *testing.T) {
|
||||
|
||||
cfg, err := config.LoadConfig(internal.GetConfigPath())
|
||||
require.NoError(t, err)
|
||||
assert.True(t, cfg.Channels.WeCom.Enabled)
|
||||
assert.Equal(t, "bot-1", cfg.Channels.WeCom.BotID)
|
||||
assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret())
|
||||
assert.Equal(t, wecomDefaultWebSocketURL, cfg.Channels.WeCom.WebSocketURL)
|
||||
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.")
|
||||
}
|
||||
|
||||
@@ -95,14 +95,24 @@ func saveWeixinConfig(token, baseURL, proxy string) error {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
cfg.Channels.Weixin.Enabled = true
|
||||
cfg.Channels.Weixin.SetToken(token)
|
||||
const defaultBase = "https://ilinkai.weixin.qq.com/"
|
||||
if baseURL != "" && baseURL != defaultBase {
|
||||
cfg.Channels.Weixin.BaseURL = baseURL
|
||||
bc := cfg.Channels.GetByType(config.ChannelWeixin)
|
||||
if bc == nil {
|
||||
bc = &config.Channel{Type: config.ChannelWeixin}
|
||||
cfg.Channels[config.ChannelWeixin] = bc
|
||||
}
|
||||
if proxy != "" {
|
||||
cfg.Channels.Weixin.Proxy = proxy
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
every int64
|
||||
cronExp string
|
||||
deliver bool
|
||||
channel string
|
||||
to string
|
||||
)
|
||||
@@ -37,7 +36,7 @@ func newAddCommand(storePath func() string) *cobra.Command {
|
||||
}
|
||||
|
||||
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 {
|
||||
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().Int64VarP(&every, "every", "e", 0, "Run every N seconds")
|
||||
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(&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("cron"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("deliver"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("to"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("channel"))
|
||||
|
||||
|
||||
@@ -2,19 +2,34 @@ package gateway
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/gateway"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/netbind"
|
||||
"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 {
|
||||
var debug bool
|
||||
var noTruncate bool
|
||||
var allowEmpty bool
|
||||
var host string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "gateway",
|
||||
@@ -33,7 +48,25 @@ func NewGatewayCommand() *cobra.Command {
|
||||
|
||||
return nil
|
||||
},
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
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,
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -29,4 +29,38 @@ func TestNewGatewayCommand(t *testing.T) {
|
||||
assert.True(t, cmd.HasFlags())
|
||||
assert.NotNil(t, cmd.Flags().Lookup("debug"))
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,7 @@ const Logo = pkg.Logo
|
||||
// GetPicoclawHome returns the picoclaw home directory.
|
||||
// Priority: $PICOCLAW_HOME > ~/.picoclaw
|
||||
func GetPicoclawHome() string {
|
||||
if home := os.Getenv(config.EnvHome); home != "" {
|
||||
return home
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, pkg.DefaultPicoClawHome)
|
||||
return config.GetHome()
|
||||
}
|
||||
|
||||
func GetConfigPath() string {
|
||||
|
||||
@@ -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 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:
|
||||
picoclaw model # Show current default model
|
||||
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 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
|
||||
(running at localhost:8000 by default) which does not require an API key.`,
|
||||
@@ -51,6 +57,8 @@ Note: 'local-model' is a special value for using a local VLLM server
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(newAddCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -66,6 +74,9 @@ func showCurrentModel(cfg *config.Config) {
|
||||
fmt.Println("\nAvailable models in your config:")
|
||||
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) {
|
||||
@@ -81,7 +92,7 @@ func listAvailableModels(cfg *config.Config) {
|
||||
if model.ModelName == defaultModel {
|
||||
marker = "> "
|
||||
}
|
||||
if model.APIKey() == "" {
|
||||
if !model.Enabled {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%s- %s (%s)\n", marker, model.ModelName, model.Model)
|
||||
@@ -92,7 +103,7 @@ func setDefaultModel(configPath string, cfg *config.Config, modelName string) er
|
||||
// Validate that the model exists in model_list
|
||||
modelFound := false
|
||||
for _, model := range cfg.ModelList {
|
||||
if model.APIKey() != "" && model.ModelName == modelName {
|
||||
if model.Enabled && model.ModelName == modelName {
|
||||
modelFound = true
|
||||
break
|
||||
}
|
||||
|
||||
@@ -58,24 +58,27 @@ func TestNewModelCommand(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
|
||||
cfg := (&config.Config{
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "gpt-4",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4"},
|
||||
{ModelName: "claude-3", Model: "anthropic/claude-3"},
|
||||
{
|
||||
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,
|
||||
},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"gpt-4": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"claude-3": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
}
|
||||
|
||||
output := captureStdout(func() {
|
||||
showCurrentModel(cfg)
|
||||
@@ -88,20 +91,21 @@ func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
|
||||
cfg := (&config.Config{
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4"},
|
||||
{
|
||||
ModelName: "gpt-4",
|
||||
Model: "openai/gpt-4",
|
||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"gpt-4": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
}
|
||||
|
||||
output := captureStdout(func() {
|
||||
showCurrentModel(cfg)
|
||||
@@ -124,25 +128,28 @@ func TestListAvailableModels_Empty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestListAvailableModels_WithModels(t *testing.T) {
|
||||
cfg := (&config.Config{
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "gpt-4",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4"},
|
||||
{ModelName: "claude-3", Model: "anthropic/claude-3"},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{ModelName: "no-key-model", Model: "openai/test"},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"gpt-4": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"claude-3": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
}
|
||||
|
||||
output := captureStdout(func() {
|
||||
listAvailableModels(cfg)
|
||||
@@ -157,24 +164,27 @@ func TestListAvailableModels_WithModels(t *testing.T) {
|
||||
func TestSetDefaultModel_ValidModel(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
cfg := (&config.Config{
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "old-model",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "new-model", Model: "openai/new-model"},
|
||||
{ModelName: "old-model", Model: "openai/old-model"},
|
||||
{
|
||||
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,
|
||||
},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"new-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"old-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
}
|
||||
|
||||
output := captureStdout(func() {
|
||||
err := setDefaultModel(configPath, cfg, "new-model")
|
||||
@@ -192,20 +202,21 @@ func TestSetDefaultModel_ValidModel(t *testing.T) {
|
||||
func TestSetDefaultModel_InvalidModel(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
cfg := (&config.Config{
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "existing-model",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "existing-model", Model: "openai/existing"},
|
||||
{
|
||||
ModelName: "existing-model",
|
||||
Model: "openai/existing",
|
||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"existing-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
}
|
||||
|
||||
assert.Error(t, setDefaultModel(configPath, cfg, "nonexistent-model"))
|
||||
}
|
||||
@@ -213,24 +224,22 @@ func TestSetDefaultModel_InvalidModel(t *testing.T) {
|
||||
func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
cfg := (&config.Config{
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "existing-model",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "existing-model", Model: "openai/existing"},
|
||||
{
|
||||
ModelName: "existing-model",
|
||||
Model: "openai/existing",
|
||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||
Enabled: true,
|
||||
},
|
||||
{ModelName: "no-key-model", Model: "openai/nokey"},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"existing-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"no-key-model": {
|
||||
APIKeys: []string{""},
|
||||
},
|
||||
}})
|
||||
}
|
||||
|
||||
assert.Error(t, setDefaultModel(configPath, cfg, "no-key-model"))
|
||||
}
|
||||
@@ -239,20 +248,21 @@ func TestSetDefaultModel_SaveConfigError(t *testing.T) {
|
||||
// Use an invalid path to trigger save error
|
||||
invalidPath := "/nonexistent/directory/config.json"
|
||||
|
||||
cfg := (&config.Config{
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "old-model",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "new-model", Model: "openai/new-model"},
|
||||
{
|
||||
ModelName: "new-model",
|
||||
Model: "openai/new-model",
|
||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"new-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
}
|
||||
|
||||
err := setDefaultModel(invalidPath, cfg, "new-model")
|
||||
|
||||
@@ -284,20 +294,21 @@ func TestModelCommandExecution_Show(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
// Create a test config
|
||||
cfg := (&config.Config{
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "test-model",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "test-model", Model: "openai/test"},
|
||||
{
|
||||
ModelName: "test-model",
|
||||
Model: "openai/test",
|
||||
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"test-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
}
|
||||
|
||||
err := config.SaveConfig(configPath, cfg)
|
||||
require.NoError(t, err)
|
||||
@@ -315,25 +326,27 @@ func TestModelCommandExecution_Show(t *testing.T) {
|
||||
func TestModelCommandExecution_Set(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
sec := &config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"old-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"new-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}}
|
||||
cfg := (&config.Config{
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "old-model",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "old-model", Model: "openai/old"},
|
||||
{ModelName: "new-model", Model: "openai/new"},
|
||||
{
|
||||
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,
|
||||
},
|
||||
},
|
||||
}).WithSecurity(sec)
|
||||
}
|
||||
|
||||
err := config.SaveConfig(configPath, cfg)
|
||||
require.NoError(t, err)
|
||||
@@ -357,28 +370,33 @@ func TestModelCommandExecution_TooManyArgs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestListAvailableModels_MarkerLogic(t *testing.T) {
|
||||
cfg := (&config.Config{
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "middle-model",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "first-model", Model: "openai/first"},
|
||||
{ModelName: "middle-model", Model: "openai/middle"},
|
||||
{ModelName: "last-model", Model: "openai/last"},
|
||||
{
|
||||
ModelName: "first-model",
|
||||
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,
|
||||
},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"first-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"middle-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"last-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
}
|
||||
|
||||
output := captureStdout(func() {
|
||||
listAvailableModels(cfg)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
//go:generate cp -r ../../../../workspace .
|
||||
//go:generate go run ../../../../scripts/copydir.go ../../../../workspace ./workspace
|
||||
//go:embed workspace
|
||||
var embeddedFiles embed.FS
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"golang.org/x/term"
|
||||
|
||||
"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/credential"
|
||||
)
|
||||
@@ -79,25 +80,7 @@ func onboard(encrypt bool) {
|
||||
workspace := cfg.WorkspacePath()
|
||||
createWorkspaceTemplates(workspace)
|
||||
|
||||
fmt.Printf("\n%s picoclaw is ready!\n", internal.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("")
|
||||
fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"")
|
||||
cliui.PrintOnboardComplete(internal.Logo, encrypt, configPath)
|
||||
}
|
||||
|
||||
// promptPassphrase reads the encryption passphrase twice from the terminal
|
||||
@@ -189,6 +172,9 @@ func copyEmbeddedToTarget(targetDir string) error {
|
||||
if err != nil {
|
||||
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
|
||||
targetPath := filepath.Join(targetDir, new_path)
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
type deps struct {
|
||||
workspace string
|
||||
installer *skills.SkillInstaller
|
||||
skillsLoader *skills.SkillsLoader
|
||||
}
|
||||
|
||||
@@ -29,15 +28,6 @@ func NewSkillsCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
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
|
||||
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) {
|
||||
if d.skillsLoader == nil {
|
||||
return nil, fmt.Errorf("skills loader is not initialized")
|
||||
@@ -75,10 +58,10 @@ func NewSkillsCommand() *cobra.Command {
|
||||
|
||||
cmd.AddCommand(
|
||||
newListCommand(loaderFn),
|
||||
newInstallCommand(installerFn),
|
||||
newInstallCommand(),
|
||||
newInstallBuiltinCommand(workspaceFn),
|
||||
newListBuiltinCommand(),
|
||||
newRemoveCommand(installerFn),
|
||||
newRemoveCommand(),
|
||||
newSearchCommand(),
|
||||
newShowCommand(loaderFn),
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -11,12 +12,23 @@ import (
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
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) {
|
||||
allSkills := loader.ListSkills()
|
||||
|
||||
@@ -35,61 +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).
|
||||
func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) error {
|
||||
func skillsInstallFromRegistry(cfg *config.Config, registryName, target string) error {
|
||||
err := utils.ValidateSkillIdentifier(registryName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("✗ invalid registry name: %w", err)
|
||||
}
|
||||
|
||||
err = utils.ValidateSkillIdentifier(slug)
|
||||
if err != nil {
|
||||
return fmt.Errorf("✗ invalid slug: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
|
||||
|
||||
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
|
||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||
ClawHub: skills.ClawHubConfig{
|
||||
Enabled: clawHubConfig.Enabled,
|
||||
BaseURL: clawHubConfig.BaseURL,
|
||||
AuthToken: clawHubConfig.AuthToken(),
|
||||
SearchPath: clawHubConfig.SearchPath,
|
||||
SkillsPath: clawHubConfig.SkillsPath,
|
||||
DownloadPath: clawHubConfig.DownloadPath,
|
||||
Timeout: clawHubConfig.Timeout,
|
||||
MaxZipSize: clawHubConfig.MaxZipSize,
|
||||
MaxResponseSize: clawHubConfig.MaxResponseSize,
|
||||
},
|
||||
})
|
||||
registryMgr := skills.NewRegistryManagerFromToolsConfig(cfg.Tools.Skills)
|
||||
|
||||
registry := registryMgr.GetRegistry(registryName)
|
||||
if registry == nil {
|
||||
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()
|
||||
targetDir := filepath.Join(workspace, "skills", slug)
|
||||
targetDir := filepath.Join(workspace, "skills", dirName)
|
||||
|
||||
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)
|
||||
@@ -99,7 +82,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er
|
||||
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 {
|
||||
rmErr := os.RemoveAll(targetDir)
|
||||
if rmErr != nil {
|
||||
@@ -114,14 +97,34 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er
|
||||
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 {
|
||||
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 != "" {
|
||||
fmt.Printf(" %s\n", result.Summary)
|
||||
}
|
||||
@@ -129,15 +132,51 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er
|
||||
return nil
|
||||
}
|
||||
|
||||
func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
|
||||
fmt.Printf("Removing skill '%s'...\n", skillName)
|
||||
|
||||
if err := installer.Uninstall(skillName); err != nil {
|
||||
fmt.Printf("✗ Failed to remove skill: %v\n", err)
|
||||
os.Exit(1)
|
||||
func writeInstalledSkillOriginMeta(targetDir string, meta installedSkillOriginMeta) error {
|
||||
data, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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) {
|
||||
@@ -237,21 +276,7 @@ func skillsSearchCmd(query string) {
|
||||
return
|
||||
}
|
||||
|
||||
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
|
||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||
ClawHub: skills.ClawHubConfig{
|
||||
Enabled: clawHubConfig.Enabled,
|
||||
BaseURL: clawHubConfig.BaseURL,
|
||||
AuthToken: clawHubConfig.AuthToken(),
|
||||
SearchPath: clawHubConfig.SearchPath,
|
||||
SkillsPath: clawHubConfig.SkillsPath,
|
||||
DownloadPath: clawHubConfig.DownloadPath,
|
||||
Timeout: clawHubConfig.Timeout,
|
||||
MaxZipSize: clawHubConfig.MaxZipSize,
|
||||
MaxResponseSize: clawHubConfig.MaxResponseSize,
|
||||
},
|
||||
})
|
||||
registryMgr := skills.NewRegistryManagerFromToolsConfig(cfg.Tools.Skills)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
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/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
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "install",
|
||||
Short: "Install skill from GitHub",
|
||||
Short: "Install skill from GitHub or a registry",
|
||||
Example: `
|
||||
picoclaw skills install sipeed/picoclaw-skills/weather
|
||||
picoclaw skills install --registry clawhub github
|
||||
@@ -34,21 +33,15 @@ picoclaw skills install --registry clawhub github
|
||||
return nil
|
||||
},
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
installer, err := installerFn()
|
||||
cfg, err := internal.LoadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if registry != "" {
|
||||
cfg, err := internal.LoadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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) {
|
||||
cmd := newInstallCommand(nil)
|
||||
cmd := newInstallCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
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.NotNil(t, cmd.RunE)
|
||||
@@ -79,7 +79,7 @@ func TestInstallCommandArgs(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := newInstallCommand(nil)
|
||||
cmd := newInstallCommand()
|
||||
|
||||
if tt.registry != "" {
|
||||
require.NoError(t, cmd.Flags().Set("registry", tt.registry))
|
||||
|
||||
@@ -3,10 +3,10 @@ package skills
|
||||
import (
|
||||
"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{
|
||||
Use: "remove",
|
||||
Aliases: []string{"rm", "uninstall"},
|
||||
@@ -14,12 +14,11 @@ func newRemoveCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra
|
||||
Args: cobra.ExactArgs(1),
|
||||
Example: `picoclaw skills remove weather`,
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
installer, err := installerFn()
|
||||
cfg, err := internal.LoadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
skillsRemoveCmd(installer, args[0])
|
||||
return nil
|
||||
return skillsRemoveFromWorkspace(cfg.WorkspacePath(), cfg.Tools.Skills, args[0])
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func TestNewRemoveSubcommand(t *testing.T) {
|
||||
cmd := newRemoveCommand(nil)
|
||||
cmd := newRemoveCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
func statusCmd() {
|
||||
@@ -17,43 +19,127 @@ func statusCmd() {
|
||||
}
|
||||
|
||||
configPath := internal.GetConfigPath()
|
||||
|
||||
fmt.Printf("%s picoclaw Status\n", internal.Logo)
|
||||
fmt.Printf("Version: %s\n", config.FormatVersion())
|
||||
build, _ := config.FormatBuildInfo()
|
||||
if build != "" {
|
||||
fmt.Printf("Build: %s\n", build)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
fmt.Println("Config:", configPath, "✓")
|
||||
} else {
|
||||
fmt.Println("Config:", configPath, "✗")
|
||||
}
|
||||
_, configStatErr := os.Stat(configPath)
|
||||
configOK := configStatErr == nil
|
||||
|
||||
workspace := cfg.WorkspacePath()
|
||||
if _, err := os.Stat(workspace); err == nil {
|
||||
fmt.Println("Workspace:", workspace, "✓")
|
||||
} else {
|
||||
fmt.Println("Workspace:", workspace, "✗")
|
||||
_, wsErr := os.Stat(workspace)
|
||||
wsOK := wsErr == nil
|
||||
|
||||
report := cliui.StatusReport{
|
||||
Logo: internal.Logo,
|
||||
Version: config.FormatVersion(),
|
||||
Build: build,
|
||||
ConfigPath: configPath,
|
||||
ConfigOK: configOK,
|
||||
WorkspacePath: workspace,
|
||||
WorkspaceOK: wsOK,
|
||||
Model: cfg.Agents.Defaults.GetModelName(),
|
||||
}
|
||||
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName())
|
||||
if configOK {
|
||||
// PicoClaw moved to a model-centric configuration (model_list). Status should
|
||||
// not depend on a legacy cfg.Providers field (which may not exist under some
|
||||
// build tags). We infer provider availability from model_list entries.
|
||||
hasProtocolKey := func(protocol string) bool {
|
||||
want := providers.NormalizeProvider(protocol)
|
||||
for _, m := range cfg.ModelList {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
got, _ := providers.ExtractProtocol(m)
|
||||
if got == want && m.APIKey() != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
findLocalModelBase := func(modelName string) (string, bool) {
|
||||
for _, m := range cfg.ModelList {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if m.ModelName == modelName && m.APIBase != "" {
|
||||
return m.APIBase, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
findProtocolBase := func(protocol string) (string, bool) {
|
||||
want := providers.NormalizeProvider(protocol)
|
||||
for _, m := range cfg.ModelList {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
got, _ := providers.ExtractProtocol(m)
|
||||
if got == want && m.APIBase != "" {
|
||||
return m.APIBase, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
hasOpenRouter := hasProtocolKey("openrouter")
|
||||
hasAnthropic := hasProtocolKey("anthropic")
|
||||
hasOpenAI := hasProtocolKey("openai")
|
||||
hasGemini := hasProtocolKey("gemini")
|
||||
hasZhipu := hasProtocolKey("zhipu")
|
||||
hasQwen := hasProtocolKey("qwen")
|
||||
hasGroq := hasProtocolKey("groq")
|
||||
hasMoonshot := hasProtocolKey("moonshot")
|
||||
hasDeepSeek := hasProtocolKey("deepseek")
|
||||
hasVolcEngine := hasProtocolKey("volcengine")
|
||||
hasNvidia := hasProtocolKey("nvidia")
|
||||
|
||||
// Local endpoints: allow both the special reserved name and protocol-based entries.
|
||||
vllmBase, hasVLLM := findLocalModelBase("local-model")
|
||||
if !hasVLLM {
|
||||
vllmBase, hasVLLM = findProtocolBase("vllm")
|
||||
}
|
||||
ollamaBase, hasOllama := findProtocolBase("ollama")
|
||||
|
||||
val := func(enabled bool, extra ...string) string {
|
||||
if enabled {
|
||||
if len(extra) > 0 && extra[0] != "" {
|
||||
return "✓ " + extra[0]
|
||||
}
|
||||
return "✓"
|
||||
}
|
||||
return "not set"
|
||||
}
|
||||
|
||||
report.Providers = []cliui.ProviderRow{
|
||||
{Name: "OpenRouter API", Val: val(hasOpenRouter)},
|
||||
{Name: "Anthropic API", Val: val(hasAnthropic)},
|
||||
{Name: "OpenAI API", Val: val(hasOpenAI)},
|
||||
{Name: "Gemini API", Val: val(hasGemini)},
|
||||
{Name: "Zhipu API", Val: val(hasZhipu)},
|
||||
{Name: "Qwen API", Val: val(hasQwen)},
|
||||
{Name: "Groq API", Val: val(hasGroq)},
|
||||
{Name: "Moonshot API", Val: val(hasMoonshot)},
|
||||
{Name: "DeepSeek API", Val: val(hasDeepSeek)},
|
||||
{Name: "VolcEngine API", Val: val(hasVolcEngine)},
|
||||
{Name: "Nvidia API", Val: val(hasNvidia)},
|
||||
{Name: "vLLM / local", Val: val(hasVLLM, vllmBase)},
|
||||
{Name: "Ollama", Val: val(hasOllama, ollamaBase)},
|
||||
}
|
||||
|
||||
store, _ := auth.LoadStore()
|
||||
if store != nil && len(store.Credentials) > 0 {
|
||||
fmt.Println("\nOAuth/Token Auth:")
|
||||
for provider, cred := range store.Credentials {
|
||||
status := "authenticated"
|
||||
st := "authenticated"
|
||||
if cred.IsExpired() {
|
||||
status = "expired"
|
||||
st = "expired"
|
||||
} else if cred.NeedsRefresh() {
|
||||
status = "needs refresh"
|
||||
st = "needs refresh"
|
||||
}
|
||||
fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, status)
|
||||
report.OAuthLines = append(report.OAuthLines,
|
||||
fmt.Sprintf("%s (%s): %s", provider, cred.AuthMethod, st))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cliui.PrintStatus(report)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package status
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
|
||||
oldStdout := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Pipe() error = %v", err)
|
||||
}
|
||||
os.Stdout = w
|
||||
|
||||
fn()
|
||||
|
||||
_ = w.Close()
|
||||
os.Stdout = oldStdout
|
||||
defer r.Close()
|
||||
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.Copy(&buf, r); err != nil {
|
||||
t.Fatalf("io.Copy() error = %v", err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestStatusCmd_RecognizesProviderFieldWithoutModelPrefix(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
workspace := filepath.Join(tmpDir, "workspace")
|
||||
if err := os.MkdirAll(workspace, 0o755); err != nil {
|
||||
t.Fatalf("os.MkdirAll() error = %v", err)
|
||||
}
|
||||
|
||||
t.Setenv(config.EnvConfig, configPath)
|
||||
t.Setenv(config.EnvHome, tmpDir)
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "gpt-5.4",
|
||||
Workspace: workspace,
|
||||
Provider: "openai",
|
||||
MaxTokens: 65536,
|
||||
Temperature: nil,
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{
|
||||
ModelName: "gpt-5.4",
|
||||
Provider: "openai",
|
||||
Model: "gpt-5.4",
|
||||
APIBase: "https://api.openai.com/v1",
|
||||
APIKeys: config.SimpleSecureStrings("test-key"),
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
ModelName: "qwen-plus",
|
||||
Provider: "qwen",
|
||||
Model: "qwen-plus",
|
||||
APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
APIKeys: config.SimpleSecureStrings("test-key"),
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("config.SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
output := captureStdout(t, statusCmd)
|
||||
|
||||
if !strings.Contains(output, "OpenAI API: \u2713") {
|
||||
t.Fatalf("status output missing OpenAI provider: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "Qwen API: \u2713") {
|
||||
t.Fatalf("status output missing Qwen provider: %s", output)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
@@ -23,12 +22,6 @@ func NewVersionCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
func printVersion() {
|
||||
fmt.Printf("%s picoclaw %s\n", internal.Logo, config.FormatVersion())
|
||||
build, goVer := config.FormatBuildInfo()
|
||||
if build != "" {
|
||||
fmt.Printf(" Build: %s\n", build)
|
||||
}
|
||||
if goVer != "" {
|
||||
fmt.Printf(" Go: %s\n", goVer)
|
||||
}
|
||||
cliui.PrintVersion(internal.Logo, "picoclaw "+config.FormatVersion(), build, goVer)
|
||||
}
|
||||
|
||||
@@ -9,14 +9,17 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/mcp"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/model"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard"
|
||||
@@ -24,17 +27,60 @@ import (
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/updater"
|
||||
)
|
||||
|
||||
var rootNoColor bool
|
||||
|
||||
func syncCliUIColor(root *cobra.Command) {
|
||||
no, _ := root.PersistentFlags().GetBool("no-color")
|
||||
cliui.Init(no || os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb")
|
||||
}
|
||||
|
||||
// earlyColorDisabled matches lipgloss/banner behavior from env and argv before Cobra parses flags.
|
||||
func earlyColorDisabled() bool {
|
||||
if os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" {
|
||||
return true
|
||||
}
|
||||
for i := 1; i < len(os.Args); i++ {
|
||||
arg := os.Args[i]
|
||||
if arg == "--no-color" || arg == "--no-color=true" || arg == "--no-color=1" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func NewPicoclawCommand() *cobra.Command {
|
||||
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, config.GetVersion())
|
||||
short := fmt.Sprintf("%s PicoClaw — personal AI assistant", internal.Logo)
|
||||
long := fmt.Sprintf(`%s PicoClaw is a lightweight personal AI assistant.
|
||||
|
||||
Version: %s`, internal.Logo, config.FormatVersion())
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "picoclaw",
|
||||
Short: short,
|
||||
Example: "picoclaw version",
|
||||
Use: "picoclaw",
|
||||
Short: short,
|
||||
Long: long,
|
||||
Example: `picoclaw version
|
||||
picoclaw onboard
|
||||
picoclaw --no-color status`,
|
||||
SilenceErrors: true,
|
||||
// Avoid plain UsageString() on stderr/stdout when a command fails; cliui
|
||||
// renders matching panels on stderr instead.
|
||||
SilenceUsage: true,
|
||||
PersistentPreRun: func(c *cobra.Command, _ []string) {
|
||||
syncCliUIColor(c.Root())
|
||||
},
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().BoolVar(&rootNoColor, "no-color", false,
|
||||
"Disable colors (boxed layout unchanged)")
|
||||
|
||||
cmd.SetHelpFunc(func(c *cobra.Command, _ []string) {
|
||||
syncCliUIColor(c.Root())
|
||||
fmt.Fprint(c.OutOrStdout(), cliui.RenderCommandHelp(c))
|
||||
})
|
||||
|
||||
cmd.AddCommand(
|
||||
onboard.NewOnboardCommand(),
|
||||
agent.NewAgentCommand(),
|
||||
@@ -42,9 +88,11 @@ func NewPicoclawCommand() *cobra.Command {
|
||||
gateway.NewGatewayCommand(),
|
||||
status.NewStatusCommand(),
|
||||
cron.NewCronCommand(),
|
||||
mcp.NewMCPCommand(),
|
||||
migrate.NewMigrateCommand(),
|
||||
skills.NewSkillsCommand(),
|
||||
model.NewModelCommand(),
|
||||
updater.NewUpdateCommand("picoclaw"),
|
||||
version.NewVersionCommand(),
|
||||
)
|
||||
|
||||
@@ -62,12 +110,44 @@ const (
|
||||
colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
|
||||
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
|
||||
"\033[0m\r\n"
|
||||
plainBanner = "\r\n" +
|
||||
"██████╗ ██╗ ██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗\n" +
|
||||
"██╔══██╗██║██╔════╝██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║\n" +
|
||||
"██████╔╝██║██║ ██║ ██║██║ ██║ ███████║██║ █╗ ██║\n" +
|
||||
"██╔═══╝ ██║██║ ██║ ██║██║ ██║ ██╔══██║██║███╗██║\n" +
|
||||
"██║ ██║╚██████╗╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
|
||||
"╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
|
||||
"\r\n"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Printf("%s", banner)
|
||||
cliui.Init(earlyColorDisabled())
|
||||
|
||||
if earlyColorDisabled() {
|
||||
fmt.Print(plainBanner)
|
||||
} else {
|
||||
fmt.Printf("%s", banner)
|
||||
}
|
||||
|
||||
tzEnv := os.Getenv("TZ")
|
||||
if tzEnv != "" {
|
||||
fmt.Println("TZ environment:", tzEnv)
|
||||
zoneinfoEnv := os.Getenv("ZONEINFO")
|
||||
fmt.Println("ZONEINFO environment:", zoneinfoEnv)
|
||||
loc, err := time.LoadLocation(tzEnv)
|
||||
if err != nil {
|
||||
fmt.Println("Error loading time zone:", err)
|
||||
} else {
|
||||
fmt.Println("Time zone loaded successfully:", loc)
|
||||
time.Local = loc //nolint:gosmopolitan // We intentionally set local timezone from TZ env
|
||||
}
|
||||
}
|
||||
|
||||
cmd := NewPicoclawCommand()
|
||||
if err := cmd.Execute(); err != nil {
|
||||
last, err := cmd.ExecuteC()
|
||||
if err != nil {
|
||||
syncCliUIColor(cmd)
|
||||
fmt.Fprint(os.Stderr, cliui.FormatCLIError(err.Error(), last))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -17,20 +18,22 @@ func TestNewPicoclawCommand(t *testing.T) {
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, config.GetVersion())
|
||||
short := fmt.Sprintf("%s PicoClaw — personal AI assistant", internal.Logo)
|
||||
longHas := strings.Contains(cmd.Long, config.FormatVersion())
|
||||
|
||||
assert.Equal(t, "picoclaw", cmd.Use)
|
||||
assert.Equal(t, short, cmd.Short)
|
||||
assert.True(t, longHas)
|
||||
|
||||
assert.True(t, cmd.HasSubCommands())
|
||||
assert.True(t, cmd.HasAvailableSubCommands())
|
||||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
assert.True(t, cmd.PersistentFlags().Lookup("no-color") != nil)
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.Nil(t, cmd.RunE)
|
||||
|
||||
assert.Nil(t, cmd.PersistentPreRun)
|
||||
assert.NotNil(t, cmd.PersistentPreRun)
|
||||
assert.Nil(t, cmd.PersistentPostRun)
|
||||
|
||||
allowedCommands := []string{
|
||||
@@ -38,11 +41,13 @@ func TestNewPicoclawCommand(t *testing.T) {
|
||||
"auth",
|
||||
"cron",
|
||||
"gateway",
|
||||
"mcp",
|
||||
"migrate",
|
||||
"model",
|
||||
"onboard",
|
||||
"skills",
|
||||
"status",
|
||||
"update",
|
||||
"version",
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
"max_tool_iterations": 20,
|
||||
"summarize_message_threshold": 20,
|
||||
"summarize_token_percent": 75,
|
||||
"split_on_marker": false,
|
||||
"tool_feedback": {
|
||||
"enabled": false,
|
||||
"max_args_length": 300
|
||||
"max_args_length": 300,
|
||||
"separate_messages": false
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -47,6 +49,15 @@
|
||||
"model": "deepseek/deepseek-chat",
|
||||
"api_key": "sk-your-deepseek-key"
|
||||
},
|
||||
{
|
||||
"model_name": "venice-uncensored",
|
||||
"model": "venice/venice-uncensored",
|
||||
"api_key": "your-venice-api-key"
|
||||
},
|
||||
{
|
||||
"model_name": "lmstudio-local",
|
||||
"model": "lmstudio/openai/gpt-oss-20b"
|
||||
},
|
||||
{
|
||||
"model_name": "longcat",
|
||||
"model": "longcat/LongCat-Flash-Thinking",
|
||||
@@ -129,6 +140,10 @@
|
||||
"encrypt_key": "",
|
||||
"verification_token": "",
|
||||
"allow_from": [],
|
||||
"placeholder": {
|
||||
"enabled": true,
|
||||
"text": ["Thinking...", "Processing...", "Typing..."]
|
||||
},
|
||||
"reasoning_channel_id": "",
|
||||
"random_reaction_emoji": [],
|
||||
"is_lark": false
|
||||
@@ -160,7 +175,7 @@
|
||||
},
|
||||
"placeholder": {
|
||||
"enabled": true,
|
||||
"text": "Thinking... 💭"
|
||||
"text": ["Thinking...", "Processing...", "Typing..."]
|
||||
},
|
||||
"reasoning_channel_id": "",
|
||||
"crypto_database_path": "",
|
||||
@@ -223,13 +238,8 @@
|
||||
"nickserv_password": "",
|
||||
"sasl_user": "",
|
||||
"sasl_password": "",
|
||||
"channels": [
|
||||
"#mychannel"
|
||||
],
|
||||
"request_caps": [
|
||||
"server-time",
|
||||
"message-tags"
|
||||
],
|
||||
"channels": ["#mychannel"],
|
||||
"request_caps": ["server-time", "message-tags"],
|
||||
"allow_from": [],
|
||||
"group_trigger": {
|
||||
"mention_only": true
|
||||
@@ -251,9 +261,7 @@
|
||||
"brave": {
|
||||
"enabled": false,
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"api_keys": [
|
||||
"YOUR_BRAVE_API_KEY"
|
||||
],
|
||||
"api_keys": ["YOUR_BRAVE_API_KEY"],
|
||||
"max_results": 5
|
||||
},
|
||||
"tavily": {
|
||||
@@ -262,16 +270,19 @@
|
||||
"base_url": "",
|
||||
"max_results": 0
|
||||
},
|
||||
"duckduckgo": {
|
||||
"provider": "auto",
|
||||
"sogou": {
|
||||
"enabled": true,
|
||||
"max_results": 5
|
||||
},
|
||||
"duckduckgo": {
|
||||
"enabled": false,
|
||||
"max_results": 5
|
||||
},
|
||||
"perplexity": {
|
||||
"enabled": false,
|
||||
"api_key": "pplx-xxx",
|
||||
"api_keys": [
|
||||
"pplx-xxx"
|
||||
],
|
||||
"api_keys": ["pplx-xxx"],
|
||||
"max_results": 5
|
||||
},
|
||||
"searxng": {
|
||||
@@ -320,19 +331,12 @@
|
||||
"filesystem": {
|
||||
"enabled": false,
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
"/tmp"
|
||||
]
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||
},
|
||||
"github": {
|
||||
"enabled": false,
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-github"
|
||||
],
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"env": {
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN"
|
||||
}
|
||||
@@ -340,10 +344,7 @@
|
||||
"brave-search": {
|
||||
"enabled": false,
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-brave-search"
|
||||
],
|
||||
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
|
||||
"env": {
|
||||
"BRAVE_API_KEY": "YOUR_BRAVE_API_KEY"
|
||||
}
|
||||
@@ -360,10 +361,7 @@
|
||||
"slack": {
|
||||
"enabled": false,
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-slack"
|
||||
],
|
||||
"args": ["-y", "@modelcontextprotocol/server-slack"],
|
||||
"env": {
|
||||
"SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN",
|
||||
"SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID"
|
||||
@@ -390,9 +388,16 @@
|
||||
"timeout": 0,
|
||||
"max_zip_size": 0,
|
||||
"max_response_size": 0
|
||||
},
|
||||
"github": {
|
||||
"enabled": true,
|
||||
"base_url": "https://github.com",
|
||||
"auth_token": "",
|
||||
"proxy": "http://127.0.0.1:7891"
|
||||
}
|
||||
},
|
||||
"github": {
|
||||
"base_url": "https://github.com",
|
||||
"proxy": "http://127.0.0.1:7891",
|
||||
"token": ""
|
||||
},
|
||||
@@ -429,7 +434,14 @@
|
||||
"enabled": true
|
||||
},
|
||||
"read_file": {
|
||||
"enabled": true
|
||||
"enabled": true,
|
||||
"mode": "bytes"
|
||||
},
|
||||
"serial": {
|
||||
"enabled": false
|
||||
},
|
||||
"send_tts": {
|
||||
"enabled": false
|
||||
},
|
||||
"spawn": {
|
||||
"enabled": true
|
||||
@@ -469,7 +481,7 @@
|
||||
},
|
||||
"gateway": {
|
||||
"_comment": "Default log level is set to 'fatal'. Other available options are 'debug', 'info', 'warn' and 'error'.",
|
||||
"host": "127.0.0.1",
|
||||
"host": "localhost",
|
||||
"port": 18790,
|
||||
"hot_reload": false,
|
||||
"log_level": "fatal"
|
||||
|
||||
@@ -26,18 +26,9 @@ RUN apk add --no-cache ca-certificates tzdata curl
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -q --spider http://localhost:18790/health || exit 1
|
||||
|
||||
# Copy binary
|
||||
# Copy binary and first-run entrypoint (same as release image).
|
||||
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Create non-root user and group
|
||||
RUN addgroup -g 1000 picoclaw && \
|
||||
adduser -D -u 1000 -G picoclaw picoclaw
|
||||
|
||||
# Switch to non-root user
|
||||
USER picoclaw
|
||||
|
||||
# Run onboard to create initial directories and config
|
||||
RUN /usr/local/bin/picoclaw onboard
|
||||
|
||||
ENTRYPOINT ["picoclaw"]
|
||||
CMD ["gateway"]
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ============================================================
|
||||
# Stage 1: Build the picoclaw binary
|
||||
# ============================================================
|
||||
FROM golang:1.26.0-alpine AS builder
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git make
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
|
||||
COPY $TARGETPLATFORM/picoclaw-launcher /usr/local/bin/picoclaw-launcher
|
||||
COPY $TARGETPLATFORM/picoclaw-launcher-tui /usr/local/bin/picoclaw-launcher-tui
|
||||
|
||||
ENTRYPOINT ["picoclaw-launcher"]
|
||||
CMD ["-public", "-no-browser"]
|
||||
CMD ["-console", "-public", "-no-browser"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ============================================================
|
||||
# Stage 1: Build the picoclaw binary
|
||||
# ============================================================
|
||||
FROM golang:1.26.0-alpine AS builder
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git make
|
||||
|
||||
@@ -48,20 +48,13 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
# Copy binary
|
||||
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
||||
|
||||
# Reuse existing node user (UID/GID 1000) — rename to picoclaw
|
||||
RUN deluser node 2>/dev/null; delgroup node 2>/dev/null; \
|
||||
addgroup -g 1000 picoclaw 2>/dev/null; \
|
||||
adduser -D -u 1000 -G picoclaw -h /home/picoclaw picoclaw 2>/dev/null || true
|
||||
|
||||
USER picoclaw
|
||||
|
||||
# Run onboard to create initial directories and config
|
||||
RUN /usr/local/bin/picoclaw onboard
|
||||
|
||||
# Copy default workspace
|
||||
COPY --chown=picoclaw:picoclaw workspace/ /home/picoclaw/.picoclaw/workspace/
|
||||
COPY workspace/ /root/.picoclaw/workspace/
|
||||
|
||||
VOLUME /home/picoclaw/.picoclaw/workspace
|
||||
VOLUME /root/.picoclaw/workspace
|
||||
|
||||
ENTRYPOINT ["picoclaw"]
|
||||
CMD ["gateway"]
|
||||
|
||||