mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
Compare commits
459 Commits
v0.2.6
...
eb0653074b
| Author | SHA1 | Date | |
|---|---|---|---|
| eb0653074b | |||
| f62de5c0d4 | |||
| e0370aafcc | |||
| 56cca3f12f | |||
| 87048499ff | |||
| 4a81f0e740 | |||
| 223ebdf0c7 | |||
| 255a67e2da | |||
| 777269b429 | |||
| d2c0b69243 | |||
| 7dc78425d1 | |||
| b3a7b7ad64 | |||
| b12f03be2e | |||
| 894c6251c5 | |||
| 306f96cfe3 | |||
| 1055e082a4 | |||
| 91f024eb1d | |||
| 6801cc7ab8 | |||
| 09d3dff432 | |||
| 6e6293e596 | |||
| f571a142bf | |||
| af901617ac | |||
| 2ae25b1038 | |||
| e1ed47b0ff | |||
| 8362203631 | |||
| 148583e7bb | |||
| a3edbcd05e | |||
| c6a09a35e2 | |||
| ffa184d183 | |||
| 8508f80608 | |||
| 569939a7b3 | |||
| 2287de521e | |||
| 871892ff15 | |||
| d5c8bfffbc | |||
| f062cb41d7 | |||
| 610e9e3fe8 | |||
| 01280eaa53 | |||
| bacb9aba7c | |||
| 6d7d1b0909 | |||
| 3788e9edad | |||
| c2044e5a2c | |||
| 7c8cd7c66a | |||
| f4338d3aab | |||
| b7edd35d13 | |||
| d0ab5aed7a | |||
| 1c25dcd239 | |||
| 2834db13de | |||
| e948106d50 | |||
| b8f4257cee | |||
| 96fd887cad | |||
| dd8e247550 | |||
| 27bd816b1c | |||
| f1f6e1131b | |||
| 6f6270b39d | |||
| 41d6156dce | |||
| ad78ba06ea | |||
| 9b7fc7aa6c | |||
| e7c0dc821a | |||
| 658961b728 | |||
| 788cda5c7a | |||
| 81a050555d | |||
| 4d3070e849 | |||
| e3a05bd36d | |||
| 0977f59fee | |||
| 00742b0196 | |||
| 0419497c72 | |||
| 864bfa1cef | |||
| c0bc8a3f9d | |||
| 96621eff21 | |||
| a7e52e8a25 | |||
| eb4e187550 | |||
| 0129da1c8e | |||
| d601b75268 | |||
| 5745957429 | |||
| ba4abff4a4 | |||
| a1b55fd4f9 | |||
| d63430ab33 | |||
| 71c49812ae | |||
| 7a1f5fe8b9 | |||
| 057683d94c | |||
| a0245c7b02 | |||
| f3ef7090c5 | |||
| be67aed4dc | |||
| f4a5d6e808 | |||
| 330aa297e2 | |||
| 4e8bd73a58 | |||
| 828a7cba70 | |||
| 490d90749c | |||
| 272dee3fca | |||
| a94ba82181 | |||
| b792d8b77b | |||
| 6e1fab80e2 | |||
| a7414608ed | |||
| dbf5d9ce1f | |||
| 5db008f384 | |||
| cb1e1a3595 | |||
| b03fa61764 | |||
| ad5232ade8 | |||
| 1722cfc282 | |||
| 5c0492900e | |||
| a36472b55f | |||
| 62d0e34ec9 | |||
| db1bc6a1f8 | |||
| 9b109dc7a8 | |||
| fc24676924 | |||
| bd867a16cd | |||
| 29e7461837 | |||
| 688d47d236 | |||
| 2baeee2834 | |||
| 893e61dc51 | |||
| 64e48163d0 | |||
| 1f0a5f4eda | |||
| 338fa258b3 | |||
| 2114e1a53f | |||
| 0f52076762 | |||
| c44bd6138c | |||
| 23df824c77 | |||
| 87ee76b117 | |||
| 7b3e800407 | |||
| c731ecdc74 | |||
| cd7717bc15 | |||
| 0bb0fc429a | |||
| e656ddf5bb | |||
| 38baf1ccd0 | |||
| 8dca2a1319 | |||
| 97b1c3efec | |||
| 0161298154 | |||
| 188ee24d2e | |||
| f90e756e21 | |||
| 78fd080189 | |||
| ed687d62ae | |||
| f62e8621fc | |||
| 4eeb69688e | |||
| 1ff8a418f6 | |||
| ddf2d7c655 | |||
| cbe6a0907c | |||
| 02d9a0d190 | |||
| afc600baed | |||
| 39dec35408 | |||
| d6b38c4236 | |||
| 1b9e7e32bd | |||
| 1acab59fc7 | |||
| bfc37b784e | |||
| 4d6337fd26 | |||
| b3d9f86a01 | |||
| f4a24614b8 | |||
| e613258fa5 | |||
| 795ee362ea | |||
| b954e6b8dc | |||
| fce800414d | |||
| b2249df3ea | |||
| 6e8a81bfbf | |||
| dc80e8f5f2 | |||
| d9717b5632 | |||
| 8caf9aeb2b | |||
| eedebabbea | |||
| f0dc709b17 | |||
| 4ddd650be4 | |||
| 9d42282672 | |||
| 9bc702ebaf | |||
| 612097b411 | |||
| 303ff8137d | |||
| 6d04d15ce0 | |||
| bdaff5cb69 | |||
| 1b2f8aac79 | |||
| 32c8b8ce6a | |||
| d2f6a08981 | |||
| 5cd10b594a | |||
| 3c4523e7aa | |||
| fc89fea319 | |||
| bcc3d447a1 | |||
| 06fad95719 | |||
| 77be169db4 | |||
| 9f0f914ad7 | |||
| 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 | |||
| a34120b821 | |||
| 6ee66123f2 | |||
| 6db17b8211 | |||
| df486b9939 | |||
| 5b0c9e2708 | |||
| 039f35563e | |||
| 0ff78fa53f | |||
| 484ef399f1 | |||
| c8335bfd47 | |||
| c47f5fd2c4 | |||
| 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 | |||
| 8f7eae8b37 | |||
| 862421b146 | |||
| 296077eabf | |||
| fe51cd504f | |||
| a827d01d7c | |||
| 27db03e5ca | |||
| 3d60385958 | |||
| 9f23ec22d6 | |||
| e32a209683 | |||
| 528c57dda0 | |||
| e6e724a827 | |||
| 718a5e7c75 | |||
| 168b75ae21 | |||
| bef17d6453 | |||
| 82bfe0d9a0 | |||
| 19a01d4264 | |||
| 3a9d1fc6fd | |||
| 53482a17bc | |||
| 59dee895fc | |||
| ca9652e120 | |||
| 3957e2cc72 | |||
| bb2167e3f3 | |||
| e0ceea91f6 | |||
| 79de00f7f3 | |||
| fcab3a1b7c | |||
| 2095ec8700 | |||
| 963ed07d69 | |||
| cf11ff70c3 | |||
| 9cfa3c3ba6 | |||
| 89af3b2511 | |||
| 765a165475 | |||
| abeb2d8e0a | |||
| f5f1dc9808 | |||
| 409251e69d | |||
| 847218ef29 | |||
| 0ef25f779e | |||
| 6429f6af9a | |||
| bca131909d | |||
| 07748bf076 | |||
| 3b173c0bee |
+2
-1
@@ -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"
|
||||
@@ -17,29 +17,38 @@ jobs:
|
||||
with:
|
||||
ref: main
|
||||
|
||||
# 1. 安装指定版本的 Go (可选,但推荐)
|
||||
# 1. Install Go from go.mod
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
# 2. 安装 pnpm
|
||||
- name: Install pnpm
|
||||
run: brew install pnpm
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.33.0
|
||||
run_install: false
|
||||
|
||||
# 3. 运行你的 Makefile 编译二进制文件
|
||||
- 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. 签名
|
||||
# 4. Apply ad-hoc signing
|
||||
- name: Ad-hoc Sign
|
||||
run: codesign --force --deep --sign - "build/PicoClaw Launcher.app"
|
||||
|
||||
# 5. 安装打包工具
|
||||
# 5. Install the DMG packaging tool
|
||||
- name: Install create-dmg
|
||||
run: brew install create-dmg
|
||||
|
||||
# 6. 执行打包命令
|
||||
# 6. Create the DMG
|
||||
- name: Create DMG
|
||||
run: |
|
||||
mkdir -p dist
|
||||
@@ -54,7 +63,7 @@ jobs:
|
||||
"dist/picoclaw-${{ matrix.arch }}.dmg" \
|
||||
"build/PicoClaw Launcher.app"
|
||||
|
||||
# 7. 上传文件到 GitHub Artifacts (供你下载)
|
||||
# 7. Upload the DMG as a GitHub artifact
|
||||
- name: Upload DMG
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
|
||||
@@ -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[@]}"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -55,6 +55,10 @@ dist/
|
||||
|
||||
# Windows Application Icon/Resource
|
||||
*.syso
|
||||
.cache/
|
||||
web/frontend/.pnpm-store/
|
||||
_tmp_*
|
||||
web/frontend/_tmp_*
|
||||
|
||||
# Test telegram integration
|
||||
cmd/telegram/
|
||||
|
||||
+10
-47
@@ -9,11 +9,10 @@ git:
|
||||
|
||||
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
|
||||
@@ -27,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
|
||||
@@ -67,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
|
||||
@@ -97,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
|
||||
@@ -159,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 }}'
|
||||
@@ -177,7 +140,6 @@ notarize:
|
||||
ids:
|
||||
- picoclaw
|
||||
- picoclaw-launcher
|
||||
- picoclaw-launcher-tui
|
||||
sign:
|
||||
certificate: "{{.Env.MACOS_SIGN_P12}}"
|
||||
password: "{{.Env.MACOS_SIGN_PASSWORD}}"
|
||||
@@ -208,7 +170,6 @@ nfpms:
|
||||
ids:
|
||||
- picoclaw
|
||||
- picoclaw-launcher
|
||||
- picoclaw-launcher-tui
|
||||
package_name: picoclaw
|
||||
file_name_template: >-
|
||||
{{ .PackageName }}_
|
||||
@@ -245,6 +206,8 @@ changelog:
|
||||
|
||||
release:
|
||||
disable: '{{ isEnvSet "NIGHTLY_BUILD" }}'
|
||||
extra_files:
|
||||
- glob: ./build/picoclaw-android-universal.zip
|
||||
footer: >-
|
||||
|
||||
---
|
||||
|
||||
+6
-3
@@ -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,4 +1,4 @@
|
||||
.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
|
||||
@@ -7,19 +7,43 @@ 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)
|
||||
@@ -73,8 +97,21 @@ 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)
|
||||
@@ -122,6 +159,30 @@ else
|
||||
|
||||
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)
|
||||
|
||||
# Default target
|
||||
@@ -130,41 +191,51 @@ 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)$(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)
|
||||
@GOARCH=${ARCH} $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH)$(EXT) ./$(CMD_DIR)
|
||||
@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)
|
||||
@GOARCH=${ARCH} $(MAKE) -C web build \
|
||||
@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-frontend:
|
||||
@$(MAKE) -C web build-frontend
|
||||
|
||||
## 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-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
|
||||
build-whatsapp-native: generate
|
||||
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
|
||||
@@ -205,11 +276,44 @@ 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)
|
||||
@@ -226,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
|
||||
@@ -257,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
|
||||
@@ -275,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:
|
||||
@@ -293,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
|
||||
|
||||
@@ -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) | [Malay](README.my.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,6 +56,14 @@
|
||||
|
||||
## 📢 News
|
||||
|
||||
2026-05-11 🛒 **LicheeRV-Claw on AliExpress!** You can now purchase LicheeRV-Claw from [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), making it easier to try PicoClaw on compact RISC-V hardware.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.aliexpress.com/item/1005006519668532.html">
|
||||
<img src="assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
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**!
|
||||
@@ -112,7 +120,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%">
|
||||
@@ -164,22 +172,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
|
||||
@@ -215,7 +233,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!
|
||||
|
||||
@@ -281,24 +299,7 @@ After this one-time step, `picoclaw-launcher` will open normally on subsequent l
|
||||
|
||||
</details>
|
||||
|
||||
### 💻 TUI Launcher (Recommended for Headless / SSH)
|
||||
|
||||
The TUI (Terminal UI) Launcher provides a full-featured terminal interface for configuration and management. Ideal for servers, Raspberry Pi, and other headless environments.
|
||||
|
||||
```bash
|
||||
picoclaw-launcher-tui
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Getting started:**
|
||||
|
||||
Use the TUI menus to: **1)** Configure a Provider -> **2)** Configure a Channel -> **3)** Start the Gateway -> **4)** Chat!
|
||||
|
||||
For detailed TUI documentation, see [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
<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.
|
||||
@@ -368,8 +369,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**
|
||||
@@ -448,20 +449,20 @@ 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 18+ messaging platforms:
|
||||
Talk to your PicoClaw through 19+ 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) |
|
||||
@@ -470,17 +471,18 @@ Talk to your PicoClaw through 18+ messaging platforms:
|
||||
| **LINE** | Medium (credentials + webhook) | Webhook | [Guide](docs/channels/line/README.md) |
|
||||
| **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/chat-apps.md#irc) |
|
||||
| **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) |
|
||||
| **MQTT** | Easy (broker + agent_id) | MQTT pub/sub | [Guide](docs/channels/mqtt/README.md) |
|
||||
| **MaixCam** | Easy (enable) | TCP socket | [Guide](docs/channels/maixcam/README.md) |
|
||||
| **Pico** | Easy (enable) | Native protocol | Built-in |
|
||||
| **Pico Client** | Easy (WebSocket URL) | WebSocket | Built-in |
|
||||
|
||||
> 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.
|
||||
|
||||
> 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/configuration.md#gateway-log-level) for details.
|
||||
> 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/chat-apps.md).
|
||||
For detailed channel setup instructions, see [Chat Apps Configuration](docs/guides/chat-apps.md).
|
||||
|
||||
## 🔧 Tools
|
||||
|
||||
@@ -491,7 +493,7 @@ PicoClaw can search the web to provide up-to-date information. Configure in `too
|
||||
| Search Engine | API Key | Free Tier | Link |
|
||||
|--------------|---------|-----------|------|
|
||||
| DuckDuckGo | Not needed | Unlimited | Built-in fallback |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Required | 1000 queries/day | AI-powered, China-optimized |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Required | 1500/month (daily allocation) | AI-powered, China-optimized |
|
||||
| [Tavily](https://tavily.com) | Required | 1000 queries/month | Optimized for AI Agents |
|
||||
| [Brave Search](https://brave.com/search/api) | Required | 2000 queries/month | Fast and private |
|
||||
| [Perplexity](https://www.perplexity.ai) | Required | Paid | AI-powered search |
|
||||
@@ -500,7 +502,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
|
||||
|
||||
@@ -513,7 +515,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
|
||||
@@ -523,6 +525,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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -530,7 +537,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)
|
||||
|
||||
@@ -553,7 +562,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
|
||||
|
||||
@@ -573,6 +595,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 |
|
||||
@@ -590,7 +617,7 @@ 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/cron.md](docs/cron.md) for current schedule types, execution modes, command-job gates, and persistence details.
|
||||
See [docs/reference/cron.md](docs/reference/cron.md) for current schedule types, execution modes, command-job gates, and persistence details.
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
@@ -598,18 +625,19 @@ 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 |
|
||||
| [Scheduled Tasks and Cron Jobs](docs/cron.md) | Cron schedule types, deliver modes, command gates, job storage |
|
||||
| [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 18+ 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
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 271 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 362 KiB After Width: | Height: | Size: 432 KiB |
+74
-28
@@ -36,6 +36,7 @@ type AggMetrics struct {
|
||||
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.
|
||||
@@ -43,6 +44,7 @@ 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).
|
||||
@@ -201,38 +203,64 @@ func EvalSeahorse(
|
||||
|
||||
// aggregateMetrics computes overall and per-category metrics.
|
||||
func aggregateMetrics(qaResults []QAResult) AggMetrics {
|
||||
byCat := map[int]*CatMetrics{}
|
||||
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 {
|
||||
totalF1 += qr.TokenF1
|
||||
totalHitRate += qr.HitRate
|
||||
cat, ok := byCat[qr.Category]
|
||||
if !ok {
|
||||
cat = &CatMetrics{}
|
||||
byCat[qr.Category] = cat
|
||||
// Skip sentinel -1.0 scores (LLM API/parse failures) from F1 averaging.
|
||||
if qr.TokenF1 >= 0 {
|
||||
totalF1 += qr.TokenF1
|
||||
validF1Count++
|
||||
}
|
||||
cat.F1 += qr.TokenF1
|
||||
cat.HitRate += qr.HitRate
|
||||
cat.QuestionCount++
|
||||
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++
|
||||
}
|
||||
n := len(qaResults)
|
||||
if n == 0 {
|
||||
n = 1
|
||||
nHit := len(qaResults)
|
||||
if nHit == 0 {
|
||||
nHit = 1
|
||||
}
|
||||
agg := AggMetrics{
|
||||
OverallF1: totalF1 / float64(n),
|
||||
OverallHitRate: totalHitRate / float64(n),
|
||||
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,
|
||||
}
|
||||
for _, cat := range agg.ByCategory {
|
||||
if cat.QuestionCount > 0 {
|
||||
cat.F1 /= float64(cat.QuestionCount)
|
||||
cat.HitRate /= float64(cat.QuestionCount)
|
||||
}
|
||||
}
|
||||
return agg
|
||||
}
|
||||
|
||||
// SaveResults writes per-sample eval results to JSON files.
|
||||
@@ -277,27 +305,43 @@ func SaveAggregated(results []EvalResult, outDir string) error {
|
||||
func computeModeAgg(results []EvalResult) AggMetrics {
|
||||
agg := AggMetrics{ByCategory: map[int]*CatMetrics{}}
|
||||
for _, r := range results {
|
||||
agg.OverallF1 += r.Agg.OverallF1 * float64(r.Agg.TotalQuestions)
|
||||
// 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
|
||||
}
|
||||
existing.F1 += cm.F1 * float64(cm.QuestionCount)
|
||||
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.OverallF1 /= float64(agg.TotalQuestions)
|
||||
agg.OverallHitRate /= float64(agg.TotalQuestions)
|
||||
}
|
||||
for _, cat := range agg.ByCategory {
|
||||
if cat.ValidF1Count > 0 {
|
||||
cat.F1 /= float64(cat.ValidF1Count)
|
||||
}
|
||||
if cat.QuestionCount > 0 {
|
||||
cat.F1 /= float64(cat.QuestionCount)
|
||||
cat.HitRate /= float64(cat.QuestionCount)
|
||||
}
|
||||
}
|
||||
@@ -359,7 +403,9 @@ func printSection(title string, results []EvalResult) {
|
||||
|
||||
// PrintComparison outputs a human-readable comparison table to stdout.
|
||||
func PrintComparison(results []EvalResult, llmResults []EvalResult) {
|
||||
printSection("No LLM generation", results)
|
||||
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
|
||||
}
|
||||
@@ -102,3 +102,81 @@ func TestComputeModeAgg(t *testing.T) {
|
||||
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,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
|
||||
}
|
||||
+166
-13
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -15,10 +16,22 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
flagData string
|
||||
flagOut string
|
||||
flagMode string
|
||||
flagBudget int
|
||||
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() {
|
||||
@@ -48,6 +61,22 @@ func main() {
|
||||
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",
|
||||
@@ -65,6 +94,22 @@ func main() {
|
||||
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)
|
||||
|
||||
@@ -136,7 +181,50 @@ func runEval(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
log.Printf("Loaded %d samples", len(samples))
|
||||
|
||||
var allResults []EvalResult
|
||||
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 {
|
||||
@@ -145,21 +233,34 @@ func runEval(cmd *cobra.Command, args []string) error {
|
||||
for i := range samples {
|
||||
legacy.IngestSample(&samples[i])
|
||||
}
|
||||
results := EvalLegacy(ctx, samples, legacy, flagBudget)
|
||||
allResults = append(allResults, results...)
|
||||
log.Printf("legacy: evaluated %d samples", len(results))
|
||||
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)
|
||||
}
|
||||
results := EvalSeahorse(ctx, samples, ir, flagBudget)
|
||||
allResults = append(allResults, results...)
|
||||
log.Printf("seahorse: evaluated %d samples", len(results))
|
||||
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)
|
||||
}
|
||||
@@ -167,7 +268,7 @@ func runEval(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("save aggregated: %w", err)
|
||||
}
|
||||
|
||||
PrintComparison(allResults, nil)
|
||||
PrintComparison(tokenResults, llmResults)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -199,10 +300,62 @@ func runReport(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("no eval results found in %s", flagOut)
|
||||
}
|
||||
|
||||
PrintComparison(allResults, nil)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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,229 +0,0 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
ppid "github.com/sipeed/picoclaw/pkg/pid"
|
||||
)
|
||||
|
||||
type gatewayStatus struct {
|
||||
running bool
|
||||
pid int
|
||||
version string
|
||||
}
|
||||
|
||||
func picoHome() string {
|
||||
return config.GetHome()
|
||||
}
|
||||
|
||||
func getGatewayStatus() gatewayStatus {
|
||||
data := ppid.ReadPidFileWithCheck(picoHome())
|
||||
if data == nil {
|
||||
return gatewayStatus{running: false}
|
||||
}
|
||||
return gatewayStatus{
|
||||
running: true,
|
||||
pid: data.PID,
|
||||
version: data.Version,
|
||||
}
|
||||
}
|
||||
|
||||
func startGateway() error {
|
||||
status := getGatewayStatus()
|
||||
if status.running {
|
||||
return fmt.Errorf("gateway is already running (PID: %d)", status.pid)
|
||||
}
|
||||
|
||||
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 &")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
_, err := strconv.Atoi(line)
|
||||
if err == nil {
|
||||
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", strconv.Itoa(status.pid)).Run()
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Wait for process to stop (ReadPidFileWithCheck cleans up stale pid file)
|
||||
for i := 0; i < 5; i++ {
|
||||
if !getGatewayStatus().running {
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
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 {
|
||||
versionInfo := ""
|
||||
if status.version != "" {
|
||||
versionInfo = fmt.Sprintf("\nVersion: %s", status.version)
|
||||
}
|
||||
statusTV.SetText(fmt.Sprintf("[#39ff14::b]GATEWAY RUNNING[-]\n\nPID: %d%s", status.pid, versionInfo))
|
||||
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))
|
||||
}
|
||||
@@ -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.String())
|
||||
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.String())
|
||||
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")))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,29 +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("")
|
||||
if encrypt {
|
||||
fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"")
|
||||
} else {
|
||||
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
|
||||
}
|
||||
cliui.PrintOnboardComplete(internal.Logo, encrypt, configPath)
|
||||
}
|
||||
|
||||
// promptPassphrase reads the encryption passphrase twice from the terminal
|
||||
@@ -193,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.String(),
|
||||
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.String(),
|
||||
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.String(),
|
||||
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)
|
||||
}
|
||||
|
||||
+74
-12
@@ -16,8 +16,10 @@ import (
|
||||
"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"
|
||||
@@ -28,15 +30,57 @@ import (
|
||||
"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 %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(),
|
||||
@@ -44,6 +88,7 @@ func NewPicoclawCommand() *cobra.Command {
|
||||
gateway.NewGatewayCommand(),
|
||||
status.NewStatusCommand(),
|
||||
cron.NewCronCommand(),
|
||||
mcp.NewMCPCommand(),
|
||||
migrate.NewMigrateCommand(),
|
||||
skills.NewSkillsCommand(),
|
||||
model.NewModelCommand(),
|
||||
@@ -65,17 +110,31 @@ 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())
|
||||
|
||||
tz_env := os.Getenv("TZ")
|
||||
if tz_env != "" {
|
||||
fmt.Println("TZ environment:", tz_env)
|
||||
zoneinfo_env := os.Getenv("ZONEINFO")
|
||||
fmt.Println("ZONEINFO environment:", zoneinfo_env)
|
||||
loc, err := time.LoadLocation(tz_env)
|
||||
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 {
|
||||
@@ -85,7 +144,10 @@ func main() {
|
||||
}
|
||||
|
||||
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 %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,6 +41,7 @@ func TestNewPicoclawCommand(t *testing.T) {
|
||||
"auth",
|
||||
"cron",
|
||||
"gateway",
|
||||
"mcp",
|
||||
"migrate",
|
||||
"model",
|
||||
"onboard",
|
||||
|
||||
@@ -11,12 +11,24 @@
|
||||
"summarize_message_threshold": 20,
|
||||
"summarize_token_percent": 75,
|
||||
"split_on_marker": false,
|
||||
"max_llm_retries": 2,
|
||||
"llm_retry_backoff_secs": 2,
|
||||
"tool_feedback": {
|
||||
"enabled": false,
|
||||
"max_args_length": 300
|
||||
"max_args_length": 300,
|
||||
"separate_messages": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"evolution": {
|
||||
"enabled": false,
|
||||
"mode": "observe",
|
||||
"state_dir": "",
|
||||
"min_task_count": 2,
|
||||
"min_success_ratio": 0.7,
|
||||
"cold_path_trigger": "after_turn",
|
||||
"cold_path_times": []
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
@@ -40,6 +52,7 @@
|
||||
},
|
||||
{
|
||||
"model_name": "gemini",
|
||||
"_comment": "Optional: set \"tool_schema_transform\": \"simple\" for providers that reject complex tool JSON Schema.",
|
||||
"model": "antigravity/gemini-2.0-flash",
|
||||
"auth_method": "oauth"
|
||||
},
|
||||
@@ -269,10 +282,15 @@
|
||||
"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",
|
||||
@@ -382,9 +400,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": ""
|
||||
},
|
||||
@@ -424,6 +449,9 @@
|
||||
"enabled": true,
|
||||
"mode": "bytes"
|
||||
},
|
||||
"serial": {
|
||||
"enabled": false
|
||||
},
|
||||
"send_tts": {
|
||||
"enabled": false
|
||||
},
|
||||
@@ -463,9 +491,18 @@
|
||||
"approval_timeout_ms": 60000
|
||||
}
|
||||
},
|
||||
"events": {
|
||||
"logging": {
|
||||
"enabled": true,
|
||||
"include": ["agent.*"],
|
||||
"exclude": [],
|
||||
"min_severity": "info",
|
||||
"include_payload": false
|
||||
}
|
||||
},
|
||||
"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"
|
||||
|
||||
+4
-13
@@ -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 ["-console", "-public", "-no-browser"]
|
||||
|
||||
+3
-10
@@ -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"]
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# ============================================================
|
||||
# Stage 1: Build frontend assets (Node.js + pnpm)
|
||||
# ============================================================
|
||||
FROM node:24-alpine3.23 AS frontend
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
WORKDIR /src/web/frontend
|
||||
|
||||
# Cache frontend dependencies
|
||||
COPY web/frontend/package.json web/frontend/pnpm-lock.yaml ./
|
||||
RUN CI=true pnpm install --frozen-lockfile
|
||||
|
||||
# Build frontend
|
||||
COPY web/frontend/ ./
|
||||
RUN pnpm build:backend
|
||||
|
||||
# ============================================================
|
||||
# Stage 2: Build Go binaries (picoclaw + picoclaw-launcher)
|
||||
# ============================================================
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git make
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Cache Go dependencies
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Copy pre-built frontend assets into the backend embed directory
|
||||
COPY --from=frontend /src/web/backend/dist web/backend/dist
|
||||
|
||||
# Build picoclaw binary (includes go generate)
|
||||
RUN make build
|
||||
|
||||
# Build picoclaw-launcher binary (frontend already built in stage 1)
|
||||
# Mirror ldflags from web/Makefile to inject version metadata
|
||||
RUN CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config && \
|
||||
VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo dev) && \
|
||||
GIT_COMMIT=$(git rev-parse --short=8 HEAD 2>/dev/null || echo dev) && \
|
||||
BUILD_TIME=$(date +%FT%T%z) && \
|
||||
GO_VERSION=$(go env GOVERSION) && \
|
||||
CGO_ENABLED=0 go build -v -tags goolm,stdjson \
|
||||
-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" \
|
||||
-o build/picoclaw-launcher ./web/backend/
|
||||
|
||||
# ============================================================
|
||||
# Stage 3: Minimal runtime image
|
||||
# ============================================================
|
||||
FROM alpine:3.23
|
||||
|
||||
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 --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
||||
COPY --from=builder /src/build/picoclaw-launcher /usr/local/bin/picoclaw-launcher
|
||||
|
||||
ENTRYPOINT ["picoclaw-launcher"]
|
||||
CMD ["-console", "-public", "-no-browser"]
|
||||
@@ -4,6 +4,9 @@ services:
|
||||
# docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Hello"
|
||||
# ─────────────────────────────────────────────
|
||||
picoclaw-agent:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
image: docker.io/sipeed/picoclaw:latest
|
||||
container_name: picoclaw-agent
|
||||
profiles:
|
||||
@@ -22,6 +25,9 @@ services:
|
||||
# docker compose -f docker/docker-compose.yml --profile gateway up
|
||||
# ─────────────────────────────────────────────
|
||||
picoclaw-gateway:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
image: docker.io/sipeed/picoclaw:latest
|
||||
container_name: picoclaw-gateway
|
||||
restart: unless-stopped
|
||||
@@ -38,6 +44,9 @@ services:
|
||||
# docker compose -f docker/docker-compose.yml --profile launcher up
|
||||
# ─────────────────────────────────────────────
|
||||
picoclaw-launcher:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile.launcher
|
||||
image: docker.io/sipeed/picoclaw:launcher
|
||||
container_name: picoclaw-launcher
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -12,4 +12,10 @@ if [ ! -d "${HOME}/.picoclaw/workspace" ] && [ ! -f "${HOME}/.picoclaw/config.js
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Remove stale PID file from a previous container run.
|
||||
# After docker kill / OOM / crash the PID file may linger on the bind-mounted
|
||||
# volume and block the next gateway start (the recorded PID could collide with
|
||||
# an unrelated process inside the new container).
|
||||
rm -f "${HOME}/.picoclaw/.picoclaw.pid"
|
||||
|
||||
exec picoclaw gateway "$@"
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
# PicoClaw Documentation
|
||||
|
||||
PicoClaw documentation is organized by document type first and language second.
|
||||
|
||||
This file describes the recommended documentation layout, how translated files should be named, and what `make lint-docs` currently checks locally.
|
||||
|
||||
These conventions are intended as contributor guidance for new or moved docs. Existing docs may still have historical exceptions, and `make lint-docs` only checks a common subset of the patterns described here.
|
||||
|
||||
## Reader Navigation
|
||||
|
||||
If you are browsing docs rather than reorganizing them, start with these directory indexes:
|
||||
|
||||
- [Guides](guides/README.md): setup, configuration, provider, and workflow guides.
|
||||
- [Reference](reference/README.md): precise configuration and behavior reference.
|
||||
- [Operations](operations/README.md): debugging and troubleshooting material.
|
||||
- [Security](security/README.md): security-focused guides and controls.
|
||||
- [Architecture](architecture/README.md): implementation notes and internal design docs.
|
||||
- [Migration](migration/README.md): upgrade and migration notes.
|
||||
|
||||
For channel-specific setup, start with [Chat Apps Configuration](guides/chat-apps.md) and then drill into `docs/channels/<name>/README.md` as needed.
|
||||
|
||||
## Principles
|
||||
|
||||
- Choose the document type directory first. Do not create language buckets such as `docs/zh/` or `docs/fr/`.
|
||||
- Keep each translated document next to its English source document.
|
||||
- Use English as the base filename with no locale suffix.
|
||||
- Use lowercase locale suffixes for translations, for example `configuration.zh.md` or `README.pt-br.md`.
|
||||
- Keep module-specific docs next to the code they describe instead of moving them into `docs/`.
|
||||
|
||||
## Recommended Directories
|
||||
|
||||
- `README.md`: English project entry document at the repository root.
|
||||
- `docs/project/`: translated project entry documents such as `README.zh.md` and `CONTRIBUTING.zh.md`.
|
||||
- `docs/guides/`: setup and usage guides.
|
||||
- `docs/reference/`: reference material and detailed configuration docs.
|
||||
- `docs/operations/`: debugging and troubleshooting docs.
|
||||
- `docs/security/`: security-related documentation.
|
||||
- `docs/architecture/`: architecture and internal design notes.
|
||||
- `docs/channels/`: channel-specific integration guides.
|
||||
- `docs/design/`: design proposals and investigations.
|
||||
- `docs/migration/`: migration notes.
|
||||
|
||||
## Recommended Naming
|
||||
|
||||
- English documents use the base filename:
|
||||
- `README.md`
|
||||
- `configuration.md`
|
||||
- Translations use `.<locale>.md`:
|
||||
- `README.zh.md`
|
||||
- `configuration.fr.md`
|
||||
- `README.pt-br.md`
|
||||
- Code-adjacent translated READMEs follow the same rule:
|
||||
- `pkg/audio/asr/README.zh.md`
|
||||
- `pkg/isolation/README.zh.md`
|
||||
|
||||
## Common Patterns To Avoid
|
||||
|
||||
- Root-level translated entry docs such as `README.zh.md` or `CONTRIBUTING.fr.md`
|
||||
- Use `docs/project/README.zh.md` or `docs/project/CONTRIBUTING.fr.md` instead.
|
||||
- Language directories under `docs/` such as `docs/zh/`, `docs/ZH/`, `docs/ja/`, or `docs/fr/`
|
||||
- Use `docs/<type>/<name>.<locale>.md` instead.
|
||||
- Nested locale buckets such as `docs/guides/zh/configuration.md` or `docs/channels/telegram/zh/README.md`
|
||||
- Keep translations beside the English source file instead.
|
||||
- Legacy translation filenames such as `README_zh.md` or `README_CN.md`
|
||||
- Use `README.zh.md`.
|
||||
- Non-canonical locale suffixes such as `configuration_zh.md` or `configuration.ZH.md`
|
||||
- Use lowercase `.<locale>.md`, for example `configuration.zh.md`.
|
||||
|
||||
## Translation Placement
|
||||
|
||||
- For docs under `docs/guides`, `docs/reference`, `docs/operations`, `docs/security`, `docs/architecture`, `docs/channels`, and `docs/migration`, keep translations beside the English source file.
|
||||
- For project entry translations, keep translated files in `docs/project/` and keep the English source in the repository root.
|
||||
- In most cases, each translated file should have an English source document:
|
||||
- `docs/guides/configuration.zh.md` usually sits beside `docs/guides/configuration.md`
|
||||
- `docs/project/README.zh.md` usually corresponds to `README.md`
|
||||
- Exception: `docs/design/` may contain locale-specific working notes without an English source document. The naming rules still apply there.
|
||||
|
||||
## Code-Adjacent Docs
|
||||
|
||||
Keep documentation next to the implementation when it primarily describes a package, command, example, or subproject.
|
||||
|
||||
Examples:
|
||||
|
||||
- `pkg/**/README.md`
|
||||
- `cmd/**/README.md`
|
||||
- `web/README.md`
|
||||
- `examples/**/README.md`
|
||||
|
||||
These files still follow the same translation naming rules.
|
||||
|
||||
## Adding a New Document
|
||||
|
||||
1. Pick the correct document type directory.
|
||||
2. Create the English source file first.
|
||||
3. Add translated siblings after the English source exists when that source is part of the same docs set.
|
||||
4. Update links from existing docs when the new doc becomes a navigation target.
|
||||
5. Run `make lint-docs` locally when adding or moving docs.
|
||||
|
||||
## Examples
|
||||
|
||||
- New setup guide:
|
||||
- `docs/guides/launcher-setup.md`
|
||||
- `docs/guides/launcher-setup.zh.md`
|
||||
- New security guide:
|
||||
- `docs/security/token-rotation.md`
|
||||
- New translated package README:
|
||||
- `pkg/channels/README.zh.md`
|
||||
|
||||
## Validation
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
make lint-docs
|
||||
```
|
||||
|
||||
The local docs linter currently checks these common cases:
|
||||
|
||||
- no root-level translated `README` or `CONTRIBUTING` files
|
||||
- no `docs/<locale>/` language buckets, regardless of case
|
||||
- no nested locale buckets under typed docs directories
|
||||
- no legacy `README_*.md` filenames
|
||||
- no non-canonical translation-like filenames such as `_zh.md` or `.ZH.md`
|
||||
- no extra Markdown files directly under `docs/` except `docs/README.md`
|
||||
- every translated Markdown file has a matching English source file
|
||||
- except for locale-specific working notes under `docs/design/`
|
||||
|
||||
`make lint-docs` is a local consistency check for common naming and placement mistakes. It helps contributors stay close to the recommended layout, but it is not intended to describe every acceptable documentation pattern in the repository.
|
||||
|
||||
When a check fails, `make lint-docs` prints the failing path, the reason, and a suggested fix.
|
||||
|
||||
If you change these recommendations or want the local linter to reflect them more closely, update this file and `scripts/lint-docs.sh` together.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Architecture
|
||||
|
||||
Internal architecture notes for major runtime mechanisms and subsystem design.
|
||||
|
||||
- [Steering](steering.md): injecting messages into a running agent loop between tool calls.
|
||||
- [SubTurn Mechanism](subturn.md): sub-agent coordination, concurrency control, and lifecycle handling.
|
||||
- [Session System](session-system.md): session scope allocation, JSONL persistence, alias compatibility, and migration. ([ZH](session-system.zh.md))
|
||||
- [Routing System](routing-system.md): agent dispatch, session policy selection, and light/heavy model routing. ([ZH](routing-system.zh.md))
|
||||
- [Runtime Events](runtime-events.md): runtime event envelope, centralized event logging, filters, and examples. ([ZH](runtime-events.zh.md))
|
||||
- [Agent Self-Evolution](agent-self-evolution.md): learning records, draft generation, application modes, and state layout.
|
||||
- [Hook System Guide](hooks/README.md): current hook architecture and protocol details.
|
||||
- [Agent Refactor](agent-refactor/README.md): notes and checkpoints for the agent refactor work.
|
||||
|
||||
For proposal-style or exploratory docs, also see [`../design/`](../design/).
|
||||
@@ -0,0 +1,100 @@
|
||||
# Agent File Rename Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Unify `pkg/agent/` package file naming to resolve the `loop_*` prefix naming confusion and unclear responsibility boundaries.
|
||||
|
||||
## Change Overview
|
||||
|
||||
### File Renames (12 files)
|
||||
|
||||
| Original | New | Description |
|
||||
|----------|-----|-------------|
|
||||
| `loop.go` | `agent.go` | AgentLoop main body + lifecycle methods |
|
||||
| `loop_message.go` | `agent_message.go` | Message handling and routing |
|
||||
| `loop_outbound.go` | `agent_outbound.go` | Response publishing |
|
||||
| `loop_event.go` | `agent_event.go` | Event system |
|
||||
| `loop_command.go` | `agent_command.go` | Command processing |
|
||||
| `loop_steering.go` | `agent_steering.go` | Steering message handling |
|
||||
| `loop_transcribe.go` | `agent_transcribe.go` | Audio transcription |
|
||||
| `loop_media.go` | `agent_media.go` | Media processing |
|
||||
| `loop_mcp.go` | `agent_mcp.go` | MCP initialization |
|
||||
| `loop_utils.go` | `agent_utils.go` | Utility functions |
|
||||
| `loop_inject.go` | `agent_inject.go` | Dependency injection |
|
||||
| `loop_turn.go` | `turn_coord.go` | Turn coordinator |
|
||||
|
||||
### File Merges (2 → 1)
|
||||
|
||||
| Original | New | Description |
|
||||
|----------|-----|-------------|
|
||||
| `turn.go` + `turn_exec.go` | `turn_state.go` | Turn-related type definitions |
|
||||
|
||||
## Final File Structure
|
||||
|
||||
```
|
||||
pkg/agent/
|
||||
├── agent.go # AgentLoop + Run/Stop/Close lifecycle
|
||||
├── agent_message.go # Message processing
|
||||
├── agent_outbound.go # Response publishing
|
||||
├── agent_event.go # Event system
|
||||
├── agent_command.go # Command processing
|
||||
├── agent_steering.go # Steering
|
||||
├── agent_transcribe.go # Transcription
|
||||
├── agent_media.go # Media processing
|
||||
├── agent_mcp.go # MCP
|
||||
├── agent_utils.go # Utility functions
|
||||
├── agent_inject.go # Dependency injection
|
||||
├── turn_coord.go # runTurn + coordinator
|
||||
├── turn_state.go # turnState + turnExecution + Control + ToolControl + LLMPhase
|
||||
├── pipeline.go # Pipeline struct + NewPipeline
|
||||
├── pipeline_setup.go
|
||||
├── pipeline_llm.go
|
||||
├── pipeline_execute.go
|
||||
└── pipeline_finalize.go
|
||||
```
|
||||
|
||||
## Naming Convention
|
||||
|
||||
| Prefix | Content | Example |
|
||||
|--------|---------|---------|
|
||||
| `agent_*` | AgentLoop method files | `agent_message.go`, `agent_event.go` |
|
||||
| `turn_*` | Turn lifecycle related | `turn_coord.go`, `turn_state.go` |
|
||||
| `pipeline_*` | Pipeline methods | `pipeline_setup.go`, `pipeline_llm.go` |
|
||||
| `context_*` | Context management | `context_manager.go`, `context_legacy.go` |
|
||||
| `hook_*` | Hook system | `hook_process.go`, `hook_mount.go` |
|
||||
|
||||
## Architecture Layers
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ AgentLoop (agent.go) │
|
||||
│ - Message loop Run/Stop/Close │
|
||||
│ - Dependency injection (agent_inject.go) │
|
||||
│ - Message routing (agent_message.go) │
|
||||
│ - Response publishing (agent_outbound.go) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Turn Coordinator (turn_coord.go) │
|
||||
│ - runTurn(): main coordinator │
|
||||
│ - abortTurn(): abort │
|
||||
│ - askSideQuestion(): side question │
|
||||
│ - selectCandidates(): model selection │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Pipeline (pipeline_*.go) │
|
||||
│ - SetupTurn(): initialization │
|
||||
│ - CallLLM(): LLM call │
|
||||
│ - ExecuteTools(): tool execution │
|
||||
│ - Finalize(): finalization │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Verification Results
|
||||
|
||||
- ✅ `go build ./pkg/agent/...` - Pass
|
||||
- ✅ `go vet ./pkg/agent/...` - No warnings
|
||||
- ✅ `go test ./pkg/agent/... -skip "TestSeahorse|TestGlobalSkillFileContentChange"` - Pass
|
||||
@@ -0,0 +1,100 @@
|
||||
# Agent 文件重命名计划
|
||||
|
||||
## 目标
|
||||
|
||||
统一 `pkg/agent/` 包的文件命名,解决 `loop_*` 前缀命名混乱、职责边界不清晰的问题。
|
||||
|
||||
## 变更概览
|
||||
|
||||
### 文件重命名(12 个)
|
||||
|
||||
| 原文件 | 新文件 | 说明 |
|
||||
|--------|--------|------|
|
||||
| `loop.go` | `agent.go` | AgentLoop 主体 + 生命周期方法 |
|
||||
| `loop_message.go` | `agent_message.go` | 消息处理和路由 |
|
||||
| `loop_outbound.go` | `agent_outbound.go` | 响应发布 |
|
||||
| `loop_event.go` | `agent_event.go` | 事件系统 |
|
||||
| `loop_command.go` | `agent_command.go` | 命令处理 |
|
||||
| `loop_steering.go` | `agent_steering.go` | Steering 消息处理 |
|
||||
| `loop_transcribe.go` | `agent_transcribe.go` | 音频转录 |
|
||||
| `loop_media.go` | `agent_media.go` | 媒体处理 |
|
||||
| `loop_mcp.go` | `agent_mcp.go` | MCP 初始化 |
|
||||
| `loop_utils.go` | `agent_utils.go` | 工具函数 |
|
||||
| `loop_inject.go` | `agent_inject.go` | 依赖注入 |
|
||||
| `loop_turn.go` | `turn_coord.go` | Turn 协调器 |
|
||||
|
||||
### 文件合并(2 → 1)
|
||||
|
||||
| 原文件 | 新文件 | 说明 |
|
||||
|--------|--------|------|
|
||||
| `turn.go` + `turn_exec.go` | `turn_state.go` | Turn 相关类型定义 |
|
||||
|
||||
## 最终文件结构
|
||||
|
||||
```
|
||||
pkg/agent/
|
||||
├── agent.go # AgentLoop + Run/Stop/Close 生命周期
|
||||
├── agent_message.go # 消息处理
|
||||
├── agent_outbound.go # 响应发布
|
||||
├── agent_event.go # 事件系统
|
||||
├── agent_command.go # 命令处理
|
||||
├── agent_steering.go # Steering
|
||||
├── agent_transcribe.go # 转录
|
||||
├── agent_media.go # 媒体处理
|
||||
├── agent_mcp.go # MCP
|
||||
├── agent_utils.go # 工具函数
|
||||
├── agent_inject.go # 依赖注入
|
||||
├── turn_coord.go # runTurn + 协调器
|
||||
├── turn_state.go # turnState + turnExecution + Control + ToolControl + LLMPhase
|
||||
├── pipeline.go # Pipeline struct + NewPipeline
|
||||
├── pipeline_setup.go
|
||||
├── pipeline_llm.go
|
||||
├── pipeline_execute.go
|
||||
└── pipeline_finalize.go
|
||||
```
|
||||
|
||||
## 命名约定
|
||||
|
||||
| 前缀 | 内容 | 示例 |
|
||||
|------|------|------|
|
||||
| `agent_*` | AgentLoop 的方法文件 | `agent_message.go`, `agent_event.go` |
|
||||
| `turn_*` | Turn 生命周期相关 | `turn_coord.go`, `turn_state.go` |
|
||||
| `pipeline_*` | Pipeline 方法 | `pipeline_setup.go`, `pipeline_llm.go` |
|
||||
| `context_*` | 上下文管理 | `context_manager.go`, `context_legacy.go` |
|
||||
| `hook_*` | Hook 系统 | `hook_process.go`, `hook_mount.go` |
|
||||
|
||||
## 架构层次
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ AgentLoop (agent.go) │
|
||||
│ - 消息循环 Run/Stop/Close │
|
||||
│ - 依赖注入 (agent_inject.go) │
|
||||
│ - 消息路由 (agent_message.go) │
|
||||
│ - 响应发布 (agent_outbound.go) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Turn Coordinator (turn_coord.go) │
|
||||
│ - runTurn(): 主协调器 │
|
||||
│ - abortTurn(): 中止 │
|
||||
│ - askSideQuestion(): 侧问 │
|
||||
│ - selectCandidates(): 模型选择 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Pipeline (pipeline_*.go) │
|
||||
│ - SetupTurn(): 初始化 │
|
||||
│ - CallLLM(): LLM 调用 │
|
||||
│ - ExecuteTools(): 工具执行 │
|
||||
│ - Finalize(): 终结 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 验证结果
|
||||
|
||||
- ✅ `go build ./pkg/agent/...` - 通过
|
||||
- ✅ `go vet ./pkg/agent/...` - 无警告
|
||||
- ✅ `go test ./pkg/agent/... -skip "TestSeahorse|TestGlobalSkillFileContentChange"` - 通过
|
||||
@@ -0,0 +1,77 @@
|
||||
# AgentLoop File Split
|
||||
|
||||
> **Note:** This document describes the file split that was completed in a previous phase. The `loop_*` naming has since been renamed to `agent_*` and `turn_*`. See [agent-rename-plan.md](./agent-rename-plan.md) for the current file structure.
|
||||
|
||||
## Overview
|
||||
|
||||
The `pkg/agent/loop.go` file (originally 4384 lines) has been split into 12 focused source files. This is a pure refactoring with no behavioral changes.
|
||||
|
||||
## Goals
|
||||
|
||||
- Reduce cognitive load when navigating agent loop code
|
||||
- Enable parallel work by decoupling concerns
|
||||
- Maintain all existing functionality and tests
|
||||
- Keep imports minimal per file
|
||||
|
||||
## Original File Map (Renamed in Phase 2)
|
||||
|
||||
| Old File | New File | Responsibility |
|
||||
|----------|----------|----------------|
|
||||
| `loop.go` | `agent.go` | Core `AgentLoop` struct, `Run`, `Stop`, `Close` |
|
||||
| `loop_turn.go` | `turn_coord.go` + `pipeline_*.go` | Turn execution: coordinator + Pipeline methods |
|
||||
| `loop_utils.go` | `agent_utils.go` | Standalone utility functions |
|
||||
| `loop_init.go` | `agent_init.go` | `NewAgentLoop` constructor and tool registration |
|
||||
| `loop_message.go` | `agent_message.go` | Message handling and routing |
|
||||
| `loop_command.go` | `agent_command.go` | Command processing |
|
||||
| `loop_mcp.go` | `agent_mcp.go` | MCP runtime |
|
||||
| `loop_event.go` | `agent_event.go` | Event system helpers |
|
||||
| `loop_media.go` | `agent_media.go` | Media resolution |
|
||||
| `loop_outbound.go` | `agent_outbound.go` | Response publishing |
|
||||
| `loop_transcribe.go` | `agent_transcribe.go` | Audio transcription |
|
||||
| `loop_steering.go` | `agent_steering.go` | Steering queue |
|
||||
| `loop_inject.go` | `agent_inject.go` | Setter injection |
|
||||
|
||||
## Current File Structure
|
||||
|
||||
See [agent-rename-plan.md](./agent-rename-plan.md) for the complete current file structure.
|
||||
|
||||
## Phase 2: Rename and Pipeline Restructuring
|
||||
|
||||
Phase 2 completed the following:
|
||||
|
||||
1. **File renaming**: All `loop_*` files renamed to `agent_*` or `turn_*`
|
||||
2. **Turn state merging**: `turn.go` + `turn_exec.go` → `turn_state.go`
|
||||
3. **Pipeline extraction**: Split large `runTurn` into Pipeline methods
|
||||
|
||||
### Pipeline Architecture
|
||||
|
||||
The Pipeline methods provide structured turn execution:
|
||||
|
||||
| Method | File | Responsibility |
|
||||
|--------|------|----------------|
|
||||
| `SetupTurn()` | `pipeline_setup.go` | History assembly, message building, candidate selection |
|
||||
| `CallLLM()` | `pipeline_llm.go` | PreLLM hooks, fallback, retry, AfterLLM hooks |
|
||||
| `ExecuteTools()` | `pipeline_execute.go` | Tool execution with hooks |
|
||||
| `Finalize()` | `pipeline_finalize.go` | Session persistence, compression |
|
||||
|
||||
## Core Principles Applied
|
||||
|
||||
### 1. Same Package, Independent Files
|
||||
All files belong to the `agent` package and compile together. This preserves the original visibility rules.
|
||||
|
||||
### 2. No Logic Changes
|
||||
All functions were moved verbatim. The extraction preserved behavioral equivalence.
|
||||
|
||||
### 3. Shared Types in turn_state.go
|
||||
The `turnState`, `turnExecution`, `Control`, `ToolControl`, and `LLMPhase` types are centralized in `turn_state.go`.
|
||||
|
||||
## Testing
|
||||
|
||||
All existing tests pass. The 5 failing tests (`TestGlobalSkillFileContentChange` and 4 Seahorse tests) are pre-existing failures unrelated to this refactor.
|
||||
|
||||
Build status: `go build ./pkg/agent/...` passes with no errors.
|
||||
|
||||
## See Also
|
||||
|
||||
- [agent-rename-plan.md](./agent-rename-plan.md) — Current file naming convention
|
||||
- [context.md](context.md) — context management and session handling
|
||||
@@ -0,0 +1,68 @@
|
||||
# Pipeline Restructuring Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Split `agent/pipeline.go` (~1400 lines) into multiple logical files, organizing code by responsibility.
|
||||
|
||||
## Final File Structure
|
||||
|
||||
```
|
||||
pkg/agent/
|
||||
├── pipeline.go # Pipeline struct + NewPipeline (~39 lines)
|
||||
├── pipeline_setup.go # SetupTurn method (~115 lines)
|
||||
├── pipeline_llm.go # CallLLM method (~519 lines)
|
||||
├── pipeline_execute.go # ExecuteTools method (~693 lines)
|
||||
└── pipeline_finalize.go # Finalize method (~78 lines)
|
||||
```
|
||||
|
||||
## Actual Line Counts
|
||||
|
||||
| File | Lines |
|
||||
|------|-------|
|
||||
| `pipeline.go` | 39 |
|
||||
| `pipeline_setup.go` | 115 |
|
||||
| `pipeline_llm.go` | 519 |
|
||||
| `pipeline_execute.go` | 693 |
|
||||
| `pipeline_finalize.go` | 78 |
|
||||
| **Total** | **1444** |
|
||||
|
||||
## Responsibility Matrix
|
||||
|
||||
| File | Method | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `pipeline.go` | `Pipeline` struct, `NewPipeline()` | Pipeline dependency container |
|
||||
| `pipeline_setup.go` | `SetupTurn()` | Turn initialization: history assembly, message building, candidate selection |
|
||||
| `pipeline_llm.go` | `CallLLM()` | LLM call: PreLLM hooks, fallback, retry, AfterLLM hooks |
|
||||
| `pipeline_execute.go` | `ExecuteTools()` | Tool execution: BeforeTool/ApproveTool/AfterTool hooks, media sending, steering handling |
|
||||
| `pipeline_finalize.go` | `Finalize()` | Turn finalization: session save, compression, status setting |
|
||||
|
||||
## Relationship Between Pipeline and Turn Coordinator
|
||||
|
||||
```
|
||||
AgentLoop (agent.go)
|
||||
│
|
||||
├── runAgentLoop() ──────────────────┐
|
||||
│ │
|
||||
│ ┌───────────────────────────────▼───────────────────────────────┐
|
||||
│ │ Turn Coordinator (turn_coord.go) │
|
||||
│ │ │
|
||||
│ │ runTurn() { │
|
||||
│ │ exec = pipeline.SetupTurn() │
|
||||
│ │ loop { │
|
||||
│ │ ctrl = pipeline.CallLLM() ──► Pipeline (pipeline_*.go) │
|
||||
│ │ if ctrl == ToolLoop { │
|
||||
│ │ toolCtrl = pipeline.ExecuteTools() │
|
||||
│ │ } │
|
||||
│ │ } │
|
||||
│ │ return pipeline.Finalize() │
|
||||
│ │ } │
|
||||
│ └─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
└── Publish response (agent_outbound.go)
|
||||
```
|
||||
|
||||
## Verification Results
|
||||
|
||||
- ✅ `go build ./pkg/agent/...` - Pass
|
||||
- ✅ `go vet ./pkg/agent/...` - No warnings
|
||||
- ✅ `go test ./pkg/agent/... -skip "TestSeahorse|TestGlobalSkillFileContentChange"` - Pass
|
||||
@@ -0,0 +1,68 @@
|
||||
# Pipeline 重构文档
|
||||
|
||||
## 目标
|
||||
|
||||
将 `agent/pipeline.go` (1400行) 拆分为多个逻辑文件,代码按职责组织。
|
||||
|
||||
## 最终文件结构
|
||||
|
||||
```
|
||||
pkg/agent/
|
||||
├── pipeline.go # Pipeline struct + NewPipeline (~39行)
|
||||
├── pipeline_setup.go # SetupTurn 方法 (~115行)
|
||||
├── pipeline_llm.go # CallLLM 方法 (~519行)
|
||||
├── pipeline_execute.go # ExecuteTools 方法 (~693行)
|
||||
└── pipeline_finalize.go # Finalize 方法 (~78行)
|
||||
```
|
||||
|
||||
## 实际行数
|
||||
|
||||
| 文件 | 行数 |
|
||||
|------|------|
|
||||
| `pipeline.go` | 39 |
|
||||
| `pipeline_setup.go` | 115 |
|
||||
| `pipeline_llm.go` | 519 |
|
||||
| `pipeline_execute.go` | 693 |
|
||||
| `pipeline_finalize.go` | 78 |
|
||||
| **总计** | **1444** |
|
||||
|
||||
## 职责说明
|
||||
|
||||
| 文件 | 方法 | 职责 |
|
||||
|------|------|------|
|
||||
| `pipeline.go` | `Pipeline` struct, `NewPipeline()` | Pipeline 依赖容器 |
|
||||
| `pipeline_setup.go` | `SetupTurn()` | Turn 初始化:历史组装、消息构建、候选人选择 |
|
||||
| `pipeline_llm.go` | `CallLLM()` | LLM 调用:PreLLM hook、fallback、重试、AfterLLM hook |
|
||||
| `pipeline_execute.go` | `ExecuteTools()` | 工具执行:BeforeTool/ApproveTool/AfterTool hook、媒体发送、steering 处理 |
|
||||
| `pipeline_finalize.go` | `Finalize()` | Turn 终结:会话保存、压缩、状态设置 |
|
||||
|
||||
## Pipeline 与 Turn Coordinator 的关系
|
||||
|
||||
```
|
||||
AgentLoop (agent.go)
|
||||
│
|
||||
├── runAgentLoop() ──────────────────┐
|
||||
│ │
|
||||
│ ┌───────────────────────────────▼───────────────────────────────┐
|
||||
│ │ Turn Coordinator (turn_coord.go) │
|
||||
│ │ │
|
||||
│ │ runTurn() { │
|
||||
│ │ exec = pipeline.SetupTurn() │
|
||||
│ │ loop { │
|
||||
│ │ ctrl = pipeline.CallLLM() ──► Pipeline (pipeline_*.go) │
|
||||
│ │ if ctrl == ToolLoop { │
|
||||
│ │ toolCtrl = pipeline.ExecuteTools() │
|
||||
│ │ } │
|
||||
│ │ } │
|
||||
│ │ return pipeline.Finalize() │
|
||||
│ │ } │
|
||||
│ └─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
└── 发布响应 (agent_outbound.go)
|
||||
```
|
||||
|
||||
## 验证结果
|
||||
|
||||
- ✅ `go build ./pkg/agent/...` - 通过
|
||||
- ✅ `go vet ./pkg/agent/...` - 无警告
|
||||
- ✅ `go test ./pkg/agent/... -skip "TestSeahorse|TestGlobalSkillFileContentChange"` - 通过
|
||||
@@ -0,0 +1,47 @@
|
||||
# Agent Self-Evolution
|
||||
|
||||
Agent self-evolution lets PicoClaw learn from completed turns and turn repeated successful behavior into skill improvements. The runtime is controlled by the top-level `evolution` config block.
|
||||
|
||||
## Flow
|
||||
|
||||
The hot path runs at the end of an agent turn. When `evolution.enabled` is true, it records a learning record with the turn summary, success state, used skills, tool executions, and session/workspace metadata. Heartbeat turns are skipped.
|
||||
|
||||
The cold path groups related task records, checks the configured success threshold, and prepares skill drafts for patterns that have enough evidence. Drafts can target new skills or append/replace/merge existing workspace skills.
|
||||
|
||||
The apply path validates generated `SKILL.md` content before writing. Invalid drafts are rejected before a skill directory or file is created.
|
||||
|
||||
## Safety Considerations
|
||||
|
||||
Evolution creates a persistent feedback loop: user input can become a task record, task records can be clustered into an LLM-generated draft, and an accepted draft can become `SKILL.md` content that is loaded into future agent prompts. Treat generated skill content as prompt-sensitive material, especially in `apply` mode.
|
||||
|
||||
The current local scanner is a narrow guardrail, not a complete safety boundary. It rejects structurally invalid drafts and a small set of obvious secret-like substrings, but it does not reliably detect prompt injection, unsafe instructions, or every form of sensitive data. Use `observe` or `draft` when human review is required before skill changes reach disk.
|
||||
|
||||
In `apply` mode, accepted drafts can update workspace skills automatically. Existing skills are backed up before replacement, but recovery is manual: an operator must restore the desired backup if an applied skill should be rolled back.
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `observe` | Record learning data only. No cold-path draft generation runs automatically. |
|
||||
| `draft` | Record learning data and generate candidate skill drafts when the cold path runs. |
|
||||
| `apply` | Generate drafts and allow accepted drafts to update workspace skills. |
|
||||
|
||||
When `evolution.enabled` is false, `mode` is treated as disabled at runtime.
|
||||
|
||||
## Cold Path Trigger
|
||||
|
||||
`cold_path_trigger` only matters in `draft` and `apply` modes.
|
||||
|
||||
| Trigger | Behavior |
|
||||
|---------|----------|
|
||||
| `after_turn` | Run the cold path after eligible turns. |
|
||||
| `scheduled` | Run the cold path at configured `cold_path_times`. |
|
||||
| `manual` | Do not run automatically. There is no user-facing Web/API/CLI trigger yet; code can still invoke `Runtime.RunColdPathOnce`. |
|
||||
|
||||
`cold_path_times` uses `HH:MM` strings and is ignored unless the trigger is `scheduled`.
|
||||
|
||||
## State
|
||||
|
||||
By default, evolution state is stored under the workspace. `state_dir` can redirect that state to another directory. The state includes learning records, clustered pattern records, drafts, and skill profiles.
|
||||
|
||||
For user-facing configuration fields, see the [Configuration Guide](../guides/configuration.md#agent-self-evolution).
|
||||
@@ -13,7 +13,7 @@ The repository no longer ships standalone example source files. The Go and Pytho
|
||||
|
||||
| Type | Interface | Stage | Can modify data |
|
||||
| --- | --- | --- | --- |
|
||||
| Observer | `EventObserver` | EventBus broadcast | No |
|
||||
| Observer | `RuntimeEventObserver` | Runtime event bus broadcast | No |
|
||||
| LLM interceptor | `LLMInterceptor` | `before_llm` / `after_llm` | Yes |
|
||||
| Tool interceptor | `ToolInterceptor` | `before_tool` / `after_tool` | Yes |
|
||||
| Tool approver | `ToolApprover` | `approve_tool` | No, returns allow/deny |
|
||||
@@ -136,9 +136,9 @@ Example:
|
||||
"/tmp/review_gate.py"
|
||||
],
|
||||
"observe": [
|
||||
"tool_exec_start",
|
||||
"tool_exec_end",
|
||||
"tool_exec_skipped"
|
||||
"agent.tool.exec_start",
|
||||
"agent.tool.exec_end",
|
||||
"agent.tool.exec_skipped"
|
||||
],
|
||||
"intercept": [
|
||||
"before_tool",
|
||||
@@ -174,7 +174,7 @@ Both examples are intentionally safe: they only log, never rewrite, and never de
|
||||
|
||||
The following is a minimal logging hook for in-process use. It implements:
|
||||
|
||||
1. `EventObserver`
|
||||
1. `RuntimeEventObserver`
|
||||
2. `LLMInterceptor`
|
||||
3. `ToolInterceptor`
|
||||
4. `ToolApprover`
|
||||
@@ -196,6 +196,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/agent"
|
||||
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
@@ -217,12 +218,12 @@ func NewExampleLoggerHook(opts ExampleLoggerHookOptions) *ExampleLoggerHook {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ExampleLoggerHook) OnEvent(ctx context.Context, evt agent.Event) error {
|
||||
func (h *ExampleLoggerHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error {
|
||||
_ = ctx
|
||||
if h == nil || !h.logEvents {
|
||||
return nil
|
||||
}
|
||||
h.record("event", evt.Meta, map[string]any{
|
||||
h.record("event", evt.Scope, map[string]any{
|
||||
"event": evt.Kind.String(),
|
||||
"payload": evt.Payload,
|
||||
}, nil)
|
||||
@@ -275,7 +276,7 @@ func (h *ExampleLoggerHook) ApproveTool(
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload any, decision any) {
|
||||
func (h *ExampleLoggerHook) record(stage string, refs any, payload any, decision any) {
|
||||
logger.InfoCF("hooks", "Example hook observed", map[string]any{
|
||||
"stage": stage,
|
||||
})
|
||||
@@ -286,7 +287,7 @@ func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload a
|
||||
entry := map[string]any{
|
||||
"ts": time.Now().UTC(),
|
||||
"stage": stage,
|
||||
"meta": meta,
|
||||
"refs": refs,
|
||||
"payload": payload,
|
||||
"decision": decision,
|
||||
}
|
||||
@@ -428,7 +429,7 @@ If you only see `before_llm` and `after_llm`, that usually means the request did
|
||||
The following script is a minimal process-hook example. It uses only the Python standard library and supports:
|
||||
|
||||
1. `hook.hello`
|
||||
2. `hook.event`
|
||||
2. `hook.runtime_event`
|
||||
3. `hook.before_tool`
|
||||
4. `hook.approve_tool`
|
||||
|
||||
@@ -564,8 +565,8 @@ def main() -> int:
|
||||
})
|
||||
|
||||
if not message_id:
|
||||
if method == "hook.event" and LOG_EVENTS:
|
||||
log_stderr(f"observed event: {params.get('Kind')}")
|
||||
if method == "hook.runtime_event" and LOG_EVENTS:
|
||||
log_stderr(f"observed event: {params.get('kind')}")
|
||||
continue
|
||||
|
||||
try:
|
||||
@@ -606,9 +607,9 @@ if __name__ == "__main__":
|
||||
"/abs/path/to/review_gate.py"
|
||||
],
|
||||
"observe": [
|
||||
"tool_exec_start",
|
||||
"tool_exec_end",
|
||||
"tool_exec_skipped"
|
||||
"agent.tool.exec_start",
|
||||
"agent.tool.exec_end",
|
||||
"agent.tool.exec_skipped"
|
||||
],
|
||||
"intercept": [
|
||||
"before_tool",
|
||||
@@ -626,7 +627,7 @@ if __name__ == "__main__":
|
||||
### Environment Variables
|
||||
|
||||
- `PICOCLAW_HOOK_LOG_EVENTS`
|
||||
Whether to write `hook.event` summaries to `stderr`, enabled by default
|
||||
Whether to write `hook.runtime_event` summaries to `stderr`, enabled by default
|
||||
- `PICOCLAW_HOOK_LOG_FILE`
|
||||
Path to an external log file. When set, the script appends inbound hook requests, notifications, and outbound responses as JSON Lines
|
||||
|
||||
@@ -645,7 +646,7 @@ Typical interpretation:
|
||||
|
||||
- Only `hook.hello`
|
||||
The process started and completed the handshake, but no business hook request has arrived yet
|
||||
- `hook.event`
|
||||
- `hook.runtime_event`
|
||||
The `observe` configuration is working
|
||||
- `hook.before_tool`
|
||||
The `intercept: ["before_tool", ...]` configuration is working
|
||||
@@ -664,7 +665,7 @@ A complete sample:
|
||||
```json
|
||||
{"ts":"2026-03-21T14:12:00+00:00","direction":"in","id":1,"method":"hook.hello","params":{"name":"py_review_gate","version":1,"modes":["observe","tool","approve"]},"notification":false}
|
||||
{"ts":"2026-03-21T14:12:00+00:00","direction":"out","id":1,"response":{"ok":true,"name":"python-review-gate"},"error":null}
|
||||
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.event","params":{"Kind":"tool_exec_start"},"notification":true}
|
||||
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.runtime_event","params":{"kind":"agent.tool.exec_start"},"notification":true}
|
||||
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":7,"method":"hook.before_tool","params":{"tool":"echo_text","arguments":{"text":"hello"}},"notification":false}
|
||||
{"ts":"2026-03-21T14:12:05+00:00","direction":"out","id":7,"response":{"action":"continue"},"error":null}
|
||||
```
|
||||
@@ -672,7 +673,7 @@ A complete sample:
|
||||
Additional notes:
|
||||
|
||||
- Timestamps are UTC
|
||||
- `notification=true` means it was a notification such as `hook.event`, which does not expect a response
|
||||
- `notification=true` means it was a notification such as `hook.runtime_event`, which does not expect a response
|
||||
- `id` increases within a single hook process; if the process restarts, the counter starts over
|
||||
|
||||
## Process-Hook Protocol
|
||||
@@ -681,7 +682,7 @@ Current process hooks use `JSON-RPC over stdio`:
|
||||
|
||||
- PicoClaw starts the external process
|
||||
- Requests and responses are exchanged as one JSON message per line
|
||||
- `hook.event` is a notification and does not need a response
|
||||
- `hook.runtime_event` is a notification and does not need a response
|
||||
- `hook.before_llm`, `hook.after_llm`, `hook.before_tool`, `hook.after_tool`, and `hook.approve_tool` are request/response calls
|
||||
|
||||
The host does not currently accept new RPCs initiated by the process hook. In practice, that means an external hook can only respond to PicoClaw calls; it cannot call back into the host to send channel messages.
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
| 类型 | 接口 | 作用阶段 | 能否改写 |
|
||||
| --- | --- | --- | --- |
|
||||
| 观察型 | `EventObserver` | EventBus 广播事件时 | 否 |
|
||||
| 观察型 | `RuntimeEventObserver` | runtime event bus 广播事件时 | 否 |
|
||||
| LLM 拦截型 | `LLMInterceptor` | `before_llm` / `after_llm` | 是 |
|
||||
| Tool 拦截型 | `ToolInterceptor` | `before_tool` / `after_tool` | 是 |
|
||||
| Tool 审批型 | `ToolApprover` | `approve_tool` | 否,返回批准/拒绝 |
|
||||
@@ -136,9 +136,9 @@ HookManager 的排序规则是:
|
||||
"/tmp/review_gate.py"
|
||||
],
|
||||
"observe": [
|
||||
"tool_exec_start",
|
||||
"tool_exec_end",
|
||||
"tool_exec_skipped"
|
||||
"agent.tool.exec_start",
|
||||
"agent.tool.exec_end",
|
||||
"agent.tool.exec_skipped"
|
||||
],
|
||||
"intercept": [
|
||||
"before_tool",
|
||||
@@ -174,7 +174,7 @@ tail -f /tmp/picoclaw-hook-review-gate.log
|
||||
|
||||
下面这段代码是一个最小的“记录型” in-process hook。它实现了:
|
||||
|
||||
1. `EventObserver`
|
||||
1. `RuntimeEventObserver`
|
||||
2. `LLMInterceptor`
|
||||
3. `ToolInterceptor`
|
||||
4. `ToolApprover`
|
||||
@@ -196,6 +196,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/agent"
|
||||
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
@@ -217,12 +218,12 @@ func NewExampleLoggerHook(opts ExampleLoggerHookOptions) *ExampleLoggerHook {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ExampleLoggerHook) OnEvent(ctx context.Context, evt agent.Event) error {
|
||||
func (h *ExampleLoggerHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error {
|
||||
_ = ctx
|
||||
if h == nil || !h.logEvents {
|
||||
return nil
|
||||
}
|
||||
h.record("event", evt.Meta, map[string]any{
|
||||
h.record("event", evt.Scope, map[string]any{
|
||||
"event": evt.Kind.String(),
|
||||
"payload": evt.Payload,
|
||||
}, nil)
|
||||
@@ -275,7 +276,7 @@ func (h *ExampleLoggerHook) ApproveTool(
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload any, decision any) {
|
||||
func (h *ExampleLoggerHook) record(stage string, refs any, payload any, decision any) {
|
||||
logger.InfoCF("hooks", "Example hook observed", map[string]any{
|
||||
"stage": stage,
|
||||
})
|
||||
@@ -286,7 +287,7 @@ func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload a
|
||||
entry := map[string]any{
|
||||
"ts": time.Now().UTC(),
|
||||
"stage": stage,
|
||||
"meta": meta,
|
||||
"refs": refs,
|
||||
"payload": payload,
|
||||
"decision": decision,
|
||||
}
|
||||
@@ -428,7 +429,7 @@ func init() {
|
||||
下面这段脚本是一个最小的 `process hook` 示例。它只使用 Python 标准库,支持:
|
||||
|
||||
1. `hook.hello`
|
||||
2. `hook.event`
|
||||
2. `hook.runtime_event`
|
||||
3. `hook.before_tool`
|
||||
4. `hook.approve_tool`
|
||||
|
||||
@@ -564,8 +565,8 @@ def main() -> int:
|
||||
})
|
||||
|
||||
if not message_id:
|
||||
if method == "hook.event" and LOG_EVENTS:
|
||||
log_stderr(f"observed event: {params.get('Kind')}")
|
||||
if method == "hook.runtime_event" and LOG_EVENTS:
|
||||
log_stderr(f"observed event: {params.get('kind')}")
|
||||
continue
|
||||
|
||||
try:
|
||||
@@ -606,9 +607,9 @@ if __name__ == "__main__":
|
||||
"/abs/path/to/review_gate.py"
|
||||
],
|
||||
"observe": [
|
||||
"tool_exec_start",
|
||||
"tool_exec_end",
|
||||
"tool_exec_skipped"
|
||||
"agent.tool.exec_start",
|
||||
"agent.tool.exec_end",
|
||||
"agent.tool.exec_skipped"
|
||||
],
|
||||
"intercept": [
|
||||
"before_tool",
|
||||
@@ -626,7 +627,7 @@ if __name__ == "__main__":
|
||||
### 环境变量
|
||||
|
||||
- `PICOCLAW_HOOK_LOG_EVENTS`
|
||||
是否把 `hook.event` 写到 `stderr`,默认开启
|
||||
是否把 `hook.runtime_event` 写到 `stderr`,默认开启
|
||||
- `PICOCLAW_HOOK_LOG_FILE`
|
||||
外部日志文件路径。设置后,脚本会把收到的 hook 请求、notification 和返回结果按 JSON Lines 追加到该文件
|
||||
|
||||
@@ -645,7 +646,7 @@ if __name__ == "__main__":
|
||||
|
||||
- 只看到 `hook.hello`
|
||||
说明进程启动并完成握手了,但还没有新的业务 hook 请求真正打进来
|
||||
- 看到 `hook.event`
|
||||
- 看到 `hook.runtime_event`
|
||||
说明 `observe` 配置生效了
|
||||
- 看到 `hook.before_tool`
|
||||
说明 `intercept: ["before_tool", ...]` 生效了
|
||||
@@ -664,7 +665,7 @@ if __name__ == "__main__":
|
||||
```json
|
||||
{"ts":"2026-03-21T14:12:00+00:00","direction":"in","id":1,"method":"hook.hello","params":{"name":"py_review_gate","version":1,"modes":["observe","tool","approve"]},"notification":false}
|
||||
{"ts":"2026-03-21T14:12:00+00:00","direction":"out","id":1,"response":{"ok":true,"name":"python-review-gate"},"error":null}
|
||||
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.event","params":{"Kind":"tool_exec_start"},"notification":true}
|
||||
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.runtime_event","params":{"kind":"agent.tool.exec_start"},"notification":true}
|
||||
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":7,"method":"hook.before_tool","params":{"tool":"echo_text","arguments":{"text":"hello"}},"notification":false}
|
||||
{"ts":"2026-03-21T14:12:05+00:00","direction":"out","id":7,"response":{"action":"continue"},"error":null}
|
||||
```
|
||||
@@ -672,7 +673,7 @@ if __name__ == "__main__":
|
||||
补充说明:
|
||||
|
||||
- 时间戳是 UTC,不是本地时区
|
||||
- `notification=true` 表示这是 `hook.event` 这类不需要响应的通知
|
||||
- `notification=true` 表示这是 `hook.runtime_event` 这类不需要响应的通知
|
||||
- `id` 会随着当前进程内的请求递增;如果 hook 进程重启,计数会重新开始
|
||||
|
||||
## Process Hook 协议约定
|
||||
@@ -681,7 +682,7 @@ if __name__ == "__main__":
|
||||
|
||||
- PicoClaw 启动外部进程
|
||||
- 请求和响应都按“一行一个 JSON 消息”传输
|
||||
- `hook.event` 是 notification,不需要响应
|
||||
- `hook.runtime_event` 是 notification,不需要响应
|
||||
- `hook.before_llm` / `hook.after_llm` / `hook.before_tool` / `hook.after_tool` / `hook.approve_tool` 是 request/response
|
||||
|
||||
当前宿主不会接受 process hook 主动发起的新 RPC。也就是说,外部 hook 现在只能“响应 PicoClaw 的调用”,不能反向调用宿主去发送 channel 消息。
|
||||
@@ -437,21 +437,28 @@ Approval hook for deciding whether to allow execution of sensitive tools.
|
||||
|
||||
---
|
||||
|
||||
## 7. `hook.event` (notification)
|
||||
## 7. `hook.runtime_event` (notification)
|
||||
|
||||
Observer event, broadcast only, no response required. `id` is `0` or absent.
|
||||
Runtime observer event, broadcast only, no response required. `id` is `0` or absent.
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "hook.event",
|
||||
"method": "hook.runtime_event",
|
||||
"params": {
|
||||
"Kind": "tool_exec_start",
|
||||
"Meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1"
|
||||
"kind": "agent.tool.exec_start",
|
||||
"source": {
|
||||
"component": "agent",
|
||||
"name": "agent-1"
|
||||
},
|
||||
"Payload": {
|
||||
"scope": {
|
||||
"agent_id": "agent-1",
|
||||
"session_key": "session-1",
|
||||
"turn_id": "turn-1",
|
||||
"channel": "cli",
|
||||
"chat_id": "chat-1"
|
||||
},
|
||||
"payload": {
|
||||
"Tool": "echo_text",
|
||||
"Arguments": {"text": "hello"}
|
||||
}
|
||||
@@ -460,12 +467,14 @@ Observer event, broadcast only, no response required. `id` is `0` or absent.
|
||||
```
|
||||
|
||||
Common `Kind` values:
|
||||
- `turn_start` / `turn_end`
|
||||
- `llm_request` / `llm_response`
|
||||
- `tool_exec_start` / `tool_exec_end` / `tool_exec_skipped`
|
||||
- `steering_injected`
|
||||
- `interrupt_received`
|
||||
- `error`
|
||||
- `agent.turn.start` / `agent.turn.end`
|
||||
- `agent.llm.request` / `agent.llm.response`
|
||||
- `agent.tool.exec_start` / `agent.tool.exec_end` / `agent.tool.exec_skipped`
|
||||
- `agent.steering.injected`
|
||||
- `agent.interrupt.received`
|
||||
- `agent.error`
|
||||
|
||||
Legacy observe configuration names such as `turn_end` and `tool_exec_start` are still accepted and normalized to runtime event names. New process hook notifications use `hook.runtime_event`.
|
||||
|
||||
---
|
||||
|
||||
@@ -513,7 +522,7 @@ Standard flow for plugin tool injection:
|
||||
```python
|
||||
def handle_before_llm(params: dict) -> dict:
|
||||
tools = params.get("tools", [])
|
||||
|
||||
|
||||
# Add plugin tool definition
|
||||
tools.append({
|
||||
"type": "function",
|
||||
@@ -529,7 +538,7 @@ def handle_before_llm(params: dict) -> dict:
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
return {
|
||||
"action": "modify",
|
||||
"request": {
|
||||
@@ -546,12 +555,12 @@ def handle_before_llm(params: dict) -> dict:
|
||||
```python
|
||||
def handle_before_tool(params: dict) -> dict:
|
||||
tool = params.get("tool", "")
|
||||
|
||||
|
||||
if tool == "my_plugin_tool":
|
||||
# Implement tool logic here
|
||||
args = params.get("arguments", {})
|
||||
input_text = args.get("input", "")
|
||||
|
||||
|
||||
# Return result directly, no need to register in ToolRegistry
|
||||
return {
|
||||
"action": "respond",
|
||||
@@ -561,8 +570,8 @@ def handle_before_tool(params: dict) -> dict:
|
||||
"is_error": False
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {"action": "continue"}
|
||||
```
|
||||
|
||||
This way, external hooks can fully implement plugin tools without registering any tool implementation inside PicoClaw.
|
||||
This way, external hooks can fully implement plugin tools without registering any tool implementation inside PicoClaw.
|
||||
+29
-20
@@ -437,21 +437,28 @@
|
||||
|
||||
---
|
||||
|
||||
## 7. `hook.event`(notification)
|
||||
## 7. `hook.runtime_event`(notification)
|
||||
|
||||
观察型事件,仅广播,无需响应。`id` 为 `0` 或不存在。
|
||||
runtime 观察型事件,仅广播,无需响应。`id` 为 `0` 或不存在。
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "hook.event",
|
||||
"method": "hook.runtime_event",
|
||||
"params": {
|
||||
"Kind": "tool_exec_start",
|
||||
"Meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1"
|
||||
"kind": "agent.tool.exec_start",
|
||||
"source": {
|
||||
"component": "agent",
|
||||
"name": "agent-1"
|
||||
},
|
||||
"Payload": {
|
||||
"scope": {
|
||||
"agent_id": "agent-1",
|
||||
"session_key": "session-1",
|
||||
"turn_id": "turn-1",
|
||||
"channel": "cli",
|
||||
"chat_id": "chat-1"
|
||||
},
|
||||
"payload": {
|
||||
"Tool": "echo_text",
|
||||
"Arguments": {"text": "hello"}
|
||||
}
|
||||
@@ -460,12 +467,14 @@
|
||||
```
|
||||
|
||||
常见 `Kind` 值:
|
||||
- `turn_start` / `turn_end`
|
||||
- `llm_request` / `llm_response`
|
||||
- `tool_exec_start` / `tool_exec_end` / `tool_exec_skipped`
|
||||
- `steering_injected`
|
||||
- `interrupt_received`
|
||||
- `error`
|
||||
- `agent.turn.start` / `agent.turn.end`
|
||||
- `agent.llm.request` / `agent.llm.response`
|
||||
- `agent.tool.exec_start` / `agent.tool.exec_end` / `agent.tool.exec_skipped`
|
||||
- `agent.steering.injected`
|
||||
- `agent.interrupt.received`
|
||||
- `agent.error`
|
||||
|
||||
旧 observe 配置名如 `turn_end`、`tool_exec_start` 仍然可用,并会归一化为 runtime event 名称。新的 process hook 通知使用 `hook.runtime_event`。
|
||||
|
||||
---
|
||||
|
||||
@@ -513,7 +522,7 @@
|
||||
```python
|
||||
def handle_before_llm(params: dict) -> dict:
|
||||
tools = params.get("tools", [])
|
||||
|
||||
|
||||
# 添加插件工具定义
|
||||
tools.append({
|
||||
"type": "function",
|
||||
@@ -529,7 +538,7 @@ def handle_before_llm(params: dict) -> dict:
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
return {
|
||||
"action": "modify",
|
||||
"request": {
|
||||
@@ -546,12 +555,12 @@ def handle_before_llm(params: dict) -> dict:
|
||||
```python
|
||||
def handle_before_tool(params: dict) -> dict:
|
||||
tool = params.get("tool", "")
|
||||
|
||||
|
||||
if tool == "my_plugin_tool":
|
||||
# 在这里实现工具逻辑
|
||||
args = params.get("arguments", {})
|
||||
input_text = args.get("input", "")
|
||||
|
||||
|
||||
# 直接返回结果,无需在 ToolRegistry 注册
|
||||
return {
|
||||
"action": "respond",
|
||||
@@ -561,8 +570,8 @@ def handle_before_tool(params: dict) -> dict:
|
||||
"is_error": False
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {"action": "continue"}
|
||||
```
|
||||
|
||||
通过这种方式,外部 hook 可以完全实现插件工具,无需在 PicoClaw 内部注册任何工具实现。
|
||||
通过这种方式,外部 hook 可以完全实现插件工具,无需在 PicoClaw 内部注册任何工具实现。
|
||||
+23
-23
@@ -67,7 +67,7 @@ def handle_hello(params: dict) -> dict:
|
||||
def handle_before_llm(params: dict) -> dict:
|
||||
"""Inject weather query tool definition"""
|
||||
tools = params.get("tools", [])
|
||||
|
||||
|
||||
# Add weather query tool
|
||||
tools.append({
|
||||
"type": "function",
|
||||
@@ -86,7 +86,7 @@ def handle_before_llm(params: dict) -> dict:
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
return {
|
||||
"action": "modify",
|
||||
"request": {
|
||||
@@ -102,17 +102,17 @@ def handle_before_tool(params: dict) -> dict:
|
||||
"""Handle tool call, return result directly"""
|
||||
tool = params.get("tool", "")
|
||||
args = params.get("arguments", {})
|
||||
|
||||
|
||||
if tool == "get_weather":
|
||||
city = args.get("city", "")
|
||||
result = get_weather(city)
|
||||
|
||||
|
||||
# Use respond action to return result directly, skip ToolRegistry
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": result,
|
||||
}
|
||||
|
||||
|
||||
# Other tools continue normal flow
|
||||
return {"action": "continue"}
|
||||
|
||||
@@ -142,7 +142,7 @@ def send_response(message_id: int, result: Any | None = None, error: str | None
|
||||
payload["error"] = {"code": -32000, "message": error}
|
||||
else:
|
||||
payload["result"] = result if result is not None else {}
|
||||
|
||||
|
||||
sys.stdout.write(json.dumps(payload, ensure_ascii=True) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
@@ -152,19 +152,19 @@ def main() -> int:
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
|
||||
method = message.get("method")
|
||||
message_id = message.get("id", 0)
|
||||
params = message.get("params") or {}
|
||||
|
||||
|
||||
if not message_id:
|
||||
continue
|
||||
|
||||
|
||||
try:
|
||||
result = handle_request(str(method or ""), params)
|
||||
send_response(int(message_id), result=result)
|
||||
@@ -172,7 +172,7 @@ def main() -> int:
|
||||
send_response(int(message_id), error=str(exc))
|
||||
except Exception as exc:
|
||||
send_response(int(message_id), error=f"unexpected error: {exc}")
|
||||
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
@@ -375,7 +375,7 @@ Multiple tools can be injected simultaneously:
|
||||
```python
|
||||
def handle_before_llm(params: dict) -> dict:
|
||||
tools = params.get("tools", [])
|
||||
|
||||
|
||||
# Tool 1: Weather query
|
||||
tools.append({
|
||||
"type": "function",
|
||||
@@ -391,7 +391,7 @@ def handle_before_llm(params: dict) -> dict:
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
# Tool 2: Calculator
|
||||
tools.append({
|
||||
"type": "function",
|
||||
@@ -407,7 +407,7 @@ def handle_before_llm(params: dict) -> dict:
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
return {
|
||||
"action": "modify",
|
||||
"request": {
|
||||
@@ -422,13 +422,13 @@ def handle_before_llm(params: dict) -> dict:
|
||||
def handle_before_tool(params: dict) -> dict:
|
||||
tool = params.get("tool", "")
|
||||
args = params.get("arguments", {})
|
||||
|
||||
|
||||
if tool == "get_weather":
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": get_weather(args.get("city", "")),
|
||||
}
|
||||
|
||||
|
||||
if tool == "calculate":
|
||||
# Simple calculation example
|
||||
try:
|
||||
@@ -451,7 +451,7 @@ def handle_before_tool(params: dict) -> dict:
|
||||
"is_error": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
return {"action": "continue"}
|
||||
```
|
||||
|
||||
@@ -504,7 +504,7 @@ func (h *WeatherPluginHook) BeforeLLM(
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
return req, agent.HookDecision{Action: agent.HookActionContinue}, nil
|
||||
}
|
||||
|
||||
@@ -514,7 +514,7 @@ func (h *WeatherPluginHook) BeforeTool(
|
||||
) (*agent.ToolCallHookRequest, agent.HookDecision, error) {
|
||||
if call.Tool == "get_weather" {
|
||||
city := call.Arguments["city"].(string)
|
||||
|
||||
|
||||
// Set HookResult, use respond action
|
||||
next := call.Clone()
|
||||
next.HookResult = &tools.ToolResult{
|
||||
@@ -522,10 +522,10 @@ func (h *WeatherPluginHook) BeforeTool(
|
||||
Silent: false,
|
||||
IsError: false,
|
||||
}
|
||||
|
||||
|
||||
return next, agent.HookDecision{Action: agent.HookActionRespond}, nil
|
||||
}
|
||||
|
||||
|
||||
return call, agent.HookDecision{Action: agent.HookActionContinue}, nil
|
||||
}
|
||||
|
||||
@@ -572,14 +572,14 @@ This means:
|
||||
def handle_before_tool(params: dict) -> dict:
|
||||
tool = params.get("tool", "")
|
||||
args = params.get("arguments", {})
|
||||
|
||||
|
||||
# Security check: only handle plugin tools
|
||||
if tool in ["get_weather", "calculate"]:
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": execute_plugin_tool(tool, args),
|
||||
}
|
||||
|
||||
|
||||
# Other tools continue normal flow (will go through approval)
|
||||
return {"action": "continue"}
|
||||
```
|
||||
+23
-23
@@ -67,7 +67,7 @@ def handle_hello(params: dict) -> dict:
|
||||
def handle_before_llm(params: dict) -> dict:
|
||||
"""注入天气查询工具定义"""
|
||||
tools = params.get("tools", [])
|
||||
|
||||
|
||||
# 添加天气查询工具
|
||||
tools.append({
|
||||
"type": "function",
|
||||
@@ -86,7 +86,7 @@ def handle_before_llm(params: dict) -> dict:
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
return {
|
||||
"action": "modify",
|
||||
"request": {
|
||||
@@ -102,17 +102,17 @@ def handle_before_tool(params: dict) -> dict:
|
||||
"""处理工具调用,直接返回结果"""
|
||||
tool = params.get("tool", "")
|
||||
args = params.get("arguments", {})
|
||||
|
||||
|
||||
if tool == "get_weather":
|
||||
city = args.get("city", "")
|
||||
result = get_weather(city)
|
||||
|
||||
|
||||
# 使用 respond action 直接返回结果,跳过 ToolRegistry
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": result,
|
||||
}
|
||||
|
||||
|
||||
# 其他工具继续正常流程
|
||||
return {"action": "continue"}
|
||||
|
||||
@@ -142,7 +142,7 @@ def send_response(message_id: int, result: Any | None = None, error: str | None
|
||||
payload["error"] = {"code": -32000, "message": error}
|
||||
else:
|
||||
payload["result"] = result if result is not None else {}
|
||||
|
||||
|
||||
sys.stdout.write(json.dumps(payload, ensure_ascii=True) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
@@ -152,19 +152,19 @@ def main() -> int:
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
|
||||
method = message.get("method")
|
||||
message_id = message.get("id", 0)
|
||||
params = message.get("params") or {}
|
||||
|
||||
|
||||
if not message_id:
|
||||
continue
|
||||
|
||||
|
||||
try:
|
||||
result = handle_request(str(method or ""), params)
|
||||
send_response(int(message_id), result=result)
|
||||
@@ -172,7 +172,7 @@ def main() -> int:
|
||||
send_response(int(message_id), error=str(exc))
|
||||
except Exception as exc:
|
||||
send_response(int(message_id), error=f"unexpected error: {exc}")
|
||||
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
@@ -375,7 +375,7 @@ media://<store-id>
|
||||
```python
|
||||
def handle_before_llm(params: dict) -> dict:
|
||||
tools = params.get("tools", [])
|
||||
|
||||
|
||||
# 工具1:天气查询
|
||||
tools.append({
|
||||
"type": "function",
|
||||
@@ -391,7 +391,7 @@ def handle_before_llm(params: dict) -> dict:
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
# 工具2:计算器
|
||||
tools.append({
|
||||
"type": "function",
|
||||
@@ -407,7 +407,7 @@ def handle_before_llm(params: dict) -> dict:
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
return {
|
||||
"action": "modify",
|
||||
"request": {
|
||||
@@ -422,13 +422,13 @@ def handle_before_llm(params: dict) -> dict:
|
||||
def handle_before_tool(params: dict) -> dict:
|
||||
tool = params.get("tool", "")
|
||||
args = params.get("arguments", {})
|
||||
|
||||
|
||||
if tool == "get_weather":
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": get_weather(args.get("city", "")),
|
||||
}
|
||||
|
||||
|
||||
if tool == "calculate":
|
||||
# 简单计算示例
|
||||
try:
|
||||
@@ -451,7 +451,7 @@ def handle_before_tool(params: dict) -> dict:
|
||||
"is_error": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
return {"action": "continue"}
|
||||
```
|
||||
|
||||
@@ -504,7 +504,7 @@ func (h *WeatherPluginHook) BeforeLLM(
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
return req, agent.HookDecision{Action: agent.HookActionContinue}, nil
|
||||
}
|
||||
|
||||
@@ -514,7 +514,7 @@ func (h *WeatherPluginHook) BeforeTool(
|
||||
) (*agent.ToolCallHookRequest, agent.HookDecision, error) {
|
||||
if call.Tool == "get_weather" {
|
||||
city := call.Arguments["city"].(string)
|
||||
|
||||
|
||||
// 设置 HookResult,使用 respond action
|
||||
next := call.Clone()
|
||||
next.HookResult = &tools.ToolResult{
|
||||
@@ -522,10 +522,10 @@ func (h *WeatherPluginHook) BeforeTool(
|
||||
Silent: false,
|
||||
IsError: false,
|
||||
}
|
||||
|
||||
|
||||
return next, agent.HookDecision{Action: agent.HookActionRespond}, nil
|
||||
}
|
||||
|
||||
|
||||
return call, agent.HookDecision{Action: agent.HookActionContinue}, nil
|
||||
}
|
||||
|
||||
@@ -572,14 +572,14 @@ func getWeatherData(city string) string {
|
||||
def handle_before_tool(params: dict) -> dict:
|
||||
tool = params.get("tool", "")
|
||||
args = params.get("arguments", {})
|
||||
|
||||
|
||||
# 安全检查:只处理插件工具
|
||||
if tool in ["get_weather", "calculate"]:
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": execute_plugin_tool(tool, args),
|
||||
}
|
||||
|
||||
|
||||
# 其他工具继续正常流程(会经过审批)
|
||||
return {"action": "continue"}
|
||||
```
|
||||
@@ -0,0 +1,282 @@
|
||||
# Routing System
|
||||
|
||||
> Back to [README](../README.md)
|
||||
|
||||
In PicoClaw, the runtime "routing system" is not just one decision.
|
||||
It is the combined pipeline that decides:
|
||||
|
||||
1. which agent handles an inbound message
|
||||
2. which session dimensions should isolate that conversation
|
||||
3. whether the turn should use the agent's primary model or a configured light model
|
||||
|
||||
This document covers the runtime path in `pkg/routing` and its integration in `pkg/agent`.
|
||||
It does not describe the launcher's HTTP `ServeMux` routes or the frontend's TanStack Router files under `web/`.
|
||||
|
||||
## Routing Layers
|
||||
|
||||
| Layer | Files | Responsibility |
|
||||
| --- | --- | --- |
|
||||
| Agent dispatch | `pkg/routing/route.go`, `pkg/routing/agent_id.go` | Choose the target agent for the inbound message. |
|
||||
| Session policy selection | `pkg/routing/route.go` | Decide which dimensions should define session isolation for that routed turn. |
|
||||
| Model routing | `pkg/routing/router.go`, `pkg/routing/features.go`, `pkg/routing/classifier.go` | Choose between the primary model and a configured light model based on message complexity. |
|
||||
| Runtime integration | `pkg/agent/registry.go`, `pkg/agent/agent_message.go`, `pkg/agent/turn_coord.go` | Apply the route result, allocate session scope, and select model candidates before provider execution. |
|
||||
|
||||
## End-To-End Flow
|
||||
|
||||
The normal path for a user message is:
|
||||
|
||||
```text
|
||||
InboundMessage
|
||||
-> NormalizeInboundContext
|
||||
-> RouteResolver.ResolveRoute(...)
|
||||
-> session.AllocateRouteSession(...)
|
||||
-> ensureSessionMetadata(...)
|
||||
-> Router.SelectModel(...)
|
||||
-> provider execution
|
||||
```
|
||||
|
||||
The first half answers "who should handle this message and what session does it belong to".
|
||||
The second half answers "which model tier should that agent use for this turn".
|
||||
|
||||
## Agent Dispatch
|
||||
|
||||
`routing.RouteResolver` turns a normalized `bus.InboundContext` into a `ResolvedRoute`:
|
||||
|
||||
```go
|
||||
type ResolvedRoute struct {
|
||||
AgentID string
|
||||
Channel string
|
||||
AccountID string
|
||||
SessionPolicy SessionPolicy
|
||||
MatchedBy string
|
||||
}
|
||||
```
|
||||
|
||||
`MatchedBy` is a debugging aid.
|
||||
Typical values are:
|
||||
|
||||
- `default`
|
||||
- `dispatch.rule`
|
||||
- `dispatch.rule:<rule-name>`
|
||||
|
||||
## Dispatch Input View
|
||||
|
||||
Before matching rules, the resolver builds a normalized `dispatchView`.
|
||||
Each field is normalized to the exact shape expected by rule matching.
|
||||
|
||||
| Selector field | Runtime shape |
|
||||
| --- | --- |
|
||||
| `channel` | lowercased channel name |
|
||||
| `account` | normalized account ID |
|
||||
| `space` | `<space_type>:<space_id>` |
|
||||
| `chat` | `<chat_type>:<chat_id>` |
|
||||
| `topic` | `topic:<topic_id>` |
|
||||
| `sender` | lowercased canonical sender ID |
|
||||
| `mentioned` | boolean copied from inbound context |
|
||||
|
||||
This means dispatch rules must match the normalized shape, for example:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"dispatch": {
|
||||
"rules": [
|
||||
{
|
||||
"name": "support-group",
|
||||
"agent": "support",
|
||||
"when": {
|
||||
"channel": "telegram",
|
||||
"chat": "group:-100123"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "slack-mentions",
|
||||
"agent": "support",
|
||||
"when": {
|
||||
"channel": "slack",
|
||||
"space": "workspace:t001",
|
||||
"mentioned": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Dispatch Algorithm
|
||||
|
||||
`ResolveRoute(...)` follows this sequence:
|
||||
|
||||
1. Normalize `channel` and `account`.
|
||||
2. Clone `session.identity_links` from config.
|
||||
3. Build the normalized dispatch view.
|
||||
4. Scan `agents.dispatch.rules` in order.
|
||||
5. Skip rules with no constraints at all.
|
||||
6. Return the first rule whose selector fields all match exactly.
|
||||
7. If no rule matches, fall back to the default agent.
|
||||
|
||||
Important consequences:
|
||||
|
||||
- first match wins
|
||||
- there is no score or priority field beyond list order
|
||||
- invalid target agent IDs fall back to the default agent
|
||||
- sender matching can see canonical identities produced by `identity_links`
|
||||
|
||||
## Default Agent Resolution
|
||||
|
||||
If no dispatch rule wins, or if a rule points at an unknown agent, the resolver picks a default agent using this order:
|
||||
|
||||
1. the agent marked `default: true`
|
||||
2. otherwise the first entry in `agents.list`
|
||||
3. otherwise implicit `main`
|
||||
|
||||
Both agent IDs and account IDs are normalized through the helpers in `pkg/routing/agent_id.go`.
|
||||
|
||||
## Session Policy Handoff
|
||||
|
||||
Agent dispatch does not directly build a session key.
|
||||
Instead it emits a `SessionPolicy`:
|
||||
|
||||
```go
|
||||
type SessionPolicy struct {
|
||||
Dimensions []string
|
||||
IdentityLinks map[string][]string
|
||||
}
|
||||
```
|
||||
|
||||
The dimensions come from:
|
||||
|
||||
- global `session.dimensions`
|
||||
- or `dispatch_rule.session_dimensions` when the matching rule overrides them
|
||||
|
||||
Only these dimension names survive normalization:
|
||||
|
||||
- `space`
|
||||
- `chat`
|
||||
- `topic`
|
||||
- `sender`
|
||||
|
||||
Invalid or duplicated entries are silently dropped.
|
||||
|
||||
`pkg/session/AllocateRouteSession(...)` then turns that policy into:
|
||||
|
||||
- a structured `SessionScope`
|
||||
- a canonical routed session key
|
||||
- legacy compatibility aliases
|
||||
|
||||
So the routing package owns "what should isolate this conversation", while the session package owns "how that isolation becomes keys and durable storage".
|
||||
|
||||
## Identity Links
|
||||
|
||||
`session.identity_links` is shared between dispatch and session allocation.
|
||||
That is intentional: a sender canonicalized for routing should also map to the same session identity.
|
||||
|
||||
Without that symmetry, the system could route two messages to the same agent but still fragment their history into different sessions.
|
||||
|
||||
## Model Routing
|
||||
|
||||
The second routing stage decides whether a turn can use a cheaper or faster light model.
|
||||
|
||||
Config shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"routing": {
|
||||
"enabled": true,
|
||||
"light_model": "gemini-2.0-flash",
|
||||
"threshold": 0.35
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`pkg/routing.Router` compares the current turn against structural features and returns:
|
||||
|
||||
- chosen model name
|
||||
- whether the light model was used
|
||||
- computed complexity score
|
||||
|
||||
If the score is below the threshold, the light model wins.
|
||||
Otherwise the agent's primary model is used.
|
||||
At runtime this only matters when the agent actually has light-model candidates configured; otherwise execution stays on the primary candidate set.
|
||||
|
||||
## Complexity Features
|
||||
|
||||
`ExtractFeatures(...)` computes a language-agnostic feature vector:
|
||||
|
||||
| Feature | Meaning |
|
||||
| --- | --- |
|
||||
| `TokenEstimate` | Approximate token count; CJK runes count more accurately than a flat rune split. |
|
||||
| `CodeBlockCount` | Number of fenced code blocks in the current message. |
|
||||
| `RecentToolCalls` | Tool-call count across the last six history entries. |
|
||||
| `ConversationDepth` | Total history length. |
|
||||
| `HasAttachments` | Detects embedded media or common media URL/file extensions. |
|
||||
|
||||
This is intentionally structural rather than keyword-based, so the router behaves the same across languages.
|
||||
|
||||
## RuleClassifier Scoring
|
||||
|
||||
The current classifier is `RuleClassifier`.
|
||||
It uses a weighted sum capped to `[0, 1]`.
|
||||
|
||||
| Signal | Score |
|
||||
| --- | --- |
|
||||
| attachments present | `1.00` |
|
||||
| token estimate `> 200` | `0.35` |
|
||||
| token estimate `> 50` | `0.15` |
|
||||
| code block present | `0.40` |
|
||||
| recent tool calls `> 3` | `0.25` |
|
||||
| recent tool calls `1..3` | `0.10` |
|
||||
| conversation depth `> 10` | `0.10` |
|
||||
|
||||
The default threshold is `0.35`.
|
||||
That makes the following behavior intentional:
|
||||
|
||||
- trivial chat stays on the light model
|
||||
- code tasks usually jump to the heavy model immediately
|
||||
- attachments always force the heavy model
|
||||
- long, plain-text prompts cross the heavy-model boundary at the default threshold
|
||||
|
||||
## Runtime Integration
|
||||
|
||||
Agent dispatch and model routing happen in different places:
|
||||
|
||||
- `pkg/agent/registry.go` owns `RouteResolver`
|
||||
- `pkg/agent/agent_message.go` resolves the route and allocates session scope
|
||||
- `pkg/agent/turn_coord.go:selectCandidates` calls `agent.Router.SelectModel(...)`
|
||||
|
||||
When the light model is selected, the agent loop swaps to `agent.LightCandidates`.
|
||||
When it is not selected, execution stays on the agent's primary provider candidate set.
|
||||
|
||||
## Explicit Session Keys
|
||||
|
||||
One nuance sits just outside `pkg/routing` but matters for the full routing story.
|
||||
|
||||
After a route is allocated, `pkg/agent/agent_utils.go:resolveScopeKey` preserves an explicit incoming session key when the caller already supplied:
|
||||
|
||||
- an opaque canonical key
|
||||
- a legacy `agent:...` key
|
||||
|
||||
That makes manual system flows, tests, and compatibility paths deterministic even when the normal routed scope would have produced a different key.
|
||||
|
||||
## What This Document Does Not Cover
|
||||
|
||||
The repository also contains two unrelated route systems:
|
||||
|
||||
- backend HTTP routes registered in `web/backend/api/router.go`
|
||||
- frontend file routes under `web/frontend/src/routes/`
|
||||
|
||||
Those are launcher implementation details.
|
||||
They are separate from the runtime routing system described here.
|
||||
|
||||
## Related Files
|
||||
|
||||
- `pkg/routing/route.go`
|
||||
- `pkg/routing/router.go`
|
||||
- `pkg/routing/classifier.go`
|
||||
- `pkg/routing/features.go`
|
||||
- `pkg/routing/agent_id.go`
|
||||
- `pkg/session/allocator.go`
|
||||
- `pkg/agent/registry.go`
|
||||
- `pkg/agent/agent_message.go`
|
||||
- `pkg/agent/turn_coord.go`
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user