Merge main into refactor/agent-loop-decomposition

Resolve conflicts between the decomposed agent loop architecture and
main's new features (media store, voice transcription, command registry,
MCP servers, parallel tool execution, model routing, and new providers).

Conflict resolution strategy:
- Keep decomposed components (ContextCompressor, ToolExecutor) alongside
  new features (mediaStore, transcriber, cmdRegistry)
- Take main's parallel tool execution (more advanced than sequential ToolExecutor)
- Keep ToolExecutor.UpdateToolContexts for context injection
- Take main's timeout error detection + use extracted IsContextWindowError helper
- Add selectCandidates for model routing support
- Add new providers (litellm, vivgrid, avian, minimax) to standardProviderRegistry
  and modelInferenceRegistry, maintaining the data-driven refactored pattern

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sai Balusu 2026-03-09 16:20:32 -04:00
commit e4081070fe
506 changed files with 67632 additions and 7253 deletions

View file

@ -5,16 +5,17 @@
# ANTHROPIC_API_KEY=sk-ant-xxx # ANTHROPIC_API_KEY=sk-ant-xxx
# OPENAI_API_KEY=sk-xxx # OPENAI_API_KEY=sk-xxx
# GEMINI_API_KEY=xxx # GEMINI_API_KEY=xxx
# CEREBRAS_API_KEY=xxx # CLAUDE_CODE_OAUTH=xxx
# ── Chat Channel ────────────────────────── # ── Chat Channel ──────────────────────────
# TELEGRAM_BOT_TOKEN=123456:ABC... # TELEGRAM_BOT_TOKEN=123456:ABC...
# DISCORD_BOT_TOKEN=xxx # DISCORD_BOT_TOKEN=xxx
# LINE_CHANNEL_SECRET=xxx # Feishu (飞书)
# LINE_CHANNEL_ACCESS_TOKEN=xxx # PICOCLAW_CHANNELS_FEISHU_APP_ID=cli_xxx
# PICOCLAW_CHANNELS_FEISHU_APP_SECRET=xxx
# PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI=Typing,OneSecond
# ── Web Search (optional) ──────────────── # ── Web Search (optional) ────────────────
# BRAVE_SEARCH_API_KEY=BSA... # BRAVE_SEARCH_API_KEY=BSA...
# ── Timezone ────────────────────────────── # ── Timezone ──────────────────────────────
TZ=Asia/Tokyo TZ=Asia/Shanghai

204
.github/workflows/nightly.yml vendored Normal file
View file

@ -0,0 +1,204 @@
name: Nightly Build
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
permissions:
contents: read
jobs:
create-tag:
name: Create Git Tag
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
changelog: ${{ steps.version.outputs.changelog }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Generate and push tag
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
TAG="v0.0.0-nightly.${DATE}.${SHA}"
else
TAG="${BASE_VERSION}-nightly.${DATE}.${SHA}"
fi
VERSION=$TAG
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "Tag $TAG already exists, reusing existing tag"
else
git tag -a "$TAG" -m "Nightly build $VERSION"
fi
git push origin "$TAG"
COMPARE_URL="https://github.com/${{ github.repository }}/commits/${TAG}"
if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "v0.0.0" ]; then
COMPARE_URL="https://github.com/${{ github.repository }}/compare/${BASE_VERSION}...${TAG}"
fi
echo "changelog=**Full Changelog**: $COMPARE_URL" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
release:
name: GoReleaser Release
needs: create-tag
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: Checkout tag
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ needs.create-tag.outputs.tag }}
- 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@v4
with:
node-version: 22
- name: Setup pnpm
run: corepack enable && corepack prepare pnpm@latest --activate
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
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 }}
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 }}
update-rolling:
name: Update Rolling Nightly
needs: [create-tag, release]
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Update nightly release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.create-tag.outputs.tag }}
TITLE: ${{ needs.create-tag.outputs.version }}
run: |
CHANGELOG='${{ needs.create-tag.outputs.changelog }}'
NOTES=$(cat <<EOF
Nightly build for **${TITLE}**
This is an automated build and may be unstable. Use with caution.
${CHANGELOG}
EOF
)
# Download assets from the newly created release if it exists,
# otherwise fall back to using locally built dist/ artifacts.
mkdir -p build
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Downloading assets from GitHub release for $TAG..."
gh release download "$TAG" --dir build
else
echo "GitHub release for $TAG not found; falling back to local dist/ artifacts..."
if [ -d "dist" ]; then
cp -R dist/* build/
else
echo "Error: no GitHub release for $TAG and no local dist/ directory found." >&2
exit 1
fi
fi
# Delete existing nightly release and tag to avoid conflicts
echo "Deleting existing nightly release and tag..."
gh release delete nightly --cleanup-tag -y || true
git push origin :refs/tags/nightly || true
gh release create nightly \
--title "Nightly Build" \
--notes "$NOTES" \
--target "${{ github.sha }}" \
--prerelease \
build/*
echo "Cleaning up old nightly releases (keeping only the most recent)..."
gh release list --limit 100 --json tagName -q '.[].tagName | select(contains("-nightly."))' | tail -n +2 | while read -r old_tag; do
if [ -n "$old_tag" ] && [ "$old_tag" != "$TAG" ]; then
echo "Deleting old nightly release: $old_tag"
gh release delete "$old_tag" --cleanup-tag -y || true
fi
done
echo "Cleaning up old 'vX.X.X-nightly...' Docker images on GHCR..."
OWNER="${{ github.repository_owner }}"
PACKAGE_NAME="${{ github.event.repository.name }}"
# Check if owner is an organization or user
ORG_TEST=$(gh api -H "Accept: application/vnd.github+json" /orgs/$OWNER 2>/dev/null || true)
if echo "$ORG_TEST" | grep -q '"login"'; then
ACCOUNT_TYPE="orgs"
else
ACCOUNT_TYPE="users"
fi
PACKAGE_URL="/${ACCOUNT_TYPE}/${OWNER}/packages/container/${PACKAGE_NAME}/versions"
OLD_NIGHTLY_VERSIONS=$(gh api --paginate -H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"$PACKAGE_URL" \
--jq ". | map(select(any(.metadata.container.tags[]; contains(\"-nightly.\") and (. != \"nightly\") and (. != \"$TAG\")))) | .[].id" 2>/dev/null || true)
for version_id in $OLD_NIGHTLY_VERSIONS; do
if [ -n "$version_id" ]; then
echo "Deleting Docker image version ID: $version_id"
gh api -X DELETE -H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/${ACCOUNT_TYPE}/${OWNER}/packages/container/${PACKAGE_NAME}/versions/$version_id" || true
fi
done

View file

@ -24,6 +24,25 @@ jobs:
with: with:
version: v2.10.1 version: v2.10.1
vuln_check:
name: Security Check
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run Govulncheck
uses: golang/govulncheck-action@v1
with:
go-package: ./...
test: test:
name: Tests name: Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest

View file

@ -17,6 +17,11 @@ on:
required: false required: false
type: boolean type: boolean
default: false default: false
upload_tos:
description: "Upload to Volcengine TOS"
required: false
type: boolean
default: true
jobs: jobs:
create-tag: create-tag:
@ -60,6 +65,14 @@ jobs:
with: with:
go-version-file: go.mod go-version-file: go.mod
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
run: corepack enable && corepack prepare pnpm@latest --activate
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@ -91,6 +104,11 @@ jobs:
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }} DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
GOVERSION: ${{ steps.setup-go.outputs.go-version }} GOVERSION: ${{ steps.setup-go.outputs.go-version }}
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 - name: Apply release flags
shell: bash shell: bash
@ -100,3 +118,12 @@ jobs:
gh release edit "${{ inputs.tag }}" \ gh release edit "${{ inputs.tag }}" \
--draft=${{ inputs.draft }} \ --draft=${{ inputs.draft }} \
--prerelease=${{ inputs.prerelease }} --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

49
.github/workflows/upload-tos.yml vendored Normal file
View file

@ -0,0 +1,49 @@
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

14
.gitignore vendored
View file

@ -10,7 +10,7 @@ build/
*.out *.out
/picoclaw /picoclaw
/picoclaw-test /picoclaw-test
cmd/picoclaw/workspace cmd/**/workspace
# Picoclaw specific # Picoclaw specific
@ -38,9 +38,21 @@ ralph/
.ralph/ .ralph/
tasks/ tasks/
# Plans
docs/plans/
# Editors # Editors
.vscode/ .vscode/
.idea/ .idea/
# Added by goreleaser init: # Added by goreleaser init:
dist/ dist/
*.vite/
# Windows Application Icon/Resource
*.syso
# Keep embedded backend dist directory placeholder in VCS
!web/backend/dist/
web/backend/dist/*
!web/backend/dist/.gitkeep

View file

@ -7,7 +7,6 @@ linters:
- containedctx - containedctx
- cyclop - cyclop
- depguard - depguard
- dupl
- dupword - dupword
- err113 - err113
- exhaustruct - exhaustruct
@ -28,9 +27,7 @@ linters:
- wsl_v5 - wsl_v5
# TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step) # TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step)
- bodyclose
- contextcheck - contextcheck
- dogsled
- embeddedstructfieldcheck - embeddedstructfieldcheck
- errcheck - errcheck
- errchkjson - errchkjson
@ -45,32 +42,24 @@ linters:
- gocritic - gocritic
- gocyclo - gocyclo
- godox - godox
- goprintffuncname
- gosec - gosec
- ineffassign - ineffassign
- lll - lll
- maintidx - maintidx
- misspell
- mnd - mnd
- modernize - modernize
- nakedret
- nestif - nestif
- nilnil - nilnil
- paralleltest - paralleltest
- perfsprint - perfsprint
- prealloc
- predeclared
- revive - revive
- staticcheck - staticcheck
- tagalign - tagalign
- testifylint - testifylint
- thelper - thelper
- unparam - unparam
- unused
- usestdlibvars - usestdlibvars
- usetesting - usetesting
- wastedassign
- whitespace
settings: settings:
errcheck: errcheck:
check-type-assertions: true check-type-assertions: true
@ -152,6 +141,9 @@ linters:
- gocognit - gocognit
- gocyclo - gocyclo
path: _test\.go$ path: _test\.go$
- linters:
- nolintlint
path: 'pkg/tools/(i2c\.go|spi\.go)$'
issues: issues:
max-issues-per-linter: 0 max-issues-per-linter: 0

View file

@ -5,7 +5,10 @@ version: 2
before: before:
hooks: hooks:
- go mod tidy - go mod tidy
- go generate ./cmd/picoclaw - 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 }}
builds: builds:
- id: picoclaw - id: picoclaw
@ -15,10 +18,10 @@ builds:
- stdjson - stdjson
ldflags: ldflags:
- -s -w - -s -w
- -X main.version={{ .Version }} - -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.version={{ .Version }}
- -X main.gitCommit={{ .ShortCommit }} - -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.gitCommit={{ .ShortCommit }}
- -X main.buildTime={{ .Date }} - -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.buildTime={{ .Date }}
- -X main.goVersion={{ .Env.GOVERSION }} - -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.goVersion={{ .Env.GOVERSION }}
goos: goos:
- linux - linux
- windows - windows
@ -28,30 +31,117 @@ builds:
- amd64 - amd64
- arm64 - arm64
- riscv64 - riscv64
- s390x - loong64
- mips64
- arm - arm
- s390x
- mipsle
goarm:
- "6"
- "7"
gomips:
- softfloat
main: ./cmd/picoclaw main: ./cmd/picoclaw
ignore: ignore:
- goos: windows - goos: windows
goarch: arm goarch: arm
- id: picoclaw-launcher
binary: picoclaw-launcher
env:
- CGO_ENABLED=0
tags:
- stdjson
ldflags:
- -s -w
goos:
- linux
- windows
- darwin
- freebsd
goarch:
- amd64
- arm64
- riscv64
- loong64
- arm
- s390x
- mipsle
goarm:
- "6"
- "7"
gomips:
- softfloat
main: ./web/backend
ignore:
- goos: windows
goarch: arm
- id: picoclaw-launcher-tui
binary: picoclaw-launcher-tui
env:
- CGO_ENABLED=0
tags:
- stdjson
ldflags:
- -s -w
goos:
- linux
- windows
- darwin
- freebsd
goarch:
- amd64
- arm64
- riscv64
- loong64
- arm
- s390x
- mipsle
goarm:
- "6"
- "7"
gomips:
- softfloat
main: ./cmd/picoclaw-launcher-tui
ignore:
- goos: windows
goarch: arm
dockers_v2: dockers_v2:
- id: picoclaw - id: picoclaw
dockerfile: Dockerfile.goreleaser dockerfile: docker/Dockerfile.goreleaser
extra_files:
- docker/entrypoint.sh
ids: ids:
- picoclaw - picoclaw
images: images:
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw" - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
- "docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}" - '{{ if not (isEnvSet "NIGHTLY_BUILD") }}docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}{{ end }}'
tags: tags:
- "{{ .Tag }}" - "{{ .Tag }}"
- "latest" - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}'
platforms: platforms:
- linux/amd64 - linux/amd64
- linux/arm64 - linux/arm64
- linux/riscv64 - 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: archives:
- formats: [tar.gz] - formats: [tar.gz]
# this name template makes the OS and Arch compatible with the results of `uname`. # this name template makes the OS and Arch compatible with the results of `uname`.
@ -67,6 +157,34 @@ archives:
- goos: windows - goos: windows
formats: [zip] 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: changelog:
sort: asc sort: asc
filters: filters:

View file

@ -269,8 +269,8 @@ Once your PR is submitted, you can reach out to the assigned reviewers listed in
|Function| Reviewer| |Function| Reviewer|
|--- |--- | |--- |--- |
|Provider|@yinwm | |Provider|@yinwm |
|Channel |@yinwm | |Channel |@yinwm/@alexhoshina |
|Agent |@lxowalle| |Agent |@lxowalle/@Zhaoyikaiii|
|Tools |@lxowalle| |Tools |@lxowalle|
|SKill || |SKill ||
|MCP || |MCP ||

View file

@ -268,8 +268,8 @@ Release 分支的保护级别高于 `main`,在任何情况下均不允许直
|Function| Reviewer| |Function| Reviewer|
|--- |--- | |--- |--- |
|Provider|@yinwm | |Provider|@yinwm |
|Channel |@yinwm | |Channel |@yinwm/@alexhoshina |
|Agent |@lxowalle| |Agent |@lxowalle/@Zhaoyikaiii|
|Tools |@lxowalle| |Tools |@lxowalle|
|SKill || |SKill ||
|MCP || |MCP ||

View file

@ -19,7 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 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 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
---
PicoClaw is heavily inspired by and based on [nanobot](https://github.com/HKUDS/nanobot) by HKUDS.

141
Makefile
View file

@ -11,12 +11,35 @@ 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") GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
BUILD_TIME=$(shell date +%FT%T%z) BUILD_TIME=$(shell date +%FT%T%z)
GO_VERSION=$(shell $(GO) version | awk '{print $$3}') GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION) -s -w" INTERNAL=github.com/sipeed/picoclaw/cmd/picoclaw/internal
LDFLAGS=-ldflags "-X $(INTERNAL).version=$(VERSION) -X $(INTERNAL).gitCommit=$(GIT_COMMIT) -X $(INTERNAL).buildTime=$(BUILD_TIME) -X $(INTERNAL).goVersion=$(GO_VERSION) -s -w"
# Go variables # Go variables
GO?=go GO?=CGO_ENABLED=0 go
GOFLAGS?=-v -tags stdjson GOFLAGS?=-v -tags stdjson
# 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?=golangci-lint GOLANGCI_LINT?=golangci-lint
@ -43,10 +66,14 @@ ifeq ($(UNAME_S),Linux)
ARCH=amd64 ARCH=amd64
else ifeq ($(UNAME_M),aarch64) else ifeq ($(UNAME_M),aarch64)
ARCH=arm64 ARCH=arm64
else ifeq ($(UNAME_M),armv81)
ARCH=arm64
else ifeq ($(UNAME_M),loongarch64) else ifeq ($(UNAME_M),loongarch64)
ARCH=loong64 ARCH=loong64
else ifeq ($(UNAME_M),riscv64) else ifeq ($(UNAME_M),riscv64)
ARCH=riscv64 ARCH=riscv64
else ifeq ($(UNAME_M),mipsel)
ARCH=mipsle
else else
ARCH=$(UNAME_M) ARCH=$(UNAME_M)
endif endif
@ -84,14 +111,74 @@ build: generate
@echo "Build complete: $(BINARY_PATH)" @echo "Build complete: $(BINARY_PATH)"
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) @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
@$(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-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 whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags whatsapp_native $(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 whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
## @$(GO) build $(GOFLAGS) -tags whatsapp_native $(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 $(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 $(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 $(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: Build picoclaw for all platforms
build-all: generate build-all: generate
@echo "Building for multiple platforms..." @echo "Building for multiple platforms..."
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR) GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(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=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=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(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 $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(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) GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
@echo "All builds complete" @echo "All builds complete"
@ -129,11 +216,11 @@ clean:
@echo "Clean complete" @echo "Clean complete"
## vet: Run go vet for static analysis ## vet: Run go vet for static analysis
vet: vet: generate
@$(GO) vet ./... @$(GO) vet ./...
## test: Test Go code ## test: Test Go code
test: test: generate
@$(GO) test ./... @$(GO) test ./...
## fmt: Format Go code ## fmt: Format Go code
@ -144,6 +231,10 @@ fmt:
lint: lint:
@$(GOLANGCI_LINT) run @$(GOLANGCI_LINT) run
## fix: Fix linting issues
fix:
@$(GOLANGCI_LINT) run --fix
## deps: Download dependencies ## deps: Download dependencies
deps: deps:
@$(GO) mod download @$(GO) mod download
@ -161,6 +252,44 @@ check: deps fmt vet test
run: build run: build
@$(BUILD_DIR)/$(BINARY_NAME) $(ARGS) @$(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
## help: Show this help message ## help: Show this help message
help: help:
@echo "picoclaw Makefile" @echo "picoclaw Makefile"
@ -169,13 +298,15 @@ help:
@echo " make [target]" @echo " make [target]"
@echo "" @echo ""
@echo "Targets:" @echo "Targets:"
@grep -E '^## ' $(MAKEFILE_LIST) | sed 's/## / /' @grep -E '^## ' $(MAKEFILE_LIST) | sort | awk -F': ' '{printf " %-16s %s\n", substr($$1, 4), $$2}'
@echo "" @echo ""
@echo "Examples:" @echo "Examples:"
@echo " make build # Build for current platform" @echo " make build # Build for current platform"
@echo " make install # Install to ~/.local/bin" @echo " make install # Install to ~/.local/bin"
@echo " make uninstall # Remove from /usr/local/bin" @echo " make uninstall # Remove from /usr/local/bin"
@echo " make install-skills # Install skills to workspace" @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 ""
@echo "Environment Variables:" @echo "Environment Variables:"
@echo " INSTALL_PREFIX # Installation prefix (default: ~/.local)" @echo " INSTALL_PREFIX # Installation prefix (default: ~/.local)"

View file

@ -7,7 +7,7 @@
<p> <p>
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go"> <img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware"> <img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License"> <img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<br> <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://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
@ -65,7 +65,7 @@
⚡️ **Démarrage Éclair** : Temps de démarrage 400X plus rapide, boot en 1 seconde même sur un cœur unique à 0,6 GHz. ⚡️ **Démarrage Éclair** : Temps de démarrage 400X plus rapide, boot en 1 seconde même sur un cœur unique à 0,6 GHz.
🌍 **Véritable Portabilité** : Un seul binaire autonome pour RISC-V, ARM et x86. Un clic et c'est parti ! 🌍 **Véritable Portabilité** : Un seul binaire autonome pour RISC-V, ARM, MIPS et x86. Un clic et c'est parti !
🤖 **Auto-Construit par l'IA** : Implémentation native en Go de manière autonome — 95% du cœur généré par l'Agent avec affinement humain dans la boucle. 🤖 **Auto-Construit par l'IA** : Implémentation native en Go de manière autonome — 95% du cœur généré par l'Agent avec affinement humain dans la boucle.
@ -164,39 +164,43 @@ Vous pouvez également exécuter PicoClaw avec Docker Compose sans rien installe
git clone https://github.com/sipeed/picoclaw.git git clone https://github.com/sipeed/picoclaw.git
cd picoclaw cd picoclaw
# 2. Configurez vos clés API # 2. Premier lancement — génère docker/data/config.json puis s'arrête
cp config/config.example.json config/config.json docker compose -f docker/docker-compose.yml --profile gateway up
vim config/config.json # Configurez DISCORD_BOT_TOKEN, clés API, etc. # Le conteneur affiche "First-run setup complete." puis s'arrête.
# 3. Compiler & Démarrer # 3. Configurez vos clés API
docker compose --profile gateway up -d vim docker/data/config.json # Clés API du fournisseur, tokens de bot, etc.
# 4. Démarrer
docker compose -f docker/docker-compose.yml --profile gateway up -d
```
> [!TIP] > [!TIP]
> **Utilisateurs Docker** : Par défaut, le Gateway écoute sur `127.0.0.1`, ce qui n'est pas accessible depuis l'hôte. Si vous avez besoin d'accéder aux endpoints de santé ou d'exposer des ports, définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` dans votre environnement ou mettez à jour `config.json`. > **Utilisateurs Docker** : Par défaut, le Gateway écoute sur `127.0.0.1`, ce qui n'est pas accessible depuis l'hôte. Si vous avez besoin d'accéder aux endpoints de santé ou d'exposer des ports, définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` dans votre environnement ou mettez à jour `config.json`.
```bash
# 5. Voir les logs
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
# 4. Voir les logs # 6. Arrêter
docker compose logs -f picoclaw-gateway docker compose -f docker/docker-compose.yml --profile gateway down
# 5. Arrêter
docker compose --profile gateway down
``` ```
### Mode Agent (exécution unique) ### Mode Agent (exécution unique)
```bash ```bash
# Poser une question # Poser une question
docker compose run --rm picoclaw-agent -m "Combien font 2+2 ?" docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Combien font 2+2 ?"
# Mode interactif # Mode interactif
docker compose run --rm picoclaw-agent docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
``` ```
### Recompiler ### Mettre à jour
```bash ```bash
docker compose --profile gateway build --no-cache docker compose -f docker/docker-compose.yml pull
docker compose --profile gateway up -d docker compose -f docker/docker-compose.yml --profile gateway up -d
``` ```
### 🚀 Démarrage Rapide ### 🚀 Démarrage Rapide
@ -221,12 +225,13 @@ picoclaw onboard
"model_name": "gpt4", "model_name": "gpt4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.2",
"api_key": "sk-your-openai-key", "api_key": "sk-your-openai-key",
"request_timeout": 300,
"api_base": "https://api.openai.com/v1" "api_base": "https://api.openai.com/v1"
} }
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"model": "gpt4" "model_name": "gpt4"
} }
}, },
"channels": { "channels": {
@ -252,6 +257,9 @@ picoclaw onboard
} }
``` ```
> **Nouveau** : Le format de configuration `model_list` permet d'ajouter des fournisseurs sans modifier le code. Voir [Configuration de Modèle](#configuration-de-modèle-model_list) pour plus de détails.
> `request_timeout` est optionnel et s'exprime en secondes. S'il est omis ou défini à `<= 0`, PicoClaw utilise le délai d'expiration par défaut (120s).
**3. Obtenir des Clés API** **3. Obtenir des Clés API**
* **Fournisseur LLM** : [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) * **Fournisseur LLM** : [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
@ -280,7 +288,7 @@ Discutez avec votre PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom
| **QQ** | Facile (AppID + AppSecret) | | **QQ** | Facile (AppID + AppSecret) |
| **DingTalk** | Moyen (identifiants de l'application) | | **DingTalk** | Moyen (identifiants de l'application) |
| **LINE** | Moyen (identifiants + URL de webhook) | | **LINE** | Moyen (identifiants + URL de webhook) |
| **WeCom** | Moyen (CorpID + configuration webhook) | | **WeCom AI Bot** | Moyen (Token + clé AES) |
<details> <details>
<summary><b>Telegram</b> (Recommandé)</summary> <summary><b>Telegram</b> (Recommandé)</summary>
@ -448,8 +456,6 @@ picoclaw gateway
"enabled": true, "enabled": true,
"channel_secret": "VOTRE_CHANNEL_SECRET", "channel_secret": "VOTRE_CHANNEL_SECRET",
"channel_access_token": "VOTRE_CHANNEL_ACCESS_TOKEN", "channel_access_token": "VOTRE_CHANNEL_ACCESS_TOKEN",
"webhook_host": "0.0.0.0",
"webhook_port": 18791,
"webhook_path": "/webhook/line", "webhook_path": "/webhook/line",
"allow_from": [] "allow_from": []
} }
@ -462,12 +468,14 @@ picoclaw gateway
LINE exige HTTPS pour les webhooks. Utilisez un reverse proxy ou un tunnel : LINE exige HTTPS pour les webhooks. Utilisez un reverse proxy ou un tunnel :
```bash ```bash
# Exemple avec ngrok # Exemple avec ngrok (tunnel vers le serveur Gateway partagé)
ngrok http 18791 ngrok http 18790
``` ```
Puis configurez l'URL du Webhook dans la LINE Developers Console sur `https://votre-domaine/webhook/line` et activez **Use webhook**. Puis configurez l'URL du Webhook dans la LINE Developers Console sur `https://votre-domaine/webhook/line` et activez **Use webhook**.
> **Note** : Le webhook LINE est servi par le serveur Gateway partagé (par défaut `127.0.0.1:18790`). Si vous utilisez ngrok ou un proxy inverse, faites pointer le tunnel vers le port `18790`.
**4. Lancer** **4. Lancer**
```bash ```bash
@ -476,19 +484,20 @@ picoclaw gateway
> Dans les discussions de groupe, le bot répond uniquement lorsqu'il est mentionné avec @. Les réponses citent le message original. > Dans les discussions de groupe, le bot répond uniquement lorsqu'il est mentionné avec @. Les réponses citent le message original.
> **Docker Compose** : Ajoutez `ports: ["18791:18791"]` au service `picoclaw-gateway` pour exposer le port du webhook. > **Docker Compose** : Si vous avez besoin d'exposer le webhook LINE via Docker, mappez le port du Gateway partagé (par défaut `18790`) vers l'hôte, par exemple `ports: ["18790:18790"]`. Notez que le serveur Gateway sert les webhooks de tous les canaux à partir de ce port.
</details> </details>
<details> <details>
<summary><b>WeCom (WeChat Work)</b></summary> <summary><b>WeCom (WeChat Work)</b></summary>
PicoClaw prend en charge deux types d'intégration WeCom : PicoClaw prend en charge trois types d'intégration WeCom :
**Option 1 : WeCom Bot (Robot Intelligent)** - Configuration plus facile, prend en charge les discussions de groupe **Option 1 : WeCom Bot (Robot)** - Configuration plus facile, prend en charge les discussions de groupe
**Option 2 : WeCom App (Application Personnalisée)** - Plus de fonctionnalités, messagerie proactive **Option 2 : WeCom App (Application Personnalisée)** - Plus de fonctionnalités, messagerie proactive, chat privé uniquement
**Option 3 : WeCom AI Bot (Bot Intelligent)** - Bot IA officiel, réponses en streaming, prend en charge groupe et privé
Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour des instructions détaillées. Voir le [Guide de Configuration WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) pour des instructions détaillées.
**Configuration Rapide - WeCom Bot :** **Configuration Rapide - WeCom Bot :**
@ -507,8 +516,6 @@ Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour
"token": "YOUR_TOKEN", "token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_ENCODING_AES_KEY", "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
"webhook_host": "0.0.0.0",
"webhook_port": 18793,
"webhook_path": "/webhook/wecom", "webhook_path": "/webhook/wecom",
"allow_from": [] "allow_from": []
} }
@ -527,7 +534,7 @@ Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour
**2. Configurer la réception des messages** **2. Configurer la réception des messages**
* Dans les détails de l'application, cliquez sur "Recevoir les Messages" → "Configurer l'API" * Dans les détails de l'application, cliquez sur "Recevoir les Messages" → "Configurer l'API"
* Définissez l'URL sur `http://your-server:18792/webhook/wecom-app` * Définissez l'URL sur `http://your-server:18790/webhook/wecom-app`
* Générez le **Token** et l'**EncodingAESKey** * Générez le **Token** et l'**EncodingAESKey**
**3. Configurer** **3. Configurer**
@ -542,8 +549,6 @@ Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour
"agent_id": 1000002, "agent_id": 1000002,
"token": "YOUR_TOKEN", "token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_ENCODING_AES_KEY", "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
"webhook_host": "0.0.0.0",
"webhook_port": 18792,
"webhook_path": "/webhook/wecom-app", "webhook_path": "/webhook/wecom-app",
"allow_from": [] "allow_from": []
} }
@ -557,7 +562,40 @@ Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour
picoclaw gateway picoclaw gateway
``` ```
> **Note** : WeCom App nécessite l'ouverture du port 18792 pour les callbacks webhook. Utilisez un proxy inverse pour HTTPS en production. > **Note** : Les callbacks webhook WeCom App sont servis par le serveur Gateway partagé (par défaut `127.0.0.1:18790`). Assurez-vous que le port `18790` est accessible ou utilisez un proxy inverse HTTPS en production.
**Configuration Rapide - WeCom AI Bot :**
**1. Créer un AI Bot**
* Accédez à la Console d'Administration WeCom → Gestion des Applications → AI Bot
* Configurez l'URL de callback : `http://your-server:18791/webhook/wecom-aibot`
* Copiez le **Token** et générez l'**EncodingAESKey**
**2. Configurer**
```json
{
"channels": {
"wecom_aibot": {
"enabled": true,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [],
"welcome_message": "Bonjour ! Comment puis-je vous aider ?"
}
}
}
```
**3. Lancer**
```bash
picoclaw gateway
```
> **Note** : WeCom AI Bot utilise le protocole pull en streaming — pas de problème de timeout. Les tâches longues (>5,5 min) basculent automatiquement vers la livraison via `response_url`.
</details> </details>
@ -571,6 +609,31 @@ Connectez PicoClaw au Réseau Social d'Agents simplement en envoyant un seul mes
Fichier de configuration : `~/.picoclaw/config.json` Fichier de configuration : `~/.picoclaw/config.json`
### Variables d'Environnement
Vous pouvez remplacer les chemins par défaut à l'aide de variables d'environnement. Ceci est utile pour les installations portables, les déploiements conteneurisés ou l'exécution de picoclaw en tant que service système. Ces variables sont indépendantes et contrôlent différents chemins.
| Variable | Description | Chemin par Défaut |
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
| `PICOCLAW_CONFIG` | Remplace le chemin du fichier de configuration. Cela indique directement à picoclaw quel `config.json` charger, en ignorant tous les autres emplacements. | `~/.picoclaw/config.json` |
| `PICOCLAW_HOME` | Remplace le répertoire racine des données picoclaw. Cela modifie l'emplacement par défaut du `workspace` et des autres répertoires de données. | `~/.picoclaw` |
**Exemples :**
```bash
# Exécuter picoclaw en utilisant un fichier de configuration spécifique
# Le chemin du workspace sera lu à partir de ce fichier de configuration
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
# Exécuter picoclaw avec toutes ses données stockées dans /opt/picoclaw
# La configuration sera chargée à partir du fichier par défaut ~/.picoclaw/config.json
# Le workspace sera créé dans /opt/picoclaw/workspace
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
# Utiliser les deux pour une configuration entièrement personnalisée
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
```
### Structure du Workspace ### Structure du Workspace
PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.picoclaw/workspace`) : PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.picoclaw/workspace`) :
@ -764,7 +827,7 @@ Le sous-agent a accès aux outils (message, web_search, etc.) et peut communique
### Fournisseurs ### Fournisseurs
> [!NOTE] > [!NOTE]
> Groq fournit la transcription vocale gratuite via Whisper. Si configuré, les messages vocaux Telegram seront automatiquement transcrits. > Groq fournit la transcription vocale gratuite via Whisper. Si configuré, les messages audio de n'importe quel canal seront automatiquement transcrits au niveau de l'agent.
| Fournisseur | Utilisation | Obtenir une Clé API | | Fournisseur | Utilisation | Obtenir une Clé API |
| ------------------------ | ---------------------------------------- | ------------------------------------------------------ | | ------------------------ | ---------------------------------------- | ------------------------------------------------------ |
@ -979,6 +1042,17 @@ Cette conception permet également le **support multi-agent** avec une sélectio
``` ```
> Exécutez `picoclaw auth login --provider anthropic` pour configurer les identifiants OAuth. > Exécutez `picoclaw auth login --provider anthropic` pour configurer les identifiants OAuth.
**Proxy/API personnalisée**
```json
{
"model_name": "my-custom-model",
"model": "openai/custom-model",
"api_base": "https://my-proxy.com/v1",
"api_key": "sk-...",
"request_timeout": 300
}
```
#### Équilibrage de Charge #### Équilibrage de Charge
Configurez plusieurs points de terminaison pour le même nom de modèle—PicoClaw utilisera automatiquement le round-robin entre eux : Configurez plusieurs points de terminaison pour le même nom de modèle—PicoClaw utilisera automatiquement le round-robin entre eux :

View file

@ -8,7 +8,7 @@
<p> <p>
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go"> <img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware"> <img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License"> <img src="https://img.shields.io/badge/license-MIT-green" alt="License">
</p> </p>
@ -49,7 +49,7 @@
⚡️ **超高速**: 起動時間 400 倍高速、0.6GHz シングルコアでも 1 秒で起動。 ⚡️ **超高速**: 起動時間 400 倍高速、0.6GHz シングルコアでも 1 秒で起動。
🌍 **真のポータビリティ**: RISC-V、ARM、x86 対応の単一バイナリ。ワンクリックで Go 🌍 **真のポータビリティ**: RISC-V、ARM、MIPS、x86 対応の単一バイナリ。ワンクリックで Go
🤖 **AI ブートストラップ**: 自律的な Go ネイティブ実装 — コアの 95% が AI 生成、人間によるレビュー付き。 🤖 **AI ブートストラップ**: 自律的な Go ネイティブ実装 — コアの 95% が AI 生成、人間によるレビュー付き。
@ -126,39 +126,43 @@ Docker Compose を使えば、ローカルにインストールせずに PicoCla
git clone https://github.com/sipeed/picoclaw.git git clone https://github.com/sipeed/picoclaw.git
cd picoclaw cd picoclaw
# 2. API キーを設定 # 2. 初回起動 — docker/data/config.json を自動生成して終了
cp config/config.example.json config/config.json docker compose -f docker/docker-compose.yml --profile gateway up
vim config/config.json # DISCORD_BOT_TOKEN, プロバイダーの API キーを設定 # コンテナが "First-run setup complete." を表示して停止します。
# 3. ビルドと起動 # 3. API キーを設定
docker compose --profile gateway up -d vim docker/data/config.json # プロバイダー API キー、Bot トークンなどを設定
# 4. 起動
docker compose -f docker/docker-compose.yml --profile gateway up -d
```
> [!TIP] > [!TIP]
> **Docker ユーザー**: デフォルトでは、Gateway は `127.0.0.1` でリッスンしており、ホストからアクセスできません。ヘルスチェックエンドポイントにアクセスしたり、ポートを公開したりする必要がある場合は、環境変数で `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`config.json` を更新してください。 > **Docker ユーザー**: デフォルトでは、Gateway は `127.0.0.1` でリッスンしており、ホストからアクセスできません。ヘルスチェックエンドポイントにアクセスしたり、ポートを公開したりする必要がある場合は、環境変数で `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`config.json` を更新してください。
```bash
# 5. ログ確認
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
# 4. ログ確認 # 6. 停止
docker compose logs -f picoclaw-gateway docker compose -f docker/docker-compose.yml --profile gateway down
# 5. 停止
docker compose --profile gateway down
``` ```
### Agent モード(ワンショット) ### Agent モード(ワンショット)
```bash ```bash
# 質問を投げる # 質問を投げる
docker compose run --rm picoclaw-agent -m "What is 2+2?" docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?"
# インタラクティブモード # インタラクティブモード
docker compose run --rm picoclaw-agent docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
``` ```
### リビルド ### アップデート
```bash ```bash
docker compose --profile gateway build --no-cache docker compose -f docker/docker-compose.yml pull
docker compose --profile gateway up -d docker compose -f docker/docker-compose.yml --profile gateway up -d
``` ```
### 🚀 クイックスタート(ネイティブ) ### 🚀 クイックスタート(ネイティブ)
@ -183,12 +187,13 @@ picoclaw onboard
"model_name": "gpt4", "model_name": "gpt4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.2",
"api_key": "sk-your-openai-key", "api_key": "sk-your-openai-key",
"request_timeout": 300,
"api_base": "https://api.openai.com/v1" "api_base": "https://api.openai.com/v1"
} }
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"model": "gpt4" "model_name": "gpt4"
} }
}, },
"channels": { "channels": {
@ -221,6 +226,9 @@ picoclaw onboard
} }
``` ```
> **新機能**: `model_list` 形式により、プロバイダーをコード変更なしで追加できます。詳細は [モデル設定](#モデル設定-model_list) を参照してください。
> `request_timeout` は任意の秒単位設定です。省略または `<= 0` の場合、PicoClaw はデフォルトのタイムアウト120秒を使用します。
**3. API キーの取得** **3. API キーの取得**
- **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) - **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
@ -249,7 +257,7 @@ Telegram、Discord、QQ、DingTalk、LINE、WeCom で PicoClaw と会話でき
| **QQ** | 簡単AppID + AppSecret | | **QQ** | 簡単AppID + AppSecret |
| **DingTalk** | 普通(アプリ認証情報) | | **DingTalk** | 普通(アプリ認証情報) |
| **LINE** | 普通(認証情報 + Webhook URL | | **LINE** | 普通(認証情報 + Webhook URL |
| **WeCom** | 普通CorpID + Webhook設定 | | **WeCom AI Bot** | 普通Token + AES キー |
<details> <details>
<summary><b>Telegram</b>(推奨)</summary> <summary><b>Telegram</b>(推奨)</summary>
@ -413,8 +421,6 @@ picoclaw gateway
"enabled": true, "enabled": true,
"channel_secret": "YOUR_CHANNEL_SECRET", "channel_secret": "YOUR_CHANNEL_SECRET",
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
"webhook_host": "0.0.0.0",
"webhook_port": 18791,
"webhook_path": "/webhook/line", "webhook_path": "/webhook/line",
"allow_from": [] "allow_from": []
} }
@ -428,11 +434,13 @@ LINE の Webhook には HTTPS が必要です。リバースプロキシまた
```bash ```bash
# ngrok の例 # ngrok の例
ngrok http 18791 ngrok http 18790
``` ```
LINE Developers Console で Webhook URL を `https://あなたのドメイン/webhook/line` に設定し、**Webhook の利用** を有効にしてください。 LINE Developers Console で Webhook URL を `https://あなたのドメイン/webhook/line` に設定し、**Webhook の利用** を有効にしてください。
> **注意**: LINE の Webhook は共有の Gateway HTTP サーバー(デフォルト: `127.0.0.1:18790`)で提供されます。ホストからアクセスする場合は Gateway のポートを公開するか、リバースプロキシを設定してください。
**4. 起動** **4. 起動**
```bash ```bash
@ -441,19 +449,20 @@ picoclaw gateway
> グループチャットでは @メンション時のみ応答します。返信は元メッセージを引用する形式です。 > グループチャットでは @メンション時のみ応答します。返信は元メッセージを引用する形式です。
> **Docker Compose**: `picoclaw-gateway` サービスに `ports: ["18791:18791"]` を追加して Webhook ポートを公開してください。 > **Docker Compose**: Gateway HTTP サーバーは共有の `127.0.0.1:18790` で Webhook を提供します。ホストからアクセスするには `picoclaw-gateway` サービスに `ports: ["18790:18790"]` を追加してください。
</details> </details>
<details> <details>
<summary><b>WeCom (企業微信)</b></summary> <summary><b>WeCom (企業微信)</b></summary>
PicoClaw は2種類の WeCom 統合をサポートしています: PicoClaw は3種類の WeCom 統合をサポートしています:
**オプション1: WeCom Bot (智能ロボット)** - 簡単な設定、グループチャット対応 **オプション1: WeCom Bot (ロボット)** - 簡単な設定、グループチャット対応
**オプション2: WeCom App (自作アプリ)** - より多機能、アクティブメッセージング対応 **オプション2: WeCom App (カスタムアプリ)** - より多機能、アクティブメッセージング対応、プライベートチャットのみ
**オプション3: WeCom AI Bot (スマートボット)** - 公式 AI Bot、ストリーミング返信、グループ・プライベート両対応
詳細な設定手順は [WeCom App Configuration Guide](docs/wecom-app-configuration.md) を参照してください。 詳細な設定手順は [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) を参照してください。
**クイックセットアップ - WeCom Bot:** **クイックセットアップ - WeCom Bot:**
@ -472,13 +481,13 @@ PicoClaw は2種類の WeCom 統合をサポートしています:
"token": "YOUR_TOKEN", "token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_ENCODING_AES_KEY", "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
"webhook_host": "0.0.0.0",
"webhook_port": 18793,
"webhook_path": "/webhook/wecom", "webhook_path": "/webhook/wecom",
"allow_from": [] "allow_from": []
} }
} }
} }
> **注意**: WeCom Bot の Webhook 受信は共有の Gateway HTTP サーバー(デフォルト: `127.0.0.1:18790`)で提供されます。ホストからアクセスする場合は Gateway のポートを公開するか、HTTPS 用のリバースプロキシを設定してください。
``` ```
**クイックセットアップ - WeCom App:** **クイックセットアップ - WeCom App:**
@ -492,7 +501,7 @@ PicoClaw は2種類の WeCom 統合をサポートしています:
**2. メッセージ受信を設定** **2. メッセージ受信を設定**
* アプリ詳細で "メッセージを受信" → "APIを設定" をクリック * アプリ詳細で "メッセージを受信" → "APIを設定" をクリック
* URL を `http://your-server:18792/webhook/wecom-app` に設定 * URL を `http://your-server:18790/webhook/wecom-app` に設定
* **Token****EncodingAESKey** を生成 * **Token****EncodingAESKey** を生成
**3. 設定** **3. 設定**
@ -507,8 +516,6 @@ PicoClaw は2種類の WeCom 統合をサポートしています:
"agent_id": 1000002, "agent_id": 1000002,
"token": "YOUR_TOKEN", "token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_ENCODING_AES_KEY", "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
"webhook_host": "0.0.0.0",
"webhook_port": 18792,
"webhook_path": "/webhook/wecom-app", "webhook_path": "/webhook/wecom-app",
"allow_from": [] "allow_from": []
} }
@ -522,7 +529,40 @@ PicoClaw は2種類の WeCom 統合をサポートしています:
picoclaw gateway picoclaw gateway
``` ```
> **注意**: WeCom App は Webhook コールバック用にポート 18792 を開放する必要があります。本番環境では HTTPS 用のリバースプロキシを使用してください。 > **注意**: WeCom App の Webhook コールバックは共有の Gateway HTTP サーバー(デフォルト: `127.0.0.1:18790`)で提供されます。ホストからアクセスする場合は HTTPS 用のリバースプロキシを設定してください。
**クイックセットアップ - WeCom AI Bot:**
**1. AI Bot を作成**
* WeCom 管理コンソール → アプリ管理 → AI Bot
* コールバック URL を設定: `http://your-server:18791/webhook/wecom-aibot`
* **Token** をコピーし、**EncodingAESKey** を生成
**2. 設定**
```json
{
"channels": {
"wecom_aibot": {
"enabled": true,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [],
"welcome_message": "こんにちは!何かお手伝いできますか?"
}
}
}
```
**3. 起動**
```bash
picoclaw gateway
```
> **注意**: WeCom AI Bot はストリーミングプルプロトコルを使用 — 返信タイムアウトの心配なし。長時間タスク(>30秒は自動的に `response_url` によるプッシュ配信に切り替わります。
</details> </details>
@ -530,6 +570,31 @@ picoclaw gateway
設定ファイル: `~/.picoclaw/config.json` 設定ファイル: `~/.picoclaw/config.json`
### 環境変数
環境変数を使用してデフォルトのパスを上書きできます。これは、ポータブルインストール、コンテナ化されたデプロイメント、または picoclaw をシステムサービスとして実行する場合に便利です。これらの変数は独立しており、異なるパスを制御します。
| 変数 | 説明 | デフォルトパス |
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
| `PICOCLAW_CONFIG` | 設定ファイルへのパスを上書きします。これにより、picoclaw は他のすべての場所を無視して、指定された `config.json` をロードします。 | `~/.picoclaw/config.json` |
| `PICOCLAW_HOME` | picoclaw データのルートディレクトリを上書きします。これにより、`workspace` やその他のデータディレクトリのデフォルトの場所が変更されます。 | `~/.picoclaw` |
**例:**
```bash
# 特定の設定ファイルを使用して picoclaw を実行する
# ワークスペースのパスはその設定ファイル内から読み込まれます
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
# すべてのデータを /opt/picoclaw に保存して picoclaw を実行する
# 設定はデフォルトの ~/.picoclaw/config.json からロードされます
# ワークスペースは /opt/picoclaw/workspace に作成されます
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
# 両方を使用して完全にカスタマイズされたセットアップを行う
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
```
### ワークスペース構成 ### ワークスペース構成
PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw/workspace`)にデータを保存します: PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw/workspace`)にデータを保存します:
@ -720,7 +785,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
### プロバイダー ### プロバイダー
> [!NOTE] > [!NOTE]
> Groq は Whisper による無料の音声文字起こしを提供しています。設定すると、Telegram の音声メッセージが自動的に文字起こしされます。 > Groq は Whisper による無料の音声文字起こしを提供しています。設定すると、あらゆるチャンネルからの音声メッセージがエージェントレベルで自動的に文字起こしされます。
| プロバイダー | 用途 | API キー取得先 | | プロバイダー | 用途 | API キー取得先 |
| --- | --- | --- | | --- | --- | --- |
@ -918,6 +983,17 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
``` ```
> OAuth認証を設定するには、`picoclaw auth login --provider anthropic` を実行してください。 > OAuth認証を設定するには、`picoclaw auth login --provider anthropic` を実行してください。
**カスタムプロキシ/API**
```json
{
"model_name": "my-custom-model",
"model": "openai/custom-model",
"api_base": "https://my-proxy.com/v1",
"api_key": "sk-...",
"request_timeout": 300
}
```
#### ロードバランシング #### ロードバランシング
同じモデル名で複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンで分散します: 同じモデル名で複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンで分散します:

409
README.md
View file

@ -7,11 +7,14 @@
<p> <p>
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go"> <img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware"> <img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License"> <img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<br> <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://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a> <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>
<br>
<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> </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) | **English** [中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English**
@ -51,7 +54,7 @@
## 📢 News ## 📢 News
2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](docs/ROADMAP.md) —we cant wait to have you on board! 2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](ROADMAP.md) —we cant wait to have you on board!
2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs & issues coming in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development. 2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs & issues coming in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting. 🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting.
@ -66,7 +69,7 @@
⚡️ **Lightning Fast**: 400X Faster startup time, boot in 1 second even in 0.6GHz single core. ⚡️ **Lightning Fast**: 400X Faster startup time, boot in 1 second even in 0.6GHz single core.
🌍 **True Portability**: Single self-contained binary across RISC-V, ARM, and x86, One-click to Go! 🌍 **True Portability**: Single self-contained binary across RISC-V, ARM, MIPS, and x86, One-click to Go!
🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement. 🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement.
@ -151,10 +154,15 @@ make build
# Build for multiple platforms # Build for multiple platforms
make build-all 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 # Build And Install
make install make install
``` ```
**Raspberry Pi Zero 2 W:** Use the binary that matches your OS: 32-bit Raspberry Pi OS → `make build-linux-arm` (output: `build/picoclaw-linux-arm`); 64-bit → `make build-linux-arm64` (output: `build/picoclaw-linux-arm64`). Or run `make build-pi-zero` to build both.
## 🐳 Docker Compose ## 🐳 Docker Compose
You can also run PicoClaw using Docker Compose without installing anything locally. You can also run PicoClaw using Docker Compose without installing anything locally.
@ -164,39 +172,43 @@ You can also run PicoClaw using Docker Compose without installing anything local
git clone https://github.com/sipeed/picoclaw.git git clone https://github.com/sipeed/picoclaw.git
cd picoclaw cd picoclaw
# 2. Set your API keys # 2. First run — auto-generates docker/data/config.json then exits
cp config/config.example.json config/config.json docker compose -f docker/docker-compose.yml --profile gateway up
vim config/config.json # Set DISCORD_BOT_TOKEN, API keys, etc. # The container prints "First-run setup complete." and stops.
# 3. Build & Start # 3. Set your API keys
docker compose --profile gateway up -d vim docker/data/config.json # Set provider API keys, bot tokens, etc.
# 4. Start
docker compose -f docker/docker-compose.yml --profile gateway up -d
```
> [!TIP] > [!TIP]
> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`. > **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`.
```bash
# 5. Check logs
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
# 4. Check logs # 6. Stop
docker compose logs -f picoclaw-gateway docker compose -f docker/docker-compose.yml --profile gateway down
# 5. Stop
docker compose --profile gateway down
``` ```
### Agent Mode (One-shot) ### Agent Mode (One-shot)
```bash ```bash
# Ask a question # Ask a question
docker compose run --rm picoclaw-agent -m "What is 2+2?" docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?"
# Interactive mode # Interactive mode
docker compose run --rm picoclaw-agent docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
``` ```
### Rebuild ### Update
```bash ```bash
docker compose --profile gateway build --no-cache docker compose -f docker/docker-compose.yml pull
docker compose --profile gateway up -d docker compose -f docker/docker-compose.yml --profile gateway up -d
``` ```
### 🚀 Quick Start ### 🚀 Quick Start
@ -204,7 +216,7 @@ docker compose --profile gateway up -d
> [!TIP] > [!TIP]
> Set your API key in `~/.picoclaw/config.json`. > Set your API key in `~/.picoclaw/config.json`.
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) > Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
> Web Search is **optional** - get free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback. > Web Search is **optional** - get free [Tavily API](https://tavily.com) (1000 free queries/month), [SearXNG](https://github.com/searxng/searxng) (free, self-hosted) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
**1. Initialize** **1. Initialize**
@ -219,7 +231,7 @@ picoclaw onboard
"agents": { "agents": {
"defaults": { "defaults": {
"workspace": "~/.picoclaw/workspace", "workspace": "~/.picoclaw/workspace",
"model": "gpt4", "model_name": "gpt4",
"max_tokens": 8192, "max_tokens": 8192,
"temperature": 0.7, "temperature": 0.7,
"max_tool_iterations": 20 "max_tool_iterations": 20
@ -229,7 +241,8 @@ picoclaw onboard
{ {
"model_name": "gpt4", "model_name": "gpt4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.2",
"api_key": "your-api-key" "api_key": "your-api-key",
"request_timeout": 300
}, },
{ {
"model_name": "claude-sonnet-4.6", "model_name": "claude-sonnet-4.6",
@ -252,6 +265,16 @@ picoclaw onboard
"duckduckgo": { "duckduckgo": {
"enabled": true, "enabled": true,
"max_results": 5 "max_results": 5
},
"perplexity": {
"enabled": false,
"api_key": "YOUR_PERPLEXITY_API_KEY",
"max_results": 5
},
"searxng": {
"enabled": false,
"base_url": "http://your-searxng-instance:8888",
"max_results": 5
} }
} }
} }
@ -259,11 +282,17 @@ picoclaw onboard
``` ```
> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details. > **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
> `request_timeout` is optional and uses seconds. If omitted or set to `<= 0`, PicoClaw uses the default timeout (120s).
**3. Get API Keys** **3. Get API Keys**
* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) * **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
* **Web Search** (optional): [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month) · [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month) * **Web Search** (optional):
* [Brave Search](https://brave.com/search/api) - Paid ($5/1000 queries, ~$5-6/month)
* [Perplexity](https://www.perplexity.ai) - AI-powered search with chat interface
* [SearXNG](https://github.com/searxng/searxng) - Self-hosted metasearch engine (free, no API key needed)
* [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month)
* DuckDuckGo - Built-in fallback (no API key required)
> **Note**: See `config.example.json` for a complete configuration template. > **Note**: See `config.example.json` for a complete configuration template.
@ -279,16 +308,20 @@ That's it! You have a working AI assistant in 2 minutes.
## 💬 Chat Apps ## 💬 Chat Apps
Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, or WeCom Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, or WeCom
> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server.
| Channel | Setup | | Channel | Setup |
| ------------ | ---------------------------------- | | ------------ | ---------------------------------- |
| **Telegram** | Easy (just a token) | | **Telegram** | Easy (just a token) |
| **Discord** | Easy (bot token + intents) | | **Discord** | Easy (bot token + intents) |
| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
| **Matrix** | Medium (homeserver + bot access token) |
| **QQ** | Easy (AppID + AppSecret) | | **QQ** | Easy (AppID + AppSecret) |
| **DingTalk** | Medium (app credentials) | | **DingTalk** | Medium (app credentials) |
| **LINE** | Medium (credentials + webhook URL) | | **LINE** | Medium (credentials + webhook URL) |
| **WeCom** | Medium (CorpID + webhook setup) | | **WeCom AI Bot** | Medium (Token + AES key) |
<details> <details>
<summary><b>Telegram</b> (Recommended)</summary> <summary><b>Telegram</b> (Recommended)</summary>
@ -321,6 +354,13 @@ Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, or WeCom
picoclaw gateway picoclaw gateway
``` ```
**4. Telegram command menu (auto-registered at startup)**
PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`) so command menu and runtime behavior stay in sync.
Telegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor.
If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background.
</details> </details>
<details> <details>
@ -349,8 +389,7 @@ picoclaw gateway
"discord": { "discord": {
"enabled": true, "enabled": true,
"token": "YOUR_BOT_TOKEN", "token": "YOUR_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"], "allow_from": ["YOUR_USER_ID"]
"mention_only": false
} }
} }
} }
@ -363,9 +402,31 @@ picoclaw gateway
* Bot Permissions: `Send Messages`, `Read Message History` * Bot Permissions: `Send Messages`, `Read Message History`
* Open the generated invite URL and add the bot to your server * Open the generated invite URL and add the bot to your server
**Optional: Mention-only mode** **Optional: Group trigger mode**
Set `"mention_only": true` to make the bot respond only when @-mentioned. Useful for shared servers where you want the bot to respond only when explicitly called. By default the bot responds to all messages in a server channel. To restrict responses to @-mentions only, add:
```json
{
"channels": {
"discord": {
"group_trigger": { "mention_only": true }
}
}
}
```
You can also trigger by keyword prefixes (e.g. `!bot`):
```json
{
"channels": {
"discord": {
"group_trigger": { "prefixes": ["!bot"] }
}
}
}
```
**6. Run** **6. Run**
@ -375,6 +436,33 @@ picoclaw gateway
</details> </details>
<details>
<summary><b>WhatsApp</b> (native via whatsmeow)</summary>
PicoClaw can connect to WhatsApp in two ways:
- **Native (recommended):** In-process using [whatsmeow](https://github.com/tulir/whatsmeow). No separate bridge. Set `"use_native": true` and leave `bridge_url` empty. On first run, scan the QR code with WhatsApp (Linked Devices). Session is stored under your workspace (e.g. `workspace/whatsapp/`). The native channel is **optional** to keep the default binary small; build with `-tags whatsapp_native` (e.g. `make build-whatsapp-native` or `go build -tags whatsapp_native ./cmd/...`).
- **Bridge:** Connect to an external WebSocket bridge. Set `bridge_url` (e.g. `ws://localhost:3001`) and keep `use_native` false.
**Configure (native)**
```json
{
"channels": {
"whatsapp": {
"enabled": true,
"use_native": true,
"session_store_path": "",
"allow_from": []
}
}
}
```
If `session_store_path` is empty, the session is stored in `&lt;workspace&gt;/whatsapp/`. Run `picoclaw gateway`; on first run, scan the QR code printed in the terminal with WhatsApp → Linked Devices.
</details>
<details> <details>
<summary><b>QQ</b></summary> <summary><b>QQ</b></summary>
@ -441,6 +529,40 @@ picoclaw gateway
``` ```
</details> </details>
<details>
<summary><b>Matrix</b></summary>
**1. Prepare bot account**
* Use your preferred homeserver (e.g. `https://matrix.org` or self-hosted)
* Create a bot user and obtain its access token
**2. Configure**
```json
{
"channels": {
"matrix": {
"enabled": true,
"homeserver": "https://matrix.org",
"user_id": "@your-bot:matrix.org",
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
"allow_from": []
}
}
}
```
**3. Run**
```bash
picoclaw gateway
```
For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](docs/channels/matrix/README.md).
</details>
<details> <details>
<summary><b>LINE</b></summary> <summary><b>LINE</b></summary>
@ -459,8 +581,6 @@ picoclaw gateway
"enabled": true, "enabled": true,
"channel_secret": "YOUR_CHANNEL_SECRET", "channel_secret": "YOUR_CHANNEL_SECRET",
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
"webhook_host": "0.0.0.0",
"webhook_port": 18791,
"webhook_path": "/webhook/line", "webhook_path": "/webhook/line",
"allow_from": [] "allow_from": []
} }
@ -468,13 +588,15 @@ picoclaw gateway
} }
``` ```
> LINE webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`).
**3. Set up Webhook URL** **3. Set up Webhook URL**
LINE requires HTTPS for webhooks. Use a reverse proxy or tunnel: LINE requires HTTPS for webhooks. Use a reverse proxy or tunnel:
```bash ```bash
# Example with ngrok # Example with ngrok (gateway default port is 18790)
ngrok http 18791 ngrok http 18790
``` ```
Then set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**. Then set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**.
@ -487,19 +609,18 @@ picoclaw gateway
> In group chats, the bot responds only when @mentioned. Replies quote the original message. > In group chats, the bot responds only when @mentioned. Replies quote the original message.
> **Docker Compose**: Add `ports: ["18791:18791"]` to the `picoclaw-gateway` service to expose the webhook port.
</details> </details>
<details> <details>
<summary><b>WeCom (企业微信)</b></summary> <summary><b>WeCom (企业微信)</b></summary>
PicoClaw supports two types of WeCom integration: PicoClaw supports three types of WeCom integration:
**Option 1: WeCom Bot (智能机器人)** - Easier setup, supports group chats **Option 1: WeCom Bot (Bot)** - Easier setup, supports group chats
**Option 2: WeCom App (自建应用)** - More features, proactive messaging **Option 2: WeCom App (Custom App)** - More features, proactive messaging, private chat only
**Option 3: WeCom AI Bot (AI Bot)** - Official AI Bot, streaming replies, supports group & private chat
See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detailed setup instructions. See [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) for detailed setup instructions.
**Quick Setup - WeCom Bot:** **Quick Setup - WeCom Bot:**
@ -518,8 +639,6 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
"token": "YOUR_TOKEN", "token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_ENCODING_AES_KEY", "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
"webhook_host": "0.0.0.0",
"webhook_port": 18793,
"webhook_path": "/webhook/wecom", "webhook_path": "/webhook/wecom",
"allow_from": [] "allow_from": []
} }
@ -527,6 +646,8 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
} }
``` ```
> WeCom webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`).
**Quick Setup - WeCom App:** **Quick Setup - WeCom App:**
**1. Create an app** **1. Create an app**
@ -534,10 +655,11 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
* Go to WeCom Admin Console → App Management → Create App * Go to WeCom Admin Console → App Management → Create App
* Copy **AgentId** and **Secret** * Copy **AgentId** and **Secret**
* Go to "My Company" page, copy **CorpID** * Go to "My Company" page, copy **CorpID**
**2. Configure receive message** **2. Configure receive message**
* In App details, click "Receive Message" → "Set API" * In App details, click "Receive Message" → "Set API"
* Set URL to `http://your-server:18792/webhook/wecom-app` * Set URL to `http://your-server:18790/webhook/wecom-app`
* Generate **Token** and **EncodingAESKey** * Generate **Token** and **EncodingAESKey**
**3. Configure** **3. Configure**
@ -552,8 +674,6 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
"agent_id": 1000002, "agent_id": 1000002,
"token": "YOUR_TOKEN", "token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_ENCODING_AES_KEY", "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
"webhook_host": "0.0.0.0",
"webhook_port": 18792,
"webhook_path": "/webhook/wecom-app", "webhook_path": "/webhook/wecom-app",
"allow_from": [] "allow_from": []
} }
@ -567,7 +687,40 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
picoclaw gateway picoclaw gateway
``` ```
> **Note**: WeCom App requires opening port 18792 for webhook callbacks. Use a reverse proxy for HTTPS. > **Note**: WeCom webhook callbacks are served on the Gateway port (default 18790). Use a reverse proxy for HTTPS.
**Quick Setup - WeCom AI Bot:**
**1. Create an AI Bot**
* Go to WeCom Admin Console → App Management → AI Bot
* In the AI Bot settings, configure callback URL: `http://your-server:18791/webhook/wecom-aibot`
* Copy **Token** and click "Random Generate" for **EncodingAESKey**
**2. Configure**
```json
{
"channels": {
"wecom_aibot": {
"enabled": true,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [],
"welcome_message": "Hello! How can I help you?"
}
}
}
```
**3. Run**
```bash
picoclaw gateway
```
> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>30 seconds) automatically switch to `response_url` push delivery.
</details> </details>
@ -581,6 +734,31 @@ Connect Picoclaw to the Agent Social Network simply by sending a single message
Config file: `~/.picoclaw/config.json` Config file: `~/.picoclaw/config.json`
### Environment Variables
You can override default paths using environment variables. This is useful for portable installations, containerized deployments, or running picoclaw as a system service. These variables are independent and control different paths.
| Variable | Description | Default Path |
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
| `PICOCLAW_CONFIG` | Overrides the path to the configuration file. This directly tells picoclaw which `config.json` to load, ignoring all other locations. | `~/.picoclaw/config.json` |
| `PICOCLAW_HOME` | Overrides the root directory for picoclaw data. This changes the default location of the `workspace` and other data directories. | `~/.picoclaw` |
**Examples:**
```bash
# Run picoclaw using a specific config file
# The workspace path will be read from within that config file
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
# Run picoclaw with all its data stored in /opt/picoclaw
# Config will be loaded from the default ~/.picoclaw/config.json
# Workspace will be created at /opt/picoclaw/workspace
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
# Use both for a fully customized setup
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
```
### Workspace Layout ### Workspace Layout
PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspace`): PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspace`):
@ -600,6 +778,26 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
└── USER.md # User preferences └── USER.md # User preferences
``` ```
### Skill Sources
By default, skills are loaded from:
1. `~/.picoclaw/workspace/skills` (workspace)
2. `~/.picoclaw/skills` (global)
3. `<current-working-directory>/skills` (builtin)
For advanced/test setups, you can override the builtin skills root with:
```bash
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
```
### Unified Command Execution Policy
- Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`.
- Channel adapters no longer consume generic commands locally; they forward inbound text to the bus/agent path. Telegram still auto-registers supported commands at startup.
- Unknown slash command (for example `/foo`) passes through to normal LLM processing.
- Registered but unsupported command on the current channel (for example `/show` on WhatsApp) returns an explicit user-facing error and stops further processing.
### 🔒 Security Sandbox ### 🔒 Security Sandbox
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace. PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.
@ -776,7 +974,7 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
### Providers ### Providers
> [!NOTE] > [!NOTE]
> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed. > Groq provides free voice transcription via Whisper. If configured, audio messages from any channel will be automatically transcribed at the agent level.
| Provider | Purpose | Get API Key | | Provider | Purpose | Get API Key |
| -------------------------- | --------------------------------------- | -------------------------------------------------------------------- | | -------------------------- | --------------------------------------- | -------------------------------------------------------------------- |
@ -789,6 +987,7 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | | `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) | | `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
| `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) |
### Model Configuration (model_list) ### Model Configuration (model_list)
@ -804,7 +1003,7 @@ This design also enables **multi-agent support** with flexible provider selectio
#### 📋 All Supported Vendors #### 📋 All Supported Vendors
| Vendor | `model` Prefix | Default API Base | Protocol | API Key | | Vendor | `model` Prefix | Default API Base | Protocol | API Key |
| ------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- | | ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- |
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | | **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
@ -816,10 +1015,12 @@ This design also enables **multi-agent support** with flexible provider selectio
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) | | **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only | | **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@ -912,10 +1113,24 @@ This design also enables **multi-agent support** with flexible provider selectio
"model_name": "my-custom-model", "model_name": "my-custom-model",
"model": "openai/custom-model", "model": "openai/custom-model",
"api_base": "https://my-proxy.com/v1", "api_base": "https://my-proxy.com/v1",
"api_key": "sk-...",
"request_timeout": 300
}
```
**LiteLLM Proxy**
```json
{
"model_name": "lite-gpt4",
"model": "litellm/lite-gpt4",
"api_base": "http://localhost:4000/v1",
"api_key": "sk-..." "api_key": "sk-..."
} }
``` ```
PicoClaw strips only the outer `litellm/` prefix before sending the request, so proxy aliases like `litellm/lite-gpt4` send `lite-gpt4`, while `litellm/openai/gpt-4o` sends `openai/gpt-4o`.
#### Load Balancing #### Load Balancing
Configure multiple endpoints for the same model name—PicoClaw will automatically round-robin between them: Configure multiple endpoints for the same model name—PicoClaw will automatically round-robin between them:
@ -1040,6 +1255,10 @@ picoclaw agent -m "Hello"
"model": "anthropic/claude-opus-4-5" "model": "anthropic/claude-opus-4-5"
} }
}, },
"session": {
"dm_scope": "per-channel-peer",
"backlog_limit": 20
},
"providers": { "providers": {
"openrouter": { "openrouter": {
"api_key": "sk-or-v1-xxx" "api_key": "sk-or-v1-xxx"
@ -1060,7 +1279,11 @@ picoclaw agent -m "Hello"
"allow_from": [""] "allow_from": [""]
}, },
"whatsapp": { "whatsapp": {
"enabled": false "enabled": false,
"bridge_url": "ws://localhost:3001",
"use_native": false,
"session_store_path": "",
"allow_from": []
}, },
"feishu": { "feishu": {
"enabled": false, "enabled": false,
@ -1087,6 +1310,16 @@ picoclaw agent -m "Hello"
"duckduckgo": { "duckduckgo": {
"enabled": true, "enabled": true,
"max_results": 5 "max_results": 5
},
"perplexity": {
"enabled": false,
"api_key": "",
"max_results": 5
},
"searxng": {
"enabled": false,
"base_url": "http://localhost:8888",
"max_results": 5
} }
}, },
"cron": { "cron": {
@ -1140,14 +1373,73 @@ discord: <https://discord.gg/V4sAZ9XWpN>
## 🐛 Troubleshooting ## 🐛 Troubleshooting
### Web search says "API 配置问题" ### Web search says "API key configuration issue"
This is normal if you haven't configured a search API key yet. PicoClaw will provide helpful links for manual searching. This is normal if you haven't configured a search API key yet. PicoClaw will provide helpful links for manual searching.
To enable web search: #### Search Provider Priority
1. **Option 1 (Recommended)**: Get a free API key at [https://brave.com/search/api](https://brave.com/search/api) (2000 free queries/month) for the best results. PicoClaw automatically selects the best available search provider in this order:
2. **Option 2 (No Credit Card)**: If you don't have a key, we automatically fall back to **DuckDuckGo** (no key required). 1. **Perplexity** (if enabled and API key configured) - AI-powered search with citations
2. **Brave Search** (if enabled and API key configured) - Privacy-focused paid API ($5/1000 queries)
3. **SearXNG** (if enabled and base_url configured) - Self-hosted metasearch aggregating 70+ engines (free)
4. **DuckDuckGo** (if enabled, default fallback) - No API key required (free)
#### Web Search Configuration Options
**Option 1 (Best Results)**: Perplexity AI Search
```json
{
"tools": {
"web": {
"perplexity": {
"enabled": true,
"api_key": "YOUR_PERPLEXITY_API_KEY",
"max_results": 5
}
}
}
}
```
**Option 2 (Paid API)**: Get an API key at [https://brave.com/search/api](https://brave.com/search/api) ($5/1000 queries, ~$5-6/month)
```json
{
"tools": {
"web": {
"brave": {
"enabled": true,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
}
}
}
}
```
**Option 3 (Self-Hosted)**: Deploy your own [SearXNG](https://github.com/searxng/searxng) instance
```json
{
"tools": {
"web": {
"searxng": {
"enabled": true,
"base_url": "http://your-server:8888",
"max_results": 5
}
}
}
}
```
Benefits of SearXNG:
- **Zero cost**: No API fees or rate limits
- **Privacy-focused**: Self-hosted, no tracking
- **Aggregate results**: Queries 70+ search engines simultaneously
- **Perfect for cloud VMs**: Solves datacenter IP blocking issues (Oracle Cloud, GCP, AWS, Azure)
- **No API key needed**: Just deploy and configure the base URL
**Option 4 (No Setup Required)**: DuckDuckGo is enabled by default as fallback (no API key needed)
Add the key to `~/.picoclaw/config.json` if using Brave: Add the key to `~/.picoclaw/config.json` if using Brave:
@ -1163,6 +1455,16 @@ Add the key to `~/.picoclaw/config.json` if using Brave:
"duckduckgo": { "duckduckgo": {
"enabled": true, "enabled": true,
"max_results": 5 "max_results": 5
},
"perplexity": {
"enabled": false,
"api_key": "YOUR_PERPLEXITY_API_KEY",
"max_results": 5
},
"searxng": {
"enabled": false,
"base_url": "http://your-searxng-instance:8888",
"max_results": 5
} }
} }
} }
@ -1182,9 +1484,10 @@ This happens when another instance of the bot is running. Make sure only one `pi
## 📝 API Key Comparison ## 📝 API Key Comparison
| Service | Free Tier | Use Case | | Service | Free Tier | Use Case |
| ---------------- | ------------------- | ------------------------------------- | | ---------------- | ------------------------ | ------------------------------------- |
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) | | **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
| **Zhipu** | 200K tokens/month | Best for Chinese users | | **Zhipu** | 200K tokens/month | Best for Chinese users |
| **Brave Search** | 2000 queries/month | Web search functionality | | **Brave Search** | Paid ($5/1000 queries) | Web search functionality |
| **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) |
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) | | **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) | | **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |

View file

@ -7,7 +7,7 @@
<p> <p>
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go"> <img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware"> <img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License"> <img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<br> <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://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
@ -66,7 +66,7 @@
⚡️ **Inicialização Relámpago**: Tempo de inicialização 400X mais rápido, boot em 1 segundo mesmo em CPU single-core de 0.6GHz. ⚡️ **Inicialização Relámpago**: Tempo de inicialização 400X mais rápido, boot em 1 segundo mesmo em CPU single-core de 0.6GHz.
🌍 **Portabilidade Real**: Um único binário auto-contido para RISC-V, ARM e x86. Um clique e já era! 🌍 **Portabilidade Real**: Um único binário auto-contido para RISC-V, ARM, MIPS e x86. Um clique e já era!
🤖 **Auto-Construído por IA**: Implementação nativa em Go de forma autônoma — 95% do núcleo gerado pelo Agente com refinamento humano no loop. 🤖 **Auto-Construído por IA**: Implementação nativa em Go de forma autônoma — 95% do núcleo gerado pelo Agente com refinamento humano no loop.
@ -165,39 +165,43 @@ Você tambêm pode rodar o PicoClaw usando Docker Compose sem instalar nada loca
git clone https://github.com/sipeed/picoclaw.git git clone https://github.com/sipeed/picoclaw.git
cd picoclaw cd picoclaw
# 2. Configure suas API keys # 2. Primeiro uso — gera docker/data/config.json automaticamente e para
cp config/config.example.json config/config.json docker compose -f docker/docker-compose.yml --profile gateway up
vim config/config.json # Configure DISCORD_BOT_TOKEN, API keys, etc. # O contêiner exibe "First-run setup complete." e para.
# 3. Build & Iniciar # 3. Configure suas API keys
docker compose --profile gateway up -d vim docker/data/config.json # Chaves de API do provedor, tokens de bot, etc.
# 4. Iniciar
docker compose -f docker/docker-compose.yml --profile gateway up -d
```
> [!TIP] > [!TIP]
> **Usuários Docker**: Por padrão, o Gateway ouve em `127.0.0.1`, o que não é acessível a partir do host. Se você precisar acessar os endpoints de integridade ou expor portas, defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` em seu ambiente ou atualize o `config.json`. > **Usuários Docker**: Por padrão, o Gateway ouve em `127.0.0.1`, o que não é acessível a partir do host. Se você precisar acessar os endpoints de integridade ou expor portas, defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` em seu ambiente ou atualize o `config.json`.
```bash
# 5. Ver logs
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
# 4. Ver logs # 6. Parar
docker compose logs -f picoclaw-gateway docker compose -f docker/docker-compose.yml --profile gateway down
# 5. Parar
docker compose --profile gateway down
``` ```
### Modo Agente (Execução única) ### Modo Agente (Execução única)
```bash ```bash
# Fazer uma pergunta # Fazer uma pergunta
docker compose run --rm picoclaw-agent -m "Quanto e 2+2?" docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Quanto e 2+2?"
# Modo interativo # Modo interativo
docker compose run --rm picoclaw-agent docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
``` ```
### Rebuild ### Atualizar
```bash ```bash
docker compose --profile gateway build --no-cache docker compose -f docker/docker-compose.yml pull
docker compose --profile gateway up -d docker compose -f docker/docker-compose.yml --profile gateway up -d
``` ```
### 🚀 Início Rápido ### 🚀 Início Rápido
@ -222,12 +226,13 @@ picoclaw onboard
"model_name": "gpt4", "model_name": "gpt4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.2",
"api_key": "sk-your-openai-key", "api_key": "sk-your-openai-key",
"request_timeout": 300,
"api_base": "https://api.openai.com/v1" "api_base": "https://api.openai.com/v1"
} }
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"model": "gpt4" "model_name": "gpt4"
} }
}, },
"tools": { "tools": {
@ -246,6 +251,9 @@ picoclaw onboard
} }
``` ```
> **Novo**: O formato de configuração `model_list` permite adicionar provedores sem alterar código. Veja [Configuração de Modelo](#configuração-de-modelo-model_list) para detalhes.
> `request_timeout` é opcional e usa segundos. Se omitido ou definido como `<= 0`, o PicoClaw usa o timeout padrão (120s).
**3. Obter API Keys** **3. Obter API Keys**
* **Provedor de LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) * **Provedor de LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
@ -274,7 +282,7 @@ Converse com seu PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom.
| **QQ** | Fácil (AppID + AppSecret) | | **QQ** | Fácil (AppID + AppSecret) |
| **DingTalk** | Médio (credenciais do app) | | **DingTalk** | Médio (credenciais do app) |
| **LINE** | Médio (credenciais + webhook URL) | | **LINE** | Médio (credenciais + webhook URL) |
| **WeCom** | Médio (CorpID + configuração webhook) | | **WeCom AI Bot** | Médio (Token + chave AES) |
<details> <details>
<summary><b>Telegram</b> (Recomendado)</summary> <summary><b>Telegram</b> (Recomendado)</summary>
@ -442,8 +450,6 @@ picoclaw gateway
"enabled": true, "enabled": true,
"channel_secret": "YOUR_CHANNEL_SECRET", "channel_secret": "YOUR_CHANNEL_SECRET",
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
"webhook_host": "0.0.0.0",
"webhook_port": 18791,
"webhook_path": "/webhook/line", "webhook_path": "/webhook/line",
"allow_from": [] "allow_from": []
} }
@ -457,11 +463,13 @@ O LINE requer HTTPS para webhooks. Use um reverse proxy ou tunnel:
```bash ```bash
# Exemplo com ngrok # Exemplo com ngrok
ngrok http 18791 ngrok http 18790
``` ```
Em seguida, configure a Webhook URL no LINE Developers Console para `https://seu-dominio/webhook/line` e habilite **Use webhook**. Em seguida, configure a Webhook URL no LINE Developers Console para `https://seu-dominio/webhook/line` e habilite **Use webhook**.
> **Nota**: O webhook do LINE é servido pelo Gateway compartilhado (padrão 127.0.0.1:18790). Use um proxy reverso/HTTPS ou túnel (como ngrok) para expor o Gateway de forma segura quando necessário.
**4. Executar** **4. Executar**
```bash ```bash
@ -470,19 +478,20 @@ picoclaw gateway
> Em chats de grupo, o bot responde apenas quando mencionado com @. As respostas citam a mensagem original. > Em chats de grupo, o bot responde apenas quando mencionado com @. As respostas citam a mensagem original.
> **Docker Compose**: Adicione `ports: ["18791:18791"]` ao serviço `picoclaw-gateway` para expor a porta do webhook. > **Docker Compose**: Se você usa Docker Compose, exponha o Gateway (padrão 127.0.0.1:18790) se precisar acessar o webhook LINE externamente, por exemplo `ports: ["18790:18790"]`.
</details> </details>
<details> <details>
<summary><b>WeCom (WeChat Work)</b></summary> <summary><b>WeCom (WeChat Work)</b></summary>
O PicoClaw suporta dois tipos de integração WeCom: O PicoClaw suporta três tipos de integração WeCom:
**Opção 1: WeCom Bot (Robô Inteligente)** - Configuração mais fácil, suporta chats em grupo **Opção 1: WeCom Bot (Robô)** - Configuração mais fácil, suporta chats em grupo
**Opção 2: WeCom App (Aplicativo Personalizado)** - Mais recursos, mensagens proativas **Opção 2: WeCom App (Aplicativo Personalizado)** - Mais recursos, mensagens proativas, somente chat privado
**Opção 3: WeCom AI Bot (Robô Inteligente)** - Bot IA oficial, respostas em streaming, suporta grupo e privado
Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para instruções detalhadas. Veja o [Guia de Configuração WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) para instruções detalhadas.
**Configuração Rápida - WeCom Bot:** **Configuração Rápida - WeCom Bot:**
@ -501,8 +510,6 @@ Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para
"token": "YOUR_TOKEN", "token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_ENCODING_AES_KEY", "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
"webhook_host": "0.0.0.0",
"webhook_port": 18793,
"webhook_path": "/webhook/wecom", "webhook_path": "/webhook/wecom",
"allow_from": [] "allow_from": []
} }
@ -510,6 +517,8 @@ Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para
} }
``` ```
> **Nota**: O webhook do WeCom Bot é atendido pelo Gateway compartilhado (padrão 127.0.0.1:18790). Use um proxy reverso/HTTPS ou túnel para expor o Gateway em produção.
**Configuração Rápida - WeCom App:** **Configuração Rápida - WeCom App:**
**1. Criar um aplicativo** **1. Criar um aplicativo**
@ -521,7 +530,7 @@ Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para
**2. Configurar recebimento de mensagens** **2. Configurar recebimento de mensagens**
* Nos detalhes do aplicativo, clique em "Receber Mensagens" → "Configurar API" * Nos detalhes do aplicativo, clique em "Receber Mensagens" → "Configurar API"
* Defina a URL como `http://your-server:18792/webhook/wecom-app` * Defina a URL como `http://your-server:18790/webhook/wecom-app`
* Gere o **Token** e o **EncodingAESKey** * Gere o **Token** e o **EncodingAESKey**
**3. Configurar** **3. Configurar**
@ -536,8 +545,6 @@ Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para
"agent_id": 1000002, "agent_id": 1000002,
"token": "YOUR_TOKEN", "token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_ENCODING_AES_KEY", "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
"webhook_host": "0.0.0.0",
"webhook_port": 18792,
"webhook_path": "/webhook/wecom-app", "webhook_path": "/webhook/wecom-app",
"allow_from": [] "allow_from": []
} }
@ -551,7 +558,40 @@ Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para
picoclaw gateway picoclaw gateway
``` ```
> **Nota**: O WeCom App requer a abertura da porta 18792 para callbacks de webhook. Use um proxy reverso para HTTPS em produção. > **Nota**: O WeCom App (callbacks de webhook) é servido pelo Gateway compartilhado (padrão 127.0.0.1:18790). Em produção use um proxy reverso HTTPS para expor a porta do Gateway, ou atualize `PICOCLAW_GATEWAY_HOST` para `0.0.0.0` se necessário.
**Configuração Rápida - WeCom AI Bot:**
**1. Criar um AI Bot**
* Acesse o Console de Administração WeCom → Gerenciamento de Aplicativos → AI Bot
* Configure a URL de callback: `http://your-server:18791/webhook/wecom-aibot`
* Copie o **Token** e gere o **EncodingAESKey**
**2. Configurar**
```json
{
"channels": {
"wecom_aibot": {
"enabled": true,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [],
"welcome_message": "Olá! Como posso ajudá-lo?"
}
}
}
```
**3. Executar**
```bash
picoclaw gateway
```
> **Nota**: O WeCom AI Bot usa protocolo de pull em streaming — sem preocupações com timeout de resposta. Tarefas longas (>5,5 min) alternam automaticamente para entrega via `response_url`.
</details> </details>
@ -565,6 +605,31 @@ Conecte o PicoClaw a Rede Social de Agentes simplesmente enviando uma única men
Arquivo de configuração: `~/.picoclaw/config.json` Arquivo de configuração: `~/.picoclaw/config.json`
### Variáveis de Ambiente
Você pode substituir os caminhos padrão usando variáveis de ambiente. Isso é útil para instalações portáteis, implantações em contêineres ou para executar o picoclaw como um serviço do sistema. Essas variáveis são independentes e controlam caminhos diferentes.
| Variável | Descrição | Caminho Padrão |
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
| `PICOCLAW_CONFIG` | Substitui o caminho para o arquivo de configuração. Isso informa diretamente ao picoclaw qual `config.json` carregar, ignorando todos os outros locais. | `~/.picoclaw/config.json` |
| `PICOCLAW_HOME` | Substitui o diretório raiz dos dados do picoclaw. Isso altera o local padrão do `workspace` e de outros diretórios de dados. | `~/.picoclaw` |
**Exemplos:**
```bash
# Executar o picoclaw usando um arquivo de configuração específico
# O caminho do workspace será lido de dentro desse arquivo de configuração
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
# Executar o picoclaw com todos os seus dados armazenados em /opt/picoclaw
# A configuração será carregada do ~/.picoclaw/config.json padrão
# O workspace será criado em /opt/picoclaw/workspace
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
# Use ambos para uma configuração totalmente personalizada
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
```
### Estrutura do Workspace ### Estrutura do Workspace
O PicoClaw armazena dados no workspace configurado (padrão: `~/.picoclaw/workspace`): O PicoClaw armazena dados no workspace configurado (padrão: `~/.picoclaw/workspace`):
@ -758,7 +823,7 @@ O subagente tem acesso às ferramentas (message, web_search, etc.) e pode se com
### Provedores ### Provedores
> [!NOTE] > [!NOTE]
> O Groq fornece transcrição de voz gratuita via Whisper. Se configurado, mensagens de voz do Telegram serão automaticamente transcritas. > O Groq fornece transcrição de voz gratuita via Whisper. Se configurado, mensagens de áudio de qualquer canal serão automaticamente transcritas no nível do agente.
| Provedor | Finalidade | Obter API Key | | Provedor | Finalidade | Obter API Key |
| --- | --- | --- | | --- | --- | --- |
@ -973,6 +1038,17 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
``` ```
> Execute `picoclaw auth login --provider anthropic` para configurar credenciais OAuth. > Execute `picoclaw auth login --provider anthropic` para configurar credenciais OAuth.
**Proxy/API personalizada**
```json
{
"model_name": "my-custom-model",
"model": "openai/custom-model",
"api_base": "https://my-proxy.com/v1",
"api_key": "sk-...",
"request_timeout": 300
}
```
#### Balanceamento de Carga #### Balanceamento de Carga
Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-robin automaticamente entre eles: Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-robin automaticamente entre eles:

View file

@ -3,11 +3,11 @@
<h1>PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go</h1> <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 1 giây · 皮皮虾,我们走!</h3> <h3>Phần cứng $10 · RAM 10MB · Khởi động 1 giây · Nào, xuất phát!</h3>
<p> <p>
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go"> <img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware"> <img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License"> <img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<br> <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://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
@ -65,7 +65,7 @@
⚡️ **Khởi động siêu nhanh**: Nhanh gấp 400 lần, khởi động trong 1 giây ngay cả trên CPU đơn nhân 0.6GHz. ⚡️ **Khởi động siêu nhanh**: Nhanh gấp 400 lần, khởi động trong 1 giây ngay cả trên CPU đơn nhân 0.6GHz.
🌍 **Di động thực sự**: Một file binary duy nhất chạy trên RISC-V, ARM và x86. Một click là chạy! 🌍 **Di động thực sự**: Một file binary duy nhất chạy trên RISC-V, ARM, MIPS và x86. Một click là chạy!
🤖 **AI tự xây dựng**: Triển khai Go-native tự động — 95% mã nguồn cốt lõi được Agent tạo ra, với sự tinh chỉnh của con người. 🤖 **AI tự xây dựng**: Triển khai Go-native tự động — 95% mã nguồn cốt lõi được Agent tạo ra, với sự tinh chỉnh của con người.
@ -145,39 +145,43 @@ Bạn cũng có thể chạy PicoClaw bằng Docker Compose mà không cần cà
git clone https://github.com/sipeed/picoclaw.git git clone https://github.com/sipeed/picoclaw.git
cd picoclaw cd picoclaw
# 2. Thiết lập API Key # 2. Lần chạy đầu tiên — tự tạo docker/data/config.json rồi dừng lại
cp config/config.example.json config/config.json docker compose -f docker/docker-compose.yml --profile gateway up
vim config/config.json # Thiết lập DISCORD_BOT_TOKEN, API keys, v.v. # Container hiển thị "First-run setup complete." rồi tự dừng.
# 3. Build & Khởi động # 3. Thiết lập API Key
docker compose --profile gateway up -d vim docker/data/config.json # API key của provider, bot token, v.v.
# 4. Khởi động
docker compose -f docker/docker-compose.yml --profile gateway up -d
```
> [!TIP] > [!TIP]
> **Người dùng Docker**: Theo mặc định, Gateway lắng nghe trên `127.0.0.1`, không thể truy cập từ máy chủ. Nếu bạn cần truy cập các endpoint kiểm tra sức khỏe hoặc mở cổng, hãy đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` trong môi trường của bạn hoặc cập nhật `config.json`. > **Người dùng Docker**: Theo mặc định, Gateway lắng nghe trên `127.0.0.1`, không thể truy cập từ máy chủ. Nếu bạn cần truy cập các endpoint kiểm tra sức khỏe hoặc mở cổng, hãy đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` trong môi trường của bạn hoặc cập nhật `config.json`.
```bash
# 5. Xem logs
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
# 4. Xem logs # 6. Dừng
docker compose logs -f picoclaw-gateway docker compose -f docker/docker-compose.yml --profile gateway down
# 5. Dừng
docker compose --profile gateway down
``` ```
### Chế độ Agent (chạy một lần) ### Chế độ Agent (chạy một lần)
```bash ```bash
# Đặt câu hỏi # Đặt câu hỏi
docker compose run --rm picoclaw-agent -m "2+2 bằng mấy?" docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2 bằng mấy?"
# Chế độ tương tác # Chế độ tương tác
docker compose run --rm picoclaw-agent docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
``` ```
### Build lại ### Cập nhật
```bash ```bash
docker compose --profile gateway build --no-cache docker compose -f docker/docker-compose.yml pull
docker compose --profile gateway up -d docker compose -f docker/docker-compose.yml --profile gateway up -d
``` ```
### 🚀 Bắt đầu nhanh ### 🚀 Bắt đầu nhanh
@ -202,12 +206,13 @@ picoclaw onboard
"model_name": "gpt4", "model_name": "gpt4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.2",
"api_key": "sk-your-openai-key", "api_key": "sk-your-openai-key",
"request_timeout": 300,
"api_base": "https://api.openai.com/v1" "api_base": "https://api.openai.com/v1"
} }
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"model": "gpt4" "model_name": "gpt4"
} }
}, },
"channels": { "channels": {
@ -220,6 +225,9 @@ picoclaw onboard
} }
``` ```
> **Mới**: Định dạng cấu hình `model_list` cho phép thêm nhà cung cấp mà không cần thay đổi mã nguồn. Xem [Cấu hình Mô hình](#cấu-hình-mô-hình-model_list) để biết chi tiết.
> `request_timeout` là tùy chọn và dùng đơn vị giây. Nếu bỏ qua hoặc đặt `<= 0`, PicoClaw sẽ dùng timeout mặc định (120s).
**3. Lấy API Key** **3. Lấy API Key**
* **Nhà cung cấp LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) * **Nhà cung cấp LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
@ -248,7 +256,7 @@ Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk, LINE hoặc WeCom.
| **QQ** | Dễ (AppID + AppSecret) | | **QQ** | Dễ (AppID + AppSecret) |
| **DingTalk** | Trung bình (app credentials) | | **DingTalk** | Trung bình (app credentials) |
| **LINE** | Trung bình (credentials + webhook URL) | | **LINE** | Trung bình (credentials + webhook URL) |
| **WeCom** | Trung bình (CorpID + cấu hình webhook) | | **WeCom AI Bot** | Trung bình (Token + khóa AES) |
<details> <details>
<summary><b>Telegram</b> (Khuyên dùng)</summary> <summary><b>Telegram</b> (Khuyên dùng)</summary>
@ -416,8 +424,6 @@ picoclaw gateway
"enabled": true, "enabled": true,
"channel_secret": "YOUR_CHANNEL_SECRET", "channel_secret": "YOUR_CHANNEL_SECRET",
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
"webhook_host": "0.0.0.0",
"webhook_port": 18791,
"webhook_path": "/webhook/line", "webhook_path": "/webhook/line",
"allow_from": [] "allow_from": []
} }
@ -431,7 +437,7 @@ LINE yêu cầu HTTPS cho webhook. Sử dụng reverse proxy hoặc tunnel:
```bash ```bash
# Ví dụ với ngrok # Ví dụ với ngrok
ngrok http 18791 ngrok http 18790
``` ```
Sau đó cài đặt Webhook URL trong LINE Developers Console thành `https://your-domain/webhook/line` và bật **Use webhook**. Sau đó cài đặt Webhook URL trong LINE Developers Console thành `https://your-domain/webhook/line` và bật **Use webhook**.
@ -444,19 +450,20 @@ picoclaw gateway
> Trong nhóm chat, bot chỉ phản hồi khi được @mention. Các câu trả lời sẽ trích dẫn tin nhắn gốc. > Trong nhóm chat, bot chỉ phản hồi khi được @mention. Các câu trả lời sẽ trích dẫn tin nhắn gốc.
> **Docker Compose**: Thêm `ports: ["18791:18791"]` vào service `picoclaw-gateway` để mở port webhook. > **Docker Compose**: Nếu bạn cần mở port webhook cục bộ, hãy thêm một rule chuyển tiếp từ port Gateway (mặc định 18790) tới host. Lưu ý: LINE webhook được phục vụ bởi Gateway HTTP chung (mặc định 127.0.0.1:18790).
</details> </details>
<details> <details>
<summary><b>WeCom (WeChat Work)</b></summary> <summary><b>WeCom (WeChat Work)</b></summary>
PicoClaw hỗ trợ hai loại tích hợp WeCom: PicoClaw hỗ trợ ba loại tích hợp WeCom:
**Tùy chọn 1: WeCom Bot (Robot Thông minh)** - Thiết lập dễ dàng hơn, hỗ trợ chat nhóm **Tùy chọn 1: WeCom Bot (Robot)** - Thiết lập dễ dàng hơn, hỗ trợ chat nhóm
**Tùy chọn 2: WeCom App (Ứng dụng Tự xây dựng)** - Nhiều tính năng hơn, nhắn tin chủ động **Tùy chọn 2: WeCom App (Ứng dụng Tùy chỉnh)** - Nhiều tính năng hơn, nhắn tin chủ động, chỉ chat riêng tư
**Tùy chọn 3: WeCom AI Bot (Bot Thông Minh)** - Bot AI chính thức, phản hồi streaming, hỗ trợ nhóm và riêng tư
Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) để biết hướng dẫn chi tiết. Xem [Hướng dẫn Cấu hình WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) để biết hướng dẫn chi tiết.
**Thiết lập Nhanh - WeCom Bot:** **Thiết lập Nhanh - WeCom Bot:**
@ -475,8 +482,6 @@ Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) đ
"token": "YOUR_TOKEN", "token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_ENCODING_AES_KEY", "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
"webhook_host": "0.0.0.0",
"webhook_port": 18793,
"webhook_path": "/webhook/wecom", "webhook_path": "/webhook/wecom",
"allow_from": [] "allow_from": []
} }
@ -484,6 +489,8 @@ Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) đ
} }
``` ```
> **Lưu ý:** Các endpoint webhook của WeCom Bot được phục vụ bởi máy chủ Gateway HTTP dùng chung (mặc định 127.0.0.1:18790). Nếu bạn cần truy cập từ bên ngoài, hãy cấu hình reverse proxy hoặc mở cổng Gateway tương ứng.
**Thiết lập Nhanh - WeCom App:** **Thiết lập Nhanh - WeCom App:**
**1. Tạo ứng dụng** **1. Tạo ứng dụng**
@ -495,7 +502,7 @@ Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) đ
**2. Cấu hình nhận tin nhắn** **2. Cấu hình nhận tin nhắn**
* Trong chi tiết ứng dụng, nhấp vào "Nhận Tin nhắn" → "Thiết lập API" * Trong chi tiết ứng dụng, nhấp vào "Nhận Tin nhắn" → "Thiết lập API"
* Đặt URL thành `http://your-server:18792/webhook/wecom-app` * Đặt URL thành `http://your-server:18790/webhook/wecom-app`
* Tạo **Token** và **EncodingAESKey** * Tạo **Token** và **EncodingAESKey**
**3. Cấu hình** **3. Cấu hình**
@ -510,8 +517,6 @@ Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) đ
"agent_id": 1000002, "agent_id": 1000002,
"token": "YOUR_TOKEN", "token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_ENCODING_AES_KEY", "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
"webhook_host": "0.0.0.0",
"webhook_port": 18792,
"webhook_path": "/webhook/wecom-app", "webhook_path": "/webhook/wecom-app",
"allow_from": [] "allow_from": []
} }
@ -525,7 +530,40 @@ Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) đ
picoclaw gateway picoclaw gateway
``` ```
> **Lưu ý**: WeCom App yêu cầu mở cổng 18792 cho callback webhook. Sử dụng proxy ngược cho HTTPS trong môi trường sản xuất. > **Lưu ý**: WeCom App callback webhook được phục vụ bởi Gateway HTTP chung (mặc định 127.0.0.1:18790). Sử dụng proxy ngược để cung cấp HTTPS trong môi trường production nếu cần.
**Thiết lập Nhanh - WeCom AI Bot:**
**1. Tạo AI Bot**
* Truy cập Bảng điều khiển Quản trị WeCom → Quản lý Ứng dụng → AI Bot
* Cấu hình URL callback: `http://your-server:18791/webhook/wecom-aibot`
* Sao chép **Token** và tạo **EncodingAESKey**
**2. Cấu hình**
```json
{
"channels": {
"wecom_aibot": {
"enabled": true,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [],
"welcome_message": "Xin chào! Tôi có thể giúp gì cho bạn?"
}
}
}
```
**3. Chạy**
```bash
picoclaw gateway
```
> **Lưu ý**: WeCom AI Bot sử dụng giao thức pull streaming — không lo timeout phản hồi. Tác vụ dài (>5,5 phút) tự động chuyển sang gửi qua `response_url`.
</details> </details>
@ -539,6 +577,31 @@ Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một
File cấu hình: `~/.picoclaw/config.json` File cấu hình: `~/.picoclaw/config.json`
### Biến môi trường
Bạn có thể ghi đè các đường dẫn mặc định bằng cách sử dụng các biến môi trường. Điều này hữu ích cho việc cài đặt di động, triển khai container hóa hoặc chạy picoclaw như một dịch vụ hệ thống. Các biến này độc lập và kiểm soát các đường dẫn khác nhau.
| Biến | Mô tả | Đường dẫn mặc định |
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
| `PICOCLAW_CONFIG` | Ghi đè đường dẫn đến file cấu hình. Điều này trực tiếp yêu cầu picoclaw tải file `config.json` nào, bỏ qua tất cả các vị trí khác. | `~/.picoclaw/config.json` |
| `PICOCLAW_HOME` | Ghi đè thư mục gốc cho dữ liệu picoclaw. Điều này thay đổi vị trí mặc định của `workspace` và các thư mục dữ liệu khác. | `~/.picoclaw` |
**Ví dụ:**
```bash
# Chạy picoclaw bằng một file cấu hình cụ thể
# Đường dẫn workspace sẽ được đọc từ trong file cấu hình đó
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
# Chạy picoclaw với tất cả dữ liệu được lưu trữ trong /opt/picoclaw
# Cấu hình sẽ được tải từ ~/.picoclaw/config.json mặc định
# Workspace sẽ được tạo tại /opt/picoclaw/workspace
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
# Sử dụng cả hai để có thiết lập tùy chỉnh hoàn toàn
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
```
### Cấu trúc Workspace ### Cấu trúc Workspace
PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: `~/.picoclaw/workspace`): PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: `~/.picoclaw/workspace`):
@ -732,7 +795,7 @@ Subagent có quyền truy cập các công cụ (message, web_search, v.v.) và
### Nhà cung cấp (Providers) ### Nhà cung cấp (Providers)
> [!NOTE] > [!NOTE]
> Groq cung cấp dịch vụ chuyển giọng nói thành văn bản miễn phí qua Whisper. Nếu đã cấu hình Groq, tin nhắn thoại trên Telegram sẽ được tự động chuyển thành văn bản. > Groq cung cấp dịch vụ chuyển giọng nói thành văn bản miễn phí qua Whisper. Nếu đã cấu hình Groq, tin nhắn âm thanh từ bất kỳ kênh nào sẽ được tự động chuyển thành văn bản ở cấp độ agent.
| Nhà cung cấp | Mục đích | Lấy API Key | | Nhà cung cấp | Mục đích | Lấy API Key |
| --- | --- | --- | | --- | --- | --- |
@ -944,6 +1007,17 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
``` ```
> Chạy `picoclaw auth login --provider anthropic` để thiết lập thông tin xác thực OAuth. > Chạy `picoclaw auth login --provider anthropic` để thiết lập thông tin xác thực OAuth.
**Proxy/API tùy chỉnh**
```json
{
"model_name": "my-custom-model",
"model": "openai/custom-model",
"api_base": "https://my-proxy.com/v1",
"api_key": "sk-...",
"request_timeout": 300
}
```
#### Cân bằng Tải tải #### Cân bằng Tải tải
Định cấu hình nhiều endpoint cho cùng một tên mô hình—PicoClaw sẽ tự động phân phối round-robin giữa chúng: Định cấu hình nhiều endpoint cho cùng một tên mô hình—PicoClaw sẽ tự động phân phối round-robin giữa chúng:

View file

@ -7,7 +7,7 @@
<p> <p>
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go"> <img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware"> <img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License"> <img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<br> <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://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
@ -67,7 +67,7 @@
⚡️ **闪电启动**: 启动速度快 400 倍,即使在 0.6GHz 单核处理器上也能在 1 秒内启动。 ⚡️ **闪电启动**: 启动速度快 400 倍,即使在 0.6GHz 单核处理器上也能在 1 秒内启动。
🌍 **真正可移植**: 跨 RISC-V、ARM 和 x86 架构的单二进制文件,一键运行! 🌍 **真正可移植**: 跨 RISC-V、ARM、MIPS 和 x86 架构的单二进制文件,一键运行!
🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。 🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。
@ -166,41 +166,43 @@ make install
git clone https://github.com/sipeed/picoclaw.git git clone https://github.com/sipeed/picoclaw.git
cd picoclaw cd picoclaw
# 2. 设置 API Key # 2. 首次运行 — 自动生成 docker/data/config.json 后退出
cp config/config.example.json config/config.json docker compose -f docker/docker-compose.yml --profile gateway up
vim config/config.json # 设置 DISCORD_BOT_TOKEN, API keys 等 # 容器打印 "First-run setup complete." 后自动停止
# 3. 构建并启动 # 3. 填写 API Key 等配置
docker compose --profile gateway up -d vim docker/data/config.json # 设置 provider API key、Bot Token 等
# 4. 正式启动
docker compose -f docker/docker-compose.yml --profile gateway up -d
```
> [!TIP] > [!TIP]
**Docker 用户**: 默认情况下, Gateway监听 `127.0.0.1`,这使得这个端口未暴露到容器外。如果你需要通过端口映射访问健康检查接口, 请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json` > **Docker 用户**: 默认情况下, Gateway 监听 `127.0.0.1`,该端口不会暴露到容器外。如果需要通过端口映射访问健康检查接口,请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json`
# 4. 查看日志 ```bash
docker compose logs -f picoclaw-gateway # 5. 查看日志
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
# 5. 停止
docker compose --profile gateway down
# 6. 停止
docker compose -f docker/docker-compose.yml --profile gateway down
``` ```
### Agent 模式 (一次性运行) ### Agent 模式 (一次性运行)
```bash ```bash
# 提问 # 提问
docker compose run --rm picoclaw-agent -m "2+2 等于几?" docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2 等于几?"
# 交互模式 # 交互模式
docker compose run --rm picoclaw-agent docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
``` ```
### 重新构建 ### 更新镜像
```bash ```bash
docker compose --profile gateway build --no-cache docker compose -f docker/docker-compose.yml pull
docker compose --profile gateway up -d docker compose -f docker/docker-compose.yml --profile gateway up -d
``` ```
### 🚀 快速开始 ### 🚀 快速开始
@ -224,7 +226,7 @@ picoclaw onboard
"agents": { "agents": {
"defaults": { "defaults": {
"workspace": "~/.picoclaw/workspace", "workspace": "~/.picoclaw/workspace",
"model": "gpt4", "model_name": "gpt4",
"max_tokens": 8192, "max_tokens": 8192,
"temperature": 0.7, "temperature": 0.7,
"max_tool_iterations": 20 "max_tool_iterations": 20
@ -234,7 +236,8 @@ picoclaw onboard
{ {
"model_name": "gpt4", "model_name": "gpt4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.2",
"api_key": "your-api-key" "api_key": "your-api-key",
"request_timeout": 300
}, },
{ {
"model_name": "claude-sonnet-4.6", "model_name": "claude-sonnet-4.6",
@ -263,6 +266,7 @@ picoclaw onboard
``` ```
> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。 > **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。
> `request_timeout` 为可选项,单位为秒。若省略或设置为 `<= 0`PicoClaw 使用默认超时120 秒)。
**3. 获取 API Key** **3. 获取 API Key**
@ -286,6 +290,8 @@ picoclaw agent -m "2+2 等于几?"
PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。 PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。
> **注意**: 所有 Webhook 类渠道LINE、WeCom 等)均挂载在同一个 Gateway HTTP 服务器上(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`无需为每个渠道单独配置端口。注意飞书Feishu使用 WebSocket/SDK 模式,不通过该共享 HTTP webhook 服务器接收消息。
### 核心渠道 ### 核心渠道
| 渠道 | 设置难度 | 特性说明 | 文档链接 | | 渠道 | 设置难度 | 特性说明 | 文档链接 |
@ -293,14 +299,22 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](docs/channels/telegram/README.zh.md) | | **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](docs/channels/telegram/README.zh.md) |
| **Discord** | ⭐ 简单 | Socket Mode支持群组/私信Bot 生态成熟 | [查看文档](docs/channels/discord/README.zh.md) | | **Discord** | ⭐ 简单 | Socket Mode支持群组/私信Bot 生态成熟 | [查看文档](docs/channels/discord/README.zh.md) |
| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) | | **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) |
| **Matrix** | ⭐⭐ 中等 | 联邦协议,支持自建 homeserver 与公开服务器 | [查看文档](docs/channels/matrix/README.zh.md) |
| **QQ** | ⭐⭐ 中等 | 官方机器人 API适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) | | **QQ** | ⭐⭐ 中等 | 官方机器人 API适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) |
| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) | | **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) |
| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)和自建应用(API) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) | | **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)、自建应用(API)和智能机器人(AI Bot) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) / [AI Bot 文档](docs/channels/wecom/wecom_aibot/README.zh.md) |
| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](docs/channels/feishu/README.zh.md) | | **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](docs/channels/feishu/README.zh.md) |
| **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) | | **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) |
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) | | **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) | | **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
### Telegram 命令注册(启动时自动同步)
PicoClaw 现在使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start``/help``/show``/list`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。
Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行统一走 Agent Loop 中的 commands executor。
如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络 ## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。 只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
@ -311,6 +325,31 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
配置文件路径: `~/.picoclaw/config.json` 配置文件路径: `~/.picoclaw/config.json`
### 环境变量
你可以使用环境变量覆盖默认路径。这对于便携安装、容器化部署或将 picoclaw 作为系统服务运行非常有用。这些变量是独立的,控制不同的路径。
| 变量 | 描述 | 默认路径 |
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
| `PICOCLAW_CONFIG` | 覆盖配置文件的路径。这直接告诉 picoclaw 加载哪个 `config.json`,忽略所有其他位置。 | `~/.picoclaw/config.json` |
| `PICOCLAW_HOME` | 覆盖 picoclaw 数据根目录。这会更改 `workspace` 和其他数据目录的默认位置。 | `~/.picoclaw` |
**示例:**
```bash
# 使用特定的配置文件运行 picoclaw
# 工作区路径将从该配置文件中读取
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
# 在 /opt/picoclaw 中存储所有数据运行 picoclaw
# 配置将从默认的 ~/.picoclaw/config.json 加载
# 工作区将在 /opt/picoclaw/workspace 创建
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
# 同时使用两者进行完全自定义设置
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
```
### 工作区布局 (Workspace Layout) ### 工作区布局 (Workspace Layout)
PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace` PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`
@ -331,6 +370,26 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
``` ```
### 技能来源 (Skill Sources)
默认情况下,技能会按以下顺序加载:
1. `~/.picoclaw/workspace/skills`(工作区)
2. `~/.picoclaw/skills`(全局)
3. `<current-working-directory>/skills`(内置)
在高级/测试场景下,可通过以下环境变量覆盖内置技能目录:
```bash
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
```
### 统一命令执行策略
- 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。
- Channel 适配器不再在本地消费通用命令;它们只负责把入站文本转发到 bus/agent 路径。Telegram 仍会在启动时自动注册其支持的命令菜单。
- 未注册的斜杠命令(例如 `/foo`)会透传给 LLM 按普通输入处理。
- 已注册但当前 channel 不支持的命令(例如 WhatsApp 上的 `/show`)会返回明确的用户可见错误,并停止后续处理。
### 心跳 / 周期性任务 (Heartbeat) ### 心跳 / 周期性任务 (Heartbeat)
PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件: PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件:
@ -414,7 +473,7 @@ Agent 读取 HEARTBEAT.md
### 提供商 (Providers) ### 提供商 (Providers)
> [!NOTE] > [!NOTE]
> Groq 通过 Whisper 提供免费的语音转录。如果配置了 GroqTelegram 语音消息将被自动转录为文字。 > Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq任意渠道的音频消息都将在 Agent 层面自动转录为文字。
| 提供商 | 用途 | 获取 API Key | | 提供商 | 用途 | 获取 API Key |
| -------------------- | ---------------------------- | -------------------------------------------------------------------- | | -------------------- | ---------------------------- | -------------------------------------------------------------------- |
@ -550,7 +609,8 @@ Agent 读取 HEARTBEAT.md
"model_name": "my-custom-model", "model_name": "my-custom-model",
"model": "openai/custom-model", "model": "openai/custom-model",
"api_base": "https://my-proxy.com/v1", "api_base": "https://my-proxy.com/v1",
"api_key": "sk-..." "api_key": "sk-...",
"request_timeout": 300
} }
``` ```
@ -669,6 +729,10 @@ picoclaw agent -m "你好"
"model": "anthropic/claude-opus-4-5" "model": "anthropic/claude-opus-4-5"
} }
}, },
"session": {
"dm_scope": "per-channel-peer",
"backlog_limit": 20
},
"providers": { "providers": {
"openrouter": { "openrouter": {
"api_key": "sk-or-v1-xxx" "api_key": "sk-or-v1-xxx"

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 348 KiB

View file

@ -0,0 +1,49 @@
package configstore
import (
"errors"
"os"
"path/filepath"
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
)
const (
configDirName = ".picoclaw"
configFileName = "config.json"
)
func ConfigPath() (string, error) {
dir, err := ConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, configFileName), nil
}
func ConfigDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, configDirName), nil
}
func Load() (*picoclawconfig.Config, error) {
path, err := ConfigPath()
if err != nil {
return nil, err
}
return picoclawconfig.LoadConfig(path)
}
func Save(cfg *picoclawconfig.Config) error {
if cfg == nil {
return errors.New("config is nil")
}
path, err := ConfigPath()
if err != nil {
return err
}
return picoclawconfig.SaveConfig(path, cfg)
}

View file

@ -0,0 +1,522 @@
package ui
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
configstore "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/config"
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
)
type appState struct {
app *tview.Application
pages *tview.Pages
stack []string
config *picoclawconfig.Config
configPath string
gatewayCmd *exec.Cmd
menus map[string]*Menu
original []byte
hasOriginal bool
backupPath string
dirty bool
logPath string
}
func Run() error {
applyStyles()
cfg, err := configstore.Load()
if err != nil {
return err
}
path, err := configstore.ConfigPath()
if err != nil {
return err
}
if cfg == nil {
cfg = picoclawconfig.DefaultConfig()
}
originalData, hasOriginal := loadOriginalConfig(path)
backupPath := path + ".bak"
if hasOriginal {
_ = writeBackupConfig(backupPath, originalData)
}
logPath := filepath.Join(filepath.Dir(path), "gateway.log")
state := &appState{
app: tview.NewApplication(),
pages: tview.NewPages(),
config: cfg,
configPath: path,
menus: map[string]*Menu{},
original: originalData,
hasOriginal: hasOriginal,
backupPath: backupPath,
logPath: logPath,
}
state.push("main", state.mainMenu())
root := tview.NewFlex().SetDirection(tview.FlexRow)
root.AddItem(bannerView(), 6, 0, false)
root.AddItem(state.pages, 0, 1, true)
root.AddItem(footerView(), 1, 0, false)
if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil {
return err
}
return nil
}
func (s *appState) push(name string, primitive tview.Primitive) {
s.pages.AddPage(name, primitive, true, true)
s.stack = append(s.stack, name)
s.pages.SwitchToPage(name)
if menu, ok := primitive.(*Menu); ok {
s.menus[name] = menu
}
}
func (s *appState) pop() {
if len(s.stack) == 0 {
return
}
last := s.stack[len(s.stack)-1]
s.pages.RemovePage(last)
s.stack = s.stack[:len(s.stack)-1]
if len(s.stack) == 0 {
s.app.Stop()
return
}
current := s.stack[len(s.stack)-1]
s.pages.SwitchToPage(current)
if menu, ok := s.menus[current]; ok {
s.refreshMenu(current, menu)
}
}
func (s *appState) mainMenu() tview.Primitive {
menu := NewMenu("Menu", nil)
refreshMainMenu(menu, s)
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyEsc:
s.requestExit()
return nil
}
return event
})
return menu
}
func (s *appState) refreshMenu(name string, menu *Menu) {
switch name {
case "main":
refreshMainMenu(menu, s)
case "model":
refreshModelMenuFromState(menu, s)
case "channel":
refreshChannelMenuFromState(menu, s)
}
}
func (s *appState) countChannels() (enabled int, total int) {
c := s.config.Channels
entries := []bool{
c.Telegram.Enabled,
c.Discord.Enabled,
c.QQ.Enabled,
c.MaixCam.Enabled,
c.WhatsApp.Enabled,
c.Feishu.Enabled,
c.DingTalk.Enabled,
c.Slack.Enabled,
c.Matrix.Enabled,
c.LINE.Enabled,
c.OneBot.Enabled,
c.WeCom.Enabled,
c.WeComApp.Enabled,
}
total = len(entries)
for _, v := range entries {
if v {
enabled++
}
}
return enabled, total
}
func refreshMainMenuIfPresent(s *appState) {
if menu, ok := s.menus["main"]; ok {
refreshMainMenu(menu, s)
}
}
func refreshMainMenu(menu *Menu, s *appState) {
selectedModel := s.selectedModelName()
modelReady := selectedModel != ""
channelReady := s.hasEnabledChannel()
enabledCount, totalChannels := s.countChannels()
gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning()
gatewayLabel := "Start Gateway"
gatewayDescription := "Launch gateway for channels"
if gatewayRunning {
gatewayLabel = "Stop Gateway"
gatewayDescription = "Gateway running"
}
items := []MenuItem{
{
Label: rootModelLabel(selectedModel),
Description: rootModelDescription(),
Action: func() {
s.push("model", s.modelMenu())
},
MainColor: func() *tcell.Color {
if modelReady {
return nil
}
color := tcell.ColorGray
return &color
}(),
},
{
Label: rootChannelLabel(channelReady),
Description: fmt.Sprintf("%d/%d enabled", enabledCount, totalChannels),
Action: func() {
s.push("channel", s.channelMenu())
},
MainColor: func() *tcell.Color {
if channelReady {
return nil
}
color := tcell.ColorGray
return &color
}(),
},
{
Label: "Start Talk",
Description: "Open picoclaw agent in terminal",
Action: func() {
s.requestStartTalk()
},
Disabled: !modelReady,
},
{
Label: gatewayLabel,
Description: gatewayDescription,
Action: func() {
if gatewayRunning {
s.stopGateway()
} else {
s.requestStartGateway()
}
refreshMainMenu(menu, s)
},
Disabled: !gatewayRunning && (!modelReady || !channelReady),
},
{
Label: "View Gateway Log",
Description: "Open gateway.log",
Action: func() {
s.viewGatewayLog()
},
},
{
Label: "Exit",
Description: "Exit the TUI",
Action: func() {
s.requestExit()
},
},
}
menu.applyItems(items)
}
func (s *appState) applyChangesValidated() bool {
if err := s.config.ValidateModelList(); err != nil {
s.showMessage("Validation failed", err.Error())
return false
}
if err := s.validateAgentModel(); err != nil {
s.showMessage("Validation failed", err.Error())
return false
}
if err := configstore.Save(s.config); err != nil {
s.showMessage("Save failed", err.Error())
return false
}
if data, err := os.ReadFile(s.configPath); err == nil {
s.original = data
s.hasOriginal = true
_ = writeBackupConfig(s.backupPath, data)
}
return true
}
func (s *appState) requestExit() {
if s.dirty {
s.confirmApplyOrDiscard(func() {
s.app.Stop()
}, func() {
s.discardChanges()
s.app.Stop()
})
return
}
s.app.Stop()
}
func (s *appState) requestStartTalk() {
if s.dirty {
s.confirmApplyOrDiscard(func() {
s.startTalk()
}, func() {
s.startTalk()
})
return
}
s.startTalk()
}
func (s *appState) requestStartGateway() {
if s.dirty {
s.confirmApplyOrDiscard(func() {
s.startGateway()
}, func() {
s.startGateway()
})
return
}
s.startGateway()
}
func (s *appState) viewGatewayLog() {
data, err := os.ReadFile(s.logPath)
if err != nil {
s.showMessage("Log not found", "gateway.log not found")
return
}
text := tview.NewTextView()
text.SetBorder(true).SetTitle("Gateway Log")
text.SetText(string(data))
text.SetDoneFunc(func(key tcell.Key) {
s.pages.RemovePage("log")
})
text.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEsc {
s.pages.RemovePage("log")
return nil
}
return event
})
s.pages.AddPage("log", text, true, true)
}
func (s *appState) selectedModelName() string {
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
if modelName == "" {
return ""
}
if !s.isActiveModelValid() {
return ""
}
return modelName
}
func rootModelLabel(selected string) string {
if selected == "" {
return "Model (None)"
}
return "Model (" + selected + ")"
}
func rootModelDescription() string {
return "Using SPACE to choose your model"
}
func rootChannelLabel(valid bool) string {
if !valid {
return "Channel (no channel enabled)"
}
return "Channel"
}
func (s *appState) startTalk() {
if !s.isActiveModelValid() {
s.showMessage("Model required", "Select a valid model before starting talk")
return
}
if !s.applyChangesValidated() {
return
}
s.app.Suspend(func() {
cmd := exec.Command("picoclaw", "agent")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
})
}
func (s *appState) startGateway() {
if !s.isActiveModelValid() {
s.showMessage("Model required", "Select a valid model before starting gateway")
return
}
if !s.hasEnabledChannel() {
s.showMessage("Channel required", "Enable at least one channel before starting gateway")
return
}
if !s.applyChangesValidated() {
return
}
_ = stopGatewayProcess()
cmd := exec.Command("picoclaw", "gateway")
logFile, err := os.OpenFile(s.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
s.showMessage("Gateway failed", err.Error())
return
}
cmd.Stdout = logFile
cmd.Stderr = logFile
if err := cmd.Start(); err != nil {
s.showMessage("Gateway failed", err.Error())
_ = logFile.Close()
return
}
_ = logFile.Close()
s.gatewayCmd = cmd
}
func (s *appState) stopGateway() {
_ = stopGatewayProcess()
if s.gatewayCmd != nil && s.gatewayCmd.Process != nil {
_ = s.gatewayCmd.Process.Kill()
}
s.gatewayCmd = nil
}
func (s *appState) isGatewayRunning() bool {
return isGatewayProcessRunning()
}
func (s *appState) validateAgentModel() error {
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
if modelName == "" {
return nil
}
_, err := s.config.GetModelConfig(modelName)
return err
}
func (s *appState) isActiveModelValid() bool {
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
if modelName == "" {
return false
}
cfg, err := s.config.GetModelConfig(modelName)
if err != nil {
return false
}
hasKey := strings.TrimSpace(cfg.APIKey) != "" || strings.TrimSpace(cfg.AuthMethod) == "oauth"
hasModel := strings.TrimSpace(cfg.Model) != ""
return hasKey && hasModel
}
func (s *appState) hasEnabledChannel() bool {
c := s.config.Channels
return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled ||
c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled ||
c.Matrix.Enabled || c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled
}
func (s *appState) confirmApplyOrDiscard(onApply func(), onDiscard func()) {
if s.pages.HasPage("apply") {
return
}
modal := tview.NewModal().
SetText("Apply changes or discard before continuing?").
AddButtons([]string{"Cancel", "Discard", "Apply"}).
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
s.pages.RemovePage("apply")
switch buttonLabel {
case "Discard":
s.discardChanges()
if onDiscard != nil {
onDiscard()
}
case "Apply":
if s.applyChangesValidated() {
s.dirty = false
if onApply != nil {
onApply()
}
}
}
})
modal.SetBorder(true)
s.pages.AddPage("apply", modal, true, true)
}
func (s *appState) discardChanges() {
if s.hasOriginal {
_ = writeOriginalConfig(s.configPath, s.original)
} else {
_ = os.Remove(s.configPath)
}
_ = os.Remove(s.backupPath)
if cfg, err := configstore.Load(); err == nil && cfg != nil {
s.config = cfg
}
s.dirty = false
refreshMainMenuIfPresent(s)
}
func (s *appState) showMessage(title, message string) {
if s.pages.HasPage("message") {
return
}
modal := tview.NewModal().
SetText(strings.TrimSpace(message)).
AddButtons([]string{"OK"}).
SetDoneFunc(func(_ int, _ string) {
s.pages.RemovePage("message")
})
modal.SetTitle(title).SetBorder(true)
modal.SetBackgroundColor(tview.Styles.ContrastBackgroundColor)
modal.SetTextColor(tview.Styles.PrimaryTextColor)
modal.SetButtonBackgroundColor(tcell.NewRGBColor(112, 102, 255))
modal.SetButtonTextColor(tview.Styles.PrimaryTextColor)
s.pages.AddPage("message", modal, true, true)
}
func loadOriginalConfig(path string) ([]byte, bool) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, false
}
return nil, false
}
return data, true
}
func writeOriginalConfig(path string, data []byte) error {
return os.WriteFile(path, data, 0o600)
}
func writeBackupConfig(path string, data []byte) error {
return os.WriteFile(path, data, 0o600)
}

View file

@ -0,0 +1,433 @@
package ui
import (
"fmt"
"strings"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
)
func (s *appState) buildChannelMenuItems() []MenuItem {
return []MenuItem{
channelItem(
"Telegram",
"Telegram bot settings",
s.config.Channels.Telegram.Enabled,
func() { s.push("channel-telegram", s.telegramForm()) },
),
channelItem(
"Discord",
"Discord bot settings",
s.config.Channels.Discord.Enabled,
func() { s.push("channel-discord", s.discordForm()) },
),
channelItem(
"QQ",
"QQ bot settings",
s.config.Channels.QQ.Enabled,
func() { s.push("channel-qq", s.qqForm()) },
),
channelItem(
"MaixCam",
"MaixCam gateway",
s.config.Channels.MaixCam.Enabled,
func() { s.push("channel-maixcam", s.maixcamForm()) },
),
channelItem(
"WhatsApp",
"WhatsApp bridge",
s.config.Channels.WhatsApp.Enabled,
func() { s.push("channel-whatsapp", s.whatsappForm()) },
),
channelItem(
"Feishu",
"Feishu bot settings",
s.config.Channels.Feishu.Enabled,
func() { s.push("channel-feishu", s.feishuForm()) },
),
channelItem(
"DingTalk",
"DingTalk bot settings",
s.config.Channels.DingTalk.Enabled,
func() { s.push("channel-dingtalk", s.dingtalkForm()) },
),
channelItem(
"Slack",
"Slack bot settings",
s.config.Channels.Slack.Enabled,
func() { s.push("channel-slack", s.slackForm()) },
),
channelItem(
"Matrix",
"Matrix bot settings",
s.config.Channels.Matrix.Enabled,
func() { s.push("channel-matrix", s.matrixForm()) },
),
channelItem(
"LINE",
"LINE bot settings",
s.config.Channels.LINE.Enabled,
func() { s.push("channel-line", s.lineForm()) },
),
channelItem(
"OneBot",
"OneBot settings",
s.config.Channels.OneBot.Enabled,
func() { s.push("channel-onebot", s.onebotForm()) },
),
channelItem(
"WeCom",
"WeCom bot settings",
s.config.Channels.WeCom.Enabled,
func() { s.push("channel-wecom", s.wecomForm()) },
),
channelItem(
"WeCom App",
"WeCom App settings",
s.config.Channels.WeComApp.Enabled,
func() { s.push("channel-wecomapp", s.wecomAppForm()) },
),
}
}
func (s *appState) channelMenu() tview.Primitive {
menu := NewMenu("Channels", s.buildChannelMenuItems())
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEsc {
s.pop()
return nil
}
return event
})
return menu
}
func refreshChannelMenuFromState(menu *Menu, s *appState) {
menu.applyItems(s.buildChannelMenuItems())
}
func (s *appState) telegramForm() tview.Primitive {
cfg := &s.config.Channels.Telegram
form := baseChannelForm("Telegram", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
cfg.Token = strings.TrimSpace(text)
})
form.AddInputField("Proxy", cfg.Proxy, 128, nil, func(text string) {
cfg.Proxy = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) discordForm() tview.Primitive {
cfg := &s.config.Channels.Discord
form := baseChannelForm("Discord", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
cfg.Token = strings.TrimSpace(text)
})
form.AddCheckbox("Mention Only", cfg.MentionOnly, func(checked bool) {
cfg.MentionOnly = checked
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) qqForm() tview.Primitive {
cfg := &s.config.Channels.QQ
form := baseChannelForm("QQ", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
cfg.AppID = strings.TrimSpace(text)
})
form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
cfg.AppSecret = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) maixcamForm() tview.Primitive {
cfg := &s.config.Channels.MaixCam
form := baseChannelForm("MaixCam", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Host", cfg.Host, 64, nil, func(text string) {
cfg.Host = strings.TrimSpace(text)
})
addIntField(form, "Port", cfg.Port, func(value int) { cfg.Port = value })
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) whatsappForm() tview.Primitive {
cfg := &s.config.Channels.WhatsApp
form := baseChannelForm("WhatsApp", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Bridge URL", cfg.BridgeURL, 128, nil, func(text string) {
cfg.BridgeURL = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) feishuForm() tview.Primitive {
cfg := &s.config.Channels.Feishu
form := baseChannelForm("Feishu", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
cfg.AppID = strings.TrimSpace(text)
})
form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
cfg.AppSecret = strings.TrimSpace(text)
})
form.AddInputField("Encrypt Key", cfg.EncryptKey, 128, nil, func(text string) {
cfg.EncryptKey = strings.TrimSpace(text)
})
form.AddInputField("Verification Token", cfg.VerificationToken, 128, nil, func(text string) {
cfg.VerificationToken = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) dingtalkForm() tview.Primitive {
cfg := &s.config.Channels.DingTalk
form := baseChannelForm("DingTalk", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Client ID", cfg.ClientID, 64, nil, func(text string) {
cfg.ClientID = strings.TrimSpace(text)
})
form.AddInputField("Client Secret", cfg.ClientSecret, 128, nil, func(text string) {
cfg.ClientSecret = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) slackForm() tview.Primitive {
cfg := &s.config.Channels.Slack
form := baseChannelForm("Slack", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Bot Token", cfg.BotToken, 128, nil, func(text string) {
cfg.BotToken = strings.TrimSpace(text)
})
form.AddInputField("App Token", cfg.AppToken, 128, nil, func(text string) {
cfg.AppToken = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) lineForm() tview.Primitive {
cfg := &s.config.Channels.LINE
form := baseChannelForm("LINE", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Channel Secret", cfg.ChannelSecret, 128, nil, func(text string) {
cfg.ChannelSecret = strings.TrimSpace(text)
})
form.AddInputField("Channel Access Token", cfg.ChannelAccessToken, 128, nil, func(text string) {
cfg.ChannelAccessToken = strings.TrimSpace(text)
})
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
cfg.WebhookHost = strings.TrimSpace(text)
})
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
cfg.WebhookPath = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) matrixForm() tview.Primitive {
cfg := &s.config.Channels.Matrix
form := baseChannelForm("Matrix", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Homeserver", cfg.Homeserver, 128, nil, func(text string) {
cfg.Homeserver = strings.TrimSpace(text)
})
form.AddInputField("User ID", cfg.UserID, 128, nil, func(text string) {
cfg.UserID = strings.TrimSpace(text)
})
form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
cfg.AccessToken = strings.TrimSpace(text)
})
form.AddInputField("Device ID", cfg.DeviceID, 128, nil, func(text string) {
cfg.DeviceID = strings.TrimSpace(text)
})
form.AddCheckbox("Join On Invite", cfg.JoinOnInvite, func(checked bool) {
cfg.JoinOnInvite = checked
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) onebotForm() tview.Primitive {
cfg := &s.config.Channels.OneBot
form := baseChannelForm("OneBot", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("WS URL", cfg.WSUrl, 128, nil, func(text string) {
cfg.WSUrl = strings.TrimSpace(text)
})
form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
cfg.AccessToken = strings.TrimSpace(text)
})
addIntField(
form,
"Reconnect Interval",
cfg.ReconnectInterval,
func(value int) { cfg.ReconnectInterval = value },
)
form.AddInputField(
"Group Trigger Prefix",
strings.Join(cfg.GroupTriggerPrefix, ","),
128,
nil,
func(text string) {
cfg.GroupTriggerPrefix = splitCSV(text)
},
)
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) wecomForm() tview.Primitive {
cfg := &s.config.Channels.WeCom
form := baseChannelForm("WeCom", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
cfg.Token = strings.TrimSpace(text)
})
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
cfg.EncodingAESKey = strings.TrimSpace(text)
})
form.AddInputField("Webhook URL", cfg.WebhookURL, 128, nil, func(text string) {
cfg.WebhookURL = strings.TrimSpace(text)
})
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
cfg.WebhookHost = strings.TrimSpace(text)
})
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
cfg.WebhookPath = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
addIntField(
form,
"Reply Timeout",
cfg.ReplyTimeout,
func(value int) { cfg.ReplyTimeout = value },
)
return wrapWithBack(form, s)
}
func (s *appState) wecomAppForm() tview.Primitive {
cfg := &s.config.Channels.WeComApp
form := baseChannelForm("WeCom App", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Corp ID", cfg.CorpID, 64, nil, func(text string) {
cfg.CorpID = strings.TrimSpace(text)
})
form.AddInputField("Corp Secret", cfg.CorpSecret, 128, nil, func(text string) {
cfg.CorpSecret = strings.TrimSpace(text)
})
addInt64Field(form, "Agent ID", cfg.AgentID, func(value int64) { cfg.AgentID = value })
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
cfg.Token = strings.TrimSpace(text)
})
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
cfg.EncodingAESKey = strings.TrimSpace(text)
})
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
cfg.WebhookHost = strings.TrimSpace(text)
})
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
cfg.WebhookPath = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
addIntField(
form,
"Reply Timeout",
cfg.ReplyTimeout,
func(value int) { cfg.ReplyTimeout = value },
)
return wrapWithBack(form, s)
}
func (s *appState) makeChannelOnEnabled(enabledPtr *bool) func(bool) {
return func(v bool) {
*enabledPtr = v
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["channel"]; ok {
refreshChannelMenuFromState(menu, s)
}
}
}
func addAllowFromField(form *tview.Form, allowFrom *picoclawconfig.FlexibleStringSlice) {
form.AddInputField("Allow From", strings.Join(*allowFrom, ","), 128, nil, func(text string) {
*allowFrom = splitCSV(text)
})
}
func baseChannelForm(title string, enabled bool, onEnabled func(bool)) *tview.Form {
form := tview.NewForm()
form.SetBorder(true).SetTitle(fmt.Sprintf("Channel: %s", title))
form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
form.AddCheckbox("Enabled", enabled, func(checked bool) {
onEnabled(checked)
})
return form
}
func wrapWithBack(form *tview.Form, s *appState) tview.Primitive {
form.AddButton("Back", func() {
s.pop()
})
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEsc {
s.pop()
return nil
}
return event
})
return form
}
func splitCSV(input string) picoclawconfig.FlexibleStringSlice {
parts := strings.Split(strings.TrimSpace(input), ",")
cleaned := make([]string, 0, len(parts))
for _, part := range parts {
value := strings.TrimSpace(part)
if value == "" {
continue
}
cleaned = append(cleaned, value)
}
return cleaned
}
func addIntField(form *tview.Form, label string, value int, onChange func(int)) {
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
var parsed int
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
onChange(parsed)
}
})
}
func addInt64Field(form *tview.Form, label string, value int64, onChange func(int64)) {
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
var parsed int64
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
onChange(parsed)
}
})
}
func channelItem(label, description string, enabled bool, action MenuAction) MenuItem {
item := MenuItem{
Label: label,
Description: description,
Action: action,
}
if !enabled {
color := tcell.ColorGray
item.MainColor = &color
}
return item
}

View file

@ -0,0 +1,16 @@
//go:build !windows
// +build !windows
package ui
import "os/exec"
func isGatewayProcessRunning() bool {
cmd := exec.Command("sh", "-c", "pgrep -f 'picoclaw\\s+gateway' >/dev/null 2>&1")
return cmd.Run() == nil
}
func stopGatewayProcess() error {
cmd := exec.Command("sh", "-c", "pkill -f 'picoclaw\\s+gateway' >/dev/null 2>&1")
return cmd.Run()
}

View file

@ -0,0 +1,16 @@
//go:build windows
// +build windows
package ui
import "os/exec"
func isGatewayProcessRunning() bool {
cmd := exec.Command("tasklist", "/FI", "IMAGENAME eq picoclaw.exe")
return cmd.Run() == nil
}
func stopGatewayProcess() error {
cmd := exec.Command("taskkill", "/F", "/IM", "picoclaw.exe")
return cmd.Run()
}

View file

@ -0,0 +1,72 @@
package ui
import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
type MenuAction func()
type MenuItem struct {
Label string
Description string
Action MenuAction
Disabled bool
MainColor *tcell.Color
DescColor *tcell.Color
}
type Menu struct {
*tview.Table
items []MenuItem
}
func NewMenu(title string, items []MenuItem) *Menu {
table := tview.NewTable().SetSelectable(true, false)
table.SetBorder(true).SetTitle(title)
table.SetBorders(false)
menu := &Menu{Table: table, items: items}
menu.applyItems(items)
menu.SetSelectedFunc(func(row, _ int) {
if row < 0 || row >= len(menu.items) {
return
}
item := menu.items[row]
if item.Disabled || item.Action == nil {
return
}
item.Action()
})
menu.SetSelectedStyle(
tcell.StyleDefault.Foreground(tview.Styles.InverseTextColor).
Background(tcell.NewRGBColor(189, 147, 249)),
)
return menu
}
func (m *Menu) applyItems(items []MenuItem) {
m.items = items
m.Clear()
for row, item := range items {
label := item.Label
if item.Disabled && label != "" {
label = label + " (disabled)"
}
left := tview.NewTableCell(label)
right := tview.NewTableCell(item.Description).SetAlign(tview.AlignRight)
if item.MainColor != nil {
left.SetTextColor(*item.MainColor)
}
if item.DescColor != nil {
right.SetTextColor(*item.DescColor)
} else {
right.SetTextColor(tview.Styles.TertiaryTextColor)
}
if item.Disabled {
left.SetTextColor(tcell.ColorGray)
right.SetTextColor(tcell.ColorGray)
}
m.SetCell(row, 0, left)
m.SetCell(row, 1, right)
}
}

View file

@ -0,0 +1,399 @@
package ui
import (
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
)
func (s *appState) modelMenu() tview.Primitive {
items := make([]MenuItem, 0, 1+len(s.config.ModelList))
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
for i := range s.config.ModelList {
index := i
model := s.config.ModelList[i]
isValid := isModelValid(model)
desc := model.APIBase
if desc == "" {
desc = model.AuthMethod
}
if desc == "" {
desc = "api_key required"
}
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
if model.ModelName == currentModel && currentModel != "" {
label = "* " + label
}
isSelected := model.ModelName == currentModel && currentModel != ""
items = append(items, MenuItem{
Label: label,
Description: desc,
MainColor: modelStatusColor(isValid, isSelected),
Action: func() {
s.push(fmt.Sprintf("model-%d", index), s.modelForm(index))
},
})
}
// Add model entry appended at the end so the models map to rows 1..N
items = append(items,
MenuItem{
Label: "**Add model**",
Description: "Append a new model entry",
Action: func() {
newName := s.nextAvailableModelName("new-model")
s.addModel(
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.2"},
)
s.push(
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
s.modelForm(len(s.config.ModelList)-1),
)
},
},
)
menu := NewMenu("Models", items)
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEsc {
s.pop()
return nil
}
if event.Rune() == ' ' {
row, _ := menu.GetSelection()
if row >= 0 && row < len(s.config.ModelList) {
model := s.config.ModelList[row]
if !isModelValid(model) {
s.showMessage(
"Invalid model",
"Select a model with api_key or oauth auth_method",
)
return nil
}
s.config.Agents.Defaults.Model = model.ModelName
s.dirty = true
refreshModelMenu(menu, s.config.Agents.Defaults.Model, s.config.ModelList)
refreshMainMenuIfPresent(s)
}
return nil
}
return event
})
return menu
}
func (s *appState) modelForm(index int) tview.Primitive {
model := &s.config.ModelList[index]
form := tview.NewForm()
form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
addInput(form, "Model Name", model.ModelName, func(value string) {
if value == "" {
s.showMessage("Invalid model name", "Model Name cannot be empty")
return
}
if s.modelNameExists(value, index) {
s.showMessage("Duplicate model name", fmt.Sprintf("Model Name '%s' already exists", value))
return
}
oldName := model.ModelName
model.ModelName = value
if s.config.Agents.Defaults.Model == oldName {
s.config.Agents.Defaults.Model = value
}
s.dirty = true
form.SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["model"]; ok {
refreshModelMenuFromState(menu, s)
}
})
addInput(form, "Model", model.Model, func(value string) {
model.Model = value
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["model"]; ok {
refreshModelMenuFromState(menu, s)
}
})
addInput(form, "API Base", model.APIBase, func(value string) {
model.APIBase = value
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["model"]; ok {
refreshModelMenuFromState(menu, s)
}
})
addInput(form, "API Key", model.APIKey, func(value string) {
model.APIKey = value
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["model"]; ok {
refreshModelMenuFromState(menu, s)
}
})
addInput(form, "Proxy", model.Proxy, func(value string) {
model.Proxy = value
})
addInput(form, "Auth Method", model.AuthMethod, func(value string) {
model.AuthMethod = value
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["model"]; ok {
refreshModelMenuFromState(menu, s)
}
})
addInput(form, "Connect Mode", model.ConnectMode, func(value string) {
model.ConnectMode = value
})
addInput(form, "Workspace", model.Workspace, func(value string) {
model.Workspace = value
})
addInput(form, "Max Tokens Field", model.MaxTokensField, func(value string) {
model.MaxTokensField = value
})
addIntInput(form, "RPM", model.RPM, func(value int) {
model.RPM = value
})
addIntInput(form, "Request Timeout", model.RequestTimeout, func(value int) {
model.RequestTimeout = value
})
form.AddButton("Delete", func() {
pageName := "confirm-delete-model"
if s.pages.HasPage(pageName) {
return
}
modal := tview.NewModal().
SetText("Are you sure you want to delete this model?").
AddButtons([]string{"Cancel", "Delete"}).
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
s.pages.RemovePage(pageName)
if buttonLabel == "Delete" {
s.deleteModel(index)
}
})
modal.SetTitle("Confirm Delete").SetBorder(true)
s.pages.AddPage(pageName, modal, true, true)
})
form.AddButton("Test", func() {
s.testModel(model)
})
form.AddButton("Back", func() {
s.pop()
})
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEsc {
s.pop()
return nil
}
return event
})
return form
}
func addInput(form *tview.Form, label, value string, onChange func(string)) {
form.AddInputField(label, value, 128, nil, func(text string) {
onChange(strings.TrimSpace(text))
})
}
func addIntInput(form *tview.Form, label string, value int, onChange func(int)) {
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
var parsed int
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
onChange(parsed)
}
})
}
func (s *appState) addModel(model picoclawconfig.ModelConfig) {
s.config.ModelList = append(s.config.ModelList, model)
}
func (s *appState) deleteModel(index int) {
if index < 0 || index >= len(s.config.ModelList) {
return
}
s.config.ModelList = append(s.config.ModelList[:index], s.config.ModelList[index+1:]...)
s.pop()
}
func modelStatusColor(valid bool, selected bool) *tcell.Color {
if valid {
color := tview.Styles.PrimaryTextColor
return &color
}
color := tcell.ColorGray
return &color
}
func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {
for i, model := range models {
row := i
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
isValid := isModelValid(model)
if model.ModelName == currentModel && currentModel != "" {
label = "* " + label
}
cell := menu.GetCell(row, 0)
if cell != nil {
cell.SetText(label)
isSelected := model.ModelName == currentModel && currentModel != ""
color := modelStatusColor(isValid, isSelected)
if color != nil {
cell.SetTextColor(*color)
}
}
}
}
func refreshModelMenuFromState(menu *Menu, s *appState) {
items := make([]MenuItem, 0, 1+len(s.config.ModelList))
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
for i := range s.config.ModelList {
index := i
model := s.config.ModelList[i]
isValid := isModelValid(model)
desc := model.APIBase
if desc == "" {
desc = model.AuthMethod
}
if desc == "" {
desc = "api_key required"
}
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
if model.ModelName == currentModel && currentModel != "" {
label = "* " + label
}
isSelected := model.ModelName == currentModel && currentModel != ""
items = append(items, MenuItem{
Label: label,
Description: desc,
MainColor: modelStatusColor(isValid, isSelected),
Action: func() {
s.push(fmt.Sprintf("model-%d", index), s.modelForm(index))
},
})
}
items = append(items,
MenuItem{
Label: "**Add Model**",
Description: "Append a new model entry",
Action: func() {
newName := s.nextAvailableModelName("new-model")
s.addModel(
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.2"},
)
s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1))
},
},
)
menu.applyItems(items)
}
func isModelValid(model picoclawconfig.ModelConfig) bool {
hasKey := strings.TrimSpace(model.APIKey) != "" ||
strings.TrimSpace(model.AuthMethod) == "oauth"
hasModel := strings.TrimSpace(model.Model) != ""
return hasKey && hasModel
}
func (s *appState) modelNameExists(name string, excludeIndex int) bool {
target := strings.TrimSpace(name)
if target == "" {
return false
}
for i := range s.config.ModelList {
if i == excludeIndex {
continue
}
if strings.TrimSpace(s.config.ModelList[i].ModelName) == target {
return true
}
}
return false
}
func (s *appState) nextAvailableModelName(base string) string {
name := strings.TrimSpace(base)
if name == "" {
name = "new-model"
}
if !s.modelNameExists(name, -1) {
return name
}
for i := 2; ; i++ {
candidate := fmt.Sprintf("%s-%d", name, i)
if !s.modelNameExists(candidate, -1) {
return candidate
}
}
}
func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
if model == nil {
return
}
if strings.TrimSpace(model.APIKey) == "" {
s.showMessage("Missing API Key", "Set api_key before testing")
return
}
base := strings.TrimSpace(model.APIBase)
if base == "" {
s.showMessage("Missing API Base", "Set api_base before testing")
return
}
modelID := strings.TrimSpace(model.Model)
if modelID == "" {
s.showMessage("Missing Model", "Set model before testing")
return
}
if !strings.HasPrefix(modelID, "openai/") {
s.showMessage("Unsupported model", "Only openai/* models are supported for test")
return
}
modelName := strings.TrimPrefix(modelID, "openai/")
endpoint := strings.TrimRight(base, "/") + "/chat/completions"
payload := fmt.Sprintf(
`{"model":"%s","messages":[{"role":"user","content":"ping"}],"max_tokens":1}`,
modelName,
)
client := &http.Client{Timeout: 10 * time.Second}
request, err := http.NewRequest("POST", endpoint, strings.NewReader(payload))
if err != nil {
s.showMessage("Test failed", err.Error())
return
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(model.APIKey))
resp, err := client.Do(request)
if err != nil {
s.showMessage("Test failed", err.Error())
return
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
s.showMessage("Test OK", resp.Status)
return
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 2048))
if err != nil {
s.showMessage("Test failed", fmt.Sprintf("failed to read response: %v", err))
return
}
s.showMessage(
"Test failed",
fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))),
)
}

View file

@ -0,0 +1,55 @@
package ui
import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
const (
colorBlue = "[#3e5db9]"
colorRed = "[#d54646]"
banner = "\r\n[::b]" +
colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" +
colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" +
colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" +
colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" +
colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
"[:]"
)
func applyStyles() {
tview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(12, 13, 22)
tview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(34, 19, 53)
tview.Styles.MoreContrastBackgroundColor = tcell.NewRGBColor(18, 18, 32)
tview.Styles.BorderColor = tcell.NewRGBColor(112, 102, 255)
tview.Styles.TitleColor = tcell.NewRGBColor(255, 121, 198)
tview.Styles.GraphicsColor = tcell.NewRGBColor(139, 233, 253)
tview.Styles.PrimaryTextColor = tcell.NewRGBColor(241, 250, 255)
tview.Styles.SecondaryTextColor = tcell.NewRGBColor(80, 250, 123)
tview.Styles.TertiaryTextColor = tcell.NewRGBColor(139, 233, 253)
tview.Styles.InverseTextColor = tcell.NewRGBColor(12, 13, 22)
tview.Styles.ContrastSecondaryTextColor = tcell.NewRGBColor(189, 147, 249)
}
func bannerView() *tview.TextView {
text := tview.NewTextView()
text.SetDynamicColors(true)
text.SetTextAlign(tview.AlignCenter)
text.SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
text.SetText(banner)
text.SetBorder(false)
return text
}
const footerText = "Esc: Back/Exit | Enter: Enter | ←↓↑→ : Move | Space: Select | Tab/Shift+Tab: Switch"
func footerView() *tview.TextView {
text := tview.NewTextView()
text.SetTextAlign(tview.AlignCenter)
text.SetText(footerText)
text.SetBackgroundColor(tview.Styles.MoreContrastBackgroundColor)
text.SetTextColor(tview.Styles.PrimaryTextColor)
text.SetBorder(false)
return text
}

View file

@ -0,0 +1,15 @@
package main
import (
"fmt"
"os"
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/ui"
)
func main() {
if err := ui.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

View file

@ -1,227 +0,0 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
package main
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/sipeed/picoclaw/pkg/cron"
)
func cronCmd() {
if len(os.Args) < 3 {
cronHelp()
return
}
subcommand := os.Args[2]
// Load config to get workspace path
cfg, err := loadConfig()
if err != nil {
fmt.Printf("Error loading config: %v\n", err)
return
}
cronStorePath := filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json")
switch subcommand {
case "list":
cronListCmd(cronStorePath)
case "add":
cronAddCmd(cronStorePath)
case "remove":
if len(os.Args) < 4 {
fmt.Println("Usage: picoclaw cron remove <job_id>")
return
}
cronRemoveCmd(cronStorePath, os.Args[3])
case "enable":
cronEnableCmd(cronStorePath, false)
case "disable":
cronEnableCmd(cronStorePath, true)
default:
fmt.Printf("Unknown cron command: %s\n", subcommand)
cronHelp()
}
}
func cronHelp() {
fmt.Println("\nCron commands:")
fmt.Println(" list List all scheduled jobs")
fmt.Println(" add Add a new scheduled job")
fmt.Println(" remove <id> Remove a job by ID")
fmt.Println(" enable <id> Enable a job")
fmt.Println(" disable <id> Disable a job")
fmt.Println()
fmt.Println("Add options:")
fmt.Println(" -n, --name Job name")
fmt.Println(" -m, --message Message for agent")
fmt.Println(" -e, --every Run every N seconds")
fmt.Println(" -c, --cron Cron expression (e.g. '0 9 * * *')")
fmt.Println(" -d, --deliver Deliver response to channel")
fmt.Println(" --to Recipient for delivery")
fmt.Println(" --channel Channel for delivery")
}
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 cronAddCmd(storePath string) {
name := ""
message := ""
var everySec *int64
cronExpr := ""
deliver := false
channel := ""
to := ""
args := os.Args[3:]
for i := 0; i < len(args); i++ {
switch args[i] {
case "-n", "--name":
if i+1 < len(args) {
name = args[i+1]
i++
}
case "-m", "--message":
if i+1 < len(args) {
message = args[i+1]
i++
}
case "-e", "--every":
if i+1 < len(args) {
var sec int64
fmt.Sscanf(args[i+1], "%d", &sec)
everySec = &sec
i++
}
case "-c", "--cron":
if i+1 < len(args) {
cronExpr = args[i+1]
i++
}
case "-d", "--deliver":
deliver = true
case "--to":
if i+1 < len(args) {
to = args[i+1]
i++
}
case "--channel":
if i+1 < len(args) {
channel = args[i+1]
i++
}
}
}
if name == "" {
fmt.Println("Error: --name is required")
return
}
if message == "" {
fmt.Println("Error: --message is required")
return
}
if everySec == nil && cronExpr == "" {
fmt.Println("Error: Either --every or --cron must be specified")
return
}
var schedule cron.CronSchedule
if everySec != nil {
everyMS := *everySec * 1000
schedule = cron.CronSchedule{
Kind: "every",
EveryMS: &everyMS,
}
} else {
schedule = cron.CronSchedule{
Kind: "cron",
Expr: cronExpr,
}
}
cs := cron.NewCronService(storePath, nil)
job, err := cs.AddJob(name, schedule, message, deliver, channel, to)
if err != nil {
fmt.Printf("Error adding job: %v\n", err)
return
}
fmt.Printf("✓ Added job '%s' (%s)\n", job.Name, job.ID)
}
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 cronEnableCmd(storePath string, disable bool) {
if len(os.Args) < 4 {
fmt.Println("Usage: picoclaw cron enable/disable <job_id>")
return
}
jobID := os.Args[3]
cs := cron.NewCronService(storePath, nil)
enabled := !disable
job := cs.EnableJob(jobID, enabled)
if job != nil {
status := "enabled"
if disable {
status = "disabled"
}
fmt.Printf("✓ Job '%s' %s\n", job.Name, status)
} else {
fmt.Printf("✗ Job %s not found\n", jobID)
}
}

View file

@ -1,81 +0,0 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
package main
import (
"fmt"
"os"
"github.com/sipeed/picoclaw/pkg/migrate"
)
func migrateCmd() {
if len(os.Args) > 2 && (os.Args[2] == "--help" || os.Args[2] == "-h") {
migrateHelp()
return
}
opts := migrate.Options{}
args := os.Args[2:]
for i := 0; i < len(args); i++ {
switch args[i] {
case "--dry-run":
opts.DryRun = true
case "--config-only":
opts.ConfigOnly = true
case "--workspace-only":
opts.WorkspaceOnly = true
case "--force":
opts.Force = true
case "--refresh":
opts.Refresh = true
case "--openclaw-home":
if i+1 < len(args) {
opts.OpenClawHome = args[i+1]
i++
}
case "--picoclaw-home":
if i+1 < len(args) {
opts.PicoClawHome = args[i+1]
i++
}
default:
fmt.Printf("Unknown flag: %s\n", args[i])
migrateHelp()
os.Exit(1)
}
}
result, err := migrate.Run(opts)
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
if !opts.DryRun {
migrate.PrintSummary(result)
}
}
func migrateHelp() {
fmt.Println("\nMigrate from OpenClaw to PicoClaw")
fmt.Println()
fmt.Println("Usage: picoclaw migrate [options]")
fmt.Println()
fmt.Println("Options:")
fmt.Println(" --dry-run Show what would be migrated without making changes")
fmt.Println(" --refresh Re-sync workspace files from OpenClaw (repeatable)")
fmt.Println(" --config-only Only migrate config, skip workspace files")
fmt.Println(" --workspace-only Only migrate workspace files, skip config")
fmt.Println(" --force Skip confirmation prompts")
fmt.Println(" --openclaw-home Override OpenClaw home directory (default: ~/.openclaw)")
fmt.Println(" --picoclaw-home Override PicoClaw home directory (default: ~/.picoclaw)")
fmt.Println()
fmt.Println("Examples:")
fmt.Println(" picoclaw migrate Detect and migrate from OpenClaw")
fmt.Println(" picoclaw migrate --dry-run Show what would be migrated")
fmt.Println(" picoclaw migrate --refresh Re-sync workspace files")
fmt.Println(" picoclaw migrate --force Migrate without confirmation")
}

View file

@ -0,0 +1,30 @@
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
}

View file

@ -0,0 +1,33 @@
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"))
}

View file

@ -1,7 +1,4 @@
// PicoClaw - Ultra-lightweight personal AI agent package agent
// License: MIT
package main
import ( import (
"bufio" "bufio"
@ -14,62 +11,44 @@ import (
"github.com/chzyer/readline" "github.com/chzyer/readline"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/agent" "github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
) )
func agentCmd() { func agentCmd(message, sessionKey, model string, debug bool) error {
message := "" if sessionKey == "" {
sessionKey := "cli:default" sessionKey = "cli:default"
modelOverride := "" }
args := os.Args[2:] if debug {
for i := 0; i < len(args); i++ {
switch args[i] {
case "--debug", "-d":
logger.SetLevel(logger.DEBUG) logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled") fmt.Println("🔍 Debug mode enabled")
case "-m", "--message":
if i+1 < len(args) {
message = args[i+1]
i++
}
case "-s", "--session":
if i+1 < len(args) {
sessionKey = args[i+1]
i++
}
case "--model", "-model":
if i+1 < len(args) {
modelOverride = args[i+1]
i++
}
}
} }
cfg, err := loadConfig() cfg, err := internal.LoadConfig()
if err != nil { if err != nil {
fmt.Printf("Error loading config: %v\n", err) return fmt.Errorf("error loading config: %w", err)
os.Exit(1)
} }
if modelOverride != "" { if model != "" {
cfg.Agents.Defaults.Model = modelOverride cfg.Agents.Defaults.ModelName = model
} }
provider, modelID, err := providers.CreateProvider(cfg) provider, modelID, err := providers.CreateProvider(cfg)
if err != nil { if err != nil {
fmt.Printf("Error creating provider: %v\n", err) return fmt.Errorf("error creating provider: %w", err)
os.Exit(1)
} }
// Use the resolved model ID from provider creation // Use the resolved model ID from provider creation
if modelID != "" { if modelID != "" {
cfg.Agents.Defaults.Model = modelID cfg.Agents.Defaults.ModelName = modelID
} }
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
defer msgBus.Close()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
// Print agent startup info (only for interactive mode) // Print agent startup info (only for interactive mode)
@ -85,18 +64,20 @@ func agentCmd() {
ctx := context.Background() ctx := context.Background()
response, err := agentLoop.ProcessDirect(ctx, message, sessionKey) response, err := agentLoop.ProcessDirect(ctx, message, sessionKey)
if err != nil { if err != nil {
fmt.Printf("Error: %v\n", err) return fmt.Errorf("error processing message: %w", err)
os.Exit(1)
} }
fmt.Printf("\n%s %s\n", logo, response) fmt.Printf("\n%s %s\n", internal.Logo, response)
} else { return nil
fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", logo) }
fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", internal.Logo)
interactiveMode(agentLoop, sessionKey) interactiveMode(agentLoop, sessionKey)
}
return nil
} }
func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
prompt := fmt.Sprintf("%s You: ", logo) prompt := fmt.Sprintf("%s You: ", internal.Logo)
rl, err := readline.NewEx(&readline.Config{ rl, err := readline.NewEx(&readline.Config{
Prompt: prompt, Prompt: prompt,
@ -141,14 +122,14 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
continue continue
} }
fmt.Printf("\n%s %s\n\n", logo, response) fmt.Printf("\n%s %s\n\n", internal.Logo, response)
} }
} }
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) { func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
reader := bufio.NewReader(os.Stdin) reader := bufio.NewReader(os.Stdin)
for { for {
fmt.Printf("%s You: ", logo) fmt.Print(fmt.Sprintf("%s You: ", internal.Logo))
line, err := reader.ReadString('\n') line, err := reader.ReadString('\n')
if err != nil { if err != nil {
if err == io.EOF { if err == io.EOF {
@ -176,6 +157,6 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
continue continue
} }
fmt.Printf("\n%s %s\n\n", logo, response) fmt.Printf("\n%s %s\n\n", internal.Logo, response)
} }
} }

View file

@ -0,0 +1,22 @@
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(),
)
return cmd
}

View file

@ -0,0 +1,55 @@
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",
}
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)
}
}

View file

@ -1,9 +1,7 @@
// PicoClaw - Ultra-lightweight personal AI agent package auth
// License: MIT
package main
import ( import (
"bufio"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@ -12,92 +10,31 @@ import (
"strings" "strings"
"time" "time"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
) )
const supportedProvidersMsg = "Supported providers: openai, anthropic, google-antigravity" const (
supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
func authCmd() { defaultAnthropicModel = "claude-sonnet-4.6"
if len(os.Args) < 3 { )
authHelp()
return
}
switch os.Args[2] {
case "login":
authLoginCmd()
case "logout":
authLogoutCmd()
case "status":
authStatusCmd()
case "models":
authModelsCmd()
default:
fmt.Printf("Unknown auth command: %s\n", os.Args[2])
authHelp()
}
}
func authHelp() {
fmt.Println("\nAuth commands:")
fmt.Println(" login Login via OAuth or paste token")
fmt.Println(" logout Remove stored credentials")
fmt.Println(" status Show current auth status")
fmt.Println(" models List available Antigravity models")
fmt.Println()
fmt.Println("Login options:")
fmt.Println(" --provider <name> Provider to login with (openai, anthropic, google-antigravity)")
fmt.Println(" --device-code Use device code flow (for headless environments)")
fmt.Println()
fmt.Println("Examples:")
fmt.Println(" picoclaw auth login --provider openai")
fmt.Println(" picoclaw auth login --provider openai --device-code")
fmt.Println(" picoclaw auth login --provider anthropic")
fmt.Println(" picoclaw auth login --provider google-antigravity")
fmt.Println(" picoclaw auth models")
fmt.Println(" picoclaw auth logout --provider openai")
fmt.Println(" picoclaw auth status")
}
func authLoginCmd() {
provider := ""
useDeviceCode := false
args := os.Args[3:]
for i := 0; i < len(args); i++ {
switch args[i] {
case "--provider", "-p":
if i+1 < len(args) {
provider = args[i+1]
i++
}
case "--device-code":
useDeviceCode = true
}
}
if provider == "" {
fmt.Println("Error: --provider is required")
fmt.Println(supportedProvidersMsg)
return
}
func authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error {
switch provider { switch provider {
case "openai": case "openai":
authLoginOpenAI(useDeviceCode) return authLoginOpenAI(useDeviceCode)
case "anthropic": case "anthropic":
authLoginPasteToken(provider) return authLoginAnthropic(useOauth)
case "google-antigravity", "antigravity": case "google-antigravity", "antigravity":
authLoginGoogleAntigravity() return authLoginGoogleAntigravity()
default: default:
fmt.Printf("Unsupported provider: %s\n", provider) return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg)
fmt.Println(supportedProvidersMsg)
} }
} }
func authLoginOpenAI(useDeviceCode bool) { func authLoginOpenAI(useDeviceCode bool) error {
cfg := auth.OpenAIOAuthConfig() cfg := auth.OpenAIOAuthConfig()
var cred *auth.AuthCredential var cred *auth.AuthCredential
@ -110,16 +47,14 @@ func authLoginOpenAI(useDeviceCode bool) {
} }
if err != nil { if err != nil {
fmt.Printf("Login failed: %v\n", err) return fmt.Errorf("login failed: %w", err)
os.Exit(1)
} }
if err = auth.SetCredential("openai", cred); err != nil { if err = auth.SetCredential("openai", cred); err != nil {
fmt.Printf("Failed to save credentials: %v\n", err) return fmt.Errorf("failed to save credentials: %w", err)
os.Exit(1)
} }
appCfg, err := loadConfig() appCfg, err := internal.LoadConfig()
if err == nil { if err == nil {
// Update Providers (legacy format) // Update Providers (legacy format)
appCfg.Providers.OpenAI.AuthMethod = "oauth" appCfg.Providers.OpenAI.AuthMethod = "oauth"
@ -144,10 +79,10 @@ func authLoginOpenAI(useDeviceCode bool) {
} }
// Update default model to use OpenAI // Update default model to use OpenAI
appCfg.Agents.Defaults.Model = "gpt-5.2" appCfg.Agents.Defaults.ModelName = "gpt-5.2"
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil { if err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
fmt.Printf("Warning: could not update config: %v\n", err) return fmt.Errorf("could not update config: %w", err)
} }
} }
@ -156,15 +91,16 @@ func authLoginOpenAI(useDeviceCode bool) {
fmt.Printf("Account: %s\n", cred.AccountID) fmt.Printf("Account: %s\n", cred.AccountID)
} }
fmt.Println("Default model set to: gpt-5.2") fmt.Println("Default model set to: gpt-5.2")
return nil
} }
func authLoginGoogleAntigravity() { func authLoginGoogleAntigravity() error {
cfg := auth.GoogleAntigravityOAuthConfig() cfg := auth.GoogleAntigravityOAuthConfig()
cred, err := auth.LoginBrowser(cfg) cred, err := auth.LoginBrowser(cfg)
if err != nil { if err != nil {
fmt.Printf("Login failed: %v\n", err) return fmt.Errorf("login failed: %w", err)
os.Exit(1)
} }
cred.Provider = "google-antigravity" cred.Provider = "google-antigravity"
@ -189,11 +125,10 @@ func authLoginGoogleAntigravity() {
} }
if err = auth.SetCredential("google-antigravity", cred); err != nil { if err = auth.SetCredential("google-antigravity", cred); err != nil {
fmt.Printf("Failed to save credentials: %v\n", err) return fmt.Errorf("failed to save credentials: %w", err)
os.Exit(1)
} }
appCfg, err := loadConfig() appCfg, err := internal.LoadConfig()
if err == nil { if err == nil {
// Update Providers (legacy format, for backward compatibility) // Update Providers (legacy format, for backward compatibility)
appCfg.Providers.Antigravity.AuthMethod = "oauth" appCfg.Providers.Antigravity.AuthMethod = "oauth"
@ -218,9 +153,9 @@ func authLoginGoogleAntigravity() {
} }
// Update default model // Update default model
appCfg.Agents.Defaults.Model = "gemini-flash" appCfg.Agents.Defaults.ModelName = "gemini-flash"
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil { if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
fmt.Printf("Warning: could not update config: %v\n", err) fmt.Printf("Warning: could not update config: %v\n", err)
} }
} }
@ -228,6 +163,83 @@ func authLoginGoogleAntigravity() {
fmt.Println("\n✓ Google Antigravity login successful!") fmt.Println("\n✓ Google Antigravity login successful!")
fmt.Println("Default model set to: gemini-flash") fmt.Println("Default model set to: gemini-flash")
fmt.Println("Try it: picoclaw agent -m \"Hello world\"") 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 {
appCfg.Providers.Anthropic.AuthMethod = "oauth"
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) { func fetchGoogleUserEmail(accessToken string) (string, error) {
@ -244,7 +256,10 @@ func fetchGoogleUserEmail(accessToken string) (string, error) {
} }
defer resp.Body.Close() defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("reading userinfo response: %w", err)
}
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("userinfo request failed: %s", string(body)) return "", fmt.Errorf("userinfo request failed: %s", string(body))
} }
@ -258,19 +273,17 @@ func fetchGoogleUserEmail(accessToken string) (string, error) {
return userInfo.Email, nil return userInfo.Email, nil
} }
func authLoginPasteToken(provider string) { func authLoginPasteToken(provider string) error {
cred, err := auth.LoginPasteToken(provider, os.Stdin) cred, err := auth.LoginPasteToken(provider, os.Stdin)
if err != nil { if err != nil {
fmt.Printf("Login failed: %v\n", err) return fmt.Errorf("login failed: %w", err)
os.Exit(1)
} }
if err = auth.SetCredential(provider, cred); err != nil { if err = auth.SetCredential(provider, cred); err != nil {
fmt.Printf("Failed to save credentials: %v\n", err) return fmt.Errorf("failed to save credentials: %w", err)
os.Exit(1)
} }
appCfg, err := loadConfig() appCfg, err := internal.LoadConfig()
if err == nil { if err == nil {
switch provider { switch provider {
case "anthropic": case "anthropic":
@ -286,13 +299,12 @@ func authLoginPasteToken(provider string) {
} }
if !found { if !found {
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
ModelName: "claude-sonnet-4.6", ModelName: defaultAnthropicModel,
Model: "anthropic/claude-sonnet-4.6", Model: "anthropic/" + defaultAnthropicModel,
AuthMethod: "token", AuthMethod: "token",
}) })
appCfg.Agents.Defaults.ModelName = defaultAnthropicModel
} }
// Update default model
appCfg.Agents.Defaults.Model = "claude-sonnet-4.6"
case "openai": case "openai":
appCfg.Providers.OpenAI.AuthMethod = "token" appCfg.Providers.OpenAI.AuthMethod = "token"
// Update ModelList // Update ModelList
@ -312,38 +324,29 @@ func authLoginPasteToken(provider string) {
}) })
} }
// Update default model // Update default model
appCfg.Agents.Defaults.Model = "gpt-5.2" appCfg.Agents.Defaults.ModelName = "gpt-5.2"
} }
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil { if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
fmt.Printf("Warning: could not update config: %v\n", err) return fmt.Errorf("could not update config: %w", err)
} }
} }
fmt.Printf("Token saved for %s!\n", provider) fmt.Printf("Token saved for %s!\n", provider)
fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.Model)
if appCfg != nil {
fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.GetModelName())
}
return nil
} }
func authLogoutCmd() { func authLogoutCmd(provider string) error {
provider := ""
args := os.Args[3:]
for i := 0; i < len(args); i++ {
switch args[i] {
case "--provider", "-p":
if i+1 < len(args) {
provider = args[i+1]
i++
}
}
}
if provider != "" { if provider != "" {
if err := auth.DeleteCredential(provider); err != nil { if err := auth.DeleteCredential(provider); err != nil {
fmt.Printf("Failed to remove credentials: %v\n", err) return fmt.Errorf("failed to remove credentials: %w", err)
os.Exit(1)
} }
appCfg, err := loadConfig() appCfg, err := internal.LoadConfig()
if err == nil { if err == nil {
// Clear AuthMethod in ModelList // Clear AuthMethod in ModelList
for i := range appCfg.ModelList { for i := range appCfg.ModelList {
@ -371,17 +374,19 @@ func authLogoutCmd() {
case "google-antigravity", "antigravity": case "google-antigravity", "antigravity":
appCfg.Providers.Antigravity.AuthMethod = "" appCfg.Providers.Antigravity.AuthMethod = ""
} }
config.SaveConfig(getConfigPath(), appCfg) config.SaveConfig(internal.GetConfigPath(), appCfg)
} }
fmt.Printf("Logged out from %s\n", provider) fmt.Printf("Logged out from %s\n", provider)
} else {
if err := auth.DeleteAllCredentials(); err != nil { return nil
fmt.Printf("Failed to remove credentials: %v\n", err)
os.Exit(1)
} }
appCfg, err := loadConfig() if err := auth.DeleteAllCredentials(); err != nil {
return fmt.Errorf("failed to remove credentials: %w", err)
}
appCfg, err := internal.LoadConfig()
if err == nil { if err == nil {
// Clear all AuthMethods in ModelList // Clear all AuthMethods in ModelList
for i := range appCfg.ModelList { for i := range appCfg.ModelList {
@ -391,24 +396,24 @@ func authLogoutCmd() {
appCfg.Providers.OpenAI.AuthMethod = "" appCfg.Providers.OpenAI.AuthMethod = ""
appCfg.Providers.Anthropic.AuthMethod = "" appCfg.Providers.Anthropic.AuthMethod = ""
appCfg.Providers.Antigravity.AuthMethod = "" appCfg.Providers.Antigravity.AuthMethod = ""
config.SaveConfig(getConfigPath(), appCfg) config.SaveConfig(internal.GetConfigPath(), appCfg)
} }
fmt.Println("Logged out from all providers") fmt.Println("Logged out from all providers")
}
return nil
} }
func authStatusCmd() { func authStatusCmd() error {
store, err := auth.LoadStore() store, err := auth.LoadStore()
if err != nil { if err != nil {
fmt.Printf("Error loading auth store: %v\n", err) return fmt.Errorf("failed to load auth store: %w", err)
return
} }
if len(store.Credentials) == 0 { if len(store.Credentials) == 0 {
fmt.Println("No authenticated providers.") fmt.Println("No authenticated providers.")
fmt.Println("Run: picoclaw auth login --provider <name>") fmt.Println("Run: picoclaw auth login --provider <name>")
return return nil
} }
fmt.Println("\nAuthenticated Providers:") fmt.Println("\nAuthenticated Providers:")
@ -436,15 +441,27 @@ func authStatusCmd() {
if !cred.ExpiresAt.IsZero() { if !cred.ExpiresAt.IsZero() {
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04")) 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() { func authModelsCmd() error {
cred, err := auth.GetCredential("google-antigravity") cred, err := auth.GetCredential("google-antigravity")
if err != nil || cred == nil { if err != nil || cred == nil {
fmt.Println("Not logged in to Google Antigravity.") return fmt.Errorf(
fmt.Println("Run: picoclaw auth login --provider google-antigravity") "not logged in to Google Antigravity.\nrun: picoclaw auth login --provider google-antigravity",
return )
} }
// Refresh token if needed // Refresh token if needed
@ -459,21 +476,18 @@ func authModelsCmd() {
projectID := cred.ProjectID projectID := cred.ProjectID
if projectID == "" { if projectID == "" {
fmt.Println("No project ID stored. Try logging in again.") return fmt.Errorf("no project id stored. Try logging in again")
return
} }
fmt.Printf("Fetching models for project: %s\n\n", projectID) fmt.Printf("Fetching models for project: %s\n\n", projectID)
models, err := providers.FetchAntigravityModels(cred.AccessToken, projectID) models, err := providers.FetchAntigravityModels(cred.AccessToken, projectID)
if err != nil { if err != nil {
fmt.Printf("Error fetching models: %v\n", err) return fmt.Errorf("error fetching models: %w", err)
return
} }
if len(models) == 0 { if len(models) == 0 {
fmt.Println("No models available.") return fmt.Errorf("no models available")
return
} }
fmt.Println("Available Antigravity Models:") fmt.Println("Available Antigravity Models:")
@ -489,6 +503,8 @@ func authModelsCmd() {
} }
fmt.Printf(" %s %s\n", status, name) fmt.Printf(" %s %s\n", status, name)
} }
return nil
} }
// isAntigravityModel checks if a model string belongs to antigravity provider // isAntigravityModel checks if a model string belongs to antigravity provider

View file

@ -0,0 +1,30 @@
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
}

View file

@ -0,0 +1,29 @@
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])
}

View file

@ -0,0 +1,20 @@
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
}

View file

@ -0,0 +1,20 @@
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"))
}

View file

@ -0,0 +1,15 @@
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
}

View file

@ -0,0 +1,19 @@
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())
}

View file

@ -0,0 +1,16 @@
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
}

View file

@ -0,0 +1,18 @@
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())
}

View file

@ -0,0 +1,64 @@
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
}

View file

@ -0,0 +1,57 @@
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)
}

View file

@ -0,0 +1,44 @@
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
}

View file

@ -0,0 +1,58 @@
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)
}
}

View file

@ -0,0 +1,16 @@
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
},
}
}

View file

@ -0,0 +1,20 @@
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())
}

View file

@ -0,0 +1,16 @@
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
},
}
}

View file

@ -0,0 +1,20 @@
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())
}

View file

@ -0,0 +1,66 @@
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)
}
}

View file

@ -0,0 +1,17 @@
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
}

View file

@ -0,0 +1,17 @@
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)
}

View file

@ -0,0 +1,18 @@
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
}

View file

@ -0,0 +1,19 @@
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())
}

View file

@ -0,0 +1,23 @@
package gateway
import (
"github.com/spf13/cobra"
)
func NewGatewayCommand() *cobra.Command {
var debug bool
cmd := &cobra.Command{
Use: "gateway",
Aliases: []string{"g"},
Short: "Start picoclaw gateway",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error {
return gatewayCmd(debug)
},
}
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
return cmd
}

View file

@ -0,0 +1,31 @@
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"))
}

View file

@ -1,58 +1,65 @@
// PicoClaw - Ultra-lightweight personal AI agent package gateway
// License: MIT
package main
import ( import (
"context" "context"
"fmt" "fmt"
"net/http" "log"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/agent" "github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/channels"
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
_ "github.com/sipeed/picoclaw/pkg/channels/irc"
_ "github.com/sipeed/picoclaw/pkg/channels/line"
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
_ "github.com/sipeed/picoclaw/pkg/channels/matrix"
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
_ "github.com/sipeed/picoclaw/pkg/channels/pico"
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
_ "github.com/sipeed/picoclaw/pkg/channels/slack"
_ "github.com/sipeed/picoclaw/pkg/channels/telegram"
_ "github.com/sipeed/picoclaw/pkg/channels/wecom"
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp"
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/cron" "github.com/sipeed/picoclaw/pkg/cron"
"github.com/sipeed/picoclaw/pkg/devices" "github.com/sipeed/picoclaw/pkg/devices"
"github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/health"
"github.com/sipeed/picoclaw/pkg/heartbeat" "github.com/sipeed/picoclaw/pkg/heartbeat"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/voice" "github.com/sipeed/picoclaw/pkg/voice"
) )
func gatewayCmd() { func gatewayCmd(debug bool) error {
// Check for --debug flag if debug {
args := os.Args[2:]
for _, arg := range args {
if arg == "--debug" || arg == "-d" {
logger.SetLevel(logger.DEBUG) logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled") fmt.Println("🔍 Debug mode enabled")
break
}
} }
cfg, err := loadConfig() cfg, err := internal.LoadConfig()
if err != nil { if err != nil {
fmt.Printf("Error loading config: %v\n", err) return fmt.Errorf("error loading config: %w", err)
os.Exit(1)
} }
provider, modelID, err := providers.CreateProvider(cfg) provider, modelID, err := providers.CreateProvider(cfg)
if err != nil { if err != nil {
fmt.Printf("Error creating provider: %v\n", err) return fmt.Errorf("error creating provider: %w", err)
os.Exit(1)
} }
// Use the resolved model ID from provider creation // Use the resolved model ID from provider creation
if modelID != "" { if modelID != "" {
cfg.Agents.Defaults.Model = modelID cfg.Agents.Defaults.ModelName = modelID
} }
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
@ -112,49 +119,28 @@ func gatewayCmd() {
return tools.SilentResult(response) return tools.SilentResult(response)
}) })
channelManager, err := channels.NewManager(cfg, msgBus) // Create media store for file lifecycle management with TTL cleanup
mediaStore := media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
Enabled: cfg.Tools.MediaCleanup.Enabled,
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,
})
mediaStore.Start()
channelManager, err := channels.NewManager(cfg, msgBus, mediaStore)
if err != nil { if err != nil {
fmt.Printf("Error creating channel manager: %v\n", err) mediaStore.Stop()
os.Exit(1) return fmt.Errorf("error creating channel manager: %w", err)
} }
// Inject channel manager into agent loop for command handling // Inject channel manager and media store into agent loop
agentLoop.SetChannelManager(channelManager) agentLoop.SetChannelManager(channelManager)
agentLoop.SetMediaStore(mediaStore)
var transcriber *voice.GroqTranscriber // Wire up voice transcription if a supported provider is configured.
groqAPIKey := cfg.Providers.Groq.APIKey if transcriber := voice.DetectTranscriber(cfg); transcriber != nil {
if groqAPIKey == "" { agentLoop.SetTranscriber(transcriber)
for _, mc := range cfg.ModelList { logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" {
groqAPIKey = mc.APIKey
break
}
}
}
if groqAPIKey != "" {
transcriber = voice.NewGroqTranscriber(groqAPIKey)
logger.InfoC("voice", "Groq voice transcription enabled")
}
if transcriber != nil {
if telegramChannel, ok := channelManager.GetChannel("telegram"); ok {
if tc, ok := telegramChannel.(*channels.TelegramChannel); ok {
tc.SetTranscriber(transcriber)
logger.InfoC("voice", "Groq transcription attached to Telegram channel")
}
}
if discordChannel, ok := channelManager.GetChannel("discord"); ok {
if dc, ok := discordChannel.(*channels.DiscordChannel); ok {
dc.SetTranscriber(transcriber)
logger.InfoC("voice", "Groq transcription attached to Discord channel")
}
}
if slackChannel, ok := channelManager.GetChannel("slack"); ok {
if sc, ok := slackChannel.(*channels.SlackChannel); ok {
sc.SetTranscriber(transcriber)
logger.InfoC("voice", "Groq transcription attached to Slack channel")
}
}
} }
enabledChannels := channelManager.GetEnabledChannels() enabledChannels := channelManager.GetEnabledChannels()
@ -192,16 +178,16 @@ func gatewayCmd() {
fmt.Println("✓ Device event service started") fmt.Println("✓ Device event service started")
} }
// Setup shared HTTP server with health endpoints and webhook handlers
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
channelManager.SetupHTTPServer(addr, healthServer)
if err := channelManager.StartAll(ctx); err != nil { if err := channelManager.StartAll(ctx); err != nil {
fmt.Printf("Error starting channels: %v\n", err) fmt.Printf("Error starting channels: %v\n", err)
return err
} }
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
go func() {
if err := healthServer.Start(); err != nil && err != http.ErrServerClosed {
logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()})
}
}()
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port) fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
go agentLoop.Run(ctx) go agentLoop.Run(ctx)
@ -211,14 +197,26 @@ func gatewayCmd() {
<-sigChan <-sigChan
fmt.Println("\nShutting down...") fmt.Println("\nShutting down...")
if cp, ok := provider.(providers.StatefulProvider); ok {
cp.Close()
}
cancel() cancel()
healthServer.Stop(context.Background()) msgBus.Close()
// Use a fresh context with timeout for graceful shutdown,
// since the original ctx is already canceled.
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer shutdownCancel()
channelManager.StopAll(shutdownCtx)
deviceService.Stop() deviceService.Stop()
heartbeatService.Stop() heartbeatService.Stop()
cronService.Stop() cronService.Stop()
mediaStore.Stop()
agentLoop.Stop() agentLoop.Stop()
channelManager.StopAll(ctx)
fmt.Println("✓ Gateway stopped") fmt.Println("✓ Gateway stopped")
return nil
} }
func setupCronTool( func setupCronTool(
@ -234,15 +232,25 @@ func setupCronTool(
// Create cron service // Create cron service
cronService := cron.NewCronService(cronStorePath, nil) cronService := cron.NewCronService(cronStorePath, nil)
// Create and register CronTool // Create and register CronTool if enabled
cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg) var cronTool *tools.CronTool
agentLoop.RegisterTool(cronTool) if cfg.Tools.IsToolEnabled("cron") {
var err error
cronTool, err = tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
if err != nil {
log.Fatalf("Critical error during CronTool initialization: %v", err)
}
// Set the onJob handler agentLoop.RegisterTool(cronTool)
}
// Set onJob handler
if cronTool != nil {
cronService.SetOnJob(func(job *cron.CronJob) (string, error) { cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
result := cronTool.ExecuteJob(context.Background(), job) result := cronTool.ExecuteJob(context.Background(), job)
return result, nil return result, nil
}) })
}
return cronService return cronService
} }

View file

@ -0,0 +1,64 @@
package internal
import (
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/sipeed/picoclaw/pkg/config"
)
const Logo = "🦞"
var (
version = "dev"
gitCommit string
buildTime string
goVersion string
)
// GetPicoclawHome returns the picoclaw home directory.
// Priority: $PICOCLAW_HOME > ~/.picoclaw
func GetPicoclawHome() string {
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
return home
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".picoclaw")
}
func GetConfigPath() string {
if configPath := os.Getenv("PICOCLAW_CONFIG"); configPath != "" {
return configPath
}
return filepath.Join(GetPicoclawHome(), "config.json")
}
func LoadConfig() (*config.Config, error) {
return config.LoadConfig(GetConfigPath())
}
// FormatVersion returns the version string with optional git commit
func FormatVersion() string {
v := version
if gitCommit != "" {
v += fmt.Sprintf(" (git: %s)", gitCommit)
}
return v
}
// FormatBuildInfo returns build time and go version info
func FormatBuildInfo() (string, string) {
build := buildTime
goVer := goVersion
if goVer == "" {
goVer = runtime.Version()
}
return build, goVer
}
// GetVersion returns the version string
func GetVersion() string {
return version
}

View file

@ -0,0 +1,128 @@
package internal
import (
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
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("PICOCLAW_HOME", "/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("PICOCLAW_HOME", "/custom/picoclaw")
t.Setenv("HOME", "/tmp/home")
got := GetConfigPath()
want := "/custom/config.json"
assert.Equal(t, want, got)
}
func TestFormatVersion_NoGitCommit(t *testing.T) {
oldVersion, oldGit := version, gitCommit
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
version = "1.2.3"
gitCommit = ""
assert.Equal(t, "1.2.3", FormatVersion())
}
func TestFormatVersion_WithGitCommit(t *testing.T) {
oldVersion, oldGit := version, gitCommit
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
version = "1.2.3"
gitCommit = "abc123"
assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion())
}
func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) {
oldBuildTime, oldGoVersion := buildTime, goVersion
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
buildTime = "2026-02-20T00:00:00Z"
goVersion = "go1.23.0"
build, goVer := FormatBuildInfo()
assert.Equal(t, buildTime, build)
assert.Equal(t, goVersion, goVer)
}
func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) {
oldBuildTime, oldGoVersion := buildTime, goVersion
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
buildTime = ""
goVersion = "go1.23.0"
build, goVer := FormatBuildInfo()
assert.Empty(t, build)
assert.Equal(t, goVersion, goVer)
}
func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) {
oldBuildTime, oldGoVersion := buildTime, goVersion
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
buildTime = "x"
goVersion = ""
build, goVer := FormatBuildInfo()
assert.Equal(t, "x", build)
assert.Equal(t, runtime.Version(), goVer)
}
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)
}
func TestGetVersion(t *testing.T) {
assert.Equal(t, "dev", GetVersion())
}
func TestGetConfigPath_WithEnv(t *testing.T) {
t.Setenv("PICOCLAW_CONFIG", "/tmp/custom/config.json")
t.Setenv("HOME", "/tmp/home") // Also set home to ensure env is preferred
got := GetConfigPath()
want := "/tmp/custom/config.json"
assert.Equal(t, want, got)
}

View file

@ -0,0 +1,52 @@
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
}

View file

@ -0,0 +1,38 @@
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"))
}

View file

@ -0,0 +1,24 @@
package onboard
import (
"embed"
"github.com/spf13/cobra"
)
//go:generate cp -r ../../../../workspace .
//go:embed workspace
var embeddedFiles embed.FS
func NewOnboardCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "onboard",
Aliases: []string{"o"},
Short: "Initialize picoclaw configuration and workspace",
Run: func(cmd *cobra.Command, args []string) {
onboard()
},
}
return cmd
}

View file

@ -0,0 +1,29 @@
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.False(t, cmd.HasFlags())
assert.False(t, cmd.HasSubCommands())
}

View file

@ -1,24 +1,17 @@
// PicoClaw - Ultra-lightweight personal AI agent package onboard
// License: MIT
package main
import ( import (
"embed"
"fmt" "fmt"
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
) )
//go:generate cp -r ../../workspace .
//go:embed workspace
var embeddedFiles embed.FS
func onboard() { func onboard() {
configPath := getConfigPath() configPath := internal.GetConfigPath()
if _, err := os.Stat(configPath); err == nil { if _, err := os.Stat(configPath); err == nil {
fmt.Printf("Config already exists at %s\n", configPath) fmt.Printf("Config already exists at %s\n", configPath)
@ -40,7 +33,7 @@ func onboard() {
workspace := cfg.WorkspacePath() workspace := cfg.WorkspacePath()
createWorkspaceTemplates(workspace) createWorkspaceTemplates(workspace)
fmt.Printf("%s picoclaw is ready!\n", logo) fmt.Printf("%s picoclaw is ready!\n", internal.Logo)
fmt.Println("\nNext steps:") fmt.Println("\nNext steps:")
fmt.Println(" 1. Add your API key to", configPath) fmt.Println(" 1. Add your API key to", configPath)
fmt.Println("") fmt.Println("")
@ -53,6 +46,13 @@ func onboard() {
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"") fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
} }
func createWorkspaceTemplates(workspace string) {
err := copyEmbeddedToTarget(workspace)
if err != nil {
fmt.Printf("Error copying workspace templates: %v\n", err)
}
}
func copyEmbeddedToTarget(targetDir string) error { func copyEmbeddedToTarget(targetDir string) error {
// Ensure target directory exists // Ensure target directory exists
if err := os.MkdirAll(targetDir, 0o755); err != nil { if err := os.MkdirAll(targetDir, 0o755); err != nil {
@ -99,10 +99,3 @@ func copyEmbeddedToTarget(targetDir string) error {
return err return err
} }
func createWorkspaceTemplates(workspace string) {
err := copyEmbeddedToTarget(workspace)
if err != nil {
fmt.Printf("Error copying workspace templates: %v\n", err)
}
}

View file

@ -0,0 +1,25 @@
package onboard
import (
"os"
"path/filepath"
"testing"
)
func TestCopyEmbeddedToTargetUsesAgentsMarkdown(t *testing.T) {
targetDir := t.TempDir()
if err := copyEmbeddedToTarget(targetDir); err != nil {
t.Fatalf("copyEmbeddedToTarget() error = %v", err)
}
agentsPath := filepath.Join(targetDir, "AGENTS.md")
if _, err := os.Stat(agentsPath); err != nil {
t.Fatalf("expected %s to exist: %v", agentsPath, err)
}
legacyPath := filepath.Join(targetDir, "AGENT.md")
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
t.Fatalf("expected legacy file %s to be absent, got err=%v", legacyPath, err)
}
}

View file

@ -0,0 +1,79 @@
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()
d.installer = skills.NewSkillInstaller(d.workspace)
// 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
}

View file

@ -0,0 +1,28 @@
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)
}

View file

@ -1,39 +1,21 @@
// PicoClaw - Ultra-lightweight personal AI agent package skills
// License: MIT
package main
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/utils"
) )
func skillsHelp() { const skillsSearchMaxResults = 20
fmt.Println("\nSkills commands:")
fmt.Println(" list List installed skills")
fmt.Println(" install <repo> Install skill from GitHub")
fmt.Println(" install-builtin Install all builtin skills to workspace")
fmt.Println(" list-builtin List available builtin skills")
fmt.Println(" remove <name> Remove installed skill")
fmt.Println(" search Search available skills")
fmt.Println(" show <name> Show skill details")
fmt.Println()
fmt.Println("Examples:")
fmt.Println(" picoclaw skills list")
fmt.Println(" picoclaw skills install sipeed/picoclaw-skills/weather")
fmt.Println(" picoclaw skills install-builtin")
fmt.Println(" picoclaw skills list-builtin")
fmt.Println(" picoclaw skills remove weather")
fmt.Println(" picoclaw skills install --registry clawhub github")
}
func skillsListCmd(loader *skills.SkillsLoader) { func skillsListCmd(loader *skills.SkillsLoader) {
allSkills := loader.ListSkills() allSkills := loader.ListSkills()
@ -53,53 +35,31 @@ func skillsListCmd(loader *skills.SkillsLoader) {
} }
} }
func skillsInstallCmd(installer *skills.SkillInstaller, cfg *config.Config) { func skillsInstallCmd(installer *skills.SkillInstaller, repo string) error {
if len(os.Args) < 4 {
fmt.Println("Usage: picoclaw skills install <github-repo>")
fmt.Println(" picoclaw skills install --registry <name> <slug>")
return
}
// Check for --registry flag.
if os.Args[3] == "--registry" {
if len(os.Args) < 6 {
fmt.Println("Usage: picoclaw skills install --registry <name> <slug>")
fmt.Println("Example: picoclaw skills install --registry clawhub github")
return
}
registryName := os.Args[4]
slug := os.Args[5]
skillsInstallFromRegistry(cfg, registryName, slug)
return
}
// Default: install from GitHub (backward compatible).
repo := os.Args[3]
fmt.Printf("Installing skill from %s...\n", repo) fmt.Printf("Installing skill from %s...\n", repo)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
if err := installer.InstallFromGitHub(ctx, repo); err != nil { if err := installer.InstallFromGitHub(ctx, repo); err != nil {
fmt.Printf("\u2717 Failed to install skill: %v\n", err) return fmt.Errorf("failed to install skill: %w", err)
os.Exit(1)
} }
fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo)) fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo))
return nil
} }
// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub). // skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).
func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) { func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) error {
err := utils.ValidateSkillIdentifier(registryName) err := utils.ValidateSkillIdentifier(registryName)
if err != nil { if err != nil {
fmt.Printf("\u2717 Invalid registry name: %v\n", err) return fmt.Errorf("✗ invalid registry name: %w", err)
os.Exit(1)
} }
err = utils.ValidateSkillIdentifier(slug) err = utils.ValidateSkillIdentifier(slug)
if err != nil { if err != nil {
fmt.Printf("\u2717 Invalid slug: %v\n", err) return fmt.Errorf("✗ invalid slug: %w", err)
os.Exit(1)
} }
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName) fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
@ -111,24 +71,21 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
registry := registryMgr.GetRegistry(registryName) registry := registryMgr.GetRegistry(registryName)
if registry == nil { if registry == nil {
fmt.Printf("\u2717 Registry '%s' not found or not enabled. Check your config.json.\n", registryName) return fmt.Errorf("✗ registry '%s' not found or not enabled. check your config.json.", registryName)
os.Exit(1)
} }
workspace := cfg.WorkspacePath() workspace := cfg.WorkspacePath()
targetDir := filepath.Join(workspace, "skills", slug) targetDir := filepath.Join(workspace, "skills", slug)
if _, err = os.Stat(targetDir); err == nil { if _, err = os.Stat(targetDir); err == nil {
fmt.Printf("\u2717 Skill '%s' already installed at %s\n", slug, targetDir) return fmt.Errorf("\u2717 skill '%s' already installed at %s", slug, targetDir)
os.Exit(1)
} }
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel() defer cancel()
if err = os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil { if err = os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil {
fmt.Printf("\u2717 Failed to create skills directory: %v\n", err) return fmt.Errorf("\u2717 failed to create skills directory: %v", err)
os.Exit(1)
} }
result, err := registry.DownloadAndInstall(ctx, slug, "", targetDir) result, err := registry.DownloadAndInstall(ctx, slug, "", targetDir)
@ -137,8 +94,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
if rmErr != nil { if rmErr != nil {
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr) fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
} }
fmt.Printf("\u2717 Failed to install skill: %v\n", err) return fmt.Errorf("✗ failed to install skill: %w", err)
os.Exit(1)
} }
if result.IsMalwareBlocked { if result.IsMalwareBlocked {
@ -146,8 +102,8 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
if rmErr != nil { if rmErr != nil {
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr) fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
} }
fmt.Printf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug)
os.Exit(1) return fmt.Errorf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug)
} }
if result.IsSuspicious { if result.IsSuspicious {
@ -158,6 +114,8 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
if result.Summary != "" { if result.Summary != "" {
fmt.Printf(" %s\n", result.Summary) fmt.Printf(" %s\n", result.Summary)
} }
return nil
} }
func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) { func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
@ -208,7 +166,7 @@ func skillsInstallBuiltinCmd(workspace string) {
} }
func skillsListBuiltinCmd() { func skillsListBuiltinCmd() {
cfg, err := loadConfig() cfg, err := internal.LoadConfig()
if err != nil { if err != nil {
fmt.Printf("Error loading config: %v\n", err) fmt.Printf("Error loading config: %v\n", err)
return return
@ -259,34 +217,43 @@ func skillsListBuiltinCmd() {
} }
} }
func skillsSearchCmd(installer *skills.SkillInstaller) { func skillsSearchCmd(query string) {
fmt.Println("Searching for available skills...") fmt.Println("Searching for available skills...")
cfg, err := internal.LoadConfig()
if err != nil {
fmt.Printf("✗ Failed to load config: %v\n", err)
return
}
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
availableSkills, err := installer.ListAvailableSkills(ctx) results, err := registryMgr.SearchAll(ctx, query, skillsSearchMaxResults)
if err != nil { if err != nil {
fmt.Printf("✗ Failed to fetch skills list: %v\n", err) fmt.Printf("✗ Failed to fetch skills list: %v\n", err)
return return
} }
if len(availableSkills) == 0 { if len(results) == 0 {
fmt.Println("No skills available.") fmt.Println("No skills available.")
return return
} }
fmt.Printf("\nAvailable Skills (%d):\n", len(availableSkills)) fmt.Printf("\nAvailable Skills (%d):\n", len(results))
fmt.Println("--------------------") fmt.Println("--------------------")
for _, skill := range availableSkills { for _, result := range results {
fmt.Printf(" 📦 %s\n", skill.Name) fmt.Printf(" 📦 %s\n", result.DisplayName)
fmt.Printf(" %s\n", skill.Description) fmt.Printf(" %s\n", result.Summary)
fmt.Printf(" Repo: %s\n", skill.Repository) fmt.Printf(" Slug: %s\n", result.Slug)
if skill.Author != "" { fmt.Printf(" Registry: %s\n", result.RegistryName)
fmt.Printf(" Author: %s\n", skill.Author) if result.Version != "" {
} fmt.Printf(" Version: %s\n", result.Version)
if len(skill.Tags) > 0 {
fmt.Printf(" Tags: %v\n", skill.Tags)
} }
fmt.Println() fmt.Println()
} }
@ -303,3 +270,37 @@ func skillsShowCmd(loader *skills.SkillsLoader, skillName string) {
fmt.Println("----------------------") fmt.Println("----------------------")
fmt.Println(content) 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
})
}

View file

@ -0,0 +1,58 @@
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(&registry, "registry", "", "Install from registry: --registry <name> <slug>")
return cmd
}

View file

@ -0,0 +1,97 @@
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)
}
})
}
}

View file

@ -0,0 +1,21 @@
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
}

View file

@ -0,0 +1,27 @@
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)
}

View file

@ -0,0 +1,25 @@
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
}

View file

@ -0,0 +1,27 @@
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)
}

View file

@ -0,0 +1,16 @@
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
}

View file

@ -0,0 +1,26 @@
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)
}

View file

@ -0,0 +1,27 @@
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
}

View file

@ -0,0 +1,29 @@
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"))
}

View file

@ -0,0 +1,23 @@
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
}

View file

@ -0,0 +1,25 @@
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)
}

View file

@ -0,0 +1,26 @@
package skills
import (
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/pkg/skills"
)
func newShowCommand(loaderFn func() (*skills.SkillsLoader, error)) *cobra.Command {
cmd := &cobra.Command{
Use: "show",
Short: "Show skill details",
Args: cobra.ExactArgs(1),
Example: `picoclaw skills show weather`,
RunE: func(_ *cobra.Command, args []string) error {
loader, err := loaderFn()
if err != nil {
return err
}
skillsShowCmd(loader, args[0])
return nil
},
}
return cmd
}

View file

@ -0,0 +1,27 @@
package skills
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewShowSubcommand(t *testing.T) {
cmd := newShowCommand(nil)
require.NotNil(t, cmd)
assert.Equal(t, "show", cmd.Use)
assert.Equal(t, "Show skill details", 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)
}

View file

@ -0,0 +1,18 @@
package status
import (
"github.com/spf13/cobra"
)
func NewStatusCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "status",
Aliases: []string{"s"},
Short: "Show picoclaw status",
Run: func(cmd *cobra.Command, args []string) {
statusCmd()
},
}
return cmd
}

View file

@ -0,0 +1,29 @@
package status
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewStatusCommand(t *testing.T) {
cmd := NewStatusCommand()
require.NotNil(t, cmd)
assert.Equal(t, "status", cmd.Use)
assert.Len(t, cmd.Aliases, 1)
assert.True(t, cmd.HasAlias("s"))
assert.Equal(t, "Show picoclaw status", cmd.Short)
assert.False(t, cmd.HasSubCommands())
assert.NotNil(t, cmd.Run)
assert.Nil(t, cmd.RunE)
assert.Nil(t, cmd.PersistentPreRun)
assert.Nil(t, cmd.PersistentPostRun)
}

View file

@ -1,27 +1,25 @@
// PicoClaw - Ultra-lightweight personal AI agent package status
// License: MIT
package main
import ( import (
"fmt" "fmt"
"os" "os"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/auth"
) )
func statusCmd() { func statusCmd() {
cfg, err := loadConfig() cfg, err := internal.LoadConfig()
if err != nil { if err != nil {
fmt.Printf("Error loading config: %v\n", err) fmt.Printf("Error loading config: %v\n", err)
return return
} }
configPath := getConfigPath() configPath := internal.GetConfigPath()
fmt.Printf("%s picoclaw Status\n", logo) fmt.Printf("%s picoclaw Status\n", internal.Logo)
fmt.Printf("Version: %s\n", formatVersion()) fmt.Printf("Version: %s\n", internal.FormatVersion())
build, _ := formatBuildInfo() build, _ := internal.FormatBuildInfo()
if build != "" { if build != "" {
fmt.Printf("Build: %s\n", build) fmt.Printf("Build: %s\n", build)
} }
@ -41,7 +39,7 @@ func statusCmd() {
} }
if _, err := os.Stat(configPath); err == nil { if _, err := os.Stat(configPath); err == nil {
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.Model) fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName())
hasOpenRouter := cfg.Providers.OpenRouter.APIKey != "" hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
hasAnthropic := cfg.Providers.Anthropic.APIKey != "" hasAnthropic := cfg.Providers.Anthropic.APIKey != ""

View file

@ -0,0 +1,33 @@
package version
import (
"fmt"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
)
func NewVersionCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "version",
Aliases: []string{"v"},
Short: "Show version information",
Run: func(_ *cobra.Command, _ []string) {
printVersion()
},
}
return cmd
}
func printVersion() {
fmt.Printf("%s picoclaw %s\n", internal.Logo, internal.FormatVersion())
build, goVer := internal.FormatBuildInfo()
if build != "" {
fmt.Printf(" Build: %s\n", build)
}
if goVer != "" {
fmt.Printf(" Go: %s\n", goVer)
}
}

View file

@ -0,0 +1,31 @@
package version
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewVersionCommand(t *testing.T) {
cmd := NewVersionCommand()
require.NotNil(t, cmd)
assert.Equal(t, "version", cmd.Use)
assert.Len(t, cmd.Aliases, 1)
assert.True(t, cmd.HasAlias("v"))
assert.False(t, cmd.HasFlags())
assert.Equal(t, "Show version information", cmd.Short)
assert.False(t, cmd.HasSubCommands())
assert.NotNil(t, cmd.Run)
assert.Nil(t, cmd.RunE)
assert.Nil(t, cmd.PersistentPreRun)
assert.Nil(t, cmd.PersistentPostRun)
}

View file

@ -8,192 +8,63 @@ package main
import ( import (
"fmt" "fmt"
"io"
"os" "os"
"path/filepath"
"runtime"
"github.com/sipeed/picoclaw/pkg/config" "github.com/spf13/cobra"
"github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
) )
var ( func NewPicoclawCommand() *cobra.Command {
version = "dev" short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion())
gitCommit string
buildTime string cmd := &cobra.Command{
goVersion string Use: "picoclaw",
Short: short,
Example: "picoclaw version",
}
cmd.AddCommand(
onboard.NewOnboardCommand(),
agent.NewAgentCommand(),
auth.NewAuthCommand(),
gateway.NewGatewayCommand(),
status.NewStatusCommand(),
cron.NewCronCommand(),
migrate.NewMigrateCommand(),
skills.NewSkillsCommand(),
version.NewVersionCommand(),
)
return cmd
}
const (
colorBlue = "\033[1;38;2;62;93;185m"
colorRed = "\033[1;38;2;213;70;70m"
banner = "\r\n" +
colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" +
colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" +
colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" +
colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" +
colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
"\033[0m\r\n"
) )
const logo = "🦞"
// formatVersion returns the version string with optional git commit
func formatVersion() string {
v := version
if gitCommit != "" {
v += fmt.Sprintf(" (git: %s)", gitCommit)
}
return v
}
// formatBuildInfo returns build time and go version info
func formatBuildInfo() (build string, goVer string) {
if buildTime != "" {
build = buildTime
}
goVer = goVersion
if goVer == "" {
goVer = runtime.Version()
}
return
}
func printVersion() {
fmt.Printf("%s picoclaw %s\n", logo, formatVersion())
build, goVer := formatBuildInfo()
if build != "" {
fmt.Printf(" Build: %s\n", build)
}
if goVer != "" {
fmt.Printf(" Go: %s\n", goVer)
}
}
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
})
}
func main() { func main() {
if len(os.Args) < 2 { fmt.Printf("%s", banner)
printHelp() cmd := NewPicoclawCommand()
os.Exit(1) if err := cmd.Execute(); err != nil {
}
command := os.Args[1]
switch command {
case "onboard":
onboard()
case "agent":
agentCmd()
case "gateway":
gatewayCmd()
case "status":
statusCmd()
case "migrate":
migrateCmd()
case "auth":
authCmd()
case "cron":
cronCmd()
case "skills":
if len(os.Args) < 3 {
skillsHelp()
return
}
subcommand := os.Args[2]
cfg, err := loadConfig()
if err != nil {
fmt.Printf("Error loading config: %v\n", err)
os.Exit(1)
}
workspace := cfg.WorkspacePath()
installer := skills.NewSkillInstaller(workspace)
// get global config directory and builtin skills directory
globalDir := filepath.Dir(getConfigPath())
globalSkillsDir := filepath.Join(globalDir, "skills")
builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir)
switch subcommand {
case "list":
skillsListCmd(skillsLoader)
case "install":
skillsInstallCmd(installer, cfg)
case "remove", "uninstall":
if len(os.Args) < 4 {
fmt.Println("Usage: picoclaw skills remove <skill-name>")
return
}
skillsRemoveCmd(installer, os.Args[3])
case "install-builtin":
skillsInstallBuiltinCmd(workspace)
case "list-builtin":
skillsListBuiltinCmd()
case "search":
skillsSearchCmd(installer)
case "show":
if len(os.Args) < 4 {
fmt.Println("Usage: picoclaw skills show <skill-name>")
return
}
skillsShowCmd(skillsLoader, os.Args[3])
default:
fmt.Printf("Unknown skills command: %s\n", subcommand)
skillsHelp()
}
case "version", "--version", "-v":
printVersion()
default:
fmt.Printf("Unknown command: %s\n", command)
printHelp()
os.Exit(1) os.Exit(1)
} }
} }
func printHelp() {
fmt.Printf("%s picoclaw - Personal AI Assistant v%s\n\n", logo, version)
fmt.Println("Usage: picoclaw <command>")
fmt.Println()
fmt.Println("Commands:")
fmt.Println(" onboard Initialize picoclaw configuration and workspace")
fmt.Println(" agent Interact with the agent directly")
fmt.Println(" auth Manage authentication (login, logout, status)")
fmt.Println(" gateway Start picoclaw gateway")
fmt.Println(" status Show picoclaw status")
fmt.Println(" cron Manage scheduled tasks")
fmt.Println(" migrate Migrate from OpenClaw to PicoClaw")
fmt.Println(" skills Manage skills (install, list, remove)")
fmt.Println(" version Show version information")
}
func getConfigPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".picoclaw", "config.json")
}
func loadConfig() (*config.Config, error) {
return config.LoadConfig(getConfigPath())
}

56
cmd/picoclaw/main_test.go Normal file
View file

@ -0,0 +1,56 @@
package main
import (
"fmt"
"slices"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
)
func TestNewPicoclawCommand(t *testing.T) {
cmd := NewPicoclawCommand()
require.NotNil(t, cmd)
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion())
assert.Equal(t, "picoclaw", cmd.Use)
assert.Equal(t, short, cmd.Short)
assert.True(t, cmd.HasSubCommands())
assert.True(t, cmd.HasAvailableSubCommands())
assert.False(t, cmd.HasFlags())
assert.Nil(t, cmd.Run)
assert.Nil(t, cmd.RunE)
assert.Nil(t, cmd.PersistentPreRun)
assert.Nil(t, cmd.PersistentPostRun)
allowedCommands := []string{
"agent",
"auth",
"cron",
"gateway",
"migrate",
"onboard",
"skills",
"status",
"version",
}
subcommands := cmd.Commands()
assert.Len(t, subcommands, len(allowedCommands))
for _, subcmd := range subcommands {
found := slices.Contains(allowedCommands, subcmd.Name())
assert.True(t, found, "unexpected subcommand %q", subcmd.Name())
assert.False(t, subcmd.Hidden)
}
}

View file

@ -3,10 +3,12 @@
"defaults": { "defaults": {
"workspace": "~/.picoclaw/workspace", "workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true, "restrict_to_workspace": true,
"model": "gpt4", "model_name": "gpt4",
"max_tokens": 8192, "max_tokens": 8192,
"temperature": 0.7, "temperature": 0.7,
"max_tool_iterations": 20 "max_tool_iterations": 20,
"summarize_message_threshold": 20,
"summarize_token_percent": 75
} }
}, },
"model_list": [ "model_list": [
@ -20,7 +22,8 @@
"model_name": "claude-sonnet-4.6", "model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6",
"api_key": "sk-ant-your-key", "api_key": "sk-ant-your-key",
"api_base": "https://api.anthropic.com/v1" "api_base": "https://api.anthropic.com/v1",
"thinking_level": "high"
}, },
{ {
"model_name": "gemini", "model_name": "gemini",
@ -49,33 +52,44 @@
"telegram": { "telegram": {
"enabled": false, "enabled": false,
"token": "YOUR_TELEGRAM_BOT_TOKEN", "token": "YOUR_TELEGRAM_BOT_TOKEN",
"base_url": "",
"proxy": "", "proxy": "",
"allow_from": [ "allow_from": [
"YOUR_USER_ID" "YOUR_USER_ID"
] ],
"reasoning_channel_id": ""
}, },
"discord": { "discord": {
"enabled": false, "enabled": false,
"token": "YOUR_DISCORD_BOT_TOKEN", "token": "YOUR_DISCORD_BOT_TOKEN",
"proxy": "",
"allow_from": [], "allow_from": [],
"group_trigger": {
"mention_only": false "mention_only": false
}, },
"reasoning_channel_id": ""
},
"qq": { "qq": {
"enabled": false, "enabled": false,
"app_id": "YOUR_QQ_APP_ID", "app_id": "YOUR_QQ_APP_ID",
"app_secret": "YOUR_QQ_APP_SECRET", "app_secret": "YOUR_QQ_APP_SECRET",
"allow_from": [] "allow_from": [],
"reasoning_channel_id": ""
}, },
"maixcam": { "maixcam": {
"enabled": false, "enabled": false,
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 18790, "port": 18790,
"allow_from": [] "allow_from": [],
"reasoning_channel_id": ""
}, },
"whatsapp": { "whatsapp": {
"enabled": false, "enabled": false,
"bridge_url": "ws://localhost:3001", "bridge_url": "ws://localhost:3001",
"allow_from": [] "use_native": false,
"session_store_path": "",
"allow_from": [],
"reasoning_channel_id": ""
}, },
"feishu": { "feishu": {
"enabled": false, "enabled": false,
@ -83,28 +97,48 @@
"app_secret": "", "app_secret": "",
"encrypt_key": "", "encrypt_key": "",
"verification_token": "", "verification_token": "",
"allow_from": [] "allow_from": [],
"reasoning_channel_id": "",
"random_reaction_emoji": []
}, },
"dingtalk": { "dingtalk": {
"enabled": false, "enabled": false,
"client_id": "YOUR_CLIENT_ID", "client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET", "client_secret": "YOUR_CLIENT_SECRET",
"allow_from": [] "allow_from": [],
"reasoning_channel_id": ""
}, },
"slack": { "slack": {
"enabled": false, "enabled": false,
"bot_token": "xoxb-YOUR-BOT-TOKEN", "bot_token": "xoxb-YOUR-BOT-TOKEN",
"app_token": "xapp-YOUR-APP-TOKEN", "app_token": "xapp-YOUR-APP-TOKEN",
"allow_from": [] "allow_from": [],
"reasoning_channel_id": ""
},
"matrix": {
"enabled": false,
"homeserver": "https://matrix.org",
"user_id": "@your-bot:matrix.org",
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
"device_id": "",
"join_on_invite": true,
"allow_from": [],
"group_trigger": {
"mention_only": true
},
"placeholder": {
"enabled": true,
"text": "Thinking... 💭"
},
"reasoning_channel_id": ""
}, },
"line": { "line": {
"enabled": false, "enabled": false,
"channel_secret": "YOUR_LINE_CHANNEL_SECRET", "channel_secret": "YOUR_LINE_CHANNEL_SECRET",
"channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN", "channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN",
"webhook_host": "0.0.0.0",
"webhook_port": 18791,
"webhook_path": "/webhook/line", "webhook_path": "/webhook/line",
"allow_from": [] "allow_from": [],
"reasoning_channel_id": ""
}, },
"onebot": { "onebot": {
"enabled": false, "enabled": false,
@ -112,33 +146,69 @@
"access_token": "", "access_token": "",
"reconnect_interval": 5, "reconnect_interval": 5,
"group_trigger_prefix": [], "group_trigger_prefix": [],
"allow_from": [] "allow_from": [],
"reasoning_channel_id": ""
}, },
"wecom": { "wecom": {
"_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats", "_comment": "WeCom Bot - Easier setup, supports group chats",
"enabled": false, "enabled": false,
"token": "YOUR_TOKEN", "token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
"webhook_host": "0.0.0.0",
"webhook_port": 18793,
"webhook_path": "/webhook/wecom", "webhook_path": "/webhook/wecom",
"allow_from": [], "allow_from": [],
"reply_timeout": 5 "reply_timeout": 5,
"reasoning_channel_id": ""
}, },
"wecom_app": { "wecom_app": {
"_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only. See docs/wecom-app-configuration.md", "_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only.",
"enabled": false, "enabled": false,
"corp_id": "YOUR_CORP_ID", "corp_id": "YOUR_CORP_ID",
"corp_secret": "YOUR_CORP_SECRET", "corp_secret": "YOUR_CORP_SECRET",
"agent_id": 1000002, "agent_id": 1000002,
"token": "YOUR_TOKEN", "token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_host": "0.0.0.0",
"webhook_port": 18792,
"webhook_path": "/webhook/wecom-app", "webhook_path": "/webhook/wecom-app",
"allow_from": [], "allow_from": [],
"reply_timeout": 5 "reply_timeout": 5,
"reasoning_channel_id": ""
},
"wecom_aibot": {
"_comment": "WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging and private chats.",
"enabled": false,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"max_steps": 10,
"welcome_message": "Hello! I'm your AI assistant. How can I help you today?",
"reasoning_channel_id": ""
},
"irc": {
"enabled": false,
"server": "irc.libera.chat:6697",
"tls": true,
"nick": "mybot",
"user": "",
"real_name": "",
"password": "",
"nickserv_password": "",
"sasl_user": "",
"sasl_password": "",
"channels": [
"#mychannel"
],
"request_caps": [
"server-time",
"message-tags"
],
"allow_from": [],
"group_trigger": {
"mention_only": true
},
"typing": {
"enabled": false
},
"reasoning_channel_id": ""
} }
}, },
"providers": { "providers": {
@ -200,42 +270,197 @@
"mistral": { "mistral": {
"api_key": "", "api_key": "",
"api_base": "https://api.mistral.ai/v1" "api_base": "https://api.mistral.ai/v1"
},
"avian": {
"api_key": "",
"api_base": "https://api.avian.io/v1"
} }
}, },
"tools": { "tools": {
"allow_read_paths": null,
"allow_write_paths": null,
"web": { "web": {
"enabled": true,
"brave": { "brave": {
"enabled": false, "enabled": false,
"api_key": "YOUR_BRAVE_API_KEY", "api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5 "max_results": 5
}, },
"tavily": {
"enabled": false,
"api_key": "",
"base_url": "",
"max_results": 0
},
"duckduckgo": { "duckduckgo": {
"enabled": true, "enabled": true,
"max_results": 5 "max_results": 5
}, },
"perplexity": { "perplexity": {
"enabled": false, "enabled": false,
"api_key": "pplx-xxx", "api_key": "",
"max_results": 5 "max_results": 5
} },
"searxng": {
"enabled": false,
"base_url": "http://localhost:8888",
"max_results": 5
},
"glm_search": {
"enabled": false,
"api_key": "",
"base_url": "https://open.bigmodel.cn/api/paas/v4/web_search",
"search_engine": "search_std",
"max_results": 5
},
"fetch_limit_bytes": 10485760
}, },
"cron": { "cron": {
"enabled": true,
"exec_timeout_minutes": 5 "exec_timeout_minutes": 5
}, },
"mcp": {
"enabled": false,
"discovery": {
"enabled": false,
"ttl": 5,
"max_search_results": 5,
"use_bm25": true,
"use_regex": false
},
"servers": {
"context7": {
"enabled": false,
"type": "http",
"url": "https://mcp.context7.com/mcp",
"headers": {
"CONTEXT7_API_KEY": "ctx7sk-xx"
}
},
"filesystem": {
"enabled": false,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"/tmp"
]
},
"github": {
"enabled": false,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-github"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN"
}
},
"brave-search": {
"enabled": false,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-brave-search"
],
"env": {
"BRAVE_API_KEY": "YOUR_BRAVE_API_KEY"
}
},
"postgres": {
"enabled": false,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-postgres",
"postgresql://user:password@localhost/dbname"
]
},
"slack": {
"enabled": false,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-slack"
],
"env": {
"SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN",
"SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID"
}
}
}
},
"exec": { "exec": {
"enable_deny_patterns": false, "enabled": true,
"custom_deny_patterns": [] "enable_deny_patterns": true,
"custom_deny_patterns": null,
"custom_allow_patterns": null
}, },
"skills": { "skills": {
"enabled": true,
"registries": { "registries": {
"clawhub": { "clawhub": {
"enabled": true, "enabled": true,
"base_url": "https://clawhub.ai", "base_url": "https://clawhub.ai",
"search_path": "/api/v1/search", "auth_token": "",
"skills_path": "/api/v1/skills", "search_path": "",
"download_path": "/api/v1/download" "skills_path": "",
"download_path": "",
"timeout": 0,
"max_zip_size": 0,
"max_response_size": 0
} }
},
"max_concurrent_searches": 2,
"search_cache": {
"max_size": 50,
"ttl_seconds": 300
} }
},
"media_cleanup": {
"enabled": true,
"max_age_minutes": 30,
"interval_minutes": 5
},
"append_file": {
"enabled": true
},
"edit_file": {
"enabled": true
},
"find_skills": {
"enabled": true
},
"i2c": {
"enabled": false
},
"install_skill": {
"enabled": true
},
"list_dir": {
"enabled": true
},
"message": {
"enabled": true
},
"read_file": {
"enabled": true
},
"spawn": {
"enabled": true
},
"spi": {
"enabled": false
},
"subagent": {
"enabled": true
},
"web_fetch": {
"enabled": true
},
"write_file": {
"enabled": true
} }
}, },
"heartbeat": { "heartbeat": {

44
docker/Dockerfile.full Normal file
View file

@ -0,0 +1,44 @@
# ============================================================
# Stage 1: Build the picoclaw binary
# ============================================================
FROM golang:1.26.0-alpine AS builder
RUN apk add --no-cache git make
WORKDIR /src
# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download
# Copy source and build
COPY . .
RUN make build
# ============================================================
# Stage 2: Node.js-based runtime with full MCP support
# ============================================================
FROM node:24-alpine3.23
# Install runtime dependencies
RUN apk add --no-cache \
ca-certificates \
curl \
git \
python3 \
py3-pip
# Install uv and symlink to system path
RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
ln -s /root/.local/bin/uv /usr/local/bin/uv && \
ln -s /root/.local/bin/uvx /usr/local/bin/uvx && \
uv --version
# Copy binary
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
# Create picoclaw home directory
RUN /usr/local/bin/picoclaw onboard
ENTRYPOINT ["picoclaw"]
CMD ["gateway"]

View file

@ -5,6 +5,8 @@ ARG TARGETPLATFORM
RUN apk add --no-cache ca-certificates tzdata RUN apk add --no-cache ca-certificates tzdata
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
COPY docker/entrypoint.sh /entrypoint.sh
ENTRYPOINT ["picoclaw"] RUN chmod +x /entrypoint.sh
CMD ["gateway"]
ENTRYPOINT ["/entrypoint.sh"]

View file

@ -0,0 +1,44 @@
services:
# ─────────────────────────────────────────────
# PicoClaw Agent (one-shot query) - Full MCP Support
# docker compose -f docker/docker-compose.full.yml run --rm picoclaw-agent -m "Hello"
# ─────────────────────────────────────────────
picoclaw-agent:
build:
context: ..
dockerfile: docker/Dockerfile.full
container_name: picoclaw-agent-full
profiles:
- agent
volumes:
- ../config/config.json:/root/.picoclaw/config.json:ro
- picoclaw-workspace:/root/.picoclaw/workspace
- picoclaw-npm-cache:/root/.npm # npm cache for faster MCP server installs
entrypoint: ["picoclaw", "agent"]
stdin_open: true
tty: true
# ─────────────────────────────────────────────
# PicoClaw Gateway (Long-running Bot) - Full MCP Support
# docker compose -f docker/docker-compose.full.yml --profile gateway up
# ─────────────────────────────────────────────
picoclaw-gateway:
build:
context: ..
dockerfile: docker/Dockerfile.full
container_name: picoclaw-gateway-full
restart: unless-stopped
profiles:
- gateway
volumes:
# Configuration file
- ../config/config.json:/root/.picoclaw/config.json:ro
# Persistent workspace (sessions, memory, logs)
- picoclaw-workspace:/root/.picoclaw/workspace
# NPM cache for faster MCP server installs
- picoclaw-npm-cache:/root/.npm
command: ["gateway"]
volumes:
picoclaw-workspace:
picoclaw-npm-cache: # Cache npm packages to speed up MCP server installations

View file

@ -1,12 +1,10 @@
services: services:
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# PicoClaw Agent (one-shot query) # PicoClaw Agent (one-shot query)
# docker compose run --rm picoclaw-agent -m "Hello" # docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Hello"
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
picoclaw-agent: picoclaw-agent:
build: image: docker.io/sipeed/picoclaw:latest
context: .
dockerfile: Dockerfile
container_name: picoclaw-agent container_name: picoclaw-agent
profiles: profiles:
- agent - agent
@ -14,33 +12,23 @@ services:
#extra_hosts: #extra_hosts:
# - "host.docker.internal:host-gateway" # - "host.docker.internal:host-gateway"
volumes: volumes:
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro - ./data:/root/.picoclaw
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
entrypoint: ["picoclaw", "agent"] entrypoint: ["picoclaw", "agent"]
stdin_open: true stdin_open: true
tty: true tty: true
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# PicoClaw Gateway (Long-running Bot) # PicoClaw Gateway (Long-running Bot)
# docker compose up picoclaw-gateway # docker compose -f docker/docker-compose.yml up picoclaw-gateway
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
picoclaw-gateway: picoclaw-gateway:
build: image: docker.io/sipeed/picoclaw:latest
context: .
dockerfile: Dockerfile
container_name: picoclaw-gateway container_name: picoclaw-gateway
restart: unless-stopped restart: on-failure
profiles: profiles:
- gateway - gateway
# Uncomment to access host network; leave commented unless needed. # Uncomment to access host network; leave commented unless needed.
#extra_hosts: #extra_hosts:
# - "host.docker.internal:host-gateway" # - "host.docker.internal:host-gateway"
volumes: volumes:
# Configuration file - ./data:/root/.picoclaw
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
# Persistent workspace (sessions, memory, logs)
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
command: ["gateway"]
volumes:
picoclaw-workspace:

15
docker/entrypoint.sh Normal file
View file

@ -0,0 +1,15 @@
#!/bin/sh
set -e
# First-run: neither config nor workspace exists.
# If config.json is already mounted but workspace is missing we skip onboard to
# avoid the interactive "Overwrite? (y/n)" prompt hanging in a non-TTY container.
if [ ! -d "${HOME}/.picoclaw/workspace" ] && [ ! -f "${HOME}/.picoclaw/config.json" ]; then
picoclaw onboard
echo ""
echo "First-run setup complete."
echo "Edit ${HOME}/.picoclaw/config.json (add your API key, etc.) then restart the container."
exit 0
fi
exec picoclaw gateway "$@"

Some files were not shown because too many files have changed in this diff Show more