mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50c58a3462 | |||
| 403e048821 |
+4
-7
@@ -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,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: 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
|
||||
|
||||
@@ -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,138 +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 Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
run: corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
- 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: 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 }}
|
||||
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; 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[@]}"
|
||||
|
||||
+29
-36
@@ -1,60 +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: Run Govulncheck
|
||||
uses: golang/govulncheck-action@v1
|
||||
with:
|
||||
go-package: ./...
|
||||
- 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
|
||||
|
||||
@@ -62,4 +54,5 @@ jobs:
|
||||
run: go generate ./...
|
||||
|
||||
- name: Run go test
|
||||
run: go test -tags goolm,stdjson ./...
|
||||
run: go test ./...
|
||||
|
||||
|
||||
@@ -17,11 +17,6 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
upload_tos:
|
||||
description: "Upload to Volcengine TOS"
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
create-tag:
|
||||
@@ -31,7 +26,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -54,47 +49,38 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- 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 Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
run: corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
- 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: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: ~> v2
|
||||
@@ -103,12 +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 }}
|
||||
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
|
||||
@@ -118,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,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
|
||||
+1
-21
@@ -10,7 +10,7 @@ build/
|
||||
*.out
|
||||
/picoclaw
|
||||
/picoclaw-test
|
||||
cmd/**/workspace
|
||||
cmd/picoclaw/workspace
|
||||
|
||||
# Picoclaw specific
|
||||
|
||||
@@ -38,29 +38,9 @@ ralph/
|
||||
.ralph/
|
||||
tasks/
|
||||
|
||||
# Plans
|
||||
docs/plans/
|
||||
docs/superpowers/
|
||||
|
||||
# Editors
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Added by goreleaser init:
|
||||
dist/
|
||||
*.vite/
|
||||
|
||||
# Windows Application Icon/Resource
|
||||
*.syso
|
||||
|
||||
# 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
|
||||
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
default: all
|
||||
disable:
|
||||
# TODO: Tweak for current project needs
|
||||
- containedctx
|
||||
- cyclop
|
||||
- depguard
|
||||
- dupword
|
||||
- err113
|
||||
- exhaustruct
|
||||
- funcorder
|
||||
- gochecknoglobals
|
||||
- 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:
|
||||
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
|
||||
+7
-175
@@ -5,184 +5,45 @@ version: 2
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- go generate ./...
|
||||
- sh -c 'cd web/frontend && pnpm install && pnpm build:backend'
|
||||
- go install github.com/tc-hib/go-winres@latest
|
||||
- go-winres make --in web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }}
|
||||
- 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={{ .Env.GOVERSION }}
|
||||
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
|
||||
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
|
||||
|
||||
- id: picoclaw-launcher-tui
|
||||
binary: picoclaw-launcher-tui
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
tags:
|
||||
- goolm
|
||||
- stdjson
|
||||
ldflags:
|
||||
- -s -w
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
- freebsd
|
||||
- netbsd
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- riscv64
|
||||
- loong64
|
||||
- arm
|
||||
- s390x
|
||||
- mipsle
|
||||
goarm:
|
||||
- "6"
|
||||
- "7"
|
||||
gomips:
|
||||
- softfloat
|
||||
main: ./cmd/picoclaw-launcher-tui
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
- goos: netbsd
|
||||
goarch: s390x
|
||||
- goos: netbsd
|
||||
goarch: mips64
|
||||
- goos: netbsd
|
||||
goarch: arm
|
||||
|
||||
dockers_v2:
|
||||
- id: picoclaw
|
||||
dockerfile: docker/Dockerfile.goreleaser
|
||||
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
|
||||
- picoclaw-launcher-tui
|
||||
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
|
||||
- picoclaw-launcher-tui
|
||||
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`.
|
||||
@@ -198,34 +59,6 @@ archives:
|
||||
- goos: windows
|
||||
formats: [zip]
|
||||
|
||||
nfpms:
|
||||
- id: picoclaw
|
||||
ids:
|
||||
- picoclaw
|
||||
- picoclaw-launcher
|
||||
- picoclaw-launcher-tui
|
||||
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:
|
||||
@@ -239,7 +72,6 @@ changelog:
|
||||
# lzma: true
|
||||
|
||||
release:
|
||||
disable: '{{ isEnvSet "NIGHTLY_BUILD" }}'
|
||||
footer: >-
|
||||
|
||||
---
|
||||
|
||||
-302
@@ -1,302 +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.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
All CI checks must pass before a PR can be merged. Run `make check` locally before pushing to catch issues early.
|
||||
|
||||
---
|
||||
|
||||
## 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 https://www.conventionalcommits.org/zh-hans/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,303 +0,0 @@
|
||||
# 参与贡献 PicoClaw
|
||||
|
||||
感谢你对 PicoClaw 的关注!本项目是一个社区驱动的开源项目,目标是构建 轻量灵活,人人可用 的个人AI助手。我们欢迎一切形式的贡献:Bug 修复、新功能、文档、翻译和测试。
|
||||
|
||||
PicoClaw 本身在很大程度上是借助 AI 辅助开发的——我们拥抱这种方式,并围绕它构建了贡献流程。
|
||||
|
||||
## 目录
|
||||
|
||||
- [行为准则](#行为准则)
|
||||
- [贡献方式](#贡献方式)
|
||||
- [快速开始](#快速开始)
|
||||
- [开发环境配置](#开发环境配置)
|
||||
- [提交修改](#提交修改)
|
||||
- [AI 辅助贡献](#ai-辅助贡献)
|
||||
- [Pull Request 流程](#pull-request-流程)
|
||||
- [分支策略](#分支策略)
|
||||
- [代码审查](#代码审查)
|
||||
- [沟通渠道](#沟通渠道)
|
||||
|
||||
---
|
||||
|
||||
## 行为准则
|
||||
|
||||
我们致力于维护一个友好、互相尊重的社区环境。请保持善意、建设性的态度,并善意地理解他人。任何形式的骚扰或歧视均不被接受。
|
||||
|
||||
---
|
||||
|
||||
## 贡献方式
|
||||
|
||||
- **Bug 反馈** — 使用 Bug 报告模板提交 Issue。
|
||||
- **功能建议** — 使用功能请求模板提交 Issue,建议在开始实现前先进行讨论。
|
||||
- **代码贡献** — 修复 Bug 或实现新功能,参见下方工作流程。
|
||||
- **文档改进** — 完善 README、文档、代码注释或翻译。
|
||||
- **测试与验证** — 在新硬件、新渠道或新 LLM 提供商上运行 PicoClaw 并反馈结果。
|
||||
|
||||
对于较大的新功能,请先提交 Issue 讨论设计方案,再动手写代码。这能避免无效投入,也确保与项目方向保持一致。
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 在 GitHub 上 **Fork** 本仓库。
|
||||
2. 将你的 Fork **克隆**到本地:
|
||||
```bash
|
||||
git clone https://github.com/<你的用户名>/picoclaw.git
|
||||
cd picoclaw
|
||||
```
|
||||
3. 添加上游远程仓库:
|
||||
```bash
|
||||
git remote add upstream https://github.com/sipeed/picoclaw.git
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 开发环境配置
|
||||
|
||||
### 前置依赖
|
||||
|
||||
- Go 1.25 或更高版本
|
||||
- `make`
|
||||
|
||||
### 构建
|
||||
|
||||
```bash
|
||||
make build # 构建二进制文件(会先执行 go generate)
|
||||
make generate # 仅执行 go generate
|
||||
make check # 完整的提交前检查:deps + fmt + vet + test
|
||||
```
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
make test # 运行所有测试
|
||||
go test -run TestName -v ./pkg/session/ # 运行单个测试
|
||||
go test -bench=. -benchmem -run='^$' ./... # 运行基准测试
|
||||
```
|
||||
|
||||
### 代码风格
|
||||
|
||||
```bash
|
||||
make fmt # 格式化代码
|
||||
make vet # 静态分析
|
||||
make lint # 完整的 lint 检查
|
||||
```
|
||||
|
||||
所有 CI 检查通过后 PR 才能被合并。推送代码前请先在本地运行 `make check`,提前发现问题。
|
||||
|
||||
---
|
||||
|
||||
## 提交修改
|
||||
|
||||
### 分支管理
|
||||
|
||||
始终从 `main` 分支切出,并在 PR 中以 `main` 为目标分支。不要直接向 `main` 或任何 `release/*` 分支推送代码:
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull upstream main
|
||||
git checkout -b 你的功能分支名
|
||||
```
|
||||
|
||||
请使用描述性的分支名,例如:`fix/telegram-timeout`、`feat/ollama-provider`、`docs/contributing-guide`。
|
||||
|
||||
### Commit 规范
|
||||
|
||||
- 使用英文撰写清晰、简洁的 commit 信息。
|
||||
- 使用祈使句:写 "Add retry logic",而不是 "Added retry logic"。
|
||||
- 有关联 Issue 时请引用:`Fix session leak (#123)`。
|
||||
- 保持 commit 专注,每个 commit 只做一件事。
|
||||
- 对于小的清理或拼写修正,提 PR 前请将其合并为一个 commit。
|
||||
- 按照 https://www.conventionalcommits.org/zh-hans/v1.0.0/ 规范来撰写
|
||||
|
||||
### 保持与上游同步
|
||||
|
||||
提 PR 前,请将你的分支变基到上游 `main`:
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git rebase upstream/main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AI 辅助贡献
|
||||
|
||||
PicoClaw 在很大程度上借助 AI 辅助开发,我们完全拥抱这种开发方式。但贡献者必须清楚地了解自己在使用 AI 工具时所承担的责任。
|
||||
|
||||
### 必须披露 AI 使用情况
|
||||
|
||||
每个 PR 都必须通过 PR 模板中的 **🤖 AI 代码生成** 部分披露 AI 参与情况,共分三个级别:
|
||||
|
||||
| 级别 | 说明 |
|
||||
|---|---|
|
||||
| 🤖 完全由 AI 生成 | AI 编写代码,贡献者负责审查和验证 |
|
||||
| 🛠️ 主要由 AI 生成 | AI 起草,贡献者做了较大修改 |
|
||||
| 👨💻 主要由人工编写 | 贡献者主导,AI 仅提供辅助或未使用 AI |
|
||||
|
||||
我们期望你诚实填写。三种级别均可接受,没有任何歧视——重要的是贡献的质量。
|
||||
|
||||
### 你对提交的代码负全责
|
||||
|
||||
使用 AI 生成代码并不能减轻你作为贡献者的责任。在提交含有 AI 生成代码的 PR 之前,你必须:
|
||||
|
||||
- **逐行阅读并理解**生成的代码。
|
||||
- **在真实环境中测试**(参见 PR 模板中的测试环境部分)。
|
||||
- **检查安全问题** — AI 模型可能生成存在安全隐患的代码(如路径穿越、注入攻击、凭据泄露等),请仔细审查。
|
||||
- **验证正确性** — AI 生成的逻辑可能听起来合理但实际上是错误的,请验证行为,而不仅仅是语法。
|
||||
|
||||
如果明显可以看出贡献者没有阅读或测试 AI 生成的代码,该 PR 将被直接关闭,不予审查。
|
||||
|
||||
### AI 生成代码的质量标准
|
||||
|
||||
AI 生成的代码与人工编写的代码遵循**相同的质量要求**:
|
||||
|
||||
- 必须通过所有 CI 检查(`make check`)。
|
||||
- 必须符合 Go 惯用写法,并与现有代码库的风格保持一致。
|
||||
- 不得引入不必要的抽象、死代码或过度设计。
|
||||
- 须在适当的地方包含或更新测试。
|
||||
|
||||
### 安全审查
|
||||
|
||||
AI 生成的代码需要格外仔细的安全审查。请特别关注以下方面:
|
||||
|
||||
- 文件路径处理与沙箱逃逸(项目历史中的 commit `244eb0b` 就是真实案例)
|
||||
- channel 处理器和 tool 实现中的外部输入校验
|
||||
- 凭据或密钥的处理
|
||||
- 命令执行(`exec.Command`、shell 调用等)
|
||||
|
||||
如果你不确定某段 AI 生成代码是否安全,请在 PR 中说明——审查者会帮助判断。
|
||||
|
||||
---
|
||||
|
||||
## Pull Request 流程
|
||||
|
||||
### 提 PR 前的检查
|
||||
|
||||
- [ ] 在本地运行 `make check` 并确认通过。
|
||||
- [ ] 完整填写 PR 模板,包括 AI 披露部分。
|
||||
- [ ] 在 PR 描述中关联相关 Issue。
|
||||
- [ ] 保持 PR 专注,避免将不相关的修改混在一起。
|
||||
|
||||
### PR 模板各部分说明
|
||||
|
||||
PR 模板要求填写:
|
||||
|
||||
- **描述** — 这个改动做了什么,为什么要做?
|
||||
- **变更类型** — Bug 修复、新功能、文档或重构。
|
||||
- **AI 代码生成** — AI 参与情况披露(必填)。
|
||||
- **关联 Issue** — 此 PR 解决的 Issue 链接。
|
||||
- **技术背景** — 参考链接和设计理由(纯文档类 PR 可跳过)。
|
||||
- **测试环境** — 用于测试的硬件、操作系统、模型/提供商和渠道。
|
||||
- **验证证据** — 可选的日志或截图,用于证明改动有效。
|
||||
- **检查清单** — 自我审查确认。
|
||||
|
||||
### PR 规模
|
||||
|
||||
请尽量提交小而易于审查的 PR。一个涉及 5 个文件共 200 行改动的 PR,远比涉及 30 个文件共 2000 行改动的 PR 容易审查。如果你的功能较大,可以考虑将其拆分为一系列逻辑完整的小 PR。
|
||||
|
||||
---
|
||||
|
||||
## 分支策略
|
||||
|
||||
### 长期分支
|
||||
|
||||
- **`main`** — 活跃开发分支。所有功能 PR 均以 `main` 为目标。该分支受保护:禁止直接推送,合并前必须获得至少一名维护者的批准。
|
||||
- **`release/x.y`** — 稳定发布分支,在某个版本准备发布时从 `main` 切出。这些分支的保护级别高于 `main`。
|
||||
|
||||
### 合并到 `main` 的前提条件
|
||||
|
||||
PR 必须同时满足以下所有条件,才能被合并:
|
||||
|
||||
1. **CI 全部通过** — 所有 GitHub Actions 工作流(lint、test、build)均为绿色。
|
||||
2. **获得审查者批准** — 至少一名维护者已批准该 PR。
|
||||
3. **无未解决的审查意见** — 所有审查讨论线程均已关闭。
|
||||
4. **PR 模板填写完整** — 包括 AI 披露和测试环境信息。
|
||||
|
||||
### 谁可以合并
|
||||
|
||||
只有维护者才能合并 PR。贡献者不能合并自己的 PR,即使拥有写权限也不行。
|
||||
|
||||
### 合并策略
|
||||
|
||||
为保持 `main` 历史清晰可读,我们对大多数 PR 使用 **Squash Merge**。每个合并的 PR 变为一个包含 PR 编号的单独 commit,例如:
|
||||
|
||||
```
|
||||
feat: Add Ollama provider support (#491)
|
||||
```
|
||||
|
||||
如果一个 PR 包含多个独立、结构清晰、能讲述完整故事的 commit,维护者可视情况使用普通 merge。
|
||||
|
||||
### Release 分支
|
||||
|
||||
当某个版本准备就绪时,维护者会从 `main` 切出 `release/x.y` 分支。此后:
|
||||
|
||||
- **新功能不会被回溯(backport)。** Release 分支切出后,不再接收任何新功能。
|
||||
- **安全修复和关键 Bug 修复会被 cherry-pick 进来。** 若 `main` 上的某个修复属于安全漏洞、数据丢失或崩溃类问题,维护者会将相关 commit cherry-pick 到受影响的 `release/x.y` 分支,并发布补丁版本。
|
||||
|
||||
如果你认为 `main` 上的某个修复应该被回溯到某个 release 分支,请在 PR 描述中注明,或单独开一个 Issue 说明。最终决定由维护者做出。
|
||||
|
||||
Release 分支的保护级别高于 `main`,在任何情况下均不允许直接推送。
|
||||
|
||||
---
|
||||
|
||||
## 代码审查
|
||||
|
||||
### 对贡献者的建议
|
||||
|
||||
- 在合理时间内回复审查意见。如果需要更多时间,请告知。
|
||||
- 更新 PR 以响应反馈时,简要说明改动内容(例如:"按建议改用了 `sync.RWMutex`")。
|
||||
- 如果你不同意某条反馈,请礼貌地阐述你的理由——审查者也可能有判断失误的时候。
|
||||
- 审查开始后请不要 force push——这会让审查者难以追踪变化。请使用额外的 commit,维护者在合并时会进行 squash。
|
||||
|
||||
### 对审查者的建议
|
||||
|
||||
审查重点:
|
||||
|
||||
1. **正确性** — 代码是否实现了其声称的功能?是否存在边界情况?
|
||||
2. **安全性** — 对 AI 生成代码、tool 实现和 channel 处理器尤其需要关注。
|
||||
3. **架构** — 实现方式是否与现有设计一致?
|
||||
4. **简洁性** — 是否有更简单的方案?是否引入了不必要的复杂度?
|
||||
5. **测试** — 改动是否有测试覆盖?现有测试是否仍然有意义?
|
||||
|
||||
请给出建设性且具体的反馈。"如果两个 goroutine 同时调用这个函数可能会有竞态条件,建议在这里加一个 mutex" 远比 "这里看起来有问题" 更有帮助。
|
||||
|
||||
### 审查者列表
|
||||
提交对应PR后,可以参考下表联系对应的审查人员沟通
|
||||
|
||||
|Function| Reviewer|
|
||||
|--- |--- |
|
||||
|Provider|@yinwm |
|
||||
|Channel |@yinwm/@alexhoshina |
|
||||
|Agent |@lxowalle/@Zhaoyikaiii|
|
||||
|Tools |@lxowalle|
|
||||
|SKill ||
|
||||
|MCP ||
|
||||
|Optimization|@lxowalle|
|
||||
|Security||
|
||||
|AI CI |@imguoguo|
|
||||
|UX ||
|
||||
|Document||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 沟通渠道
|
||||
|
||||
- **GitHub Issues** — Bug 报告、功能建议、设计讨论。
|
||||
- **GitHub Discussions** — 一般性问题、想法交流、社区讨论。
|
||||
- **Pull Request 评论** — 与具体代码相关的反馈。
|
||||
- **Wechat&Discord** — 当你有至少一个已合并的PR后,我们会邀请你加入开发者交流群
|
||||
|
||||
有疑问时,请先开 Issue 讨论,再动手写代码。这几乎没有成本,却能避免大量无效投入。
|
||||
|
||||
---
|
||||
|
||||
## 关于本项目的 AI 驱动起源
|
||||
|
||||
PicoClaw 的架构在人工监督下,经由 AI 辅助完成了大量设计和实现工作。如果你发现某处看起来奇怪或过度设计,这可能是该过程留下的痕迹——欢迎提 Issue 讨论。
|
||||
|
||||
我们相信,负责任地使用 AI 辅助开发能产生优秀的成果。我们同样相信,人类必须对自己提交的内容负责。这两点并不矛盾。
|
||||
|
||||
感谢你的贡献!
|
||||
@@ -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
|
||||
|
||||
@@ -29,14 +29,7 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
# Copy binary
|
||||
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
||||
|
||||
# Create non-root user and group
|
||||
RUN addgroup -g 1000 picoclaw && \
|
||||
adduser -D -u 1000 -G picoclaw picoclaw
|
||||
|
||||
# Switch to non-root user
|
||||
USER picoclaw
|
||||
|
||||
# Run onboard to create initial directories and config
|
||||
# Create picoclaw home directory
|
||||
RUN /usr/local/bin/picoclaw onboard
|
||||
|
||||
ENTRYPOINT ["picoclaw"]
|
||||
@@ -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.
|
||||
|
||||
@@ -11,50 +11,16 @@ 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}')
|
||||
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
|
||||
LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w
|
||||
LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION)"
|
||||
|
||||
# Go variables
|
||||
GO?=CGO_ENABLED=0 go
|
||||
WEB_GO?=$(GO)
|
||||
GO_BUILD_TAGS?=goolm,stdjson
|
||||
GOFLAGS?=-v -tags $(GO_BUILD_TAGS)
|
||||
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
|
||||
|
||||
# Golangci-lint
|
||||
GOLANGCI_LINT?=golangci-lint
|
||||
GO?=go
|
||||
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
|
||||
@@ -73,20 +39,15 @@ 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_ENABLED=1 go
|
||||
ifeq ($(UNAME_M),x86_64)
|
||||
ARCH=amd64
|
||||
else ifeq ($(UNAME_M),arm64)
|
||||
@@ -115,100 +76,28 @@ generate:
|
||||
build: generate
|
||||
@echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||
@$(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)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@if [ ! -f web/backend/dist/index.html ]; then \
|
||||
echo "Building frontend..."; \
|
||||
cd web/frontend && pnpm install && pnpm build:backend; \
|
||||
fi
|
||||
@$(WEB_GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH) ./web/backend
|
||||
@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher
|
||||
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher"
|
||||
|
||||
## build-launcher-tui: Build the picoclaw-launcher TUI binary
|
||||
build-launcher-tui:
|
||||
@echo "Building picoclaw-launcher-tui for $(PLATFORM)/$(ARCH)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) ./cmd/picoclaw-launcher-tui
|
||||
@ln -sf picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher-tui
|
||||
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-tui"
|
||||
|
||||
## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
|
||||
build-whatsapp-native: generate
|
||||
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
|
||||
@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-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit)
|
||||
build-pi-zero: build-linux-arm build-linux-arm64
|
||||
@echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)"
|
||||
|
||||
## build-all: Build picoclaw for all platforms
|
||||
build-all: 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)
|
||||
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)
|
||||
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!"
|
||||
|
||||
@@ -234,27 +123,16 @@ clean:
|
||||
@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: Run linters
|
||||
lint:
|
||||
@$(GOLANGCI_LINT) run --build-tags $(GO_BUILD_TAGS)
|
||||
|
||||
## fix: Fix linting issues
|
||||
fix:
|
||||
@$(GOLANGCI_LINT) run --fix --build-tags $(GO_BUILD_TAGS)
|
||||
@$(GO) fmt ./...
|
||||
|
||||
## deps: Download dependencies
|
||||
deps:
|
||||
@@ -273,56 +151,6 @@ check: deps fmt vet test
|
||||
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:
|
||||
@echo "Building macOS .app bundle..."
|
||||
@if [ "$(UNAME_S)" != "Darwin" ]; then \
|
||||
echo "Error: This target is only available on macOS"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@cd web && $(MAKE) build && cd ..
|
||||
@./scripts/build-macos-app.sh $(BINARY_NAME)-$(PLATFORM)-$(ARCH)
|
||||
@echo "macOS .app bundle created: $(BUILD_DIR)/PicoClaw.app"
|
||||
|
||||
## help: Show this help message
|
||||
help:
|
||||
@echo "picoclaw Makefile"
|
||||
@@ -331,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)"
|
||||
|
||||
-586
@@ -1,586 +0,0 @@
|
||||
<div align="center">
|
||||
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||
|
||||
<h1>PicoClaw : Assistant IA Ultra-Efficace en Go</h1>
|
||||
|
||||
<h3>Matériel à $10 · 10 Mo de RAM · Démarrage en ms · Let's Go, PicoClaw!</h3>
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
<br>
|
||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
|
||||
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
||||
<br>
|
||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</p>
|
||||
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
> **PicoClaw** est un projet open-source indépendant initié par [Sipeed](https://sipeed.com), entièrement écrit en **Go** à partir de zéro — ce n'est pas un fork d'OpenClaw, de NanoBot ou de tout autre projet.
|
||||
|
||||
**PicoClaw** est un assistant personnel IA ultra-léger inspiré de [NanoBot](https://github.com/HKUDS/nanobot). Il a été entièrement reconstruit en **Go** via un processus d'auto-amorçage (self-bootstrapping) — l'Agent IA lui-même a piloté la migration architecturale et l'optimisation du code.
|
||||
|
||||
**Fonctionne sur du matériel à $10 avec <10 Mo de RAM** — c'est 99% de mémoire en moins qu'OpenClaw et 98% moins cher qu'un Mac mini !
|
||||
|
||||
|
||||
<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]
|
||||
> **Avis de sécurité**
|
||||
>
|
||||
> * **PAS DE CRYPTO :** PicoClaw n'a **pas** émis de tokens officiels ni de cryptomonnaie. Toute affirmation sur `pump.fun` ou d'autres plateformes de trading est une **arnaque**.
|
||||
> * **DOMAINE OFFICIEL :** Le **SEUL** site officiel est **[picoclaw.io](https://picoclaw.io)**, et le site de l'entreprise est **[sipeed.com](https://sipeed.com)**
|
||||
> * **ATTENTION :** De nombreux domaines `.ai/.org/.com/.net/...` ont été enregistrés par des tiers. Ne leur faites pas confiance.
|
||||
> * **NOTE :** PicoClaw est en développement rapide précoce. Des problèmes de sécurité non résolus peuvent exister. Ne pas déployer en production avant la v1.0.
|
||||
> * **NOTE :** PicoClaw a récemment fusionné de nombreuses PRs. Les builds récents peuvent utiliser 10-20 Mo de RAM. L'optimisation des ressources est prévue après la stabilisation des fonctionnalités.
|
||||
|
||||
## 📢 Actualités
|
||||
|
||||
2026-03-17 🚀 **v0.2.3 publiée !** Interface system tray (Windows & Linux), requête de statut des sous-agents (`spawn_status`), rechargement à chaud expérimental du Gateway, sécurisation Cron, et 2 correctifs de sécurité. PicoClaw a atteint **25K Stars** !
|
||||
|
||||
2026-03-09 🎉 **v0.2.1 — La plus grande mise à jour à ce jour !** Support du protocole MCP, 4 nouveaux channels (Matrix/IRC/WeCom/Discord Proxy), 3 nouveaux providers (Kimi/Minimax/Avian), pipeline vision, stockage mémoire JSONL, routage de modèles.
|
||||
|
||||
2026-02-28 📦 **v0.2.0** publiée avec support Docker Compose et Web UI Launcher.
|
||||
|
||||
2026-02-26 🎉 PicoClaw atteint **20K Stars** en seulement 17 jours ! L'orchestration automatique des channels et les interfaces de capacités sont disponibles.
|
||||
|
||||
<details>
|
||||
<summary>Actualités précédentes...</summary>
|
||||
|
||||
2026-02-16 🎉 PicoClaw dépasse 12K Stars en une semaine ! Rôles de mainteneurs communautaires et [Roadmap](ROADMAP.md) officiellement lancés.
|
||||
|
||||
2026-02-13 🎉 PicoClaw dépasse 5000 Stars en 4 jours ! Roadmap du projet et groupes de développeurs en cours.
|
||||
|
||||
2026-02-09 🎉 **PicoClaw publié !** Construit en 1 jour pour apporter les Agents IA sur du matériel à $10 avec <10 Mo de RAM. Let's Go, PicoClaw !
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## ✨ Fonctionnalités
|
||||
|
||||
🪶 **Ultra-léger** : Empreinte mémoire du cœur <10 Mo — 99% plus petit qu'OpenClaw.*
|
||||
|
||||
💰 **Coût minimal** : Suffisamment efficace pour fonctionner sur du matériel à $10 — 98% moins cher qu'un Mac mini.
|
||||
|
||||
⚡️ **Démarrage ultra-rapide** : 400x plus rapide au démarrage. Démarre en <1s même sur un processeur monocœur à 0,6 GHz.
|
||||
|
||||
🌍 **Vraiment portable** : Binaire unique pour les architectures RISC-V, ARM, MIPS et x86. Un seul binaire, fonctionne partout !
|
||||
|
||||
🤖 **Auto-amorcé par IA** : Implémentation native pure Go — 95% du code principal a été généré par un Agent et affiné via une révision humaine en boucle.
|
||||
|
||||
🔌 **Support MCP** : Intégration native du [Model Context Protocol](https://modelcontextprotocol.io/) — connectez n'importe quel serveur MCP pour étendre les capacités de l'Agent.
|
||||
|
||||
👁️ **Pipeline vision** : Envoyez des images et des fichiers directement à l'Agent — encodage base64 automatique pour les LLMs multimodaux.
|
||||
|
||||
🧠 **Routage intelligent** : Routage de modèles basé sur des règles — les requêtes simples vont vers des modèles légers, économisant les coûts API.
|
||||
|
||||
_*Les builds récents peuvent utiliser 10-20 Mo en raison des fusions rapides de PRs. L'optimisation des ressources est prévue. Comparaison de vitesse de démarrage basée sur des benchmarks monocœur à 0,8 GHz (voir tableau ci-dessous)._
|
||||
|
||||
<div align="center">
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
|
||||
| **Langage** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1 Go | >100 Mo | **< 10 Mo*** |
|
||||
| **Temps de démarrage**</br>(cœur 0,8 GHz) | >500s | >30s | **<1s** |
|
||||
| **Coût** | Mac Mini $599 | La plupart des cartes Linux ~$50 | **N'importe quelle carte Linux**</br>**à partir de $10** |
|
||||
|
||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
</div>
|
||||
|
||||
> **[Liste de compatibilité matérielle](docs/fr/hardware-compatibility.md)** — Voir toutes les cartes testées, du RISC-V à $5 au Raspberry Pi en passant par les téléphones Android. Votre carte n'est pas listée ? Soumettez une PR !
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
||||
</p>
|
||||
|
||||
## 🦾 Démonstration
|
||||
|
||||
### 🛠️ Flux de travail standard de l'assistant
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th><p align="center">Mode Ingénieur Full-Stack</p></th>
|
||||
<th><p align="center">Journalisation & Planification</p></th>
|
||||
<th><p align="center">Recherche Web & Apprentissage</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">Développer · Déployer · Mettre à l'échelle</td>
|
||||
<td align="center">Planifier · Automatiser · Mémoriser</td>
|
||||
<td align="center">Découvrir · Analyser · Tendances</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 🐜 Déploiement innovant à faible empreinte
|
||||
|
||||
PicoClaw peut être déployé sur pratiquement n'importe quel appareil Linux !
|
||||
|
||||
- $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) édition E(Ethernet) ou W(WiFi6), pour un assistant domestique minimal
|
||||
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), pour des opérations serveur automatisées
|
||||
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ou $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera), pour la surveillance intelligente
|
||||
|
||||
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
|
||||
|
||||
🌟 D'autres cas de déploiement vous attendent !
|
||||
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### Télécharger depuis picoclaw.io (Recommandé)
|
||||
|
||||
Visitez **[picoclaw.io](https://picoclaw.io)** — le site officiel détecte automatiquement votre plateforme et fournit un téléchargement en un clic. Pas besoin de choisir manuellement une architecture.
|
||||
|
||||
### Télécharger le binaire précompilé
|
||||
|
||||
Vous pouvez aussi télécharger le binaire pour votre plateforme depuis la page [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
|
||||
|
||||
### Compiler depuis les sources (pour le développement)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# Compiler le binaire principal
|
||||
make build
|
||||
|
||||
# Compiler le Web UI Launcher (requis pour le mode WebUI)
|
||||
make build-launcher
|
||||
|
||||
# Compiler pour plusieurs plateformes
|
||||
make build-all
|
||||
|
||||
# Compiler pour Raspberry Pi Zero 2 W (32 bits : make build-linux-arm ; 64 bits : make build-linux-arm64)
|
||||
make build-pi-zero
|
||||
|
||||
# Compiler et installer
|
||||
make install
|
||||
```
|
||||
|
||||
**Raspberry Pi Zero 2 W :** Utilisez le binaire correspondant à votre OS : Raspberry Pi OS 32 bits -> `make build-linux-arm` ; 64 bits -> `make build-linux-arm64`. Ou exécutez `make build-pi-zero` pour compiler les deux.
|
||||
|
||||
## 🚀 Guide de démarrage rapide
|
||||
|
||||
### 🌐 WebUI Launcher (Recommandé pour le bureau)
|
||||
|
||||
Le WebUI Launcher fournit une interface basée sur navigateur pour la configuration et le chat. C'est la façon la plus simple de démarrer — aucune connaissance de la ligne de commande requise.
|
||||
|
||||
**Option 1 : Double-clic (Bureau)**
|
||||
|
||||
Après téléchargement depuis [picoclaw.io](https://picoclaw.io), double-cliquez sur `picoclaw-launcher` (ou `picoclaw-launcher.exe` sous Windows). Votre navigateur s'ouvrira automatiquement sur `http://localhost:18800`.
|
||||
|
||||
**Option 2 : Ligne de commande**
|
||||
|
||||
```bash
|
||||
picoclaw-launcher
|
||||
# Ouvrez http://localhost:18800 dans votre navigateur
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> **Accès distant / Docker / VM :** Ajoutez le flag `-public` pour écouter sur toutes les interfaces :
|
||||
> ```bash
|
||||
> picoclaw-launcher -public
|
||||
> ```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Pour commencer :**
|
||||
|
||||
Ouvrez le WebUI, puis : **1)** Configurez un Provider (ajoutez votre clé API LLM) -> **2)** Configurez un Channel (ex. Telegram) -> **3)** Démarrez le Gateway -> **4)** Chattez !
|
||||
|
||||
Pour la documentation détaillée du WebUI, voir [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
<details>
|
||||
<summary><b>Docker (alternative)</b></summary>
|
||||
|
||||
```bash
|
||||
# 1. Cloner ce dépôt
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
|
||||
# 2. Premier lancement — génère automatiquement docker/data/config.json puis s'arrête
|
||||
# (se déclenche uniquement quand config.json et workspace/ sont tous deux absents)
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up
|
||||
# Le conteneur affiche "First-run setup complete." et s'arrête.
|
||||
|
||||
# 3. Définir vos clés API
|
||||
vim docker/data/config.json
|
||||
|
||||
# 4. Démarrer
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
# Ouvrez http://localhost:18800
|
||||
```
|
||||
|
||||
> **Utilisateurs Docker / VM :** Le Gateway écoute sur `127.0.0.1` par défaut. Définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` ou utilisez le flag `-public` pour le rendre accessible depuis l'hôte.
|
||||
|
||||
```bash
|
||||
# Vérifier les logs
|
||||
docker compose -f docker/docker-compose.yml logs -f
|
||||
|
||||
# Arrêter
|
||||
docker compose -f docker/docker-compose.yml --profile launcher down
|
||||
|
||||
# Mettre à jour
|
||||
docker compose -f docker/docker-compose.yml pull
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### 💻 TUI Launcher (Recommandé pour les environnements sans interface / SSH)
|
||||
|
||||
Le TUI (Terminal UI) Launcher fournit une interface terminal complète pour la configuration et la gestion. Idéal pour les serveurs, Raspberry Pi et autres environnements sans interface graphique.
|
||||
|
||||
```bash
|
||||
picoclaw-launcher-tui
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Pour commencer :**
|
||||
|
||||
Utilisez les menus TUI pour : **1)** Configurer un Provider -> **2)** Configurer un Channel -> **3)** Démarrer le Gateway -> **4)** Chattez !
|
||||
|
||||
Pour la documentation détaillée du TUI, voir [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
### 📱 Android
|
||||
|
||||
Donnez une seconde vie à votre téléphone vieux de dix ans ! Transformez-le en assistant IA intelligent avec PicoClaw.
|
||||
|
||||
**Option 1 : Termux (disponible maintenant)**
|
||||
|
||||
1. Installez [Termux](https://github.com/termux/termux-app) (téléchargez depuis [GitHub Releases](https://github.com/termux/termux-app/releases), ou cherchez dans F-Droid / Google Play)
|
||||
2. Exécutez les commandes suivantes :
|
||||
|
||||
```bash
|
||||
# Télécharger la dernière version
|
||||
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
|
||||
tar xzf picoclaw_Linux_arm64.tar.gz
|
||||
pkg install proot
|
||||
termux-chroot ./picoclaw onboard # chroot fournit une arborescence Linux standard
|
||||
```
|
||||
|
||||
Suivez ensuite la section Terminal Launcher ci-dessous pour terminer la configuration.
|
||||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||
|
||||
**Option 2 : Installation APK (bientôt disponible)**
|
||||
|
||||
Un APK Android autonome avec WebUI intégré est en développement. Restez à l'écoute !
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (pour les environnements à ressources limitées)</b></summary>
|
||||
|
||||
Pour les environnements minimaux où seul le binaire principal `picoclaw` est disponible (sans Launcher UI), vous pouvez tout configurer via la ligne de commande et un fichier de configuration JSON.
|
||||
|
||||
**1. Initialiser**
|
||||
|
||||
```bash
|
||||
picoclaw onboard
|
||||
```
|
||||
|
||||
Cela crée `~/.picoclaw/config.json` et le répertoire workspace.
|
||||
|
||||
**2. Configurer** (`~/.picoclaw/config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
}
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-api-key"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> Voir `config/config.example.json` dans le dépôt pour un modèle de configuration complet avec toutes les options disponibles.
|
||||
|
||||
**3. Chatter**
|
||||
|
||||
```bash
|
||||
# Question ponctuelle
|
||||
picoclaw agent -m "What is 2+2?"
|
||||
|
||||
# Mode interactif
|
||||
picoclaw agent
|
||||
|
||||
# Démarrer le gateway pour l'intégration d'applications de chat
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## 🔌 Providers (LLM)
|
||||
|
||||
PicoClaw supporte plus de 30 providers LLM via la configuration `model_list`. Utilisez le format `protocole/modèle` :
|
||||
|
||||
| Provider | Protocole | Clé API | Notes |
|
||||
|----------|-----------|---------|-------|
|
||||
| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Requise | GPT-5.4, GPT-4o, o3, etc. |
|
||||
| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Requise | Claude Opus 4.6, Sonnet 4.6, etc. |
|
||||
| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Requise | Gemini 3 Flash, 2.5 Pro, etc. |
|
||||
| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Requise | 200+ modèles, API unifiée |
|
||||
| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Requise | GLM-4.7, GLM-5, etc. |
|
||||
| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Requise | DeepSeek-V3, DeepSeek-R1 |
|
||||
| [Volcengine](https://console.volcengine.com) | `volcengine/` | Requise | Modèles Doubao, Ark |
|
||||
| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Requise | Qwen3, Qwen-Max, etc. |
|
||||
| [Groq](https://console.groq.com/keys) | `groq/` | Requise | Inférence rapide (Llama, Mixtral) |
|
||||
| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Requise | Modèles Kimi |
|
||||
| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Requise | Modèles MiniMax |
|
||||
| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Requise | Mistral Large, Codestral |
|
||||
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Requise | Modèles hébergés NVIDIA |
|
||||
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Requise | Inférence rapide |
|
||||
| [Novita AI](https://novita.ai/) | `novita/` | Requise | Divers modèles open |
|
||||
| [Ollama](https://ollama.com/) | `ollama/` | Non requise | Modèles locaux, auto-hébergé |
|
||||
| [vLLM](https://docs.vllm.ai/) | `vllm/` | Non requise | Déploiement local, compatible OpenAI |
|
||||
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Variable | Proxy pour 100+ providers |
|
||||
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Requise | Déploiement Azure entreprise |
|
||||
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Connexion par code appareil |
|
||||
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
|
||||
|
||||
<details>
|
||||
<summary><b>Déploiement local (Ollama, vLLM, etc.)</b></summary>
|
||||
|
||||
**Ollama :**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-llama",
|
||||
"model": "ollama/llama3.1:8b",
|
||||
"api_base": "http://localhost:11434/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**vLLM :**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-vllm",
|
||||
"model": "vllm/your-model",
|
||||
"api_base": "http://localhost:8000/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Pour les détails complets de configuration des providers, voir [Providers & Models](docs/fr/providers.md).
|
||||
|
||||
</details>
|
||||
|
||||
## 💬 Channels (Applications de chat)
|
||||
|
||||
Parlez à votre PicoClaw via plus de 17 plateformes de messagerie :
|
||||
|
||||
| Channel | Configuration | Protocole | Docs |
|
||||
|---------|---------------|-----------|------|
|
||||
| **Telegram** | Facile (token bot) | Long polling | [Guide](docs/channels/telegram/README.fr.md) |
|
||||
| **Discord** | Facile (token bot + intents) | WebSocket | [Guide](docs/channels/discord/README.fr.md) |
|
||||
| **WhatsApp** | Facile (scan QR ou URL bridge) | Natif / Bridge | [Guide](docs/fr/chat-apps.md#whatsapp) |
|
||||
| **Weixin** | Facile (scan QR natif) | iLink API | [Guide](docs/fr/chat-apps.md#weixin) |
|
||||
| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.fr.md) |
|
||||
| **Slack** | Facile (token bot + app) | Socket Mode | [Guide](docs/channels/slack/README.fr.md) |
|
||||
| **Matrix** | Moyen (homeserver + token) | Sync API | [Guide](docs/channels/matrix/README.fr.md) |
|
||||
| **DingTalk** | Moyen (identifiants client) | Stream | [Guide](docs/channels/dingtalk/README.fr.md) |
|
||||
| **Feishu / Lark** | Moyen (App ID + Secret) | WebSocket/SDK | [Guide](docs/channels/feishu/README.fr.md) |
|
||||
| **LINE** | Moyen (identifiants + webhook) | Webhook | [Guide](docs/channels/line/README.fr.md) |
|
||||
| **WeCom Bot** | Moyen (URL webhook) | Webhook | [Guide](docs/channels/wecom/wecom_bot/README.fr.md) |
|
||||
| **WeCom App** | Moyen (identifiants corp) | Webhook | [Guide](docs/channels/wecom/wecom_app/README.fr.md) |
|
||||
| **WeCom AI Bot** | Moyen (token + clé AES) | WebSocket / Webhook | [Guide](docs/channels/wecom/wecom_aibot/README.fr.md) |
|
||||
| **IRC** | Moyen (serveur + pseudo) | Protocole IRC | [Guide](docs/fr/chat-apps.md#irc) |
|
||||
| **OneBot** | Moyen (URL WebSocket) | OneBot v11 | [Guide](docs/channels/onebot/README.fr.md) |
|
||||
| **MaixCam** | Facile (activer) | Socket TCP | [Guide](docs/channels/maixcam/README.fr.md) |
|
||||
| **Pico** | Facile (activer) | Protocole natif | Intégré |
|
||||
| **Pico Client** | Facile (URL WebSocket) | WebSocket | Intégré |
|
||||
|
||||
> Tous les channels basés sur webhook partagent un seul serveur HTTP Gateway (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). Feishu utilise le mode WebSocket/SDK et n'utilise pas le serveur HTTP partagé.
|
||||
|
||||
Pour les instructions détaillées de configuration des channels, voir [Configuration des applications de chat](docs/fr/chat-apps.md).
|
||||
|
||||
## 🔧 Outils
|
||||
|
||||
### 🔍 Recherche Web
|
||||
|
||||
PicoClaw peut effectuer des recherches sur le web pour fournir des informations à jour. Configurez dans `tools.web` :
|
||||
|
||||
| Moteur de recherche | Clé API | Niveau gratuit | Lien |
|
||||
|--------------------|---------|----------------|------|
|
||||
| DuckDuckGo | Non requise | Illimité | Fallback intégré |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Requise | 1000 requêtes/jour | IA, optimisé pour le chinois |
|
||||
| [Tavily](https://tavily.com) | Requise | 1000 requêtes/mois | Optimisé pour les Agents IA |
|
||||
| [Brave Search](https://brave.com/search/api) | Requise | 2000 requêtes/mois | Rapide et privé |
|
||||
| [Perplexity](https://www.perplexity.ai) | Requise | Payant | Recherche propulsée par IA |
|
||||
| [SearXNG](https://github.com/searxng/searxng) | Non requise | Auto-hébergé | Métamoteur de recherche gratuit |
|
||||
| [GLM Search](https://open.bigmodel.cn/) | Requise | Variable | Recherche web Zhipu |
|
||||
|
||||
### ⚙️ Autres outils
|
||||
|
||||
PicoClaw inclut des outils intégrés pour les opérations sur fichiers, l'exécution de code, la planification et plus encore. Voir [Configuration des outils](docs/fr/tools_configuration.md) pour les détails.
|
||||
|
||||
## 🎯 Skills
|
||||
|
||||
Les Skills sont des capacités modulaires qui étendent votre Agent. Elles sont chargées depuis les fichiers `SKILL.md` dans votre workspace.
|
||||
|
||||
**Installer des Skills depuis ClawHub :**
|
||||
|
||||
```bash
|
||||
picoclaw skills search "web scraping"
|
||||
picoclaw skills install <skill-name>
|
||||
```
|
||||
|
||||
**Configurer le token ClawHub** (optionnel, pour des limites de débit plus élevées) :
|
||||
|
||||
Ajoutez à votre `config.json` :
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"skills": {
|
||||
"registries": {
|
||||
"clawhub": {
|
||||
"auth_token": "your-clawhub-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pour plus de détails, voir [Configuration des outils - Skills](docs/fr/tools_configuration.md#skills-tool).
|
||||
|
||||
## 🔗 MCP (Model Context Protocol)
|
||||
|
||||
PicoClaw supporte nativement [MCP](https://modelcontextprotocol.io/) — connectez n'importe quel serveur MCP pour étendre les capacités de votre Agent avec des outils et sources de données externes.
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcp": {
|
||||
"enabled": true,
|
||||
"servers": {
|
||||
"filesystem": {
|
||||
"enabled": true,
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pour la configuration MCP complète (transports stdio, SSE, HTTP, Tool Discovery), voir [Configuration des outils - MCP](docs/fr/tools_configuration.md#mcp-tool).
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Rejoignez le réseau social des Agents
|
||||
|
||||
Connectez PicoClaw au réseau social des Agents simplement en envoyant un seul message via le CLI ou n'importe quelle application de chat intégrée.
|
||||
|
||||
**Lisez `https://clawdchat.ai/skill.md` et suivez les instructions pour rejoindre [ClawdChat.ai](https://clawdchat.ai)**
|
||||
|
||||
## 🖥️ Référence CLI
|
||||
|
||||
| Commande | Description |
|
||||
| ------------------------- | ---------------------------------------- |
|
||||
| `picoclaw onboard` | Initialiser la config & le workspace |
|
||||
| `picoclaw auth weixin` | Connecter un compte WeChat via QR |
|
||||
| `picoclaw agent -m "..."` | Chatter avec l'agent |
|
||||
| `picoclaw agent` | Mode chat interactif |
|
||||
| `picoclaw gateway` | Démarrer le gateway |
|
||||
| `picoclaw status` | Afficher le statut |
|
||||
| `picoclaw version` | Afficher les informations de version |
|
||||
| `picoclaw model` | Voir ou changer le modèle par défaut |
|
||||
| `picoclaw cron list` | Lister toutes les tâches planifiées |
|
||||
| `picoclaw cron add ...` | Ajouter une tâche planifiée |
|
||||
| `picoclaw cron disable` | Désactiver une tâche planifiée |
|
||||
| `picoclaw cron remove` | Supprimer une tâche planifiée |
|
||||
| `picoclaw skills list` | Lister les Skills installées |
|
||||
| `picoclaw skills install` | Installer une Skill |
|
||||
| `picoclaw migrate` | Migrer les données depuis d'anciennes versions |
|
||||
| `picoclaw auth login` | S'authentifier auprès des providers |
|
||||
|
||||
### ⏰ Tâches planifiées / Rappels
|
||||
|
||||
PicoClaw supporte les rappels planifiés et les tâches récurrentes via l'outil `cron` :
|
||||
|
||||
* **Rappels ponctuels** : "Rappelle-moi dans 10 minutes" -> se déclenche une fois après 10 min
|
||||
* **Tâches récurrentes** : "Rappelle-moi toutes les 2 heures" -> se déclenche toutes les 2 heures
|
||||
* **Expressions cron** : "Rappelle-moi à 9h chaque jour" -> utilise une expression cron
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
Pour des guides détaillés au-delà de ce README :
|
||||
|
||||
| Sujet | Description |
|
||||
|-------|-------------|
|
||||
| [Docker & Démarrage rapide](docs/fr/docker.md) | Configuration Docker Compose, modes Launcher/Agent |
|
||||
| [Applications de chat](docs/fr/chat-apps.md) | Guides de configuration pour les 17+ channels |
|
||||
| [Configuration](docs/fr/configuration.md) | Variables d'environnement, structure du workspace, sandbox de sécurité |
|
||||
| [Providers & Modèles](docs/fr/providers.md) | 30+ providers LLM, routage de modèles, configuration model_list |
|
||||
| [Spawn & Tâches asynchrones](docs/fr/spawn-tasks.md) | Tâches rapides, tâches longues avec spawn, orchestration de sous-agents asynchrones |
|
||||
| [Hooks](docs/hooks/README.md) | Système de hooks événementiels : observateurs, intercepteurs, hooks d'approbation |
|
||||
| [Steering](docs/steering.md) | Injecter des messages dans une boucle agent en cours d'exécution |
|
||||
| [SubTurn](docs/subturn.md) | Coordination de subagents, contrôle de concurrence, cycle de vie |
|
||||
| [Dépannage](docs/fr/troubleshooting.md) | Problèmes courants et solutions |
|
||||
| [Configuration des outils](docs/fr/tools_configuration.md) | Activation/désactivation par outil, politiques d'exécution, MCP, Skills |
|
||||
| [Compatibilité matérielle](docs/fr/hardware-compatibility.md) | Cartes testées, exigences minimales |
|
||||
|
||||
## 🤝 Contribuer & Roadmap
|
||||
|
||||
Les PRs sont les bienvenues ! Le code source est intentionnellement petit et lisible.
|
||||
|
||||
Consultez notre [Roadmap communautaire](https://github.com/sipeed/picoclaw/issues/988) et [CONTRIBUTING.md](CONTRIBUTING.md) pour les directives.
|
||||
|
||||
Groupe de développeurs en construction, rejoignez-le après votre première PR fusionnée !
|
||||
|
||||
Groupes d'utilisateurs :
|
||||
|
||||
Discord : <https://discord.gg/V4sAZ9XWpN>
|
||||
|
||||
WeChat :
|
||||
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
||||
|
||||
|
||||
|
||||
|
||||
-579
@@ -1,579 +0,0 @@
|
||||
<div align="center">
|
||||
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||
|
||||
<h1>PicoClaw: Asisten AI Super Ringan berbasis Go</h1>
|
||||
|
||||
<h3>Perangkat Keras $10 · RAM 10MB · Boot ms · Let's Go, PicoClaw!</h3>
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
<br>
|
||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
|
||||
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
||||
<br>
|
||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</p>
|
||||
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [English](README.md) | **Bahasa Indonesia**
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
> **PicoClaw** adalah proyek open-source independen yang diinisiasi oleh [Sipeed](https://sipeed.com), ditulis sepenuhnya dalam **Go** — bukan fork dari OpenClaw, NanoBot, atau proyek lainnya.
|
||||
|
||||
**PicoClaw** adalah asisten AI pribadi yang super ringan, terinspirasi dari [NanoBot](https://github.com/HKUDS/nanobot). Dibangun ulang dari awal dalam **Go** melalui proses "self-bootstrapping" — AI Agent itu sendiri yang memandu migrasi arsitektur dan optimasi kode.
|
||||
|
||||
**Berjalan di perangkat keras $10 dengan RAM <10MB** — hemat 99% memori dibanding OpenClaw dan 98% lebih murah dari Mac mini!
|
||||
|
||||
<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]
|
||||
> **Peringatan Keamanan**
|
||||
>
|
||||
> * **TANPA KRIPTO:** PicoClaw **tidak** menerbitkan token atau cryptocurrency resmi apa pun. Semua klaim di `pump.fun` atau platform trading lainnya adalah **penipuan**.
|
||||
> * **DOMAIN RESMI:** Satu-satunya website resmi adalah **[picoclaw.io](https://picoclaw.io)**, dan website perusahaan adalah **[sipeed.com](https://sipeed.com)**
|
||||
> * **WASPADA:** Banyak domain `.ai/.org/.com/.net/...` telah didaftarkan oleh pihak ketiga. Jangan percaya mereka.
|
||||
> * **CATATAN:** PicoClaw masih dalam tahap pengembangan awal yang cepat. Mungkin ada masalah keamanan yang belum terselesaikan. Jangan deploy ke produksi sebelum v1.0.
|
||||
> * **CATATAN:** PicoClaw baru-baru ini menggabungkan banyak PR. Build terbaru mungkin menggunakan RAM 10-20MB. Optimasi sumber daya direncanakan setelah fitur stabil.
|
||||
|
||||
## 📢 Berita
|
||||
|
||||
2026-03-17 🚀 **v0.2.3 Dirilis!** UI system tray (Windows & Linux), pelacakan status sub-agent (`spawn_status`), eksperimental Gateway hot-reload, gerbang keamanan Cron, dan 2 perbaikan keamanan. PicoClaw telah mencapai **25K Stars**!
|
||||
|
||||
2026-03-09 🎉 **v0.2.1 — Update terbesar sejauh ini!** Dukungan protokol MCP, 4 channel baru (Matrix/IRC/WeCom/Discord Proxy), 3 provider baru (Kimi/Minimax/Avian), pipeline vision, penyimpanan memori JSONL, routing model.
|
||||
|
||||
2026-02-28 📦 **v0.2.0** dirilis dengan dukungan Docker Compose dan Web UI Launcher.
|
||||
|
||||
2026-02-26 🎉 PicoClaw mencapai **20K Stars** hanya dalam 17 hari! Orkestrasi channel otomatis dan antarmuka kapabilitas kini aktif.
|
||||
|
||||
<details>
|
||||
<summary>Berita sebelumnya...</summary>
|
||||
|
||||
2026-02-16 🎉 PicoClaw menembus 12K Stars dalam satu minggu! Peran maintainer komunitas dan [Roadmap](ROADMAP.md) resmi diluncurkan.
|
||||
|
||||
2026-02-13 🎉 PicoClaw menembus 5000 Stars dalam 4 hari! Roadmap proyek dan grup pengembang sedang dalam proses.
|
||||
|
||||
2026-02-09 🎉 **PicoClaw Diluncurkan!** Dibangun dalam 1 hari untuk menghadirkan AI Agent ke perangkat keras $10 dengan RAM <10MB. Let's Go, PicoClaw!
|
||||
|
||||
</details>
|
||||
|
||||
## ✨ Fitur
|
||||
|
||||
🪶 **Super Ringan**: Penggunaan memori inti <10MB — 99% lebih kecil dari OpenClaw.*
|
||||
|
||||
💰 **Biaya Minimal**: Cukup efisien untuk berjalan di perangkat keras $10 — 98% lebih murah dari Mac mini.
|
||||
|
||||
⚡️ **Boot Secepat Kilat**: Startup 400x lebih cepat. Boot dalam <1 detik bahkan di prosesor single-core 0,6GHz.
|
||||
|
||||
🌍 **Portabilitas Sejati**: Satu binary untuk RISC-V, ARM, MIPS, dan x86. Satu binary, jalan di mana saja!
|
||||
|
||||
🤖 **AI-Bootstrapped**: Implementasi Go native murni — 95% kode inti dihasilkan oleh Agent dengan penyempurnaan human-in-the-loop.
|
||||
|
||||
🔌 **Dukungan MCP**: Integrasi [Model Context Protocol](https://modelcontextprotocol.io/) native — hubungkan server MCP mana pun untuk memperluas kapabilitas Agent.
|
||||
|
||||
👁️ **Pipeline Vision**: Kirim gambar dan file langsung ke Agent — encoding base64 otomatis untuk LLM multimodal.
|
||||
|
||||
🧠 **Routing Cerdas**: Routing model berbasis aturan — kueri sederhana diarahkan ke model ringan, menghemat biaya API.
|
||||
|
||||
_*Build terbaru mungkin menggunakan 10-20MB karena penggabungan PR yang cepat. Optimasi sumber daya direncanakan. Perbandingan kecepatan boot berdasarkan benchmark single-core 0,8GHz (lihat tabel di bawah)._
|
||||
|
||||
<div align="center">
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
|
||||
| **Bahasa** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1GB | >100MB | **< 10MB*** |
|
||||
| **Waktu Boot**</br>(core 0,8GHz) | >500d | >30d | **<1d** |
|
||||
| **Biaya** | Mac Mini $599 | Kebanyakan board Linux ~$50 | **Board Linux mana pun**</br>**mulai $10** |
|
||||
|
||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
</div>
|
||||
|
||||
> **[Daftar Kompatibilitas Hardware](docs/hardware-compatibility.md)** — Lihat semua board yang telah diuji, dari RISC-V $5 hingga Raspberry Pi hingga ponsel Android. Board Anda belum terdaftar? Kirim PR!
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
||||
</p>
|
||||
|
||||
## 🦾 Demonstrasi
|
||||
|
||||
### 🛠️ Alur Kerja Asisten Standar
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th><p align="center">Mode Full-Stack Engineer</p></th>
|
||||
<th><p align="center">Pencatatan & Perencanaan</p></th>
|
||||
<th><p align="center">Pencarian Web & Pembelajaran</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">Develop · Deploy · Scale</td>
|
||||
<td align="center">Jadwal · Otomasi · Ingat</td>
|
||||
<td align="center">Temukan · Wawasan · Tren</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 🐜 Deploy Inovatif dengan Footprint Rendah
|
||||
|
||||
PicoClaw dapat di-deploy di hampir semua perangkat Linux!
|
||||
|
||||
- $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versi E(Ethernet) atau W(WiFi6), untuk home assistant minimal
|
||||
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), atau $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), untuk operasi server otomatis
|
||||
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) atau $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera), untuk pengawasan cerdas
|
||||
|
||||
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
|
||||
|
||||
🌟 Lebih Banyak Kasus Deploy Menanti!
|
||||
|
||||
## 📦 Instalasi
|
||||
|
||||
### Unduh dari picoclaw.io (Direkomendasikan)
|
||||
|
||||
Kunjungi **[picoclaw.io](https://picoclaw.io)** — website resmi mendeteksi platform Anda secara otomatis dan menyediakan unduhan satu klik. Tidak perlu memilih arsitektur secara manual.
|
||||
|
||||
### Unduh binary yang sudah dikompilasi
|
||||
|
||||
Atau, unduh binary untuk platform Anda dari halaman [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
|
||||
|
||||
### Build dari source (untuk pengembangan)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# Build binary inti
|
||||
make build
|
||||
|
||||
# Build Web UI Launcher (diperlukan untuk mode WebUI)
|
||||
make build-launcher
|
||||
|
||||
# Build untuk berbagai platform
|
||||
make build-all
|
||||
|
||||
# Build untuk Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||
make build-pi-zero
|
||||
|
||||
# Build dan instal
|
||||
make install
|
||||
```
|
||||
|
||||
**Raspberry Pi Zero 2 W:** Gunakan binary yang sesuai dengan OS Anda: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Atau jalankan `make build-pi-zero` untuk build keduanya.
|
||||
|
||||
## 🚀 Panduan Memulai Cepat
|
||||
|
||||
### 🌐 WebUI Launcher (Direkomendasikan untuk Desktop)
|
||||
|
||||
WebUI Launcher menyediakan antarmuka berbasis browser untuk konfigurasi dan chat. Ini adalah cara termudah untuk memulai — tidak perlu pengetahuan command-line.
|
||||
|
||||
**Opsi 1: Klik dua kali (Desktop)**
|
||||
|
||||
Setelah mengunduh dari [picoclaw.io](https://picoclaw.io), klik dua kali `picoclaw-launcher` (atau `picoclaw-launcher.exe` di Windows). Browser Anda akan terbuka otomatis di `http://localhost:18800`.
|
||||
|
||||
**Opsi 2: Command line**
|
||||
|
||||
```bash
|
||||
picoclaw-launcher
|
||||
# Buka http://localhost:18800 di browser Anda
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> **Akses jarak jauh / Docker / VM:** Tambahkan flag `-public` untuk mendengarkan di semua antarmuka:
|
||||
> ```bash
|
||||
> picoclaw-launcher -public
|
||||
> ```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Memulai:**
|
||||
|
||||
Buka WebUI, lalu: **1)** Konfigurasi Provider (tambahkan API key LLM Anda) -> **2)** Konfigurasi Channel (mis. Telegram) -> **3)** Mulai Gateway -> **4)** Chat!
|
||||
|
||||
Untuk dokumentasi WebUI lengkap, lihat [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
<details>
|
||||
<summary><b>Docker (alternatif)</b></summary>
|
||||
|
||||
```bash
|
||||
# 1. Clone repo ini
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
|
||||
# 2. Jalankan pertama kali — otomatis membuat docker/data/config.json lalu keluar
|
||||
# (hanya terpicu ketika config.json dan workspace/ keduanya tidak ada)
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up
|
||||
# Container mencetak "First-run setup complete." dan berhenti.
|
||||
|
||||
# 3. Atur API key Anda
|
||||
vim docker/data/config.json
|
||||
|
||||
# 4. Mulai
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
# Buka http://localhost:18800
|
||||
```
|
||||
|
||||
> **Pengguna Docker / VM:** Gateway mendengarkan di `127.0.0.1` secara default. Atur `PICOCLAW_GATEWAY_HOST=0.0.0.0` atau gunakan flag `-public` agar dapat diakses dari host.
|
||||
|
||||
```bash
|
||||
# Cek log
|
||||
docker compose -f docker/docker-compose.yml logs -f
|
||||
|
||||
# Hentikan
|
||||
docker compose -f docker/docker-compose.yml --profile launcher down
|
||||
|
||||
# Update
|
||||
docker compose -f docker/docker-compose.yml pull
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### 💻 TUI Launcher (Direkomendasikan untuk Headless / SSH)
|
||||
|
||||
TUI (Terminal UI) Launcher menyediakan antarmuka terminal lengkap untuk konfigurasi dan manajemen. Ideal untuk server, Raspberry Pi, dan lingkungan headless lainnya.
|
||||
|
||||
```bash
|
||||
picoclaw-launcher-tui
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Memulai:**
|
||||
|
||||
Gunakan menu TUI untuk: **1)** Konfigurasi Provider -> **2)** Konfigurasi Channel -> **3)** Mulai Gateway -> **4)** Chat!
|
||||
|
||||
Untuk dokumentasi TUI lengkap, lihat [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
### 📱 Android
|
||||
|
||||
Berikan kehidupan kedua untuk ponsel lama Anda! Ubah menjadi Asisten AI pintar dengan PicoClaw.
|
||||
|
||||
**Opsi 1: Termux (tersedia sekarang)**
|
||||
|
||||
1. Instal [Termux](https://github.com/termux/termux-app) (unduh dari [GitHub Releases](https://github.com/termux/termux-app/releases), atau cari di F-Droid / Google Play)
|
||||
2. Jalankan perintah berikut:
|
||||
|
||||
```bash
|
||||
# Unduh rilis terbaru
|
||||
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
|
||||
tar xzf picoclaw_Linux_arm64.tar.gz
|
||||
pkg install proot
|
||||
termux-chroot ./picoclaw onboard # chroot menyediakan tata letak filesystem Linux standar
|
||||
```
|
||||
|
||||
Kemudian ikuti bagian Terminal Launcher di bawah untuk menyelesaikan konfigurasi.
|
||||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||
|
||||
**Opsi 2: Instal APK (segera hadir)**
|
||||
|
||||
APK Android mandiri dengan WebUI bawaan sedang dalam pengembangan. Pantau terus!
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (untuk lingkungan dengan sumber daya terbatas)</b></summary>
|
||||
|
||||
Untuk lingkungan minimal di mana hanya binary inti `picoclaw` yang tersedia (tanpa Launcher UI), Anda dapat mengonfigurasi semuanya melalui command line dan file konfigurasi JSON.
|
||||
|
||||
**1. Inisialisasi**
|
||||
|
||||
```bash
|
||||
picoclaw onboard
|
||||
```
|
||||
|
||||
Ini membuat `~/.picoclaw/config.json` dan direktori workspace.
|
||||
|
||||
**2. Konfigurasi** (`~/.picoclaw/config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
}
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-api-key"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> Lihat `config/config.example.json` di repo untuk template konfigurasi lengkap dengan semua opsi yang tersedia.
|
||||
|
||||
**3. Chat**
|
||||
|
||||
```bash
|
||||
# Pertanyaan satu kali
|
||||
picoclaw agent -m "What is 2+2?"
|
||||
|
||||
# Mode interaktif
|
||||
picoclaw agent
|
||||
|
||||
# Mulai gateway untuk integrasi aplikasi chat
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 🔌 Providers (LLM)
|
||||
|
||||
PicoClaw mendukung 30+ provider LLM melalui konfigurasi `model_list`. Gunakan format `protocol/model`:
|
||||
|
||||
| Provider | Protocol | API Key | Catatan |
|
||||
|----------|----------|---------|---------|
|
||||
| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Diperlukan | GPT-5.4, GPT-4o, o3, dll. |
|
||||
| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Diperlukan | Claude Opus 4.6, Sonnet 4.6, dll. |
|
||||
| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Diperlukan | Gemini 3 Flash, 2.5 Pro, dll. |
|
||||
| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Diperlukan | 200+ model, API terpadu |
|
||||
| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Diperlukan | GLM-4.7, GLM-5, dll. |
|
||||
| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Diperlukan | DeepSeek-V3, DeepSeek-R1 |
|
||||
| [Volcengine](https://console.volcengine.com) | `volcengine/` | Diperlukan | Doubao, model Ark |
|
||||
| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Diperlukan | Qwen3, Qwen-Max, dll. |
|
||||
| [Groq](https://console.groq.com/keys) | `groq/` | Diperlukan | Inferensi cepat (Llama, Mixtral) |
|
||||
| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Diperlukan | Model Kimi |
|
||||
| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Diperlukan | Model MiniMax |
|
||||
| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Diperlukan | Mistral Large, Codestral |
|
||||
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Diperlukan | Model yang di-host NVIDIA |
|
||||
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Diperlukan | Inferensi cepat |
|
||||
| [Novita AI](https://novita.ai/) | `novita/` | Diperlukan | Berbagai model open |
|
||||
| [Ollama](https://ollama.com/) | `ollama/` | Tidak perlu | Model lokal, self-hosted |
|
||||
| [vLLM](https://docs.vllm.ai/) | `vllm/` | Tidak perlu | Deploy lokal, kompatibel OpenAI |
|
||||
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Bervariasi | Proxy untuk 100+ provider |
|
||||
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Diperlukan | Deploy Azure enterprise |
|
||||
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Login dengan device code |
|
||||
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
|
||||
|
||||
<details>
|
||||
<summary><b>Deploy lokal (Ollama, vLLM, dll.)</b></summary>
|
||||
|
||||
**Ollama:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-llama",
|
||||
"model": "ollama/llama3.1:8b",
|
||||
"api_base": "http://localhost:11434/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**vLLM:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-vllm",
|
||||
"model": "vllm/your-model",
|
||||
"api_base": "http://localhost:8000/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Untuk detail konfigurasi provider lengkap, lihat [Providers & Models](docs/providers.md).
|
||||
|
||||
</details>
|
||||
|
||||
## 💬 Channels (Aplikasi Chat)
|
||||
|
||||
Bicara dengan PicoClaw Anda melalui 17+ platform pesan:
|
||||
|
||||
| Channel | Pengaturan | Protocol | Dokumentasi |
|
||||
|---------|------------|----------|-------------|
|
||||
| **Telegram** | Mudah (bot token) | Long polling | [Panduan](docs/channels/telegram/README.md) |
|
||||
| **Discord** | Mudah (bot token + intents) | WebSocket | [Panduan](docs/channels/discord/README.md) |
|
||||
| **WhatsApp** | Mudah (scan QR atau bridge URL) | Native / Bridge | [Panduan](docs/chat-apps.md#whatsapp) |
|
||||
| **Weixin** | Mudah (scan QR native) | iLink API | [Panduan](docs/chat-apps.md#weixin) |
|
||||
| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](docs/channels/qq/README.md) |
|
||||
| **Slack** | Mudah (bot + app token) | Socket Mode | [Panduan](docs/channels/slack/README.md) |
|
||||
| **Matrix** | Sedang (homeserver + token) | Sync API | [Panduan](docs/channels/matrix/README.md) |
|
||||
| **DingTalk** | Sedang (client credentials) | Stream | [Panduan](docs/channels/dingtalk/README.md) |
|
||||
| **Feishu / Lark** | Sedang (App ID + Secret) | WebSocket/SDK | [Panduan](docs/channels/feishu/README.md) |
|
||||
| **LINE** | Sedang (credentials + webhook) | Webhook | [Panduan](docs/channels/line/README.md) |
|
||||
| **WeCom Bot** | Sedang (webhook URL) | Webhook | [Panduan](docs/channels/wecom/wecom_bot/README.md) |
|
||||
| **WeCom App** | Sedang (corp credentials) | Webhook | [Panduan](docs/channels/wecom/wecom_app/README.md) |
|
||||
| **WeCom AI Bot** | Sedang (token + AES key) | WebSocket / Webhook | [Panduan](docs/channels/wecom/wecom_aibot/README.md) |
|
||||
| **IRC** | Sedang (server + nick) | IRC protocol | [Panduan](docs/chat-apps.md#irc) |
|
||||
| **OneBot** | Sedang (WebSocket URL) | OneBot v11 | [Panduan](docs/channels/onebot/README.md) |
|
||||
| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](docs/channels/maixcam/README.md) |
|
||||
| **Pico** | Mudah (aktifkan) | Native protocol | Bawaan |
|
||||
| **Pico Client** | Mudah (WebSocket URL) | WebSocket | Bawaan |
|
||||
|
||||
> Semua channel berbasis webhook berbagi satu server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu menggunakan mode WebSocket/SDK dan tidak menggunakan server HTTP bersama.
|
||||
|
||||
Untuk instruksi pengaturan channel lengkap, lihat [Konfigurasi Aplikasi Chat](docs/chat-apps.md).
|
||||
|
||||
## 🔧 Tools
|
||||
|
||||
### 🔍 Pencarian Web
|
||||
|
||||
PicoClaw dapat mencari web untuk memberikan informasi terkini. Konfigurasi di `tools.web`:
|
||||
|
||||
| Mesin Pencari | API Key | Tier Gratis | Tautan |
|
||||
|--------------|---------|-------------|--------|
|
||||
| DuckDuckGo | Tidak perlu | Tidak terbatas | Fallback bawaan |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Diperlukan | 1000 kueri/hari | Bertenaga AI, dioptimalkan untuk bahasa Mandarin |
|
||||
| [Tavily](https://tavily.com) | Diperlukan | 1000 kueri/bulan | Dioptimalkan untuk AI Agent |
|
||||
| [Brave Search](https://brave.com/search/api) | Diperlukan | 2000 kueri/bulan | Cepat dan privat |
|
||||
| [Perplexity](https://www.perplexity.ai) | Diperlukan | Berbayar | Pencarian bertenaga AI |
|
||||
| [SearXNG](https://github.com/searxng/searxng) | Tidak perlu | Self-hosted | Mesin metasearch gratis |
|
||||
| [GLM Search](https://open.bigmodel.cn/) | Diperlukan | Bervariasi | Pencarian web Zhipu |
|
||||
|
||||
### ⚙️ Tools Lainnya
|
||||
|
||||
PicoClaw menyertakan tools bawaan untuk operasi file, eksekusi kode, penjadwalan, dan lainnya. Lihat [Konfigurasi Tools](docs/tools_configuration.md) untuk detail.
|
||||
|
||||
## 🎯 Skills
|
||||
|
||||
Skills adalah kapabilitas modular yang memperluas Agent Anda. Dimuat dari file `SKILL.md` di workspace Anda.
|
||||
|
||||
**Instal skills dari ClawHub:**
|
||||
|
||||
```bash
|
||||
picoclaw skills search "web scraping"
|
||||
picoclaw skills install <skill-name>
|
||||
```
|
||||
|
||||
**Konfigurasi token ClawHub** (opsional, untuk rate limit lebih tinggi):
|
||||
|
||||
Tambahkan ke `config.json` Anda:
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"skills": {
|
||||
"registries": {
|
||||
"clawhub": {
|
||||
"auth_token": "your-clawhub-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Untuk detail lebih lanjut, lihat [Konfigurasi Tools - Skills](docs/tools_configuration.md#skills-tool).
|
||||
|
||||
## 🔗 MCP (Model Context Protocol)
|
||||
|
||||
PicoClaw mendukung [MCP](https://modelcontextprotocol.io/) secara native — hubungkan server MCP mana pun untuk memperluas kapabilitas Agent Anda dengan tools dan sumber data eksternal.
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcp": {
|
||||
"enabled": true,
|
||||
"servers": {
|
||||
"filesystem": {
|
||||
"enabled": true,
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Untuk konfigurasi MCP lengkap (transport stdio, SSE, HTTP, Tool Discovery), lihat [Konfigurasi Tools - MCP](docs/tools_configuration.md#mcp-tool).
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Bergabung dengan Jaringan Sosial Agent
|
||||
|
||||
Hubungkan PicoClaw ke Jaringan Sosial Agent hanya dengan mengirim satu pesan melalui CLI atau Aplikasi Chat terintegrasi mana pun.
|
||||
|
||||
**Baca `https://clawdchat.ai/skill.md` dan ikuti instruksi untuk bergabung dengan [ClawdChat.ai](https://clawdchat.ai)**
|
||||
|
||||
## 🖥️ Referensi CLI
|
||||
|
||||
| Perintah | Deskripsi |
|
||||
| -------------------------- | -------------------------------- |
|
||||
| `picoclaw onboard` | Inisialisasi konfigurasi & workspace |
|
||||
| `picoclaw auth weixin` | Hubungkan akun WeChat via QR |
|
||||
| `picoclaw agent -m "..."` | Chat dengan agent |
|
||||
| `picoclaw agent` | Mode chat interaktif |
|
||||
| `picoclaw gateway` | Mulai gateway |
|
||||
| `picoclaw status` | Tampilkan status |
|
||||
| `picoclaw version` | Tampilkan info versi |
|
||||
| `picoclaw model` | Lihat atau ganti model default |
|
||||
| `picoclaw cron list` | Daftar semua tugas terjadwal |
|
||||
| `picoclaw cron add ...` | Tambah tugas terjadwal |
|
||||
| `picoclaw cron disable` | Nonaktifkan tugas terjadwal |
|
||||
| `picoclaw cron remove` | Hapus tugas terjadwal |
|
||||
| `picoclaw skills list` | Daftar skill yang terinstal |
|
||||
| `picoclaw skills install` | Instal skill |
|
||||
| `picoclaw migrate` | Migrasi data dari versi lama |
|
||||
| `picoclaw auth login` | Autentikasi dengan provider |
|
||||
|
||||
### ⏰ Tugas Terjadwal / Pengingat
|
||||
|
||||
PicoClaw mendukung pengingat terjadwal dan tugas berulang melalui tool `cron`:
|
||||
|
||||
* **Pengingat satu kali**: "Ingatkan saya dalam 10 menit" -> terpicu sekali setelah 10 menit
|
||||
* **Tugas berulang**: "Ingatkan saya setiap 2 jam" -> terpicu setiap 2 jam
|
||||
* **Ekspresi cron**: "Ingatkan saya jam 9 pagi setiap hari" -> menggunakan ekspresi cron
|
||||
|
||||
## 📚 Dokumentasi
|
||||
|
||||
Untuk panduan lengkap di luar README ini:
|
||||
|
||||
| Topik | Deskripsi |
|
||||
|-------|-----------|
|
||||
| [Docker & Panduan Cepat](docs/docker.md) | Pengaturan Docker Compose, mode Launcher/Agent |
|
||||
| [Aplikasi Chat](docs/chat-apps.md) | Semua 17+ panduan pengaturan channel |
|
||||
| [Konfigurasi](docs/configuration.md) | Variabel environment, tata letak workspace, sandbox keamanan |
|
||||
| [Providers & Models](docs/providers.md) | 30+ provider LLM, routing model, konfigurasi model_list |
|
||||
| [Spawn & Tugas Async](docs/spawn-tasks.md) | Tugas cepat, tugas panjang dengan spawn, orkestrasi sub-agent async |
|
||||
| [Hooks](docs/hooks/README.md) | Sistem hook berbasis event: observer, interceptor, approval hook |
|
||||
| [Steering](docs/steering.md) | Menyuntikkan pesan ke dalam loop agent yang sedang berjalan |
|
||||
| [SubTurn](docs/subturn.md) | Koordinasi subagent, kontrol konkurensi, siklus hidup |
|
||||
| [Pemecahan Masalah](docs/troubleshooting.md) | Masalah umum dan solusinya |
|
||||
| [Konfigurasi Tools](docs/tools_configuration.md) | Aktifkan/nonaktifkan per-tool, kebijakan exec, MCP, Skills |
|
||||
| [Kompatibilitas Hardware](docs/hardware-compatibility.md) | Board yang telah diuji, persyaratan minimum |
|
||||
|
||||
## 🤝 Kontribusi & Roadmap
|
||||
|
||||
PR sangat diterima! Codebase sengaja dibuat kecil dan mudah dibaca.
|
||||
|
||||
Lihat [Roadmap Komunitas](https://github.com/sipeed/picoclaw/issues/988) dan [CONTRIBUTING.md](CONTRIBUTING.md) untuk panduan.
|
||||
|
||||
Grup pengembang sedang dibangun, bergabunglah setelah PR pertama Anda di-merge!
|
||||
|
||||
Grup Pengguna:
|
||||
|
||||
Discord: <https://discord.gg/V4sAZ9XWpN>
|
||||
|
||||
WeChat:
|
||||
<img src="assets/wechat.png" alt="Kode QR grup WeChat" width="512">
|
||||
|
||||
-578
@@ -1,578 +0,0 @@
|
||||
<div align="center">
|
||||
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||
|
||||
<h1>PicoClaw: Assistente IA Ultra-Efficiente in Go</h1>
|
||||
|
||||
<h3>Hardware da $10 · 10MB di RAM · Avvio in ms · Let's Go, PicoClaw!</h3>
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
<br>
|
||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
|
||||
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
||||
<br>
|
||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</p>
|
||||
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [English](README.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
> **PicoClaw** è un progetto open-source indipendente avviato da [Sipeed](https://sipeed.com), scritto interamente in **Go** da zero — non è un fork di OpenClaw, NanoBot o di qualsiasi altro progetto.
|
||||
|
||||
**PicoClaw** è un assistente IA personale ultra-leggero ispirato a [NanoBot](https://github.com/HKUDS/nanobot). È stato riscritto da zero in **Go** attraverso un processo di "auto-bootstrapping" — l'Agent IA stesso ha guidato la migrazione architetturale e l'ottimizzazione del codice.
|
||||
|
||||
**Funziona su hardware da $10 con <10MB di RAM** — il 99% di memoria in meno rispetto a OpenClaw e il 98% più economico di un Mac mini!
|
||||
|
||||
<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]
|
||||
> **Avviso di Sicurezza**
|
||||
>
|
||||
> * **NESSUNA CRYPTO:** PicoClaw **non** ha emesso token o criptovalute ufficiali. Qualsiasi annuncio su `pump.fun` o altre piattaforme di trading è una **truffa**.
|
||||
> * **DOMINIO UFFICIALE:** L'**UNICO** sito ufficiale è **[picoclaw.io](https://picoclaw.io)**, e il sito aziendale è **[sipeed.com](https://sipeed.com)**
|
||||
> * **ATTENZIONE:** Molti domini `.ai/.org/.com/.net/...` sono stati registrati da terze parti. Non fidarti di essi.
|
||||
> * **NOTA:** PicoClaw è in fase di sviluppo iniziale rapido. Potrebbero esserci problemi di sicurezza non risolti. Non distribuire in produzione prima della v1.0.
|
||||
> * **NOTA:** PicoClaw ha recentemente unito molte PR. Le build recenti potrebbero usare 10-20MB di RAM. L'ottimizzazione delle risorse è pianificata dopo la stabilizzazione delle funzionalità.
|
||||
|
||||
## 📢 Novità
|
||||
|
||||
2026-03-17 🚀 **v0.2.3 rilasciata!** Interfaccia system tray (Windows & Linux), query sullo stato dei sub-agent (`spawn_status`), hot-reload sperimentale del Gateway, gate di sicurezza per Cron e 2 correzioni di sicurezza. PicoClaw raggiunge **25K Stars**!
|
||||
|
||||
2026-03-09 🎉 **v0.2.1 — Il più grande aggiornamento di sempre!** Supporto al protocollo MCP, 4 nuovi canali (Matrix/IRC/WeCom/Discord Proxy), 3 nuovi provider (Kimi/Minimax/Avian), pipeline di visione, store di memoria JSONL e routing dei modelli.
|
||||
|
||||
2026-02-28 📦 **v0.2.0** rilasciata con supporto Docker Compose e Web UI Launcher.
|
||||
|
||||
2026-02-26 🎉 PicoClaw raggiunge **20K stelle** in soli 17 giorni! Orchestrazione automatica dei canali e interfacce di capacità sono attive.
|
||||
|
||||
<details>
|
||||
<summary>Notizie precedenti...</summary>
|
||||
|
||||
2026-02-16 🎉 PicoClaw supera 12K stelle in una settimana! Ruoli di maintainer della community e [Roadmap](ROADMAP.md) pubblicati ufficialmente.
|
||||
|
||||
2026-02-13 🎉 PicoClaw supera 5000 stelle in 4 giorni! Roadmap del progetto e gruppi sviluppatori in fase di avvio.
|
||||
|
||||
2026-02-09 🎉 **PicoClaw lanciato!** Costruito in 1 giorno per portare gli AI Agent su hardware da $10 con <10MB di RAM. Let's Go, PicoClaw!
|
||||
|
||||
</details>
|
||||
|
||||
## ✨ Caratteristiche
|
||||
|
||||
🪶 **Ultra-Leggero**: Impronta di memoria <10MB — il 99% più piccolo rispetto a OpenClaw.*
|
||||
|
||||
💰 **Costo Minimo**: Abbastanza efficiente da girare su hardware da $10 — il 98% più economico di un Mac mini.
|
||||
|
||||
⚡️ **Avvio Fulmineo**: Avvio 400 volte più veloce. Boot in meno di 1 secondo anche su un singolo core a 0,6 GHz.
|
||||
|
||||
🌍 **Vera Portabilità**: Singolo binario per RISC-V, ARM, MIPS e x86. Un binario, funziona ovunque!
|
||||
|
||||
🤖 **Auto-Costruito dall'IA**: Implementazione nativa in Go — il 95% del codice core è stato generato da un Agent e perfezionato tramite revisione umana nel ciclo.
|
||||
|
||||
🔌 **Supporto MCP**: Integrazione nativa del [Model Context Protocol](https://modelcontextprotocol.io/) — connetti qualsiasi server MCP per estendere le capacità dell'Agent.
|
||||
|
||||
👁️ **Pipeline di Visione**: Invia immagini e file direttamente all'Agent — codifica base64 automatica per LLM multimodali.
|
||||
|
||||
🧠 **Routing Intelligente**: Routing dei modelli basato su regole — le query semplici vanno verso modelli leggeri, risparmiando sui costi API.
|
||||
|
||||
_*Le build recenti potrebbero usare 10-20MB a causa delle fusioni rapide di PR. L'ottimizzazione delle risorse è pianificata. Il confronto dell'avvio è basato su benchmark con singolo core a 0,8 GHz (vedi tabella sotto)._
|
||||
|
||||
<div align="center">
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
|
||||
| **Linguaggio** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1GB | >100MB | **< 10MB*** |
|
||||
| **Avvio**</br>(core 0,8 GHz) | >500s | >30s | **<1s** |
|
||||
| **Costo** | Mac Mini $599 | La maggior parte degli SBC Linux ~$50 | **Qualsiasi scheda Linux**</br>**a partire da $10** |
|
||||
|
||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
</div>
|
||||
|
||||
> **[Lista di Compatibilità Hardware](docs/hardware-compatibility.md)** — Vedi tutte le schede testate, dai $5 RISC-V al Raspberry Pi ai telefoni Android. La tua scheda non è elencata? Invia una PR!
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
||||
</p>
|
||||
|
||||
## 🦾 Dimostrazione
|
||||
|
||||
### 🛠️ Flussi di Lavoro Standard dell'Assistente
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th><p align="center">Modalità Ingegnere Full-Stack</p></th>
|
||||
<th><p align="center">Log & Pianificazione</p></th>
|
||||
<th><p align="center">Ricerca Web & Apprendimento</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">Sviluppa · Distribuisci · Scala</td>
|
||||
<td align="center">Pianifica · Automatizza · Memorizza</td>
|
||||
<td align="center">Scopri · Analizza · Tendenze</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 🐜 Deploy Innovativo a Bassa Impronta
|
||||
|
||||
PicoClaw può essere distribuito su quasi qualsiasi dispositivo Linux!
|
||||
|
||||
- $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versione E (Ethernet) o W (WiFi6), per un assistente domotico minimale
|
||||
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), o $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), per la manutenzione automatizzata dei server
|
||||
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) o $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera), per la sorveglianza intelligente
|
||||
|
||||
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
|
||||
|
||||
🌟 Molti altri scenari di deploy ti aspettano!
|
||||
|
||||
## 📦 Installazione
|
||||
|
||||
### Scarica da picoclaw.io (Consigliato)
|
||||
|
||||
Visita **[picoclaw.io](https://picoclaw.io)** — il sito ufficiale rileva automaticamente la tua piattaforma e fornisce il download con un clic. Non è necessario scegliere manualmente l'architettura.
|
||||
|
||||
### Scarica il binario precompilato
|
||||
|
||||
In alternativa, scarica il binario per la tua piattaforma dalla pagina delle [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
|
||||
|
||||
### Compila dai sorgenti (per lo sviluppo)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# Compila il binario core
|
||||
make build
|
||||
|
||||
# Compila il Web UI Launcher (necessario per la modalità WebUI)
|
||||
make build-launcher
|
||||
|
||||
# Compila per più piattaforme
|
||||
make build-all
|
||||
|
||||
# Compila per Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||
make build-pi-zero
|
||||
|
||||
# Compila e installa
|
||||
make install
|
||||
```
|
||||
|
||||
**Raspberry Pi Zero 2 W:** Usa il binario che corrisponde al tuo OS: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Oppure esegui `make build-pi-zero` per compilare entrambi.
|
||||
|
||||
## 🚀 Guida Rapida
|
||||
|
||||
### 🌐 WebUI Launcher (Consigliato per Desktop)
|
||||
|
||||
Il WebUI Launcher fornisce un'interfaccia basata su browser per la configurazione e la chat. È il modo più semplice per iniziare — non è richiesta alcuna conoscenza della riga di comando.
|
||||
|
||||
**Opzione 1: Doppio clic (Desktop)**
|
||||
|
||||
Dopo aver scaricato da [picoclaw.io](https://picoclaw.io), fai doppio clic su `picoclaw-launcher` (o `picoclaw-launcher.exe` su Windows). Il browser si aprirà automaticamente su `http://localhost:18800`.
|
||||
|
||||
**Opzione 2: Riga di comando**
|
||||
|
||||
```bash
|
||||
picoclaw-launcher
|
||||
# Apri http://localhost:18800 nel browser
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> **Accesso remoto / Docker / VM:** Aggiungi il flag `-public` per ascoltare su tutte le interfacce:
|
||||
> ```bash
|
||||
> picoclaw-launcher -public
|
||||
> ```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Per iniziare:**
|
||||
|
||||
Apri il WebUI, poi: **1)** Configura un Provider (aggiungi la tua API key LLM) -> **2)** Configura un Channel (es. Telegram) -> **3)** Avvia il Gateway -> **4)** Chatta!
|
||||
|
||||
Per la documentazione dettagliata del WebUI, vedi [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
<details>
|
||||
<summary><b>Docker (alternativa)</b></summary>
|
||||
|
||||
```bash
|
||||
# 1. Clona questo repo
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
|
||||
# 2. Prima esecuzione — genera automaticamente docker/data/config.json poi si ferma
|
||||
# (si attiva solo quando sia config.json che workspace/ sono assenti)
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up
|
||||
# Il container stampa "First-run setup complete." e si ferma.
|
||||
|
||||
# 3. Imposta le tue API key
|
||||
vim docker/data/config.json
|
||||
|
||||
# 4. Avvia
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
# Apri http://localhost:18800
|
||||
```
|
||||
|
||||
> **Utenti Docker / VM:** Il Gateway ascolta su `127.0.0.1` per impostazione predefinita. Imposta `PICOCLAW_GATEWAY_HOST=0.0.0.0` o usa il flag `-public` per renderlo accessibile dall'host.
|
||||
|
||||
```bash
|
||||
# Controlla i log
|
||||
docker compose -f docker/docker-compose.yml logs -f
|
||||
|
||||
# Ferma
|
||||
docker compose -f docker/docker-compose.yml --profile launcher down
|
||||
|
||||
# Aggiorna
|
||||
docker compose -f docker/docker-compose.yml pull
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### 💻 TUI Launcher (Consigliato per Headless / SSH)
|
||||
|
||||
Il TUI (Terminal UI) Launcher fornisce un'interfaccia terminale completa per la configurazione e la gestione. Ideale per server, Raspberry Pi e altri ambienti headless.
|
||||
|
||||
```bash
|
||||
picoclaw-launcher-tui
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Per iniziare:**
|
||||
|
||||
Usa i menu TUI per: **1)** Configurare un Provider -> **2)** Configurare un Channel -> **3)** Avviare il Gateway -> **4)** Chattare!
|
||||
|
||||
Per la documentazione dettagliata del TUI, vedi [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
### 📱 Android
|
||||
|
||||
Dai una seconda vita al tuo telefono di dieci anni fa! Trasformalo in un assistente IA intelligente con PicoClaw.
|
||||
|
||||
**Opzione 1: Termux (disponibile ora)**
|
||||
|
||||
1. Installa [Termux](https://github.com/termux/termux-app) (scarica da [GitHub Releases](https://github.com/termux/termux-app/releases), o cerca su F-Droid / Google Play)
|
||||
2. Esegui i seguenti comandi:
|
||||
|
||||
```bash
|
||||
# Scarica l'ultima release
|
||||
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
|
||||
tar xzf picoclaw_Linux_arm64.tar.gz
|
||||
pkg install proot
|
||||
termux-chroot ./picoclaw onboard # chroot fornisce un layout standard del filesystem Linux
|
||||
```
|
||||
|
||||
Poi segui la sezione Terminal Launcher qui sotto per completare la configurazione.
|
||||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||
|
||||
**Opzione 2: APK Install (prossimamente)**
|
||||
|
||||
Un APK Android standalone con WebUI integrato è in sviluppo. Resta sintonizzato!
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (per ambienti con risorse limitate)</b></summary>
|
||||
|
||||
Per ambienti minimali dove è disponibile solo il binario core `picoclaw` (senza Launcher UI), puoi configurare tutto tramite riga di comando e un file di configurazione JSON.
|
||||
|
||||
**1. Inizializza**
|
||||
|
||||
```bash
|
||||
picoclaw onboard
|
||||
```
|
||||
|
||||
Questo crea `~/.picoclaw/config.json` e la directory workspace.
|
||||
|
||||
**2. Configura** (`~/.picoclaw/config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
}
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-api-key"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> Vedi `config/config.example.json` nel repo per un template di configurazione completo con tutte le opzioni disponibili.
|
||||
|
||||
**3. Chatta**
|
||||
|
||||
```bash
|
||||
# Domanda singola
|
||||
picoclaw agent -m "Quanto fa 2+2?"
|
||||
|
||||
# Modalità interattiva
|
||||
picoclaw agent
|
||||
|
||||
# Avvia il gateway per l'integrazione con app di chat
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 🔌 Provider (LLM)
|
||||
|
||||
PicoClaw supporta 30+ provider LLM tramite la configurazione `model_list`. Usa il formato `protocollo/modello`:
|
||||
|
||||
| Provider | Protocollo | API Key | Note |
|
||||
|----------|------------|---------|------|
|
||||
| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Richiesta | GPT-5.4, GPT-4o, o3, ecc. |
|
||||
| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Richiesta | Claude Opus 4.6, Sonnet 4.6, ecc. |
|
||||
| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Richiesta | Gemini 3 Flash, 2.5 Pro, ecc. |
|
||||
| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Richiesta | 200+ modelli, API unificata |
|
||||
| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Richiesta | GLM-4.7, GLM-5, ecc. |
|
||||
| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Richiesta | DeepSeek-V3, DeepSeek-R1 |
|
||||
| [Volcengine](https://console.volcengine.com) | `volcengine/` | Richiesta | Doubao, modelli Ark |
|
||||
| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Richiesta | Qwen3, Qwen-Max, ecc. |
|
||||
| [Groq](https://console.groq.com/keys) | `groq/` | Richiesta | Inferenza veloce (Llama, Mixtral) |
|
||||
| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Richiesta | Modelli Kimi |
|
||||
| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Richiesta | Modelli MiniMax |
|
||||
| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Richiesta | Mistral Large, Codestral |
|
||||
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Richiesta | Modelli ospitati NVIDIA |
|
||||
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Richiesta | Inferenza veloce |
|
||||
| [Novita AI](https://novita.ai/) | `novita/` | Richiesta | Vari modelli open |
|
||||
| [Ollama](https://ollama.com/) | `ollama/` | Non necessaria | Modelli locali, self-hosted |
|
||||
| [vLLM](https://docs.vllm.ai/) | `vllm/` | Non necessaria | Deploy locale, compatibile OpenAI |
|
||||
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Variabile | Proxy per 100+ provider |
|
||||
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Richiesta | Deploy Azure enterprise |
|
||||
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Login con device code |
|
||||
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
|
||||
|
||||
<details>
|
||||
<summary><b>Deploy locale (Ollama, vLLM, ecc.)</b></summary>
|
||||
|
||||
**Ollama:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-llama",
|
||||
"model": "ollama/llama3.1:8b",
|
||||
"api_base": "http://localhost:11434/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**vLLM:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-vllm",
|
||||
"model": "vllm/your-model",
|
||||
"api_base": "http://localhost:8000/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Per i dettagli completi sulla configurazione dei provider, vedi [Provider & Modelli](docs/providers.md).
|
||||
|
||||
</details>
|
||||
|
||||
## 💬 Channel (App di Chat)
|
||||
|
||||
Parla con il tuo PicoClaw attraverso 17+ piattaforme di messaggistica:
|
||||
|
||||
| Channel | Configurazione | Protocollo | Docs |
|
||||
|---------|----------------|------------|------|
|
||||
| **Telegram** | Facile (bot token) | Long polling | [Guida](docs/channels/telegram/README.md) |
|
||||
| **Discord** | Facile (bot token + intents) | WebSocket | [Guida](docs/channels/discord/README.md) |
|
||||
| **WhatsApp** | Facile (QR scan o bridge URL) | Nativo / Bridge | [Guida](docs/chat-apps.md#whatsapp) |
|
||||
| **Weixin** | Facile (scan QR nativo) | iLink API | [Guida](docs/chat-apps.md#weixin) |
|
||||
| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guida](docs/channels/qq/README.md) |
|
||||
| **Slack** | Facile (bot + app token) | Socket Mode | [Guida](docs/channels/slack/README.md) |
|
||||
| **Matrix** | Medio (homeserver + token) | Sync API | [Guida](docs/channels/matrix/README.md) |
|
||||
| **DingTalk** | Medio (credenziali client) | Stream | [Guida](docs/channels/dingtalk/README.md) |
|
||||
| **Feishu / Lark** | Medio (App ID + Secret) | WebSocket/SDK | [Guida](docs/channels/feishu/README.md) |
|
||||
| **LINE** | Medio (credenziali + webhook) | Webhook | [Guida](docs/channels/line/README.md) |
|
||||
| **WeCom Bot** | Medio (webhook URL) | Webhook | [Guida](docs/channels/wecom/wecom_bot/README.md) |
|
||||
| **WeCom App** | Medio (credenziali aziendali) | Webhook | [Guida](docs/channels/wecom/wecom_app/README.md) |
|
||||
| **WeCom AI Bot** | Medio (token + AES key) | WebSocket / Webhook | [Guida](docs/channels/wecom/wecom_aibot/README.md) |
|
||||
| **IRC** | Medio (server + nick) | Protocollo IRC | [Guida](docs/chat-apps.md#irc) |
|
||||
| **OneBot** | Medio (WebSocket URL) | OneBot v11 | [Guida](docs/channels/onebot/README.md) |
|
||||
| **MaixCam** | Facile (abilita) | TCP socket | [Guida](docs/channels/maixcam/README.md) |
|
||||
| **Pico** | Facile (abilita) | Protocollo nativo | Integrato |
|
||||
| **Pico Client** | Facile (WebSocket URL) | WebSocket | Integrato |
|
||||
|
||||
> Tutti i channel basati su webhook condividono un singolo server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu usa la modalità WebSocket/SDK e non usa il server HTTP condiviso.
|
||||
|
||||
Per istruzioni dettagliate sulla configurazione dei channel, vedi [Configurazione App di Chat](docs/chat-apps.md).
|
||||
|
||||
## 🔧 Strumenti
|
||||
|
||||
### 🔍 Ricerca Web
|
||||
|
||||
PicoClaw può cercare sul web per fornire informazioni aggiornate. Configura in `tools.web`:
|
||||
|
||||
| Motore di Ricerca | API Key | Piano Gratuito | Link |
|
||||
|-------------------|---------|----------------|------|
|
||||
| DuckDuckGo | Non necessaria | Illimitato | Fallback integrato |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Richiesta | 1000 query/giorno | IA, ottimizzato per il cinese |
|
||||
| [Tavily](https://tavily.com) | Richiesta | 1000 query/mese | Ottimizzato per AI Agent |
|
||||
| [Brave Search](https://brave.com/search/api) | Richiesta | 2000 query/mese | Veloce e privato |
|
||||
| [Perplexity](https://www.perplexity.ai) | Richiesta | A pagamento | Ricerca potenziata dall'IA |
|
||||
| [SearXNG](https://github.com/searxng/searxng) | Non necessaria | Self-hosted | Metasearch engine gratuito |
|
||||
| [GLM Search](https://open.bigmodel.cn/) | Richiesta | Variabile | Ricerca web Zhipu |
|
||||
|
||||
### ⚙️ Altri Strumenti
|
||||
|
||||
PicoClaw include strumenti integrati per operazioni su file, esecuzione di codice, pianificazione e altro. Vedi [Configurazione degli Strumenti](docs/tools_configuration.md) per i dettagli.
|
||||
|
||||
## 🎯 Skill
|
||||
|
||||
Le Skill sono capacità modulari che estendono il tuo Agent. Vengono caricate dai file `SKILL.md` nel tuo workspace.
|
||||
|
||||
**Installa skill da ClawHub:**
|
||||
|
||||
```bash
|
||||
picoclaw skills search "web scraping"
|
||||
picoclaw skills install <skill-name>
|
||||
```
|
||||
|
||||
**Configura il token ClawHub** (opzionale, per limiti di frequenza più alti):
|
||||
|
||||
Aggiungi al tuo `config.json`:
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"skills": {
|
||||
"registries": {
|
||||
"clawhub": {
|
||||
"auth_token": "your-clawhub-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Per maggiori dettagli, vedi [Configurazione degli Strumenti - Skill](docs/tools_configuration.md#skills-tool).
|
||||
|
||||
## 🔗 MCP (Model Context Protocol)
|
||||
|
||||
PicoClaw supporta nativamente [MCP](https://modelcontextprotocol.io/) — connetti qualsiasi server MCP per estendere le capacità del tuo Agent con strumenti e sorgenti di dati esterni.
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcp": {
|
||||
"enabled": true,
|
||||
"servers": {
|
||||
"filesystem": {
|
||||
"enabled": true,
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Per la configurazione MCP completa (trasporti stdio, SSE, HTTP, Tool Discovery), vedi [Configurazione degli Strumenti - MCP](docs/tools_configuration.md#mcp-tool).
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Unisciti al Social Network degli Agent
|
||||
|
||||
Connetti PicoClaw al Social Network degli Agent semplicemente inviando un singolo messaggio tramite CLI o qualsiasi app di chat integrata.
|
||||
|
||||
**Leggi `https://clawdchat.ai/skill.md` e segui le istruzioni per unirti a [ClawdChat.ai](https://clawdchat.ai)**
|
||||
|
||||
## 🖥️ Riferimento CLI
|
||||
|
||||
| Comando | Descrizione |
|
||||
| ------------------------- | ---------------------------------- |
|
||||
| `picoclaw onboard` | Inizializza config & workspace |
|
||||
| `picoclaw auth weixin` | Connetti account WeChat tramite QR |
|
||||
| `picoclaw agent -m "..."` | Chatta con l'agent |
|
||||
| `picoclaw agent` | Modalità chat interattiva |
|
||||
| `picoclaw gateway` | Avvia il gateway |
|
||||
| `picoclaw status` | Mostra lo stato |
|
||||
| `picoclaw version` | Mostra le info sulla versione |
|
||||
| `picoclaw model` | Visualizza o cambia il modello predefinito |
|
||||
| `picoclaw cron list` | Elenca tutti i job pianificati |
|
||||
| `picoclaw cron add ...` | Aggiunge un job pianificato |
|
||||
| `picoclaw cron disable` | Disabilita un job pianificato |
|
||||
| `picoclaw cron remove` | Rimuove un job pianificato |
|
||||
| `picoclaw skills list` | Elenca le skill installate |
|
||||
| `picoclaw skills install` | Installa una skill |
|
||||
| `picoclaw migrate` | Migra i dati dalle versioni precedenti |
|
||||
| `picoclaw auth login` | Autenticazione con i provider |
|
||||
|
||||
### ⏰ Task Pianificati / Promemoria
|
||||
|
||||
PicoClaw supporta promemoria pianificati e task ricorrenti tramite lo strumento `cron`:
|
||||
|
||||
* **Promemoria una tantum**: "Ricordami tra 10 minuti" -> si attiva una volta dopo 10 min
|
||||
* **Task ricorrenti**: "Ricordami ogni 2 ore" -> si attiva ogni 2 ore
|
||||
* **Espressioni cron**: "Ricordami alle 9 ogni giorno" -> usa un'espressione cron
|
||||
|
||||
## 📚 Documentazione
|
||||
|
||||
Per guide dettagliate oltre questo README:
|
||||
|
||||
| Argomento | Descrizione |
|
||||
|-----------|-------------|
|
||||
| [Docker & Avvio Rapido](docs/docker.md) | Configurazione Docker Compose, modalità Launcher/Agent |
|
||||
| [App di Chat](docs/chat-apps.md) | Tutte le guide di configurazione per 17+ channel |
|
||||
| [Configurazione](docs/configuration.md) | Variabili d'ambiente, struttura del workspace, sandbox di sicurezza |
|
||||
| [Provider & Modelli](docs/providers.md) | 30+ provider LLM, routing dei modelli, configurazione model_list |
|
||||
| [Spawn & Task Asincroni](docs/spawn-tasks.md) | Task veloci, task lunghi con spawn, orchestrazione asincrona di sub-agent |
|
||||
| [Hooks](docs/hooks/README.md) | Sistema di hook event-driven: observer, interceptor, approval hook |
|
||||
| [Steering](docs/steering.md) | Iniettare messaggi in un loop agent in esecuzione |
|
||||
| [SubTurn](docs/subturn.md) | Coordinamento subagent, controllo concorrenza, ciclo di vita |
|
||||
| [Risoluzione Problemi](docs/troubleshooting.md) | Problemi comuni e soluzioni |
|
||||
| [Configurazione degli Strumenti](docs/tools_configuration.md) | Abilitazione/disabilitazione per strumento, politiche exec, MCP, Skill |
|
||||
| [Compatibilità Hardware](docs/hardware-compatibility.md) | Schede testate, requisiti minimi |
|
||||
|
||||
## 🤝 Contribuisci & Roadmap
|
||||
|
||||
Le PR sono benvenute! Il codice è volutamente piccolo e leggibile.
|
||||
|
||||
Consulta la nostra [Roadmap della Community](https://github.com/sipeed/picoclaw/issues/988) e [CONTRIBUTING.md](CONTRIBUTING.md) per le linee guida.
|
||||
|
||||
Gruppo sviluppatori in costruzione, unisciti dopo la tua prima PR accettata!
|
||||
|
||||
Gruppi utenti:
|
||||
|
||||
Discord: <https://discord.gg/V4sAZ9XWpN>
|
||||
|
||||
WeChat:
|
||||
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
||||
+595
-398
File diff suppressed because it is too large
Load Diff
-578
@@ -1,578 +0,0 @@
|
||||
<div align="center">
|
||||
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||
|
||||
<h1>PicoClaw: Assistente de IA Ultra-Eficiente em Go</h1>
|
||||
|
||||
<h3>Hardware de $10 · 10MB de RAM · Boot em ms · Let's Go, PicoClaw!</h3>
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
<br>
|
||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
|
||||
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
||||
<br>
|
||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</p>
|
||||
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
> **PicoClaw** é um projeto open-source independente iniciado pela [Sipeed](https://sipeed.com), escrito inteiramente em **Go** do zero — não é um fork do OpenClaw, NanoBot ou qualquer outro projeto.
|
||||
|
||||
**PicoClaw** é um assistente de IA pessoal ultra-leve inspirado no [NanoBot](https://github.com/HKUDS/nanobot). Foi reconstruído do zero em **Go** por meio de um processo de "auto-bootstrapping" — o próprio AI Agent conduziu a migração de arquitetura e a otimização do código.
|
||||
|
||||
**Roda em hardware de $10 com menos de 10MB de RAM** — isso é 99% menos memória que o OpenClaw e 98% mais barato que um Mac mini!
|
||||
|
||||
<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]
|
||||
> **Aviso de Segurança**
|
||||
>
|
||||
> * **SEM CRIPTO:** O PicoClaw **não** emitiu nenhum token oficial ou criptomoeda. Todas as alegações no `pump.fun` ou outras plataformas de negociação são **golpes**.
|
||||
> * **DOMÍNIO OFICIAL:** O **ÚNICO** site oficial é **[picoclaw.io](https://picoclaw.io)**, e o site da empresa é **[sipeed.com](https://sipeed.com)**
|
||||
> * **ATENÇÃO:** Muitos domínios `.ai/.org/.com/.net/...` foram registrados por terceiros. Não confie neles.
|
||||
> * **NOTA:** O PicoClaw está em desenvolvimento rápido inicial. Podem existir problemas de segurança não resolvidos. Não implante em produção antes da v1.0.
|
||||
> * **NOTA:** O PicoClaw mesclou muitos PRs recentemente. Builds recentes podem usar 10-20MB de RAM. A otimização de recursos está planejada após a estabilização de funcionalidades.
|
||||
|
||||
## 📢 Novidades
|
||||
|
||||
2026-03-17 🚀 **v0.2.3 Lançada!** UI na bandeja do sistema (Windows e Linux), consulta de status de sub-agent (`spawn_status`), hot-reload experimental do Gateway, controle de segurança do Cron e 2 correções de segurança. O PicoClaw atingiu **25K Stars**!
|
||||
|
||||
2026-03-09 🎉 **v0.2.1 — Maior atualização até agora!** Suporte ao protocolo MCP, 4 novos channels (Matrix/IRC/WeCom/Discord Proxy), 3 novos providers (Kimi/Minimax/Avian), pipeline de visão, armazenamento de memória JSONL, roteamento de modelos.
|
||||
|
||||
2026-02-28 📦 **v0.2.0** lançada com suporte a Docker Compose e Web UI Launcher.
|
||||
|
||||
2026-02-26 🎉 O PicoClaw atinge **20K Stars** em apenas 17 dias! Orquestração automática de channels e interfaces de capacidade estão disponíveis.
|
||||
|
||||
<details>
|
||||
<summary>Notícias anteriores...</summary>
|
||||
|
||||
2026-02-16 🎉 O PicoClaw ultrapassa 12K Stars em uma semana! Funções de mantenedor da comunidade e [Roadmap](ROADMAP.md) lançados oficialmente.
|
||||
|
||||
2026-02-13 🎉 O PicoClaw ultrapassa 5000 Stars em 4 dias! Roadmap do projeto e grupos de desenvolvedores em andamento.
|
||||
|
||||
2026-02-09 🎉 **PicoClaw Lançado!** Construído em 1 dia para levar AI Agents a hardware de $10 com menos de 10MB de RAM. Let's Go, PicoClaw!
|
||||
|
||||
</details>
|
||||
|
||||
## ✨ Funcionalidades
|
||||
|
||||
🪶 **Ultra-leve**: Footprint de memória do núcleo <10MB — 99% menor que o OpenClaw.*
|
||||
|
||||
💰 **Custo mínimo**: Eficiente o suficiente para rodar em hardware de $10 — 98% mais barato que um Mac mini.
|
||||
|
||||
⚡️ **Boot ultrarrápido**: Inicialização 400x mais rápida. Boot em menos de 1s mesmo em um processador single-core de 0,6GHz.
|
||||
|
||||
🌍 **Verdadeiramente portátil**: Binário único para arquiteturas RISC-V, ARM, MIPS e x86. Um binário, roda em qualquer lugar!
|
||||
|
||||
🤖 **Bootstrapped por IA**: Implementação nativa pura em Go — 95% do código principal foi gerado por um Agent e refinado por revisão humana.
|
||||
|
||||
🔌 **Suporte a MCP**: Integração nativa com o [Model Context Protocol](https://modelcontextprotocol.io/) — conecte qualquer servidor MCP para estender as capacidades do Agent.
|
||||
|
||||
👁️ **Pipeline de visão**: Envie imagens e arquivos diretamente ao Agent — codificação base64 automática para LLMs multimodais.
|
||||
|
||||
🧠 **Roteamento inteligente**: Roteamento de modelos baseado em regras — consultas simples vão para modelos leves, economizando custos de API.
|
||||
|
||||
_*Builds recentes podem usar 10-20MB devido a merges rápidos de PRs. Otimização de recursos está planejada. Comparação de velocidade de boot baseada em benchmarks de single-core a 0,8GHz (veja tabela abaixo)._
|
||||
|
||||
<div align="center">
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
|
||||
| **Linguagem** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1GB | >100MB | **< 10MB*** |
|
||||
| **Tempo de boot**</br>(core 0,8GHz) | >500s | >30s | **<1s** |
|
||||
| **Custo** | Mac Mini $599 | Maioria das placas Linux ~$50 | **Qualquer placa Linux**</br>**a partir de $10** |
|
||||
|
||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
</div>
|
||||
|
||||
> **[Lista de Compatibilidade de Hardware](docs/pt-br/hardware-compatibility.md)** — Veja todas as placas testadas, de RISC-V de $5 ao Raspberry Pi e celulares Android. Sua placa não está listada? Envie um PR!
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
||||
</p>
|
||||
|
||||
## 🦾 Demonstração
|
||||
|
||||
### 🛠️ Fluxos de Trabalho Padrão do Assistente
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th><p align="center">Modo Engenheiro Full-Stack</p></th>
|
||||
<th><p align="center">Registro e Planejamento</p></th>
|
||||
<th><p align="center">Busca na Web e Aprendizado</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">Desenvolver · Implantar · Escalar</td>
|
||||
<td align="center">Agendar · Automatizar · Lembrar</td>
|
||||
<td align="center">Descobrir · Insights · Tendências</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 🐜 Implantação Inovadora de Baixo Consumo
|
||||
|
||||
O PicoClaw pode ser implantado em praticamente qualquer dispositivo Linux!
|
||||
|
||||
- $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) edição E(Ethernet) ou W(WiFi6), para um assistente doméstico mínimo
|
||||
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), para operações automatizadas de servidor
|
||||
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ou $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera), para vigilância inteligente
|
||||
|
||||
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
|
||||
|
||||
🌟 Mais Casos de Implantação Aguardam!
|
||||
|
||||
## 📦 Instalação
|
||||
|
||||
### Download pelo picoclaw.io (Recomendado)
|
||||
|
||||
Acesse **[picoclaw.io](https://picoclaw.io)** — o site oficial detecta automaticamente sua plataforma e fornece download com um clique. Não é necessário selecionar a arquitetura manualmente.
|
||||
|
||||
### Download do binário pré-compilado
|
||||
|
||||
Alternativamente, baixe o binário para sua plataforma na página de [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
|
||||
|
||||
### Compilar a partir do código-fonte (para desenvolvimento)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# Compilar o binário principal
|
||||
make build
|
||||
|
||||
# Compilar o Web UI Launcher (necessário para o modo WebUI)
|
||||
make build-launcher
|
||||
|
||||
# Compilar para múltiplas plataformas
|
||||
make build-all
|
||||
|
||||
# Compilar para Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||
make build-pi-zero
|
||||
|
||||
# Compilar e instalar
|
||||
make install
|
||||
```
|
||||
|
||||
**Raspberry Pi Zero 2 W:** Use o binário que corresponde ao seu SO: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Ou execute `make build-pi-zero` para compilar ambos.
|
||||
|
||||
## 🚀 Guia de Início Rápido
|
||||
|
||||
### 🌐 WebUI Launcher (Recomendado para Desktop)
|
||||
|
||||
O WebUI Launcher fornece uma interface baseada em navegador para configuração e chat. Esta é a maneira mais fácil de começar — sem necessidade de conhecimento de linha de comando.
|
||||
|
||||
**Opção 1: Duplo clique (Desktop)**
|
||||
|
||||
Após baixar de [picoclaw.io](https://picoclaw.io), dê duplo clique em `picoclaw-launcher` (ou `picoclaw-launcher.exe` no Windows). Seu navegador abrirá automaticamente em `http://localhost:18800`.
|
||||
|
||||
**Opção 2: Linha de comando**
|
||||
|
||||
```bash
|
||||
picoclaw-launcher
|
||||
# Abra http://localhost:18800 no seu navegador
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> **Acesso remoto / Docker / VM:** Adicione a flag `-public` para escutar em todas as interfaces:
|
||||
> ```bash
|
||||
> picoclaw-launcher -public
|
||||
> ```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Primeiros passos:**
|
||||
|
||||
Abra o WebUI e então: **1)** Configure um Provider (adicione sua API key de LLM) -> **2)** Configure um Channel (ex.: Telegram) -> **3)** Inicie o Gateway -> **4)** Converse!
|
||||
|
||||
Para documentação detalhada do WebUI, veja [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
<details>
|
||||
<summary><b>Docker (alternativa)</b></summary>
|
||||
|
||||
```bash
|
||||
# 1. Clone este repositório
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
|
||||
# 2. Primeira execução — gera automaticamente docker/data/config.json e encerra
|
||||
# (só é acionado quando config.json e workspace/ estão ausentes)
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up
|
||||
# O container imprime "First-run setup complete." e para.
|
||||
|
||||
# 3. Configure suas API keys
|
||||
vim docker/data/config.json
|
||||
|
||||
# 4. Iniciar
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
# Abra http://localhost:18800
|
||||
```
|
||||
|
||||
> **Usuários de Docker / VM:** O Gateway escuta em `127.0.0.1` por padrão. Defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` ou use a flag `-public` para torná-lo acessível pelo host.
|
||||
|
||||
```bash
|
||||
# Verificar logs
|
||||
docker compose -f docker/docker-compose.yml logs -f
|
||||
|
||||
# Parar
|
||||
docker compose -f docker/docker-compose.yml --profile launcher down
|
||||
|
||||
# Atualizar
|
||||
docker compose -f docker/docker-compose.yml pull
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### 💻 TUI Launcher (Recomendado para Headless / SSH)
|
||||
|
||||
O TUI (Terminal UI) Launcher fornece uma interface de terminal completa para configuração e gerenciamento. Ideal para servidores, Raspberry Pi e outros ambientes headless.
|
||||
|
||||
```bash
|
||||
picoclaw-launcher-tui
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Primeiros passos:**
|
||||
|
||||
Use os menus do TUI para: **1)** Configurar um Provider -> **2)** Configurar um Channel -> **3)** Iniciar o Gateway -> **4)** Conversar!
|
||||
|
||||
Para documentação detalhada do TUI, veja [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
### 📱 Android
|
||||
|
||||
Dê uma segunda vida ao seu celular de uma década! Transforme-o em um Assistente de IA inteligente com o PicoClaw.
|
||||
|
||||
**Opção 1: Termux (disponível agora)**
|
||||
|
||||
1. Instale o [Termux](https://github.com/termux/termux-app) (baixe nas [GitHub Releases](https://github.com/termux/termux-app/releases), ou pesquise no F-Droid / Google Play)
|
||||
2. Execute os seguintes comandos:
|
||||
|
||||
```bash
|
||||
# Baixar a versão mais recente
|
||||
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
|
||||
tar xzf picoclaw_Linux_arm64.tar.gz
|
||||
pkg install proot
|
||||
termux-chroot ./picoclaw onboard # chroot fornece um layout padrão de sistema de arquivos Linux
|
||||
```
|
||||
|
||||
Em seguida, siga a seção Terminal Launcher abaixo para concluir a configuração.
|
||||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||
|
||||
**Opção 2: Instalação via APK (em breve)**
|
||||
|
||||
Um APK Android independente com WebUI integrado está em desenvolvimento. Fique ligado!
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (para ambientes com recursos limitados)</b></summary>
|
||||
|
||||
Para ambientes mínimos onde apenas o binário principal `picoclaw` está disponível (sem Launcher UI), você pode configurar tudo via linha de comando e um arquivo de configuração JSON.
|
||||
|
||||
**1. Inicializar**
|
||||
|
||||
```bash
|
||||
picoclaw onboard
|
||||
```
|
||||
|
||||
Isso cria `~/.picoclaw/config.json` e o diretório workspace.
|
||||
|
||||
**2. Configurar** (`~/.picoclaw/config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
}
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-api-key"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> Veja `config/config.example.json` no repositório para um template de configuração completo com todas as opções disponíveis.
|
||||
|
||||
**3. Conversar**
|
||||
|
||||
```bash
|
||||
# Pergunta única
|
||||
picoclaw agent -m "What is 2+2?"
|
||||
|
||||
# Modo interativo
|
||||
picoclaw agent
|
||||
|
||||
# Iniciar gateway para integração com app de chat
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 🔌 Providers (LLM)
|
||||
|
||||
O PicoClaw suporta mais de 30 providers de LLM através da configuração `model_list`. Use o formato `protocolo/modelo`:
|
||||
|
||||
| Provider | Protocolo | API Key | Notas |
|
||||
|----------|-----------|---------|-------|
|
||||
| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Obrigatória | GPT-5.4, GPT-4o, o3, etc. |
|
||||
| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Obrigatória | Claude Opus 4.6, Sonnet 4.6, etc. |
|
||||
| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Obrigatória | Gemini 3 Flash, 2.5 Pro, etc. |
|
||||
| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Obrigatória | 200+ modelos, API unificada |
|
||||
| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Obrigatória | GLM-4.7, GLM-5, etc. |
|
||||
| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Obrigatória | DeepSeek-V3, DeepSeek-R1 |
|
||||
| [Volcengine](https://console.volcengine.com) | `volcengine/` | Obrigatória | Modelos Doubao, Ark |
|
||||
| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Obrigatória | Qwen3, Qwen-Max, etc. |
|
||||
| [Groq](https://console.groq.com/keys) | `groq/` | Obrigatória | Inferência rápida (Llama, Mixtral) |
|
||||
| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Obrigatória | Modelos Kimi |
|
||||
| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Obrigatória | Modelos MiniMax |
|
||||
| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Obrigatória | Mistral Large, Codestral |
|
||||
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Obrigatória | Modelos hospedados pela NVIDIA |
|
||||
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Obrigatória | Inferência rápida |
|
||||
| [Novita AI](https://novita.ai/) | `novita/` | Obrigatória | Vários modelos abertos |
|
||||
| [Ollama](https://ollama.com/) | `ollama/` | Não necessária | Modelos locais, self-hosted |
|
||||
| [vLLM](https://docs.vllm.ai/) | `vllm/` | Não necessária | Implantação local, compatível com OpenAI |
|
||||
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Varia | Proxy para 100+ providers |
|
||||
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Obrigatória | Implantação Azure Enterprise |
|
||||
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Login por código de dispositivo |
|
||||
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
|
||||
|
||||
<details>
|
||||
<summary><b>Implantação local (Ollama, vLLM, etc.)</b></summary>
|
||||
|
||||
**Ollama:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-llama",
|
||||
"model": "ollama/llama3.1:8b",
|
||||
"api_base": "http://localhost:11434/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**vLLM:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-vllm",
|
||||
"model": "vllm/your-model",
|
||||
"api_base": "http://localhost:8000/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Para detalhes completos de configuração de providers, veja [Providers & Models](docs/pt-br/providers.md).
|
||||
|
||||
</details>
|
||||
|
||||
## 💬 Channels (Apps de Chat)
|
||||
|
||||
Converse com seu PicoClaw por meio de mais de 17 plataformas de mensagens:
|
||||
|
||||
| Channel | Configuração | Protocolo | Docs |
|
||||
|---------|--------------|-----------|------|
|
||||
| **Telegram** | Fácil (bot token) | Long polling | [Guia](docs/channels/telegram/README.pt-br.md) |
|
||||
| **Discord** | Fácil (bot token + intents) | WebSocket | [Guia](docs/channels/discord/README.pt-br.md) |
|
||||
| **WhatsApp** | Fácil (QR scan ou bridge URL) | Nativo / Bridge | [Guia](docs/pt-br/chat-apps.md#whatsapp) |
|
||||
| **Weixin** | Fácil (scan QR nativo) | iLink API | [Guia](docs/pt-br/chat-apps.md#weixin) |
|
||||
| **QQ** | Fácil (AppID + AppSecret) | WebSocket | [Guia](docs/channels/qq/README.pt-br.md) |
|
||||
| **Slack** | Fácil (bot + app token) | Socket Mode | [Guia](docs/channels/slack/README.pt-br.md) |
|
||||
| **Matrix** | Médio (homeserver + token) | Sync API | [Guia](docs/channels/matrix/README.pt-br.md) |
|
||||
| **DingTalk** | Médio (credenciais do cliente) | Stream | [Guia](docs/channels/dingtalk/README.pt-br.md) |
|
||||
| **Feishu / Lark** | Médio (App ID + Secret) | WebSocket/SDK | [Guia](docs/channels/feishu/README.pt-br.md) |
|
||||
| **LINE** | Médio (credenciais + webhook) | Webhook | [Guia](docs/channels/line/README.pt-br.md) |
|
||||
| **WeCom Bot** | Médio (webhook URL) | Webhook | [Guia](docs/channels/wecom/wecom_bot/README.pt-br.md) |
|
||||
| **WeCom App** | Médio (credenciais corporativas) | Webhook | [Guia](docs/channels/wecom/wecom_app/README.pt-br.md) |
|
||||
| **WeCom AI Bot** | Médio (token + chave AES) | WebSocket / Webhook | [Guia](docs/channels/wecom/wecom_aibot/README.pt-br.md) |
|
||||
| **IRC** | Médio (servidor + nick) | Protocolo IRC | [Guia](docs/pt-br/chat-apps.md#irc) |
|
||||
| **OneBot** | Médio (WebSocket URL) | OneBot v11 | [Guia](docs/channels/onebot/README.pt-br.md) |
|
||||
| **MaixCam** | Fácil (habilitar) | TCP socket | [Guia](docs/channels/maixcam/README.pt-br.md) |
|
||||
| **Pico** | Fácil (habilitar) | Protocolo nativo | Integrado |
|
||||
| **Pico Client** | Fácil (WebSocket URL) | WebSocket | Integrado |
|
||||
|
||||
> Todos os channels baseados em webhook compartilham um único servidor HTTP do Gateway (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). O Feishu usa modo WebSocket/SDK e não utiliza o servidor HTTP compartilhado.
|
||||
|
||||
Para instruções detalhadas de configuração de channels, veja [Configuração de Apps de Chat](docs/pt-br/chat-apps.md).
|
||||
|
||||
## 🔧 Ferramentas
|
||||
|
||||
### 🔍 Busca na Web
|
||||
|
||||
O PicoClaw pode pesquisar na web para fornecer informações atualizadas. Configure em `tools.web`:
|
||||
|
||||
| Motor de Busca | API Key | Nível Gratuito | Link |
|
||||
|----------------|---------|----------------|------|
|
||||
| DuckDuckGo | Não necessária | Ilimitado | Fallback integrado |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Obrigatória | 1000 consultas/dia | IA, otimizado para chinês |
|
||||
| [Tavily](https://tavily.com) | Obrigatória | 1000 consultas/mês | Otimizado para AI Agents |
|
||||
| [Brave Search](https://brave.com/search/api) | Obrigatória | 2000 consultas/mês | Rápido e privado |
|
||||
| [Perplexity](https://www.perplexity.ai) | Obrigatória | Pago | Busca com IA |
|
||||
| [SearXNG](https://github.com/searxng/searxng) | Não necessária | Self-hosted | Metabuscador gratuito |
|
||||
| [GLM Search](https://open.bigmodel.cn/) | Obrigatória | Varia | Busca web Zhipu |
|
||||
|
||||
### ⚙️ Outras Ferramentas
|
||||
|
||||
O PicoClaw inclui ferramentas integradas para operações de arquivo, execução de código, agendamento e mais. Veja [Configuração de Ferramentas](docs/pt-br/tools_configuration.md) para detalhes.
|
||||
|
||||
## 🎯 Skills
|
||||
|
||||
Skills são capacidades modulares que estendem seu Agent. Elas são carregadas a partir de arquivos `SKILL.md` no seu workspace.
|
||||
|
||||
**Instalar skills do ClawHub:**
|
||||
|
||||
```bash
|
||||
picoclaw skills search "web scraping"
|
||||
picoclaw skills install <skill-name>
|
||||
```
|
||||
|
||||
**Configurar token do ClawHub** (opcional, para limites de taxa mais altos):
|
||||
|
||||
Adicione ao seu `config.json`:
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"skills": {
|
||||
"registries": {
|
||||
"clawhub": {
|
||||
"auth_token": "your-clawhub-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Para mais detalhes, veja [Configuração de Ferramentas - Skills](docs/pt-br/tools_configuration.md#skills-tool).
|
||||
|
||||
## 🔗 MCP (Model Context Protocol)
|
||||
|
||||
O PicoClaw suporta nativamente o [MCP](https://modelcontextprotocol.io/) — conecte qualquer servidor MCP para estender as capacidades do seu Agent com ferramentas externas e fontes de dados.
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcp": {
|
||||
"enabled": true,
|
||||
"servers": {
|
||||
"filesystem": {
|
||||
"enabled": true,
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Para configuração completa de MCP (transportes stdio, SSE, HTTP, Tool Discovery), veja [Configuração de Ferramentas - MCP](docs/pt-br/tools_configuration.md#mcp-tool).
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Junte-se à Rede Social de Agents
|
||||
|
||||
Conecte o PicoClaw à Rede Social de Agents simplesmente enviando uma única mensagem via CLI ou qualquer App de Chat integrado.
|
||||
|
||||
**Leia `https://clawdchat.ai/skill.md` e siga as instruções para entrar no [ClawdChat.ai](https://clawdchat.ai)**
|
||||
|
||||
## 🖥️ Referência CLI
|
||||
|
||||
| Comando | Descrição |
|
||||
| ------------------------- | -------------------------------------- |
|
||||
| `picoclaw onboard` | Inicializar config e workspace |
|
||||
| `picoclaw auth weixin` | Conectar conta WeChat via QR |
|
||||
| `picoclaw agent -m "..."` | Conversar com o agent |
|
||||
| `picoclaw agent` | Modo de chat interativo |
|
||||
| `picoclaw gateway` | Iniciar o gateway |
|
||||
| `picoclaw status` | Exibir status |
|
||||
| `picoclaw version` | Exibir informações de versão |
|
||||
| `picoclaw model` | Ver ou trocar o modelo padrão |
|
||||
| `picoclaw cron list` | Listar todos os jobs agendados |
|
||||
| `picoclaw cron add ...` | Adicionar um job agendado |
|
||||
| `picoclaw cron disable` | Desabilitar um job agendado |
|
||||
| `picoclaw cron remove` | Remover um job agendado |
|
||||
| `picoclaw skills list` | Listar skills instaladas |
|
||||
| `picoclaw skills install` | Instalar uma skill |
|
||||
| `picoclaw migrate` | Migrar dados de versões anteriores |
|
||||
| `picoclaw auth login` | Autenticar com providers |
|
||||
|
||||
### ⏰ Tarefas Agendadas / Lembretes
|
||||
|
||||
O PicoClaw suporta lembretes agendados e tarefas recorrentes através da ferramenta `cron`:
|
||||
|
||||
* **Lembretes únicos**: "Lembre-me em 10 minutos" -> dispara uma vez após 10min
|
||||
* **Tarefas recorrentes**: "Lembre-me a cada 2 horas" -> dispara a cada 2 horas
|
||||
* **Expressões cron**: "Lembre-me às 9h diariamente" -> usa expressão cron
|
||||
|
||||
## 📚 Documentação
|
||||
|
||||
Para guias detalhados além deste README:
|
||||
|
||||
| Tópico | Descrição |
|
||||
|--------|-----------|
|
||||
| [Docker & Início Rápido](docs/pt-br/docker.md) | Configuração do Docker Compose, modos Launcher/Agent |
|
||||
| [Apps de Chat](docs/pt-br/chat-apps.md) | Guias de configuração para todos os 17+ channels |
|
||||
| [Configuração](docs/pt-br/configuration.md) | Variáveis de ambiente, layout do workspace, sandbox de segurança |
|
||||
| [Providers & Models](docs/pt-br/providers.md) | 30+ providers de LLM, roteamento de modelos, configuração de model_list |
|
||||
| [Spawn & Tarefas Assíncronas](docs/pt-br/spawn-tasks.md) | Tarefas rápidas, tarefas longas com spawn, orquestração assíncrona de sub-agents |
|
||||
| [Hooks](docs/hooks/README.md) | Sistema de hooks orientado a eventos: observadores, interceptores, hooks de aprovação |
|
||||
| [Steering](docs/steering.md) | Injetar mensagens em um loop de agente em execução |
|
||||
| [SubTurn](docs/subturn.md) | Coordenação de subagentes, controle de concorrência, ciclo de vida |
|
||||
| [Solução de Problemas](docs/pt-br/troubleshooting.md) | Problemas comuns e soluções |
|
||||
| [Configuração de Ferramentas](docs/pt-br/tools_configuration.md) | Habilitar/desabilitar por ferramenta, políticas de exec, MCP, Skills |
|
||||
| [Compatibilidade de Hardware](docs/pt-br/hardware-compatibility.md) | Placas testadas, requisitos mínimos |
|
||||
|
||||
## 🤝 Contribuir & Roadmap
|
||||
|
||||
PRs são bem-vindos! O código-fonte é intencionalmente pequeno e legível.
|
||||
|
||||
Veja nosso [Roadmap da Comunidade](https://github.com/sipeed/picoclaw/issues/988) e [CONTRIBUTING.md](CONTRIBUTING.md) para diretrizes.
|
||||
|
||||
Grupo de desenvolvedores em formação, entre após seu primeiro PR mesclado!
|
||||
|
||||
Grupos de Usuários:
|
||||
|
||||
Discord: <https://discord.gg/V4sAZ9XWpN>
|
||||
|
||||
WeChat:
|
||||
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
||||
-578
@@ -1,578 +0,0 @@
|
||||
<div align="center">
|
||||
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||
|
||||
<h1>PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go</h1>
|
||||
|
||||
<h3>Phần cứng $10 · RAM 10MB · Khởi động ms · Let's Go, PicoClaw!</h3>
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
<br>
|
||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
|
||||
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
||||
<br>
|
||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</p>
|
||||
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
> **PicoClaw** là một dự án mã nguồn mở độc lập do [Sipeed](https://sipeed.com) khởi xướng, được viết hoàn toàn bằng **Go** từ đầu — không phải fork của OpenClaw, NanoBot hay bất kỳ dự án nào khác.
|
||||
|
||||
**PicoClaw** là trợ lý AI cá nhân siêu nhẹ lấy cảm hứng từ [NanoBot](https://github.com/HKUDS/nanobot). Nó được xây dựng lại từ đầu bằng **Go** thông qua quá trình "tự khởi động" — chính AI Agent đã dẫn dắt quá trình di chuyển kiến trúc và tối ưu hóa mã nguồn.
|
||||
|
||||
**Chạy trên phần cứng $10 với <10MB RAM** — ít hơn 99% bộ nhớ so với OpenClaw và rẻ hơn 98% so với Mac mini!
|
||||
|
||||
<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]
|
||||
> **Thông báo Bảo mật**
|
||||
>
|
||||
> * **KHÔNG CÓ CRYPTO:** PicoClaw **chưa** phát hành bất kỳ token hay tiền điện tử chính thức nào. Mọi thông tin trên `pump.fun` hoặc các nền tảng giao dịch khác đều là **lừa đảo**.
|
||||
> * **DOMAIN CHÍNH THỨC:** Website chính thức **DUY NHẤT** là **[picoclaw.io](https://picoclaw.io)**, và website công ty là **[sipeed.com](https://sipeed.com)**
|
||||
> * **CẢNH BÁO:** Nhiều domain `.ai/.org/.com/.net/...` đã bị bên thứ ba đăng ký. Đừng tin tưởng chúng.
|
||||
> * **LƯU Ý:** PicoClaw đang trong giai đoạn phát triển nhanh. Có thể còn các vấn đề bảo mật chưa được giải quyết. Không triển khai lên môi trường production trước v1.0.
|
||||
> * **LƯU Ý:** PicoClaw gần đây đã merge nhiều PR. Các bản build gần đây có thể dùng 10-20MB RAM. Tối ưu hóa tài nguyên được lên kế hoạch sau khi tính năng ổn định.
|
||||
|
||||
## 📢 Tin tức
|
||||
|
||||
2026-03-17 🚀 **v0.2.3 đã phát hành!** Giao diện system tray (Windows & Linux), truy vấn trạng thái sub-agent (`spawn_status`), thử nghiệm Gateway hot-reload, bảo mật Cron, và 2 bản vá bảo mật. PicoClaw đã đạt **25K Stars**!
|
||||
|
||||
2026-03-09 🎉 **v0.2.1 — Bản cập nhật lớn nhất từ trước đến nay!** Hỗ trợ giao thức MCP, 4 Channel mới (Matrix/IRC/WeCom/Discord Proxy), 3 Provider mới (Kimi/Minimax/Avian), pipeline thị giác, bộ nhớ JSONL, định tuyến mô hình.
|
||||
|
||||
2026-02-28 📦 **v0.2.0** phát hành với hỗ trợ Docker Compose và Web UI Launcher.
|
||||
|
||||
2026-02-26 🎉 PicoClaw đạt **20K Stars** chỉ trong 17 ngày! Tự động điều phối Channel và giao diện khả năng đã hoạt động.
|
||||
|
||||
<details>
|
||||
<summary>Tin tức trước đó...</summary>
|
||||
|
||||
2026-02-16 🎉 PicoClaw vượt 12K Stars trong một tuần! Vai trò người duy trì cộng đồng và [Lộ trình](ROADMAP.md) chính thức ra mắt.
|
||||
|
||||
2026-02-13 🎉 PicoClaw vượt 5000 Stars trong 4 ngày! Lộ trình dự án và nhóm nhà phát triển đang được xây dựng.
|
||||
|
||||
2026-02-09 🎉 **PicoClaw ra mắt!** Được xây dựng trong 1 ngày để đưa AI Agent lên phần cứng $10 với <10MB RAM. Let's Go, PicoClaw!
|
||||
|
||||
</details>
|
||||
|
||||
## ✨ Tính năng
|
||||
|
||||
🪶 **Siêu nhẹ**: Bộ nhớ lõi <10MB — nhỏ hơn 99% so với OpenClaw.*
|
||||
|
||||
💰 **Chi phí tối thiểu**: Đủ hiệu quả để chạy trên phần cứng $10 — rẻ hơn 98% so với Mac mini.
|
||||
|
||||
⚡️ **Khởi động cực nhanh**: Khởi động nhanh hơn 400 lần. Khởi động trong <1 giây ngay cả trên bộ xử lý đơn nhân 0.6GHz.
|
||||
|
||||
🌍 **Thực sự di động**: Một binary duy nhất cho các kiến trúc RISC-V, ARM, MIPS và x86. Một binary, chạy mọi nơi!
|
||||
|
||||
🤖 **Được AI khởi động**: Triển khai Go thuần túy — 95% mã lõi được tạo bởi Agent và tinh chỉnh qua quy trình human-in-the-loop.
|
||||
|
||||
🔌 **Hỗ trợ MCP**: Tích hợp [Model Context Protocol](https://modelcontextprotocol.io/) gốc — kết nối bất kỳ MCP server nào để mở rộng khả năng Agent.
|
||||
|
||||
👁️ **Pipeline thị giác**: Gửi hình ảnh và tệp trực tiếp đến Agent — tự động mã hóa base64 cho LLM đa phương thức.
|
||||
|
||||
🧠 **Định tuyến thông minh**: Định tuyến mô hình dựa trên quy tắc — các truy vấn đơn giản đến mô hình nhẹ, tiết kiệm chi phí API.
|
||||
|
||||
_*Các bản build gần đây có thể dùng 10-20MB do merge PR nhanh. Tối ưu hóa tài nguyên đang được lên kế hoạch. So sánh tốc độ khởi động dựa trên benchmark lõi đơn 0.8GHz (xem bảng bên dưới)._
|
||||
|
||||
<div align="center">
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
|
||||
| **Ngôn ngữ** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1GB | >100MB | **< 10MB*** |
|
||||
| **Thời gian khởi động**</br>(lõi 0.8GHz) | >500s | >30s | **<1s** |
|
||||
| **Chi phí** | Mac Mini $599 | Hầu hết board Linux ~$50 | **Bất kỳ board Linux**</br>**từ $10** |
|
||||
|
||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
</div>
|
||||
|
||||
> **[Danh sách Tương thích Phần cứng](docs/vi/hardware-compatibility.md)** — Xem tất cả các board đã được kiểm tra, từ RISC-V $5 đến Raspberry Pi đến điện thoại Android. Board của bạn chưa có trong danh sách? Gửi PR!
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
|
||||
</p>
|
||||
|
||||
## 🦾 Minh họa
|
||||
|
||||
### 🛠️ Quy trình Trợ lý Tiêu chuẩn
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th><p align="center">Chế độ Kỹ sư Full-Stack</p></th>
|
||||
<th><p align="center">Ghi nhật ký & Lập kế hoạch</p></th>
|
||||
<th><p align="center">Tìm kiếm Web & Học tập</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">Phát triển · Triển khai · Mở rộng</td>
|
||||
<td align="center">Lên lịch · Tự động hóa · Ghi nhớ</td>
|
||||
<td align="center">Khám phá · Thông tin · Xu hướng</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 🐜 Triển khai Sáng tạo với Dấu chân Nhỏ
|
||||
|
||||
PicoClaw có thể được triển khai trên hầu hết mọi thiết bị Linux!
|
||||
|
||||
- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) phiên bản E(Ethernet) hoặc W(WiFi6), cho trợ lý gia đình tối giản
|
||||
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), hoặc $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), cho vận hành máy chủ tự động
|
||||
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) hoặc $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera), cho giám sát thông minh
|
||||
|
||||
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
|
||||
|
||||
🌟 Còn nhiều trường hợp triển khai đang chờ đón!
|
||||
|
||||
## 📦 Cài đặt
|
||||
|
||||
### Tải xuống từ picoclaw.io (Khuyến nghị)
|
||||
|
||||
Truy cập **[picoclaw.io](https://picoclaw.io)** — website chính thức tự động phát hiện nền tảng của bạn và cung cấp tải xuống một cú nhấp. Không cần chọn kiến trúc thủ công.
|
||||
|
||||
### Tải xuống binary đã biên dịch sẵn
|
||||
|
||||
Ngoài ra, tải binary cho nền tảng của bạn từ trang [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
|
||||
|
||||
### Xây dựng từ mã nguồn (để phát triển)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# Build core binary
|
||||
make build
|
||||
|
||||
# Build Web UI Launcher (required for WebUI mode)
|
||||
make build-launcher
|
||||
|
||||
# Build for multiple platforms
|
||||
make build-all
|
||||
|
||||
# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||
make build-pi-zero
|
||||
|
||||
# Build and install
|
||||
make install
|
||||
```
|
||||
|
||||
**Raspberry Pi Zero 2 W:** Sử dụng binary phù hợp với hệ điều hành của bạn: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Hoặc chạy `make build-pi-zero` để xây dựng cả hai.
|
||||
|
||||
## 🚀 Hướng dẫn Khởi động Nhanh
|
||||
|
||||
### 🌐 WebUI Launcher (Khuyến nghị cho Desktop)
|
||||
|
||||
WebUI Launcher cung cấp giao diện dựa trên trình duyệt để cấu hình và trò chuyện. Đây là cách dễ nhất để bắt đầu — không cần kiến thức dòng lệnh.
|
||||
|
||||
**Tùy chọn 1: Nhấp đúp (Desktop)**
|
||||
|
||||
Sau khi tải xuống từ [picoclaw.io](https://picoclaw.io), nhấp đúp vào `picoclaw-launcher` (hoặc `picoclaw-launcher.exe` trên Windows). Trình duyệt của bạn sẽ tự động mở tại `http://localhost:18800`.
|
||||
|
||||
**Tùy chọn 2: Dòng lệnh**
|
||||
|
||||
```bash
|
||||
picoclaw-launcher
|
||||
# Mở http://localhost:18800 trong trình duyệt của bạn
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> **Truy cập từ xa / Docker / VM:** Thêm cờ `-public` để lắng nghe trên tất cả giao diện:
|
||||
> ```bash
|
||||
> picoclaw-launcher -public
|
||||
> ```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Bắt đầu:**
|
||||
|
||||
Mở WebUI, sau đó: **1)** Cấu hình Provider (thêm API key LLM của bạn) -> **2)** Cấu hình Channel (ví dụ: Telegram) -> **3)** Khởi động Gateway -> **4)** Trò chuyện!
|
||||
|
||||
Để biết tài liệu WebUI chi tiết, xem [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
<details>
|
||||
<summary><b>Docker (thay thế)</b></summary>
|
||||
|
||||
```bash
|
||||
# 1. Clone this repo
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
|
||||
# 2. First run — auto-generates docker/data/config.json then exits
|
||||
# (only triggers when both config.json and workspace/ are missing)
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up
|
||||
# The container prints "First-run setup complete." and stops.
|
||||
|
||||
# 3. Set your API keys
|
||||
vim docker/data/config.json
|
||||
|
||||
# 4. Start
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
# Open http://localhost:18800
|
||||
```
|
||||
|
||||
> **Người dùng Docker / VM:** Gateway lắng nghe trên `127.0.0.1` theo mặc định. Đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` hoặc dùng cờ `-public` để có thể truy cập từ host.
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
docker compose -f docker/docker-compose.yml logs -f
|
||||
|
||||
# Stop
|
||||
docker compose -f docker/docker-compose.yml --profile launcher down
|
||||
|
||||
# Update
|
||||
docker compose -f docker/docker-compose.yml pull
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### 💻 TUI Launcher (Khuyến nghị cho Headless / SSH)
|
||||
|
||||
TUI (Terminal UI) Launcher cung cấp giao diện terminal đầy đủ tính năng để cấu hình và quản lý. Lý tưởng cho máy chủ, Raspberry Pi và các môi trường headless khác.
|
||||
|
||||
```bash
|
||||
picoclaw-launcher-tui
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
|
||||
</p>
|
||||
|
||||
**Bắt đầu:**
|
||||
|
||||
Sử dụng menu TUI để: **1)** Cấu hình Provider -> **2)** Cấu hình Channel -> **3)** Khởi động Gateway -> **4)** Trò chuyện!
|
||||
|
||||
Để biết tài liệu TUI chi tiết, xem [docs.picoclaw.io](https://docs.picoclaw.io).
|
||||
|
||||
### 📱 Android
|
||||
|
||||
Hãy cho chiếc điện thoại cũ của bạn một cuộc sống mới! Biến nó thành Trợ lý AI thông minh với PicoClaw.
|
||||
|
||||
**Tùy chọn 1: Termux (có sẵn ngay)**
|
||||
|
||||
1. Cài đặt [Termux](https://github.com/termux/termux-app) (tải từ [GitHub Releases](https://github.com/termux/termux-app/releases), hoặc tìm kiếm trong F-Droid / Google Play)
|
||||
2. Chạy các lệnh sau:
|
||||
|
||||
```bash
|
||||
# Download the latest release
|
||||
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
|
||||
tar xzf picoclaw_Linux_arm64.tar.gz
|
||||
pkg install proot
|
||||
termux-chroot ./picoclaw onboard # chroot provides a standard Linux filesystem layout
|
||||
```
|
||||
|
||||
Sau đó làm theo phần Terminal Launcher bên dưới để hoàn tất cấu hình.
|
||||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
||||
|
||||
**Tùy chọn 2: Cài đặt APK (sắp ra mắt)**
|
||||
|
||||
Một APK Android độc lập với WebUI tích hợp đang được phát triển. Hãy đón chờ!
|
||||
|
||||
<details>
|
||||
<summary><b>Terminal Launcher (cho môi trường hạn chế tài nguyên)</b></summary>
|
||||
|
||||
Đối với các môi trường tối giản chỉ có binary lõi `picoclaw` (không có Launcher UI), bạn có thể cấu hình mọi thứ qua dòng lệnh và tệp cấu hình JSON.
|
||||
|
||||
**1. Khởi tạo**
|
||||
|
||||
```bash
|
||||
picoclaw onboard
|
||||
```
|
||||
|
||||
Lệnh này tạo `~/.picoclaw/config.json` và thư mục workspace.
|
||||
|
||||
**2. Cấu hình** (`~/.picoclaw/config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
}
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-api-key"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> Xem `config/config.example.json` trong repo để có mẫu cấu hình đầy đủ với tất cả các tùy chọn có sẵn.
|
||||
|
||||
**3. Trò chuyện**
|
||||
|
||||
```bash
|
||||
# One-shot question
|
||||
picoclaw agent -m "What is 2+2?"
|
||||
|
||||
# Interactive mode
|
||||
picoclaw agent
|
||||
|
||||
# Start gateway for chat app integration
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 🔌 Providers (LLM)
|
||||
|
||||
PicoClaw hỗ trợ 30+ Provider LLM thông qua cấu hình `model_list`. Sử dụng định dạng `protocol/model`:
|
||||
|
||||
| Provider | Protocol | API Key | Ghi chú |
|
||||
|----------|----------|---------|---------|
|
||||
| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Bắt buộc | GPT-5.4, GPT-4o, o3, v.v. |
|
||||
| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Bắt buộc | Claude Opus 4.6, Sonnet 4.6, v.v. |
|
||||
| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Bắt buộc | Gemini 3 Flash, 2.5 Pro, v.v. |
|
||||
| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Bắt buộc | 200+ mô hình, API thống nhất |
|
||||
| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Bắt buộc | GLM-4.7, GLM-5, v.v. |
|
||||
| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Bắt buộc | DeepSeek-V3, DeepSeek-R1 |
|
||||
| [Volcengine](https://console.volcengine.com) | `volcengine/` | Bắt buộc | Doubao, Ark models |
|
||||
| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Bắt buộc | Qwen3, Qwen-Max, v.v. |
|
||||
| [Groq](https://console.groq.com/keys) | `groq/` | Bắt buộc | Suy luận nhanh (Llama, Mixtral) |
|
||||
| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Bắt buộc | Kimi models |
|
||||
| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Bắt buộc | MiniMax models |
|
||||
| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Bắt buộc | Mistral Large, Codestral |
|
||||
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Bắt buộc | Mô hình do NVIDIA lưu trữ |
|
||||
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Bắt buộc | Suy luận nhanh |
|
||||
| [Novita AI](https://novita.ai/) | `novita/` | Bắt buộc | Nhiều mô hình mở |
|
||||
| [Ollama](https://ollama.com/) | `ollama/` | Không cần | Mô hình cục bộ, tự lưu trữ |
|
||||
| [vLLM](https://docs.vllm.ai/) | `vllm/` | Không cần | Triển khai cục bộ, tương thích OpenAI |
|
||||
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Tùy | Proxy cho 100+ provider |
|
||||
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Bắt buộc | Triển khai Azure doanh nghiệp |
|
||||
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Đăng nhập bằng device code |
|
||||
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
|
||||
|
||||
<details>
|
||||
<summary><b>Triển khai cục bộ (Ollama, vLLM, v.v.)</b></summary>
|
||||
|
||||
**Ollama:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-llama",
|
||||
"model": "ollama/llama3.1:8b",
|
||||
"api_base": "http://localhost:11434/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**vLLM:**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "local-vllm",
|
||||
"model": "vllm/your-model",
|
||||
"api_base": "http://localhost:8000/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Để biết chi tiết cấu hình provider đầy đủ, xem [Providers & Models](docs/vi/providers.md).
|
||||
|
||||
</details>
|
||||
|
||||
## 💬 Channels (Ứng dụng Chat)
|
||||
|
||||
Trò chuyện với PicoClaw của bạn qua 17+ nền tảng nhắn tin:
|
||||
|
||||
| Channel | Thiết lập | Protocol | Tài liệu |
|
||||
|---------|-----------|----------|----------|
|
||||
| **Telegram** | Dễ (bot token) | Long polling | [Hướng dẫn](docs/channels/telegram/README.vi.md) |
|
||||
| **Discord** | Dễ (bot token + intents) | WebSocket | [Hướng dẫn](docs/channels/discord/README.vi.md) |
|
||||
| **WhatsApp** | Dễ (quét QR hoặc bridge URL) | Native / Bridge | [Hướng dẫn](docs/vi/chat-apps.md#whatsapp) |
|
||||
| **Weixin** | Dễ (quét QR gốc) | iLink API | [Hướng dẫn](docs/vi/chat-apps.md#weixin) |
|
||||
| **QQ** | Dễ (AppID + AppSecret) | WebSocket | [Hướng dẫn](docs/channels/qq/README.vi.md) |
|
||||
| **Slack** | Dễ (bot + app token) | Socket Mode | [Hướng dẫn](docs/channels/slack/README.vi.md) |
|
||||
| **Matrix** | Trung bình (homeserver + token) | Sync API | [Hướng dẫn](docs/channels/matrix/README.vi.md) |
|
||||
| **DingTalk** | Trung bình (client credentials) | Stream | [Hướng dẫn](docs/channels/dingtalk/README.vi.md) |
|
||||
| **Feishu / Lark** | Trung bình (App ID + Secret) | WebSocket/SDK | [Hướng dẫn](docs/channels/feishu/README.vi.md) |
|
||||
| **LINE** | Trung bình (credentials + webhook) | Webhook | [Hướng dẫn](docs/channels/line/README.vi.md) |
|
||||
| **WeCom Bot** | Trung bình (webhook URL) | Webhook | [Hướng dẫn](docs/channels/wecom/wecom_bot/README.vi.md) |
|
||||
| **WeCom App** | Trung bình (corp credentials) | Webhook | [Hướng dẫn](docs/channels/wecom/wecom_app/README.vi.md) |
|
||||
| **WeCom AI Bot** | Trung bình (token + AES key) | WebSocket / Webhook | [Hướng dẫn](docs/channels/wecom/wecom_aibot/README.vi.md) |
|
||||
| **IRC** | Trung bình (server + nick) | IRC protocol | [Hướng dẫn](docs/vi/chat-apps.md#irc) |
|
||||
| **OneBot** | Trung bình (WebSocket URL) | OneBot v11 | [Hướng dẫn](docs/channels/onebot/README.vi.md) |
|
||||
| **MaixCam** | Dễ (bật) | TCP socket | [Hướng dẫn](docs/channels/maixcam/README.vi.md) |
|
||||
| **Pico** | Dễ (bật) | Native protocol | Tích hợp sẵn |
|
||||
| **Pico Client** | Dễ (WebSocket URL) | WebSocket | Tích hợp sẵn |
|
||||
|
||||
> Tất cả các Channel dựa trên webhook dùng chung một Gateway HTTP server (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`). Feishu sử dụng chế độ WebSocket/SDK và không dùng HTTP server chung.
|
||||
|
||||
Để biết hướng dẫn thiết lập Channel chi tiết, xem [Cấu hình Ứng dụng Chat](docs/vi/chat-apps.md).
|
||||
|
||||
## 🔧 Tools
|
||||
|
||||
### 🔍 Tìm kiếm Web
|
||||
|
||||
PicoClaw có thể tìm kiếm web để cung cấp thông tin cập nhật. Cấu hình trong `tools.web`:
|
||||
|
||||
| Công cụ Tìm kiếm | API Key | Gói miễn phí | Liên kết |
|
||||
|------------------|---------|--------------|----------|
|
||||
| DuckDuckGo | Không cần | Không giới hạn | Dự phòng tích hợp sẵn |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Bắt buộc | 1000 truy vấn/ngày | AI, tối ưu cho tiếng Trung |
|
||||
| [Tavily](https://tavily.com) | Bắt buộc | 1000 truy vấn/tháng | Tối ưu cho AI Agent |
|
||||
| [Brave Search](https://brave.com/search/api) | Bắt buộc | 2000 truy vấn/tháng | Nhanh và riêng tư |
|
||||
| [Perplexity](https://www.perplexity.ai) | Bắt buộc | Trả phí | Tìm kiếm hỗ trợ AI |
|
||||
| [SearXNG](https://github.com/searxng/searxng) | Không cần | Tự lưu trữ | Metasearch engine miễn phí |
|
||||
| [GLM Search](https://open.bigmodel.cn/) | Bắt buộc | Tùy | Tìm kiếm web Zhipu |
|
||||
|
||||
### ⚙️ Các Tools Khác
|
||||
|
||||
PicoClaw bao gồm các tool tích hợp sẵn cho thao tác tệp, thực thi mã, lên lịch và nhiều hơn nữa. Xem [Cấu hình Tools](docs/vi/tools_configuration.md) để biết chi tiết.
|
||||
|
||||
## 🎯 Skills
|
||||
|
||||
Skills là các khả năng mô-đun mở rộng Agent của bạn. Chúng được tải từ các tệp `SKILL.md` trong workspace của bạn.
|
||||
|
||||
**Cài đặt Skills từ ClawHub:**
|
||||
|
||||
```bash
|
||||
picoclaw skills search "web scraping"
|
||||
picoclaw skills install <skill-name>
|
||||
```
|
||||
|
||||
**Cấu hình token ClawHub** (tùy chọn, để có giới hạn tốc độ cao hơn):
|
||||
|
||||
Thêm vào `config.json` của bạn:
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"skills": {
|
||||
"registries": {
|
||||
"clawhub": {
|
||||
"auth_token": "your-clawhub-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Để biết thêm chi tiết, xem [Cấu hình Tools - Skills](docs/vi/tools_configuration.md#skills-tool).
|
||||
|
||||
## 🔗 MCP (Model Context Protocol)
|
||||
|
||||
PicoClaw hỗ trợ [MCP](https://modelcontextprotocol.io/) gốc — kết nối bất kỳ MCP server nào để mở rộng khả năng Agent của bạn với các tool và nguồn dữ liệu bên ngoài.
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcp": {
|
||||
"enabled": true,
|
||||
"servers": {
|
||||
"filesystem": {
|
||||
"enabled": true,
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Để biết cấu hình MCP đầy đủ (stdio, SSE, HTTP transports, Tool Discovery), xem [Cấu hình Tools - MCP](docs/vi/tools_configuration.md#mcp-tool).
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Tham gia Mạng xã hội Agent
|
||||
|
||||
Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một tin nhắn duy nhất qua CLI hoặc bất kỳ Ứng dụng Chat nào đã tích hợp.
|
||||
|
||||
**Đọc `https://clawdchat.ai/skill.md` và làm theo hướng dẫn để tham gia [ClawdChat.ai](https://clawdchat.ai)**
|
||||
|
||||
## 🖥️ Tham chiếu CLI
|
||||
|
||||
| Lệnh | Mô tả |
|
||||
| ------------------------- | ---------------------------------------- |
|
||||
| `picoclaw onboard` | Khởi tạo cấu hình & workspace |
|
||||
| `picoclaw auth weixin` | Kết nối tài khoản WeChat qua QR |
|
||||
| `picoclaw agent -m "..."` | Trò chuyện với agent |
|
||||
| `picoclaw agent` | Chế độ trò chuyện tương tác |
|
||||
| `picoclaw gateway` | Khởi động gateway |
|
||||
| `picoclaw status` | Hiển thị trạng thái |
|
||||
| `picoclaw version` | Hiển thị thông tin phiên bản |
|
||||
| `picoclaw model` | Xem hoặc chuyển đổi mô hình mặc định |
|
||||
| `picoclaw cron list` | Liệt kê tất cả công việc đã lên lịch |
|
||||
| `picoclaw cron add ...` | Thêm công việc đã lên lịch |
|
||||
| `picoclaw cron disable` | Vô hiệu hóa công việc đã lên lịch |
|
||||
| `picoclaw cron remove` | Xóa công việc đã lên lịch |
|
||||
| `picoclaw skills list` | Liệt kê các Skill đã cài đặt |
|
||||
| `picoclaw skills install` | Cài đặt một Skill |
|
||||
| `picoclaw migrate` | Di chuyển dữ liệu từ các phiên bản cũ |
|
||||
| `picoclaw auth login` | Xác thực với các provider |
|
||||
|
||||
### ⏰ Tác vụ Đã lên lịch / Nhắc nhở
|
||||
|
||||
PicoClaw hỗ trợ nhắc nhở đã lên lịch và tác vụ định kỳ thông qua tool `cron`:
|
||||
|
||||
* **Nhắc nhở một lần**: "Nhắc tôi sau 10 phút" -> kích hoạt một lần sau 10 phút
|
||||
* **Tác vụ định kỳ**: "Nhắc tôi mỗi 2 giờ" -> kích hoạt mỗi 2 giờ
|
||||
* **Biểu thức Cron**: "Nhắc tôi lúc 9 giờ sáng hàng ngày" -> sử dụng biểu thức cron
|
||||
|
||||
## 📚 Tài liệu
|
||||
|
||||
Để biết các hướng dẫn chi tiết ngoài README này:
|
||||
|
||||
| Chủ đề | Mô tả |
|
||||
|--------|-------|
|
||||
| [Docker & Khởi động Nhanh](docs/vi/docker.md) | Thiết lập Docker Compose, chế độ Launcher/Agent |
|
||||
| [Ứng dụng Chat](docs/vi/chat-apps.md) | Hướng dẫn thiết lập 17+ Channel |
|
||||
| [Cấu hình](docs/vi/configuration.md) | Biến môi trường, bố cục workspace, sandbox bảo mật |
|
||||
| [Providers & Models](docs/vi/providers.md) | 30+ Provider LLM, định tuyến mô hình, cấu hình model_list |
|
||||
| [Spawn & Tác vụ Bất đồng bộ](docs/vi/spawn-tasks.md) | Tác vụ nhanh, tác vụ dài với spawn, điều phối sub-agent bất đồng bộ |
|
||||
| [Hooks](docs/hooks/README.md) | Hệ thống hook hướng sự kiện: observer, interceptor, approval hook |
|
||||
| [Steering](docs/steering.md) | Chèn tin nhắn vào vòng lặp agent đang chạy |
|
||||
| [SubTurn](docs/subturn.md) | Điều phối subagent, kiểm soát đồng thời, vòng đời |
|
||||
| [Khắc phục sự cố](docs/vi/troubleshooting.md) | Các vấn đề thường gặp và giải pháp |
|
||||
| [Cấu hình Tools](docs/vi/tools_configuration.md) | Bật/tắt từng tool, chính sách exec, MCP, Skills |
|
||||
| [Tương thích Phần cứng](docs/vi/hardware-compatibility.md) | Các board đã kiểm tra, yêu cầu tối thiểu |
|
||||
|
||||
## 🤝 Đóng góp & Lộ trình
|
||||
|
||||
PR luôn được chào đón! Codebase được thiết kế nhỏ gọn và dễ đọc.
|
||||
|
||||
Xem [Lộ trình Cộng đồng](https://github.com/sipeed/picoclaw/issues/988) và [CONTRIBUTING.md](CONTRIBUTING.md) để biết hướng dẫn.
|
||||
|
||||
Nhóm nhà phát triển đang được xây dựng, tham gia sau khi PR đầu tiên của bạn được merge!
|
||||
|
||||
Nhóm Người dùng:
|
||||
|
||||
Discord: <https://discord.gg/V4sAZ9XWpN>
|
||||
|
||||
WeChat:
|
||||
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
||||
+529
-368
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 228 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 102 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 208 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 37 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 142 KiB |
@@ -1,69 +0,0 @@
|
||||
# Picoclaw Launcher TUI
|
||||
|
||||
This directory contains the terminal-based TUI launcher for `picoclaw`.
|
||||
It provides a lightweight, terminal-native user interface for managing, configuring, and interacting with the core `picoclaw` engine, without requiring a web browser or graphical environment.
|
||||
|
||||
## Architecture
|
||||
|
||||
The TUI launcher is implemented purely in Go with no external runtime dependencies:
|
||||
* **`main.go`**: Application entry point, handles initialization and main event loop
|
||||
* **`ui/`**: TUI interface components built on tview + tcell framework:
|
||||
- `home.go`: Main dashboard with navigation menu
|
||||
- `schemes.go`: AI model scheme management
|
||||
- `users.go`: User and API key management for model providers
|
||||
- `channels.go`: Communication channel (Telegram/Discord/WeChat etc.) configuration editor
|
||||
- `gateway.go`: PicoClaw gateway daemon lifecycle management (start/stop/status)
|
||||
- `app.go`: Core TUI application framework and navigation logic
|
||||
- `models.go`: Data structures and state management
|
||||
* **`config/`**: Configuration management layer, integrates with the core picoclaw configuration system
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
* Go 1.25+
|
||||
* Terminal with 256-color support (most modern terminals are compatible)
|
||||
|
||||
### Development
|
||||
|
||||
Run the TUI launcher directly in development mode:
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
go run ./cmd/picoclaw-launcher-tui
|
||||
|
||||
# Or from this directory
|
||||
go run .
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
Build the standalone TUI launcher binary:
|
||||
|
||||
```bash
|
||||
# From project root (recommended)
|
||||
make build-launcher-tui
|
||||
|
||||
# Output will be at:
|
||||
# build/picoclaw-launcher-tui-<platform>-<arch>
|
||||
# with symlink build/picoclaw-launcher-tui
|
||||
|
||||
# Or build directly from this directory
|
||||
go build -o picoclaw-launcher-tui .
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
* 🖥️ Terminal-native interface - works over SSH, on headless servers, and in low-resource environments
|
||||
* ⚙️ AI model scheme and API key management
|
||||
* 📱 Communication channel configuration editor (Telegram/Discord/WeChat etc.)
|
||||
* 🔄 PicoClaw gateway daemon management (start/stop/status monitoring)
|
||||
* 💬 One-click launch of interactive AI chat session
|
||||
* 🎯 Keyboard-first design with intuitive shortcuts
|
||||
|
||||
### Other Commands
|
||||
|
||||
```bash
|
||||
# Run with custom config file path
|
||||
go run . /path/to/custom/config.json
|
||||
```
|
||||
@@ -1,236 +0,0 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
// Package config provides types and I/O for ~/.picoclaw/tui.toml.
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||
)
|
||||
|
||||
// DefaultConfigPath returns the default path to the tui.toml config file.
|
||||
func DefaultConfigPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
home = "."
|
||||
}
|
||||
return filepath.Join(home, ".picoclaw", "tui.toml")
|
||||
}
|
||||
|
||||
// TUIConfig is the top-level structure of ~/.picoclaw/tui.toml.
|
||||
type TUIConfig struct {
|
||||
Version string `toml:"version"`
|
||||
Model Model `toml:"model"`
|
||||
Provider Provider `toml:"provider"`
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
Type string `toml:"type"` // "provider" (default) | "manual"
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
Schemes []Scheme `toml:"schemes"`
|
||||
Users []User `toml:"users"`
|
||||
Current ProviderCurrent `toml:"current"`
|
||||
}
|
||||
|
||||
type Scheme struct {
|
||||
Name string `toml:"name"` // unique key
|
||||
BaseURL string `toml:"baseURL"` // required
|
||||
Type string `toml:"type"` // "openai-compatible" (default) | "anthropic"
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Name string `toml:"name"`
|
||||
Scheme string `toml:"scheme"` // references Scheme.Name; (Name+Scheme) is unique
|
||||
Type string `toml:"type"` // "key" (default) | "OAuth"
|
||||
Key string `toml:"key"`
|
||||
}
|
||||
|
||||
type ProviderCurrent struct {
|
||||
Scheme string `toml:"scheme"` // references Scheme.Name
|
||||
User string `toml:"user"` // references User.Name where User.Scheme == Scheme
|
||||
Model string `toml:"model"` // from GET <baseURL>/models
|
||||
}
|
||||
|
||||
// DefaultConfig returns a minimal valid TUIConfig.
|
||||
func DefaultConfig() *TUIConfig {
|
||||
return &TUIConfig{
|
||||
Version: "1.0",
|
||||
Model: Model{Type: "provider"},
|
||||
Provider: Provider{
|
||||
Schemes: []Scheme{},
|
||||
Users: []User{},
|
||||
Current: ProviderCurrent{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads the TUI config from path. Returns a default config if the file does not exist.
|
||||
func Load(path string) (*TUIConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return DefaultConfig(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read config file %q: %w", path, err)
|
||||
}
|
||||
|
||||
cfg := DefaultConfig()
|
||||
if _, err := toml.Decode(string(data), cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config file %q: %w", path, err)
|
||||
}
|
||||
|
||||
applyDefaults(cfg)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Save writes cfg to path atomically (safe for flash / SD storage).
|
||||
func Save(path string, cfg *TUIConfig) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
enc := toml.NewEncoder(&buf)
|
||||
if err := enc.Encode(cfg); err != nil {
|
||||
return fmt.Errorf("failed to encode config: %w", err)
|
||||
}
|
||||
if err := fileutil.WriteFileAtomic(path, buf.Bytes(), 0o600); err != nil {
|
||||
return fmt.Errorf("failed to write config file %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyDefaults(cfg *TUIConfig) {
|
||||
if cfg.Version == "" {
|
||||
cfg.Version = "1.0"
|
||||
}
|
||||
if cfg.Model.Type == "" {
|
||||
cfg.Model.Type = "provider"
|
||||
}
|
||||
for i := range cfg.Provider.Schemes {
|
||||
if cfg.Provider.Schemes[i].Type == "" {
|
||||
cfg.Provider.Schemes[i].Type = "openai-compatible"
|
||||
}
|
||||
}
|
||||
for i := range cfg.Provider.Users {
|
||||
if cfg.Provider.Users[i].Type == "" {
|
||||
cfg.Provider.Users[i].Type = "key"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SchemeByName returns the first Scheme whose Name matches, or nil.
|
||||
func (p *Provider) SchemeByName(name string) *Scheme {
|
||||
for i := range p.Schemes {
|
||||
if p.Schemes[i].Name == name {
|
||||
return &p.Schemes[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UsersForScheme returns all users whose Scheme field matches schemeName.
|
||||
func (p *Provider) UsersForScheme(schemeName string) []User {
|
||||
var out []User
|
||||
for _, u := range p.Users {
|
||||
if u.Scheme == schemeName {
|
||||
out = append(out, u)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SyncSelectedModelToMainConfig syncs the currently selected model to ~/.picoclaw/config.json
|
||||
// Adds/replaces a "tui-prefer" model entry and sets it as the default model.
|
||||
// Preserves all other existing fields in the config file unchanged.
|
||||
func SyncSelectedModelToMainConfig(scheme Scheme, user User, modelID string) error {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
home = "."
|
||||
}
|
||||
mainConfigPath := filepath.Join(home, ".picoclaw", "config.json")
|
||||
|
||||
var cfg map[string]any
|
||||
if data, readErr := os.ReadFile(mainConfigPath); readErr == nil {
|
||||
if unmarshalErr := json.Unmarshal(data, &cfg); unmarshalErr != nil {
|
||||
cfg = make(map[string]any)
|
||||
}
|
||||
} else {
|
||||
cfg = make(map[string]any)
|
||||
}
|
||||
|
||||
if _, ok := cfg["agents"]; !ok {
|
||||
cfg["agents"] = make(map[string]any)
|
||||
}
|
||||
agents, ok := cfg["agents"].(map[string]any)
|
||||
if ok {
|
||||
if _, ok := agents["defaults"]; !ok {
|
||||
agents["defaults"] = make(map[string]any)
|
||||
}
|
||||
defaults, ok := agents["defaults"].(map[string]any)
|
||||
if ok {
|
||||
defaults["model"] = "tui-prefer"
|
||||
}
|
||||
}
|
||||
|
||||
tuiModel := map[string]any{
|
||||
"model_name": "tui-prefer",
|
||||
"model": modelID,
|
||||
"api_key": user.Key,
|
||||
"api_base": scheme.BaseURL,
|
||||
}
|
||||
|
||||
modelList := []any{}
|
||||
if ml, ok := cfg["model_list"].([]any); ok {
|
||||
modelList = ml
|
||||
}
|
||||
|
||||
found := false
|
||||
for i, m := range modelList {
|
||||
if entry, ok := m.(map[string]any); ok {
|
||||
if name, ok := entry["model_name"].(string); ok && name == "tui-prefer" {
|
||||
modelList[i] = tuiModel
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
modelList = append(modelList, tuiModel)
|
||||
}
|
||||
cfg["model_list"] = modelList
|
||||
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(mainConfigPath), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(mainConfigPath, data, 0o600)
|
||||
}
|
||||
|
||||
func (cfg *TUIConfig) CurrentModelLabel() string {
|
||||
cur := cfg.Provider.Current
|
||||
if cur.Model == "" {
|
||||
return "(not configured)"
|
||||
}
|
||||
label := cur.Scheme
|
||||
if label != "" {
|
||||
label += " / "
|
||||
}
|
||||
return label + cur.Model
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/ui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configPath := tuicfg.DefaultConfigPath()
|
||||
if len(os.Args) > 1 {
|
||||
configPath = os.Args[1]
|
||||
}
|
||||
|
||||
configDir := filepath.Dir(configPath)
|
||||
if _, err := os.Stat(configDir); os.IsNotExist(err) {
|
||||
cmd := exec.Command("picoclaw", "onboard")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
_ = cmd.Run()
|
||||
}
|
||||
|
||||
cfg, err := tuicfg.Load(configPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
app := ui.New(cfg, configPath)
|
||||
// Bind model selection hook to sync to main config
|
||||
app.OnModelSelected = func(scheme tuicfg.Scheme, user tuicfg.User, modelID string) {
|
||||
_ = tuicfg.SyncSelectedModelToMainConfig(scheme, user, modelID)
|
||||
}
|
||||
if err := app.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||
)
|
||||
|
||||
// App is the root TUI application.
|
||||
type App struct {
|
||||
tapp *tview.Application
|
||||
pages *tview.Pages
|
||||
pageStack []string
|
||||
cfg *tuicfg.TUIConfig
|
||||
configPath string
|
||||
pageRefreshFns map[string]func()
|
||||
headerModelTV *tview.TextView
|
||||
modalOpen map[string]bool
|
||||
|
||||
// OnModelSelected is called when a model is selected in the UI.
|
||||
// Can be nil to disable.
|
||||
OnModelSelected func(scheme tuicfg.Scheme, user tuicfg.User, modelID string)
|
||||
|
||||
modelCache map[string][]modelEntry
|
||||
modelCacheMu sync.RWMutex
|
||||
refreshMu sync.Mutex
|
||||
}
|
||||
|
||||
// cacheKey returns the map key for a (scheme, user) pair.
|
||||
func cacheKey(schemeName, userName string) string {
|
||||
return fmt.Sprintf("%s/%s", schemeName, userName)
|
||||
}
|
||||
|
||||
// cachedModels returns a defensive copy of the cached model list for a user (may be nil).
|
||||
func (a *App) cachedModels(schemeName, userName string) []modelEntry {
|
||||
a.modelCacheMu.RLock()
|
||||
defer a.modelCacheMu.RUnlock()
|
||||
entries := a.modelCache[cacheKey(schemeName, userName)]
|
||||
return append([]modelEntry(nil), entries...)
|
||||
}
|
||||
|
||||
// refreshModelCache fetches models for every user in the config concurrently.
|
||||
// Serialized by refreshMu so concurrent calls don't race on the cache map.
|
||||
// When all fetches complete it calls onDone via QueueUpdateDraw.
|
||||
func (a *App) refreshModelCache(onDone func()) {
|
||||
go func() {
|
||||
a.refreshMu.Lock()
|
||||
defer a.refreshMu.Unlock()
|
||||
|
||||
users := a.cfg.Provider.Users
|
||||
schemes := a.cfg.Provider.Schemes
|
||||
|
||||
schemeURL := make(map[string]string, len(schemes))
|
||||
for _, s := range schemes {
|
||||
schemeURL[s.Name] = s.BaseURL
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, u := range users {
|
||||
baseURL, ok := schemeURL[u.Scheme]
|
||||
if !ok || baseURL == "" {
|
||||
continue
|
||||
}
|
||||
if u.Key == "" {
|
||||
a.modelCacheMu.Lock()
|
||||
if a.modelCache == nil {
|
||||
a.modelCache = make(map[string][]modelEntry)
|
||||
}
|
||||
a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
|
||||
a.modelCacheMu.Unlock()
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
bURL := baseURL
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
entries, err := fetchModels(bURL, u.Key)
|
||||
a.modelCacheMu.Lock()
|
||||
if a.modelCache == nil {
|
||||
a.modelCache = make(map[string][]modelEntry)
|
||||
}
|
||||
if err != nil || len(entries) == 0 {
|
||||
a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
|
||||
} else {
|
||||
a.modelCache[cacheKey(u.Scheme, u.Name)] = entries
|
||||
}
|
||||
a.modelCacheMu.Unlock()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if onDone != nil {
|
||||
a.tapp.QueueUpdateDraw(onDone)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// New creates and wires up the TUI application.
|
||||
func New(cfg *tuicfg.TUIConfig, configPath string) *App {
|
||||
// Cyberpunk Theme Colors
|
||||
// Dark background
|
||||
tview.Styles.PrimitiveBackgroundColor = tcell.NewHexColor(0x050510) // Deep Void
|
||||
tview.Styles.ContrastBackgroundColor = tcell.NewHexColor(0x1a1a2e) // Dark Indigo
|
||||
tview.Styles.MoreContrastBackgroundColor = tcell.NewHexColor(0x2a2a40)
|
||||
|
||||
// Borders and Titles
|
||||
tview.Styles.BorderColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
|
||||
tview.Styles.TitleColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
|
||||
tview.Styles.GraphicsColor = tcell.NewHexColor(0xff00ff) // Neon Magenta
|
||||
|
||||
// Text
|
||||
tview.Styles.PrimaryTextColor = tcell.NewHexColor(0xe0e0e0) // Off-white
|
||||
tview.Styles.SecondaryTextColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
|
||||
tview.Styles.TertiaryTextColor = tcell.NewHexColor(0x39ff14) // Neon Lime
|
||||
tview.Styles.InverseTextColor = tcell.NewHexColor(0x000000) // Black
|
||||
tview.Styles.ContrastSecondaryTextColor = tcell.NewHexColor(0xff00ff) // Neon Magenta
|
||||
|
||||
a := &App{
|
||||
tapp: tview.NewApplication(),
|
||||
pages: tview.NewPages(),
|
||||
pageStack: []string{},
|
||||
cfg: cfg,
|
||||
configPath: configPath,
|
||||
pageRefreshFns: make(map[string]func()),
|
||||
modalOpen: make(map[string]bool),
|
||||
}
|
||||
|
||||
a.tapp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
if len(a.modalOpen) > 0 {
|
||||
return event
|
||||
}
|
||||
return a.goBack()
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
a.buildPages()
|
||||
return a
|
||||
}
|
||||
|
||||
// Run starts the TUI event loop.
|
||||
func (a *App) Run() error {
|
||||
return a.tapp.SetRoot(a.pages, true).EnableMouse(true).Run()
|
||||
}
|
||||
|
||||
func (a *App) buildPages() {
|
||||
a.pages.AddPage("home", a.newHomePage(), true, true)
|
||||
a.pageStack = []string{"home"}
|
||||
}
|
||||
|
||||
func (a *App) navigateTo(name string, page tview.Primitive) {
|
||||
a.pages.RemovePage(name)
|
||||
a.pages.AddPage(name, page, true, false)
|
||||
a.pageStack = append(a.pageStack, name)
|
||||
a.pages.SwitchToPage(name)
|
||||
}
|
||||
|
||||
func (a *App) goBack() *tcell.EventKey {
|
||||
if len(a.pageStack) <= 1 {
|
||||
return nil
|
||||
}
|
||||
popped := a.pageStack[len(a.pageStack)-1]
|
||||
a.pageStack = a.pageStack[:len(a.pageStack)-1]
|
||||
a.pages.RemovePage(popped)
|
||||
prev := a.pageStack[len(a.pageStack)-1]
|
||||
if fn, ok := a.pageRefreshFns[prev]; ok {
|
||||
fn()
|
||||
}
|
||||
if prev == "home" && a.headerModelTV != nil {
|
||||
a.headerModelTV.SetText(a.cfg.CurrentModelLabel() + " ")
|
||||
}
|
||||
a.pages.SwitchToPage(prev)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) showModal(name string, primitive tview.Primitive) {
|
||||
a.modalOpen[name] = true
|
||||
a.pages.AddPage(name, primitive, true, true)
|
||||
}
|
||||
|
||||
func (a *App) hideModal(name string) {
|
||||
delete(a.modalOpen, name)
|
||||
a.pages.HidePage(name)
|
||||
a.pages.RemovePage(name)
|
||||
}
|
||||
|
||||
func (a *App) save() {
|
||||
if err := tuicfg.Save(a.configPath, a.cfg); err != nil {
|
||||
a.showError("save failed: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) showError(msg string) {
|
||||
modal := tview.NewModal().
|
||||
SetText(" [red::b]ERROR[-::-]\n\n" + msg).
|
||||
AddButtons([]string{"OK"}).
|
||||
SetDoneFunc(func(_ int, _ string) {
|
||||
a.hideModal("error")
|
||||
})
|
||||
// Cyberpunk Modal Style
|
||||
modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
|
||||
modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White
|
||||
modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red
|
||||
modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White
|
||||
a.showModal("error", modal)
|
||||
}
|
||||
|
||||
func (a *App) confirmDelete(label string, onConfirm func()) {
|
||||
modal := tview.NewModal().
|
||||
SetText(" [red::b]DELETE WARNING[-::-]\n\nDelete " + label + "?\n[gray]This action cannot be undone.[-]").
|
||||
AddButtons([]string{"Delete", "Cancel"}).
|
||||
SetDoneFunc(func(_ int, buttonLabel string) {
|
||||
a.hideModal("confirm-delete")
|
||||
if buttonLabel == "Delete" {
|
||||
onConfirm()
|
||||
}
|
||||
})
|
||||
// Cyberpunk Modal Style
|
||||
modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
|
||||
modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White
|
||||
modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red for danger
|
||||
modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White
|
||||
a.showModal("confirm-delete", modal)
|
||||
}
|
||||
|
||||
func centeredForm(form *tview.Form, widthPct, height int) tview.Primitive {
|
||||
return tview.NewFlex().
|
||||
AddItem(tview.NewBox(), 0, 1, false).
|
||||
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(tview.NewBox(), 0, 1, false).
|
||||
AddItem(form, height, 1, true).
|
||||
AddItem(tview.NewBox(), 0, 1, false), 0, widthPct, true).
|
||||
AddItem(tview.NewBox(), 0, 1, false)
|
||||
}
|
||||
|
||||
func hintBar(text string) *tview.TextView {
|
||||
tv := tview.NewTextView().
|
||||
SetText(text).
|
||||
SetDynamicColors(true).
|
||||
SetTextAlign(tview.AlignCenter).
|
||||
SetTextColor(tcell.NewHexColor(0x00f0ff)) // Neon Cyan
|
||||
tv.SetBackgroundColor(tcell.NewHexColor(0x2a2a40)) // Darker Indigo
|
||||
return tv
|
||||
}
|
||||
|
||||
func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tview.Primitive {
|
||||
var modelTV *tview.TextView
|
||||
if pageID == "home" {
|
||||
if a.headerModelTV == nil {
|
||||
a.headerModelTV = tview.NewTextView()
|
||||
a.headerModelTV.SetTextAlign(tview.AlignRight).
|
||||
SetTextColor(tcell.NewHexColor(0x39ff14)). // Neon Lime
|
||||
SetDynamicColors(true).
|
||||
SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
}
|
||||
modelTV = a.headerModelTV
|
||||
modelTV.SetText("MODEL: " + a.cfg.CurrentModelLabel() + " ")
|
||||
} else {
|
||||
modelTV = tview.NewTextView()
|
||||
modelTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
}
|
||||
|
||||
headerLeft := tview.NewTextView().
|
||||
SetText(" [#ff00ff::b]///[#00f0ff] PICOCLAW LAUNCHER [#ff00ff]///").
|
||||
SetDynamicColors(true).
|
||||
SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
header := tview.NewFlex().
|
||||
AddItem(headerLeft, 0, 1, false).
|
||||
AddItem(modelTV, 0, 1, false)
|
||||
|
||||
sidebar := tview.NewTextView().
|
||||
SetDynamicColors(true).
|
||||
SetWrap(false)
|
||||
sidebar.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
|
||||
|
||||
// Cyberpunk Sidebar Styling
|
||||
activePrefix := "[#39ff14::b]>> " // Neon Lime arrow
|
||||
activeSuffix := "[-]"
|
||||
inactivePrefix := "[#808080] "
|
||||
inactiveSuffix := "[-]"
|
||||
|
||||
sbText := "\n\n" // Top padding
|
||||
|
||||
menuItem := func(id, label string) string {
|
||||
if pageID == id {
|
||||
return activePrefix + label + activeSuffix + "\n\n"
|
||||
}
|
||||
return inactivePrefix + label + inactiveSuffix + "\n\n"
|
||||
}
|
||||
|
||||
sbText += menuItem("home", "HOME")
|
||||
sbText += menuItem("schemes", "SCHEMES")
|
||||
sbText += menuItem("users", "USERS")
|
||||
sbText += menuItem("models", "MODELS")
|
||||
sbText += menuItem("channels", "CHANNELS")
|
||||
sbText += menuItem("gateway", "GATEWAY")
|
||||
|
||||
sidebar.SetText(sbText)
|
||||
|
||||
footer := hintBar(hint)
|
||||
|
||||
grid := tview.NewGrid().
|
||||
SetRows(1, 0, 1).
|
||||
SetColumns(20, 0). // Slightly wider sidebar
|
||||
AddItem(header, 0, 0, 1, 2, 0, 0, false).
|
||||
AddItem(sidebar, 1, 0, 1, 1, 0, 0, false).
|
||||
AddItem(content, 1, 1, 1, 1, 0, 0, true).
|
||||
AddItem(footer, 2, 0, 1, 2, 0, 0, false)
|
||||
|
||||
// Add a border around the content area if possible, or ensure content has its own border
|
||||
// grid.SetBorders(false) // Grid borders usually look bad, handled by components
|
||||
|
||||
return grid
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
func (a *App) newChannelsPage() tview.Primitive {
|
||||
list := tview.NewList()
|
||||
list.SetBorder(true).
|
||||
SetTitle(" [#00f0ff::b] COMMUNICATION CHANNELS ").
|
||||
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
|
||||
list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
|
||||
list.SetSelectedStyle(
|
||||
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0x050510)),
|
||||
)
|
||||
list.SetHighlightFullLine(true)
|
||||
list.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
rebuild := func() {
|
||||
sel := list.GetCurrentItem()
|
||||
list.Clear()
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
home = "."
|
||||
}
|
||||
configPath := filepath.Join(home, ".picoclaw", "config.json")
|
||||
|
||||
var cfg map[string]any
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
_ = json.Unmarshal(data, &cfg)
|
||||
}
|
||||
|
||||
if chRaw, ok := cfg["channels"].(map[string]any); ok {
|
||||
for name, ch := range chRaw {
|
||||
chMap, ok := ch.(map[string]any)
|
||||
enabled := "disabled"
|
||||
if ok {
|
||||
if e, ok := chMap["enabled"].(bool); ok && e {
|
||||
enabled = "enabled"
|
||||
}
|
||||
}
|
||||
list.AddItem(name, fmt.Sprintf("Status: %s", enabled), 0, func() {
|
||||
a.showChannelEditForm(configPath, name, chMap)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if sel >= 0 && sel < list.GetItemCount() {
|
||||
list.SetCurrentItem(sel)
|
||||
}
|
||||
}
|
||||
rebuild()
|
||||
|
||||
a.pageRefreshFns["channels"] = rebuild
|
||||
|
||||
list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
return a.goBack()
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
return a.buildShell("channels", list, " [#ff00ff]Enter:[-] edit [#ff2a2a]ESC:[-] back ")
|
||||
}
|
||||
|
||||
func (a *App) showChannelEditForm(configPath, channelName string, existing map[string]any) {
|
||||
form := tview.NewForm()
|
||||
form.SetBorder(true).
|
||||
SetTitle(" [::b]EDIT CHANNEL ").
|
||||
SetTitleColor(tcell.NewHexColor(0x39ff14)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
|
||||
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
|
||||
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
|
||||
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
|
||||
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
|
||||
|
||||
fields := make(map[string]*tview.InputField)
|
||||
var nameField *tview.InputField
|
||||
|
||||
if channelName == "" {
|
||||
nameField = tview.NewInputField().
|
||||
SetLabel("Channel Name").
|
||||
SetText("").
|
||||
SetFieldWidth(28)
|
||||
form.AddFormItem(nameField)
|
||||
}
|
||||
|
||||
for k, v := range existing {
|
||||
if reflect.ValueOf(v).Kind() == reflect.Map || reflect.ValueOf(v).Kind() == reflect.Slice {
|
||||
continue
|
||||
}
|
||||
valStr := fmt.Sprintf("%v", v)
|
||||
field := tview.NewInputField().
|
||||
SetLabel(k).
|
||||
SetText(valStr).
|
||||
SetFieldWidth(28)
|
||||
form.AddFormItem(field)
|
||||
fields[k] = field
|
||||
}
|
||||
|
||||
form.AddButton("SAVE", func() {
|
||||
var cfg map[string]any
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
cfg = make(map[string]any)
|
||||
}
|
||||
} else {
|
||||
cfg = make(map[string]any)
|
||||
}
|
||||
|
||||
if _, ok := cfg["channels"]; !ok {
|
||||
cfg["channels"] = make(map[string]any)
|
||||
}
|
||||
channels, ok := cfg["channels"].(map[string]any)
|
||||
if !ok {
|
||||
channels = make(map[string]any)
|
||||
cfg["channels"] = channels
|
||||
}
|
||||
|
||||
finalName := channelName
|
||||
if channelName == "" {
|
||||
if nameField == nil || nameField.GetText() == "" {
|
||||
a.showError("Channel name is required")
|
||||
return
|
||||
}
|
||||
finalName = nameField.GetText()
|
||||
}
|
||||
|
||||
updated := make(map[string]any)
|
||||
if existing != nil {
|
||||
for k, v := range existing {
|
||||
updated[k] = v
|
||||
}
|
||||
}
|
||||
for k, field := range fields {
|
||||
val := field.GetText()
|
||||
if val == "true" {
|
||||
updated[k] = true
|
||||
} else if val == "false" {
|
||||
updated[k] = false
|
||||
} else if num, err := strconv.Atoi(val); err == nil {
|
||||
updated[k] = num
|
||||
} else {
|
||||
updated[k] = val
|
||||
}
|
||||
}
|
||||
|
||||
if channelName != "" && finalName != channelName {
|
||||
delete(channels, channelName)
|
||||
}
|
||||
channels[finalName] = updated
|
||||
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
a.showError(fmt.Sprintf("Failed to save config: %v", err))
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
|
||||
a.showError(fmt.Sprintf("Failed to create config directory: %v", err))
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(configPath, data, 0o600); err != nil {
|
||||
a.showError(fmt.Sprintf("Failed to write config: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
a.hideModal("channel-edit")
|
||||
a.goBack()
|
||||
})
|
||||
|
||||
form.AddButton("CANCEL", func() {
|
||||
a.hideModal("channel-edit")
|
||||
})
|
||||
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
a.hideModal("channel-edit")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
a.showModal("channel-edit", centeredForm(form, 4, 20))
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
const pidFileName = "gateway.pid"
|
||||
|
||||
type gatewayStatus struct {
|
||||
running bool
|
||||
pid int
|
||||
}
|
||||
|
||||
func getPidPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
home = "."
|
||||
}
|
||||
return filepath.Join(home, ".picoclaw", pidFileName)
|
||||
}
|
||||
|
||||
func isProcessRunning(pid int) bool {
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid))
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(output), strconv.Itoa(pid))
|
||||
} else if runtime.GOOS == "darwin" {
|
||||
cmd := exec.Command("ps", "aux")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(output), fmt.Sprintf(" %d ", pid))
|
||||
}
|
||||
// Linux
|
||||
_, err := os.Stat(fmt.Sprintf("/proc/%d", pid))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func getGatewayStatus() gatewayStatus {
|
||||
pidPath := getPidPath()
|
||||
data, err := os.ReadFile(pidPath)
|
||||
if err != nil {
|
||||
return gatewayStatus{running: false}
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
if err != nil {
|
||||
return gatewayStatus{running: false}
|
||||
}
|
||||
if !isProcessRunning(pid) {
|
||||
os.Remove(pidPath)
|
||||
return gatewayStatus{running: false}
|
||||
}
|
||||
return gatewayStatus{
|
||||
running: true,
|
||||
pid: pid,
|
||||
}
|
||||
}
|
||||
|
||||
func startGateway() error {
|
||||
status := getGatewayStatus()
|
||||
if status.running {
|
||||
return fmt.Errorf("gateway is already running (PID: %d)", status.pid)
|
||||
}
|
||||
|
||||
pidPath := getPidPath()
|
||||
var cmd *exec.Cmd
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = exec.Command("cmd", "/C", "start /B picoclaw gateway > NUL 2>&1")
|
||||
} else {
|
||||
cmd = exec.Command("sh", "-c", "nohup picoclaw gateway > /dev/null 2>&1 & echo $! > "+pidPath)
|
||||
}
|
||||
|
||||
err := cmd.Start()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd := exec.Command(
|
||||
"wmic",
|
||||
"process",
|
||||
"where",
|
||||
"name='picoclaw.exe' and commandline like '%gateway%'",
|
||||
"get",
|
||||
"processid",
|
||||
)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get gateway PID: %w", err)
|
||||
}
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines[1:] {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
pid, err := strconv.Atoi(line)
|
||||
if err == nil {
|
||||
os.WriteFile(pidPath, []byte(strconv.Itoa(pid)), 0o600)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
status = getGatewayStatus()
|
||||
if !status.running {
|
||||
return fmt.Errorf("failed to start gateway")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stopGateway() error {
|
||||
status := getGatewayStatus()
|
||||
if !status.running {
|
||||
return fmt.Errorf("gateway is not running")
|
||||
}
|
||||
|
||||
var err error
|
||||
if runtime.GOOS == "windows" {
|
||||
err = exec.Command("taskkill", "/F", "/PID", strconv.Itoa(status.pid)).Run()
|
||||
} else {
|
||||
err = exec.Command("kill", "-9", strconv.Itoa(status.pid)).Run()
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 多次尝试确认进程已停止
|
||||
for i := 0; i < 5; i++ {
|
||||
if !isProcessRunning(status.pid) {
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
os.Remove(getPidPath())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) newGatewayPage() tview.Primitive {
|
||||
flex := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
flex.SetBorder(true).
|
||||
SetTitle(" [#00f0ff::b] GATEWAY MANAGEMENT ").
|
||||
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
flex.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
statusTV := tview.NewTextView().
|
||||
SetDynamicColors(true).
|
||||
SetTextAlign(tview.AlignCenter).
|
||||
SetText("Checking status...")
|
||||
statusTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
var updateStatus func()
|
||||
|
||||
// 使用List作为按钮,保证显示和交互正常
|
||||
buttons := tview.NewList()
|
||||
buttons.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
buttons.SetMainTextColor(tcell.ColorWhite)
|
||||
buttons.SetSelectedBackgroundColor(tcell.NewHexColor(0xff00ff))
|
||||
buttons.SetSelectedTextColor(tcell.ColorBlack)
|
||||
|
||||
buttons.AddItem(" [lime]START[white] ", "", 0, func() {
|
||||
if !getGatewayStatus().running {
|
||||
err := startGateway()
|
||||
if err != nil {
|
||||
a.showError(err.Error())
|
||||
}
|
||||
updateStatus()
|
||||
}
|
||||
})
|
||||
buttons.AddItem(" [red]STOP[white] ", "", 0, func() {
|
||||
if getGatewayStatus().running {
|
||||
err := stopGateway()
|
||||
if err != nil {
|
||||
a.showError(err.Error())
|
||||
}
|
||||
updateStatus()
|
||||
}
|
||||
})
|
||||
|
||||
buttonFlex := tview.NewFlex().SetDirection(tview.FlexColumn)
|
||||
buttonFlex.
|
||||
AddItem(tview.NewBox(), 0, 1, false).
|
||||
AddItem(buttons, 20, 1, true).
|
||||
AddItem(tview.NewBox(), 0, 1, false)
|
||||
|
||||
flex.
|
||||
AddItem(tview.NewBox(), 0, 1, false).
|
||||
AddItem(statusTV, 3, 1, false).
|
||||
AddItem(tview.NewBox(), 0, 1, false).
|
||||
AddItem(buttonFlex, 4, 1, true).
|
||||
AddItem(tview.NewBox(), 0, 1, false)
|
||||
|
||||
updateStatus = func() {
|
||||
status := getGatewayStatus()
|
||||
if status.running {
|
||||
statusTV.SetText(fmt.Sprintf("[#39ff14::b]GATEWAY RUNNING[-]\n\nPID: %d", status.pid))
|
||||
buttons.SetItemText(0, " [gray]START[white] ", "")
|
||||
buttons.SetItemText(1, " [red]STOP[white] ", "")
|
||||
} else {
|
||||
statusTV.SetText("[#ff2a2a::b]GATEWAY STOPPED[-]\n\nPID: N/A")
|
||||
buttons.SetItemText(0, " [lime]START[white] ", "")
|
||||
buttons.SetItemText(1, " [gray]STOP[white] ", "")
|
||||
}
|
||||
}
|
||||
|
||||
updateStatus()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
a.tapp.QueueUpdateDraw(updateStatus)
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
originalInputCapture := flex.GetInputCapture()
|
||||
flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
close(done)
|
||||
return a.goBack()
|
||||
}
|
||||
if originalInputCapture != nil {
|
||||
return originalInputCapture(event)
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
a.pageRefreshFns["gateway"] = updateStatus
|
||||
|
||||
return a.buildShell("gateway", flex, " [#39ff14]Enter:[-] select [#ff2a2a]ESC:[-] back ")
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
func (a *App) newHomePage() tview.Primitive {
|
||||
list := tview.NewList()
|
||||
list.SetBorder(true).
|
||||
SetTitle(" [#00f0ff::b] ACTIVE CONFIGURATION ").
|
||||
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
|
||||
list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
|
||||
list.SetSelectedStyle(
|
||||
tcell.StyleDefault.Background(tcell.NewHexColor(0x39ff14)).Foreground(tcell.NewHexColor(0x050510)),
|
||||
)
|
||||
list.SetHighlightFullLine(true)
|
||||
list.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
rebuildList := func() {
|
||||
sel := list.GetCurrentItem()
|
||||
list.Clear()
|
||||
list.AddItem("MODEL: "+a.cfg.CurrentModelLabel(), "Select to configure AI model", 'm', func() {
|
||||
a.navigateTo("schemes", a.newSchemesPage())
|
||||
})
|
||||
list.AddItem(
|
||||
"CHANNELS: Configure communication channels",
|
||||
"Manage Telegram/Discord/WeChat channels",
|
||||
'n',
|
||||
func() {
|
||||
a.navigateTo("channels", a.newChannelsPage())
|
||||
},
|
||||
)
|
||||
list.AddItem("GATEWAY MANAGEMENT", "Manage PicoClaw gateway daemon", 'g', func() {
|
||||
a.navigateTo("gateway", a.newGatewayPage())
|
||||
})
|
||||
list.AddItem("CHAT: Start AI agent chat", "Launch interactive chat session", 'c', func() {
|
||||
a.tapp.Suspend(func() {
|
||||
cmd := exec.Command("picoclaw", "agent")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
_ = cmd.Run()
|
||||
})
|
||||
})
|
||||
list.AddItem("QUIT SYSTEM", "Exit PicoClaw Launcher", 'q', func() { a.tapp.Stop() })
|
||||
if sel >= 0 && sel < list.GetItemCount() {
|
||||
list.SetCurrentItem(sel)
|
||||
}
|
||||
}
|
||||
rebuildList()
|
||||
|
||||
a.pageRefreshFns["home"] = rebuildList
|
||||
|
||||
return a.buildShell(
|
||||
"home",
|
||||
list,
|
||||
" [#00f0ff]m:[-] model [#00f0ff]n:[-] channels [#00f0ff]g:[-] gateway [#00f0ff]c:[-] chat [#ff2a2a]q:[-] quit ",
|
||||
)
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||
)
|
||||
|
||||
type modelsAPIResponse struct {
|
||||
Data []modelEntry `json:"data"`
|
||||
}
|
||||
|
||||
type modelEntry struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitive {
|
||||
table := tview.NewTable().
|
||||
SetBorders(false).
|
||||
SetSelectable(true, false).
|
||||
SetFixed(0, 0)
|
||||
table.SetBorder(true).
|
||||
SetTitle(fmt.Sprintf(" [#00f0ff::b] MODELS · %s / %s ", schemeName, userName)).
|
||||
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
table.SetSelectedStyle(
|
||||
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
|
||||
)
|
||||
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
var modelIDs []string
|
||||
|
||||
status := tview.NewTextView().
|
||||
SetTextAlign(tview.AlignCenter).
|
||||
SetDynamicColors(true).
|
||||
SetText("[#ffff00]FETCHING MODELS...[-]")
|
||||
status.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
flex := tview.NewFlex().
|
||||
SetDirection(tview.FlexRow).
|
||||
AddItem(status, 1, 0, false).
|
||||
AddItem(table, 0, 1, false)
|
||||
|
||||
apiKey := a.resolveKey(schemeName, userName)
|
||||
|
||||
go func() {
|
||||
var entries []modelEntry
|
||||
var err error
|
||||
if apiKey == "" {
|
||||
err = fmt.Errorf("key is required")
|
||||
} else {
|
||||
entries, err = fetchModels(baseURL, apiKey)
|
||||
}
|
||||
|
||||
a.modelCacheMu.Lock()
|
||||
if a.modelCache == nil {
|
||||
a.modelCache = make(map[string][]modelEntry)
|
||||
}
|
||||
if err == nil && len(entries) > 0 {
|
||||
a.modelCache[cacheKey(schemeName, userName)] = entries
|
||||
} else {
|
||||
a.modelCache[cacheKey(schemeName, userName)] = nil
|
||||
}
|
||||
a.modelCacheMu.Unlock()
|
||||
|
||||
a.tapp.QueueUpdateDraw(func() {
|
||||
if err != nil {
|
||||
status.SetText(fmt.Sprintf("[#ff2a2a]ERROR: %s[-]", err.Error()))
|
||||
table.SetCell(0, 0, tview.NewTableCell(" (failed to load models)"))
|
||||
a.tapp.SetFocus(table)
|
||||
return
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
status.SetText("[#ff2a2a]NO MODELS RETURNED[-]")
|
||||
table.SetCell(0, 0, tview.NewTableCell(" (no models available)"))
|
||||
a.tapp.SetFocus(table)
|
||||
return
|
||||
}
|
||||
|
||||
status.SetText(fmt.Sprintf("[#39ff14]%d MODEL(S) LOADED[-]", len(entries)))
|
||||
for i, m := range entries {
|
||||
modelIDs = append(modelIDs, m.ID)
|
||||
table.SetCell(i, 0,
|
||||
tview.NewTableCell(fmt.Sprintf("%3d", i+1)).
|
||||
SetAlign(tview.AlignRight).
|
||||
SetTextColor(tcell.NewHexColor(0x808080)).
|
||||
SetSelectable(false),
|
||||
)
|
||||
table.SetCell(i, 1,
|
||||
tview.NewTableCell(" "+m.ID).
|
||||
SetAlign(tview.AlignLeft).
|
||||
SetExpansion(1).
|
||||
SetTextColor(tcell.NewHexColor(0xe0e0e0)),
|
||||
)
|
||||
}
|
||||
a.tapp.SetFocus(table)
|
||||
})
|
||||
}()
|
||||
|
||||
table.SetSelectedFunc(func(row, _ int) {
|
||||
if row < 0 || row >= len(modelIDs) {
|
||||
return
|
||||
}
|
||||
a.cfg.Provider.Current = tuicfg.ProviderCurrent{
|
||||
Scheme: schemeName,
|
||||
User: userName,
|
||||
Model: modelIDs[row],
|
||||
}
|
||||
a.save()
|
||||
|
||||
// Trigger model selected callback if set
|
||||
if a.OnModelSelected != nil && a.cfg.Model.Type == "provider" {
|
||||
scheme := a.cfg.Provider.SchemeByName(schemeName)
|
||||
if scheme == nil {
|
||||
a.goBack()
|
||||
return
|
||||
}
|
||||
var user tuicfg.User
|
||||
for _, u := range a.cfg.Provider.Users {
|
||||
if u.Scheme == schemeName && u.Name == userName {
|
||||
user = u
|
||||
break
|
||||
}
|
||||
}
|
||||
a.OnModelSelected(*scheme, user, modelIDs[row])
|
||||
}
|
||||
|
||||
a.goBack()
|
||||
})
|
||||
|
||||
return a.buildShell("models", flex, " [#39ff14]Enter:[-] select [#ff00ff]ESC:[-] back ")
|
||||
}
|
||||
|
||||
func (a *App) resolveKey(schemeName, userName string) string {
|
||||
for _, u := range a.cfg.Provider.Users {
|
||||
if u.Scheme == schemeName && u.Name == userName {
|
||||
return u.Key
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fetchModels(baseURL, apiKey string) ([]modelEntry, error) {
|
||||
url := strings.TrimRight(baseURL, "/") + "/models"
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
if apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
var result modelsAPIResponse
|
||||
if err := json.Unmarshal(body, &result); err == nil && len(result.Data) > 0 {
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
var arr []modelEntry
|
||||
if err := json.Unmarshal(body, &arr); err == nil {
|
||||
return arr, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"decode response: unrecognized shape: %s",
|
||||
strings.TrimSpace(string(body[:min(len(body), 256)])),
|
||||
)
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||
)
|
||||
|
||||
func (a *App) newSchemesPage() tview.Primitive {
|
||||
table := tview.NewTable().
|
||||
SetBorders(false).
|
||||
SetSelectable(true, false)
|
||||
table.SetBorder(true).
|
||||
SetTitle(" [#00f0ff::b] PROVIDER SCHEMES ").
|
||||
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
table.SetSelectedStyle(
|
||||
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
|
||||
)
|
||||
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
rowToIdx := func(row int) int { return row / 2 }
|
||||
|
||||
selectedSchemeName := func() string {
|
||||
row, _ := table.GetSelection()
|
||||
idx := rowToIdx(row)
|
||||
schemes := a.cfg.Provider.Schemes
|
||||
if idx >= 0 && idx < len(schemes) {
|
||||
return schemes[idx].Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
rebuild := func() {
|
||||
selName := selectedSchemeName()
|
||||
table.Clear()
|
||||
schemes := a.cfg.Provider.Schemes
|
||||
for i, s := range schemes {
|
||||
nameRow := i * 2
|
||||
detailRow := nameRow + 1
|
||||
|
||||
table.SetCell(nameRow, 0,
|
||||
tview.NewTableCell(" "+s.Name).
|
||||
SetTextColor(tcell.NewHexColor(0xe0e0e0)).
|
||||
SetExpansion(1).
|
||||
SetSelectable(true),
|
||||
)
|
||||
|
||||
users := a.cfg.Provider.UsersForScheme(s.Name)
|
||||
n := len(users)
|
||||
m := 0
|
||||
for _, u := range users {
|
||||
if models := a.cachedModels(s.Name, u.Name); len(models) > 0 {
|
||||
m++
|
||||
}
|
||||
}
|
||||
table.SetCell(detailRow, 0,
|
||||
tview.NewTableCell(fmt.Sprintf(" [#808080](%d/%d) %s", m, n, s.BaseURL)).
|
||||
SetTextColor(tcell.NewHexColor(0x808080)).
|
||||
SetExpansion(1).
|
||||
SetSelectable(false),
|
||||
)
|
||||
table.SetCell(detailRow, 1,
|
||||
tview.NewTableCell("[#00f0ff]"+s.Type+" ").
|
||||
SetAlign(tview.AlignRight).
|
||||
SetSelectable(false),
|
||||
)
|
||||
}
|
||||
if selName != "" {
|
||||
for i, s := range schemes {
|
||||
if s.Name == selName {
|
||||
table.Select(i*2, 0)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if table.GetRowCount() > 0 {
|
||||
table.Select(0, 0)
|
||||
}
|
||||
}
|
||||
rebuild()
|
||||
|
||||
a.refreshModelCache(rebuild)
|
||||
a.pageRefreshFns["schemes"] = func() { a.refreshModelCache(rebuild) }
|
||||
|
||||
table.SetSelectedFunc(func(row, _ int) {
|
||||
idx := rowToIdx(row)
|
||||
schemes := a.cfg.Provider.Schemes
|
||||
if idx < 0 || idx >= len(schemes) {
|
||||
return
|
||||
}
|
||||
name := schemes[idx].Name
|
||||
a.navigateTo("users", a.newUsersPage(name))
|
||||
})
|
||||
|
||||
table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
row, _ := table.GetSelection()
|
||||
idx := rowToIdx(row)
|
||||
schemes := a.cfg.Provider.Schemes
|
||||
switch event.Rune() {
|
||||
case 'a':
|
||||
a.showSchemeForm(nil, func(s tuicfg.Scheme) {
|
||||
a.cfg.Provider.Schemes = append(a.cfg.Provider.Schemes, s)
|
||||
a.save()
|
||||
a.refreshModelCache(rebuild)
|
||||
})
|
||||
return nil
|
||||
case 'e':
|
||||
if idx < 0 || idx >= len(schemes) {
|
||||
return nil
|
||||
}
|
||||
origName := schemes[idx].Name
|
||||
orig := schemes[idx]
|
||||
a.showSchemeForm(&orig, func(s tuicfg.Scheme) {
|
||||
current := a.cfg.Provider.Schemes
|
||||
for i, sc := range current {
|
||||
if sc.Name == origName {
|
||||
a.cfg.Provider.Schemes[i] = s
|
||||
break
|
||||
}
|
||||
}
|
||||
a.save()
|
||||
a.refreshModelCache(func() {
|
||||
rebuild()
|
||||
for i, sc := range a.cfg.Provider.Schemes {
|
||||
if sc.Name == s.Name {
|
||||
table.Select(i*2, 0)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
return nil
|
||||
case 'd':
|
||||
if idx < 0 || idx >= len(schemes) {
|
||||
return nil
|
||||
}
|
||||
name := schemes[idx].Name
|
||||
a.confirmDelete(fmt.Sprintf("scheme %q", name), func() {
|
||||
current := a.cfg.Provider.Schemes
|
||||
newSchemes := make([]tuicfg.Scheme, 0, len(current))
|
||||
for _, sc := range current {
|
||||
if sc.Name != name {
|
||||
newSchemes = append(newSchemes, sc)
|
||||
}
|
||||
}
|
||||
a.cfg.Provider.Schemes = newSchemes
|
||||
|
||||
existing := a.cfg.Provider.Users
|
||||
filtered := make([]tuicfg.User, 0, len(existing))
|
||||
for _, u := range existing {
|
||||
if u.Scheme != name {
|
||||
filtered = append(filtered, u)
|
||||
}
|
||||
}
|
||||
a.cfg.Provider.Users = filtered
|
||||
|
||||
a.save()
|
||||
a.refreshModelCache(rebuild)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
return a.buildShell(
|
||||
"schemes",
|
||||
table,
|
||||
" [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] open [#ff00ff]ESC:[-] back ",
|
||||
)
|
||||
}
|
||||
|
||||
func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) {
|
||||
name := ""
|
||||
baseURL := ""
|
||||
schemeType := "openai-compatible"
|
||||
title := " ADD SCHEME "
|
||||
|
||||
if existing != nil {
|
||||
name = existing.Name
|
||||
baseURL = existing.BaseURL
|
||||
schemeType = existing.Type
|
||||
title = " EDIT SCHEME "
|
||||
}
|
||||
|
||||
typeOptions := []string{"openai-compatible", "anthropic"}
|
||||
typeIdx := 0
|
||||
for i, t := range typeOptions {
|
||||
if t == schemeType {
|
||||
typeIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
form := tview.NewForm()
|
||||
|
||||
form.
|
||||
AddInputField("Name", name, 20, nil, func(text string) { name = text }).
|
||||
AddInputField("Base URL", baseURL, 28, nil, func(text string) { baseURL = text }).
|
||||
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }).
|
||||
AddButton("SAVE", func() {
|
||||
if name == "" {
|
||||
a.showError("Name is required")
|
||||
return
|
||||
}
|
||||
if baseURL == "" {
|
||||
a.showError("Base URL is required")
|
||||
return
|
||||
}
|
||||
if existing == nil {
|
||||
for _, s := range a.cfg.Provider.Schemes {
|
||||
if s.Name == name {
|
||||
a.showError(fmt.Sprintf("Scheme name %q already exists", name))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
a.hideModal("scheme-form")
|
||||
onSave(tuicfg.Scheme{Name: name, BaseURL: baseURL, Type: schemeType})
|
||||
}).
|
||||
AddButton("CANCEL", func() {
|
||||
a.hideModal("scheme-form")
|
||||
})
|
||||
|
||||
form.SetBorder(true).
|
||||
SetTitle(" [::b]" + title + " ").
|
||||
SetTitleColor(tcell.NewHexColor(0x39ff14)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
|
||||
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
|
||||
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
|
||||
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
|
||||
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
a.hideModal("scheme-form")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
a.showModal("scheme-form", centeredForm(form, 4, 12))
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
|
||||
)
|
||||
|
||||
func (a *App) newUsersPage(schemeName string) tview.Primitive {
|
||||
table := tview.NewTable().
|
||||
SetBorders(false).
|
||||
SetSelectable(true, false)
|
||||
table.SetBorder(true).
|
||||
SetTitle(fmt.Sprintf(" [#00f0ff::b] USERS · %s ", schemeName)).
|
||||
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
table.SetSelectedStyle(
|
||||
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
|
||||
)
|
||||
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
|
||||
visibleUsers := func() []tuicfg.User {
|
||||
var out []tuicfg.User
|
||||
for _, u := range a.cfg.Provider.Users {
|
||||
if u.Scheme == schemeName {
|
||||
out = append(out, u)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
findUserGlobalIdx := func(userName string) int {
|
||||
for i, u := range a.cfg.Provider.Users {
|
||||
if u.Scheme == schemeName && u.Name == userName {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
rowToVisIdx := func(row int) int { return row / 2 }
|
||||
|
||||
selectedUserName := func() string {
|
||||
row, _ := table.GetSelection()
|
||||
users := visibleUsers()
|
||||
visIdx := rowToVisIdx(row)
|
||||
if visIdx >= 0 && visIdx < len(users) {
|
||||
return users[visIdx].Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
rebuild := func() {
|
||||
selName := selectedUserName()
|
||||
table.Clear()
|
||||
users := visibleUsers()
|
||||
for i, u := range users {
|
||||
nameRow := i * 2
|
||||
detailRow := nameRow + 1
|
||||
|
||||
table.SetCell(nameRow, 0,
|
||||
tview.NewTableCell(" "+u.Name).
|
||||
SetTextColor(tcell.NewHexColor(0xe0e0e0)).
|
||||
SetExpansion(1).
|
||||
SetSelectable(true),
|
||||
)
|
||||
table.SetCell(nameRow, 1,
|
||||
tview.NewTableCell("").
|
||||
SetSelectable(false),
|
||||
)
|
||||
|
||||
models := a.cachedModels(schemeName, u.Name)
|
||||
var detailText string
|
||||
if len(models) > 0 {
|
||||
detailText = fmt.Sprintf(" [#39ff14]%d models available[-]", len(models))
|
||||
} else {
|
||||
detailText = " [#ff2a2a]Inactive / No Access[-]"
|
||||
}
|
||||
table.SetCell(detailRow, 0,
|
||||
tview.NewTableCell(detailText).
|
||||
SetTextColor(tcell.NewHexColor(0x808080)).
|
||||
SetExpansion(1).
|
||||
SetSelectable(false),
|
||||
)
|
||||
table.SetCell(detailRow, 1,
|
||||
tview.NewTableCell("[#00f0ff]"+u.Type+" ").
|
||||
SetAlign(tview.AlignRight).
|
||||
SetSelectable(false),
|
||||
)
|
||||
}
|
||||
if selName != "" {
|
||||
for i, u := range users {
|
||||
if u.Name == selName {
|
||||
table.Select(i*2, 0)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if table.GetRowCount() > 0 {
|
||||
table.Select(0, 0)
|
||||
}
|
||||
}
|
||||
rebuild()
|
||||
|
||||
a.refreshModelCache(rebuild)
|
||||
a.pageRefreshFns["users"] = func() { a.refreshModelCache(rebuild) }
|
||||
|
||||
table.SetSelectedFunc(func(row, _ int) {
|
||||
visIdx := rowToVisIdx(row)
|
||||
users := visibleUsers()
|
||||
if visIdx < 0 || visIdx >= len(users) {
|
||||
return
|
||||
}
|
||||
uName := users[visIdx].Name
|
||||
scheme := a.cfg.Provider.SchemeByName(schemeName)
|
||||
if scheme == nil {
|
||||
a.showError(fmt.Sprintf("Scheme %q not found", schemeName))
|
||||
return
|
||||
}
|
||||
a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL))
|
||||
})
|
||||
|
||||
table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
row, _ := table.GetSelection()
|
||||
visIdx := rowToVisIdx(row)
|
||||
users := visibleUsers()
|
||||
switch event.Rune() {
|
||||
case 'a':
|
||||
a.showUserForm(schemeName, nil, func(u tuicfg.User) {
|
||||
a.cfg.Provider.Users = append(a.cfg.Provider.Users, u)
|
||||
a.save()
|
||||
a.refreshModelCache(rebuild)
|
||||
})
|
||||
return nil
|
||||
case 'e':
|
||||
if visIdx < 0 || visIdx >= len(users) {
|
||||
return nil
|
||||
}
|
||||
origName := users[visIdx].Name
|
||||
orig := a.cfg.Provider.Users[findUserGlobalIdx(origName)]
|
||||
a.showUserForm(schemeName, &orig, func(u tuicfg.User) {
|
||||
cfgIdx := findUserGlobalIdx(origName)
|
||||
if cfgIdx < 0 {
|
||||
a.showError(fmt.Sprintf("User %q no longer exists", origName))
|
||||
return
|
||||
}
|
||||
a.cfg.Provider.Users[cfgIdx] = u
|
||||
a.save()
|
||||
a.refreshModelCache(func() {
|
||||
rebuild()
|
||||
for i, usr := range visibleUsers() {
|
||||
if usr.Name == u.Name {
|
||||
table.Select(i*2, 0)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
return nil
|
||||
case 'd':
|
||||
if visIdx < 0 || visIdx >= len(users) {
|
||||
return nil
|
||||
}
|
||||
uName := users[visIdx].Name
|
||||
a.confirmDelete(fmt.Sprintf("user %q", uName), func() {
|
||||
cfgIdx := findUserGlobalIdx(uName)
|
||||
if cfgIdx < 0 {
|
||||
return
|
||||
}
|
||||
all := a.cfg.Provider.Users
|
||||
a.cfg.Provider.Users = append(all[:cfgIdx], all[cfgIdx+1:]...)
|
||||
a.save()
|
||||
a.refreshModelCache(rebuild)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
return a.buildShell(
|
||||
"users",
|
||||
table,
|
||||
" [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] models [#ff00ff]ESC:[-] back ",
|
||||
)
|
||||
}
|
||||
|
||||
func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) {
|
||||
name := ""
|
||||
userType := "key"
|
||||
key := ""
|
||||
title := " ADD USER "
|
||||
|
||||
if existing != nil {
|
||||
name = existing.Name
|
||||
userType = existing.Type
|
||||
key = existing.Key
|
||||
title = " EDIT USER "
|
||||
}
|
||||
|
||||
typeOptions := []string{"key", "OAuth"}
|
||||
typeIdx := 0
|
||||
for i, t := range typeOptions {
|
||||
if t == userType {
|
||||
typeIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
form := tview.NewForm()
|
||||
form.
|
||||
AddInputField("Name", name, 20, nil, func(text string) { name = text }).
|
||||
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }).
|
||||
AddPasswordField("Key", key, 28, '*', func(text string) { key = text }).
|
||||
AddButton("SAVE", func() {
|
||||
if name == "" {
|
||||
a.showError("Name is required")
|
||||
return
|
||||
}
|
||||
if existing == nil {
|
||||
for _, u := range a.cfg.Provider.Users {
|
||||
if u.Scheme == schemeName && u.Name == name {
|
||||
a.showError(fmt.Sprintf("User name %q already exists for this scheme", name))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
a.hideModal("user-form")
|
||||
onSave(tuicfg.User{Name: name, Scheme: schemeName, Type: userType, Key: key})
|
||||
}).
|
||||
AddButton("CANCEL", func() {
|
||||
a.hideModal("user-form")
|
||||
})
|
||||
|
||||
form.SetBorder(true).
|
||||
SetTitle(" [::b]" + title + " ").
|
||||
SetTitleColor(tcell.NewHexColor(0x39ff14)).
|
||||
SetBorderColor(tcell.NewHexColor(0x00f0ff))
|
||||
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
|
||||
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
|
||||
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
|
||||
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
|
||||
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
|
||||
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
a.hideModal("user-form")
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
a.showModal("user-form", centeredForm(form, 4, 13))
|
||||
}
|
||||
@@ -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,163 +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)
|
||||
}
|
||||
|
||||
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,505 +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"
|
||||
defaultAnthropicModel = "claude-sonnet-4.6"
|
||||
)
|
||||
|
||||
func authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error {
|
||||
switch provider {
|
||||
case "openai":
|
||||
return authLoginOpenAI(useDeviceCode)
|
||||
case "anthropic":
|
||||
return authLoginAnthropic(useOauth)
|
||||
case "google-antigravity", "antigravity":
|
||||
return authLoginGoogleAntigravity()
|
||||
default:
|
||||
return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func authLoginOpenAI(useDeviceCode bool) error {
|
||||
cfg := auth.OpenAIOAuthConfig()
|
||||
|
||||
var cred *auth.AuthCredential
|
||||
var err error
|
||||
|
||||
if useDeviceCode {
|
||||
cred, err = auth.LoginDeviceCode(cfg)
|
||||
} else {
|
||||
cred, err = auth.LoginBrowser(cfg)
|
||||
}
|
||||
|
||||
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].Model) {
|
||||
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() error {
|
||||
cfg := auth.GoogleAntigravityOAuthConfig()
|
||||
|
||||
cred, err := auth.LoginBrowser(cfg)
|
||||
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].Model) {
|
||||
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].Model) {
|
||||
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].Model) {
|
||||
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].Model) {
|
||||
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].Model) {
|
||||
appCfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
case "anthropic":
|
||||
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
||||
appCfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
case "google-antigravity", "antigravity":
|
||||
if isAntigravityModel(appCfg.ModelList[i].Model) {
|
||||
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 string belongs to antigravity provider
|
||||
func isAntigravityModel(model string) bool {
|
||||
return model == "antigravity" ||
|
||||
model == "google-antigravity" ||
|
||||
strings.HasPrefix(model, "antigravity/") ||
|
||||
strings.HasPrefix(model, "google-antigravity/")
|
||||
}
|
||||
|
||||
// isOpenAIModel checks if a model string belongs to openai provider
|
||||
func isOpenAIModel(model string) bool {
|
||||
return model == "openai" ||
|
||||
strings.HasPrefix(model, "openai/")
|
||||
}
|
||||
|
||||
// isAnthropicModel checks if a model string belongs to anthropic provider
|
||||
func isAnthropicModel(model string) bool {
|
||||
return model == "anthropic" ||
|
||||
strings.HasPrefix(model, "anthropic/")
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package auth
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newLoginCommand() *cobra.Command {
|
||||
var (
|
||||
provider string
|
||||
useDeviceCode bool
|
||||
useOauth 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)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)")
|
||||
cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)")
|
||||
cmd.Flags().BoolVar(
|
||||
&useOauth, "setup-token", false,
|
||||
"Use setup-token flow for Anthropic (from `claude setup-token`)",
|
||||
)
|
||||
_ = cmd.MarkFlagRequired("provider")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,29 +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"))
|
||||
|
||||
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,18 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -1,407 +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"
|
||||
)
|
||||
|
||||
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) {
|
||||
cfg.Channels.WeCom.Enabled = true
|
||||
cfg.Channels.WeCom.BotID = botInfo.BotID
|
||||
cfg.Channels.WeCom.SetSecret(botInfo.Secret)
|
||||
if strings.TrimSpace(cfg.Channels.WeCom.WebSocketURL) == "" {
|
||||
cfg.Channels.WeCom.WebSocketURL = wecomDefaultWebSocketURL
|
||||
}
|
||||
}
|
||||
|
||||
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,157 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"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 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 := httptest.NewServer(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"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
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 := httptest.NewServer(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"}}}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
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()
|
||||
cfg.Channels.WeCom.WebSocketURL = ""
|
||||
|
||||
applyWeComAuthResult(cfg, wecomQRBotInfo{
|
||||
BotID: "bot-1",
|
||||
Secret: "secret-1",
|
||||
})
|
||||
|
||||
assert.True(t, cfg.Channels.WeCom.Enabled)
|
||||
assert.Equal(t, "bot-1", cfg.Channels.WeCom.BotID)
|
||||
assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret())
|
||||
assert.Equal(t, wecomDefaultWebSocketURL, cfg.Channels.WeCom.WebSocketURL)
|
||||
}
|
||||
|
||||
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)
|
||||
assert.True(t, cfg.Channels.WeCom.Enabled)
|
||||
assert.Equal(t, "bot-1", cfg.Channels.WeCom.BotID)
|
||||
assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret())
|
||||
assert.Equal(t, wecomDefaultWebSocketURL, cfg.Channels.WeCom.WebSocketURL)
|
||||
assert.Contains(t, output.String(), "WeCom connected.")
|
||||
}
|
||||
@@ -1,124 +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)
|
||||
}
|
||||
|
||||
cfg.Channels.Weixin.Enabled = true
|
||||
cfg.Channels.Weixin.SetToken(token)
|
||||
const defaultBase = "https://ilinkai.weixin.qq.com/"
|
||||
if baseURL != "" && baseURL != defaultBase {
|
||||
cfg.Channels.Weixin.BaseURL = baseURL
|
||||
}
|
||||
if proxy != "" {
|
||||
cfg.Channels.Weixin.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,64 +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
|
||||
deliver bool
|
||||
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, deliver, 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().BoolVarP(&deliver, "deliver", "d", false, "Deliver response to channel")
|
||||
cmd.Flags().StringVar(&to, "to", "", "Recipient for delivery")
|
||||
cmd.Flags().StringVar(&channel, "channel", "", "Channel for delivery")
|
||||
|
||||
_ = cmd.MarkFlagRequired("name")
|
||||
_ = cmd.MarkFlagRequired("message")
|
||||
cmd.MarkFlagsMutuallyExclusive("every", "cron")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,57 +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("deliver"))
|
||||
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,52 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/gateway"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
func NewGatewayCommand() *cobra.Command {
|
||||
var debug bool
|
||||
var noTruncate bool
|
||||
var allowEmpty bool
|
||||
|
||||
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(_ *cobra.Command, _ []string) error {
|
||||
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",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,32 +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"))
|
||||
}
|
||||
@@ -1,56 +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 {
|
||||
if home := os.Getenv(config.EnvHome); home != "" {
|
||||
return home
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, pkg.DefaultPicoClawHome)
|
||||
}
|
||||
|
||||
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,52 +0,0 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/migrate"
|
||||
)
|
||||
|
||||
func NewMigrateCommand() *cobra.Command {
|
||||
var opts migrate.Options
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "migrate",
|
||||
Short: "Migrate from xxxclaw(openclaw, etc.) to picoclaw",
|
||||
Args: cobra.NoArgs,
|
||||
Example: ` picoclaw migrate
|
||||
picoclaw migrate --from openclaw
|
||||
picoclaw migrate --dry-run
|
||||
picoclaw migrate --refresh
|
||||
picoclaw migrate --force`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
m := migrate.NewMigrateInstance(opts)
|
||||
result, err := m.Run(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !opts.DryRun {
|
||||
m.PrintSummary(result)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false,
|
||||
"Show what would be migrated without making changes")
|
||||
cmd.Flags().StringVar(&opts.Source, "from", "openclaw",
|
||||
"Source to migrate from (e.g., openclaw)")
|
||||
cmd.Flags().BoolVar(&opts.Refresh, "refresh", false,
|
||||
"Re-sync workspace files from OpenClaw (repeatable)")
|
||||
cmd.Flags().BoolVar(&opts.ConfigOnly, "config-only", false,
|
||||
"Only migrate config, skip workspace files")
|
||||
cmd.Flags().BoolVar(&opts.WorkspaceOnly, "workspace-only", false,
|
||||
"Only migrate workspace files, skip config")
|
||||
cmd.Flags().BoolVar(&opts.Force, "force", false,
|
||||
"Skip confirmation prompts")
|
||||
cmd.Flags().StringVar(&opts.SourceHome, "source-home", "",
|
||||
"Override source home directory (default: ~/.openclaw)")
|
||||
cmd.Flags().StringVar(&opts.TargetHome, "target-home", "",
|
||||
"Override target home directory (default: ~/.picoclaw)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewMigrateCommand(t *testing.T) {
|
||||
cmd := NewMigrateCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "migrate", cmd.Use)
|
||||
assert.Equal(t, "Migrate from xxxclaw(openclaw, etc.) to picoclaw", cmd.Short)
|
||||
|
||||
assert.Len(t, cmd.Aliases, 0)
|
||||
|
||||
assert.True(t, cmd.HasExample())
|
||||
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("dry-run"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("refresh"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("config-only"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("workspace-only"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("force"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("source-home"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("target-home"))
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// LocalModel is a special model name that indicates that the model is local and with or without api_key.
|
||||
const LocalModel = "local-model"
|
||||
|
||||
func NewModelCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "model [model_name]",
|
||||
Short: "Show or change the default model",
|
||||
Long: `Show or change the default model configuration.
|
||||
|
||||
If no argument is provided, shows the current default model.
|
||||
If a model name is provided, sets it as the default model.
|
||||
|
||||
Examples:
|
||||
picoclaw model # Show current default model
|
||||
picoclaw model gpt-5.2 # Set gpt-5.2 as default
|
||||
picoclaw model claude-sonnet-4.6 # Set claude-sonnet-4.6 as default
|
||||
picoclaw model local-model # Set local VLLM server as default
|
||||
|
||||
Note: 'local-model' is a special value for using a local VLLM server
|
||||
(running at localhost:8000 by default) which does not require an API key.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
configPath := internal.GetConfigPath()
|
||||
|
||||
// Load current config
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
// Show current default model
|
||||
showCurrentModel(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set new default model
|
||||
modelName := args[0]
|
||||
return setDefaultModel(configPath, cfg, modelName)
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func showCurrentModel(cfg *config.Config) {
|
||||
defaultModel := cfg.Agents.Defaults.ModelName
|
||||
|
||||
if defaultModel == "" {
|
||||
fmt.Println("No default model is currently set.")
|
||||
fmt.Println("\nAvailable models in your config:")
|
||||
listAvailableModels(cfg)
|
||||
} else {
|
||||
fmt.Printf("Current default model: %s\n", defaultModel)
|
||||
fmt.Println("\nAvailable models in your config:")
|
||||
listAvailableModels(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func listAvailableModels(cfg *config.Config) {
|
||||
if len(cfg.ModelList) == 0 {
|
||||
fmt.Println(" No models configured in model_list")
|
||||
return
|
||||
}
|
||||
|
||||
defaultModel := cfg.Agents.Defaults.ModelName
|
||||
|
||||
for _, model := range cfg.ModelList {
|
||||
marker := " "
|
||||
if model.ModelName == defaultModel {
|
||||
marker = "> "
|
||||
}
|
||||
if model.APIKey() == "" {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%s- %s (%s)\n", marker, model.ModelName, model.Model)
|
||||
}
|
||||
}
|
||||
|
||||
func setDefaultModel(configPath string, cfg *config.Config, modelName string) error {
|
||||
// Validate that the model exists in model_list
|
||||
modelFound := false
|
||||
for _, model := range cfg.ModelList {
|
||||
if model.APIKey() != "" && model.ModelName == modelName {
|
||||
modelFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !modelFound && modelName != LocalModel {
|
||||
return fmt.Errorf("cannot found model '%s' in config", modelName)
|
||||
}
|
||||
|
||||
// Update the default model
|
||||
// Clear old model field and set new model_name
|
||||
oldModel := cfg.Agents.Defaults.ModelName
|
||||
|
||||
cfg.Agents.Defaults.ModelName = modelName
|
||||
|
||||
// Save config back to file
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Default model changed from '%s' to '%s'\n",
|
||||
formatModelName(oldModel), modelName)
|
||||
fmt.Println("\nThe new default model will be used for all agent interactions.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatModelName(name string) string {
|
||||
if name == "" {
|
||||
return "(none)"
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -1,390 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
var configPath = ""
|
||||
|
||||
func initTest(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath = filepath.Join(tmpDir, "config.json")
|
||||
_ = os.Setenv("PICOCLAW_CONFIG", configPath)
|
||||
}
|
||||
|
||||
// captureStdout captures stdout during the execution of fn and returns the captured output
|
||||
func captureStdout(fn func()) string {
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
fn()
|
||||
|
||||
w.Close()
|
||||
os.Stdout = oldStdout
|
||||
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestNewModelCommand(t *testing.T) {
|
||||
cmd := NewModelCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "model [model_name]", cmd.Use)
|
||||
assert.Equal(t, "Show or change the default model", cmd.Short)
|
||||
|
||||
assert.Len(t, cmd.Aliases, 0)
|
||||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
|
||||
assert.Nil(t, cmd.PersistentPreRunE)
|
||||
assert.Nil(t, cmd.PersistentPreRun)
|
||||
assert.Nil(t, cmd.PersistentPostRun)
|
||||
}
|
||||
|
||||
func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "gpt-4",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4"},
|
||||
{ModelName: "claude-3", Model: "anthropic/claude-3"},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"gpt-4": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"claude-3": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
|
||||
output := captureStdout(func() {
|
||||
showCurrentModel(cfg)
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "Current default model: gpt-4")
|
||||
assert.Contains(t, output, "Available models in your config:")
|
||||
assert.Contains(t, output, "gpt-4")
|
||||
assert.Contains(t, output, "claude-3")
|
||||
}
|
||||
|
||||
func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4"},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"gpt-4": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
|
||||
output := captureStdout(func() {
|
||||
showCurrentModel(cfg)
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "No default model is currently set.")
|
||||
assert.Contains(t, output, "Available models in your config:")
|
||||
}
|
||||
|
||||
func TestListAvailableModels_Empty(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ModelList: []*config.ModelConfig{},
|
||||
}
|
||||
|
||||
output := captureStdout(func() {
|
||||
listAvailableModels(cfg)
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "No models configured in model_list")
|
||||
}
|
||||
|
||||
func TestListAvailableModels_WithModels(t *testing.T) {
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "gpt-4",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4"},
|
||||
{ModelName: "claude-3", Model: "anthropic/claude-3"},
|
||||
{ModelName: "no-key-model", Model: "openai/test"},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"gpt-4": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"claude-3": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
|
||||
output := captureStdout(func() {
|
||||
listAvailableModels(cfg)
|
||||
})
|
||||
|
||||
assert.NotEmpty(t, output)
|
||||
assert.Contains(t, output, "> - gpt-4 (openai/gpt-4)")
|
||||
assert.Contains(t, output, "claude-3 (anthropic/claude-3)")
|
||||
assert.NotContains(t, output, "no-key-model")
|
||||
}
|
||||
|
||||
func TestSetDefaultModel_ValidModel(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "old-model",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "new-model", Model: "openai/new-model"},
|
||||
{ModelName: "old-model", Model: "openai/old-model"},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"new-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"old-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
|
||||
output := captureStdout(func() {
|
||||
err := setDefaultModel(configPath, cfg, "new-model")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "Default model changed from 'old-model' to 'new-model'")
|
||||
|
||||
// Verify config was updated
|
||||
updatedCfg, err := config.LoadConfig(configPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "new-model", updatedCfg.Agents.Defaults.ModelName)
|
||||
}
|
||||
|
||||
func TestSetDefaultModel_InvalidModel(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "existing-model",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "existing-model", Model: "openai/existing"},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"existing-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
|
||||
assert.Error(t, setDefaultModel(configPath, cfg, "nonexistent-model"))
|
||||
}
|
||||
|
||||
func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "existing-model",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "existing-model", Model: "openai/existing"},
|
||||
{ModelName: "no-key-model", Model: "openai/nokey"},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"existing-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"no-key-model": {
|
||||
APIKeys: []string{""},
|
||||
},
|
||||
}})
|
||||
|
||||
assert.Error(t, setDefaultModel(configPath, cfg, "no-key-model"))
|
||||
}
|
||||
|
||||
func TestSetDefaultModel_SaveConfigError(t *testing.T) {
|
||||
// Use an invalid path to trigger save error
|
||||
invalidPath := "/nonexistent/directory/config.json"
|
||||
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "old-model",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "new-model", Model: "openai/new-model"},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"new-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
|
||||
err := setDefaultModel(invalidPath, cfg, "new-model")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to save config")
|
||||
}
|
||||
|
||||
func TestFormatModelName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"empty string", "", "(none)"},
|
||||
{"simple model", "gpt-4", "gpt-4"},
|
||||
{"model with version", "claude-sonnet-4.6", "claude-sonnet-4.6"},
|
||||
{"model with spaces", "my model", "my model"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := formatModelName(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelCommandExecution_Show(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
// Create a test config
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "test-model",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "test-model", Model: "openai/test"},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"test-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
|
||||
err := config.SaveConfig(configPath, cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
cmd := NewModelCommand()
|
||||
|
||||
output := captureStdout(func() {
|
||||
err = cmd.RunE(cmd, []string{})
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "Current default model: test-model")
|
||||
}
|
||||
|
||||
func TestModelCommandExecution_Set(t *testing.T) {
|
||||
initTest(t)
|
||||
|
||||
sec := &config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"old-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"new-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}}
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "old-model",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "old-model", Model: "openai/old"},
|
||||
{ModelName: "new-model", Model: "openai/new"},
|
||||
},
|
||||
}).WithSecurity(sec)
|
||||
|
||||
err := config.SaveConfig(configPath, cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
cmd := NewModelCommand()
|
||||
|
||||
output := captureStdout(func() {
|
||||
err = cmd.RunE(cmd, []string{"new-model"})
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "Default model changed from 'old-model' to 'new-model'")
|
||||
}
|
||||
|
||||
func TestModelCommandExecution_TooManyArgs(t *testing.T) {
|
||||
cmd := NewModelCommand()
|
||||
|
||||
err := cmd.RunE(cmd, []string{"model1", "model2"})
|
||||
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestListAvailableModels_MarkerLogic(t *testing.T) {
|
||||
cfg := (&config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
ModelName: "middle-model",
|
||||
},
|
||||
},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{ModelName: "first-model", Model: "openai/first"},
|
||||
{ModelName: "middle-model", Model: "openai/middle"},
|
||||
{ModelName: "last-model", Model: "openai/last"},
|
||||
},
|
||||
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
||||
"first-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"middle-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
"last-model": {
|
||||
APIKeys: []string{"test"},
|
||||
},
|
||||
}})
|
||||
|
||||
output := captureStdout(func() {
|
||||
listAvailableModels(cfg)
|
||||
})
|
||||
|
||||
assert.Contains(t, output, " - first-model (openai/first)")
|
||||
assert.Contains(t, output, "> - middle-model (openai/middle)")
|
||||
assert.Contains(t, output, " - last-model (openai/last)")
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package onboard
|
||||
|
||||
import (
|
||||
"embed"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
//go:generate cp -r ../../../../workspace .
|
||||
//go:embed workspace
|
||||
var embeddedFiles embed.FS
|
||||
|
||||
func NewOnboardCommand() *cobra.Command {
|
||||
var encrypt bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "onboard",
|
||||
Aliases: []string{"o"},
|
||||
Short: "Initialize picoclaw configuration and workspace",
|
||||
// Run without subcommands → original onboard flow
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if len(args) == 0 {
|
||||
onboard(encrypt)
|
||||
} else {
|
||||
_ = cmd.Help()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&encrypt, "enc", false,
|
||||
"Enable credential encryption (generates SSH key and prompts for passphrase)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package onboard
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewOnboardCommand(t *testing.T) {
|
||||
cmd := NewOnboardCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "onboard", cmd.Use)
|
||||
assert.Equal(t, "Initialize picoclaw configuration and workspace", cmd.Short)
|
||||
|
||||
assert.Len(t, cmd.Aliases, 1)
|
||||
assert.True(t, cmd.HasAlias("o"))
|
||||
|
||||
assert.NotNil(t, cmd.Run)
|
||||
assert.Nil(t, cmd.RunE)
|
||||
|
||||
assert.Nil(t, cmd.PersistentPreRun)
|
||||
assert.Nil(t, cmd.PersistentPostRun)
|
||||
|
||||
assert.True(t, cmd.HasFlags())
|
||||
encFlag := cmd.Flags().Lookup("enc")
|
||||
require.NotNil(t, encFlag, "expected --enc flag to be registered")
|
||||
assert.Equal(t, "false", encFlag.DefValue, "--enc should default to false")
|
||||
assert.False(t, cmd.HasSubCommands())
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
package onboard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/term"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/credential"
|
||||
)
|
||||
|
||||
func onboard(encrypt bool) {
|
||||
configPath := internal.GetConfigPath()
|
||||
|
||||
configExists := false
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
configExists = true
|
||||
if encrypt {
|
||||
// Only ask for confirmation when *both* config and SSH key already exist,
|
||||
// indicating a full re-onboard that would reset the config to defaults.
|
||||
sshKeyPath, _ := credential.DefaultSSHKeyPath()
|
||||
if _, err := os.Stat(sshKeyPath); err == nil {
|
||||
// Both exist — confirm a full reset.
|
||||
fmt.Printf("Config already exists at %s\n", configPath)
|
||||
fmt.Print("Overwrite config with defaults? (y/n): ")
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
if response != "y" {
|
||||
fmt.Println("Aborted.")
|
||||
return
|
||||
}
|
||||
configExists = false // user agreed to reset; treat as fresh
|
||||
}
|
||||
// Config exists but SSH key is missing — keep existing config, only add SSH key.
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
if encrypt {
|
||||
fmt.Println("\nSet up credential encryption")
|
||||
fmt.Println("-----------------------------")
|
||||
passphrase, pErr := promptPassphrase()
|
||||
if pErr != nil {
|
||||
fmt.Printf("Error: %v\n", pErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Expose the passphrase to credential.PassphraseProvider (which calls
|
||||
// os.Getenv by default) so that SaveConfig can encrypt api_keys.
|
||||
// This process is a one-shot CLI tool; the env var is never exposed outside
|
||||
// the current process and disappears when it exits.
|
||||
os.Setenv(credential.PassphraseEnvVar, passphrase)
|
||||
|
||||
if err = setupSSHKey(); err != nil {
|
||||
fmt.Printf("Error generating SSH key: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var cfg *config.Config
|
||||
if configExists {
|
||||
// Preserve the existing config; SaveConfig will re-encrypt api_keys with the new passphrase.
|
||||
cfg, err = config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading existing config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
cfg = config.DefaultConfig()
|
||||
}
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
fmt.Printf("Error saving config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
workspace := cfg.WorkspacePath()
|
||||
createWorkspaceTemplates(workspace)
|
||||
|
||||
fmt.Printf("\n%s picoclaw is ready!\n", internal.Logo)
|
||||
fmt.Println("\nNext steps:")
|
||||
if encrypt {
|
||||
fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:")
|
||||
fmt.Println(" export PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Linux/macOS")
|
||||
fmt.Println(" set PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Windows cmd")
|
||||
fmt.Println("")
|
||||
fmt.Println(" 2. Add your API key to", configPath)
|
||||
} else {
|
||||
fmt.Println(" 1. Add your API key to", configPath)
|
||||
}
|
||||
fmt.Println("")
|
||||
fmt.Println(" Recommended:")
|
||||
fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
|
||||
fmt.Println(" - Ollama: https://ollama.com (local, free)")
|
||||
fmt.Println("")
|
||||
fmt.Println(" See README.md for 17+ supported providers.")
|
||||
fmt.Println("")
|
||||
fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"")
|
||||
}
|
||||
|
||||
// promptPassphrase reads the encryption passphrase twice from the terminal
|
||||
// (with echo disabled) and returns it. Returns an error if the passphrase is
|
||||
// empty or if the two inputs do not match.
|
||||
func promptPassphrase() (string, error) {
|
||||
fmt.Print("Enter passphrase for credential encryption: ")
|
||||
p1, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Println()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading passphrase: %w", err)
|
||||
}
|
||||
if len(p1) == 0 {
|
||||
return "", fmt.Errorf("passphrase must not be empty")
|
||||
}
|
||||
|
||||
fmt.Print("Confirm passphrase: ")
|
||||
p2, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Println()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading passphrase confirmation: %w", err)
|
||||
}
|
||||
|
||||
if string(p1) != string(p2) {
|
||||
return "", fmt.Errorf("passphrases do not match")
|
||||
}
|
||||
return string(p1), nil
|
||||
}
|
||||
|
||||
// setupSSHKey generates the picoclaw-specific SSH key at ~/.ssh/picoclaw_ed25519.key.
|
||||
// If the key already exists the user is warned and asked to confirm overwrite.
|
||||
// Answering anything other than "y" keeps the existing key (not an error).
|
||||
func setupSSHKey() error {
|
||||
keyPath, err := credential.DefaultSSHKeyPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot determine SSH key path: %w", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(keyPath); err == nil {
|
||||
fmt.Printf("\n⚠️ WARNING: %s already exists.\n", keyPath)
|
||||
fmt.Println(" Overwriting will invalidate any credentials previously encrypted with this key.")
|
||||
fmt.Print(" Overwrite? (y/n): ")
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
if response != "y" {
|
||||
fmt.Println("Keeping existing SSH key.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := credential.GenerateSSHKey(keyPath); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("SSH key generated: %s\n", keyPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func createWorkspaceTemplates(workspace string) {
|
||||
err := copyEmbeddedToTarget(workspace)
|
||||
if err != nil {
|
||||
fmt.Printf("Error copying workspace templates: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func copyEmbeddedToTarget(targetDir string) error {
|
||||
// Ensure target directory exists
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
return fmt.Errorf("Failed to create target directory: %w", err)
|
||||
}
|
||||
|
||||
// Walk through all files in embed.FS
|
||||
err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip directories
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read embedded file
|
||||
data, err := embeddedFiles.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to read embedded file %s: %w", path, err)
|
||||
}
|
||||
|
||||
new_path, err := filepath.Rel("workspace", path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
|
||||
}
|
||||
|
||||
// Build target file path
|
||||
targetPath := filepath.Join(targetDir, new_path)
|
||||
|
||||
// Ensure target file's directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err)
|
||||
}
|
||||
|
||||
// Write file
|
||||
if err := os.WriteFile(targetPath, data, 0o644); err != nil {
|
||||
return fmt.Errorf("Failed to write file %s: %w", targetPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package onboard
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCopyEmbeddedToTargetUsesStructuredAgentFiles(t *testing.T) {
|
||||
targetDir := t.TempDir()
|
||||
|
||||
if err := copyEmbeddedToTarget(targetDir); err != nil {
|
||||
t.Fatalf("copyEmbeddedToTarget() error = %v", err)
|
||||
}
|
||||
|
||||
agentPath := filepath.Join(targetDir, "AGENT.md")
|
||||
if _, err := os.Stat(agentPath); err != nil {
|
||||
t.Fatalf("expected %s to exist: %v", agentPath, err)
|
||||
}
|
||||
|
||||
soulPath := filepath.Join(targetDir, "SOUL.md")
|
||||
if _, err := os.Stat(soulPath); err != nil {
|
||||
t.Fatalf("expected %s to exist: %v", soulPath, err)
|
||||
}
|
||||
|
||||
userPath := filepath.Join(targetDir, "USER.md")
|
||||
if _, err := os.Stat(userPath); err != nil {
|
||||
t.Fatalf("expected %s to exist: %v", userPath, err)
|
||||
}
|
||||
|
||||
for _, legacyName := range []string{"AGENTS.md", "IDENTITY.md"} {
|
||||
legacyPath := filepath.Join(targetDir, legacyName)
|
||||
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected legacy file %s to be absent, got err=%v", legacyPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
||||
type deps struct {
|
||||
workspace string
|
||||
installer *skills.SkillInstaller
|
||||
skillsLoader *skills.SkillsLoader
|
||||
}
|
||||
|
||||
func NewSkillsCommand() *cobra.Command {
|
||||
var d deps
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "skills",
|
||||
Short: "Manage skills",
|
||||
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cfg, err := internal.LoadConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading config: %w", err)
|
||||
}
|
||||
|
||||
d.workspace = cfg.WorkspacePath()
|
||||
installer, err := skills.NewSkillInstaller(
|
||||
d.workspace,
|
||||
cfg.Tools.Skills.Github.Token(),
|
||||
cfg.Tools.Skills.Github.Proxy,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating skills installer: %w", err)
|
||||
}
|
||||
d.installer = installer
|
||||
|
||||
// get global config directory and builtin skills directory
|
||||
globalDir := filepath.Dir(internal.GetConfigPath())
|
||||
globalSkillsDir := filepath.Join(globalDir, "skills")
|
||||
builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
|
||||
d.skillsLoader = skills.NewSkillsLoader(d.workspace, globalSkillsDir, builtinSkillsDir)
|
||||
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
installerFn := func() (*skills.SkillInstaller, error) {
|
||||
if d.installer == nil {
|
||||
return nil, fmt.Errorf("skills installer is not initialized")
|
||||
}
|
||||
return d.installer, nil
|
||||
}
|
||||
|
||||
loaderFn := func() (*skills.SkillsLoader, error) {
|
||||
if d.skillsLoader == nil {
|
||||
return nil, fmt.Errorf("skills loader is not initialized")
|
||||
}
|
||||
return d.skillsLoader, nil
|
||||
}
|
||||
|
||||
workspaceFn := func() (string, error) {
|
||||
if d.workspace == "" {
|
||||
return "", fmt.Errorf("workspace is not initialized")
|
||||
}
|
||||
return d.workspace, nil
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
newListCommand(loaderFn),
|
||||
newInstallCommand(installerFn),
|
||||
newInstallBuiltinCommand(workspaceFn),
|
||||
newListBuiltinCommand(),
|
||||
newRemoveCommand(installerFn),
|
||||
newSearchCommand(),
|
||||
newShowCommand(loaderFn),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewSkillsCommand(t *testing.T) {
|
||||
cmd := NewSkillsCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "skills", cmd.Use)
|
||||
assert.Equal(t, "Manage skills", cmd.Short)
|
||||
|
||||
assert.Len(t, cmd.Aliases, 0)
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
const skillsSearchMaxResults = 20
|
||||
|
||||
func skillsListCmd(loader *skills.SkillsLoader) {
|
||||
allSkills := loader.ListSkills()
|
||||
|
||||
if len(allSkills) == 0 {
|
||||
fmt.Println("No skills installed.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("\nInstalled Skills:")
|
||||
fmt.Println("------------------")
|
||||
for _, skill := range allSkills {
|
||||
fmt.Printf(" ✓ %s (%s)\n", skill.Name, skill.Source)
|
||||
if skill.Description != "" {
|
||||
fmt.Printf(" %s\n", skill.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func skillsInstallCmd(installer *skills.SkillInstaller, repo string) error {
|
||||
fmt.Printf("Installing skill from %s...\n", repo)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := installer.InstallFromGitHub(ctx, repo); err != nil {
|
||||
return fmt.Errorf("failed to install skill: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).
|
||||
func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) error {
|
||||
err := utils.ValidateSkillIdentifier(registryName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("✗ invalid registry name: %w", err)
|
||||
}
|
||||
|
||||
err = utils.ValidateSkillIdentifier(slug)
|
||||
if err != nil {
|
||||
return fmt.Errorf("✗ invalid slug: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
|
||||
|
||||
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
|
||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||
ClawHub: skills.ClawHubConfig{
|
||||
Enabled: clawHubConfig.Enabled,
|
||||
BaseURL: clawHubConfig.BaseURL,
|
||||
AuthToken: clawHubConfig.AuthToken(),
|
||||
SearchPath: clawHubConfig.SearchPath,
|
||||
SkillsPath: clawHubConfig.SkillsPath,
|
||||
DownloadPath: clawHubConfig.DownloadPath,
|
||||
Timeout: clawHubConfig.Timeout,
|
||||
MaxZipSize: clawHubConfig.MaxZipSize,
|
||||
MaxResponseSize: clawHubConfig.MaxResponseSize,
|
||||
},
|
||||
})
|
||||
|
||||
registry := registryMgr.GetRegistry(registryName)
|
||||
if registry == nil {
|
||||
return fmt.Errorf("✗ registry '%s' not found or not enabled. check your config.json.", registryName)
|
||||
}
|
||||
|
||||
workspace := cfg.WorkspacePath()
|
||||
targetDir := filepath.Join(workspace, "skills", slug)
|
||||
|
||||
if _, err = os.Stat(targetDir); err == nil {
|
||||
return fmt.Errorf("\u2717 skill '%s' already installed at %s", slug, targetDir)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err = os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil {
|
||||
return fmt.Errorf("\u2717 failed to create skills directory: %v", err)
|
||||
}
|
||||
|
||||
result, err := registry.DownloadAndInstall(ctx, slug, "", targetDir)
|
||||
if err != nil {
|
||||
rmErr := os.RemoveAll(targetDir)
|
||||
if rmErr != nil {
|
||||
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
|
||||
}
|
||||
return fmt.Errorf("✗ failed to install skill: %w", err)
|
||||
}
|
||||
|
||||
if result.IsMalwareBlocked {
|
||||
rmErr := os.RemoveAll(targetDir)
|
||||
if rmErr != nil {
|
||||
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
|
||||
}
|
||||
|
||||
return fmt.Errorf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug)
|
||||
}
|
||||
|
||||
if result.IsSuspicious {
|
||||
fmt.Printf("\u26a0\ufe0f Warning: skill '%s' is flagged as suspicious.\n", slug)
|
||||
}
|
||||
|
||||
fmt.Printf("\u2713 Skill '%s' v%s installed successfully!\n", slug, result.Version)
|
||||
if result.Summary != "" {
|
||||
fmt.Printf(" %s\n", result.Summary)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
|
||||
fmt.Printf("Removing skill '%s'...\n", skillName)
|
||||
|
||||
if err := installer.Uninstall(skillName); err != nil {
|
||||
fmt.Printf("✗ Failed to remove skill: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Skill '%s' removed successfully!\n", skillName)
|
||||
}
|
||||
|
||||
func skillsInstallBuiltinCmd(workspace string) {
|
||||
builtinSkillsDir := "./picoclaw/skills"
|
||||
workspaceSkillsDir := filepath.Join(workspace, "skills")
|
||||
|
||||
fmt.Printf("Copying builtin skills to workspace...\n")
|
||||
|
||||
skillsToInstall := []string{
|
||||
"weather",
|
||||
"news",
|
||||
"stock",
|
||||
"calculator",
|
||||
}
|
||||
|
||||
for _, skillName := range skillsToInstall {
|
||||
builtinPath := filepath.Join(builtinSkillsDir, skillName)
|
||||
workspacePath := filepath.Join(workspaceSkillsDir, skillName)
|
||||
|
||||
if _, err := os.Stat(builtinPath); err != nil {
|
||||
fmt.Printf("⊘ Builtin skill '%s' not found: %v\n", skillName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(workspacePath, 0o755); err != nil {
|
||||
fmt.Printf("✗ Failed to create directory for %s: %v\n", skillName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := copyDirectory(builtinPath, workspacePath); err != nil {
|
||||
fmt.Printf("✗ Failed to copy %s: %v\n", skillName, err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n✓ All builtin skills installed!")
|
||||
fmt.Println("Now you can use them in your workspace.")
|
||||
}
|
||||
|
||||
func skillsListBuiltinCmd() {
|
||||
cfg, err := internal.LoadConfig()
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading config: %v\n", err)
|
||||
return
|
||||
}
|
||||
builtinSkillsDir := filepath.Join(filepath.Dir(cfg.WorkspacePath()), "picoclaw", "skills")
|
||||
|
||||
fmt.Println("\nAvailable Builtin Skills:")
|
||||
fmt.Println("-----------------------")
|
||||
|
||||
entries, err := os.ReadDir(builtinSkillsDir)
|
||||
if err != nil {
|
||||
fmt.Printf("Error reading builtin skills: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
fmt.Println("No builtin skills available.")
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
skillName := entry.Name()
|
||||
skillFile := filepath.Join(builtinSkillsDir, skillName, "SKILL.md")
|
||||
|
||||
description := "No description"
|
||||
if _, err := os.Stat(skillFile); err == nil {
|
||||
data, err := os.ReadFile(skillFile)
|
||||
if err == nil {
|
||||
content := string(data)
|
||||
if idx := strings.Index(content, "\n"); idx > 0 {
|
||||
firstLine := content[:idx]
|
||||
if strings.Contains(firstLine, "description:") {
|
||||
descLine := strings.Index(content[idx:], "\n")
|
||||
if descLine > 0 {
|
||||
description = strings.TrimSpace(content[idx+descLine : idx+descLine])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
status := "✓"
|
||||
fmt.Printf(" %s %s\n", status, entry.Name())
|
||||
if description != "" {
|
||||
fmt.Printf(" %s\n", description)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func skillsSearchCmd(query string) {
|
||||
fmt.Println("Searching for available skills...")
|
||||
|
||||
cfg, err := internal.LoadConfig()
|
||||
if err != nil {
|
||||
fmt.Printf("✗ Failed to load config: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
|
||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||
ClawHub: skills.ClawHubConfig{
|
||||
Enabled: clawHubConfig.Enabled,
|
||||
BaseURL: clawHubConfig.BaseURL,
|
||||
AuthToken: clawHubConfig.AuthToken(),
|
||||
SearchPath: clawHubConfig.SearchPath,
|
||||
SkillsPath: clawHubConfig.SkillsPath,
|
||||
DownloadPath: clawHubConfig.DownloadPath,
|
||||
Timeout: clawHubConfig.Timeout,
|
||||
MaxZipSize: clawHubConfig.MaxZipSize,
|
||||
MaxResponseSize: clawHubConfig.MaxResponseSize,
|
||||
},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
results, err := registryMgr.SearchAll(ctx, query, skillsSearchMaxResults)
|
||||
if err != nil {
|
||||
fmt.Printf("✗ Failed to fetch skills list: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
fmt.Println("No skills available.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\nAvailable Skills (%d):\n", len(results))
|
||||
fmt.Println("--------------------")
|
||||
for _, result := range results {
|
||||
fmt.Printf(" 📦 %s\n", result.DisplayName)
|
||||
fmt.Printf(" %s\n", result.Summary)
|
||||
fmt.Printf(" Slug: %s\n", result.Slug)
|
||||
fmt.Printf(" Registry: %s\n", result.RegistryName)
|
||||
if result.Version != "" {
|
||||
fmt.Printf(" Version: %s\n", result.Version)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func skillsShowCmd(loader *skills.SkillsLoader, skillName string) {
|
||||
content, ok := loader.LoadSkill(skillName)
|
||||
if !ok {
|
||||
fmt.Printf("✗ Skill '%s' not found\n", skillName)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\n📦 Skill: %s\n", skillName)
|
||||
fmt.Println("----------------------")
|
||||
fmt.Println(content)
|
||||
}
|
||||
|
||||
func copyDirectory(src, dst string) error {
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstPath := filepath.Join(dst, relPath)
|
||||
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(dstPath, info.Mode())
|
||||
}
|
||||
|
||||
srcFile, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
return err
|
||||
})
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
||||
func newInstallCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
|
||||
var registry string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "install",
|
||||
Short: "Install skill from GitHub",
|
||||
Example: `
|
||||
picoclaw skills install sipeed/picoclaw-skills/weather
|
||||
picoclaw skills install --registry clawhub github
|
||||
`,
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if registry != "" {
|
||||
if len(args) != 1 {
|
||||
return fmt.Errorf("when --registry is set, exactly 1 argument is required: <slug>")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(args) != 1 {
|
||||
return fmt.Errorf("exactly 1 argument is required: <github>")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
installer, err := installerFn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if registry != "" {
|
||||
cfg, err := internal.LoadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return skillsInstallFromRegistry(cfg, registry, args[0])
|
||||
}
|
||||
|
||||
return skillsInstallCmd(installer, args[0])
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(®istry, "registry", "", "Install from registry: --registry <name> <slug>")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewInstallSubcommand(t *testing.T) {
|
||||
cmd := newInstallCommand(nil)
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "install", cmd.Use)
|
||||
assert.Equal(t, "Install skill from GitHub", cmd.Short)
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
|
||||
assert.True(t, cmd.HasExample())
|
||||
assert.False(t, cmd.HasSubCommands())
|
||||
|
||||
assert.True(t, cmd.HasFlags())
|
||||
assert.NotNil(t, cmd.Flags().Lookup("registry"))
|
||||
|
||||
assert.Len(t, cmd.Aliases, 0)
|
||||
}
|
||||
|
||||
func TestInstallCommandArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
registry string
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "no registry, one arg",
|
||||
args: []string{"sipeed/picoclaw-skills/weather"},
|
||||
registry: "",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "no registry, no args",
|
||||
args: []string{},
|
||||
registry: "",
|
||||
expectError: true,
|
||||
errorMsg: "exactly 1 argument is required: <github>",
|
||||
},
|
||||
{
|
||||
name: "no registry, too many args",
|
||||
args: []string{"arg1", "arg2"},
|
||||
registry: "",
|
||||
expectError: true,
|
||||
errorMsg: "exactly 1 argument is required: <github>",
|
||||
},
|
||||
{
|
||||
name: "with registry, one arg",
|
||||
args: []string{"weather-skill"},
|
||||
registry: "clawhub",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "with registry, no args",
|
||||
args: []string{},
|
||||
registry: "clawhub",
|
||||
expectError: true,
|
||||
errorMsg: "when --registry is set, exactly 1 argument is required: <slug>",
|
||||
},
|
||||
{
|
||||
name: "with registry, too many args",
|
||||
args: []string{"arg1", "arg2"},
|
||||
registry: "clawhub",
|
||||
expectError: true,
|
||||
errorMsg: "when --registry is set, exactly 1 argument is required: <slug>",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := newInstallCommand(nil)
|
||||
|
||||
if tt.registry != "" {
|
||||
require.NoError(t, cmd.Flags().Set("registry", tt.registry))
|
||||
}
|
||||
|
||||
err := cmd.Args(cmd, tt.args)
|
||||
if tt.expectError {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tt.errorMsg, err.Error())
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package skills
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newInstallBuiltinCommand(workspaceFn func() (string, error)) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "install-builtin",
|
||||
Short: "Install all builtin skills to workspace",
|
||||
Example: `picoclaw skills install-builtin`,
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
workspace, err := workspaceFn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
skillsInstallBuiltinCmd(workspace)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewInstallbuiltinSubcommand(t *testing.T) {
|
||||
cmd := newInstallBuiltinCommand(nil)
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "install-builtin", cmd.Use)
|
||||
assert.Equal(t, "Install all builtin skills to workspace", cmd.Short)
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
|
||||
assert.True(t, cmd.HasExample())
|
||||
assert.False(t, cmd.HasSubCommands())
|
||||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
|
||||
assert.Len(t, cmd.Aliases, 0)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
||||
func newListCommand(loaderFn func() (*skills.SkillsLoader, error)) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List installed skills",
|
||||
Example: `picoclaw skills list`,
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
loader, err := loaderFn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
skillsListCmd(loader)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewListSubcommand(t *testing.T) {
|
||||
cmd := newListCommand(nil)
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "list", cmd.Use)
|
||||
assert.Equal(t, "List installed skills", cmd.Short)
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
|
||||
assert.True(t, cmd.HasExample())
|
||||
assert.False(t, cmd.HasSubCommands())
|
||||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
|
||||
assert.Len(t, cmd.Aliases, 0)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package skills
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newListBuiltinCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list-builtin",
|
||||
Short: "List available builtin skills",
|
||||
Example: `picoclaw skills list-builtin`,
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
skillsListBuiltinCmd()
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewListbuiltinSubcommand(t *testing.T) {
|
||||
cmd := newListBuiltinCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "list-builtin", cmd.Use)
|
||||
assert.Equal(t, "List available builtin skills", cmd.Short)
|
||||
|
||||
assert.NotNil(t, cmd.Run)
|
||||
|
||||
assert.True(t, cmd.HasExample())
|
||||
assert.False(t, cmd.HasSubCommands())
|
||||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
|
||||
assert.Len(t, cmd.Aliases, 0)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
||||
func newRemoveCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove",
|
||||
Aliases: []string{"rm", "uninstall"},
|
||||
Short: "Remove installed skill",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Example: `picoclaw skills remove weather`,
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
installer, err := installerFn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
skillsRemoveCmd(installer, args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewRemoveSubcommand(t *testing.T) {
|
||||
cmd := newRemoveCommand(nil)
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "remove", cmd.Use)
|
||||
assert.Equal(t, "Remove installed skill", cmd.Short)
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
|
||||
assert.True(t, cmd.HasExample())
|
||||
assert.False(t, cmd.HasSubCommands())
|
||||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
|
||||
assert.Len(t, cmd.Aliases, 2)
|
||||
assert.True(t, cmd.HasAlias("rm"))
|
||||
assert.True(t, cmd.HasAlias("uninstall"))
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newSearchCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "search [query]",
|
||||
Short: "Search available skills",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
query := ""
|
||||
if len(args) == 1 {
|
||||
query = args[0]
|
||||
}
|
||||
skillsSearchCmd(query)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewSearchSubcommand(t *testing.T) {
|
||||
cmd := newSearchCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "search [query]", cmd.Use)
|
||||
assert.Equal(t, "Search available skills", cmd.Short)
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
|
||||
assert.False(t, cmd.HasSubCommands())
|
||||
assert.False(t, cmd.HasFlags())
|
||||
|
||||
assert.Len(t, cmd.Aliases, 0)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user