Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50c58a3462 | |||
| 403e048821 |
@@ -1,5 +1,3 @@
|
||||
# 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/
|
||||
@@ -8,4 +6,5 @@ config/
|
||||
.env
|
||||
.env.example
|
||||
*.md
|
||||
LICENSE
|
||||
assets/
|
||||
|
||||
@@ -5,18 +5,15 @@
|
||||
# ANTHROPIC_API_KEY=sk-ant-xxx
|
||||
# OPENAI_API_KEY=sk-xxx
|
||||
# GEMINI_API_KEY=xxx
|
||||
# MODELSCOPE_API_KEY=xxx
|
||||
# CLAUDE_CODE_OAUTH=xxx
|
||||
|
||||
# ── Chat Channel ──────────────────────────
|
||||
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
||||
# DISCORD_BOT_TOKEN=xxx
|
||||
# Feishu (飞书)
|
||||
# PICOCLAW_CHANNELS_FEISHU_APP_ID=cli_xxx
|
||||
# PICOCLAW_CHANNELS_FEISHU_APP_SECRET=xxx
|
||||
# PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI=Typing,OneSecond
|
||||
# LINE_CHANNEL_SECRET=xxx
|
||||
# LINE_CHANNEL_ACCESS_TOKEN=xxx
|
||||
|
||||
# ── Web Search (optional) ────────────────
|
||||
# BRAVE_SEARCH_API_KEY=BSA...
|
||||
|
||||
# ── Timezone ──────────────────────────────
|
||||
TZ=Asia/Shanghai
|
||||
TZ=Asia/Tokyo
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Ensure shell scripts always use LF line endings regardless of OS.
|
||||
*.sh text eol=lf
|
||||
docker/entrypoint.sh text eol=lf
|
||||
@@ -1,27 +0,0 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
|
||||
# Go dependencies (entire repo)
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "go"
|
||||
|
||||
# Frontend dependencies
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/web/frontend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "frontend"
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -1,7 +1,4 @@
|
||||
## 📝 Description
|
||||
|
||||
<!-- Please briefly describe the changes and purpose of this PR -->
|
||||
|
||||
## 🗣️ Type of Change
|
||||
- [ ] 🐞 Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] ✨ New feature (non-breaking change which adds functionality)
|
||||
@@ -14,29 +11,26 @@
|
||||
- [ ] 👨💻 Mostly Human-written (Human lead, AI assisted or none)
|
||||
|
||||
|
||||
## 🔗 Related Issue
|
||||
|
||||
<!-- Please link the related issue(s) (e.g., Fixes #123, Closes #456) -->
|
||||
|
||||
## 🔗 Linked Issue
|
||||
## 📚 Technical Context (Skip for Docs)
|
||||
- **Reference URL:**
|
||||
- **Reasoning:**
|
||||
|
||||
## 🧪 Test Environment
|
||||
- **Hardware:** <!-- e.g. Raspberry Pi 5, Orange Pi, PC-->
|
||||
- **OS:** <!-- e.g. Debian 12, Ubuntu 22.04 -->
|
||||
- **Model/Provider:** <!-- e.g. OpenAI GPT-4o, Kimi k2, DeepSeek-V3 -->
|
||||
- **Channels:** <!-- e.g. Discord, Telegram, Feishu, ... -->
|
||||
* **Reference:** [URL]
|
||||
* **Reasoning:** ...
|
||||
|
||||
|
||||
## 📸 Evidence (Optional)
|
||||
## 🧪 Test Environment & Hardware
|
||||
- **Hardware:** [e.g. Raspberry Pi 5, Orange Pi, PC]
|
||||
- **OS:** [e.g. Debian 12, Ubuntu 22.04]
|
||||
- **Model/Provider:** [e.g. OpenAI GPT-4o, Kimi k2, DeepSeek-V3]
|
||||
- **Channels:** [e.g. Discord, Telegram, Feishu, ...]
|
||||
|
||||
|
||||
## 📸 Proof of Work (Optional for Docs)
|
||||
<details>
|
||||
<summary>Click to view Logs/Screenshots</summary>
|
||||
|
||||
<!-- Please paste relevant screenshots or logs here -->
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## ☑️ Checklist
|
||||
- [ ] My code/docs follow the style of this project.
|
||||
- [ ] I have performed a self-review of my own changes.
|
||||
|
||||
@@ -2,19 +2,24 @@ name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build core binaries
|
||||
- name: fmt
|
||||
run: |
|
||||
make fmt
|
||||
git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1)
|
||||
|
||||
- name: Build
|
||||
run: make build-all
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
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"
|
||||
@@ -1,71 +0,0 @@
|
||||
name: Create macOS DMG
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.arch }}
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
matrix:
|
||||
# This creates two parallel jobs
|
||||
arch: [arm64, amd64]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
|
||||
# 1. Install Go from go.mod
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.33.0
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
||||
|
||||
# 3. Build the application bundle
|
||||
- name: Build with Make
|
||||
run: make build ARCH=${{ matrix.arch }} && make build-macos-app ARCH=${{ matrix.arch }}
|
||||
|
||||
# 4. Apply ad-hoc signing
|
||||
- name: Ad-hoc Sign
|
||||
run: codesign --force --deep --sign - "build/PicoClaw Launcher.app"
|
||||
|
||||
# 5. Install the DMG packaging tool
|
||||
- name: Install create-dmg
|
||||
run: brew install create-dmg
|
||||
|
||||
# 6. Create the DMG
|
||||
- name: Create DMG
|
||||
run: |
|
||||
mkdir -p dist
|
||||
create-dmg \
|
||||
--volname "PicoClaw Installer" \
|
||||
--window-pos 200 120 \
|
||||
--window-size 800 400 \
|
||||
--icon-size 100 \
|
||||
--icon "PicoClaw Launcher.app" 200 190 \
|
||||
--hide-extension "PicoClaw Launcher.app" \
|
||||
--app-drop-link 600 185 \
|
||||
"dist/picoclaw-${{ matrix.arch }}.dmg" \
|
||||
"build/PicoClaw Launcher.app"
|
||||
|
||||
# 7. Upload the DMG as a GitHub artifact
|
||||
- name: Upload DMG
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: macos-dmg-${{ matrix.arch }}
|
||||
path: dist/*.dmg
|
||||
@@ -25,17 +25,17 @@ jobs:
|
||||
steps:
|
||||
# ── Checkout ──────────────────────────────
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
# ── Docker Buildx ─────────────────────────
|
||||
- name: 🔧 Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# ── Login to GHCR ─────────────────────────
|
||||
- name: 🔑 Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
|
||||
# ── Login to Docker Hub ────────────────────
|
||||
- name: 🔑 Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
|
||||
# ── Build & Push ──────────────────────────
|
||||
- name: 🚀 Build and push Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
name: Nightly Build
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
nightly:
|
||||
name: Nightly Build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute version
|
||||
id: version
|
||||
run: |
|
||||
DATE=$(date -u +%Y%m%d)
|
||||
SHA=$(git rev-parse --short=8 HEAD)
|
||||
BASE_VERSION=$(git describe --tags --match "v*" --exclude "*nightly*" --abbrev=0 2>/dev/null || true)
|
||||
if [ -z "$BASE_VERSION" ] || [ "$BASE_VERSION" = "v0.0.0" ]; then
|
||||
VERSION="v0.0.0-nightly.${DATE}.${SHA}"
|
||||
else
|
||||
VERSION="${BASE_VERSION}-nightly.${DATE}.${SHA}"
|
||||
fi
|
||||
|
||||
COMPARE_URL="https://github.com/${{ github.repository }}/commits/main"
|
||||
if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "v0.0.0" ]; then
|
||||
COMPARE_URL="https://github.com/${{ github.repository }}/compare/${BASE_VERSION}...main"
|
||||
fi
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "changelog=**Full Changelog**: $COMPARE_URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Go from go.mod
|
||||
id: setup-go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.33.0
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: docker.io
|
||||
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 }}"
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: ~> v2
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||
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 }}
|
||||
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
|
||||
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
|
||||
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
|
||||
|
||||
- name: Update nightly release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
CHANGELOG='${{ steps.version.outputs.changelog }}'
|
||||
NOTES=$(cat <<EOF
|
||||
Nightly build for **${VERSION}**
|
||||
|
||||
This is an automated build and may be unstable. Use with caution.
|
||||
|
||||
${CHANGELOG}
|
||||
EOF
|
||||
)
|
||||
|
||||
# Delete existing nightly release and tag
|
||||
gh release delete nightly --cleanup-tag -y 2>/dev/null || true
|
||||
|
||||
# Force-update nightly tag to current HEAD
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag -fa nightly -m "Nightly build ${VERSION}"
|
||||
git push origin nightly
|
||||
|
||||
# Collect release artifacts from goreleaser dist/
|
||||
ASSETS=()
|
||||
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
|
||||
|
||||
# Create nightly release (prerelease, NOT latest)
|
||||
gh release create nightly \
|
||||
--title "Nightly Build" \
|
||||
--notes "$NOTES" \
|
||||
--target "${{ github.sha }}" \
|
||||
--prerelease \
|
||||
--latest=false \
|
||||
"${ASSETS[@]}"
|
||||
@@ -1,61 +1,52 @@
|
||||
name: PR
|
||||
name: pr-check
|
||||
|
||||
on:
|
||||
pull_request: { }
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Linter
|
||||
fmt-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Check formatting
|
||||
run: |
|
||||
make fmt
|
||||
git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1)
|
||||
|
||||
vet:
|
||||
runs-on: ubuntu-latest
|
||||
needs: fmt-check
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run go generate
|
||||
run: go generate ./...
|
||||
|
||||
- name: Golangci Lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.10.1
|
||||
args: --build-tags=goolm,stdjson
|
||||
|
||||
vuln_check:
|
||||
name: Security Check
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GOFLAGS: -tags=goolm,stdjson
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install govulncheck
|
||||
run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4
|
||||
|
||||
- name: Run Govulncheck
|
||||
run: govulncheck -C . -format text ./...
|
||||
- name: Run go vet
|
||||
run: go vet ./...
|
||||
|
||||
test:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: fmt-check
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
@@ -63,4 +54,5 @@ jobs:
|
||||
run: go generate ./...
|
||||
|
||||
- name: Run go test
|
||||
run: go test -tags goolm,stdjson ./...
|
||||
run: go test ./...
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
name: Release
|
||||
name: Create Tag and Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing tag to release (e.g. v0.2.0)"
|
||||
description: "Release tag (required, e.g. v0.2.0)"
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
@@ -17,80 +17,70 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
upload_tos:
|
||||
description: "Upload to Volcengine TOS"
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
create-tag:
|
||||
name: Create Git Tag
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
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
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Setup Go from go.mod
|
||||
id: setup-go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.33.0
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: docker.io
|
||||
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
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: ~> v2
|
||||
@@ -99,13 +89,6 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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 }}
|
||||
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
|
||||
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
|
||||
|
||||
- name: Apply release flags
|
||||
shell: bash
|
||||
@@ -115,12 +98,3 @@ jobs:
|
||||
gh release edit "${{ inputs.tag }}" \
|
||||
--draft=${{ inputs.draft }} \
|
||||
--prerelease=${{ inputs.prerelease }}
|
||||
|
||||
upload-tos:
|
||||
name: Upload to TOS
|
||||
needs: release
|
||||
if: ${{ inputs.upload_tos }}
|
||||
uses: ./.github/workflows/upload-tos.yml
|
||||
with:
|
||||
tag: ${{ inputs.tag }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
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
|
||||
@@ -1,49 +0,0 @@
|
||||
name: Upload to Volcengine TOS
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to download and upload (e.g. v0.2.0)"
|
||||
required: true
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to download and upload"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
upload-tos:
|
||||
name: Upload to Volcengine TOS
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
gh release download "${{ inputs.tag }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--dir artifacts \
|
||||
--pattern "*.tar.gz" \
|
||||
--pattern "*.zip" \
|
||||
--pattern "*.rpm" \
|
||||
--pattern "*.deb"
|
||||
|
||||
- name: Upload to Volcengine TOS
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.VOLC_TOS_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.VOLC_TOS_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: cn-beijing
|
||||
run: |
|
||||
aws configure set default.s3.addressing_style virtual
|
||||
TOS_ENDPOINT="https://tos-s3-cn-beijing.volces.com"
|
||||
# Upload to versioned directory
|
||||
aws s3 sync artifacts/ "s3://picoclaw-downloads/${{ inputs.tag }}/" \
|
||||
--endpoint-url "$TOS_ENDPOINT"
|
||||
# Upload to latest (overwrite)
|
||||
aws s3 sync artifacts/ "s3://picoclaw-downloads/latest/" \
|
||||
--endpoint-url "$TOS_ENDPOINT" \
|
||||
--delete
|
||||
@@ -10,7 +10,7 @@ build/
|
||||
*.out
|
||||
/picoclaw
|
||||
/picoclaw-test
|
||||
cmd/**/workspace
|
||||
cmd/picoclaw/workspace
|
||||
|
||||
# Picoclaw specific
|
||||
|
||||
@@ -25,9 +25,6 @@ build/
|
||||
# Secrets & Config (keep templates, ignore actual secrets)
|
||||
.env
|
||||
config/config.json
|
||||
.security.yml
|
||||
onboard
|
||||
|
||||
|
||||
# Test
|
||||
coverage.txt
|
||||
@@ -41,35 +38,9 @@ ralph/
|
||||
.ralph/
|
||||
tasks/
|
||||
|
||||
# Plans
|
||||
docs/plans/
|
||||
docs/superpowers/
|
||||
|
||||
# Editors
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Added by goreleaser init:
|
||||
dist/
|
||||
*.vite/
|
||||
|
||||
# Windows Application Icon/Resource
|
||||
*.syso
|
||||
.cache/
|
||||
web/frontend/.pnpm-store/
|
||||
_tmp_*
|
||||
web/frontend/_tmp_*
|
||||
|
||||
# Test telegram integration
|
||||
cmd/telegram/
|
||||
|
||||
# Keep embedded backend dist directory placeholder in VCS
|
||||
!web/backend/dist/
|
||||
web/backend/dist/*
|
||||
!web/backend/dist/.gitkeep
|
||||
|
||||
.claude/
|
||||
|
||||
docker/data
|
||||
|
||||
.omc/
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
default: all
|
||||
disable:
|
||||
# TODO: Tweak for current project needs
|
||||
- containedctx
|
||||
- cyclop
|
||||
- depguard
|
||||
- dupword
|
||||
- err113
|
||||
- exhaustruct
|
||||
- funcorder
|
||||
- gochecknoglobals
|
||||
- gosmopolitan # Project legitimately uses CJK text in tests (FTS5, token counting)
|
||||
- godot
|
||||
- intrange
|
||||
- ireturn
|
||||
- nlreturn
|
||||
- noctx
|
||||
- noinlineerr
|
||||
- nonamedreturns
|
||||
- tagliatelle
|
||||
- testpackage
|
||||
- varnamelen
|
||||
- wrapcheck
|
||||
- wsl
|
||||
- wsl_v5
|
||||
|
||||
# TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step)
|
||||
- contextcheck
|
||||
- embeddedstructfieldcheck
|
||||
- errcheck
|
||||
- errchkjson
|
||||
- errorlint
|
||||
- exhaustive
|
||||
- forbidigo
|
||||
- forcetypeassert
|
||||
- funlen
|
||||
- gochecknoinits
|
||||
- gocognit
|
||||
- goconst
|
||||
- gocritic
|
||||
- gocyclo
|
||||
- godox
|
||||
- gosec
|
||||
- ineffassign
|
||||
- lll
|
||||
- maintidx
|
||||
- mnd
|
||||
- modernize
|
||||
- nestif
|
||||
- nilnil
|
||||
- paralleltest
|
||||
- perfsprint
|
||||
- revive
|
||||
- staticcheck
|
||||
- tagalign
|
||||
- testifylint
|
||||
- thelper
|
||||
- unparam
|
||||
- usestdlibvars
|
||||
- usetesting
|
||||
settings:
|
||||
gomoddirectives:
|
||||
replace-allow-list:
|
||||
- github.com/bwmarrin/discordgo
|
||||
errcheck:
|
||||
check-type-assertions: true
|
||||
check-blank: true
|
||||
exhaustive:
|
||||
default-signifies-exhaustive: true
|
||||
funlen:
|
||||
lines: 120
|
||||
statements: 40
|
||||
gocognit:
|
||||
min-complexity: 25
|
||||
gocyclo:
|
||||
min-complexity: 20
|
||||
govet:
|
||||
enable-all: true
|
||||
disable:
|
||||
- fieldalignment
|
||||
lll:
|
||||
line-length: 120
|
||||
tab-width: 4
|
||||
misspell:
|
||||
locale: US
|
||||
mnd:
|
||||
checks:
|
||||
- argument
|
||||
- assign
|
||||
- case
|
||||
- condition
|
||||
- operation
|
||||
- return
|
||||
nakedret:
|
||||
max-func-lines: 3
|
||||
revive:
|
||||
enable-all-rules: true
|
||||
rules:
|
||||
- name: add-constant
|
||||
disabled: true
|
||||
- name: argument-limit
|
||||
arguments:
|
||||
- 7
|
||||
severity: warning
|
||||
- name: banned-characters
|
||||
disabled: true
|
||||
- name: cognitive-complexity
|
||||
disabled: true
|
||||
- name: comment-spacings
|
||||
arguments:
|
||||
- nolint
|
||||
severity: warning
|
||||
- name: cyclomatic
|
||||
disabled: true
|
||||
- name: file-header
|
||||
disabled: true
|
||||
- name: function-result-limit
|
||||
arguments:
|
||||
- 3
|
||||
severity: warning
|
||||
- name: function-length
|
||||
disabled: true
|
||||
- name: line-length-limit
|
||||
disabled: true
|
||||
- name: max-public-structs
|
||||
disabled: true
|
||||
- name: modifies-value-receiver
|
||||
disabled: true
|
||||
- name: package-comments
|
||||
disabled: true
|
||||
- name: unused-receiver
|
||||
disabled: true
|
||||
exclusions:
|
||||
generated: lax
|
||||
rules:
|
||||
- linters:
|
||||
- lll
|
||||
source: '^//go:generate '
|
||||
- linters:
|
||||
- funlen
|
||||
- maintidx
|
||||
- gocognit
|
||||
- gocyclo
|
||||
path: _test\.go$
|
||||
- linters:
|
||||
- nolintlint
|
||||
path: 'pkg/tools/(i2c\.go|spi\.go)$'
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gci
|
||||
- gofmt
|
||||
- gofumpt
|
||||
- goimports
|
||||
- golines
|
||||
settings:
|
||||
gci:
|
||||
sections:
|
||||
- standard
|
||||
- default
|
||||
- localmodule
|
||||
custom-order: true
|
||||
gofmt:
|
||||
simplify: true
|
||||
rewrite-rules:
|
||||
- pattern: "interface{}"
|
||||
replacement: "any"
|
||||
- pattern: "a[b:len(a)]"
|
||||
replacement: "a[b:]"
|
||||
golines:
|
||||
max-len: 120
|
||||
@@ -2,154 +2,48 @@
|
||||
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
|
||||
version: 2
|
||||
|
||||
git:
|
||||
ignore_tags:
|
||||
- nightly
|
||||
- ".*-nightly.*"
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go generate ./...
|
||||
- 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'
|
||||
- go mod tidy
|
||||
- go generate ./cmd/picoclaw
|
||||
|
||||
builds:
|
||||
- id: picoclaw
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
tags:
|
||||
- goolm
|
||||
- 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
|
||||
- darwin
|
||||
- freebsd
|
||||
- netbsd
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- riscv64
|
||||
- loong64
|
||||
- arm
|
||||
- s390x
|
||||
- mipsle
|
||||
goarm:
|
||||
- "6"
|
||||
- "7"
|
||||
gomips:
|
||||
- softfloat
|
||||
- mips64
|
||||
- arm
|
||||
main: ./cmd/picoclaw
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
- goos: netbsd
|
||||
goarch: s390x
|
||||
- goos: netbsd
|
||||
goarch: mips64
|
||||
- goos: netbsd
|
||||
goarch: arm
|
||||
|
||||
- id: picoclaw-launcher
|
||||
binary: picoclaw-launcher
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
tags:
|
||||
- goolm
|
||||
- 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
|
||||
- darwin
|
||||
- freebsd
|
||||
- netbsd
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- riscv64
|
||||
- loong64
|
||||
- arm
|
||||
- s390x
|
||||
- mipsle
|
||||
goarm:
|
||||
- "6"
|
||||
- "7"
|
||||
gomips:
|
||||
- softfloat
|
||||
main: ./web/backend
|
||||
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
|
||||
extra_files:
|
||||
- docker/entrypoint.sh
|
||||
dockerfile: Dockerfile.goreleaser
|
||||
ids:
|
||||
- picoclaw
|
||||
images:
|
||||
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
|
||||
- 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
|
||||
- "docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}"
|
||||
tags:
|
||||
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}{{ .Tag }}{{ end }}'
|
||||
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}'
|
||||
- "{{ .Tag }}"
|
||||
- "latest"
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
- linux/riscv64
|
||||
|
||||
- id: picoclaw-launcher
|
||||
dockerfile: docker/Dockerfile.goreleaser.launcher
|
||||
ids:
|
||||
- picoclaw
|
||||
- picoclaw-launcher
|
||||
images:
|
||||
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
|
||||
- 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
|
||||
tags:
|
||||
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}{{ .Tag }}-launcher{{ end }}'
|
||||
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}launcher{{ end }}'
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
- linux/riscv64
|
||||
|
||||
notarize:
|
||||
macos:
|
||||
- enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}'
|
||||
ids:
|
||||
- picoclaw
|
||||
- picoclaw-launcher
|
||||
sign:
|
||||
certificate: "{{.Env.MACOS_SIGN_P12}}"
|
||||
password: "{{.Env.MACOS_SIGN_PASSWORD}}"
|
||||
notarize:
|
||||
issuer_id: "{{.Env.MACOS_NOTARY_ISSUER_ID}}"
|
||||
key_id: "{{.Env.MACOS_NOTARY_KEY_ID}}"
|
||||
key: "{{.Env.MACOS_NOTARY_KEY}}"
|
||||
wait: true
|
||||
timeout: 20m
|
||||
|
||||
archives:
|
||||
- formats: [tar.gz]
|
||||
# this name template makes the OS and Arch compatible with the results of `uname`.
|
||||
@@ -165,33 +59,6 @@ archives:
|
||||
- goos: windows
|
||||
formats: [zip]
|
||||
|
||||
nfpms:
|
||||
- id: picoclaw
|
||||
ids:
|
||||
- picoclaw
|
||||
- picoclaw-launcher
|
||||
package_name: picoclaw
|
||||
file_name_template: >-
|
||||
{{ .PackageName }}_
|
||||
{{- if eq .Arch "amd64" }}x86_64
|
||||
{{- else if eq .Arch "arm64" }}aarch64
|
||||
{{- else if eq .Arch "arm" }}armv{{ .Arm }}
|
||||
{{- else }}{{ .Arch }}{{ end }}
|
||||
vendor: picoclaw
|
||||
homepage: https://github.com/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw
|
||||
maintainer: picoclaw contributors
|
||||
description: picoclaw - a tool for managing and running tasks
|
||||
license: MIT
|
||||
formats:
|
||||
- rpm
|
||||
- deb
|
||||
bindir: /usr/bin
|
||||
contents:
|
||||
- src: web/picoclaw-launcher.desktop
|
||||
dst: /usr/share/applications/picoclaw-launcher.desktop
|
||||
- src: web/picoclaw-launcher.png
|
||||
dst: /usr/share/icons/hicolor/512x512/apps/picoclaw-launcher.png
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
@@ -205,9 +72,6 @@ changelog:
|
||||
# lzma: true
|
||||
|
||||
release:
|
||||
disable: '{{ isEnvSet "NIGHTLY_BUILD" }}'
|
||||
extra_files:
|
||||
- glob: ./build/picoclaw-android-universal.zip
|
||||
footer: >-
|
||||
|
||||
---
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
# Contributing to PicoClaw
|
||||
|
||||
Thank you for your interest in contributing to PicoClaw! This project is a community-driven effort to build the lightweight and versatile personal AI assistant. We welcome contributions of all kinds: bug fixes, features, documentation, translations, and testing.
|
||||
|
||||
PicoClaw itself was substantially developed with AI assistance — we embrace this approach and have built our contribution process around it.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Ways to Contribute](#ways-to-contribute)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Making Changes](#making-changes)
|
||||
- [AI-Assisted Contributions](#ai-assisted-contributions)
|
||||
- [Pull Request Process](#pull-request-process)
|
||||
- [Branch Strategy](#branch-strategy)
|
||||
- [Code Review](#code-review)
|
||||
- [Communication](#communication)
|
||||
|
||||
---
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
We are committed to maintaining a welcoming and respectful community. Be kind, constructive, and assume good faith. Harassment or discrimination of any kind will not be tolerated.
|
||||
|
||||
---
|
||||
|
||||
## Ways to Contribute
|
||||
|
||||
- **Bug reports** — Open an issue using the bug report template.
|
||||
- **Feature requests** — Open an issue using the feature request template; discuss before implementing.
|
||||
- **Code** — Fix bugs or implement features. See the workflow below.
|
||||
- **Documentation** — Improve READMEs, docs, inline comments, or translations.
|
||||
- **Testing** — Run PicoClaw on new hardware, channels, or LLM providers and report your results.
|
||||
|
||||
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
|
||||
|
||||
1. **Fork** the repository on GitHub.
|
||||
2. **Clone** your fork locally:
|
||||
```bash
|
||||
git clone https://github.com/<your-username>/picoclaw.git
|
||||
cd picoclaw
|
||||
```
|
||||
3. Add the upstream remote:
|
||||
```bash
|
||||
git remote add upstream https://github.com/sipeed/picoclaw.git
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.25 or later
|
||||
- `make`
|
||||
|
||||
### Build
|
||||
|
||||
```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 + docs consistency checks
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
make test # Run all tests
|
||||
go test -run TestName -v ./pkg/session/ # Run a single test
|
||||
go test -bench=. -benchmem -run='^$' ./... # Run benchmarks
|
||||
```
|
||||
|
||||
### Code Style
|
||||
|
||||
```bash
|
||||
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, including the common docs consistency checks from `make lint-docs`.
|
||||
|
||||
---
|
||||
|
||||
## Making Changes
|
||||
|
||||
### Branching
|
||||
|
||||
Always branch off `main` and target `main` in your PR. Never push directly to `main` or any `release/*` branch:
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull upstream main
|
||||
git checkout -b your-feature-branch
|
||||
```
|
||||
|
||||
Use descriptive branch names, e.g. `fix/telegram-timeout`, `feat/ollama-provider`, `docs/contributing-guide`.
|
||||
|
||||
### Commits
|
||||
|
||||
- Write clear, concise commit messages in English.
|
||||
- Use the imperative mood: "Add retry logic" not "Added retry logic".
|
||||
- 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 [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||
|
||||
### Keeping Up to Date
|
||||
|
||||
Rebase your branch onto upstream `main` before opening a PR:
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git rebase upstream/main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AI-Assisted Contributions
|
||||
|
||||
PicoClaw was built with substantial AI assistance, and we fully embrace AI-assisted development. However, contributors must understand their responsibilities when using AI tools.
|
||||
|
||||
### Disclosure Is Required
|
||||
|
||||
Every PR must disclose AI involvement using the PR template's **🤖 AI Code Generation** section. There are three levels:
|
||||
|
||||
| Level | Description |
|
||||
|---|---|
|
||||
| 🤖 Fully AI-generated | AI wrote the code; contributor reviewed and validated it |
|
||||
| 🛠️ Mostly AI-generated | AI produced the draft; contributor made significant modifications |
|
||||
| 👨💻 Mostly Human-written | Contributor led; AI provided suggestions or none at all |
|
||||
|
||||
Honest disclosure is expected. There is no stigma attached to any level — what matters is the quality of the contribution.
|
||||
|
||||
### You Are Responsible for What You Submit
|
||||
|
||||
Using AI to generate code does not reduce your responsibility as the contributor. Before opening a PR with AI-generated code, you must:
|
||||
|
||||
- **Read and understand** every line of the generated code.
|
||||
- **Test it** in a real environment (see the Test Environment section of the PR template).
|
||||
- **Check for security issues** — AI models can generate subtly insecure code (e.g., path traversal, injection, credential exposure). Review carefully.
|
||||
- **Verify correctness** — AI-generated logic can be plausible-sounding but wrong. Validate the behavior, not just the syntax.
|
||||
|
||||
PRs where it is clear the contributor has not read or tested the AI-generated code will be closed without review.
|
||||
|
||||
### AI-Generated Code Quality Standards
|
||||
|
||||
AI-generated contributions are held to the **same quality bar** as human-written code:
|
||||
|
||||
- It must pass all CI checks (`make check`).
|
||||
- It must be idiomatic Go and consistent with the existing codebase style.
|
||||
- It must not introduce unnecessary abstractions, dead code, or over-engineering.
|
||||
- It must include or update tests where appropriate.
|
||||
|
||||
### Security Review
|
||||
|
||||
AI-generated code requires extra security scrutiny. Pay special attention to:
|
||||
|
||||
- File path handling and sandbox escapes (see commit `244eb0b` for a real example)
|
||||
- External input validation in channel handlers and tool implementations
|
||||
- Credential or secret handling
|
||||
- Command execution (`exec.Command`, shell invocations)
|
||||
|
||||
If you are unsure whether a piece of AI-generated code is safe, say so in the PR — reviewers will help.
|
||||
|
||||
---
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
### Before Opening a PR
|
||||
|
||||
- [ ] Run `make check` and ensure it passes locally.
|
||||
- [ ] Fill in the PR template completely, including the AI disclosure section.
|
||||
- [ ] Link any related issue(s) in the PR description.
|
||||
- [ ] Keep the PR focused. Avoid bundling unrelated changes together.
|
||||
|
||||
### PR Template Sections
|
||||
|
||||
The PR template asks for:
|
||||
|
||||
- **Description** — What does this change do and why?
|
||||
- **Type of Change** — Bug fix, feature, docs, or refactor.
|
||||
- **AI Code Generation** — Disclosure of AI involvement (required).
|
||||
- **Related Issue** — Link to the issue this addresses.
|
||||
- **Technical Context** — Reference URLs and reasoning (skip for pure docs PRs).
|
||||
- **Test Environment** — Hardware, OS, model/provider, and channels used for testing.
|
||||
- **Evidence** — Optional logs or screenshots demonstrating the change works.
|
||||
- **Checklist** — Self-review confirmation.
|
||||
|
||||
### PR Size
|
||||
|
||||
Prefer small, reviewable PRs. A PR that changes 200 lines across 5 files is much easier to review than one that changes 2000 lines across 30 files. If your feature is large, consider splitting it into a series of smaller, logically complete PRs.
|
||||
|
||||
---
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
### Long-Lived Branches
|
||||
|
||||
- **`main`** — the active development branch. All feature PRs target `main`. The branch is protected: direct pushes are not permitted, and at least one maintainer approval is required before merging.
|
||||
- **`release/x.y`** — stable release branches, cut from `main` when a version is ready to ship. These branches are more strictly protected than `main`.
|
||||
|
||||
### Requirements to Merge into `main`
|
||||
|
||||
A PR can only be merged when all of the following are satisfied:
|
||||
|
||||
1. **CI passes** — All GitHub Actions workflows (lint, test, build) must be green.
|
||||
2. **Reviewer approval** — At least one maintainer has approved the PR.
|
||||
3. **No unresolved review comments** — All review threads must be resolved.
|
||||
4. **PR template is complete** — Including AI disclosure and test environment.
|
||||
|
||||
### Who Can Merge
|
||||
|
||||
Only maintainers can merge PRs. Contributors cannot merge their own PRs, even if they have write access.
|
||||
|
||||
### Merge Strategy
|
||||
|
||||
We use **squash merge** for most PRs to keep the `main` history clean and readable. Each merged PR becomes a single commit referencing the PR number, e.g.:
|
||||
|
||||
```
|
||||
feat: Add Ollama provider support (#491)
|
||||
```
|
||||
|
||||
If a PR consists of multiple independent, well-separated commits that tell a clear story, a regular merge may be used at the maintainer's discretion.
|
||||
|
||||
### Release Branches
|
||||
|
||||
When a version is ready, maintainers cut a `release/x.y` branch from `main`. After that point:
|
||||
|
||||
- **New features are not backported.** The release branch receives no new functionality after it is cut.
|
||||
- **Security fixes and critical bug fixes are cherry-picked.** If a fix in `main` qualifies (security vulnerability, data loss, crash), maintainers will cherry-pick the relevant commit(s) onto the affected `release/x.y` branch and issue a patch release.
|
||||
|
||||
If you believe a fix in `main` should be backported to a release branch, note it in the PR description or open a separate issue. The decision rests with the maintainers.
|
||||
|
||||
Release branches have stricter protections than `main` and are never directly pushed to under any circumstances.
|
||||
|
||||
---
|
||||
|
||||
## Code Review
|
||||
|
||||
### For Contributors
|
||||
|
||||
- Respond to review comments within a reasonable time. If you need more time, say so.
|
||||
- When you update a PR in response to feedback, briefly note what changed (e.g., "Updated to use `sync.RWMutex` as suggested").
|
||||
- If you disagree with feedback, engage respectfully. Explain your reasoning; reviewers can be wrong too.
|
||||
- Do not force-push after a review has started — it makes it harder for reviewers to see what changed. Use additional commits instead; the maintainer will squash on merge.
|
||||
|
||||
### For Reviewers
|
||||
|
||||
Review for:
|
||||
|
||||
1. **Correctness** — Does the code do what it claims? Are there edge cases?
|
||||
2. **Security** — Especially for AI-generated code, tool implementations, and channel handlers.
|
||||
3. **Architecture** — Is the approach consistent with the existing design?
|
||||
4. **Simplicity** — Is there a simpler solution? Does this add unnecessary complexity?
|
||||
5. **Tests** — Are the changes covered by tests? Are existing tests still meaningful?
|
||||
|
||||
Be constructive and specific. "This could have a race condition if two goroutines call this concurrently — consider using a mutex here" is better than "this looks wrong".
|
||||
|
||||
|
||||
### Reviewer List
|
||||
Once your PR is submitted, you can reach out to the assigned reviewers listed in the following table.
|
||||
|
||||
|Function| Reviewer|
|
||||
|--- |--- |
|
||||
|Provider|@yinwm |
|
||||
|Channel |@yinwm/@alexhoshina |
|
||||
|Agent |@lxowalle/@Zhaoyikaiii|
|
||||
|Tools |@lxowalle|
|
||||
|SKill ||
|
||||
|MCP ||
|
||||
|Optimization|@lxowalle|
|
||||
|Security||
|
||||
|AI CI |@imguoguo|
|
||||
|UX ||
|
||||
|Document||
|
||||
|
||||
---
|
||||
|
||||
## Communication
|
||||
|
||||
- **GitHub Issues** — Bug reports, feature requests, design discussions.
|
||||
- **GitHub Discussions** — General questions, ideas, community conversation.
|
||||
- **Pull Request comments** — Code-specific feedback.
|
||||
- **Wechat&Discord** — We will invite you when you have at least one merged PR
|
||||
|
||||
When in doubt, open an issue before writing code. It costs little and prevents wasted effort.
|
||||
|
||||
---
|
||||
|
||||
## A Note on the Project's AI-Driven Origin
|
||||
|
||||
PicoClaw's architecture was substantially designed and implemented with AI assistance, guided by human oversight. If you find something that looks odd or over-engineered, it may be an artifact of that process — opening an issue to discuss it is always welcome.
|
||||
|
||||
We believe AI-assisted development done responsibly produces great results. We also believe humans must remain accountable for what they ship. These two beliefs are not in conflict.
|
||||
|
||||
Thank you for contributing!
|
||||
@@ -1,7 +1,7 @@
|
||||
# ============================================================
|
||||
# Stage 1: Build the picoclaw binary
|
||||
# ============================================================
|
||||
FROM golang:1.25-alpine AS builder
|
||||
FROM golang:1.26.0-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git make
|
||||
|
||||
@@ -26,9 +26,11 @@ 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 and first-run entrypoint (same as release image).
|
||||
# Copy binary
|
||||
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
# Create picoclaw home directory
|
||||
RUN /usr/local/bin/picoclaw onboard
|
||||
|
||||
ENTRYPOINT ["picoclaw"]
|
||||
CMD ["gateway"]
|
||||
@@ -5,8 +5,6 @@ ARG TARGETPLATFORM
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
ENTRYPOINT ["picoclaw"]
|
||||
CMD ["gateway"]
|
||||
@@ -19,3 +19,7 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
PicoClaw is heavily inspired by and based on [nanobot](https://github.com/HKUDS/nanobot) by HKUDS.
|
||||
|
||||
@@ -1,92 +1,26 @@
|
||||
.PHONY: all build install uninstall clean help test build-all lint-docs
|
||||
.PHONY: all build install uninstall clean help test
|
||||
|
||||
# Build variables
|
||||
BINARY_NAME=picoclaw
|
||||
BUILD_DIR=build
|
||||
CMD_DIR=cmd/$(BINARY_NAME)
|
||||
MAIN_GO=$(CMD_DIR)/main.go
|
||||
EXT=
|
||||
|
||||
ifeq ($(OS),Windows_NT)
|
||||
POWERSHELL=powershell -NoProfile -Command
|
||||
WINDOWS_GOARCH_RAW:=$(strip $(shell go env GOARCH 2>NUL))
|
||||
endif
|
||||
|
||||
# Version
|
||||
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
|
||||
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}')
|
||||
LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION)"
|
||||
|
||||
# Go variables
|
||||
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)
|
||||
GO_BUILD_TAGS_NO_GOOLM:=$(subst $(space),$(comma),$(strip $(filter-out goolm,$(subst $(comma),$(space),$(GO_BUILD_TAGS)))))
|
||||
GOFLAGS_NO_GOOLM?=-v -tags $(GO_BUILD_TAGS_NO_GOOLM)
|
||||
|
||||
# Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600).
|
||||
#
|
||||
# Bytes (octal): \004 \024 \000 \160 → little-endian 0x70001404
|
||||
# 0x70000000 EF_MIPS_ARCH_32R2 MIPS32 Release 2
|
||||
# 0x00001000 EF_MIPS_ABI_O32 O32 ABI
|
||||
# 0x00000400 EF_MIPS_NAN2008 IEEE 754-2008 NaN encoding
|
||||
# 0x00000004 EF_MIPS_CPIC PIC calling sequence
|
||||
#
|
||||
# Go's GOMIPS=softfloat emits no FP instructions, so the NaN mode is irrelevant
|
||||
# at runtime — this is purely an ELF metadata fix to satisfy the kernel's check.
|
||||
# patchelf cannot modify e_flags; dd at a fixed offset is the most portable way.
|
||||
#
|
||||
# Ref: https://codebrowser.dev/linux/linux/arch/mips/include/asm/elf.h.html
|
||||
define PATCH_MIPS_FLAGS
|
||||
@if [ -f "$(1)" ]; then \
|
||||
printf '\004\024\000\160' | dd of=$(1) bs=1 seek=36 count=4 conv=notrunc 2>/dev/null || \
|
||||
{ echo "Error: failed to patch MIPS e_flags for $(1)"; exit 1; }; \
|
||||
else \
|
||||
echo "Error: $(1) not found, cannot patch MIPS e_flags"; exit 1; \
|
||||
fi
|
||||
endef
|
||||
|
||||
# Patch creack/pty for loong64 support (upstream doesn't have ztypes_loong64.go)
|
||||
PTY_PATCH_LOONG64=pty_dir=$$(go env GOMODCACHE)/github.com/creack/pty@v1.1.9; \
|
||||
if [ -d "$$pty_dir" ] && [ ! -f "$$pty_dir/ztypes_loong64.go" ]; then \
|
||||
chmod +w "$$pty_dir" 2>/dev/null || true; \
|
||||
printf '//go:build linux && loong64\npackage pty\ntype (_C_int int32; _C_uint uint32)\n' > "$$pty_dir/ztypes_loong64.go"; \
|
||||
fi
|
||||
|
||||
# Golangci-lint
|
||||
GOLANGCI_LINT?=golangci-lint
|
||||
GOFLAGS?=-v
|
||||
|
||||
# Installation
|
||||
INSTALL_PREFIX?=$(HOME)/.local
|
||||
INSTALL_BIN_DIR=$(INSTALL_PREFIX)/bin
|
||||
INSTALL_MAN_DIR=$(INSTALL_PREFIX)/share/man/man1
|
||||
INSTALL_TMP_SUFFIX=.new
|
||||
|
||||
# Workspace and Skills
|
||||
PICOCLAW_HOME?=$(HOME)/.picoclaw
|
||||
@@ -94,24 +28,9 @@ WORKSPACE_DIR?=$(PICOCLAW_HOME)/workspace
|
||||
WORKSPACE_SKILLS_DIR=$(WORKSPACE_DIR)/skills
|
||||
BUILTIN_SKILLS_DIR=$(CURDIR)/skills
|
||||
|
||||
LNCMD=ln -sf
|
||||
|
||||
# OS detection
|
||||
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
|
||||
UNAME_S:=$(shell uname -s)
|
||||
UNAME_M:=$(shell uname -m)
|
||||
|
||||
# Platform-specific settings
|
||||
ifeq ($(UNAME_S),Linux)
|
||||
@@ -120,67 +39,25 @@ ifeq ($(UNAME_S),Linux)
|
||||
ARCH=amd64
|
||||
else ifeq ($(UNAME_M),aarch64)
|
||||
ARCH=arm64
|
||||
else ifeq ($(UNAME_M),armv81)
|
||||
ARCH=arm64
|
||||
else ifeq ($(UNAME_M),loongarch64)
|
||||
ARCH=loong64
|
||||
else ifeq ($(UNAME_M),riscv64)
|
||||
ARCH=riscv64
|
||||
else ifeq ($(UNAME_M),mipsel)
|
||||
ARCH=mipsle
|
||||
else
|
||||
ARCH=$(UNAME_M)
|
||||
endif
|
||||
else ifeq ($(UNAME_S),Darwin)
|
||||
PLATFORM=darwin
|
||||
WEB_GO=CGO_LDFLAGS="-mmacosx-version-min=10.11" CGO_CFLAGS="-mmacosx-version-min=10.11" CGO_ENABLED=1 go
|
||||
ifeq ($(UNAME_M),x86_64)
|
||||
ARCH?=amd64
|
||||
ARCH=amd64
|
||||
else ifeq ($(UNAME_M),arm64)
|
||||
ARCH?=arm64
|
||||
ARCH=arm64
|
||||
else
|
||||
ARCH?=$(UNAME_M)
|
||||
ARCH=$(UNAME_M)
|
||||
endif
|
||||
else
|
||||
PLATFORM=$(UNAME_S)
|
||||
ifeq ($(UNAME_M),x86_64)
|
||||
ARCH?=amd64
|
||||
else
|
||||
ARCH?=$(UNAME_M)
|
||||
endif
|
||||
# Detect Windows (Git Bash / MSYS2)
|
||||
IS_WINDOWS:=$(if $(findstring MINGW,$(UNAME_S)),yes,$(if $(findstring MSYS,$(UNAME_S)),yes,$(if $(findstring CYGWIN,$(UNAME_S)),yes,no)))
|
||||
ifeq ($(IS_WINDOWS),yes)
|
||||
EXT=.exe
|
||||
LNCMD=cp
|
||||
else ifeq ($(UNAME_S),windows) # failsafe for force windows build in other OS using UNAME_S=windows
|
||||
EXT=.exe
|
||||
endif
|
||||
|
||||
endif
|
||||
|
||||
ifeq ($(OS),Windows_NT)
|
||||
PLATFORM=windows
|
||||
ifeq ($(UNAME_M),x86_64)
|
||||
ARCH?=amd64
|
||||
else ifeq ($(UNAME_M),arm64)
|
||||
ARCH?=arm64
|
||||
else
|
||||
ARCH?=$(UNAME_M)
|
||||
endif
|
||||
EXT=.exe
|
||||
endif
|
||||
|
||||
ifneq ($(strip $(GOOS)),)
|
||||
PLATFORM:=$(GOOS)
|
||||
endif
|
||||
|
||||
ifneq ($(strip $(GOARCH)),)
|
||||
ARCH:=$(GOARCH)
|
||||
endif
|
||||
|
||||
ifeq ($(PLATFORM),windows)
|
||||
EXT=.exe
|
||||
ARCH=$(UNAME_M)
|
||||
endif
|
||||
|
||||
BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH)
|
||||
@@ -191,155 +68,36 @@ 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
|
||||
@GOOS=$$($(GO) env GOHOSTOS) GOARCH=$$($(GO) env GOHOSTARCH) $(GO) generate ./...
|
||||
endif
|
||||
@$(GO) generate ./...
|
||||
@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
|
||||
@echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..."
|
||||
@mkdir -p $(BUILD_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)"
|
||||
@$(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||
@echo "Build complete: $(BINARY_PATH)"
|
||||
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||
|
||||
## 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)
|
||||
@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-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
|
||||
build-whatsapp-native: generate
|
||||
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
|
||||
@echo "Building for multiple platforms..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=amd64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=loong64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=riscv64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags $(GO_BUILD_TAGS_NO_GOOLM),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||
GOOS=darwin GOARCH=arm64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||
GOOS=windows GOARCH=amd64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||
## @$(GO) build $(GOFLAGS) -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||
@echo "Build complete"
|
||||
## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||
|
||||
## build-linux-arm: Build for Linux ARMv7 (e.g. Raspberry Pi Zero 2 W 32-bit)
|
||||
build-linux-arm: generate
|
||||
@echo "Building for linux/arm (GOARM=7)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm"
|
||||
|
||||
## build-linux-arm64: Build for Linux ARM64 (e.g. Raspberry Pi Zero 2 W 64-bit)
|
||||
build-linux-arm64: generate
|
||||
@echo "Building for linux/arm64..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
|
||||
|
||||
## build-linux-mipsle: Build for Linux MIPS32 LE
|
||||
build-linux-mipsle: generate
|
||||
@echo "Building for linux/mipsle (softfloat)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(GOFLAGS_NO_GOOLM) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||
@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 the picoclaw core binary for all Makefile-managed platforms
|
||||
## build-all: Build picoclaw for all platforms
|
||||
build-all: generate
|
||||
@echo "Building for multiple platforms..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||
@$(PTY_PATCH_LOONG64)
|
||||
GOOS=linux GOARCH=loong64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=riscv64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(GOFLAGS_NO_GOOLM) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
|
||||
GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||
GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||
GOOS=netbsd GOARCH=amd64 $(GO) build $(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 "Core builds complete"
|
||||
GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||
GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||
@echo "All builds complete"
|
||||
|
||||
## install: Install picoclaw to system and copy builtin skills
|
||||
install: build
|
||||
@echo "Installing $(BINARY_NAME)..."
|
||||
@mkdir -p $(INSTALL_BIN_DIR)
|
||||
# Copy binary with temporary suffix to ensure atomic update
|
||||
@cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)
|
||||
@chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)
|
||||
@mv -f $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
|
||||
@cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
|
||||
@chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)
|
||||
@echo "Installed binary to $(INSTALL_BIN_DIR)/$(BINARY_NAME)"
|
||||
@echo "Installation complete!"
|
||||
|
||||
@@ -361,40 +119,20 @@ 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
|
||||
vet: generate
|
||||
@packages="$$($(GO) list $(GOFLAGS) ./...)" && \
|
||||
$(GO) vet $(GOFLAGS) $$(printf '%s\n' "$$packages" | grep -v '^github.com/sipeed/picoclaw/web/')
|
||||
@cd web/backend && $(WEB_GO) vet ./...
|
||||
vet:
|
||||
@$(GO) vet ./...
|
||||
|
||||
## test: Test Go code
|
||||
test: generate
|
||||
@$(GO) test $(GOFLAGS) $$($(GO) list $(GOFLAGS) ./... | grep -v github.com/sipeed/picoclaw/web/)
|
||||
@cd web && make test
|
||||
## fmt: Format Go code
|
||||
test:
|
||||
@$(GO) test ./...
|
||||
|
||||
## fmt: Format Go code
|
||||
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:
|
||||
@$(GOLANGCI_LINT) run --fix --build-tags $(GO_BUILD_TAGS)
|
||||
@$(GO) fmt ./...
|
||||
|
||||
## deps: Download dependencies
|
||||
deps:
|
||||
@@ -406,81 +144,13 @@ update-deps:
|
||||
@$(GO) get -u ./...
|
||||
@$(GO) mod tidy
|
||||
|
||||
## check: Run deps, fmt, vet, tests, and docs consistency checks
|
||||
check: deps fmt vet test lint-docs
|
||||
## check: Run vet, fmt, and verify dependencies
|
||||
check: deps fmt vet test
|
||||
|
||||
## run: Build and run picoclaw
|
||||
run: build
|
||||
@$(BUILD_DIR)/$(BINARY_NAME) $(ARGS)
|
||||
|
||||
## docker-build: Build Docker image (minimal Alpine-based)
|
||||
docker-build:
|
||||
@echo "Building minimal Docker image (Alpine-based)..."
|
||||
docker compose -f docker/docker-compose.yml build picoclaw-agent picoclaw-gateway
|
||||
|
||||
## docker-build-full: Build Docker image with full MCP support (Node.js 24)
|
||||
docker-build-full:
|
||||
@echo "Building full-featured Docker image (Node.js 24)..."
|
||||
docker compose -f docker/docker-compose.full.yml build picoclaw-agent picoclaw-gateway
|
||||
|
||||
## docker-test: Test MCP tools in Docker container
|
||||
docker-test:
|
||||
@echo "Testing MCP tools in Docker..."
|
||||
@chmod +x scripts/test-docker-mcp.sh
|
||||
@./scripts/test-docker-mcp.sh
|
||||
|
||||
## docker-run: Run picoclaw gateway in Docker (Alpine-based)
|
||||
docker-run:
|
||||
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||
|
||||
## docker-run-full: Run picoclaw gateway in Docker (full-featured)
|
||||
docker-run-full:
|
||||
docker compose -f docker/docker-compose.full.yml --profile gateway up
|
||||
|
||||
## docker-run-agent: Run picoclaw agent in Docker (interactive, Alpine-based)
|
||||
docker-run-agent:
|
||||
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||
|
||||
## docker-run-agent-full: Run picoclaw agent in Docker (interactive, full-featured)
|
||||
docker-run-agent-full:
|
||||
docker compose -f docker/docker-compose.full.yml run --rm picoclaw-agent
|
||||
|
||||
## docker-clean: Clean Docker images and volumes
|
||||
docker-clean:
|
||||
docker compose -f docker/docker-compose.yml down -v
|
||||
docker compose -f docker/docker-compose.full.yml down -v
|
||||
docker rmi picoclaw:latest picoclaw:full 2>/dev/null || true
|
||||
|
||||
|
||||
## build-macos-app: Build PicoClaw macOS .app bundle (no terminal window)
|
||||
build-macos-app:build-launcher
|
||||
@echo "Building macOS .app bundle..."
|
||||
@if [ "$(UNAME_S)" != "Darwin" ]; then \
|
||||
echo "Error: This target is only available on macOS"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@./scripts/build-macos-app.sh $(PLATFORM)-$(ARCH)
|
||||
@echo "macOS .app bundle created: $(BUILD_DIR)/PicoClaw.app"
|
||||
|
||||
## mem: Build membench, download LOCOMO data (if needed), run benchmark, and show results
|
||||
mem:
|
||||
@echo "Building membench..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@$(GO) build -o $(BUILD_DIR)/membench ./cmd/membench
|
||||
@echo "Build complete: $(BUILD_DIR)/membench"
|
||||
@if [ ! -f $(BUILD_DIR)/memdata/locomo10.json ]; then \
|
||||
echo "Downloading LOCOMO dataset..."; \
|
||||
mkdir -p $(BUILD_DIR)/memdata; \
|
||||
curl -sfL "https://raw.githubusercontent.com/snap-research/locomo/main/data/locomo10.json" \
|
||||
-o $(BUILD_DIR)/memdata/locomo10.json && [ -s $(BUILD_DIR)/memdata/locomo10.json ] || { echo "Error: LOCOMO download failed"; exit 1; }; \
|
||||
echo "Download complete"; \
|
||||
else \
|
||||
echo "LOCOMO dataset already exists, skipping download"; \
|
||||
fi
|
||||
@echo "Running benchmark..."
|
||||
@rm -rf $(BUILD_DIR)/memout
|
||||
@$(BUILD_DIR)/membench run --data $(BUILD_DIR)/memdata --out $(BUILD_DIR)/memout --budget 4000
|
||||
|
||||
## help: Show this help message
|
||||
help:
|
||||
@echo "picoclaw Makefile"
|
||||
@@ -489,15 +159,13 @@ help:
|
||||
@echo " make [target]"
|
||||
@echo ""
|
||||
@echo "Targets:"
|
||||
@grep -E '^## ' $(MAKEFILE_LIST) | sort | awk -F': ' '{printf " %-16s %s\n", substr($$1, 4), $$2}'
|
||||
@grep -E '^## ' $(MAKEFILE_LIST) | sed 's/## / /'
|
||||
@echo ""
|
||||
@echo "Examples:"
|
||||
@echo " make build # Build for current platform"
|
||||
@echo " make install # Install to ~/.local/bin"
|
||||
@echo " make uninstall # Remove from /usr/local/bin"
|
||||
@echo " make install-skills # Install skills to workspace"
|
||||
@echo " make docker-build # Build minimal Docker image"
|
||||
@echo " make docker-test # Test MCP tools in Docker"
|
||||
@echo ""
|
||||
@echo "Environment Variables:"
|
||||
@echo " INSTALL_PREFIX # Installation prefix (default: ~/.local)"
|
||||
|
||||
@@ -0,0 +1,775 @@
|
||||
<div align="center">
|
||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
<h1>PicoClaw: Go で書かれた超効率 AI アシスタント</h1>
|
||||
|
||||
<h3>$10 ハードウェア · 10MB RAM · 1秒起動 · 皮皮虾,我们走!</h3>
|
||||
<h3></h3>
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
</p>
|
||||
|
||||
**日本語** | [English](README.md)
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
---
|
||||
|
||||
🦐 PicoClaw は [nanobot](https://github.com/HKUDS/nanobot) にインスパイアされた超軽量パーソナル AI アシスタントです。Go でゼロからリファクタリングされ、AI エージェント自身がアーキテクチャの移行とコード最適化を推進するセルフブートストラッピングプロセスで構築されました。
|
||||
|
||||
⚡️ $10 のハードウェアで 10MB 未満の RAM で動作:OpenClaw より 99% 少ないメモリ、Mac mini より 98% 安い!
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<td align="center" valign="top">
|
||||
<p align="center">
|
||||
<img src="assets/picoclaw_mem.gif" width="360" height="240">
|
||||
</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<p align="center">
|
||||
<img src="assets/licheervnano.png" width="400" height="240">
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 📢 ニュース
|
||||
2026-02-09 🎉 PicoClaw リリース!$10 ハードウェアで 10MB 未満の RAM で動く AI エージェントを 1 日で構築。🦐 皮皮虾,我们走!
|
||||
|
||||
## ✨ 特徴
|
||||
|
||||
🪶 **超軽量**: メモリフットプリント 10MB 未満 — Clawdbot のコア機能より 99% 小さい。
|
||||
|
||||
💰 **最小コスト**: $10 ハードウェアで動作 — Mac mini より 98% 安い。
|
||||
|
||||
⚡️ **超高速**: 起動時間 400 倍高速、0.6GHz シングルコアでも 1 秒で起動。
|
||||
|
||||
🌍 **真のポータビリティ**: RISC-V、ARM、x86 対応の単一バイナリ。ワンクリックで Go!
|
||||
|
||||
🤖 **AI ブートストラップ**: 自律的な Go ネイティブ実装 — コアの 95% が AI 生成、人間によるレビュー付き。
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| --- | --- | --- |--- |
|
||||
| **言語** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1GB |>100MB| **< 10MB** |
|
||||
| **起動時間**</br>(0.8GHz コア) | >500秒 | >30秒 | **<1秒** |
|
||||
| **コスト** | Mac Mini 599$ | 大半の Linux SBC </br>~50$ |**あらゆる Linux ボード**</br>**最安 10$** |
|
||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
|
||||
## 🦾 デモンストレーション
|
||||
### 🛠️ スタンダードアシスタントワークフロー
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th><p align="center">🧩 フルスタックエンジニア</p></th>
|
||||
<th><p align="center">🗂️ ログ&計画管理</p></th>
|
||||
<th><p align="center">🔎 Web 検索&学習</p></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">開発 · デプロイ · スケール</td>
|
||||
<td align="center">スケジュール · 自動化 · メモリ</td>
|
||||
<td align="center">発見 · インサイト · トレンド</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 🐜 革新的な省フットプリントデプロイ
|
||||
PicoClaw はほぼすべての Linux デバイスにデプロイできます!
|
||||
|
||||
- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) または W(WiFi6) バージョン、最小ホームアシスタントに
|
||||
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html) または $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) サーバー自動メンテナンスに
|
||||
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) または $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) スマート監視に
|
||||
|
||||
https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4
|
||||
|
||||
🌟 もっと多くのデプロイ事例が待っています!
|
||||
|
||||
## 📦 インストール
|
||||
|
||||
### コンパイル済みバイナリでインストール
|
||||
|
||||
[リリースページ](https://github.com/sipeed/picoclaw/releases) からお使いのプラットフォーム用のファームウェアをダウンロードしてください。
|
||||
|
||||
### ソースからインストール(最新機能、開発向け推奨)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# ビルド(インストール不要)
|
||||
make build
|
||||
|
||||
# 複数プラットフォーム向けビルド
|
||||
make build-all
|
||||
|
||||
# ビルドとインストール
|
||||
make install
|
||||
```
|
||||
|
||||
## 🐳 Docker Compose
|
||||
|
||||
Docker Compose を使えば、ローカルにインストールせずに PicoClaw を実行できます。
|
||||
|
||||
```bash
|
||||
# 1. リポジトリをクローン
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
|
||||
# 2. API キーを設定
|
||||
cp config/config.example.json config/config.json
|
||||
vim config/config.json # DISCORD_BOT_TOKEN, プロバイダーの API キーを設定
|
||||
|
||||
# 3. ビルドと起動
|
||||
docker compose --profile gateway up -d
|
||||
|
||||
# 4. ログ確認
|
||||
docker compose logs -f picoclaw-gateway
|
||||
|
||||
# 5. 停止
|
||||
docker compose --profile gateway down
|
||||
```
|
||||
|
||||
### Agent モード(ワンショット)
|
||||
|
||||
```bash
|
||||
# 質問を投げる
|
||||
docker compose run --rm picoclaw-agent -m "What is 2+2?"
|
||||
|
||||
# インタラクティブモード
|
||||
docker compose run --rm picoclaw-agent
|
||||
```
|
||||
|
||||
### リビルド
|
||||
|
||||
```bash
|
||||
docker compose --profile gateway build --no-cache
|
||||
docker compose --profile gateway up -d
|
||||
```
|
||||
|
||||
### 🚀 クイックスタート(ネイティブ)
|
||||
|
||||
> [!TIP]
|
||||
> `~/.picoclaw/config.json` に API キーを設定してください。
|
||||
> API キーの取得先: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
||||
> Web 検索は **任意** です - 無料の [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)
|
||||
|
||||
**1. 初期化**
|
||||
|
||||
```bash
|
||||
picoclaw onboard
|
||||
```
|
||||
|
||||
**2. 設定** (`~/.picoclaw/config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model": "glm-4.7",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"api_key": "xxx",
|
||||
"api_base": "https://openrouter.ai/api/v1"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. API キーの取得**
|
||||
|
||||
- **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||
- **Web 検索**(任意): [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト)
|
||||
|
||||
> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。
|
||||
|
||||
**3. チャット**
|
||||
|
||||
```bash
|
||||
picoclaw agent -m "What is 2+2?"
|
||||
```
|
||||
|
||||
これだけです!2 分で AI アシスタントが動きます。
|
||||
|
||||
---
|
||||
|
||||
## 💬 チャットアプリ
|
||||
|
||||
Telegram、Discord、QQ、DingTalk、LINE で PicoClaw と会話できます
|
||||
|
||||
| チャネル | セットアップ |
|
||||
|---------|------------|
|
||||
| **Telegram** | 簡単(トークンのみ) |
|
||||
| **Discord** | 簡単(Bot トークン + Intents) |
|
||||
| **QQ** | 簡単(AppID + AppSecret) |
|
||||
| **DingTalk** | 普通(アプリ認証情報) |
|
||||
| **LINE** | 普通(認証情報 + Webhook URL) |
|
||||
|
||||
<details>
|
||||
<summary><b>Telegram</b>(推奨)</summary>
|
||||
|
||||
**1. Bot を作成**
|
||||
|
||||
- Telegram を開き、`@BotFather` を検索
|
||||
- `/newbot` を送信、プロンプトに従う
|
||||
- トークンをコピー
|
||||
|
||||
**2. 設定**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allowFrom": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> ユーザー ID は Telegram の `@userinfobot` から取得できます。
|
||||
|
||||
**3. 起動**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>Discord</b></summary>
|
||||
|
||||
**1. Bot を作成**
|
||||
- https://discord.com/developers/applications にアクセス
|
||||
- アプリケーションを作成 → Bot → Add Bot
|
||||
- Bot トークンをコピー
|
||||
|
||||
**2. Intents を有効化**
|
||||
- Bot の設定画面で **MESSAGE CONTENT INTENT** を有効化
|
||||
- (任意)**SERVER MEMBERS INTENT** も有効化
|
||||
|
||||
**3. ユーザー ID を取得**
|
||||
- Discord 設定 → 詳細設定 → **開発者モード** を有効化
|
||||
- 自分のアバターを右クリック → **ユーザーIDをコピー**
|
||||
|
||||
**4. 設定**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allowFrom": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**5. Bot を招待**
|
||||
- OAuth2 → URL Generator
|
||||
- Scopes: `bot`
|
||||
- Bot Permissions: `Send Messages`, `Read Message History`
|
||||
- 生成された招待 URL を開き、サーバーに Bot を追加
|
||||
|
||||
**6. 起動**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>QQ</b></summary>
|
||||
|
||||
**1. Bot を作成**
|
||||
|
||||
- [QQ オープンプラットフォーム](https://q.qq.com/#) にアクセス
|
||||
- アプリケーションを作成 → **AppID** と **AppSecret** を取得
|
||||
|
||||
**2. 設定**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"qq": {
|
||||
"enabled": true,
|
||||
"app_id": "YOUR_APP_ID",
|
||||
"app_secret": "YOUR_APP_SECRET",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `allow_from` を空にすると全ユーザーを許可、QQ番号を指定してアクセス制限可能。
|
||||
|
||||
**3. 起動**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>DingTalk</b></summary>
|
||||
|
||||
**1. Bot を作成**
|
||||
|
||||
- [オープンプラットフォーム](https://open.dingtalk.com/) にアクセス
|
||||
- 内部アプリを作成
|
||||
- Client ID と Client Secret をコピー
|
||||
|
||||
**2. 設定**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"dingtalk": {
|
||||
"enabled": true,
|
||||
"client_id": "YOUR_CLIENT_ID",
|
||||
"client_secret": "YOUR_CLIENT_SECRET",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `allow_from` を空にすると全ユーザーを許可、ユーザーIDを指定してアクセス制限可能。
|
||||
|
||||
**3. 起動**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>LINE</b></summary>
|
||||
|
||||
**1. LINE 公式アカウントを作成**
|
||||
|
||||
- [LINE Developers Console](https://developers.line.biz/) にアクセス
|
||||
- プロバイダーを作成 → Messaging API チャネルを作成
|
||||
- **チャネルシークレット** と **チャネルアクセストークン** をコピー
|
||||
|
||||
**2. 設定**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"line": {
|
||||
"enabled": true,
|
||||
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18791,
|
||||
"webhook_path": "/webhook/line",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Webhook URL を設定**
|
||||
|
||||
LINE の Webhook には HTTPS が必要です。リバースプロキシまたはトンネルを使用してください:
|
||||
|
||||
```bash
|
||||
# ngrok の例
|
||||
ngrok http 18791
|
||||
```
|
||||
|
||||
LINE Developers Console で Webhook URL を `https://あなたのドメイン/webhook/line` に設定し、**Webhook の利用** を有効にしてください。
|
||||
|
||||
**4. 起動**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
> グループチャットでは @メンション時のみ応答します。返信は元メッセージを引用する形式です。
|
||||
|
||||
> **Docker Compose**: `picoclaw-gateway` サービスに `ports: ["18791:18791"]` を追加して Webhook ポートを公開してください。
|
||||
|
||||
</details>
|
||||
|
||||
## ⚙️ 設定
|
||||
|
||||
設定ファイル: `~/.picoclaw/config.json`
|
||||
|
||||
### ワークスペース構成
|
||||
|
||||
PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw/workspace`)にデータを保存します:
|
||||
|
||||
```
|
||||
~/.picoclaw/workspace/
|
||||
├── sessions/ # 会話セッションと履歴
|
||||
├── memory/ # 長期メモリ(MEMORY.md)
|
||||
├── state/ # 永続状態(最後のチャネルなど)
|
||||
├── cron/ # スケジュールジョブデータベース
|
||||
├── skills/ # カスタムスキル
|
||||
├── AGENTS.md # エージェントの行動ガイド
|
||||
├── HEARTBEAT.md # 定期タスクプロンプト(30分ごとに確認)
|
||||
├── IDENTITY.md # エージェントのアイデンティティ
|
||||
├── SOUL.md # エージェントのソウル
|
||||
├── TOOLS.md # ツールの説明
|
||||
└── USER.md # ユーザー設定
|
||||
```
|
||||
|
||||
### 🔒 セキュリティサンドボックス
|
||||
|
||||
PicoClaw はデフォルトでサンドボックス環境で実行されます。エージェントは設定されたワークスペース内のファイルにのみアクセスし、コマンドを実行できます。
|
||||
|
||||
#### デフォルト設定
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"restrict_to_workspace": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| オプション | デフォルト | 説明 |
|
||||
|-----------|-----------|------|
|
||||
| `workspace` | `~/.picoclaw/workspace` | エージェントの作業ディレクトリ |
|
||||
| `restrict_to_workspace` | `true` | ファイル/コマンドアクセスをワークスペースに制限 |
|
||||
|
||||
#### 保護対象ツール
|
||||
|
||||
`restrict_to_workspace: true` の場合、以下のツールがサンドボックス化されます:
|
||||
|
||||
| ツール | 機能 | 制限 |
|
||||
|-------|------|------|
|
||||
| `read_file` | ファイル読み込み | ワークスペース内のファイルのみ |
|
||||
| `write_file` | ファイル書き込み | ワークスペース内のファイルのみ |
|
||||
| `list_dir` | ディレクトリ一覧 | ワークスペース内のディレクトリのみ |
|
||||
| `edit_file` | ファイル編集 | ワークスペース内のファイルのみ |
|
||||
| `append_file` | ファイル追記 | ワークスペース内のファイルのみ |
|
||||
| `exec` | コマンド実行 | コマンドパスはワークスペース内である必要あり |
|
||||
|
||||
#### exec ツールの追加保護
|
||||
|
||||
`restrict_to_workspace: false` でも、`exec` ツールは以下の危険なコマンドをブロックします:
|
||||
|
||||
- `rm -rf`, `del /f`, `rmdir /s` — 一括削除
|
||||
- `format`, `mkfs`, `diskpart` — ディスクフォーマット
|
||||
- `dd if=` — ディスクイメージング
|
||||
- `/dev/sd[a-z]` への書き込み — 直接ディスク書き込み
|
||||
- `shutdown`, `reboot`, `poweroff` — システムシャットダウン
|
||||
- フォークボム `:(){ :|:& };:`
|
||||
|
||||
#### エラー例
|
||||
|
||||
```
|
||||
[ERROR] tool: Tool execution failed
|
||||
{tool=exec, error=Command blocked by safety guard (path outside working dir)}
|
||||
```
|
||||
|
||||
```
|
||||
[ERROR] tool: Tool execution failed
|
||||
{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
|
||||
```
|
||||
|
||||
#### 制限の無効化(セキュリティリスク)
|
||||
|
||||
エージェントにワークスペース外のパスへのアクセスが必要な場合:
|
||||
|
||||
**方法1: 設定ファイル**
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"restrict_to_workspace": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**方法2: 環境変数**
|
||||
```bash
|
||||
export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
|
||||
```
|
||||
|
||||
> ⚠️ **警告**: この制限を無効にすると、エージェントはシステム上の任意のパスにアクセスできるようになります。制御された環境でのみ慎重に使用してください。
|
||||
|
||||
#### セキュリティ境界の一貫性
|
||||
|
||||
`restrict_to_workspace` 設定は、すべての実行パスで一貫して適用されます:
|
||||
|
||||
| 実行パス | セキュリティ境界 |
|
||||
|---------|-----------------|
|
||||
| メインエージェント | `restrict_to_workspace` ✅ |
|
||||
| サブエージェント / Spawn | 同じ制限を継承 ✅ |
|
||||
| ハートビートタスク | 同じ制限を継承 ✅ |
|
||||
|
||||
すべてのパスで同じワークスペース制限が適用されます — サブエージェントやスケジュールタスクを通じてセキュリティ境界をバイパスする方法はありません。
|
||||
|
||||
### ハートビート(定期タスク)
|
||||
|
||||
PicoClaw は自動的に定期タスクを実行できます。ワークスペースに `HEARTBEAT.md` ファイルを作成します:
|
||||
|
||||
```markdown
|
||||
# 定期タスク
|
||||
|
||||
- 重要なメールをチェック
|
||||
- 今後の予定を確認
|
||||
- 天気予報をチェック
|
||||
```
|
||||
|
||||
エージェントは30分ごと(設定可能)にこのファイルを読み込み、利用可能なツールを使ってタスクを実行します。
|
||||
|
||||
#### spawn で非同期タスク実行
|
||||
|
||||
時間のかかるタスク(Web検索、API呼び出し)には `spawn` ツールを使って**サブエージェント**を作成します:
|
||||
|
||||
```markdown
|
||||
# 定期タスク
|
||||
|
||||
## クイックタスク(直接応答)
|
||||
- 現在時刻を報告
|
||||
|
||||
## 長時間タスク(spawn で非同期)
|
||||
- AIニュースを検索して要約
|
||||
- メールをチェックして重要なメッセージを報告
|
||||
```
|
||||
|
||||
**主な特徴:**
|
||||
|
||||
| 機能 | 説明 |
|
||||
|------|------|
|
||||
| **spawn** | 非同期サブエージェントを作成、ハートビートをブロックしない |
|
||||
| **独立コンテキスト** | サブエージェントは独自のコンテキストを持ち、セッション履歴なし |
|
||||
| **message ツール** | サブエージェントは message ツールで直接ユーザーと通信 |
|
||||
| **非ブロッキング** | spawn 後、ハートビートは次のタスクへ継続 |
|
||||
|
||||
#### サブエージェントの通信方法
|
||||
|
||||
```
|
||||
ハートビート発動
|
||||
↓
|
||||
エージェントが HEARTBEAT.md を読む
|
||||
↓
|
||||
長いタスク: spawn サブエージェント
|
||||
↓ ↓
|
||||
次のタスクへ継続 サブエージェントが独立して動作
|
||||
↓ ↓
|
||||
全タスク完了 message ツールを使用
|
||||
↓ ↓
|
||||
HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||
```
|
||||
|
||||
サブエージェントはツール(message、web_search など)にアクセスでき、メインエージェントを経由せずにユーザーと通信できます。
|
||||
|
||||
**設定:**
|
||||
|
||||
```json
|
||||
{
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| オプション | デフォルト | 説明 |
|
||||
|-----------|-----------|------|
|
||||
| `enabled` | `true` | ハートビートの有効/無効 |
|
||||
| `interval` | `30` | チェック間隔(分)、最小5分 |
|
||||
|
||||
**環境変数:**
|
||||
- `PICOCLAW_HEARTBEAT_ENABLED=false` で無効化
|
||||
- `PICOCLAW_HEARTBEAT_INTERVAL=60` で間隔変更
|
||||
|
||||
### 基本設定
|
||||
|
||||
1. **設定ファイルの作成:**
|
||||
|
||||
```bash
|
||||
cp config.example.json config/config.json
|
||||
```
|
||||
|
||||
2. **設定の編集:**
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"api_key": "sk-or-v1-..."
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_DISCORD_BOT_TOKEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **実行**
|
||||
|
||||
```bash
|
||||
picoclaw agent -m "Hello"
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>完全な設定例</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "anthropic/claude-opus-4-5"
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"apiKey": "sk-or-v1-xxx"
|
||||
},
|
||||
"groq": {
|
||||
"apiKey": "gsk_xxx"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "123456:ABC...",
|
||||
"allowFrom": ["123456789"]
|
||||
},
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "",
|
||||
"allow_from": [""]
|
||||
},
|
||||
"whatsapp": {
|
||||
"enabled": false
|
||||
},
|
||||
"feishu": {
|
||||
"enabled": false,
|
||||
"appId": "cli_xxx",
|
||||
"appSecret": "xxx",
|
||||
"encryptKey": "",
|
||||
"verificationToken": "",
|
||||
"allowFrom": []
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"apiKey": "BSA..."
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## CLI リファレンス
|
||||
|
||||
| コマンド | 説明 |
|
||||
|---------|------|
|
||||
| `picoclaw onboard` | 設定&ワークスペースの初期化 |
|
||||
| `picoclaw agent -m "..."` | エージェントとチャット |
|
||||
| `picoclaw agent` | インタラクティブチャットモード |
|
||||
| `picoclaw gateway` | ゲートウェイを起動 |
|
||||
| `picoclaw status` | ステータスを表示 |
|
||||
|
||||
## 🤝 コントリビュート&ロードマップ
|
||||
|
||||
PR 歓迎!コードベースは意図的に小さく読みやすくしています。🤗
|
||||
|
||||
Discord: https://discord.gg/V4sAZ9XWpN
|
||||
|
||||
<img src="assets/wechat.png" alt="PicoClaw" width="512">
|
||||
|
||||
|
||||
## 🐛 トラブルシューティング
|
||||
|
||||
### Web 検索で「API 配置问题」と表示される
|
||||
|
||||
検索 API キーをまだ設定していない場合、これは正常です。PicoClaw は手動検索用の便利なリンクを提供します。
|
||||
|
||||
Web 検索を有効にするには:
|
||||
1. [https://brave.com/search/api](https://brave.com/search/api) で無料の API キーを取得(月 2000 クエリ無料)
|
||||
2. `~/.picoclaw/config.json` に追加:
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### コンテンツフィルタリングエラーが出る
|
||||
|
||||
一部のプロバイダー(Zhipu など)にはコンテンツフィルタリングがあります。クエリを言い換えるか、別のモデルを使用してください。
|
||||
|
||||
### Telegram Bot で「Conflict: terminated by other getUpdates」と表示される
|
||||
|
||||
別のインスタンスが実行中の場合に発生します。`picoclaw gateway` が 1 つだけ実行されていることを確認してください。
|
||||
|
||||
---
|
||||
|
||||
## 📝 API キー比較
|
||||
|
||||
| サービス | 無料枠 | ユースケース |
|
||||
|---------|--------|------------|
|
||||
| **OpenRouter** | 月 200K トークン | 複数モデル(Claude, GPT-4 など) |
|
||||
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
|
||||
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
|
||||
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
|
||||
@@ -0,0 +1,744 @@
|
||||
<div align="center">
|
||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
<h1>PicoClaw: 基于Go语言的超高效 AI 助手</h1>
|
||||
|
||||
<h3>10$硬件 · 10MB内存 · 1秒启动 · 皮皮虾,我们走!</h3>
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
<br>
|
||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||
</p>
|
||||
|
||||
**中文** | [日本語](README.ja.md) | [English](README.md)
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
🦐 **PicoClaw** 是一个受 [nanobot](https://github.com/HKUDS/nanobot) 启发的超轻量级个人 AI 助手。它采用 **Go 语言** 从零重构,经历了一个“自举”过程——即由 AI Agent 自身驱动了整个架构迁移和代码优化。
|
||||
|
||||
⚡️ **极致轻量**:可在 **10 美元** 的硬件上运行,内存占用 **<10MB**。这意味着比 OpenClaw 节省 99% 的内存,比 Mac mini 便宜 98%!
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<td align="center" valign="top">
|
||||
<p align="center">
|
||||
<img src="assets/picoclaw_mem.gif" width="360" height="240">
|
||||
</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<p align="center">
|
||||
<img src="assets/licheervnano.png" width="400" height="240">
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
注意:人手有限,中文文档可能略有滞后,请优先查看英文文档。
|
||||
|
||||
> [!CAUTION]
|
||||
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
|
||||
> * **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。
|
||||
> * **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
|
||||
> * **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。
|
||||
> * **注意:** picoclaw正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在1.0正式版发布前,请不要将其部署到生产环境中
|
||||
> * **注意:** picoclaw最近合并了大量PRs,近期版本可能内存占用较大(10~20MB),我们将在功能较为收敛后进行资源占用优化.
|
||||
|
||||
|
||||
## 📢 新闻 (News)
|
||||
2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/picoclaw_community_roadmap_260216.md), 期待你的参与!
|
||||
|
||||
2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。
|
||||
🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。
|
||||
|
||||
2026-02-09 🎉 **PicoClaw 正式发布!** 仅用 1 天构建,旨在将 AI Agent 带入 10 美元硬件与 <10MB 内存的世界。🦐 PicoClaw(皮皮虾),我们走!
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
🪶 **超轻量级**: 核心功能内存占用 <10MB — 比 Clawdbot 小 99%。
|
||||
|
||||
💰 **极低成本**: 高效到足以在 10 美元的硬件上运行 — 比 Mac mini 便宜 98%。
|
||||
|
||||
⚡️ **闪电启动**: 启动速度快 400 倍,即使在 0.6GHz 单核处理器上也能在 1 秒内启动。
|
||||
|
||||
🌍 **真正可移植**: 跨 RISC-V、ARM 和 x86 架构的单二进制文件,一键运行!
|
||||
|
||||
🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| --- | --- | --- | --- |
|
||||
| **语言** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1GB | >100MB | **< 10MB** |
|
||||
| **启动时间**</br>(0.8GHz core) | >500s | >30s | **<1s** |
|
||||
| **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板**</br>**低至 $10** |
|
||||
|
||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
## 🦾 演示
|
||||
|
||||
### 🛠️ 标准助手工作流
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th><p align="center">🧩 全栈工程师模式</p></th>
|
||||
<th><p align="center">🗂️ 日志与规划管理</p></th>
|
||||
<th><p align="center">🔎 网络搜索与学习</p></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">开发 • 部署 • 扩展</td>
|
||||
<td align="center">日程 • 自动化 • 记忆</td>
|
||||
<td align="center">发现 • 洞察 • 趋势</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 📱 在手机上轻松运行
|
||||
picoclaw 可以将你10年前的老旧手机废物利用,变身成为你的AI助理!快速指南:
|
||||
1. 先去应用商店下载安装Termux
|
||||
2. 打开后执行指令
|
||||
```bash
|
||||
# 注意: 下面的v0.1.1 可以换为你实际看到的最新版本
|
||||
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
|
||||
chmod +x picoclaw-linux-arm64
|
||||
pkg install proot
|
||||
termux-chroot ./picoclaw-linux-arm64 onboard
|
||||
```
|
||||
然后跟随下面的“快速开始”章节继续配置picoclaw即可使用!
|
||||
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
|
||||
|
||||
|
||||
### 🐜 创新的低占用部署
|
||||
|
||||
PicoClaw 几乎可以部署在任何 Linux 设备上!
|
||||
|
||||
* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手。
|
||||
* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维。
|
||||
* $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控。
|
||||
|
||||
[https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4](https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4)
|
||||
|
||||
🌟 更多部署案例敬请期待!
|
||||
|
||||
## 📦 安装
|
||||
|
||||
### 使用预编译二进制文件安装
|
||||
|
||||
从 [Release 页面](https://github.com/sipeed/picoclaw/releases) 下载适用于您平台的固件。
|
||||
|
||||
### 从源码安装(获取最新特性,开发推荐)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# 构建(无需安装)
|
||||
make build
|
||||
|
||||
# 为多平台构建
|
||||
make build-all
|
||||
|
||||
# 构建并安装
|
||||
make install
|
||||
|
||||
```
|
||||
|
||||
## 🐳 Docker Compose
|
||||
|
||||
您也可以使用 Docker Compose 运行 PicoClaw,无需在本地安装任何环境。
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
|
||||
# 2. 设置 API Key
|
||||
cp config/config.example.json config/config.json
|
||||
vim config/config.json # 设置 DISCORD_BOT_TOKEN, API keys 等
|
||||
|
||||
# 3. 构建并启动
|
||||
docker compose --profile gateway up -d
|
||||
|
||||
# 4. 查看日志
|
||||
docker compose logs -f picoclaw-gateway
|
||||
|
||||
# 5. 停止
|
||||
docker compose --profile gateway down
|
||||
|
||||
```
|
||||
|
||||
### Agent 模式 (一次性运行)
|
||||
|
||||
```bash
|
||||
# 提问
|
||||
docker compose run --rm picoclaw-agent -m "2+2 等于几?"
|
||||
|
||||
# 交互模式
|
||||
docker compose run --rm picoclaw-agent
|
||||
|
||||
```
|
||||
|
||||
### 重新构建
|
||||
|
||||
```bash
|
||||
docker compose --profile gateway build --no-cache
|
||||
docker compose --profile gateway up -d
|
||||
|
||||
```
|
||||
|
||||
### 🚀 快速开始
|
||||
|
||||
> [!TIP]
|
||||
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。
|
||||
> 获取 API Key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
||||
> 网络搜索是 **可选的** - 获取免费的 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
|
||||
|
||||
**1. 初始化 (Initialize)**
|
||||
|
||||
```bash
|
||||
picoclaw onboard
|
||||
|
||||
```
|
||||
|
||||
**2. 配置 (Configure)** (`~/.picoclaw/config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model": "glm-4.7",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"api_key": "xxx",
|
||||
"api_base": "https://openrouter.ai/api/v1"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**3. 获取 API Key**
|
||||
|
||||
* **LLM 提供商**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||
* **网络搜索** (可选): [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月)
|
||||
|
||||
> **注意**: 完整的配置模板请参考 `config.example.json`。
|
||||
|
||||
**4. 对话 (Chat)**
|
||||
|
||||
```bash
|
||||
picoclaw agent -m "2+2 等于几?"
|
||||
|
||||
```
|
||||
|
||||
就是这样!您在 2 分钟内就拥有了一个可工作的 AI 助手。
|
||||
|
||||
---
|
||||
|
||||
## 💬 聊天应用集成 (Chat Apps)
|
||||
|
||||
通过 Telegram, Discord 或钉钉与您的 PicoClaw 对话。
|
||||
|
||||
| 渠道 | 设置难度 |
|
||||
| --- | --- |
|
||||
| **Telegram** | 简单 (仅需 token) |
|
||||
| **Discord** | 简单 (bot token + intents) |
|
||||
| **QQ** | 简单 (AppID + AppSecret) |
|
||||
| **钉钉 (DingTalk)** | 中等 (app credentials) |
|
||||
|
||||
<details>
|
||||
<summary><b>Telegram</b> (推荐)</summary>
|
||||
|
||||
**1. 创建机器人**
|
||||
|
||||
* 打开 Telegram,搜索 `@BotFather`
|
||||
* 发送 `/newbot`,按照提示操作
|
||||
* 复制 token
|
||||
|
||||
**2. 配置**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allowFrom": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
> 从 Telegram 上的 `@userinfobot` 获取您的用户 ID。
|
||||
|
||||
**3. 运行**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Discord</b></summary>
|
||||
|
||||
**1. 创建机器人**
|
||||
|
||||
* 前往 [https://discord.com/developers/applications](https://discord.com/developers/applications)
|
||||
* Create an application → Bot → Add Bot
|
||||
* 复制 bot token
|
||||
|
||||
**2. 开启 Intents**
|
||||
|
||||
* 在 Bot 设置中,开启 **MESSAGE CONTENT INTENT**
|
||||
* (可选) 如果计划基于成员数据使用白名单,开启 **SERVER MEMBERS INTENT**
|
||||
|
||||
**3. 获取您的 User ID**
|
||||
|
||||
* Discord 设置 → Advanced → 开启 **Developer Mode**
|
||||
* 右键点击您的头像 → **Copy User ID**
|
||||
|
||||
**4. 配置**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allowFrom": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**5. 邀请机器人**
|
||||
|
||||
* OAuth2 → URL Generator
|
||||
* Scopes: `bot`
|
||||
* Bot Permissions: `Send Messages`, `Read Message History`
|
||||
* 打开生成的邀请 URL,将机器人添加到您的服务器
|
||||
|
||||
**6. 运行**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>QQ</b></summary>
|
||||
|
||||
**1. 创建机器人**
|
||||
|
||||
* 前往 [QQ 开放平台](https://q.qq.com/#)
|
||||
* 创建应用 → 获取 **AppID** 和 **AppSecret**
|
||||
|
||||
**2. 配置**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"qq": {
|
||||
"enabled": true,
|
||||
"app_id": "YOUR_APP_ID",
|
||||
"app_secret": "YOUR_APP_SECRET",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
> 将 `allow_from` 设为空以允许所有用户,或指定 QQ 号以限制访问。
|
||||
|
||||
**3. 运行**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>钉钉 (DingTalk)</b></summary>
|
||||
|
||||
**1. 创建机器人**
|
||||
|
||||
* 前往 [开放平台](https://open.dingtalk.com/)
|
||||
* 创建内部应用
|
||||
* 复制 Client ID 和 Client Secret
|
||||
|
||||
**2. 配置**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"dingtalk": {
|
||||
"enabled": true,
|
||||
"client_id": "YOUR_CLIENT_ID",
|
||||
"client_secret": "YOUR_CLIENT_SECRET",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
> 将 `allow_from` 设为空以允许所有用户,或指定 ID 以限制访问。
|
||||
|
||||
**3. 运行**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
||||
|
||||
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
||||
|
||||
**阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai**](https://clawdchat.ai)
|
||||
|
||||
## ⚙️ 配置详解
|
||||
|
||||
配置文件路径: `~/.picoclaw/config.json`
|
||||
|
||||
### 工作区布局 (Workspace Layout)
|
||||
|
||||
PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`):
|
||||
|
||||
```
|
||||
~/.picoclaw/workspace/
|
||||
├── sessions/ # 对话会话和历史
|
||||
├── memory/ # 长期记忆 (MEMORY.md)
|
||||
├── state/ # 持久化状态 (最后一次频道等)
|
||||
├── cron/ # 定时任务数据库
|
||||
├── skills/ # 自定义技能
|
||||
├── AGENTS.md # Agent 行为指南
|
||||
├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次)
|
||||
├── IDENTITY.md # Agent 身份设定
|
||||
├── SOUL.md # Agent 灵魂/性格
|
||||
├── TOOLS.md # 工具描述
|
||||
└── USER.md # 用户偏好
|
||||
|
||||
```
|
||||
|
||||
### 心跳 / 周期性任务 (Heartbeat)
|
||||
|
||||
PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件:
|
||||
|
||||
```markdown
|
||||
# Periodic Tasks
|
||||
|
||||
- Check my email for important messages
|
||||
- Review my calendar for upcoming events
|
||||
- Check the weather forecast
|
||||
|
||||
```
|
||||
|
||||
Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具执行任务。
|
||||
|
||||
#### 使用 Spawn 的异步任务
|
||||
|
||||
对于耗时较长的任务(网络搜索、API 调用),使用 `spawn` 工具创建一个 **子 Agent (subagent)**:
|
||||
|
||||
```markdown
|
||||
# Periodic Tasks
|
||||
|
||||
## Quick Tasks (respond directly)
|
||||
- Report current time
|
||||
|
||||
## Long Tasks (use spawn for async)
|
||||
- Search the web for AI news and summarize
|
||||
- Check email and report important messages
|
||||
|
||||
```
|
||||
|
||||
**关键行为:**
|
||||
|
||||
| 特性 | 描述 |
|
||||
| --- | --- |
|
||||
| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 |
|
||||
| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 |
|
||||
| **message tool** | 子 Agent 通过 message 工具直接与用户通信 |
|
||||
| **非阻塞** | spawn 后,心跳继续处理下一个任务 |
|
||||
|
||||
#### 子 Agent 通信原理
|
||||
|
||||
```
|
||||
心跳触发 (Heartbeat triggers)
|
||||
↓
|
||||
Agent 读取 HEARTBEAT.md
|
||||
↓
|
||||
对于长任务: spawn 子 Agent
|
||||
↓ ↓
|
||||
继续下一个任务 子 Agent 独立工作
|
||||
↓ ↓
|
||||
所有任务完成 子 Agent 使用 "message" 工具
|
||||
↓ ↓
|
||||
响应 HEARTBEAT_OK 用户直接收到结果
|
||||
|
||||
```
|
||||
|
||||
子 Agent 可以访问工具(message, web_search 等),并且无需通过主 Agent 即可独立与用户通信。
|
||||
|
||||
**配置:**
|
||||
|
||||
```json
|
||||
{
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
| 选项 | 默认值 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | `true` | 启用/禁用心跳 |
|
||||
| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) |
|
||||
|
||||
**环境变量:**
|
||||
|
||||
* `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用
|
||||
* `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔
|
||||
|
||||
### 提供商 (Providers)
|
||||
|
||||
> [!NOTE]
|
||||
> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,Telegram 语音消息将被自动转录为文字。
|
||||
|
||||
| 提供商 | 用途 | 获取 API Key |
|
||||
| --- | --- | --- |
|
||||
| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) |
|
||||
| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `anthropic(待测试)` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai(待测试)` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) |
|
||||
| `deepseek(待测试)` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||
| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||
|
||||
<details>
|
||||
<summary><b>智谱 (Zhipu) 配置示例</b></summary>
|
||||
|
||||
**1. 获取 API key 和 base URL**
|
||||
|
||||
* 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
|
||||
|
||||
**2. 配置**
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model": "glm-4.7",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"zhipu": {
|
||||
"api_key": "Your API Key",
|
||||
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**3. 运行**
|
||||
|
||||
```bash
|
||||
picoclaw agent -m "你好"
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>完整配置示例</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "anthropic/claude-opus-4-5"
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"api_key": "sk-or-v1-xxx"
|
||||
},
|
||||
"groq": {
|
||||
"api_key": "gsk_xxx"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "123456:ABC...",
|
||||
"allow_from": ["123456789"]
|
||||
},
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "",
|
||||
"allow_from": [""]
|
||||
},
|
||||
"whatsapp": {
|
||||
"enabled": false
|
||||
},
|
||||
"feishu": {
|
||||
"enabled": false,
|
||||
"app_id": "cli_xxx",
|
||||
"app_secret": "xxx",
|
||||
"encrypt_key": "",
|
||||
"verification_token": "",
|
||||
"allow_from": []
|
||||
},
|
||||
"qq": {
|
||||
"enabled": false,
|
||||
"app_id": "",
|
||||
"app_secret": "",
|
||||
"allow_from": []
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"api_key": "BSA..."
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## CLI 命令行参考
|
||||
|
||||
| 命令 | 描述 |
|
||||
| --- | --- |
|
||||
| `picoclaw onboard` | 初始化配置和工作区 |
|
||||
| `picoclaw agent -m "..."` | 与 Agent 对话 |
|
||||
| `picoclaw agent` | 交互式聊天模式 |
|
||||
| `picoclaw gateway` | 启动网关 (Gateway) |
|
||||
| `picoclaw status` | 显示状态 |
|
||||
| `picoclaw cron list` | 列出所有定时任务 |
|
||||
| `picoclaw cron add ...` | 添加定时任务 |
|
||||
|
||||
### 定时任务 / 提醒 (Scheduled Tasks)
|
||||
|
||||
PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
|
||||
|
||||
* **一次性提醒**: "Remind me in 10 minutes" (10分钟后提醒我) → 10分钟后触发一次
|
||||
* **重复任务**: "Remind me every 2 hours" (每2小时提醒我) → 每2小时触发
|
||||
* **Cron 表达式**: "Remind me at 9am daily" (每天上午9点提醒我) → 使用 cron 表达式
|
||||
|
||||
任务存储在 `~/.picoclaw/workspace/cron/` 中并自动处理。
|
||||
|
||||
## 🤝 贡献与路线图 (Roadmap)
|
||||
|
||||
欢迎提交 PR!代码库刻意保持小巧和可读。🤗
|
||||
|
||||
路线图即将发布...
|
||||
|
||||
开发者群组正在组建中,入群门槛:至少合并过 1 个 PR。
|
||||
|
||||
用户群组:
|
||||
|
||||
Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
||||
|
||||
<img src="assets/wechat.png" alt="PicoClaw" width="512">
|
||||
|
||||
## 🐛 疑难解答 (Troubleshooting)
|
||||
|
||||
### 网络搜索提示 "API 配置问题"
|
||||
|
||||
如果您尚未配置搜索 API Key,这是正常的。PicoClaw 会提供手动搜索的帮助链接。
|
||||
|
||||
启用网络搜索:
|
||||
|
||||
1. 在 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (每月 2000 次免费查询)
|
||||
2. 添加到 `~/.picoclaw/config.json`:
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 遇到内容过滤错误 (Content Filtering Errors)
|
||||
|
||||
某些提供商(如智谱)有严格的内容过滤。尝试改写您的问题或使用其他模型。
|
||||
|
||||
### Telegram bot 提示 "Conflict: terminated by other getUpdates"
|
||||
|
||||
这表示有另一个机器人实例正在运行。请确保同一时间只有一个 `picoclaw gateway` 进程在运行。
|
||||
|
||||
---
|
||||
|
||||
## 📝 API Key 对比
|
||||
|
||||
| 服务 | 免费层级 | 适用场景 |
|
||||
| --- | --- | --- |
|
||||
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
|
||||
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
|
||||
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
|
||||
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
|
||||
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 449 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 360 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 233 KiB |
@@ -1,412 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/seahorse"
|
||||
)
|
||||
|
||||
// EvalResult holds per-sample evaluation results for one mode.
|
||||
type EvalResult struct {
|
||||
Mode string `json:"mode"`
|
||||
SampleID string `json:"sampleId"`
|
||||
QAResults []QAResult `json:"qaResults"`
|
||||
Agg AggMetrics `json:"aggregated"`
|
||||
}
|
||||
|
||||
// QAResult holds metrics for a single QA pair.
|
||||
type QAResult struct {
|
||||
Question string `json:"question"`
|
||||
Category int `json:"category"`
|
||||
GoldAnswer string `json:"goldAnswer"`
|
||||
TokenF1 float64 `json:"tokenF1"`
|
||||
HitRate float64 `json:"hitRate"`
|
||||
}
|
||||
|
||||
// AggMetrics holds aggregated evaluation metrics.
|
||||
type AggMetrics struct {
|
||||
OverallF1 float64 `json:"overallF1"`
|
||||
OverallHitRate float64 `json:"overallHitRate"`
|
||||
ByCategory map[int]*CatMetrics `json:"byCategory"`
|
||||
TotalQuestions int `json:"totalQuestions"`
|
||||
ValidF1Count int `json:"validF1Count"`
|
||||
}
|
||||
|
||||
// CatMetrics holds metrics for a single category.
|
||||
type CatMetrics struct {
|
||||
F1 float64 `json:"f1"`
|
||||
HitRate float64 `json:"hitRate"`
|
||||
QuestionCount int `json:"questionCount"`
|
||||
ValidF1Count int `json:"validF1Count"`
|
||||
}
|
||||
|
||||
// EvalLegacy evaluates using legacy session store (raw history + budget truncation).
|
||||
func EvalLegacy(
|
||||
ctx context.Context,
|
||||
samples []LocomoSample,
|
||||
legacy *LegacyStore,
|
||||
budgetTokens int,
|
||||
) []EvalResult {
|
||||
results := make([]EvalResult, 0, len(samples))
|
||||
for si := range samples {
|
||||
sample := &samples[si]
|
||||
history := legacy.GetHistory(sample.SampleID)
|
||||
|
||||
// Convert messages to content strings
|
||||
allContent := make([]string, 0, len(history))
|
||||
for _, msg := range history {
|
||||
allContent = append(allContent, msg.Content)
|
||||
}
|
||||
|
||||
qaResults := make([]QAResult, 0, len(sample.QA))
|
||||
for qi := range sample.QA {
|
||||
qa := &sample.QA[qi]
|
||||
// Budget truncate the full history
|
||||
truncated, _ := BudgetTruncate(allContent, budgetTokens)
|
||||
context := StringListToContent(truncated)
|
||||
|
||||
f1 := TokenOverlapF1(context, qa.AnswerString())
|
||||
hitRate := RecallHitRate(qa.Evidence, sample, context)
|
||||
|
||||
qaResults = append(qaResults, QAResult{
|
||||
Question: qa.Question,
|
||||
Category: qa.Category,
|
||||
GoldAnswer: qa.AnswerString(),
|
||||
TokenF1: f1,
|
||||
HitRate: hitRate,
|
||||
})
|
||||
}
|
||||
|
||||
results = append(results, EvalResult{
|
||||
Mode: "legacy",
|
||||
SampleID: sample.SampleID,
|
||||
QAResults: qaResults,
|
||||
Agg: aggregateMetrics(qaResults),
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// EvalSeahorse evaluates using seahorse short memory (per-keyword search + expand).
|
||||
func EvalSeahorse(
|
||||
ctx context.Context,
|
||||
samples []LocomoSample,
|
||||
ir *SeahorseIngestResult,
|
||||
budgetTokens int,
|
||||
) []EvalResult {
|
||||
store := ir.Engine.GetRetrieval().Store()
|
||||
retrieval := ir.Engine.GetRetrieval()
|
||||
|
||||
results := make([]EvalResult, 0, len(samples))
|
||||
for si := range samples {
|
||||
sample := &samples[si]
|
||||
convID, ok := ir.ConvMap[sample.SampleID]
|
||||
if !ok {
|
||||
log.Printf("WARN: no conversation ID for sample %s", sample.SampleID)
|
||||
continue
|
||||
}
|
||||
|
||||
qaResults := make([]QAResult, 0, len(sample.QA))
|
||||
for qi := range sample.QA {
|
||||
qa := &sample.QA[qi]
|
||||
keywords := ExtractKeywords(qa.Question)
|
||||
|
||||
// Search each keyword individually and union results,
|
||||
// tracking best BM25 rank per message for relevance sorting.
|
||||
bestRank := map[int64]float64{}
|
||||
for _, kw := range keywords {
|
||||
searchResults, err := store.SearchMessages(ctx, seahorse.SearchInput{
|
||||
Pattern: kw,
|
||||
ConversationID: convID,
|
||||
Limit: 20,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("WARN: search failed for keyword %q: %v", kw, err)
|
||||
continue
|
||||
}
|
||||
for _, sr := range searchResults {
|
||||
if sr.MessageID > 0 {
|
||||
if prev, ok := bestRank[sr.MessageID]; !ok || sr.Rank < prev {
|
||||
bestRank[sr.MessageID] = sr.Rank
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sort messageIDs by rank ascending (best/most-negative first).
|
||||
// BudgetTruncate walks from the front, keeping best-ranked messages.
|
||||
// Note: SQLite FTS5 bm25() returns negative values where more
|
||||
// negative = better match.
|
||||
messageIDs := make([]int64, 0, len(bestRank))
|
||||
for id := range bestRank {
|
||||
messageIDs = append(messageIDs, id)
|
||||
}
|
||||
sort.Slice(messageIDs, func(i, j int) bool {
|
||||
return bestRank[messageIDs[i]] < bestRank[messageIDs[j]]
|
||||
})
|
||||
|
||||
// Expand messages to get full content
|
||||
var contentParts []string
|
||||
if len(messageIDs) > 0 {
|
||||
expandResult, err := retrieval.ExpandMessages(ctx, messageIDs)
|
||||
if err != nil {
|
||||
log.Printf("WARN: expand failed for sample %s: %v", sample.SampleID, err)
|
||||
} else {
|
||||
for _, msg := range expandResult.Messages {
|
||||
contentParts = append(contentParts, msg.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(contentParts) == 0 {
|
||||
qaResults = append(qaResults, QAResult{
|
||||
Question: qa.Question,
|
||||
Category: qa.Category,
|
||||
GoldAnswer: qa.AnswerString(),
|
||||
TokenF1: 0.0,
|
||||
HitRate: 0.0,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Budget truncate (drop worst-ranked)
|
||||
truncated, _ := BudgetTruncate(contentParts, budgetTokens)
|
||||
context := StringListToContent(truncated)
|
||||
|
||||
f1 := TokenOverlapF1(context, qa.AnswerString())
|
||||
hitRate := RecallHitRate(qa.Evidence, sample, context)
|
||||
|
||||
qaResults = append(qaResults, QAResult{
|
||||
Question: qa.Question,
|
||||
Category: qa.Category,
|
||||
GoldAnswer: qa.AnswerString(),
|
||||
TokenF1: f1,
|
||||
HitRate: hitRate,
|
||||
})
|
||||
}
|
||||
|
||||
results = append(results, EvalResult{
|
||||
Mode: "seahorse",
|
||||
SampleID: sample.SampleID,
|
||||
QAResults: qaResults,
|
||||
Agg: aggregateMetrics(qaResults),
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// aggregateMetrics computes overall and per-category metrics.
|
||||
func aggregateMetrics(qaResults []QAResult) AggMetrics {
|
||||
type catAccum struct {
|
||||
f1Sum float64
|
||||
f1Count int
|
||||
hitRateSum float64
|
||||
hitRateCount int
|
||||
}
|
||||
byCatAcc := map[int]*catAccum{}
|
||||
totalF1 := 0.0
|
||||
totalHitRate := 0.0
|
||||
validF1Count := 0
|
||||
for _, qr := range qaResults {
|
||||
// Skip sentinel -1.0 scores (LLM API/parse failures) from F1 averaging.
|
||||
if qr.TokenF1 >= 0 {
|
||||
totalF1 += qr.TokenF1
|
||||
validF1Count++
|
||||
}
|
||||
totalHitRate += qr.HitRate
|
||||
acc, ok := byCatAcc[qr.Category]
|
||||
if !ok {
|
||||
acc = &catAccum{}
|
||||
byCatAcc[qr.Category] = acc
|
||||
}
|
||||
if qr.TokenF1 >= 0 {
|
||||
acc.f1Sum += qr.TokenF1
|
||||
acc.f1Count++
|
||||
}
|
||||
acc.hitRateSum += qr.HitRate
|
||||
acc.hitRateCount++
|
||||
}
|
||||
nHit := len(qaResults)
|
||||
if nHit == 0 {
|
||||
nHit = 1
|
||||
}
|
||||
byCat := map[int]*CatMetrics{}
|
||||
for cat, acc := range byCatAcc {
|
||||
cm := &CatMetrics{
|
||||
QuestionCount: acc.hitRateCount,
|
||||
ValidF1Count: acc.f1Count,
|
||||
}
|
||||
if acc.f1Count > 0 {
|
||||
cm.F1 = acc.f1Sum / float64(acc.f1Count)
|
||||
}
|
||||
if acc.hitRateCount > 0 {
|
||||
cm.HitRate = acc.hitRateSum / float64(acc.hitRateCount)
|
||||
}
|
||||
byCat[cat] = cm
|
||||
}
|
||||
var overallF1 float64
|
||||
if validF1Count > 0 {
|
||||
overallF1 = totalF1 / float64(validF1Count)
|
||||
}
|
||||
return AggMetrics{
|
||||
OverallF1: overallF1,
|
||||
OverallHitRate: totalHitRate / float64(nHit),
|
||||
ByCategory: byCat,
|
||||
TotalQuestions: len(qaResults),
|
||||
ValidF1Count: validF1Count,
|
||||
}
|
||||
}
|
||||
|
||||
// SaveResults writes per-sample eval results to JSON files.
|
||||
func SaveResults(results []EvalResult, outDir string) error {
|
||||
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create output dir: %w", err)
|
||||
}
|
||||
for _, r := range results {
|
||||
path := filepath.Join(outDir, fmt.Sprintf("eval_%s_%s.json", r.Mode, r.SampleID))
|
||||
data, err := json.MarshalIndent(r, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal result: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return fmt.Errorf("write result: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveAggregated writes a combined results.json with all modes.
|
||||
func SaveAggregated(results []EvalResult, outDir string) error {
|
||||
byMode := map[string][]EvalResult{}
|
||||
for _, r := range results {
|
||||
byMode[r.Mode] = append(byMode[r.Mode], r)
|
||||
}
|
||||
|
||||
aggMap := map[string]AggMetrics{}
|
||||
for mode, modeResults := range byMode {
|
||||
aggMap[mode] = computeModeAgg(modeResults)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(aggMap, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(outDir, "results.json"), data, 0o644)
|
||||
}
|
||||
|
||||
// computeModeAgg aggregates results for a single mode using weighted averaging
|
||||
// (weighted by question count per sample). All modes must have the same Mode field.
|
||||
func computeModeAgg(results []EvalResult) AggMetrics {
|
||||
agg := AggMetrics{ByCategory: map[int]*CatMetrics{}}
|
||||
for _, r := range results {
|
||||
// Backward compat: old eval JSON (token mode) without ValidF1Count → use TotalQuestions.
|
||||
// LLM modes may legitimately have ValidF1Count==0 (all failures).
|
||||
vf1 := r.Agg.ValidF1Count
|
||||
if vf1 == 0 && r.Agg.TotalQuestions > 0 && !strings.HasSuffix(r.Mode, "-llm") {
|
||||
vf1 = r.Agg.TotalQuestions
|
||||
}
|
||||
agg.OverallF1 += r.Agg.OverallF1 * float64(vf1)
|
||||
agg.OverallHitRate += r.Agg.OverallHitRate * float64(r.Agg.TotalQuestions)
|
||||
agg.TotalQuestions += r.Agg.TotalQuestions
|
||||
agg.ValidF1Count += vf1
|
||||
for cat, cm := range r.Agg.ByCategory {
|
||||
existing, ok := agg.ByCategory[cat]
|
||||
if !ok {
|
||||
existing = &CatMetrics{}
|
||||
agg.ByCategory[cat] = existing
|
||||
}
|
||||
cvf1 := cm.ValidF1Count
|
||||
if cvf1 == 0 && cm.QuestionCount > 0 && !strings.HasSuffix(r.Mode, "-llm") {
|
||||
cvf1 = cm.QuestionCount
|
||||
}
|
||||
existing.F1 += cm.F1 * float64(cvf1)
|
||||
existing.HitRate += cm.HitRate * float64(cm.QuestionCount)
|
||||
existing.QuestionCount += cm.QuestionCount
|
||||
existing.ValidF1Count += cvf1
|
||||
}
|
||||
}
|
||||
if agg.ValidF1Count > 0 {
|
||||
agg.OverallF1 /= float64(agg.ValidF1Count)
|
||||
}
|
||||
if agg.TotalQuestions > 0 {
|
||||
agg.OverallHitRate /= float64(agg.TotalQuestions)
|
||||
}
|
||||
for _, cat := range agg.ByCategory {
|
||||
if cat.ValidF1Count > 0 {
|
||||
cat.F1 /= float64(cat.ValidF1Count)
|
||||
}
|
||||
if cat.QuestionCount > 0 {
|
||||
cat.HitRate /= float64(cat.QuestionCount)
|
||||
}
|
||||
}
|
||||
return agg
|
||||
}
|
||||
|
||||
// printSection prints a single comparison table section.
|
||||
func printSection(title string, results []EvalResult) {
|
||||
fmt.Printf("\n--- %s ---\n", title)
|
||||
byMode := map[string][]EvalResult{}
|
||||
for _, r := range results {
|
||||
byMode[r.Mode] = append(byMode[r.Mode], r)
|
||||
}
|
||||
|
||||
modes := map[string]AggMetrics{}
|
||||
for mode, modeResults := range byMode {
|
||||
modes[mode] = computeModeAgg(modeResults)
|
||||
}
|
||||
|
||||
modeKeys := make([]string, 0, len(modes))
|
||||
for k := range modes {
|
||||
modeKeys = append(modeKeys, k)
|
||||
}
|
||||
sort.Strings(modeKeys)
|
||||
|
||||
// Collect all category keys across modes
|
||||
catSet := map[int]bool{}
|
||||
for _, agg := range modes {
|
||||
for cat := range agg.ByCategory {
|
||||
catSet[cat] = true
|
||||
}
|
||||
}
|
||||
cats := make([]int, 0, len(catSet))
|
||||
for cat := range catSet {
|
||||
cats = append(cats, cat)
|
||||
}
|
||||
sort.Ints(cats)
|
||||
|
||||
fmt.Printf("%-10s %-8s %-8s", "Mode", "HitRate", "F1")
|
||||
for _, cat := range cats {
|
||||
fmt.Printf(" %-7s", fmt.Sprintf("C%d", cat))
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println(strings.Repeat("-", 10+8+8+7*len(cats)+8))
|
||||
|
||||
for _, mode := range modeKeys {
|
||||
agg := modes[mode]
|
||||
fmt.Printf("%-10s %-8.4f %-8.4f", mode, agg.OverallHitRate, agg.OverallF1)
|
||||
for _, cat := range cats {
|
||||
if cm, ok := agg.ByCategory[cat]; ok {
|
||||
fmt.Printf(" %-7.4f", cm.HitRate)
|
||||
} else {
|
||||
fmt.Printf(" %-7s", "N/A")
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// PrintComparison outputs a human-readable comparison table to stdout.
|
||||
func PrintComparison(results []EvalResult, llmResults []EvalResult) {
|
||||
if len(results) > 0 {
|
||||
printSection("No LLM generation", results)
|
||||
}
|
||||
if len(llmResults) > 0 {
|
||||
printSection("With LLM", llmResults)
|
||||
}
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestComputeModeAggAllCategories(t *testing.T) {
|
||||
results := []EvalResult{
|
||||
{
|
||||
Mode: "test",
|
||||
SampleID: "s1",
|
||||
QAResults: []QAResult{
|
||||
{Category: 1, TokenF1: 0.5, HitRate: 0.8},
|
||||
{Category: 2, TokenF1: 0.3, HitRate: 0.6},
|
||||
{Category: 3, TokenF1: 0.1, HitRate: 0.4},
|
||||
{Category: 4, TokenF1: 0.7, HitRate: 0.9},
|
||||
{Category: 5, TokenF1: 0.2, HitRate: 0.1},
|
||||
},
|
||||
},
|
||||
}
|
||||
for i := range results {
|
||||
results[i].Agg = aggregateMetrics(results[i].QAResults)
|
||||
}
|
||||
|
||||
got := computeModeAgg(results)
|
||||
|
||||
// Should have all 5 categories
|
||||
for cat := 1; cat <= 5; cat++ {
|
||||
cm, ok := got.ByCategory[cat]
|
||||
if !ok {
|
||||
t.Errorf("ByCategory missing category %d", cat)
|
||||
continue
|
||||
}
|
||||
if cm.QuestionCount != 1 {
|
||||
t.Errorf("ByCategory[%d].QuestionCount = %d, want 1", cat, cm.QuestionCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify specific F1 values per category
|
||||
wantF1 := map[int]float64{1: 0.5, 2: 0.3, 3: 0.1, 4: 0.7, 5: 0.2}
|
||||
for cat, want := range wantF1 {
|
||||
if cm, ok := got.ByCategory[cat]; ok {
|
||||
if math.Abs(cm.F1-want) > 1e-9 {
|
||||
t.Errorf("ByCategory[%d].F1 = %.4f, want %.4f", cat, cm.F1, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeModeAgg(t *testing.T) {
|
||||
// Two samples with different question counts:
|
||||
// sample-a: 2 questions, F1 = [0.4, 0.6] → avg 0.5
|
||||
// sample-b: 8 questions, F1 = [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1] → avg 0.1
|
||||
//
|
||||
// Unweighted (PrintComparison bug): (0.5 + 0.1) / 2 = 0.3
|
||||
// Weighted (correct): (0.4+0.6 + 0.1*8) / 10 = 1.8 / 10 = 0.18
|
||||
results := []EvalResult{
|
||||
{
|
||||
Mode: "test",
|
||||
SampleID: "sample-a",
|
||||
QAResults: []QAResult{
|
||||
{TokenF1: 0.4, HitRate: 0.5},
|
||||
{TokenF1: 0.6, HitRate: 0.7},
|
||||
},
|
||||
},
|
||||
{
|
||||
Mode: "test",
|
||||
SampleID: "sample-b",
|
||||
QAResults: []QAResult{
|
||||
{TokenF1: 0.1, HitRate: 0.2},
|
||||
{TokenF1: 0.1, HitRate: 0.2},
|
||||
{TokenF1: 0.1, HitRate: 0.2},
|
||||
{TokenF1: 0.1, HitRate: 0.2},
|
||||
{TokenF1: 0.1, HitRate: 0.2},
|
||||
{TokenF1: 0.1, HitRate: 0.2},
|
||||
{TokenF1: 0.1, HitRate: 0.2},
|
||||
{TokenF1: 0.1, HitRate: 0.2},
|
||||
},
|
||||
},
|
||||
}
|
||||
// Compute per-sample aggregates
|
||||
for i := range results {
|
||||
results[i].Agg = aggregateMetrics(results[i].QAResults)
|
||||
}
|
||||
|
||||
got := computeModeAgg(results)
|
||||
|
||||
// Weighted: (0.4+0.6+0.1*8) / 10 = 1.8/10 = 0.18
|
||||
wantF1 := 0.18
|
||||
if math.Abs(got.OverallF1-wantF1) > 1e-9 {
|
||||
t.Errorf("OverallF1 = %.6f, want %.6f (weighted average)", got.OverallF1, wantF1)
|
||||
}
|
||||
|
||||
// Weighted: (0.5+0.7+0.2*8) / 10 = 2.8/10 = 0.28
|
||||
wantRecall := 0.28
|
||||
if math.Abs(got.OverallHitRate-wantRecall) > 1e-9 {
|
||||
t.Errorf("OverallHitRate = %.6f, want %.6f (weighted average)", got.OverallHitRate, wantRecall)
|
||||
}
|
||||
|
||||
if got.TotalQuestions != 10 {
|
||||
t.Errorf("TotalQuestions = %d, want 10", got.TotalQuestions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateMetricsSentinel(t *testing.T) {
|
||||
qa := []QAResult{
|
||||
{Category: 1, TokenF1: 0.8, HitRate: 0.5},
|
||||
{Category: 1, TokenF1: -1.0, HitRate: 0.3},
|
||||
{Category: 1, TokenF1: 0.4, HitRate: 0.7},
|
||||
}
|
||||
agg := aggregateMetrics(qa)
|
||||
|
||||
if agg.ValidF1Count != 2 {
|
||||
t.Errorf("ValidF1Count = %d, want 2", agg.ValidF1Count)
|
||||
}
|
||||
if agg.TotalQuestions != 3 {
|
||||
t.Errorf("TotalQuestions = %d, want 3", agg.TotalQuestions)
|
||||
}
|
||||
wantF1 := (0.8 + 0.4) / 2.0
|
||||
if math.Abs(agg.OverallF1-wantF1) > 1e-9 {
|
||||
t.Errorf("OverallF1 = %.6f, want %.6f", agg.OverallF1, wantF1)
|
||||
}
|
||||
wantHR := (0.5 + 0.3 + 0.7) / 3.0
|
||||
if math.Abs(agg.OverallHitRate-wantHR) > 1e-9 {
|
||||
t.Errorf("OverallHitRate = %.6f, want %.6f", agg.OverallHitRate, wantHR)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateMetricsAllSentinel(t *testing.T) {
|
||||
qa := []QAResult{
|
||||
{Category: 1, TokenF1: -1.0, HitRate: 0.5},
|
||||
{Category: 1, TokenF1: -1.0, HitRate: 0.3},
|
||||
}
|
||||
agg := aggregateMetrics(qa)
|
||||
|
||||
if agg.ValidF1Count != 0 {
|
||||
t.Errorf("ValidF1Count = %d, want 0", agg.ValidF1Count)
|
||||
}
|
||||
if agg.OverallF1 != 0 {
|
||||
t.Errorf("OverallF1 = %.6f, want 0", agg.OverallF1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeModeAggSentinelWeighting(t *testing.T) {
|
||||
results := []EvalResult{
|
||||
{
|
||||
Mode: "test",
|
||||
SampleID: "s1",
|
||||
QAResults: []QAResult{
|
||||
{Category: 1, TokenF1: 0.8, HitRate: 0.5},
|
||||
{Category: 1, TokenF1: -1.0, HitRate: 0.3},
|
||||
},
|
||||
},
|
||||
{
|
||||
Mode: "test",
|
||||
SampleID: "s2",
|
||||
QAResults: []QAResult{
|
||||
{Category: 1, TokenF1: 0.4, HitRate: 0.6},
|
||||
{Category: 1, TokenF1: 0.6, HitRate: 0.8},
|
||||
},
|
||||
},
|
||||
}
|
||||
for i := range results {
|
||||
results[i].Agg = aggregateMetrics(results[i].QAResults)
|
||||
}
|
||||
|
||||
got := computeModeAgg(results)
|
||||
|
||||
// s1: ValidF1Count=1, F1=0.8; s2: ValidF1Count=2, F1=0.5
|
||||
// Weighted: (0.8*1 + 0.5*2) / 3 = 1.8/3 = 0.6
|
||||
wantF1 := 0.6
|
||||
if math.Abs(got.OverallF1-wantF1) > 1e-9 {
|
||||
t.Errorf("OverallF1 = %.6f, want %.6f", got.OverallF1, wantF1)
|
||||
}
|
||||
if got.ValidF1Count != 3 {
|
||||
t.Errorf("ValidF1Count = %d, want 3", got.ValidF1Count)
|
||||
}
|
||||
if got.TotalQuestions != 4 {
|
||||
t.Errorf("TotalQuestions = %d, want 4", got.TotalQuestions)
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/seahorse"
|
||||
)
|
||||
|
||||
// ConvMap stores the mapping from sampleID to seahorse ConversationID.
|
||||
type ConvMap map[string]int64
|
||||
|
||||
// SeahorseIngestResult holds the results of ingesting into seahorse.
|
||||
type SeahorseIngestResult struct {
|
||||
Engine *seahorse.Engine
|
||||
ConvMap ConvMap // sampleID → conversationID
|
||||
}
|
||||
|
||||
// IngestSeahorse loads all LOCOMO samples into a seahorse Engine.
|
||||
// Returns the engine and a mapping from sampleID to conversationID for scoped retrieval.
|
||||
func IngestSeahorse(ctx context.Context, samples []LocomoSample, dbPath string) (*SeahorseIngestResult, error) {
|
||||
noopFn := func(ctx context.Context, prompt string, opts seahorse.CompleteOptions) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
engine, err := seahorse.NewEngine(seahorse.Config{
|
||||
DBPath: dbPath,
|
||||
}, noopFn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create seahorse engine: %w", err)
|
||||
}
|
||||
|
||||
store := engine.GetRetrieval().Store()
|
||||
convMap := make(ConvMap)
|
||||
|
||||
for si := range samples {
|
||||
sample := &samples[si]
|
||||
sessionKey := "locomo-" + sample.SampleID
|
||||
|
||||
// Check if conversation already exists (idempotent)
|
||||
existing, _ := store.GetConversationBySessionKey(ctx, sessionKey)
|
||||
if existing != nil {
|
||||
convMap[sample.SampleID] = existing.ConversationID
|
||||
log.Printf("Skipping existing sample %s: convID=%d", sample.SampleID, existing.ConversationID)
|
||||
continue
|
||||
}
|
||||
|
||||
turns := GetTurns(sample)
|
||||
|
||||
// Convert turns to seahorse messages
|
||||
msgs := make([]seahorse.Message, 0, len(turns))
|
||||
for _, turn := range turns {
|
||||
content := turn.Speaker + ": " + turn.Text
|
||||
msgs = append(msgs, seahorse.Message{
|
||||
Role: "user",
|
||||
Content: content,
|
||||
TokenCount: len(turn.Text) / 4,
|
||||
})
|
||||
}
|
||||
|
||||
// Ingest all turns for this sample
|
||||
_, err := engine.Ingest(ctx, sessionKey, msgs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ingest sample %s: %w", sample.SampleID, err)
|
||||
}
|
||||
|
||||
// Get the conversation ID for scoped retrieval
|
||||
conv, err := store.GetConversationBySessionKey(ctx, sessionKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get conversation for %s: %w", sample.SampleID, err)
|
||||
}
|
||||
if conv == nil {
|
||||
return nil, fmt.Errorf("conversation not found for %s after ingest", sample.SampleID)
|
||||
}
|
||||
convMap[sample.SampleID] = conv.ConversationID
|
||||
log.Printf("Ingested sample %s: %d turns, convID=%d", sample.SampleID, len(turns), conv.ConversationID)
|
||||
}
|
||||
|
||||
log.Printf("Seahorse ingestion complete: %d samples, %d conversations", len(samples), len(convMap))
|
||||
return &SeahorseIngestResult{
|
||||
Engine: engine,
|
||||
ConvMap: convMap,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/seahorse"
|
||||
)
|
||||
|
||||
func TestIngestSeahorseIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
|
||||
// Minimal test data
|
||||
samples := []LocomoSample{
|
||||
{
|
||||
SampleID: "test-1",
|
||||
Conversation: map[string]json.RawMessage{
|
||||
"session_1": json.RawMessage(`[
|
||||
{"speaker":"A","dia_id":"D1:1","text":"hello world this is a test message"},
|
||||
{"speaker":"B","dia_id":"D1:2","text":"another message for testing purposes"}
|
||||
]`),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// First ingestion
|
||||
result1, err := IngestSeahorse(ctx, samples, dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("first ingest failed: %v", err)
|
||||
}
|
||||
convCount1 := len(result1.ConvMap)
|
||||
result1.Engine.Close()
|
||||
|
||||
// Second ingestion on same DB — should reuse existing data
|
||||
result2, err := IngestSeahorse(ctx, samples, dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("second ingest failed: %v", err)
|
||||
}
|
||||
defer result2.Engine.Close()
|
||||
|
||||
// ConvMap should have same number of entries (no duplicates)
|
||||
if len(result2.ConvMap) != convCount1 {
|
||||
t.Errorf("second ingest convMap has %d entries, want %d (same as first)",
|
||||
len(result2.ConvMap), convCount1)
|
||||
}
|
||||
|
||||
// Verify conversation IDs are the same (reused, not new ones)
|
||||
for id, cid1 := range result1.ConvMap {
|
||||
cid2, ok := result2.ConvMap[id]
|
||||
if !ok {
|
||||
t.Errorf("sample %s missing from second ConvMap", id)
|
||||
continue
|
||||
}
|
||||
if cid2 != cid1 {
|
||||
t.Errorf("sample %s: second ingest got convID %d, want %d (reused)", id, cid2, cid1)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify no duplicate messages by counting
|
||||
store := result2.Engine.GetRetrieval().Store()
|
||||
for _, convID := range result2.ConvMap {
|
||||
msgs, err := store.SearchMessages(ctx, seahorse.SearchInput{
|
||||
Pattern: "test",
|
||||
ConversationID: convID,
|
||||
Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("search failed: %v", err)
|
||||
}
|
||||
// Should find exactly 1 message containing "test" (the first turn)
|
||||
if len(msgs) > 2 {
|
||||
t.Errorf("found %d messages for 'test' in conv %d, expected ≤2 (no duplicates)", len(msgs), convID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/session"
|
||||
)
|
||||
|
||||
// LegacyStore wraps session.SessionManager for legacy baseline.
|
||||
type LegacyStore struct {
|
||||
sm *session.SessionManager
|
||||
}
|
||||
|
||||
// NewLegacyStore creates a new in-memory session manager.
|
||||
func NewLegacyStore() *LegacyStore {
|
||||
return &LegacyStore{
|
||||
sm: session.NewSessionManager(""),
|
||||
}
|
||||
}
|
||||
|
||||
// IngestSample loads all turns from a LOCOMO sample into the legacy session store.
|
||||
func (ls *LegacyStore) IngestSample(sample *LocomoSample) {
|
||||
sessionKey := "locomo-" + sample.SampleID
|
||||
turns := GetTurns(sample)
|
||||
for _, turn := range turns {
|
||||
content := turn.Speaker + ": " + turn.Text
|
||||
ls.sm.AddMessage(sessionKey, "user", content)
|
||||
}
|
||||
}
|
||||
|
||||
// GetHistory returns all messages for a sample's session.
|
||||
func (ls *LegacyStore) GetHistory(sampleID string) []providers.Message {
|
||||
sessionKey := "locomo-" + sampleID
|
||||
return ls.sm.GetHistory(sessionKey)
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LocomoSample represents one conversation sample from the LOCOMO dataset.
|
||||
type LocomoSample struct {
|
||||
SampleID string `json:"sample_id"`
|
||||
Conversation map[string]json.RawMessage `json:"conversation"`
|
||||
QA []LocomoQA `json:"qa"`
|
||||
}
|
||||
|
||||
// LocomoTurn represents a single turn in a conversation.
|
||||
type LocomoTurn struct {
|
||||
Speaker string `json:"speaker"`
|
||||
DiaID string `json:"dia_id"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// LocomoQA represents a question-answer pair with evidence.
|
||||
type LocomoQA struct {
|
||||
Question string `json:"question"`
|
||||
Answer json.RawMessage `json:"answer"` // can be string or int (category 1-4)
|
||||
AdversarialAnswer string `json:"adversarial_answer"` // category 5 only
|
||||
Evidence []string `json:"evidence"`
|
||||
Category int `json:"category"` // 1=single-hop, 2=multi-hop, 3=open-ended, 5=adversarial
|
||||
}
|
||||
|
||||
// AnswerString returns the answer as a string, handling both string and int types.
|
||||
func (qa *LocomoQA) AnswerString() string {
|
||||
// Prefer answer field (category 1-4)
|
||||
if len(qa.Answer) > 0 {
|
||||
var s string
|
||||
if err := json.Unmarshal(qa.Answer, &s); err == nil {
|
||||
return s
|
||||
}
|
||||
var n json.Number
|
||||
if err := json.Unmarshal(qa.Answer, &n); err == nil {
|
||||
return n.String()
|
||||
}
|
||||
return strings.Trim(string(qa.Answer), `"`)
|
||||
}
|
||||
// Fallback to adversarial_answer (category 5)
|
||||
return qa.AdversarialAnswer
|
||||
}
|
||||
|
||||
// LoadDataset reads all JSON files from dataDir and returns parsed samples.
|
||||
func LoadDataset(dataDir string) ([]LocomoSample, error) {
|
||||
entries, err := os.ReadDir(dataDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read data dir %s: %w", dataDir, err)
|
||||
}
|
||||
|
||||
var samples []LocomoSample
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".json") {
|
||||
path := filepath.Join(dataDir, entry.Name())
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read file %s: %w", path, err)
|
||||
}
|
||||
var batch []LocomoSample
|
||||
if err := json.Unmarshal(data, &batch); err != nil {
|
||||
return nil, fmt.Errorf("parse file %s: %w", path, err)
|
||||
}
|
||||
samples = append(samples, batch...)
|
||||
}
|
||||
}
|
||||
return samples, nil
|
||||
}
|
||||
|
||||
// GetSessionNames returns sorted session keys (session_1, session_2, ...) from conversation.
|
||||
func GetSessionNames(conv map[string]json.RawMessage) []string {
|
||||
var names []string
|
||||
for k := range conv {
|
||||
if strings.HasPrefix(k, "session_") && !strings.Contains(k, "_date_time") {
|
||||
names = append(names, k)
|
||||
}
|
||||
}
|
||||
sort.Slice(names, func(i, j int) bool {
|
||||
ni := sessionNum(names[i])
|
||||
nj := sessionNum(names[j])
|
||||
return ni < nj
|
||||
})
|
||||
return names
|
||||
}
|
||||
|
||||
func sessionNum(key string) int {
|
||||
// "session_1" → 1, "session_10" → 10
|
||||
parts := strings.SplitN(key, "_", 2)
|
||||
if len(parts) < 2 {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(parts[1])
|
||||
return n
|
||||
}
|
||||
|
||||
// GetTurns flattens all sessions' turns in chronological order.
|
||||
func GetTurns(sample *LocomoSample) []LocomoTurn {
|
||||
names := GetSessionNames(sample.Conversation)
|
||||
var all []LocomoTurn
|
||||
for _, name := range names {
|
||||
raw, ok := sample.Conversation[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var turns []LocomoTurn
|
||||
if err := json.Unmarshal(raw, &turns); err != nil {
|
||||
log.Printf("WARNING: unmarshal failed for session %q in sample %s: %v", name, sample.SampleID, err)
|
||||
continue
|
||||
}
|
||||
all = append(all, turns...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// GetTurnByDiaID finds a specific turn by dia_id (e.g. "D1:3").
|
||||
func GetTurnByDiaID(sample *LocomoSample, diaID string) *LocomoTurn {
|
||||
turns := GetTurns(sample)
|
||||
for i := range turns {
|
||||
if turns[i].DiaID == diaID {
|
||||
return &turns[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSpeakers returns the two speaker names from conversation metadata.
|
||||
func GetSpeakers(conv map[string]json.RawMessage) (string, string) {
|
||||
var a, b string
|
||||
json.Unmarshal(conv["speaker_a"], &a)
|
||||
json.Unmarshal(conv["speaker_b"], &b)
|
||||
return a, b
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAnswerString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
json string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"string answer",
|
||||
`{"question":"Q","answer":"Paris","evidence":[],"category":1}`,
|
||||
"Paris",
|
||||
},
|
||||
{
|
||||
"int answer",
|
||||
`{"question":"Q","answer":42,"evidence":[],"category":1}`,
|
||||
"42",
|
||||
},
|
||||
{
|
||||
"adversarial answer (category 5)",
|
||||
`{"question":"Q","evidence":[],"category":5,"adversarial_answer":"self-care is important"}`,
|
||||
"self-care is important",
|
||||
},
|
||||
{
|
||||
"both answer and adversarial_answer present",
|
||||
`{"question":"Q","answer":"normal","evidence":[],"category":5,"adversarial_answer":"adversarial"}`,
|
||||
"normal",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var qa LocomoQA
|
||||
if err := json.Unmarshal([]byte(tt.json), &qa); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
got := qa.AnswerString()
|
||||
if got != tt.want {
|
||||
t.Errorf("AnswerString() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSessionNames(t *testing.T) {
|
||||
conv := map[string]json.RawMessage{
|
||||
"session_2": {},
|
||||
"session_1": {},
|
||||
"session_10": {},
|
||||
"session_1_date_time": {},
|
||||
"speaker_a": {},
|
||||
}
|
||||
names := GetSessionNames(conv)
|
||||
want := []string{"session_1", "session_2", "session_10"}
|
||||
if len(names) != len(want) {
|
||||
t.Fatalf("got %v, want %v", names, want)
|
||||
}
|
||||
for i, n := range names {
|
||||
if n != want[i] {
|
||||
t.Errorf("names[%d] = %q, want %q", i, n, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
flagData string
|
||||
flagOut string
|
||||
flagMode string
|
||||
flagBudget int
|
||||
flagEvalMode string
|
||||
flagAPIBase string
|
||||
flagAPIKey string
|
||||
flagModel string
|
||||
flagNoThinking bool
|
||||
flagLimit int
|
||||
flagTimeout int
|
||||
flagRetries int
|
||||
flagJudgeModel string
|
||||
flagJudgeAPIBase string
|
||||
flagJudgeAPIKey string
|
||||
flagConcurrency int
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Suppress seahorse INFO logs during benchmark
|
||||
logger.SetLevel(logger.WARN)
|
||||
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "membench",
|
||||
Short: "Memory benchmark tool for picoclaw",
|
||||
}
|
||||
|
||||
ingestCmd := &cobra.Command{
|
||||
Use: "ingest",
|
||||
Short: "Load LOCOMO data into storage backends",
|
||||
RunE: runIngest,
|
||||
}
|
||||
ingestCmd.Flags().StringVar(&flagData, "data", "", "LOCOMO dataset directory (required)")
|
||||
ingestCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
|
||||
ingestCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to ingest: legacy, seahorse, or all")
|
||||
|
||||
evalCmd := &cobra.Command{
|
||||
Use: "eval",
|
||||
Short: "Run QA evaluation against ingested data",
|
||||
RunE: runEval,
|
||||
}
|
||||
evalCmd.Flags().StringVar(&flagData, "data", "", "LOCOMO dataset directory (required)")
|
||||
evalCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
|
||||
evalCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to evaluate: legacy, seahorse, or all")
|
||||
evalCmd.Flags().IntVar(&flagBudget, "budget", 4000, "token budget for retrieval")
|
||||
evalCmd.Flags().
|
||||
StringVar(&flagEvalMode, "eval-mode", "token", "evaluation mode: token (direct match) or llm (LLM-as-Judge)")
|
||||
evalCmd.Flags().
|
||||
StringVar(&flagAPIBase, "api-base", "", "API base URL with version path, e.g. http://host/v1 (default: http://127.0.0.1:8080/v1, env: MEMBENCH_API_BASE)")
|
||||
evalCmd.Flags().StringVar(&flagAPIKey, "api-key", "", "API key for the LLM endpoint (env: MEMBENCH_API_KEY)")
|
||||
evalCmd.Flags().StringVar(&flagModel, "model", "", "model name for LLM eval (env: MEMBENCH_MODEL)")
|
||||
evalCmd.Flags().
|
||||
BoolVar(&flagNoThinking, "no-thinking", false, "disable thinking mode via chat_template_kwargs (llama.cpp + Qwen)")
|
||||
evalCmd.Flags().IntVar(&flagLimit, "limit", 0, "max QA questions per sample (0 = all)")
|
||||
evalCmd.Flags().IntVar(&flagTimeout, "timeout", 120, "HTTP timeout in seconds for LLM requests")
|
||||
evalCmd.Flags().IntVar(&flagRetries, "retries", 3, "max retry attempts for transient LLM errors (timeout/5xx/429)")
|
||||
evalCmd.Flags().StringVar(&flagJudgeModel, "judge-model", "", "model for judge scoring (defaults to --model)")
|
||||
evalCmd.Flags().
|
||||
StringVar(&flagJudgeAPIBase, "judge-api-base", "", "API base URL for judge model (defaults to --api-base)")
|
||||
evalCmd.Flags().StringVar(&flagJudgeAPIKey, "judge-api-key", "", "API key for judge model (defaults to --api-key)")
|
||||
evalCmd.Flags().IntVar(&flagConcurrency, "concurrency", 1, "number of concurrent QA evaluations")
|
||||
|
||||
reportCmd := &cobra.Command{
|
||||
Use: "report",
|
||||
Short: "Output comparison results from evaluation",
|
||||
RunE: runReport,
|
||||
}
|
||||
reportCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
|
||||
|
||||
runCmd := &cobra.Command{
|
||||
Use: "run",
|
||||
Short: "Convenience: eval + report (ingestion is done inline)",
|
||||
RunE: runAll,
|
||||
}
|
||||
runCmd.Flags().StringVar(&flagData, "data", "", "LOCOMO dataset directory (required)")
|
||||
runCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
|
||||
runCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to run: legacy, seahorse, or all")
|
||||
runCmd.Flags().IntVar(&flagBudget, "budget", 4000, "token budget for retrieval")
|
||||
runCmd.Flags().
|
||||
StringVar(&flagEvalMode, "eval-mode", "token", "evaluation mode: token (direct match) or llm (LLM-as-Judge)")
|
||||
runCmd.Flags().
|
||||
StringVar(&flagAPIBase, "api-base", "", "API base URL with version path, e.g. http://host/v1 (default: http://127.0.0.1:8080/v1, env: MEMBENCH_API_BASE)")
|
||||
runCmd.Flags().StringVar(&flagAPIKey, "api-key", "", "API key for the LLM endpoint (env: MEMBENCH_API_KEY)")
|
||||
runCmd.Flags().StringVar(&flagModel, "model", "", "model name for LLM eval (env: MEMBENCH_MODEL)")
|
||||
runCmd.Flags().
|
||||
BoolVar(&flagNoThinking, "no-thinking", false, "disable thinking mode via chat_template_kwargs (llama.cpp + Qwen)")
|
||||
runCmd.Flags().IntVar(&flagLimit, "limit", 0, "max QA questions per sample (0 = all)")
|
||||
runCmd.Flags().IntVar(&flagTimeout, "timeout", 120, "HTTP timeout in seconds for LLM requests")
|
||||
runCmd.Flags().IntVar(&flagRetries, "retries", 3, "max retry attempts for transient LLM errors (timeout/5xx/429)")
|
||||
runCmd.Flags().StringVar(&flagJudgeModel, "judge-model", "", "model for judge scoring (defaults to --model)")
|
||||
runCmd.Flags().
|
||||
StringVar(&flagJudgeAPIBase, "judge-api-base", "", "API base URL for judge model (defaults to --api-base)")
|
||||
runCmd.Flags().StringVar(&flagJudgeAPIKey, "judge-api-key", "", "API key for judge model (defaults to --api-key)")
|
||||
runCmd.Flags().IntVar(&flagConcurrency, "concurrency", 1, "number of concurrent QA evaluations")
|
||||
|
||||
rootCmd.AddCommand(ingestCmd, evalCmd, reportCmd, runCmd)
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func modesFromFlag() []string {
|
||||
switch strings.ToLower(flagMode) {
|
||||
case "all":
|
||||
return []string{"legacy", "seahorse"}
|
||||
default:
|
||||
return []string{strings.ToLower(flagMode)}
|
||||
}
|
||||
}
|
||||
|
||||
func runIngest(cmd *cobra.Command, args []string) error {
|
||||
if flagData == "" {
|
||||
return fmt.Errorf("--data is required")
|
||||
}
|
||||
modes := modesFromFlag()
|
||||
if len(modes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
samples, err := LoadDataset(flagData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load dataset: %w", err)
|
||||
}
|
||||
log.Printf("Loaded %d samples from %s", len(samples), flagData)
|
||||
|
||||
for _, mode := range modes {
|
||||
switch mode {
|
||||
case "legacy":
|
||||
legacy := NewLegacyStore()
|
||||
for i := range samples {
|
||||
legacy.IngestSample(&samples[i])
|
||||
}
|
||||
log.Printf("legacy: ingested %d samples", len(samples))
|
||||
case "seahorse":
|
||||
dbPath := filepath.Join(flagOut, "seahorse.db")
|
||||
if err := os.MkdirAll(flagOut, 0o755); err != nil {
|
||||
return fmt.Errorf("create out dir: %w", err)
|
||||
}
|
||||
_, err := IngestSeahorse(ctx, samples, dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ingest seahorse: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runEval(cmd *cobra.Command, args []string) error {
|
||||
if flagData == "" {
|
||||
return fmt.Errorf("--data is required")
|
||||
}
|
||||
modes := modesFromFlag()
|
||||
if len(modes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
samples, err := LoadDataset(flagData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load dataset: %w", err)
|
||||
}
|
||||
log.Printf("Loaded %d samples", len(samples))
|
||||
|
||||
if flagLimit > 0 {
|
||||
for i := range samples {
|
||||
if len(samples[i].QA) > flagLimit {
|
||||
samples[i].QA = samples[i].QA[:flagLimit]
|
||||
}
|
||||
}
|
||||
log.Printf("Limited to %d QA per sample", flagLimit)
|
||||
}
|
||||
|
||||
evalMode := strings.ToLower(strings.TrimSpace(flagEvalMode))
|
||||
var useLLM bool
|
||||
switch evalMode {
|
||||
case "token":
|
||||
useLLM = false
|
||||
case "llm":
|
||||
useLLM = true
|
||||
default:
|
||||
return fmt.Errorf("invalid --eval-mode %q: must be token or llm", flagEvalMode)
|
||||
}
|
||||
var answerClient, judgeClient *LLMClient
|
||||
if useLLM {
|
||||
opts, err := buildLLMOptions()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
answerClient = NewLLMClient(opts)
|
||||
judgeClient = answerClient // default: same client
|
||||
if flagJudgeModel != "" {
|
||||
jOpts := opts // copy base settings
|
||||
jOpts.Model = flagJudgeModel
|
||||
if flagJudgeAPIBase != "" {
|
||||
jOpts.BaseURL = flagJudgeAPIBase
|
||||
}
|
||||
if flagJudgeAPIKey != "" {
|
||||
jOpts.APIKey = flagJudgeAPIKey
|
||||
}
|
||||
judgeClient = NewLLMClient(jOpts)
|
||||
log.Printf("Judge model: model=%s base=%s no-thinking=%v", jOpts.Model, jOpts.BaseURL, jOpts.NoThinking)
|
||||
}
|
||||
log.Printf("LLM eval mode: model=%s base=%s no-thinking=%v concurrency=%d",
|
||||
opts.Model, opts.BaseURL, opts.NoThinking, flagConcurrency)
|
||||
}
|
||||
|
||||
var tokenResults, llmResults []EvalResult
|
||||
|
||||
for _, mode := range modes {
|
||||
switch mode {
|
||||
case "legacy":
|
||||
legacy := NewLegacyStore()
|
||||
for i := range samples {
|
||||
legacy.IngestSample(&samples[i])
|
||||
}
|
||||
if useLLM {
|
||||
results := EvalLegacyLLM(ctx, samples, legacy, flagBudget, answerClient, judgeClient, flagConcurrency)
|
||||
llmResults = append(llmResults, results...)
|
||||
log.Printf("legacy-llm: evaluated %d samples", len(results))
|
||||
} else {
|
||||
results := EvalLegacy(ctx, samples, legacy, flagBudget)
|
||||
tokenResults = append(tokenResults, results...)
|
||||
log.Printf("legacy: evaluated %d samples", len(results))
|
||||
}
|
||||
case "seahorse":
|
||||
dbPath := filepath.Join(flagOut, "seahorse.db")
|
||||
ir, err := IngestSeahorse(ctx, samples, dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ingest seahorse: %w", err)
|
||||
}
|
||||
if useLLM {
|
||||
results := EvalSeahorseLLM(ctx, samples, ir, flagBudget, answerClient, judgeClient, flagConcurrency)
|
||||
llmResults = append(llmResults, results...)
|
||||
log.Printf("seahorse-llm: evaluated %d samples", len(results))
|
||||
} else {
|
||||
results := EvalSeahorse(ctx, samples, ir, flagBudget)
|
||||
tokenResults = append(tokenResults, results...)
|
||||
log.Printf("seahorse: evaluated %d samples", len(results))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allResults := append(tokenResults, llmResults...)
|
||||
if err := SaveResults(allResults, flagOut); err != nil {
|
||||
return fmt.Errorf("save results: %w", err)
|
||||
}
|
||||
if err := SaveAggregated(allResults, flagOut); err != nil {
|
||||
return fmt.Errorf("save aggregated: %w", err)
|
||||
}
|
||||
|
||||
PrintComparison(tokenResults, llmResults)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runReport(cmd *cobra.Command, args []string) error {
|
||||
entries, err := os.ReadDir(flagOut)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read out dir: %w", err)
|
||||
}
|
||||
|
||||
var allResults []EvalResult
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasPrefix(entry.Name(), "eval_") && strings.HasSuffix(entry.Name(), ".json") {
|
||||
path := filepath.Join(flagOut, entry.Name())
|
||||
var r EvalResult
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
log.Printf("WARN: read %s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal(data, &r); err != nil {
|
||||
log.Printf("WARN: parse %s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
allResults = append(allResults, r)
|
||||
}
|
||||
}
|
||||
|
||||
if len(allResults) == 0 {
|
||||
return fmt.Errorf("no eval results found in %s", flagOut)
|
||||
}
|
||||
|
||||
var tokenResults, llmResults []EvalResult
|
||||
for _, r := range allResults {
|
||||
if strings.HasSuffix(r.Mode, "-llm") {
|
||||
llmResults = append(llmResults, r)
|
||||
} else {
|
||||
tokenResults = append(tokenResults, r)
|
||||
}
|
||||
}
|
||||
PrintComparison(tokenResults, llmResults)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAll(cmd *cobra.Command, args []string) error {
|
||||
return runEval(cmd, args)
|
||||
}
|
||||
|
||||
// envOrFlag returns the flag value if non-empty, otherwise falls back to the
|
||||
// environment variable.
|
||||
func envOrFlag(flag, envKey string) string {
|
||||
if flag != "" {
|
||||
return flag
|
||||
}
|
||||
return os.Getenv(envKey)
|
||||
}
|
||||
|
||||
// buildLLMOptions resolves LLM client configuration from flags and environment
|
||||
// variables. Flag values take precedence over environment variables.
|
||||
//
|
||||
// Environment variables:
|
||||
//
|
||||
// MEMBENCH_API_BASE – OpenAI-compatible base URL (default http://127.0.0.1:8080/v1)
|
||||
// MEMBENCH_API_KEY – Bearer token for the endpoint
|
||||
// MEMBENCH_MODEL – Model name to send in the request
|
||||
func buildLLMOptions() (LLMClientOptions, error) {
|
||||
base := envOrFlag(flagAPIBase, "MEMBENCH_API_BASE")
|
||||
if base == "" {
|
||||
base = "http://127.0.0.1:8080/v1"
|
||||
}
|
||||
model := envOrFlag(flagModel, "MEMBENCH_MODEL")
|
||||
if model == "" {
|
||||
return LLMClientOptions{}, fmt.Errorf(
|
||||
"--model or MEMBENCH_MODEL is required for LLM eval mode",
|
||||
)
|
||||
}
|
||||
apiKey := envOrFlag(flagAPIKey, "MEMBENCH_API_KEY")
|
||||
|
||||
if flagTimeout <= 0 {
|
||||
return LLMClientOptions{}, fmt.Errorf("--timeout must be > 0, got %d", flagTimeout)
|
||||
}
|
||||
|
||||
return LLMClientOptions{
|
||||
BaseURL: base,
|
||||
Model: model,
|
||||
APIKey: apiKey,
|
||||
NoThinking: flagNoThinking,
|
||||
Timeout: time.Duration(flagTimeout) * time.Second,
|
||||
MaxRetries: flagRetries,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// diaIDRe matches valid dia_id patterns like "D1:3", "D30:5".
|
||||
var diaIDRe = regexp.MustCompile(`^D(\d+):(\d+)$`)
|
||||
|
||||
// SplitEvidenceIDs splits an evidence string that may contain multiple
|
||||
// semicolon-separated or space-separated dia_ids. Only returns valid IDs.
|
||||
// Example: "D8:6; D9:17" → ["D8:6", "D9:17"]
|
||||
// Example: "D9:1 D4:4 D4:6" → ["D9:1", "D4:4", "D4:6"]
|
||||
func SplitEvidenceIDs(evidence string) []string {
|
||||
if evidence == "" {
|
||||
return nil
|
||||
}
|
||||
// Split on semicolons first, then spaces
|
||||
parts := strings.Split(evidence, ";")
|
||||
var ids []string
|
||||
for _, part := range parts {
|
||||
for _, token := range strings.Fields(strings.TrimSpace(part)) {
|
||||
token = strings.TrimSpace(token)
|
||||
if diaIDRe.MatchString(token) {
|
||||
ids = append(ids, NormalizeDiaID(token))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// NormalizeDiaID strips leading zeros from the number parts of a dia_id.
|
||||
// "D30:05" → "D30:5", "D10:003" → "D10:3"
|
||||
func NormalizeDiaID(id string) string {
|
||||
m := diaIDRe.FindStringSubmatch(id)
|
||||
if m == nil {
|
||||
return id
|
||||
}
|
||||
session, _ := strconv.Atoi(m[1])
|
||||
turn, _ := strconv.Atoi(m[2])
|
||||
return fmt.Sprintf("D%d:%d", session, turn)
|
||||
}
|
||||
|
||||
// stopwords is a fixed English stopword list for deterministic keyword extraction.
|
||||
var stopwords = map[string]struct{}{
|
||||
"a": {}, "an": {}, "the": {},
|
||||
"is": {}, "are": {}, "was": {}, "were": {},
|
||||
"did": {}, "does": {}, "do": {},
|
||||
"when": {}, "where": {}, "what": {}, "who": {},
|
||||
"how": {}, "why": {},
|
||||
"to": {}, "of": {}, "in": {}, "on": {}, "at": {},
|
||||
"for": {}, "and": {}, "or": {}, "but": {}, "not": {},
|
||||
"it": {}, "this": {}, "that": {}, "with": {},
|
||||
"from": {}, "by": {}, "as": {},
|
||||
"if": {}, "then": {}, "than": {}, "so": {},
|
||||
"no": {}, "yes": {},
|
||||
"all": {}, "any": {}, "each": {}, "every": {},
|
||||
"some": {}, "such": {},
|
||||
"about": {}, "into": {}, "over": {},
|
||||
"after": {}, "before": {}, "between": {},
|
||||
"through": {}, "during": {}, "until": {},
|
||||
"would": {}, "could": {}, "should": {},
|
||||
"may": {}, "might": {}, "can": {},
|
||||
"will": {}, "shall": {}, "must": {},
|
||||
"have": {}, "has": {}, "had": {},
|
||||
"been": {}, "being": {}, "be": {},
|
||||
"go": {}, "went": {}, "gone": {},
|
||||
"i": {}, "you": {}, "me": {}, "my": {}, "your": {},
|
||||
"we": {}, "they": {}, "them": {}, "our": {},
|
||||
"its": {}, "their": {}, "he": {}, "she": {},
|
||||
"his": {}, "her": {},
|
||||
}
|
||||
|
||||
// ExtractKeywords removes stopwords and punctuation, returns individual keywords.
|
||||
// Deterministic: uses fixed stopword list, no LLM.
|
||||
func ExtractKeywords(question string) []string {
|
||||
// Lowercase and split on whitespace/punctuation
|
||||
lower := strings.ToLower(question)
|
||||
words := strings.FieldsFunc(lower, func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
|
||||
var keywords []string
|
||||
for _, w := range words {
|
||||
if w == "" || len(w) < 2 {
|
||||
continue
|
||||
}
|
||||
if _, ok := stopwords[w]; ok {
|
||||
continue
|
||||
}
|
||||
keywords = append(keywords, w)
|
||||
if len(keywords) >= 6 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return keywords
|
||||
}
|
||||
|
||||
// TokenOverlapF1 computes token-level F1 between prediction and reference.
|
||||
// Both strings are lowercased and split on whitespace.
|
||||
// NOTE: This metric underestimates quality for multi-hop (cat 2) and
|
||||
// open-ended (cat 3) questions where the gold answer uses different phrasing
|
||||
// than the source text. LLM-Judge scoring is a v2 follow-up.
|
||||
func TokenOverlapF1(prediction, reference string) float64 {
|
||||
predTokens := tokenize(prediction)
|
||||
refTokens := tokenize(reference)
|
||||
|
||||
if len(predTokens) == 0 && len(refTokens) == 0 {
|
||||
return 1.0
|
||||
}
|
||||
if len(predTokens) == 0 || len(refTokens) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// Count matches
|
||||
refCount := map[string]int{}
|
||||
for _, t := range refTokens {
|
||||
refCount[t]++
|
||||
}
|
||||
|
||||
predCount := map[string]int{}
|
||||
for _, t := range predTokens {
|
||||
predCount[t]++
|
||||
}
|
||||
|
||||
var matches float64
|
||||
for token, pc := range predCount {
|
||||
if rc, ok := refCount[token]; ok {
|
||||
matches += float64(min(pc, rc))
|
||||
}
|
||||
}
|
||||
|
||||
precision := matches / float64(len(predTokens))
|
||||
recall := matches / float64(len(refTokens))
|
||||
|
||||
if precision+recall == 0 {
|
||||
return 0.0
|
||||
}
|
||||
return 2 * precision * recall / (precision + recall)
|
||||
}
|
||||
|
||||
func tokenize(s string) []string {
|
||||
lower := strings.ToLower(s)
|
||||
return strings.Fields(lower)
|
||||
}
|
||||
|
||||
// RecallHitRate computes fraction of evidence IDs found in retrieved content.
|
||||
// For each evidence dia_id, looks up the turn text and checks substring match.
|
||||
// Logs a warning for turns with text < 20 chars (higher false-positive risk).
|
||||
func RecallHitRate(evidenceIDs []string, sample *LocomoSample, retrievedContent string) float64 {
|
||||
if len(evidenceIDs) == 0 {
|
||||
return 1.0 // no evidence required = perfect
|
||||
}
|
||||
|
||||
// Expand any multi-ID evidence entries (e.g. "D8:6; D9:17" or "D9:1 D4:4")
|
||||
var expanded []string
|
||||
for _, id := range evidenceIDs {
|
||||
split := SplitEvidenceIDs(id)
|
||||
if split != nil {
|
||||
expanded = append(expanded, split...)
|
||||
}
|
||||
}
|
||||
if len(expanded) == 0 {
|
||||
log.Printf("WARNING: no valid dia_ids after expanding evidence %v", evidenceIDs)
|
||||
return float64(0) / float64(len(evidenceIDs))
|
||||
}
|
||||
|
||||
// Build turn index once (avoids re-parsing JSON per ID)
|
||||
turns := GetTurns(sample)
|
||||
turnMap := make(map[string]*LocomoTurn, len(turns))
|
||||
for i := range turns {
|
||||
turnMap[turns[i].DiaID] = &turns[i]
|
||||
}
|
||||
|
||||
lowerRetrieved := strings.ToLower(retrievedContent)
|
||||
found := 0
|
||||
resolvable := 0
|
||||
for _, diaID := range expanded {
|
||||
turn, ok := turnMap[diaID]
|
||||
if !ok {
|
||||
log.Printf("WARNING: dia_id %q not found in sample %s", diaID, sample.SampleID)
|
||||
continue
|
||||
}
|
||||
resolvable++
|
||||
if len(turn.Text) < 20 {
|
||||
log.Printf("WARNING: short turn text (%d chars) for dia_id %s: %q",
|
||||
len(turn.Text), diaID, turn.Text)
|
||||
}
|
||||
if strings.Contains(lowerRetrieved, strings.ToLower(turn.Text)) {
|
||||
found++
|
||||
}
|
||||
}
|
||||
if resolvable == 0 {
|
||||
return 0.0 // no resolvable evidence = can't evaluate
|
||||
}
|
||||
return float64(found) / float64(resolvable)
|
||||
}
|
||||
|
||||
// BudgetTruncate truncates messages to fit within a token budget.
|
||||
// Returns the truncated messages and total token count.
|
||||
func BudgetTruncate(messages []string, budgetTokens int) ([]string, int) {
|
||||
var result []string
|
||||
total := 0
|
||||
// Walk from the front (best first) and keep until budget exhausted.
|
||||
for i := 0; i < len(messages); i++ {
|
||||
tokens := len(messages[i]) / 4
|
||||
if total+tokens > budgetTokens && len(result) > 0 {
|
||||
break
|
||||
}
|
||||
result = append(result, messages[i])
|
||||
total += tokens
|
||||
}
|
||||
return result, total
|
||||
}
|
||||
|
||||
// StringListToContent joins a list of strings into a single content string.
|
||||
func StringListToContent(parts []string) string {
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSplitEvidenceIDs(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{"D1:3", []string{"D1:3"}},
|
||||
{"D8:6; D9:17", []string{"D8:6", "D9:17"}},
|
||||
{"D9:1 D4:4 D4:6", []string{"D9:1", "D4:4", "D4:6"}},
|
||||
{"D22:1 D22:2 D9:10 D9:11", []string{"D22:1", "D22:2", "D9:10", "D9:11"}},
|
||||
{"D21:18 D21:22 D11:15 D11:19", []string{"D21:18", "D21:22", "D11:15", "D11:19"}},
|
||||
{"D30:05", []string{"D30:5"}},
|
||||
{"D", nil},
|
||||
{"D:", nil},
|
||||
{"", nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := SplitEvidenceIDs(tt.input)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("SplitEvidenceIDs(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("[%d] = %q, want %q", i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDiaID(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"D1:3", "D1:3"},
|
||||
{"D30:05", "D30:5"},
|
||||
{"D10:003", "D10:3"},
|
||||
{"D1:0", "D1:0"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := NormalizeDiaID(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("NormalizeDiaID(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenOverlapF1(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prediction string
|
||||
reference string
|
||||
want float64
|
||||
}{
|
||||
{"exact match", "hello world", "hello world", 1.0},
|
||||
{"no overlap", "foo bar", "baz qux", 0.0},
|
||||
{"empty both", "", "", 1.0},
|
||||
{"empty prediction", "", "hello", 0.0},
|
||||
{"empty reference", "hello", "", 0.0},
|
||||
{"partial overlap", "the cat sat on the mat", "the cat on the floor", 8.0 / 11.0},
|
||||
{"case insensitive", "Hello World", "hello world", 1.0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := TokenOverlapF1(tt.prediction, tt.reference)
|
||||
if math.Abs(got-tt.want) > 1e-9 {
|
||||
t.Errorf("TokenOverlapF1(%q, %q) = %.4f, want %.4f",
|
||||
tt.prediction, tt.reference, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBudgetTruncate(t *testing.T) {
|
||||
t.Run("within budget returns all", func(t *testing.T) {
|
||||
msgs := []string{"short", "message", "here"}
|
||||
result, total := BudgetTruncate(msgs, 1000)
|
||||
if len(result) != 3 {
|
||||
t.Errorf("expected 3 messages, got %d", len(result))
|
||||
}
|
||||
if total == 0 {
|
||||
t.Error("expected non-zero token count")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("over budget keeps best first", func(t *testing.T) {
|
||||
msgs := []string{
|
||||
"best message that is quite long and takes up tokens",
|
||||
"good message also fairly long content",
|
||||
"worst short",
|
||||
}
|
||||
result, _ := BudgetTruncate(msgs, 5) // very small budget
|
||||
if len(result) == 0 {
|
||||
t.Fatal("expected at least one message")
|
||||
}
|
||||
// Best-ranked (first) should be kept
|
||||
if result[0] != "best message that is quite long and takes up tokens" {
|
||||
t.Errorf("expected best message kept first, got %q", result[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("over budget keeps best ranked first", func(t *testing.T) {
|
||||
// Messages are sorted by bm25 rank ascending (best/most-negative first).
|
||||
// When budget is insufficient, BudgetTruncate must keep the front
|
||||
// (best-ranked) messages, not the tail (worst-ranked).
|
||||
msgs := []string{
|
||||
"best ranked message with some content here",
|
||||
"second best message also has content",
|
||||
"third message here too",
|
||||
"worst ranked short",
|
||||
}
|
||||
// Budget only fits ~1 message (~10 tokens per message, budget=12)
|
||||
result, _ := BudgetTruncate(msgs, 12)
|
||||
if len(result) == 0 {
|
||||
t.Fatal("expected at least one message")
|
||||
}
|
||||
if result[0] != "best ranked message with some content here" {
|
||||
t.Errorf("expected best-ranked (first) message kept, got %q", result[0])
|
||||
}
|
||||
// Worst-ranked (last) must NOT appear
|
||||
for _, m := range result {
|
||||
if m == "worst ranked short" {
|
||||
t.Error("worst-ranked message should have been truncated")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserves original order", func(t *testing.T) {
|
||||
msgs := []string{"alpha", "beta", "gamma"}
|
||||
result, _ := BudgetTruncate(msgs, 100)
|
||||
for i, got := range result {
|
||||
if got != msgs[i] {
|
||||
t.Errorf("result[%d] = %q, want %q", i, got, msgs[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty input", func(t *testing.T) {
|
||||
result, total := BudgetTruncate(nil, 100)
|
||||
if len(result) != 0 {
|
||||
t.Errorf("expected 0 messages, got %d", len(result))
|
||||
}
|
||||
if total != 0 {
|
||||
t.Errorf("expected 0 tokens, got %d", total)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecallHitRate(t *testing.T) {
|
||||
// Build a sample with known turns
|
||||
sample := &LocomoSample{
|
||||
SampleID: "test-sample",
|
||||
Conversation: map[string]json.RawMessage{
|
||||
"session_1": json.RawMessage(`[
|
||||
{"speaker":"A","dia_id":"D1:1","text":"hello world this is a test message with enough length"},
|
||||
{"speaker":"B","dia_id":"D1:2","text":"another message for testing recall computation purposes here"},
|
||||
{"speaker":"A","dia_id":"D1:3","text":"third turn with some more content to test"}
|
||||
]`),
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("all evidence found", func(t *testing.T) {
|
||||
retrieved := "hello world this is a test message with enough length another message for testing recall computation purposes here"
|
||||
got := RecallHitRate([]string{"D1:1", "D1:2"}, sample, retrieved)
|
||||
if math.Abs(got-1.0) > 1e-9 {
|
||||
t.Errorf("RecallHitRate all found = %.4f, want 1.0", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("partial evidence found", func(t *testing.T) {
|
||||
retrieved := "hello world this is a test message with enough length"
|
||||
got := RecallHitRate([]string{"D1:1", "D1:2"}, sample, retrieved)
|
||||
if math.Abs(got-0.5) > 1e-9 {
|
||||
t.Errorf("RecallHitRate partial = %.4f, want 0.5", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no evidence required", func(t *testing.T) {
|
||||
got := RecallHitRate(nil, sample, "anything")
|
||||
if got != 1.0 {
|
||||
t.Errorf("RecallHitRate no evidence = %.4f, want 1.0", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing turn excluded from denominator", func(t *testing.T) {
|
||||
// D1:1 is found, D99:1 does not exist in sample
|
||||
// Should only count resolvable turns in denominator
|
||||
retrieved := "hello world this is a test message with enough length"
|
||||
got := RecallHitRate([]string{"D1:1", "D99:1"}, sample, retrieved)
|
||||
if math.Abs(got-1.0) > 1e-9 {
|
||||
t.Errorf("RecallHitRate missing turn = %.4f, want 1.0 (unresolvable excluded)", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractKeywords(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{"simple", "What is the capital of France", []string{"capital", "france"}},
|
||||
{
|
||||
"stops removed",
|
||||
"Who is the president of the United States",
|
||||
[]string{"president", "united", "states"},
|
||||
},
|
||||
{
|
||||
"max 6 keywords",
|
||||
"one two three four five six seven eight nine ten",
|
||||
[]string{"one", "two", "three", "four", "five", "six"},
|
||||
},
|
||||
{"short words filtered", "I am a go to the store", []string{"am", "store"}},
|
||||
{"empty", "", nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ExtractKeywords(tt.input)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("ExtractKeywords(%q) = %v (len %d), want %v (len %d)",
|
||||
tt.input, got, len(got), tt.want, len(tt.want))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("[%d] = %q, want %q", i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// 仅在 /etc/resolv.conf 不存在时才覆盖(即 Android 环境)
|
||||
if _, err := os.Stat("/etc/resolv.conf"); err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 从环境变量获取 DNS server 列表,多个用 ; 隔开
|
||||
// 例如: PICOCLAW_DNS_SERVER="8.8.8.8:53;1.1.1.1:53;223.5.5.5:53"
|
||||
dnsEnv := os.Getenv("PICOCLAW_DNS_SERVER")
|
||||
if dnsEnv == "" {
|
||||
dnsEnv = "8.8.8.8:53;1.1.1.1:53"
|
||||
}
|
||||
|
||||
var dnsServers []string
|
||||
for _, s := range strings.Split(dnsEnv, ";") {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
// 如果没有带端口号,自动补上 :53
|
||||
if _, _, err := net.SplitHostPort(s); err != nil {
|
||||
s = s + ":53"
|
||||
}
|
||||
dnsServers = append(dnsServers, s)
|
||||
}
|
||||
}
|
||||
|
||||
// 轮询索引,在多个 DNS 服务器之间轮转
|
||||
var idx uint64
|
||||
|
||||
customResolver := &net.Resolver{
|
||||
PreferGo: true,
|
||||
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
d := net.Dialer{Timeout: 5 * time.Second}
|
||||
// Round-robin: 依次尝试不同的 DNS 服务器
|
||||
server := dnsServers[atomic.AddUint64(&idx, 1)%uint64(len(dnsServers))]
|
||||
return d.DialContext(ctx, "udp", server)
|
||||
},
|
||||
}
|
||||
|
||||
// 覆盖全局 DefaultResolver
|
||||
net.DefaultResolver = customResolver
|
||||
|
||||
// 覆盖 http.DefaultTransport 使用自定义 DNS 解析的 DialContext
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
Resolver: customResolver,
|
||||
}
|
||||
|
||||
if tr, ok := http.DefaultTransport.(*http.Transport); ok {
|
||||
tr.DialContext = dialer.DialContext
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewAgentCommand() *cobra.Command {
|
||||
var (
|
||||
message string
|
||||
sessionKey string
|
||||
model string
|
||||
debug bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "agent",
|
||||
Short: "Interact with the agent directly",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return agentCmd(message, sessionKey, model, debug)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
||||
cmd.Flags().StringVarP(&message, "message", "m", "", "Send a single message (non-interactive mode)")
|
||||
cmd.Flags().StringVarP(&sessionKey, "session", "s", "cli:default", "Session key")
|
||||
cmd.Flags().StringVarP(&model, "model", "", "", "Model to use")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewAgentCommand(t *testing.T) {
|
||||
cmd := NewAgentCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "agent", cmd.Use)
|
||||
assert.Equal(t, "Interact with the agent directly", cmd.Short)
|
||||
|
||||
assert.Len(t, cmd.Aliases, 0)
|
||||
assert.False(t, cmd.HasSubCommands())
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
|
||||
assert.Nil(t, cmd.PersistentPreRun)
|
||||
assert.Nil(t, cmd.PersistentPostRun)
|
||||
|
||||
assert.True(t, cmd.HasFlags())
|
||||
|
||||
assert.NotNil(t, cmd.Flags().Lookup("debug"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("message"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("session"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("model"))
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/ergochat/readline"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/agent"
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
func agentCmd(message, sessionKey, model string, debug bool) error {
|
||||
if sessionKey == "" {
|
||||
sessionKey = "cli:default"
|
||||
}
|
||||
|
||||
cfg, err := internal.LoadConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading config: %w", err)
|
||||
}
|
||||
|
||||
logger.ConfigureFromEnv()
|
||||
|
||||
if debug {
|
||||
logger.SetLevel(logger.DEBUG)
|
||||
fmt.Println("🔍 Debug mode enabled")
|
||||
}
|
||||
|
||||
if model != "" {
|
||||
cfg.Agents.Defaults.ModelName = model
|
||||
}
|
||||
|
||||
provider, modelID, err := providers.CreateProvider(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating provider: %w", err)
|
||||
}
|
||||
|
||||
// Use the resolved model ID from provider creation
|
||||
if modelID != "" {
|
||||
cfg.Agents.Defaults.ModelName = modelID
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
defer msgBus.Close()
|
||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
||||
defer agentLoop.Close()
|
||||
|
||||
// Print agent startup info (only for interactive mode)
|
||||
startupInfo := agentLoop.GetStartupInfo()
|
||||
logger.InfoCF("agent", "Agent initialized",
|
||||
map[string]any{
|
||||
"tools_count": startupInfo["tools"].(map[string]any)["count"],
|
||||
"skills_total": startupInfo["skills"].(map[string]any)["total"],
|
||||
"skills_available": startupInfo["skills"].(map[string]any)["available"],
|
||||
})
|
||||
|
||||
if message != "" {
|
||||
ctx := context.Background()
|
||||
response, err := agentLoop.ProcessDirect(ctx, message, sessionKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error processing message: %w", err)
|
||||
}
|
||||
fmt.Printf("\n%s %s\n", internal.Logo, response)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", internal.Logo)
|
||||
interactiveMode(agentLoop, sessionKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||
prompt := fmt.Sprintf("%s You: ", internal.Logo)
|
||||
|
||||
rl, err := readline.NewEx(&readline.Config{
|
||||
Prompt: prompt,
|
||||
HistoryFile: filepath.Join(os.TempDir(), ".picoclaw_history"),
|
||||
HistoryLimit: 100,
|
||||
InterruptPrompt: "^C",
|
||||
EOFPrompt: "exit",
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("Error initializing readline: %v\n", err)
|
||||
fmt.Println("Falling back to simple input mode...")
|
||||
simpleInteractiveMode(agentLoop, sessionKey)
|
||||
return
|
||||
}
|
||||
defer rl.Close()
|
||||
|
||||
for {
|
||||
line, err := rl.Readline()
|
||||
if err != nil {
|
||||
if err == readline.ErrInterrupt || err == io.EOF {
|
||||
fmt.Println("\nGoodbye!")
|
||||
return
|
||||
}
|
||||
fmt.Printf("Error reading input: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
input := strings.TrimSpace(line)
|
||||
if input == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if input == "exit" || input == "quit" {
|
||||
fmt.Println("Goodbye!")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s %s\n\n", internal.Logo, response)
|
||||
}
|
||||
}
|
||||
|
||||
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
for {
|
||||
fmt.Print(fmt.Sprintf("%s You: ", internal.Logo))
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
fmt.Println("\nGoodbye!")
|
||||
return
|
||||
}
|
||||
fmt.Printf("Error reading input: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
input := strings.TrimSpace(line)
|
||||
if input == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if input == "exit" || input == "quit" {
|
||||
fmt.Println("Goodbye!")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s %s\n\n", internal.Logo, response)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package auth
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func NewAuthCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Manage authentication (login, logout, status)",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
newLoginCommand(),
|
||||
newLogoutCommand(),
|
||||
newStatusCommand(),
|
||||
newModelsCommand(),
|
||||
newWeixinCommand(),
|
||||
newWeComCommand(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewAuthCommand(t *testing.T) {
|
||||
cmd := NewAuthCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "auth", cmd.Use)
|
||||
assert.Equal(t, "Manage authentication (login, logout, status)", cmd.Short)
|
||||
|
||||
assert.Len(t, cmd.Aliases, 0)
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
|
||||
assert.Nil(t, cmd.PersistentPreRun)
|
||||
assert.Nil(t, cmd.PersistentPostRun)
|
||||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
assert.True(t, cmd.HasSubCommands())
|
||||
|
||||
allowedCommands := []string{
|
||||
"login",
|
||||
"logout",
|
||||
"status",
|
||||
"models",
|
||||
"weixin",
|
||||
"wecom",
|
||||
}
|
||||
|
||||
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.Len(t, subcmd.Aliases, 0)
|
||||
assert.False(t, subcmd.Hidden)
|
||||
|
||||
assert.False(t, subcmd.HasSubCommands())
|
||||
|
||||
assert.Nil(t, subcmd.Run)
|
||||
assert.NotNil(t, subcmd.RunE)
|
||||
|
||||
assert.Nil(t, subcmd.PersistentPreRun)
|
||||
assert.Nil(t, subcmd.PersistentPostRun)
|
||||
}
|
||||
}
|
||||
@@ -1,503 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
const (
|
||||
supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity, antigravity"
|
||||
defaultAnthropicModel = "claude-sonnet-4.6"
|
||||
)
|
||||
|
||||
func authLoginCmd(provider string, useDeviceCode bool, useOauth bool, noBrowser bool) error {
|
||||
switch provider {
|
||||
case "openai":
|
||||
return authLoginOpenAI(useDeviceCode, noBrowser)
|
||||
case "anthropic":
|
||||
return authLoginAnthropic(useOauth)
|
||||
case "google-antigravity", "antigravity":
|
||||
return authLoginGoogleAntigravity(noBrowser)
|
||||
default:
|
||||
return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func authLoginOpenAI(useDeviceCode bool, noBrowser bool) error {
|
||||
cfg := auth.OpenAIOAuthConfig()
|
||||
|
||||
var cred *auth.AuthCredential
|
||||
var err error
|
||||
|
||||
if useDeviceCode {
|
||||
cred, err = auth.LoginDeviceCode(cfg)
|
||||
} else {
|
||||
cred, err = auth.LoginBrowserWithOptions(cfg, auth.LoginBrowserOptions{NoBrowser: noBrowser})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("login failed: %w", err)
|
||||
}
|
||||
|
||||
if err = auth.SetCredential("openai", cred); err != nil {
|
||||
return fmt.Errorf("failed to save credentials: %w", err)
|
||||
}
|
||||
|
||||
appCfg, err := internal.LoadConfig()
|
||||
if err == nil {
|
||||
// Update or add openai in ModelList
|
||||
foundOpenAI := false
|
||||
for i := range appCfg.ModelList {
|
||||
if isOpenAIModel(appCfg.ModelList[i]) {
|
||||
appCfg.ModelList[i].AuthMethod = "oauth"
|
||||
foundOpenAI = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If no openai in ModelList, add it
|
||||
if !foundOpenAI {
|
||||
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||
ModelName: "gpt-5.4",
|
||||
Model: "openai/gpt-5.4",
|
||||
AuthMethod: "oauth",
|
||||
})
|
||||
}
|
||||
|
||||
// Update default model to use OpenAI
|
||||
appCfg.Agents.Defaults.ModelName = "gpt-5.4"
|
||||
|
||||
if err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||
return fmt.Errorf("could not update config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Login successful!")
|
||||
if cred.AccountID != "" {
|
||||
fmt.Printf("Account: %s\n", cred.AccountID)
|
||||
}
|
||||
fmt.Println("Default model set to: gpt-5.4")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func authLoginGoogleAntigravity(noBrowser bool) error {
|
||||
cfg := auth.GoogleAntigravityOAuthConfig()
|
||||
|
||||
cred, err := auth.LoginBrowserWithOptions(cfg, auth.LoginBrowserOptions{NoBrowser: noBrowser})
|
||||
if err != nil {
|
||||
return fmt.Errorf("login failed: %w", err)
|
||||
}
|
||||
|
||||
cred.Provider = "google-antigravity"
|
||||
|
||||
// Fetch user email from Google userinfo
|
||||
email, err := fetchGoogleUserEmail(cred.AccessToken)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: could not fetch email: %v\n", err)
|
||||
} else {
|
||||
cred.Email = email
|
||||
fmt.Printf("Email: %s\n", email)
|
||||
}
|
||||
|
||||
// Fetch Cloud Code Assist project ID
|
||||
projectID, err := providers.FetchAntigravityProjectID(cred.AccessToken)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: could not fetch project ID: %v\n", err)
|
||||
fmt.Println("You may need Google Cloud Code Assist enabled on your account.")
|
||||
} else {
|
||||
cred.ProjectID = projectID
|
||||
fmt.Printf("Project: %s\n", projectID)
|
||||
}
|
||||
|
||||
if err = auth.SetCredential("google-antigravity", cred); err != nil {
|
||||
return fmt.Errorf("failed to save credentials: %w", err)
|
||||
}
|
||||
|
||||
appCfg, err := internal.LoadConfig()
|
||||
if err == nil {
|
||||
// Update or add antigravity in ModelList
|
||||
foundAntigravity := false
|
||||
for i := range appCfg.ModelList {
|
||||
if isAntigravityModel(appCfg.ModelList[i]) {
|
||||
appCfg.ModelList[i].AuthMethod = "oauth"
|
||||
foundAntigravity = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If no antigravity in ModelList, add it
|
||||
if !foundAntigravity {
|
||||
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||
ModelName: "gemini-flash",
|
||||
Model: "antigravity/gemini-3-flash",
|
||||
AuthMethod: "oauth",
|
||||
})
|
||||
}
|
||||
|
||||
// Update default model
|
||||
appCfg.Agents.Defaults.ModelName = "gemini-flash"
|
||||
|
||||
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||
fmt.Printf("Warning: could not update config: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n✓ Google Antigravity login successful!")
|
||||
fmt.Println("Default model set to: gemini-flash")
|
||||
fmt.Println("Try it: picoclaw agent -m \"Hello world\"")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func authLoginAnthropic(useOauth bool) error {
|
||||
if useOauth {
|
||||
return authLoginAnthropicSetupToken()
|
||||
}
|
||||
|
||||
fmt.Println("Anthropic login method:")
|
||||
fmt.Println(" 1) Setup token (from `claude setup-token`) (Recommended)")
|
||||
fmt.Println(" 2) API key (from console.anthropic.com)")
|
||||
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
for {
|
||||
fmt.Print("Choose [1]: ")
|
||||
choice := "1"
|
||||
if scanner.Scan() {
|
||||
text := strings.TrimSpace(scanner.Text())
|
||||
if text != "" {
|
||||
choice = text
|
||||
}
|
||||
}
|
||||
|
||||
switch choice {
|
||||
case "1":
|
||||
return authLoginAnthropicSetupToken()
|
||||
case "2":
|
||||
return authLoginPasteToken("anthropic")
|
||||
default:
|
||||
fmt.Printf("Invalid choice: %s. Please enter 1 or 2.\n", choice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func authLoginAnthropicSetupToken() error {
|
||||
cred, err := auth.LoginSetupToken(os.Stdin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("login failed: %w", err)
|
||||
}
|
||||
|
||||
if err = auth.SetCredential("anthropic", cred); err != nil {
|
||||
return fmt.Errorf("failed to save credentials: %w", err)
|
||||
}
|
||||
|
||||
appCfg, err := internal.LoadConfig()
|
||||
if err == nil {
|
||||
found := false
|
||||
for i := range appCfg.ModelList {
|
||||
if isAnthropicModel(appCfg.ModelList[i]) {
|
||||
appCfg.ModelList[i].AuthMethod = "oauth"
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||
ModelName: defaultAnthropicModel,
|
||||
Model: "anthropic/" + defaultAnthropicModel,
|
||||
AuthMethod: "oauth",
|
||||
})
|
||||
// Only set default model if user has no default configured yet
|
||||
if appCfg.Agents.Defaults.GetModelName() == "" {
|
||||
appCfg.Agents.Defaults.ModelName = defaultAnthropicModel
|
||||
}
|
||||
}
|
||||
|
||||
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||
return fmt.Errorf("could not update config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Setup token saved for Anthropic!")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading userinfo response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
||||
}
|
||||
|
||||
var userInfo struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &userInfo); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return userInfo.Email, nil
|
||||
}
|
||||
|
||||
func authLoginPasteToken(provider string) error {
|
||||
cred, err := auth.LoginPasteToken(provider, os.Stdin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("login failed: %w", err)
|
||||
}
|
||||
|
||||
if err = auth.SetCredential(provider, cred); err != nil {
|
||||
return fmt.Errorf("failed to save credentials: %w", err)
|
||||
}
|
||||
|
||||
appCfg, err := internal.LoadConfig()
|
||||
if err == nil {
|
||||
switch provider {
|
||||
case "anthropic":
|
||||
// Update ModelList
|
||||
found := false
|
||||
for i := range appCfg.ModelList {
|
||||
if isAnthropicModel(appCfg.ModelList[i]) {
|
||||
appCfg.ModelList[i].AuthMethod = "token"
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||
ModelName: defaultAnthropicModel,
|
||||
Model: "anthropic/" + defaultAnthropicModel,
|
||||
AuthMethod: "token",
|
||||
})
|
||||
appCfg.Agents.Defaults.ModelName = defaultAnthropicModel
|
||||
}
|
||||
case "openai":
|
||||
// Update ModelList
|
||||
found := false
|
||||
for i := range appCfg.ModelList {
|
||||
if isOpenAIModel(appCfg.ModelList[i]) {
|
||||
appCfg.ModelList[i].AuthMethod = "token"
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
|
||||
ModelName: "gpt-5.4",
|
||||
Model: "openai/gpt-5.4",
|
||||
AuthMethod: "token",
|
||||
})
|
||||
}
|
||||
// Update default model
|
||||
appCfg.Agents.Defaults.ModelName = "gpt-5.4"
|
||||
}
|
||||
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||
return fmt.Errorf("could not update config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Token saved for %s!\n", provider)
|
||||
|
||||
if appCfg != nil {
|
||||
fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.GetModelName())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func authLogoutCmd(provider string) error {
|
||||
if provider != "" {
|
||||
if err := auth.DeleteCredential(provider); err != nil {
|
||||
return fmt.Errorf("failed to remove credentials: %w", err)
|
||||
}
|
||||
|
||||
appCfg, err := internal.LoadConfig()
|
||||
if err == nil {
|
||||
// Clear AuthMethod in ModelList
|
||||
for i := range appCfg.ModelList {
|
||||
switch provider {
|
||||
case "openai":
|
||||
if isOpenAIModel(appCfg.ModelList[i]) {
|
||||
appCfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
case "anthropic":
|
||||
if isAnthropicModel(appCfg.ModelList[i]) {
|
||||
appCfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
case "google-antigravity", "antigravity":
|
||||
if isAntigravityModel(appCfg.ModelList[i]) {
|
||||
appCfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
config.SaveConfig(internal.GetConfigPath(), appCfg)
|
||||
}
|
||||
|
||||
fmt.Printf("Logged out from %s\n", provider)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := auth.DeleteAllCredentials(); err != nil {
|
||||
return fmt.Errorf("failed to remove credentials: %w", err)
|
||||
}
|
||||
|
||||
appCfg, err := internal.LoadConfig()
|
||||
if err == nil {
|
||||
// Clear all AuthMethods in ModelList
|
||||
for i := range appCfg.ModelList {
|
||||
appCfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
config.SaveConfig(internal.GetConfigPath(), appCfg)
|
||||
}
|
||||
|
||||
fmt.Println("Logged out from all providers")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func authStatusCmd() error {
|
||||
store, err := auth.LoadStore()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load auth store: %w", err)
|
||||
}
|
||||
|
||||
if len(store.Credentials) == 0 {
|
||||
fmt.Println("No authenticated providers.")
|
||||
fmt.Println("Run: picoclaw auth login --provider <name>")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("\nAuthenticated Providers:")
|
||||
fmt.Println("------------------------")
|
||||
for provider, cred := range store.Credentials {
|
||||
status := "active"
|
||||
if cred.IsExpired() {
|
||||
status = "expired"
|
||||
} else if cred.NeedsRefresh() {
|
||||
status = "needs refresh"
|
||||
}
|
||||
|
||||
fmt.Printf(" %s:\n", provider)
|
||||
fmt.Printf(" Method: %s\n", cred.AuthMethod)
|
||||
fmt.Printf(" Status: %s\n", status)
|
||||
if cred.AccountID != "" {
|
||||
fmt.Printf(" Account: %s\n", cred.AccountID)
|
||||
}
|
||||
if cred.Email != "" {
|
||||
fmt.Printf(" Email: %s\n", cred.Email)
|
||||
}
|
||||
if cred.ProjectID != "" {
|
||||
fmt.Printf(" Project: %s\n", cred.ProjectID)
|
||||
}
|
||||
if !cred.ExpiresAt.IsZero() {
|
||||
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
|
||||
}
|
||||
|
||||
if provider == "anthropic" && cred.AuthMethod == "oauth" {
|
||||
usage, err := auth.FetchAnthropicUsage(cred.AccessToken)
|
||||
if err != nil {
|
||||
fmt.Printf(" Usage: unavailable (%v)\n", err)
|
||||
} else {
|
||||
fmt.Printf(" Usage (5h): %.1f%%\n", usage.FiveHourUtilization*100)
|
||||
fmt.Printf(" Usage (7d): %.1f%%\n", usage.SevenDayUtilization*100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func authModelsCmd() error {
|
||||
cred, err := auth.GetCredential("google-antigravity")
|
||||
if err != nil || cred == nil {
|
||||
return fmt.Errorf(
|
||||
"not logged in to Google Antigravity.\nrun: picoclaw auth login --provider google-antigravity",
|
||||
)
|
||||
}
|
||||
|
||||
// Refresh token if needed
|
||||
if cred.NeedsRefresh() && cred.RefreshToken != "" {
|
||||
oauthCfg := auth.GoogleAntigravityOAuthConfig()
|
||||
refreshed, refreshErr := auth.RefreshAccessToken(cred, oauthCfg)
|
||||
if refreshErr == nil {
|
||||
cred = refreshed
|
||||
_ = auth.SetCredential("google-antigravity", cred)
|
||||
}
|
||||
}
|
||||
|
||||
projectID := cred.ProjectID
|
||||
if projectID == "" {
|
||||
return fmt.Errorf("no project id stored. Try logging in again")
|
||||
}
|
||||
|
||||
fmt.Printf("Fetching models for project: %s\n\n", projectID)
|
||||
|
||||
models, err := providers.FetchAntigravityModels(cred.AccessToken, projectID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error fetching models: %w", err)
|
||||
}
|
||||
|
||||
if len(models) == 0 {
|
||||
return fmt.Errorf("no models available")
|
||||
}
|
||||
|
||||
fmt.Println("Available Antigravity Models:")
|
||||
fmt.Println("-----------------------------")
|
||||
for _, m := range models {
|
||||
status := "✓"
|
||||
if m.IsExhausted {
|
||||
status = "✗ (quota exhausted)"
|
||||
}
|
||||
name := m.ID
|
||||
if m.DisplayName != "" {
|
||||
name = fmt.Sprintf("%s (%s)", m.ID, m.DisplayName)
|
||||
}
|
||||
fmt.Printf(" %s %s\n", status, name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 config belongs to the OpenAI provider.
|
||||
func isOpenAIModel(modelCfg *config.ModelConfig) bool {
|
||||
protocol, _ := providers.ExtractProtocol(modelCfg)
|
||||
return protocol == "openai"
|
||||
}
|
||||
|
||||
// isAnthropicModel checks if a model config belongs to the Anthropic provider.
|
||||
func isAnthropicModel(modelCfg *config.ModelConfig) bool {
|
||||
protocol, _ := providers.ExtractProtocol(modelCfg)
|
||||
return protocol == "anthropic"
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package auth
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newLoginCommand() *cobra.Command {
|
||||
var (
|
||||
provider string
|
||||
useDeviceCode bool
|
||||
useOauth bool
|
||||
noBrowser bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "Login via OAuth or paste token",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return authLoginCmd(provider, useDeviceCode, useOauth, noBrowser)
|
||||
},
|
||||
}
|
||||
|
||||
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`)",
|
||||
)
|
||||
_ = cmd.MarkFlagRequired("provider")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewLoginSubCommand(t *testing.T) {
|
||||
cmd := newLoginCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "Login via OAuth or paste token", cmd.Short)
|
||||
|
||||
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)
|
||||
|
||||
val, found := providerFlag.Annotations[cobra.BashCompOneRequiredFlag]
|
||||
require.True(t, found)
|
||||
require.NotEmpty(t, val)
|
||||
assert.Equal(t, "true", val[0])
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package auth
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newLogoutCommand() *cobra.Command {
|
||||
var provider string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "logout",
|
||||
Short: "Remove stored credentials",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return authLogoutCmd(provider)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to logout from (openai, anthropic); empty = all")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewLogoutSubcommand(t *testing.T) {
|
||||
cmd := newLogoutCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "Remove stored credentials", cmd.Short)
|
||||
|
||||
assert.True(t, cmd.HasFlags())
|
||||
|
||||
assert.NotNil(t, cmd.Flags().Lookup("provider"))
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package auth
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newModelsCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "models",
|
||||
Short: "Show available models",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return authModelsCmd()
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewModelsCommand(t *testing.T) {
|
||||
cmd := newModelsCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "models", cmd.Use)
|
||||
assert.Equal(t, "Show available models", cmd.Short)
|
||||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package auth
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newStatusCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show current auth status",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return authStatusCmd()
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
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()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "Show current auth status", cmd.Short)
|
||||
|
||||
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"))
|
||||
}
|
||||
@@ -1,428 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mdp/qrterminal/v3"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
wecomQRSourceID = "picoclaw"
|
||||
wecomQRGenerateEndpoint = "https://work.weixin.qq.com/ai/qc/generate"
|
||||
wecomQRQueryEndpoint = "https://work.weixin.qq.com/ai/qc/query_result"
|
||||
wecomQRPageEndpoint = "https://work.weixin.qq.com/ai/qc/gen"
|
||||
wecomQRHTTPTimeout = 15 * time.Second
|
||||
wecomQRPollInterval = 3 * time.Second
|
||||
wecomQRPollTimeout = 5 * time.Minute
|
||||
wecomDefaultWebSocketURL = "wss://openws.work.weixin.qq.com"
|
||||
)
|
||||
|
||||
type wecomQRScanner func(context.Context, wecomQRFlowOptions) (wecomQRBotInfo, error)
|
||||
|
||||
type wecomQRFlowOptions struct {
|
||||
HTTPClient *http.Client
|
||||
GenerateURL string
|
||||
QueryURL string
|
||||
QRCodePageURL string
|
||||
SourceID string
|
||||
PollInterval time.Duration
|
||||
PollTimeout time.Duration
|
||||
Writer io.Writer
|
||||
}
|
||||
|
||||
type wecomQRBotInfo struct {
|
||||
BotID string
|
||||
Secret string
|
||||
}
|
||||
|
||||
type wecomQRSession struct {
|
||||
SCode string
|
||||
AuthURL string
|
||||
}
|
||||
|
||||
type wecomQRGenerateResponse struct {
|
||||
ErrCode int `json:"errcode,omitempty"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
Data struct {
|
||||
SCode string `json:"scode"`
|
||||
AuthURL string `json:"auth_url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type wecomQRQueryResponse struct {
|
||||
ErrCode int `json:"errcode,omitempty"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
BotInfo struct {
|
||||
BotID string `json:"botid"`
|
||||
Secret string `json:"secret"`
|
||||
} `json:"bot_info"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func newWeComCommand() *cobra.Command {
|
||||
var timeout time.Duration
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "wecom",
|
||||
Short: "Scan a WeCom QR code and configure channels.wecom",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return authWeComCmd(timeout)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().DurationVar(&timeout, "timeout", wecomQRPollTimeout, "How long to wait for QR confirmation")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func authWeComCmd(timeout time.Duration) error {
|
||||
return authWeComCmdWithScanner(context.Background(), os.Stdout, timeout, scanWeComQRCodeInteractive)
|
||||
}
|
||||
|
||||
func authWeComCmdWithScanner(
|
||||
ctx context.Context,
|
||||
writer io.Writer,
|
||||
timeout time.Duration,
|
||||
scanner wecomQRScanner,
|
||||
) error {
|
||||
if scanner == nil {
|
||||
return fmt.Errorf("wecom QR scanner is nil")
|
||||
}
|
||||
if writer == nil {
|
||||
writer = os.Stdout
|
||||
}
|
||||
|
||||
cfg, err := internal.LoadConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
opts := defaultWeComQRFlowOptions(timeout)
|
||||
opts.Writer = writer
|
||||
|
||||
botInfo, err := scanner(ctx, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
applyWeComAuthResult(cfg, botInfo)
|
||||
|
||||
if saveErr := config.SaveConfig(internal.GetConfigPath(), cfg); saveErr != nil {
|
||||
return fmt.Errorf("failed to save config: %w", saveErr)
|
||||
}
|
||||
|
||||
fmt.Fprintln(writer)
|
||||
fmt.Fprintln(writer, "WeCom connected.")
|
||||
fmt.Fprintf(writer, "Bot ID: %s\n", botInfo.BotID)
|
||||
fmt.Fprintf(writer, "Config: %s\n", internal.GetConfigPath())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultWeComQRFlowOptions(timeout time.Duration) wecomQRFlowOptions {
|
||||
if timeout <= 0 {
|
||||
timeout = wecomQRPollTimeout
|
||||
}
|
||||
|
||||
return wecomQRFlowOptions{
|
||||
HTTPClient: &http.Client{Timeout: wecomQRHTTPTimeout},
|
||||
GenerateURL: wecomQRGenerateEndpoint,
|
||||
QueryURL: wecomQRQueryEndpoint,
|
||||
QRCodePageURL: wecomQRPageEndpoint,
|
||||
SourceID: wecomQRSourceID,
|
||||
PollInterval: wecomQRPollInterval,
|
||||
PollTimeout: timeout,
|
||||
Writer: os.Stdout,
|
||||
}
|
||||
}
|
||||
|
||||
func applyWeComAuthResult(cfg *config.Config, botInfo wecomQRBotInfo) {
|
||||
bc := cfg.Channels.GetByType(config.ChannelWeCom)
|
||||
if bc == nil {
|
||||
bc = &config.Channel{Type: config.ChannelWeCom}
|
||||
cfg.Channels["wecom"] = bc
|
||||
}
|
||||
bc.Enabled = true
|
||||
|
||||
decoded, err := bc.GetDecoded()
|
||||
if err != nil {
|
||||
logger.ErrorCF("wecom", "failed to decode WeCom settings", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
wecomCfg, ok := decoded.(*config.WeComSettings)
|
||||
if !ok {
|
||||
logger.ErrorCF("wecom", "unexpected WeCom settings type", map[string]any{
|
||||
"got": fmt.Sprintf("%T", decoded),
|
||||
})
|
||||
return
|
||||
}
|
||||
wecomCfg.BotID = botInfo.BotID
|
||||
wecomCfg.Secret = *config.NewSecureString(botInfo.Secret)
|
||||
if strings.TrimSpace(wecomCfg.WebSocketURL) == "" {
|
||||
wecomCfg.WebSocketURL = wecomDefaultWebSocketURL
|
||||
}
|
||||
}
|
||||
|
||||
func scanWeComQRCodeInteractive(ctx context.Context, opts wecomQRFlowOptions) (wecomQRBotInfo, error) {
|
||||
opts = normalizeWeComQRFlowOptions(opts)
|
||||
|
||||
fmt.Fprintln(opts.Writer, "Requesting WeCom QR code...")
|
||||
|
||||
session, err := fetchWeComQRCode(ctx, opts)
|
||||
if err != nil {
|
||||
return wecomQRBotInfo{}, err
|
||||
}
|
||||
|
||||
fmt.Fprintln(opts.Writer)
|
||||
fmt.Fprintln(opts.Writer, "=======================================================")
|
||||
fmt.Fprintln(opts.Writer, "Please scan the following QR code with WeCom:")
|
||||
fmt.Fprintln(opts.Writer, "=======================================================")
|
||||
fmt.Fprintln(opts.Writer)
|
||||
|
||||
qrterminal.GenerateWithConfig(session.AuthURL, qrterminal.Config{
|
||||
Level: qrterminal.L,
|
||||
Writer: opts.Writer,
|
||||
HalfBlocks: true,
|
||||
})
|
||||
|
||||
pageURL, err := buildWeComQRCodePageURL(opts.QRCodePageURL, opts.SourceID, session.SCode)
|
||||
if err != nil {
|
||||
return wecomQRBotInfo{}, err
|
||||
}
|
||||
|
||||
fmt.Fprintln(opts.Writer)
|
||||
fmt.Fprintf(opts.Writer, "QR Code Link: %s\n", pageURL)
|
||||
fmt.Fprintln(opts.Writer)
|
||||
fmt.Fprintln(opts.Writer, "Waiting for scan...")
|
||||
|
||||
return pollWeComQRCodeResult(ctx, opts, session.SCode)
|
||||
}
|
||||
|
||||
func normalizeWeComQRFlowOptions(opts wecomQRFlowOptions) wecomQRFlowOptions {
|
||||
if opts.HTTPClient == nil {
|
||||
opts.HTTPClient = &http.Client{Timeout: wecomQRHTTPTimeout}
|
||||
}
|
||||
if strings.TrimSpace(opts.GenerateURL) == "" {
|
||||
opts.GenerateURL = wecomQRGenerateEndpoint
|
||||
}
|
||||
if strings.TrimSpace(opts.QueryURL) == "" {
|
||||
opts.QueryURL = wecomQRQueryEndpoint
|
||||
}
|
||||
if strings.TrimSpace(opts.QRCodePageURL) == "" {
|
||||
opts.QRCodePageURL = wecomQRPageEndpoint
|
||||
}
|
||||
if strings.TrimSpace(opts.SourceID) == "" {
|
||||
opts.SourceID = wecomQRSourceID
|
||||
}
|
||||
if opts.PollInterval <= 0 {
|
||||
opts.PollInterval = wecomQRPollInterval
|
||||
}
|
||||
if opts.PollTimeout <= 0 {
|
||||
opts.PollTimeout = wecomQRPollTimeout
|
||||
}
|
||||
if opts.Writer == nil {
|
||||
opts.Writer = os.Stdout
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
func fetchWeComQRCode(ctx context.Context, opts wecomQRFlowOptions) (wecomQRSession, error) {
|
||||
generateURL, err := buildWeComQRGenerateURL(opts.GenerateURL, opts.SourceID, wecomPlatformCode())
|
||||
if err != nil {
|
||||
return wecomQRSession{}, err
|
||||
}
|
||||
|
||||
var resp wecomQRGenerateResponse
|
||||
if err := doWeComJSONGet(ctx, opts.HTTPClient, generateURL, &resp); err != nil {
|
||||
return wecomQRSession{}, fmt.Errorf("failed to get WeCom QR code: %w", err)
|
||||
}
|
||||
if resp.ErrCode != 0 {
|
||||
return wecomQRSession{}, fmt.Errorf(
|
||||
"failed to get WeCom QR code: errcode=%d errmsg=%s",
|
||||
resp.ErrCode,
|
||||
resp.ErrMsg,
|
||||
)
|
||||
}
|
||||
if resp.Data.SCode == "" || resp.Data.AuthURL == "" {
|
||||
return wecomQRSession{}, fmt.Errorf("failed to get WeCom QR code: response missing scode or auth_url")
|
||||
}
|
||||
|
||||
return wecomQRSession{
|
||||
SCode: resp.Data.SCode,
|
||||
AuthURL: resp.Data.AuthURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func pollWeComQRCodeResult(ctx context.Context, opts wecomQRFlowOptions, scode string) (wecomQRBotInfo, error) {
|
||||
if strings.TrimSpace(scode) == "" {
|
||||
return wecomQRBotInfo{}, fmt.Errorf("missing WeCom QR scode")
|
||||
}
|
||||
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, opts.PollTimeout)
|
||||
defer cancel()
|
||||
|
||||
var scannedPrinted bool
|
||||
|
||||
for {
|
||||
status, err := queryWeComQRCodeStatus(timeoutCtx, opts, scode)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) {
|
||||
return wecomQRBotInfo{}, fmt.Errorf("WeCom QR scan timed out after %s", opts.PollTimeout)
|
||||
}
|
||||
return wecomQRBotInfo{}, err
|
||||
}
|
||||
|
||||
switch strings.ToLower(status.Data.Status) {
|
||||
case "success":
|
||||
if status.Data.BotInfo.BotID == "" || status.Data.BotInfo.Secret == "" {
|
||||
return wecomQRBotInfo{}, fmt.Errorf("WeCom QR scan succeeded but bot credentials are missing")
|
||||
}
|
||||
return wecomQRBotInfo{
|
||||
BotID: status.Data.BotInfo.BotID,
|
||||
Secret: status.Data.BotInfo.Secret,
|
||||
}, nil
|
||||
case "expired":
|
||||
return wecomQRBotInfo{}, fmt.Errorf("WeCom QR code expired, please retry")
|
||||
case "scaned", "scanned":
|
||||
if !scannedPrinted {
|
||||
fmt.Fprintln(opts.Writer, "QR code scanned. Confirm the login in WeCom.")
|
||||
scannedPrinted = true
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-timeoutCtx.Done():
|
||||
if errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) {
|
||||
return wecomQRBotInfo{}, fmt.Errorf("WeCom QR scan timed out after %s", opts.PollTimeout)
|
||||
}
|
||||
return wecomQRBotInfo{}, timeoutCtx.Err()
|
||||
case <-time.After(opts.PollInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func queryWeComQRCodeStatus(ctx context.Context, opts wecomQRFlowOptions, scode string) (wecomQRQueryResponse, error) {
|
||||
queryURL, err := buildWeComQRQueryURL(opts.QueryURL, scode)
|
||||
if err != nil {
|
||||
return wecomQRQueryResponse{}, err
|
||||
}
|
||||
|
||||
var resp wecomQRQueryResponse
|
||||
if err := doWeComJSONGet(ctx, opts.HTTPClient, queryURL, &resp); err != nil {
|
||||
return wecomQRQueryResponse{}, fmt.Errorf("failed to query WeCom QR result: %w", err)
|
||||
}
|
||||
if resp.ErrCode != 0 {
|
||||
return wecomQRQueryResponse{}, fmt.Errorf(
|
||||
"failed to query WeCom QR result: errcode=%d errmsg=%s",
|
||||
resp.ErrCode,
|
||||
resp.ErrMsg,
|
||||
)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func buildWeComQRGenerateURL(baseURL, sourceID string, platformCode int) (string, error) {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid WeCom QR generate URL: %w", err)
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
query.Set("source", sourceID)
|
||||
query.Set("sourceID", sourceID)
|
||||
query.Set("plat", strconv.Itoa(platformCode))
|
||||
u.RawQuery = query.Encode()
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func buildWeComQRQueryURL(baseURL, scode string) (string, error) {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid WeCom QR query URL: %w", err)
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
query.Set("scode", scode)
|
||||
u.RawQuery = query.Encode()
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func buildWeComQRCodePageURL(baseURL, sourceID, scode string) (string, error) {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid WeCom QR page URL: %w", err)
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
query.Set("source", sourceID)
|
||||
query.Set("sourceID", sourceID)
|
||||
query.Set("scode", scode)
|
||||
u.RawQuery = query.Encode()
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func doWeComJSONGet(ctx context.Context, client *http.Client, targetURL string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("unexpected status %s", resp.Status)
|
||||
}
|
||||
return fmt.Errorf("unexpected status %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("decode JSON response: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func wecomPlatformCode() int {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return 1
|
||||
case "windows":
|
||||
return 2
|
||||
case "linux":
|
||||
return 3
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func newIPv4TestServer(t *testing.T, handler http.Handler) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
server := httptest.NewUnstartedServer(handler)
|
||||
listener, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
server.Listener = listener
|
||||
server.Start()
|
||||
t.Cleanup(server.Close)
|
||||
return server
|
||||
}
|
||||
|
||||
func TestNewWeComCommand(t *testing.T) {
|
||||
cmd := newWeComCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
assert.Equal(t, "wecom", cmd.Use)
|
||||
assert.Equal(t, "Scan a WeCom QR code and configure channels.wecom", cmd.Short)
|
||||
assert.NotNil(t, cmd.Flags().Lookup("timeout"))
|
||||
}
|
||||
|
||||
func TestBuildWeComQRGenerateURL(t *testing.T) {
|
||||
rawURL, err := buildWeComQRGenerateURL("https://example.com/ai/qc/generate", wecomQRSourceID, 3)
|
||||
require.NoError(t, err)
|
||||
|
||||
parsed, err := url.Parse(rawURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, wecomQRSourceID, parsed.Query().Get("source"))
|
||||
assert.Equal(t, wecomQRSourceID, parsed.Query().Get("sourceID"))
|
||||
assert.Equal(t, "3", parsed.Query().Get("plat"))
|
||||
}
|
||||
|
||||
func TestBuildWeComQRCodePageURL(t *testing.T) {
|
||||
rawURL, err := buildWeComQRCodePageURL("https://example.com/ai/qc/gen", wecomQRSourceID, "scode-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
parsed, err := url.Parse(rawURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, wecomQRSourceID, parsed.Query().Get("source"))
|
||||
assert.Equal(t, wecomQRSourceID, parsed.Query().Get("sourceID"))
|
||||
assert.Equal(t, "scode-1", parsed.Query().Get("scode"))
|
||||
}
|
||||
|
||||
func TestFetchWeComQRCode(t *testing.T) {
|
||||
server := newIPv4TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/generate", r.URL.Path)
|
||||
assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("source"))
|
||||
assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("sourceID"))
|
||||
assert.Equal(t, strconv.Itoa(wecomPlatformCode()), r.URL.Query().Get("plat"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":{"scode":"scode-1","auth_url":"https://example.com/qr"}}`))
|
||||
}))
|
||||
|
||||
opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{
|
||||
HTTPClient: server.Client(),
|
||||
GenerateURL: server.URL + "/generate",
|
||||
Writer: bytes.NewBuffer(nil),
|
||||
})
|
||||
|
||||
session, err := fetchWeComQRCode(context.Background(), opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "scode-1", session.SCode)
|
||||
assert.Equal(t, "https://example.com/qr", session.AuthURL)
|
||||
}
|
||||
|
||||
func TestPollWeComQRCodeResult(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
|
||||
server := newIPv4TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
call := calls.Add(1)
|
||||
assert.Equal(t, "/query", r.URL.Path)
|
||||
assert.Equal(t, "scode-1", r.URL.Query().Get("scode"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch call {
|
||||
case 1:
|
||||
_, _ = w.Write([]byte(`{"data":{"status":"wait"}}`))
|
||||
case 2:
|
||||
_, _ = w.Write([]byte(`{"data":{"status":"scaned"}}`))
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{"data":{"status":"success","bot_info":{"botid":"bot-1","secret":"secret-1"}}}`))
|
||||
}
|
||||
}))
|
||||
|
||||
var output bytes.Buffer
|
||||
opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{
|
||||
HTTPClient: server.Client(),
|
||||
QueryURL: server.URL + "/query",
|
||||
PollInterval: time.Millisecond,
|
||||
PollTimeout: time.Second,
|
||||
Writer: &output,
|
||||
})
|
||||
|
||||
botInfo, err := pollWeComQRCodeResult(context.Background(), opts, "scode-1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "bot-1", botInfo.BotID)
|
||||
assert.Equal(t, "secret-1", botInfo.Secret)
|
||||
assert.Contains(t, output.String(), "QR code scanned. Confirm the login in WeCom.")
|
||||
}
|
||||
|
||||
func TestApplyWeComAuthResult(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
require.NoError(t, config.InitChannelList(cfg.Channels))
|
||||
wecom := cfg.Channels["wecom"]
|
||||
t.Logf("wecom: %+v", wecom)
|
||||
decoded, err := wecom.GetDecoded()
|
||||
require.NoError(t, err)
|
||||
weCfg := decoded.(*config.WeComSettings)
|
||||
weCfg.WebSocketURL = ""
|
||||
|
||||
applyWeComAuthResult(cfg, wecomQRBotInfo{
|
||||
BotID: "bot-1",
|
||||
Secret: "secret-1",
|
||||
})
|
||||
|
||||
assert.True(t, wecom.Enabled)
|
||||
assert.Equal(t, "bot-1", weCfg.BotID)
|
||||
assert.Equal(t, "secret-1", weCfg.Secret.String())
|
||||
assert.Equal(t, wecomDefaultWebSocketURL, weCfg.WebSocketURL)
|
||||
}
|
||||
|
||||
func TestAuthWeComCmdWithScanner(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
t.Setenv(config.EnvHome, tmpDir)
|
||||
t.Setenv(config.EnvConfig, configPath)
|
||||
|
||||
var output bytes.Buffer
|
||||
err := authWeComCmdWithScanner(
|
||||
context.Background(),
|
||||
&output,
|
||||
time.Second,
|
||||
func(_ context.Context, opts wecomQRFlowOptions) (wecomQRBotInfo, error) {
|
||||
assert.Equal(t, wecomQRSourceID, opts.SourceID)
|
||||
return wecomQRBotInfo{
|
||||
BotID: "bot-1",
|
||||
Secret: "secret-1",
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := config.LoadConfig(internal.GetConfigPath())
|
||||
require.NoError(t, err)
|
||||
wecom := cfg.Channels["wecom"]
|
||||
decoded, err := wecom.GetDecoded()
|
||||
require.NoError(t, err)
|
||||
weCfg := decoded.(*config.WeComSettings)
|
||||
assert.True(t, wecom.Enabled)
|
||||
assert.Equal(t, "bot-1", weCfg.BotID)
|
||||
assert.Equal(t, "secret-1", weCfg.Secret.String())
|
||||
assert.Equal(t, wecomDefaultWebSocketURL, weCfg.WebSocketURL)
|
||||
assert.Contains(t, output.String(), "WeCom connected.")
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/channels/weixin"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func newWeixinCommand() *cobra.Command {
|
||||
var baseURL string
|
||||
var proxy string
|
||||
var timeout int
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "weixin",
|
||||
Short: "Connect a WeChat personal account via QR code",
|
||||
Long: `Start the interactive Weixin (WeChat personal) QR code login flow.
|
||||
|
||||
A QR code is displayed in the terminal. Scan it with the WeChat mobile app
|
||||
to authorize your account. On success, the bot token is saved to the picoclaw
|
||||
config so you can start the gateway immediately.
|
||||
|
||||
Example:
|
||||
picoclaw auth weixin`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return runWeixinOnboard(baseURL, proxy, time.Duration(timeout)*time.Second)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&baseURL, "base-url", "https://ilinkai.weixin.qq.com/", "iLink API base URL")
|
||||
cmd.Flags().StringVar(&proxy, "proxy", "", "HTTP proxy URL (e.g. http://localhost:7890)")
|
||||
cmd.Flags().IntVar(&timeout, "timeout", 300, "Login timeout in seconds")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runWeixinOnboard(baseURL, proxy string, timeout time.Duration) error {
|
||||
fmt.Println("Starting Weixin (WeChat personal) login...")
|
||||
fmt.Println()
|
||||
|
||||
botToken, userID, accountID, returnedBaseURL, err := weixin.PerformLoginInteractive(
|
||||
context.Background(),
|
||||
weixin.AuthFlowOpts{
|
||||
BaseURL: baseURL,
|
||||
Timeout: timeout,
|
||||
Proxy: proxy,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("login failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("✅ Login successful!")
|
||||
fmt.Printf(" Account ID : %s\n", accountID)
|
||||
if userID != "" {
|
||||
fmt.Printf(" User ID : %s\n", userID)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Prefer the server-returned base URL (may be region-specific)
|
||||
effectiveBaseURL := returnedBaseURL
|
||||
if effectiveBaseURL == "" {
|
||||
effectiveBaseURL = baseURL
|
||||
}
|
||||
|
||||
if err := saveWeixinConfig(botToken, effectiveBaseURL, proxy); err != nil {
|
||||
fmt.Printf("⚠️ Could not auto-save to config: %v\n", err)
|
||||
printManualWeixinConfig(botToken, effectiveBaseURL)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("✓ Config updated. Start the gateway with:")
|
||||
fmt.Println()
|
||||
fmt.Println(" picoclaw gateway")
|
||||
fmt.Println()
|
||||
fmt.Println("To restrict which WeChat users can send messages, add their user IDs")
|
||||
fmt.Println("to channels.weixin.allow_from in your config.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveWeixinConfig patches channels.weixin in the config and saves it.
|
||||
func saveWeixinConfig(token, baseURL, proxy string) error {
|
||||
cfgPath := internal.GetConfigPath()
|
||||
|
||||
cfg, err := config.LoadConfig(cfgPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
bc := cfg.Channels.GetByType(config.ChannelWeixin)
|
||||
if bc == nil {
|
||||
bc = &config.Channel{Type: config.ChannelWeixin}
|
||||
cfg.Channels[config.ChannelWeixin] = bc
|
||||
}
|
||||
bc.Enabled = true
|
||||
|
||||
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
|
||||
if weixinCfg, ok := decoded.(*config.WeixinSettings); ok {
|
||||
weixinCfg.Token = *config.NewSecureString(token)
|
||||
const defaultBase = "https://ilinkai.weixin.qq.com/"
|
||||
if baseURL != "" && baseURL != defaultBase {
|
||||
weixinCfg.BaseURL = baseURL
|
||||
}
|
||||
if proxy != "" {
|
||||
weixinCfg.Proxy = proxy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config.SaveConfig(cfgPath, cfg)
|
||||
}
|
||||
|
||||
func printManualWeixinConfig(token, baseURL string) {
|
||||
fmt.Println()
|
||||
fmt.Println("Add the following to the channels section of your picoclaw config:")
|
||||
fmt.Println()
|
||||
fmt.Println(` "weixin": {`)
|
||||
fmt.Println(` "enabled": true,`)
|
||||
fmt.Printf(" \"token\": %q,\n", token)
|
||||
const defaultBase = "https://ilinkai.weixin.qq.com/"
|
||||
if baseURL != "" && baseURL != defaultBase {
|
||||
fmt.Printf(" \"base_url\": %q,\n", baseURL)
|
||||
}
|
||||
fmt.Println(` "allow_from": []`)
|
||||
fmt.Println(` }`)
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
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")
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
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:")
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
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 + "…"
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
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!\""
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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")))
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/cron"
|
||||
)
|
||||
|
||||
func newAddCommand(storePath func() string) *cobra.Command {
|
||||
var (
|
||||
name string
|
||||
message string
|
||||
every int64
|
||||
cronExp string
|
||||
channel string
|
||||
to string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "Add a new scheduled job",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if every <= 0 && cronExp == "" {
|
||||
return fmt.Errorf("either --every or --cron must be specified")
|
||||
}
|
||||
|
||||
var schedule cron.CronSchedule
|
||||
if every > 0 {
|
||||
everyMS := every * 1000
|
||||
schedule = cron.CronSchedule{Kind: "every", EveryMS: &everyMS}
|
||||
} else {
|
||||
schedule = cron.CronSchedule{Kind: "cron", Expr: cronExp}
|
||||
}
|
||||
|
||||
cs := cron.NewCronService(storePath(), nil)
|
||||
job, err := cs.AddJob(name, schedule, message, channel, to)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error adding job: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Added job '%s' (%s)\n", job.Name, job.ID)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&name, "name", "n", "", "Job name")
|
||||
cmd.Flags().StringVarP(&message, "message", "m", "", "Message for agent")
|
||||
cmd.Flags().Int64VarP(&every, "every", "e", 0, "Run every N seconds")
|
||||
cmd.Flags().StringVarP(&cronExp, "cron", "c", "", "Cron expression (e.g. '0 9 * * *')")
|
||||
cmd.Flags().StringVar(&to, "to", "", "Recipient for delivery")
|
||||
cmd.Flags().StringVar(&channel, "channel", "", "Channel for delivery")
|
||||
|
||||
_ = cmd.MarkFlagRequired("name")
|
||||
_ = cmd.MarkFlagRequired("message")
|
||||
cmd.MarkFlagsMutuallyExclusive("every", "cron")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewAddSubcommand(t *testing.T) {
|
||||
fn := func() string { return "" }
|
||||
cmd := newAddCommand(fn)
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "add", cmd.Use)
|
||||
assert.Equal(t, "Add a new scheduled job", cmd.Short)
|
||||
|
||||
assert.True(t, cmd.HasFlags())
|
||||
|
||||
assert.NotNil(t, cmd.Flags().Lookup("every"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("cron"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("to"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("channel"))
|
||||
|
||||
nameFlag := cmd.Flags().Lookup("name")
|
||||
require.NotNil(t, nameFlag)
|
||||
|
||||
messageFlag := cmd.Flags().Lookup("message")
|
||||
require.NotNil(t, messageFlag)
|
||||
|
||||
val, found := nameFlag.Annotations[cobra.BashCompOneRequiredFlag]
|
||||
require.True(t, found)
|
||||
require.NotEmpty(t, val)
|
||||
assert.Equal(t, "true", val[0])
|
||||
|
||||
val, found = messageFlag.Annotations[cobra.BashCompOneRequiredFlag]
|
||||
require.True(t, found)
|
||||
require.NotEmpty(t, val)
|
||||
assert.Equal(t, "true", val[0])
|
||||
}
|
||||
|
||||
func TestNewAddCommandEveryAndCronMutuallyExclusive(t *testing.T) {
|
||||
cmd := newAddCommand(func() string { return "testing" })
|
||||
|
||||
cmd.SetArgs([]string{
|
||||
"--name", "job",
|
||||
"--message", "hello",
|
||||
"--every", "10",
|
||||
"--cron", "0 9 * * *",
|
||||
})
|
||||
|
||||
err := cmd.Execute()
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
)
|
||||
|
||||
func NewCronCommand() *cobra.Command {
|
||||
var storePath string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "cron",
|
||||
Aliases: []string{"c"},
|
||||
Short: "Manage scheduled tasks",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
// Resolve storePath at execution time so it reflects the current config
|
||||
// and is shared across all subcommands.
|
||||
PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
|
||||
cfg, err := internal.LoadConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading config: %w", err)
|
||||
}
|
||||
storePath = filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
newListCommand(func() string { return storePath }),
|
||||
newAddCommand(func() string { return storePath }),
|
||||
newRemoveCommand(func() string { return storePath }),
|
||||
newEnableCommand(func() string { return storePath }),
|
||||
newDisableCommand(func() string { return storePath }),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewCronCommand(t *testing.T) {
|
||||
cmd := NewCronCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "Manage scheduled tasks", cmd.Short)
|
||||
|
||||
assert.Len(t, cmd.Aliases, 1)
|
||||
assert.True(t, cmd.HasAlias("c"))
|
||||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
|
||||
assert.NotNil(t, cmd.PersistentPreRunE)
|
||||
assert.Nil(t, cmd.PersistentPreRun)
|
||||
assert.Nil(t, cmd.PersistentPostRun)
|
||||
|
||||
assert.True(t, cmd.HasSubCommands())
|
||||
|
||||
allowedCommands := []string{
|
||||
"list",
|
||||
"add",
|
||||
"remove",
|
||||
"enable",
|
||||
"disable",
|
||||
}
|
||||
|
||||
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.Len(t, subcmd.Aliases, 0)
|
||||
assert.False(t, subcmd.Hidden)
|
||||
|
||||
assert.False(t, subcmd.HasSubCommands())
|
||||
|
||||
assert.Nil(t, subcmd.Run)
|
||||
assert.NotNil(t, subcmd.RunE)
|
||||
|
||||
assert.Nil(t, subcmd.PersistentPreRun)
|
||||
assert.Nil(t, subcmd.PersistentPostRun)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package cron
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newDisableCommand(storePath func() string) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "disable",
|
||||
Short: "Disable a job",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Example: `picoclaw cron disable 1`,
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
cronSetJobEnabled(storePath(), args[0], false)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDisableSubcommand(t *testing.T) {
|
||||
fn := func() string { return "" }
|
||||
cmd := newDisableCommand(fn)
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "disable", cmd.Use)
|
||||
assert.Equal(t, "Disable a job", cmd.Short)
|
||||
|
||||
assert.True(t, cmd.HasExample())
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package cron
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newEnableCommand(storePath func() string) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "enable",
|
||||
Short: "Enable a job",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Example: `picoclaw cron enable 1`,
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
cronSetJobEnabled(storePath(), args[0], true)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEnableSubcommand(t *testing.T) {
|
||||
fn := func() string { return "" }
|
||||
cmd := newEnableCommand(fn)
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "enable", cmd.Use)
|
||||
assert.Equal(t, "Enable a job", cmd.Short)
|
||||
|
||||
assert.True(t, cmd.HasExample())
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/cron"
|
||||
)
|
||||
|
||||
func cronListCmd(storePath string) {
|
||||
cs := cron.NewCronService(storePath, nil)
|
||||
jobs := cs.ListJobs(true) // Show all jobs, including disabled
|
||||
|
||||
if len(jobs) == 0 {
|
||||
fmt.Println("No scheduled jobs.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("\nScheduled Jobs:")
|
||||
fmt.Println("----------------")
|
||||
for _, job := range jobs {
|
||||
var schedule string
|
||||
if job.Schedule.Kind == "every" && job.Schedule.EveryMS != nil {
|
||||
schedule = fmt.Sprintf("every %ds", *job.Schedule.EveryMS/1000)
|
||||
} else if job.Schedule.Kind == "cron" {
|
||||
schedule = job.Schedule.Expr
|
||||
} else {
|
||||
schedule = "one-time"
|
||||
}
|
||||
|
||||
nextRun := "scheduled"
|
||||
if job.State.NextRunAtMS != nil {
|
||||
nextTime := time.UnixMilli(*job.State.NextRunAtMS)
|
||||
nextRun = nextTime.Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
status := "enabled"
|
||||
if !job.Enabled {
|
||||
status = "disabled"
|
||||
}
|
||||
|
||||
fmt.Printf(" %s (%s)\n", job.Name, job.ID)
|
||||
fmt.Printf(" Schedule: %s\n", schedule)
|
||||
fmt.Printf(" Status: %s\n", status)
|
||||
fmt.Printf(" Next run: %s\n", nextRun)
|
||||
}
|
||||
}
|
||||
|
||||
func cronRemoveCmd(storePath, jobID string) {
|
||||
cs := cron.NewCronService(storePath, nil)
|
||||
if cs.RemoveJob(jobID) {
|
||||
fmt.Printf("✓ Removed job %s\n", jobID)
|
||||
} else {
|
||||
fmt.Printf("✗ Job %s not found\n", jobID)
|
||||
}
|
||||
}
|
||||
|
||||
func cronSetJobEnabled(storePath, jobID string, enabled bool) {
|
||||
cs := cron.NewCronService(storePath, nil)
|
||||
job := cs.EnableJob(jobID, enabled)
|
||||
if job != nil {
|
||||
fmt.Printf("✓ Job '%s' enabled\n", job.Name)
|
||||
} else {
|
||||
fmt.Printf("✗ Job %s not found\n", jobID)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package cron
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newListCommand(storePath func() string) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all scheduled jobs",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
cronListCmd(storePath())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewListSubcommand(t *testing.T) {
|
||||
fn := func() string { return "" }
|
||||
cmd := newListCommand(fn)
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "List all scheduled jobs", cmd.Short)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package cron
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newRemoveCommand(storePath func() string) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove",
|
||||
Short: "Remove a job by ID",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Example: `picoclaw cron remove 1`,
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
cronRemoveCmd(storePath(), args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewRemoveSubcommand(t *testing.T) {
|
||||
fn := func() string { return "" }
|
||||
cmd := newRemoveCommand(fn)
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "Remove a job by ID", cmd.Short)
|
||||
|
||||
assert.True(t, cmd.HasExample())
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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",
|
||||
Aliases: []string{"g"},
|
||||
Short: "Start picoclaw gateway",
|
||||
Args: cobra.NoArgs,
|
||||
PreRunE: func(_ *cobra.Command, _ []string) error {
|
||||
if noTruncate && !debug {
|
||||
return fmt.Errorf("the --no-truncate option can only be used in conjunction with --debug (-d)")
|
||||
}
|
||||
|
||||
if noTruncate {
|
||||
utils.SetDisableTruncation(true)
|
||||
logger.Info("String truncation is globally disabled via 'no-truncate' flag")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
||||
cmd.Flags().BoolVarP(&noTruncate, "no-truncate", "T", false, "Disable string truncation in debug logs")
|
||||
cmd.Flags().BoolVarP(
|
||||
&allowEmpty,
|
||||
"allow-empty",
|
||||
"E",
|
||||
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
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewGatewayCommand(t *testing.T) {
|
||||
cmd := NewGatewayCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "gateway", cmd.Use)
|
||||
assert.Equal(t, "Start picoclaw gateway", cmd.Short)
|
||||
|
||||
assert.Len(t, cmd.Aliases, 1)
|
||||
assert.True(t, cmd.HasAlias("g"))
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
|
||||
assert.Nil(t, cmd.PersistentPreRun)
|
||||
assert.Nil(t, cmd.PersistentPostRun)
|
||||
|
||||
assert.False(t, cmd.HasSubCommands())
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
const Logo = pkg.Logo
|
||||
|
||||
// GetPicoclawHome returns the picoclaw home directory.
|
||||
// Priority: $PICOCLAW_HOME > ~/.picoclaw
|
||||
func GetPicoclawHome() string {
|
||||
return config.GetHome()
|
||||
}
|
||||
|
||||
func GetConfigPath() string {
|
||||
if configPath := os.Getenv(config.EnvConfig); configPath != "" {
|
||||
return configPath
|
||||
}
|
||||
return filepath.Join(GetPicoclawHome(), "config.json")
|
||||
}
|
||||
|
||||
func LoadConfig() (*config.Config, error) {
|
||||
cfg, err := config.LoadConfig(GetConfigPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.SetLevelFromString(cfg.Gateway.LogLevel)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// FormatVersion returns the version string with optional git commit
|
||||
// Deprecated: Use pkg/config.FormatVersion instead
|
||||
func FormatVersion() string {
|
||||
return config.FormatVersion()
|
||||
}
|
||||
|
||||
// FormatBuildInfo returns build time and go version info
|
||||
// Deprecated: Use pkg/config.FormatBuildInfo instead
|
||||
func FormatBuildInfo() (string, string) {
|
||||
return config.FormatBuildInfo()
|
||||
}
|
||||
|
||||
// GetVersion returns the version string
|
||||
// Deprecated: Use pkg/config.GetVersion instead
|
||||
func GetVersion() string {
|
||||
return config.GetVersion()
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func TestGetConfigPath(t *testing.T) {
|
||||
t.Setenv("HOME", "/tmp/home")
|
||||
|
||||
got := GetConfigPath()
|
||||
want := filepath.Join("/tmp/home", ".picoclaw", "config.json")
|
||||
|
||||
assert.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) {
|
||||
t.Setenv(config.EnvHome, "/custom/picoclaw")
|
||||
t.Setenv("HOME", "/tmp/home")
|
||||
|
||||
got := GetConfigPath()
|
||||
want := filepath.Join("/custom/picoclaw", "config.json")
|
||||
|
||||
assert.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) {
|
||||
t.Setenv("PICOCLAW_CONFIG", "/custom/config.json")
|
||||
t.Setenv(config.EnvHome, "/custom/picoclaw")
|
||||
t.Setenv("HOME", "/tmp/home")
|
||||
|
||||
got := GetConfigPath()
|
||||
want := "/custom/config.json"
|
||||
|
||||
assert.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func TestGetConfigPath_Windows(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("windows-specific HOME behavior varies; run on windows")
|
||||
}
|
||||
|
||||
testUserProfilePath := `C:\Users\Test`
|
||||
t.Setenv("USERPROFILE", testUserProfilePath)
|
||||
|
||||
got := GetConfigPath()
|
||||
want := filepath.Join(testUserProfilePath, ".picoclaw", "config.json")
|
||||
|
||||
require.True(t, strings.EqualFold(got, want), "GetConfigPath() = %q, want %q", got, want)
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,619 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
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
|
||||
}
|
||||