update branch

This commit is contained in:
qwen.ai[bot] 2026-04-12 22:18:48 +00:00
parent 6189df2f48
commit 8cdd857c93
592 changed files with 164161 additions and 17 deletions

91
.gitignore vendored
View file

@ -1,43 +1,100 @@
```
# Go build artifacts
# Go-specific ignores
*.o
*.a
*.out
*.so
*.exe
*.dll
*.dylib
*.test
*_test.go
*.prof
cover.out
coverage.html
# Build artifacts
bin/
_dist/
_dist/*
dist/
dist/*
build/
build/*
target/
target/*
# Dependencies
vendor/
_deps/
_deps/*
# Logs and temp files
# Logs
*.log
_logs/
_logs/*
# Temp files
*.tmp
*.temp
*.swp
*~
.DS_Store
Thumbs.db
# Environment
.env
.env.local
*.env.*
.env.*
!*.env.example
# Editors
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*.bak
*.backup
# Coverage
# Testing
_coverage/
coverage/
htmlcov/
.coverage
*.coverprofile
# Build directories
build/
dist/
target/
# System files
.DS_Store
Thumbs.db
ehthumbs.db
Icon?
apidoc/
apidocs/
# Python artifacts (if any Python files exist)
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
# Package management
go.mod
go.sum
!go.mod
!go.sum
# Ignore compiled Go binaries in bin directories
bin/*.exe
bin/*.dll
bin/*.so
bin/*.dylib
bin/*.a
bin/*.o
# Compressed archives
*.zip
*.tar.gz
*.tar.xz
*.tar.bz2
*.gz
*.bz2
*.xz
*.7z
*.rar
*.tgz
*.tar
*.deb
*.rpm
```

430
picoclaw/Makefile Normal file
View file

@ -0,0 +1,430 @@
.PHONY: all build install uninstall clean help test
# Build variables
BINARY_NAME=picoclaw
BUILD_DIR=build
CMD_DIR=cmd/$(BINARY_NAME)
MAIN_GO=$(CMD_DIR)/main.go
EXT=
# Version
VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
BUILD_TIME=$(shell date +%FT%T%z)
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w
# Go variables
GO?=CGO_ENABLED=0 go
WEB_GO?=$(GO)
GO_BUILD_TAGS?=goolm,stdjson
GOFLAGS?=-v -tags $(GO_BUILD_TAGS)
comma:=,
empty:=
space:=$(empty) $(empty)
GO_BUILD_TAGS_NO_GOOLM:=$(subst $(space),$(comma),$(strip $(filter-out goolm,$(subst $(comma),$(space),$(GO_BUILD_TAGS)))))
GOFLAGS_NO_GOOLM?=-v -tags $(GO_BUILD_TAGS_NO_GOOLM)
# Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600).
#
# Bytes (octal): \004 \024 \000 \160 → little-endian 0x70001404
# 0x70000000 EF_MIPS_ARCH_32R2 MIPS32 Release 2
# 0x00001000 EF_MIPS_ABI_O32 O32 ABI
# 0x00000400 EF_MIPS_NAN2008 IEEE 754-2008 NaN encoding
# 0x00000004 EF_MIPS_CPIC PIC calling sequence
#
# Go's GOMIPS=softfloat emits no FP instructions, so the NaN mode is irrelevant
# at runtime — this is purely an ELF metadata fix to satisfy the kernel's check.
# patchelf cannot modify e_flags; dd at a fixed offset is the most portable way.
#
# Ref: https://codebrowser.dev/linux/linux/arch/mips/include/asm/elf.h.html
define PATCH_MIPS_FLAGS
@if [ -f "$(1)" ]; then \
printf '\004\024\000\160' | dd of=$(1) bs=1 seek=36 count=4 conv=notrunc 2>/dev/null || \
{ echo "Error: failed to patch MIPS e_flags for $(1)"; exit 1; }; \
else \
echo "Error: $(1) not found, cannot patch MIPS e_flags"; exit 1; \
fi
endef
# Patch creack/pty for loong64 support (upstream doesn't have ztypes_loong64.go)
PTY_PATCH_LOONG64=pty_dir=$$(go env GOMODCACHE)/github.com/creack/pty@v1.1.9; \
if [ -d "$$pty_dir" ] && [ ! -f "$$pty_dir/ztypes_loong64.go" ]; then \
chmod +w "$$pty_dir" 2>/dev/null || true; \
printf '//go:build linux && loong64\npackage pty\ntype (_C_int int32; _C_uint uint32)\n' > "$$pty_dir/ztypes_loong64.go"; \
fi
# Golangci-lint
GOLANGCI_LINT?=golangci-lint
# Installation
INSTALL_PREFIX?=$(HOME)/.local
INSTALL_BIN_DIR=$(INSTALL_PREFIX)/bin
INSTALL_MAN_DIR=$(INSTALL_PREFIX)/share/man/man1
INSTALL_TMP_SUFFIX=.new
# Workspace and Skills
PICOCLAW_HOME?=$(HOME)/.picoclaw
WORKSPACE_DIR?=$(PICOCLAW_HOME)/workspace
WORKSPACE_SKILLS_DIR=$(WORKSPACE_DIR)/skills
BUILTIN_SKILLS_DIR=$(CURDIR)/skills
LNCMD=ln -sf
# OS detection
UNAME_S?=$(shell uname -s)
UNAME_M?=$(shell uname -m)
# Platform-specific settings
ifeq ($(UNAME_S),Linux)
PLATFORM=linux
ifeq ($(UNAME_M),x86_64)
ARCH=amd64
else ifeq ($(UNAME_M),aarch64)
ARCH=arm64
else ifeq ($(UNAME_M),armv81)
ARCH=arm64
else ifeq ($(UNAME_M),loongarch64)
ARCH=loong64
else ifeq ($(UNAME_M),riscv64)
ARCH=riscv64
else ifeq ($(UNAME_M),mipsel)
ARCH=mipsle
else
ARCH=$(UNAME_M)
endif
else ifeq ($(UNAME_S),Darwin)
PLATFORM=darwin
WEB_GO=CGO_LDFLAGS="-mmacosx-version-min=10.11" CGO_CFLAGS="-mmacosx-version-min=10.11" CGO_ENABLED=1 go
ifeq ($(UNAME_M),x86_64)
ARCH?=amd64
else ifeq ($(UNAME_M),arm64)
ARCH?=arm64
else
ARCH?=$(UNAME_M)
endif
else
PLATFORM=$(UNAME_S)
ifeq ($(UNAME_M),x86_64)
ARCH?=amd64
else
ARCH?=$(UNAME_M)
endif
# Detect Windows (Git Bash / MSYS2)
IS_WINDOWS:=$(if $(findstring MINGW,$(UNAME_S)),yes,$(if $(findstring MSYS,$(UNAME_S)),yes,$(if $(findstring CYGWIN,$(UNAME_S)),yes,no)))
ifeq ($(IS_WINDOWS),yes)
EXT=.exe
LNCMD=cp
else ifeq ($(UNAME_S),windows) # failsafe for force windows build in other OS using UNAME_S=windows
EXT=.exe
endif
endif
BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH)
# Default target
all: build
## generate: Run generate
generate:
@echo "Run generate..."
@rm -r ./$(CMD_DIR)/workspace 2>/dev/null || true
@$(GO) generate ./...
@echo "Run generate complete"
## build: Build the picoclaw binary for current platform
build: generate
@echo "Building $(BINARY_NAME)$(EXT) for $(PLATFORM)/$(ARCH)..."
@mkdir -p $(BUILD_DIR)
@GOARCH=${ARCH} $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH)$(EXT) ./$(CMD_DIR)
@echo "Build complete: $(BINARY_PATH)$(EXT)"
@$(LNCMD) $(BINARY_NAME)-$(PLATFORM)-$(ARCH)$(EXT) $(BUILD_DIR)/$(BINARY_NAME)$(EXT)
## build-launcher: Build the picoclaw-launcher (web console) binary
build-launcher:
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
@mkdir -p $(BUILD_DIR)
@GOARCH=${ARCH} $(MAKE) -C web build \
OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)" \
WEB_GO='$(WEB_GO)' \
GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \
LDFLAGS='$(LDFLAGS)'
@$(LNCMD) picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT) $(BUILD_DIR)/picoclaw-launcher$(EXT)
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher$(EXT)"
build-launcher-frontend:
@$(MAKE) -C web build-frontend
## build-launcher-tui: Build the picoclaw-launcher TUI binary
build-launcher-tui:
@echo "Building picoclaw-launcher-tui for $(PLATFORM)/$(ARCH)..."
@mkdir -p $(BUILD_DIR)
@$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) ./cmd/picoclaw-launcher-tui
@ln -sf picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher-tui
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-tui"
## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
build-whatsapp-native: generate
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
@echo "Building for multiple platforms..."
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=amd64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
GOOS=linux GOARCH=arm64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
GOOS=linux GOARCH=loong64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
GOOS=linux GOARCH=riscv64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags $(GO_BUILD_TAGS_NO_GOOLM),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
GOOS=darwin GOARCH=arm64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
GOOS=windows GOARCH=amd64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
## @$(GO) build $(GOFLAGS) -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR)
@echo "Build complete"
## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
## build-linux-arm: Build for Linux ARMv7 (e.g. Raspberry Pi Zero 2 W 32-bit)
build-linux-arm: generate
@echo "Building for linux/arm (GOARM=7)..."
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm"
## build-linux-arm64: Build for Linux ARM64 (e.g. Raspberry Pi Zero 2 W 64-bit)
build-linux-arm64: generate
@echo "Building for linux/arm64..."
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
## build-linux-mipsle: Build for Linux MIPS32 LE
build-linux-mipsle: generate
@echo "Building for linux/mipsle (softfloat)..."
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(GOFLAGS_NO_GOOLM) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle"
## build-android-arm64: Build core for Android ARM64
build-android-arm64: generate
@echo "Building for android/arm64..."
@mkdir -p $(BUILD_DIR)
GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 ./$(CMD_DIR)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-android-arm64"
## build-launcher-android-arm64: Build launcher for Android ARM64
build-launcher-android-arm64:
@echo "Building picoclaw-launcher for android/arm64..."
@mkdir -p $(BUILD_DIR)
@$(MAKE) -C web build-android-arm64 \
OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-android-arm64"
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-android-arm64"
## build-android-bundle: Build core and launcher for all Android architectures and package as universal zip
build-android-bundle: generate
@echo "Building core for all Android architectures..."
@mkdir -p $(BUILD_DIR)
GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 ./$(CMD_DIR)
@echo "Building launcher for Android arm64..."
@$(MAKE) build-launcher-android-arm64
@echo "Staging JNI libs..."
@rm -rf $(BUILD_DIR)/android-staging
@mkdir -p $(BUILD_DIR)/android-staging/arm64-v8a
@cp $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 $(BUILD_DIR)/android-staging/arm64-v8a/libpicoclaw.so
@cp $(BUILD_DIR)/picoclaw-launcher-android-arm64 $(BUILD_DIR)/android-staging/arm64-v8a/libpicoclaw-web.so
@cd $(BUILD_DIR)/android-staging && zip -r ../picoclaw-android-universal.zip .
@rm -rf $(BUILD_DIR)/android-staging
@echo "All Android builds complete: $(BUILD_DIR)/picoclaw-android-universal.zip"
## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit)
build-pi-zero: build-linux-arm build-linux-arm64
@echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)"
## build-all: Build picoclaw for all platforms
build-all: generate
@echo "Building for multiple platforms..."
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
GOOS=linux GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
@$(PTY_PATCH_LOONG64)
GOOS=linux GOARCH=loong64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
GOOS=linux GOARCH=riscv64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(GOFLAGS_NO_GOOLM) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
GOOS=netbsd GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR)
GOOS=netbsd GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR)
@$(MAKE) build-android-bundle
@echo "All builds complete"
## install: Install picoclaw to system and copy builtin skills
install: build
@echo "Installing $(BINARY_NAME)..."
@mkdir -p $(INSTALL_BIN_DIR)
# Copy binary with temporary suffix to ensure atomic update
@cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)
@chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)
@mv -f $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
@echo "Installed binary to $(INSTALL_BIN_DIR)/$(BINARY_NAME)"
@echo "Installation complete!"
## uninstall: Remove picoclaw from system
uninstall:
@echo "Uninstalling $(BINARY_NAME)..."
@rm -f $(INSTALL_BIN_DIR)/$(BINARY_NAME)
@echo "Removed binary from $(INSTALL_BIN_DIR)/$(BINARY_NAME)"
@echo "Note: Only the executable file has been deleted."
@echo "If you need to delete all configurations (config.json, workspace, etc.), run 'make uninstall-all'"
## uninstall-all: Remove picoclaw and all data
uninstall-all:
@echo "Removing workspace and skills..."
@rm -rf $(PICOCLAW_HOME)
@echo "Removed workspace: $(PICOCLAW_HOME)"
@echo "Complete uninstallation done!"
## clean: Remove build artifacts
clean:
@echo "Cleaning build artifacts..."
@rm -rf $(BUILD_DIR)
@echo "Clean complete"
## vet: Run go vet for static analysis
vet: generate
@packages="$$($(GO) list $(GOFLAGS) ./...)" && \
$(GO) vet $(GOFLAGS) $$(printf '%s\n' "$$packages" | grep -v '^github.com/sipeed/picoclaw/web/')
@cd web/backend && $(WEB_GO) vet ./...
## test: Test Go code
test: generate
@$(GO) test $(GOFLAGS) $$($(GO) list $(GOFLAGS) ./... | grep -v github.com/sipeed/picoclaw/web/)
@cd web && make test
## fmt: Format Go code
fmt:
@$(GOLANGCI_LINT) fmt
## lint: Run linters
lint:
@$(GOLANGCI_LINT) run --build-tags $(GO_BUILD_TAGS)
## fix: Fix linting issues
fix:
@$(GOLANGCI_LINT) run --fix --build-tags $(GO_BUILD_TAGS)
## deps: Download dependencies
deps:
@$(GO) mod download
@$(GO) mod verify
## update-deps: Update dependencies
update-deps:
@$(GO) get -u ./...
@$(GO) mod tidy
## check: Run vet, fmt, and verify dependencies
check: deps fmt vet test
## run: Build and run picoclaw
run: build
@$(BUILD_DIR)/$(BINARY_NAME) $(ARGS)
## docker-build: Build Docker image (minimal Alpine-based)
docker-build:
@echo "Building minimal Docker image (Alpine-based)..."
docker compose -f docker/docker-compose.yml build picoclaw-agent picoclaw-gateway
## docker-build-full: Build Docker image with full MCP support (Node.js 24)
docker-build-full:
@echo "Building full-featured Docker image (Node.js 24)..."
docker compose -f docker/docker-compose.full.yml build picoclaw-agent picoclaw-gateway
## docker-test: Test MCP tools in Docker container
docker-test:
@echo "Testing MCP tools in Docker..."
@chmod +x scripts/test-docker-mcp.sh
@./scripts/test-docker-mcp.sh
## docker-run: Run picoclaw gateway in Docker (Alpine-based)
docker-run:
docker compose -f docker/docker-compose.yml --profile gateway up
## docker-run-full: Run picoclaw gateway in Docker (full-featured)
docker-run-full:
docker compose -f docker/docker-compose.full.yml --profile gateway up
## docker-run-agent: Run picoclaw agent in Docker (interactive, Alpine-based)
docker-run-agent:
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
## docker-run-agent-full: Run picoclaw agent in Docker (interactive, full-featured)
docker-run-agent-full:
docker compose -f docker/docker-compose.full.yml run --rm picoclaw-agent
## docker-clean: Clean Docker images and volumes
docker-clean:
docker compose -f docker/docker-compose.yml down -v
docker compose -f docker/docker-compose.full.yml down -v
docker rmi picoclaw:latest picoclaw:full 2>/dev/null || true
## build-macos-app: Build PicoClaw macOS .app bundle (no terminal window)
build-macos-app:build-launcher
@echo "Building macOS .app bundle..."
@if [ "$(UNAME_S)" != "Darwin" ]; then \
echo "Error: This target is only available on macOS"; \
exit 1; \
fi
@./scripts/build-macos-app.sh $(PLATFORM)-$(ARCH)
@echo "macOS .app bundle created: $(BUILD_DIR)/PicoClaw.app"
## mem: Build membench, download LOCOMO data (if needed), run benchmark, and show results
mem:
@echo "Building membench..."
@mkdir -p $(BUILD_DIR)
@$(GO) build -o $(BUILD_DIR)/membench ./cmd/membench
@echo "Build complete: $(BUILD_DIR)/membench"
@if [ ! -f $(BUILD_DIR)/memdata/locomo10.json ]; then \
echo "Downloading LOCOMO dataset..."; \
mkdir -p $(BUILD_DIR)/memdata; \
curl -sfL "https://raw.githubusercontent.com/snap-research/locomo/main/data/locomo10.json" \
-o $(BUILD_DIR)/memdata/locomo10.json && [ -s $(BUILD_DIR)/memdata/locomo10.json ] || { echo "Error: LOCOMO download failed"; exit 1; }; \
echo "Download complete"; \
else \
echo "LOCOMO dataset already exists, skipping download"; \
fi
@echo "Running benchmark..."
@rm -rf $(BUILD_DIR)/memout
@$(BUILD_DIR)/membench run --data $(BUILD_DIR)/memdata --out $(BUILD_DIR)/memout --budget 4000
## help: Show this help message
help:
@echo "picoclaw Makefile"
@echo ""
@echo "Usage:"
@echo " make [target]"
@echo ""
@echo "Targets:"
@grep -E '^## ' $(MAKEFILE_LIST) | sort | awk -F': ' '{printf " %-16s %s\n", substr($$1, 4), $$2}'
@echo ""
@echo "Examples:"
@echo " make build # Build for current platform"
@echo " make install # Install to ~/.local/bin"
@echo " make uninstall # Remove from /usr/local/bin"
@echo " make install-skills # Install skills to workspace"
@echo " make docker-build # Build minimal Docker image"
@echo " make docker-test # Test MCP tools in Docker"
@echo ""
@echo "Environment Variables:"
@echo " INSTALL_PREFIX # Installation prefix (default: ~/.local)"
@echo " WORKSPACE_DIR # Workspace directory (default: ~/.picoclaw/workspace)"
@echo " VERSION # Version string (default: git describe)"
@echo ""
@echo "Current Configuration:"
@echo " Platform: $(PLATFORM)/$(ARCH)"
@echo " Binary: $(BINARY_PATH)"
@echo " Install Prefix: $(INSTALL_PREFIX)"
@echo " Workspace: $(WORKSPACE_DIR)"

View file

@ -0,0 +1,366 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"github.com/sipeed/picoclaw/pkg/seahorse"
)
// EvalResult holds per-sample evaluation results for one mode.
type EvalResult struct {
Mode string `json:"mode"`
SampleID string `json:"sampleId"`
QAResults []QAResult `json:"qaResults"`
Agg AggMetrics `json:"aggregated"`
}
// QAResult holds metrics for a single QA pair.
type QAResult struct {
Question string `json:"question"`
Category int `json:"category"`
GoldAnswer string `json:"goldAnswer"`
TokenF1 float64 `json:"tokenF1"`
HitRate float64 `json:"hitRate"`
}
// AggMetrics holds aggregated evaluation metrics.
type AggMetrics struct {
OverallF1 float64 `json:"overallF1"`
OverallHitRate float64 `json:"overallHitRate"`
ByCategory map[int]*CatMetrics `json:"byCategory"`
TotalQuestions int `json:"totalQuestions"`
}
// CatMetrics holds metrics for a single category.
type CatMetrics struct {
F1 float64 `json:"f1"`
HitRate float64 `json:"hitRate"`
QuestionCount int `json:"questionCount"`
}
// EvalLegacy evaluates using legacy session store (raw history + budget truncation).
func EvalLegacy(
ctx context.Context,
samples []LocomoSample,
legacy *LegacyStore,
budgetTokens int,
) []EvalResult {
results := make([]EvalResult, 0, len(samples))
for si := range samples {
sample := &samples[si]
history := legacy.GetHistory(sample.SampleID)
// Convert messages to content strings
allContent := make([]string, 0, len(history))
for _, msg := range history {
allContent = append(allContent, msg.Content)
}
qaResults := make([]QAResult, 0, len(sample.QA))
for qi := range sample.QA {
qa := &sample.QA[qi]
// Budget truncate the full history
truncated, _ := BudgetTruncate(allContent, budgetTokens)
context := StringListToContent(truncated)
f1 := TokenOverlapF1(context, qa.AnswerString())
hitRate := RecallHitRate(qa.Evidence, sample, context)
qaResults = append(qaResults, QAResult{
Question: qa.Question,
Category: qa.Category,
GoldAnswer: qa.AnswerString(),
TokenF1: f1,
HitRate: hitRate,
})
}
results = append(results, EvalResult{
Mode: "legacy",
SampleID: sample.SampleID,
QAResults: qaResults,
Agg: aggregateMetrics(qaResults),
})
}
return results
}
// EvalSeahorse evaluates using seahorse short memory (per-keyword search + expand).
func EvalSeahorse(
ctx context.Context,
samples []LocomoSample,
ir *SeahorseIngestResult,
budgetTokens int,
) []EvalResult {
store := ir.Engine.GetRetrieval().Store()
retrieval := ir.Engine.GetRetrieval()
results := make([]EvalResult, 0, len(samples))
for si := range samples {
sample := &samples[si]
convID, ok := ir.ConvMap[sample.SampleID]
if !ok {
log.Printf("WARN: no conversation ID for sample %s", sample.SampleID)
continue
}
qaResults := make([]QAResult, 0, len(sample.QA))
for qi := range sample.QA {
qa := &sample.QA[qi]
keywords := ExtractKeywords(qa.Question)
// Search each keyword individually and union results,
// tracking best BM25 rank per message for relevance sorting.
bestRank := map[int64]float64{}
for _, kw := range keywords {
searchResults, err := store.SearchMessages(ctx, seahorse.SearchInput{
Pattern: kw,
ConversationID: convID,
Limit: 20,
})
if err != nil {
log.Printf("WARN: search failed for keyword %q: %v", kw, err)
continue
}
for _, sr := range searchResults {
if sr.MessageID > 0 {
if prev, ok := bestRank[sr.MessageID]; !ok || sr.Rank < prev {
bestRank[sr.MessageID] = sr.Rank
}
}
}
}
// Sort messageIDs by rank ascending (best/most-negative first).
// BudgetTruncate walks from the front, keeping best-ranked messages.
// Note: SQLite FTS5 bm25() returns negative values where more
// negative = better match.
messageIDs := make([]int64, 0, len(bestRank))
for id := range bestRank {
messageIDs = append(messageIDs, id)
}
sort.Slice(messageIDs, func(i, j int) bool {
return bestRank[messageIDs[i]] < bestRank[messageIDs[j]]
})
// Expand messages to get full content
var contentParts []string
if len(messageIDs) > 0 {
expandResult, err := retrieval.ExpandMessages(ctx, messageIDs)
if err != nil {
log.Printf("WARN: expand failed for sample %s: %v", sample.SampleID, err)
} else {
for _, msg := range expandResult.Messages {
contentParts = append(contentParts, msg.Content)
}
}
}
if len(contentParts) == 0 {
qaResults = append(qaResults, QAResult{
Question: qa.Question,
Category: qa.Category,
GoldAnswer: qa.AnswerString(),
TokenF1: 0.0,
HitRate: 0.0,
})
continue
}
// Budget truncate (drop worst-ranked)
truncated, _ := BudgetTruncate(contentParts, budgetTokens)
context := StringListToContent(truncated)
f1 := TokenOverlapF1(context, qa.AnswerString())
hitRate := RecallHitRate(qa.Evidence, sample, context)
qaResults = append(qaResults, QAResult{
Question: qa.Question,
Category: qa.Category,
GoldAnswer: qa.AnswerString(),
TokenF1: f1,
HitRate: hitRate,
})
}
results = append(results, EvalResult{
Mode: "seahorse",
SampleID: sample.SampleID,
QAResults: qaResults,
Agg: aggregateMetrics(qaResults),
})
}
return results
}
// aggregateMetrics computes overall and per-category metrics.
func aggregateMetrics(qaResults []QAResult) AggMetrics {
byCat := map[int]*CatMetrics{}
totalF1 := 0.0
totalHitRate := 0.0
for _, qr := range qaResults {
totalF1 += qr.TokenF1
totalHitRate += qr.HitRate
cat, ok := byCat[qr.Category]
if !ok {
cat = &CatMetrics{}
byCat[qr.Category] = cat
}
cat.F1 += qr.TokenF1
cat.HitRate += qr.HitRate
cat.QuestionCount++
}
n := len(qaResults)
if n == 0 {
n = 1
}
agg := AggMetrics{
OverallF1: totalF1 / float64(n),
OverallHitRate: totalHitRate / float64(n),
ByCategory: byCat,
TotalQuestions: len(qaResults),
}
for _, cat := range agg.ByCategory {
if cat.QuestionCount > 0 {
cat.F1 /= float64(cat.QuestionCount)
cat.HitRate /= float64(cat.QuestionCount)
}
}
return agg
}
// SaveResults writes per-sample eval results to JSON files.
func SaveResults(results []EvalResult, outDir string) error {
if err := os.MkdirAll(outDir, 0o755); err != nil {
return fmt.Errorf("create output dir: %w", err)
}
for _, r := range results {
path := filepath.Join(outDir, fmt.Sprintf("eval_%s_%s.json", r.Mode, r.SampleID))
data, err := json.MarshalIndent(r, "", " ")
if err != nil {
return fmt.Errorf("marshal result: %w", err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("write result: %w", err)
}
}
return nil
}
// SaveAggregated writes a combined results.json with all modes.
func SaveAggregated(results []EvalResult, outDir string) error {
byMode := map[string][]EvalResult{}
for _, r := range results {
byMode[r.Mode] = append(byMode[r.Mode], r)
}
aggMap := map[string]AggMetrics{}
for mode, modeResults := range byMode {
aggMap[mode] = computeModeAgg(modeResults)
}
data, err := json.MarshalIndent(aggMap, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(outDir, "results.json"), data, 0o644)
}
// computeModeAgg aggregates results for a single mode using weighted averaging
// (weighted by question count per sample). All modes must have the same Mode field.
func computeModeAgg(results []EvalResult) AggMetrics {
agg := AggMetrics{ByCategory: map[int]*CatMetrics{}}
for _, r := range results {
agg.OverallF1 += r.Agg.OverallF1 * float64(r.Agg.TotalQuestions)
agg.OverallHitRate += r.Agg.OverallHitRate * float64(r.Agg.TotalQuestions)
agg.TotalQuestions += r.Agg.TotalQuestions
for cat, cm := range r.Agg.ByCategory {
existing, ok := agg.ByCategory[cat]
if !ok {
existing = &CatMetrics{}
agg.ByCategory[cat] = existing
}
existing.F1 += cm.F1 * float64(cm.QuestionCount)
existing.HitRate += cm.HitRate * float64(cm.QuestionCount)
existing.QuestionCount += cm.QuestionCount
}
}
if agg.TotalQuestions > 0 {
agg.OverallF1 /= float64(agg.TotalQuestions)
agg.OverallHitRate /= float64(agg.TotalQuestions)
}
for _, cat := range agg.ByCategory {
if cat.QuestionCount > 0 {
cat.F1 /= float64(cat.QuestionCount)
cat.HitRate /= float64(cat.QuestionCount)
}
}
return agg
}
// printSection prints a single comparison table section.
func printSection(title string, results []EvalResult) {
fmt.Printf("\n--- %s ---\n", title)
byMode := map[string][]EvalResult{}
for _, r := range results {
byMode[r.Mode] = append(byMode[r.Mode], r)
}
modes := map[string]AggMetrics{}
for mode, modeResults := range byMode {
modes[mode] = computeModeAgg(modeResults)
}
modeKeys := make([]string, 0, len(modes))
for k := range modes {
modeKeys = append(modeKeys, k)
}
sort.Strings(modeKeys)
// Collect all category keys across modes
catSet := map[int]bool{}
for _, agg := range modes {
for cat := range agg.ByCategory {
catSet[cat] = true
}
}
cats := make([]int, 0, len(catSet))
for cat := range catSet {
cats = append(cats, cat)
}
sort.Ints(cats)
fmt.Printf("%-10s %-8s %-8s", "Mode", "HitRate", "F1")
for _, cat := range cats {
fmt.Printf(" %-7s", fmt.Sprintf("C%d", cat))
}
fmt.Println()
fmt.Println(strings.Repeat("-", 10+8+8+7*len(cats)+8))
for _, mode := range modeKeys {
agg := modes[mode]
fmt.Printf("%-10s %-8.4f %-8.4f", mode, agg.OverallHitRate, agg.OverallF1)
for _, cat := range cats {
if cm, ok := agg.ByCategory[cat]; ok {
fmt.Printf(" %-7.4f", cm.HitRate)
} else {
fmt.Printf(" %-7s", "N/A")
}
}
fmt.Println()
}
}
// PrintComparison outputs a human-readable comparison table to stdout.
func PrintComparison(results []EvalResult, llmResults []EvalResult) {
printSection("No LLM generation", results)
if len(llmResults) > 0 {
printSection("With LLM", llmResults)
}
}

View file

@ -0,0 +1,104 @@
package main
import (
"math"
"testing"
)
func TestComputeModeAggAllCategories(t *testing.T) {
results := []EvalResult{
{
Mode: "test",
SampleID: "s1",
QAResults: []QAResult{
{Category: 1, TokenF1: 0.5, HitRate: 0.8},
{Category: 2, TokenF1: 0.3, HitRate: 0.6},
{Category: 3, TokenF1: 0.1, HitRate: 0.4},
{Category: 4, TokenF1: 0.7, HitRate: 0.9},
{Category: 5, TokenF1: 0.2, HitRate: 0.1},
},
},
}
for i := range results {
results[i].Agg = aggregateMetrics(results[i].QAResults)
}
got := computeModeAgg(results)
// Should have all 5 categories
for cat := 1; cat <= 5; cat++ {
cm, ok := got.ByCategory[cat]
if !ok {
t.Errorf("ByCategory missing category %d", cat)
continue
}
if cm.QuestionCount != 1 {
t.Errorf("ByCategory[%d].QuestionCount = %d, want 1", cat, cm.QuestionCount)
}
}
// Verify specific F1 values per category
wantF1 := map[int]float64{1: 0.5, 2: 0.3, 3: 0.1, 4: 0.7, 5: 0.2}
for cat, want := range wantF1 {
if cm, ok := got.ByCategory[cat]; ok {
if math.Abs(cm.F1-want) > 1e-9 {
t.Errorf("ByCategory[%d].F1 = %.4f, want %.4f", cat, cm.F1, want)
}
}
}
}
func TestComputeModeAgg(t *testing.T) {
// Two samples with different question counts:
// sample-a: 2 questions, F1 = [0.4, 0.6] → avg 0.5
// sample-b: 8 questions, F1 = [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1] → avg 0.1
//
// Unweighted (PrintComparison bug): (0.5 + 0.1) / 2 = 0.3
// Weighted (correct): (0.4+0.6 + 0.1*8) / 10 = 1.8 / 10 = 0.18
results := []EvalResult{
{
Mode: "test",
SampleID: "sample-a",
QAResults: []QAResult{
{TokenF1: 0.4, HitRate: 0.5},
{TokenF1: 0.6, HitRate: 0.7},
},
},
{
Mode: "test",
SampleID: "sample-b",
QAResults: []QAResult{
{TokenF1: 0.1, HitRate: 0.2},
{TokenF1: 0.1, HitRate: 0.2},
{TokenF1: 0.1, HitRate: 0.2},
{TokenF1: 0.1, HitRate: 0.2},
{TokenF1: 0.1, HitRate: 0.2},
{TokenF1: 0.1, HitRate: 0.2},
{TokenF1: 0.1, HitRate: 0.2},
{TokenF1: 0.1, HitRate: 0.2},
},
},
}
// Compute per-sample aggregates
for i := range results {
results[i].Agg = aggregateMetrics(results[i].QAResults)
}
got := computeModeAgg(results)
// Weighted: (0.4+0.6+0.1*8) / 10 = 1.8/10 = 0.18
wantF1 := 0.18
if math.Abs(got.OverallF1-wantF1) > 1e-9 {
t.Errorf("OverallF1 = %.6f, want %.6f (weighted average)", got.OverallF1, wantF1)
}
// Weighted: (0.5+0.7+0.2*8) / 10 = 2.8/10 = 0.28
wantRecall := 0.28
if math.Abs(got.OverallHitRate-wantRecall) > 1e-9 {
t.Errorf("OverallHitRate = %.6f, want %.6f (weighted average)", got.OverallHitRate, wantRecall)
}
if got.TotalQuestions != 10 {
t.Errorf("TotalQuestions = %d, want 10", got.TotalQuestions)
}
}

View file

@ -0,0 +1,85 @@
package main
import (
"context"
"fmt"
"log"
"github.com/sipeed/picoclaw/pkg/seahorse"
)
// ConvMap stores the mapping from sampleID to seahorse ConversationID.
type ConvMap map[string]int64
// SeahorseIngestResult holds the results of ingesting into seahorse.
type SeahorseIngestResult struct {
Engine *seahorse.Engine
ConvMap ConvMap // sampleID → conversationID
}
// IngestSeahorse loads all LOCOMO samples into a seahorse Engine.
// Returns the engine and a mapping from sampleID to conversationID for scoped retrieval.
func IngestSeahorse(ctx context.Context, samples []LocomoSample, dbPath string) (*SeahorseIngestResult, error) {
noopFn := func(ctx context.Context, prompt string, opts seahorse.CompleteOptions) (string, error) {
return "", nil
}
engine, err := seahorse.NewEngine(seahorse.Config{
DBPath: dbPath,
}, noopFn)
if err != nil {
return nil, fmt.Errorf("create seahorse engine: %w", err)
}
store := engine.GetRetrieval().Store()
convMap := make(ConvMap)
for si := range samples {
sample := &samples[si]
sessionKey := "locomo-" + sample.SampleID
// Check if conversation already exists (idempotent)
existing, _ := store.GetConversationBySessionKey(ctx, sessionKey)
if existing != nil {
convMap[sample.SampleID] = existing.ConversationID
log.Printf("Skipping existing sample %s: convID=%d", sample.SampleID, existing.ConversationID)
continue
}
turns := GetTurns(sample)
// Convert turns to seahorse messages
msgs := make([]seahorse.Message, 0, len(turns))
for _, turn := range turns {
content := turn.Speaker + ": " + turn.Text
msgs = append(msgs, seahorse.Message{
Role: "user",
Content: content,
TokenCount: len(turn.Text) / 4,
})
}
// Ingest all turns for this sample
_, err := engine.Ingest(ctx, sessionKey, msgs)
if err != nil {
return nil, fmt.Errorf("ingest sample %s: %w", sample.SampleID, err)
}
// Get the conversation ID for scoped retrieval
conv, err := store.GetConversationBySessionKey(ctx, sessionKey)
if err != nil {
return nil, fmt.Errorf("get conversation for %s: %w", sample.SampleID, err)
}
if conv == nil {
return nil, fmt.Errorf("conversation not found for %s after ingest", sample.SampleID)
}
convMap[sample.SampleID] = conv.ConversationID
log.Printf("Ingested sample %s: %d turns, convID=%d", sample.SampleID, len(turns), conv.ConversationID)
}
log.Printf("Seahorse ingestion complete: %d samples, %d conversations", len(samples), len(convMap))
return &SeahorseIngestResult{
Engine: engine,
ConvMap: convMap,
}, nil
}

View file

@ -0,0 +1,79 @@
package main
import (
"context"
"encoding/json"
"path/filepath"
"testing"
"github.com/sipeed/picoclaw/pkg/seahorse"
)
func TestIngestSeahorseIdempotent(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
// Minimal test data
samples := []LocomoSample{
{
SampleID: "test-1",
Conversation: map[string]json.RawMessage{
"session_1": json.RawMessage(`[
{"speaker":"A","dia_id":"D1:1","text":"hello world this is a test message"},
{"speaker":"B","dia_id":"D1:2","text":"another message for testing purposes"}
]`),
},
},
}
// First ingestion
result1, err := IngestSeahorse(ctx, samples, dbPath)
if err != nil {
t.Fatalf("first ingest failed: %v", err)
}
convCount1 := len(result1.ConvMap)
result1.Engine.Close()
// Second ingestion on same DB — should reuse existing data
result2, err := IngestSeahorse(ctx, samples, dbPath)
if err != nil {
t.Fatalf("second ingest failed: %v", err)
}
defer result2.Engine.Close()
// ConvMap should have same number of entries (no duplicates)
if len(result2.ConvMap) != convCount1 {
t.Errorf("second ingest convMap has %d entries, want %d (same as first)",
len(result2.ConvMap), convCount1)
}
// Verify conversation IDs are the same (reused, not new ones)
for id, cid1 := range result1.ConvMap {
cid2, ok := result2.ConvMap[id]
if !ok {
t.Errorf("sample %s missing from second ConvMap", id)
continue
}
if cid2 != cid1 {
t.Errorf("sample %s: second ingest got convID %d, want %d (reused)", id, cid2, cid1)
}
}
// Verify no duplicate messages by counting
store := result2.Engine.GetRetrieval().Store()
for _, convID := range result2.ConvMap {
msgs, err := store.SearchMessages(ctx, seahorse.SearchInput{
Pattern: "test",
ConversationID: convID,
Limit: 100,
})
if err != nil {
t.Fatalf("search failed: %v", err)
}
// Should find exactly 1 message containing "test" (the first turn)
if len(msgs) > 2 {
t.Errorf("found %d messages for 'test' in conv %d, expected ≤2 (no duplicates)", len(msgs), convID)
}
}
}

View file

@ -0,0 +1,34 @@
package main
import (
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/session"
)
// LegacyStore wraps session.SessionManager for legacy baseline.
type LegacyStore struct {
sm *session.SessionManager
}
// NewLegacyStore creates a new in-memory session manager.
func NewLegacyStore() *LegacyStore {
return &LegacyStore{
sm: session.NewSessionManager(""),
}
}
// IngestSample loads all turns from a LOCOMO sample into the legacy session store.
func (ls *LegacyStore) IngestSample(sample *LocomoSample) {
sessionKey := "locomo-" + sample.SampleID
turns := GetTurns(sample)
for _, turn := range turns {
content := turn.Speaker + ": " + turn.Text
ls.sm.AddMessage(sessionKey, "user", content)
}
}
// GetHistory returns all messages for a sample's session.
func (ls *LegacyStore) GetHistory(sampleID string) []providers.Message {
sessionKey := "locomo-" + sampleID
return ls.sm.GetHistory(sessionKey)
}

View file

@ -0,0 +1,142 @@
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
// LocomoSample represents one conversation sample from the LOCOMO dataset.
type LocomoSample struct {
SampleID string `json:"sample_id"`
Conversation map[string]json.RawMessage `json:"conversation"`
QA []LocomoQA `json:"qa"`
}
// LocomoTurn represents a single turn in a conversation.
type LocomoTurn struct {
Speaker string `json:"speaker"`
DiaID string `json:"dia_id"`
Text string `json:"text"`
}
// LocomoQA represents a question-answer pair with evidence.
type LocomoQA struct {
Question string `json:"question"`
Answer json.RawMessage `json:"answer"` // can be string or int (category 1-4)
AdversarialAnswer string `json:"adversarial_answer"` // category 5 only
Evidence []string `json:"evidence"`
Category int `json:"category"` // 1=single-hop, 2=multi-hop, 3=open-ended, 5=adversarial
}
// AnswerString returns the answer as a string, handling both string and int types.
func (qa *LocomoQA) AnswerString() string {
// Prefer answer field (category 1-4)
if len(qa.Answer) > 0 {
var s string
if err := json.Unmarshal(qa.Answer, &s); err == nil {
return s
}
var n json.Number
if err := json.Unmarshal(qa.Answer, &n); err == nil {
return n.String()
}
return strings.Trim(string(qa.Answer), `"`)
}
// Fallback to adversarial_answer (category 5)
return qa.AdversarialAnswer
}
// LoadDataset reads all JSON files from dataDir and returns parsed samples.
func LoadDataset(dataDir string) ([]LocomoSample, error) {
entries, err := os.ReadDir(dataDir)
if err != nil {
return nil, fmt.Errorf("read data dir %s: %w", dataDir, err)
}
var samples []LocomoSample
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".json") {
path := filepath.Join(dataDir, entry.Name())
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read file %s: %w", path, err)
}
var batch []LocomoSample
if err := json.Unmarshal(data, &batch); err != nil {
return nil, fmt.Errorf("parse file %s: %w", path, err)
}
samples = append(samples, batch...)
}
}
return samples, nil
}
// GetSessionNames returns sorted session keys (session_1, session_2, ...) from conversation.
func GetSessionNames(conv map[string]json.RawMessage) []string {
var names []string
for k := range conv {
if strings.HasPrefix(k, "session_") && !strings.Contains(k, "_date_time") {
names = append(names, k)
}
}
sort.Slice(names, func(i, j int) bool {
ni := sessionNum(names[i])
nj := sessionNum(names[j])
return ni < nj
})
return names
}
func sessionNum(key string) int {
// "session_1" → 1, "session_10" → 10
parts := strings.SplitN(key, "_", 2)
if len(parts) < 2 {
return 0
}
n, _ := strconv.Atoi(parts[1])
return n
}
// GetTurns flattens all sessions' turns in chronological order.
func GetTurns(sample *LocomoSample) []LocomoTurn {
names := GetSessionNames(sample.Conversation)
var all []LocomoTurn
for _, name := range names {
raw, ok := sample.Conversation[name]
if !ok {
continue
}
var turns []LocomoTurn
if err := json.Unmarshal(raw, &turns); err != nil {
log.Printf("WARNING: unmarshal failed for session %q in sample %s: %v", name, sample.SampleID, err)
continue
}
all = append(all, turns...)
}
return all
}
// GetTurnByDiaID finds a specific turn by dia_id (e.g. "D1:3").
func GetTurnByDiaID(sample *LocomoSample, diaID string) *LocomoTurn {
turns := GetTurns(sample)
for i := range turns {
if turns[i].DiaID == diaID {
return &turns[i]
}
}
return nil
}
// GetSpeakers returns the two speaker names from conversation metadata.
func GetSpeakers(conv map[string]json.RawMessage) (string, string) {
var a, b string
json.Unmarshal(conv["speaker_a"], &a)
json.Unmarshal(conv["speaker_b"], &b)
return a, b
}

View file

@ -0,0 +1,67 @@
package main
import (
"encoding/json"
"testing"
)
func TestAnswerString(t *testing.T) {
tests := []struct {
name string
json string
want string
}{
{
"string answer",
`{"question":"Q","answer":"Paris","evidence":[],"category":1}`,
"Paris",
},
{
"int answer",
`{"question":"Q","answer":42,"evidence":[],"category":1}`,
"42",
},
{
"adversarial answer (category 5)",
`{"question":"Q","evidence":[],"category":5,"adversarial_answer":"self-care is important"}`,
"self-care is important",
},
{
"both answer and adversarial_answer present",
`{"question":"Q","answer":"normal","evidence":[],"category":5,"adversarial_answer":"adversarial"}`,
"normal",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var qa LocomoQA
if err := json.Unmarshal([]byte(tt.json), &qa); err != nil {
t.Fatalf("unmarshal: %v", err)
}
got := qa.AnswerString()
if got != tt.want {
t.Errorf("AnswerString() = %q, want %q", got, tt.want)
}
})
}
}
func TestGetSessionNames(t *testing.T) {
conv := map[string]json.RawMessage{
"session_2": {},
"session_1": {},
"session_10": {},
"session_1_date_time": {},
"speaker_a": {},
}
names := GetSessionNames(conv)
want := []string{"session_1", "session_2", "session_10"}
if len(names) != len(want) {
t.Fatalf("got %v, want %v", names, want)
}
for i, n := range names {
if n != want[i] {
t.Errorf("names[%d] = %q, want %q", i, n, want[i])
}
}
}

View file

@ -0,0 +1,208 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/pkg/logger"
)
var (
flagData string
flagOut string
flagMode string
flagBudget int
)
func main() {
// Suppress seahorse INFO logs during benchmark
logger.SetLevel(logger.WARN)
rootCmd := &cobra.Command{
Use: "membench",
Short: "Memory benchmark tool for picoclaw",
}
ingestCmd := &cobra.Command{
Use: "ingest",
Short: "Load LOCOMO data into storage backends",
RunE: runIngest,
}
ingestCmd.Flags().StringVar(&flagData, "data", "", "LOCOMO dataset directory (required)")
ingestCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
ingestCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to ingest: legacy, seahorse, or all")
evalCmd := &cobra.Command{
Use: "eval",
Short: "Run QA evaluation against ingested data",
RunE: runEval,
}
evalCmd.Flags().StringVar(&flagData, "data", "", "LOCOMO dataset directory (required)")
evalCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
evalCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to evaluate: legacy, seahorse, or all")
evalCmd.Flags().IntVar(&flagBudget, "budget", 4000, "token budget for retrieval")
reportCmd := &cobra.Command{
Use: "report",
Short: "Output comparison results from evaluation",
RunE: runReport,
}
reportCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
runCmd := &cobra.Command{
Use: "run",
Short: "Convenience: eval + report (ingestion is done inline)",
RunE: runAll,
}
runCmd.Flags().StringVar(&flagData, "data", "", "LOCOMO dataset directory (required)")
runCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory")
runCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to run: legacy, seahorse, or all")
runCmd.Flags().IntVar(&flagBudget, "budget", 4000, "token budget for retrieval")
rootCmd.AddCommand(ingestCmd, evalCmd, reportCmd, runCmd)
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
func modesFromFlag() []string {
switch strings.ToLower(flagMode) {
case "all":
return []string{"legacy", "seahorse"}
default:
return []string{strings.ToLower(flagMode)}
}
}
func runIngest(cmd *cobra.Command, args []string) error {
if flagData == "" {
return fmt.Errorf("--data is required")
}
modes := modesFromFlag()
if len(modes) == 0 {
return nil
}
ctx := context.Background()
samples, err := LoadDataset(flagData)
if err != nil {
return fmt.Errorf("load dataset: %w", err)
}
log.Printf("Loaded %d samples from %s", len(samples), flagData)
for _, mode := range modes {
switch mode {
case "legacy":
legacy := NewLegacyStore()
for i := range samples {
legacy.IngestSample(&samples[i])
}
log.Printf("legacy: ingested %d samples", len(samples))
case "seahorse":
dbPath := filepath.Join(flagOut, "seahorse.db")
if err := os.MkdirAll(flagOut, 0o755); err != nil {
return fmt.Errorf("create out dir: %w", err)
}
_, err := IngestSeahorse(ctx, samples, dbPath)
if err != nil {
return fmt.Errorf("ingest seahorse: %w", err)
}
}
}
return nil
}
func runEval(cmd *cobra.Command, args []string) error {
if flagData == "" {
return fmt.Errorf("--data is required")
}
modes := modesFromFlag()
if len(modes) == 0 {
return nil
}
ctx := context.Background()
samples, err := LoadDataset(flagData)
if err != nil {
return fmt.Errorf("load dataset: %w", err)
}
log.Printf("Loaded %d samples", len(samples))
var allResults []EvalResult
for _, mode := range modes {
switch mode {
case "legacy":
legacy := NewLegacyStore()
for i := range samples {
legacy.IngestSample(&samples[i])
}
results := EvalLegacy(ctx, samples, legacy, flagBudget)
allResults = append(allResults, results...)
log.Printf("legacy: evaluated %d samples", len(results))
case "seahorse":
dbPath := filepath.Join(flagOut, "seahorse.db")
ir, err := IngestSeahorse(ctx, samples, dbPath)
if err != nil {
return fmt.Errorf("ingest seahorse: %w", err)
}
results := EvalSeahorse(ctx, samples, ir, flagBudget)
allResults = append(allResults, results...)
log.Printf("seahorse: evaluated %d samples", len(results))
}
}
if err := SaveResults(allResults, flagOut); err != nil {
return fmt.Errorf("save results: %w", err)
}
if err := SaveAggregated(allResults, flagOut); err != nil {
return fmt.Errorf("save aggregated: %w", err)
}
PrintComparison(allResults, nil)
return nil
}
func runReport(cmd *cobra.Command, args []string) error {
entries, err := os.ReadDir(flagOut)
if err != nil {
return fmt.Errorf("read out dir: %w", err)
}
var allResults []EvalResult
for _, entry := range entries {
if !entry.IsDir() && strings.HasPrefix(entry.Name(), "eval_") && strings.HasSuffix(entry.Name(), ".json") {
path := filepath.Join(flagOut, entry.Name())
var r EvalResult
data, err := os.ReadFile(path)
if err != nil {
log.Printf("WARN: read %s: %v", path, err)
continue
}
if err := json.Unmarshal(data, &r); err != nil {
log.Printf("WARN: parse %s: %v", path, err)
continue
}
allResults = append(allResults, r)
}
}
if len(allResults) == 0 {
return fmt.Errorf("no eval results found in %s", flagOut)
}
PrintComparison(allResults, nil)
return nil
}
func runAll(cmd *cobra.Command, args []string) error {
return runEval(cmd, args)
}

View file

@ -0,0 +1,227 @@
package main
import (
"fmt"
"log"
"regexp"
"strconv"
"strings"
"unicode"
)
// diaIDRe matches valid dia_id patterns like "D1:3", "D30:5".
var diaIDRe = regexp.MustCompile(`^D(\d+):(\d+)$`)
// SplitEvidenceIDs splits an evidence string that may contain multiple
// semicolon-separated or space-separated dia_ids. Only returns valid IDs.
// Example: "D8:6; D9:17" → ["D8:6", "D9:17"]
// Example: "D9:1 D4:4 D4:6" → ["D9:1", "D4:4", "D4:6"]
func SplitEvidenceIDs(evidence string) []string {
if evidence == "" {
return nil
}
// Split on semicolons first, then spaces
parts := strings.Split(evidence, ";")
var ids []string
for _, part := range parts {
for _, token := range strings.Fields(strings.TrimSpace(part)) {
token = strings.TrimSpace(token)
if diaIDRe.MatchString(token) {
ids = append(ids, NormalizeDiaID(token))
}
}
}
if len(ids) == 0 {
return nil
}
return ids
}
// NormalizeDiaID strips leading zeros from the number parts of a dia_id.
// "D30:05" → "D30:5", "D10:003" → "D10:3"
func NormalizeDiaID(id string) string {
m := diaIDRe.FindStringSubmatch(id)
if m == nil {
return id
}
session, _ := strconv.Atoi(m[1])
turn, _ := strconv.Atoi(m[2])
return fmt.Sprintf("D%d:%d", session, turn)
}
// stopwords is a fixed English stopword list for deterministic keyword extraction.
var stopwords = map[string]struct{}{
"a": {}, "an": {}, "the": {},
"is": {}, "are": {}, "was": {}, "were": {},
"did": {}, "does": {}, "do": {},
"when": {}, "where": {}, "what": {}, "who": {},
"how": {}, "why": {},
"to": {}, "of": {}, "in": {}, "on": {}, "at": {},
"for": {}, "and": {}, "or": {}, "but": {}, "not": {},
"it": {}, "this": {}, "that": {}, "with": {},
"from": {}, "by": {}, "as": {},
"if": {}, "then": {}, "than": {}, "so": {},
"no": {}, "yes": {},
"all": {}, "any": {}, "each": {}, "every": {},
"some": {}, "such": {},
"about": {}, "into": {}, "over": {},
"after": {}, "before": {}, "between": {},
"through": {}, "during": {}, "until": {},
"would": {}, "could": {}, "should": {},
"may": {}, "might": {}, "can": {},
"will": {}, "shall": {}, "must": {},
"have": {}, "has": {}, "had": {},
"been": {}, "being": {}, "be": {},
"go": {}, "went": {}, "gone": {},
"i": {}, "you": {}, "me": {}, "my": {}, "your": {},
"we": {}, "they": {}, "them": {}, "our": {},
"its": {}, "their": {}, "he": {}, "she": {},
"his": {}, "her": {},
}
// ExtractKeywords removes stopwords and punctuation, returns individual keywords.
// Deterministic: uses fixed stopword list, no LLM.
func ExtractKeywords(question string) []string {
// Lowercase and split on whitespace/punctuation
lower := strings.ToLower(question)
words := strings.FieldsFunc(lower, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
var keywords []string
for _, w := range words {
if w == "" || len(w) < 2 {
continue
}
if _, ok := stopwords[w]; ok {
continue
}
keywords = append(keywords, w)
if len(keywords) >= 6 {
break
}
}
return keywords
}
// TokenOverlapF1 computes token-level F1 between prediction and reference.
// Both strings are lowercased and split on whitespace.
// NOTE: This metric underestimates quality for multi-hop (cat 2) and
// open-ended (cat 3) questions where the gold answer uses different phrasing
// than the source text. LLM-Judge scoring is a v2 follow-up.
func TokenOverlapF1(prediction, reference string) float64 {
predTokens := tokenize(prediction)
refTokens := tokenize(reference)
if len(predTokens) == 0 && len(refTokens) == 0 {
return 1.0
}
if len(predTokens) == 0 || len(refTokens) == 0 {
return 0.0
}
// Count matches
refCount := map[string]int{}
for _, t := range refTokens {
refCount[t]++
}
predCount := map[string]int{}
for _, t := range predTokens {
predCount[t]++
}
var matches float64
for token, pc := range predCount {
if rc, ok := refCount[token]; ok {
matches += float64(min(pc, rc))
}
}
precision := matches / float64(len(predTokens))
recall := matches / float64(len(refTokens))
if precision+recall == 0 {
return 0.0
}
return 2 * precision * recall / (precision + recall)
}
func tokenize(s string) []string {
lower := strings.ToLower(s)
return strings.Fields(lower)
}
// RecallHitRate computes fraction of evidence IDs found in retrieved content.
// For each evidence dia_id, looks up the turn text and checks substring match.
// Logs a warning for turns with text < 20 chars (higher false-positive risk).
func RecallHitRate(evidenceIDs []string, sample *LocomoSample, retrievedContent string) float64 {
if len(evidenceIDs) == 0 {
return 1.0 // no evidence required = perfect
}
// Expand any multi-ID evidence entries (e.g. "D8:6; D9:17" or "D9:1 D4:4")
var expanded []string
for _, id := range evidenceIDs {
split := SplitEvidenceIDs(id)
if split != nil {
expanded = append(expanded, split...)
}
}
if len(expanded) == 0 {
log.Printf("WARNING: no valid dia_ids after expanding evidence %v", evidenceIDs)
return float64(0) / float64(len(evidenceIDs))
}
// Build turn index once (avoids re-parsing JSON per ID)
turns := GetTurns(sample)
turnMap := make(map[string]*LocomoTurn, len(turns))
for i := range turns {
turnMap[turns[i].DiaID] = &turns[i]
}
lowerRetrieved := strings.ToLower(retrievedContent)
found := 0
resolvable := 0
for _, diaID := range expanded {
turn, ok := turnMap[diaID]
if !ok {
log.Printf("WARNING: dia_id %q not found in sample %s", diaID, sample.SampleID)
continue
}
resolvable++
if len(turn.Text) < 20 {
log.Printf("WARNING: short turn text (%d chars) for dia_id %s: %q",
len(turn.Text), diaID, turn.Text)
}
if strings.Contains(lowerRetrieved, strings.ToLower(turn.Text)) {
found++
}
}
if resolvable == 0 {
return 0.0 // no resolvable evidence = can't evaluate
}
return float64(found) / float64(resolvable)
}
// BudgetTruncate truncates messages to fit within a token budget.
// Returns the truncated messages and total token count.
func BudgetTruncate(messages []string, budgetTokens int) ([]string, int) {
var result []string
total := 0
// Walk from the front (best first) and keep until budget exhausted.
for i := 0; i < len(messages); i++ {
tokens := len(messages[i]) / 4
if total+tokens > budgetTokens && len(result) > 0 {
break
}
result = append(result, messages[i])
total += tokens
}
return result, total
}
// StringListToContent joins a list of strings into a single content string.
func StringListToContent(parts []string) string {
return strings.Join(parts, "\n")
}

View file

@ -0,0 +1,239 @@
package main
import (
"encoding/json"
"math"
"testing"
)
func TestSplitEvidenceIDs(t *testing.T) {
tests := []struct {
input string
want []string
}{
{"D1:3", []string{"D1:3"}},
{"D8:6; D9:17", []string{"D8:6", "D9:17"}},
{"D9:1 D4:4 D4:6", []string{"D9:1", "D4:4", "D4:6"}},
{"D22:1 D22:2 D9:10 D9:11", []string{"D22:1", "D22:2", "D9:10", "D9:11"}},
{"D21:18 D21:22 D11:15 D11:19", []string{"D21:18", "D21:22", "D11:15", "D11:19"}},
{"D30:05", []string{"D30:5"}},
{"D", nil},
{"D:", nil},
{"", nil},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := SplitEvidenceIDs(tt.input)
if len(got) != len(tt.want) {
t.Fatalf("SplitEvidenceIDs(%q) = %v, want %v", tt.input, got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("[%d] = %q, want %q", i, got[i], tt.want[i])
}
}
})
}
}
func TestNormalizeDiaID(t *testing.T) {
tests := []struct {
input string
want string
}{
{"D1:3", "D1:3"},
{"D30:05", "D30:5"},
{"D10:003", "D10:3"},
{"D1:0", "D1:0"},
}
for _, tt := range tests {
got := NormalizeDiaID(tt.input)
if got != tt.want {
t.Errorf("NormalizeDiaID(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestTokenOverlapF1(t *testing.T) {
tests := []struct {
name string
prediction string
reference string
want float64
}{
{"exact match", "hello world", "hello world", 1.0},
{"no overlap", "foo bar", "baz qux", 0.0},
{"empty both", "", "", 1.0},
{"empty prediction", "", "hello", 0.0},
{"empty reference", "hello", "", 0.0},
{"partial overlap", "the cat sat on the mat", "the cat on the floor", 8.0 / 11.0},
{"case insensitive", "Hello World", "hello world", 1.0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := TokenOverlapF1(tt.prediction, tt.reference)
if math.Abs(got-tt.want) > 1e-9 {
t.Errorf("TokenOverlapF1(%q, %q) = %.4f, want %.4f",
tt.prediction, tt.reference, got, tt.want)
}
})
}
}
func TestBudgetTruncate(t *testing.T) {
t.Run("within budget returns all", func(t *testing.T) {
msgs := []string{"short", "message", "here"}
result, total := BudgetTruncate(msgs, 1000)
if len(result) != 3 {
t.Errorf("expected 3 messages, got %d", len(result))
}
if total == 0 {
t.Error("expected non-zero token count")
}
})
t.Run("over budget keeps best first", func(t *testing.T) {
msgs := []string{
"best message that is quite long and takes up tokens",
"good message also fairly long content",
"worst short",
}
result, _ := BudgetTruncate(msgs, 5) // very small budget
if len(result) == 0 {
t.Fatal("expected at least one message")
}
// Best-ranked (first) should be kept
if result[0] != "best message that is quite long and takes up tokens" {
t.Errorf("expected best message kept first, got %q", result[0])
}
})
t.Run("over budget keeps best ranked first", func(t *testing.T) {
// Messages are sorted by bm25 rank ascending (best/most-negative first).
// When budget is insufficient, BudgetTruncate must keep the front
// (best-ranked) messages, not the tail (worst-ranked).
msgs := []string{
"best ranked message with some content here",
"second best message also has content",
"third message here too",
"worst ranked short",
}
// Budget only fits ~1 message (~10 tokens per message, budget=12)
result, _ := BudgetTruncate(msgs, 12)
if len(result) == 0 {
t.Fatal("expected at least one message")
}
if result[0] != "best ranked message with some content here" {
t.Errorf("expected best-ranked (first) message kept, got %q", result[0])
}
// Worst-ranked (last) must NOT appear
for _, m := range result {
if m == "worst ranked short" {
t.Error("worst-ranked message should have been truncated")
}
}
})
t.Run("preserves original order", func(t *testing.T) {
msgs := []string{"alpha", "beta", "gamma"}
result, _ := BudgetTruncate(msgs, 100)
for i, got := range result {
if got != msgs[i] {
t.Errorf("result[%d] = %q, want %q", i, got, msgs[i])
}
}
})
t.Run("empty input", func(t *testing.T) {
result, total := BudgetTruncate(nil, 100)
if len(result) != 0 {
t.Errorf("expected 0 messages, got %d", len(result))
}
if total != 0 {
t.Errorf("expected 0 tokens, got %d", total)
}
})
}
func TestRecallHitRate(t *testing.T) {
// Build a sample with known turns
sample := &LocomoSample{
SampleID: "test-sample",
Conversation: map[string]json.RawMessage{
"session_1": json.RawMessage(`[
{"speaker":"A","dia_id":"D1:1","text":"hello world this is a test message with enough length"},
{"speaker":"B","dia_id":"D1:2","text":"another message for testing recall computation purposes here"},
{"speaker":"A","dia_id":"D1:3","text":"third turn with some more content to test"}
]`),
},
}
t.Run("all evidence found", func(t *testing.T) {
retrieved := "hello world this is a test message with enough length another message for testing recall computation purposes here"
got := RecallHitRate([]string{"D1:1", "D1:2"}, sample, retrieved)
if math.Abs(got-1.0) > 1e-9 {
t.Errorf("RecallHitRate all found = %.4f, want 1.0", got)
}
})
t.Run("partial evidence found", func(t *testing.T) {
retrieved := "hello world this is a test message with enough length"
got := RecallHitRate([]string{"D1:1", "D1:2"}, sample, retrieved)
if math.Abs(got-0.5) > 1e-9 {
t.Errorf("RecallHitRate partial = %.4f, want 0.5", got)
}
})
t.Run("no evidence required", func(t *testing.T) {
got := RecallHitRate(nil, sample, "anything")
if got != 1.0 {
t.Errorf("RecallHitRate no evidence = %.4f, want 1.0", got)
}
})
t.Run("missing turn excluded from denominator", func(t *testing.T) {
// D1:1 is found, D99:1 does not exist in sample
// Should only count resolvable turns in denominator
retrieved := "hello world this is a test message with enough length"
got := RecallHitRate([]string{"D1:1", "D99:1"}, sample, retrieved)
if math.Abs(got-1.0) > 1e-9 {
t.Errorf("RecallHitRate missing turn = %.4f, want 1.0 (unresolvable excluded)", got)
}
})
}
func TestExtractKeywords(t *testing.T) {
tests := []struct {
name string
input string
want []string
}{
{"simple", "What is the capital of France", []string{"capital", "france"}},
{
"stops removed",
"Who is the president of the United States",
[]string{"president", "united", "states"},
},
{
"max 6 keywords",
"one two three four five six seven eight nine ten",
[]string{"one", "two", "three", "four", "five", "six"},
},
{"short words filtered", "I am a go to the store", []string{"am", "store"}},
{"empty", "", nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ExtractKeywords(tt.input)
if len(got) != len(tt.want) {
t.Fatalf("ExtractKeywords(%q) = %v (len %d), want %v (len %d)",
tt.input, got, len(got), tt.want, len(tt.want))
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("[%d] = %q, want %q", i, got[i], tt.want[i])
}
}
})
}
}

View file

@ -0,0 +1,69 @@
# Picoclaw Launcher TUI
This directory contains the terminal-based TUI launcher for `picoclaw`.
It provides a lightweight, terminal-native user interface for managing, configuring, and interacting with the core `picoclaw` engine, without requiring a web browser or graphical environment.
## Architecture
The TUI launcher is implemented purely in Go with no external runtime dependencies:
* **`main.go`**: Application entry point, handles initialization and main event loop
* **`ui/`**: TUI interface components built on tview + tcell framework:
- `home.go`: Main dashboard with navigation menu
- `schemes.go`: AI model scheme management
- `users.go`: User and API key management for model providers
- `channels.go`: Communication channel (Telegram/Discord/WeChat etc.) configuration editor
- `gateway.go`: PicoClaw gateway daemon lifecycle management (start/stop/status)
- `app.go`: Core TUI application framework and navigation logic
- `models.go`: Data structures and state management
* **`config/`**: Configuration management layer, integrates with the core picoclaw configuration system
## Getting Started
### Prerequisites
* Go 1.25+
* Terminal with 256-color support (most modern terminals are compatible)
### Development
Run the TUI launcher directly in development mode:
```bash
# From project root
go run ./cmd/picoclaw-launcher-tui
# Or from this directory
go run .
```
### Build
Build the standalone TUI launcher binary:
```bash
# From project root (recommended)
make build-launcher-tui
# Output will be at:
# build/picoclaw-launcher-tui-<platform>-<arch>
# with symlink build/picoclaw-launcher-tui
# Or build directly from this directory
go build -o picoclaw-launcher-tui .
```
### Key Features
* 🖥️ Terminal-native interface - works over SSH, on headless servers, and in low-resource environments
* ⚙️ AI model scheme and API key management
* 📱 Communication channel configuration editor (Telegram/Discord/WeChat etc.)
* 🔄 PicoClaw gateway daemon management (start/stop/status monitoring)
* 💬 One-click launch of interactive AI chat session
* 🎯 Keyboard-first design with intuitive shortcuts
### Other Commands
```bash
# Run with custom config file path
go run . /path/to/custom/config.json
```

View file

@ -0,0 +1,236 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
// Package config provides types and I/O for ~/.picoclaw/tui.toml.
package config
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/BurntSushi/toml"
"github.com/sipeed/picoclaw/pkg/fileutil"
)
// DefaultConfigPath returns the default path to the tui.toml config file.
func DefaultConfigPath() string {
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
return filepath.Join(home, ".picoclaw", "tui.toml")
}
// TUIConfig is the top-level structure of ~/.picoclaw/tui.toml.
type TUIConfig struct {
Version string `toml:"version"`
Model Model `toml:"model"`
Provider Provider `toml:"provider"`
}
type Model struct {
Type string `toml:"type"` // "provider" (default) | "manual"
}
type Provider struct {
Schemes []Scheme `toml:"schemes"`
Users []User `toml:"users"`
Current ProviderCurrent `toml:"current"`
}
type Scheme struct {
Name string `toml:"name"` // unique key
BaseURL string `toml:"baseURL"` // required
Type string `toml:"type"` // "openai-compatible" (default) | "anthropic"
}
type User struct {
Name string `toml:"name"`
Scheme string `toml:"scheme"` // references Scheme.Name; (Name+Scheme) is unique
Type string `toml:"type"` // "key" (default) | "OAuth"
Key string `toml:"key"`
}
type ProviderCurrent struct {
Scheme string `toml:"scheme"` // references Scheme.Name
User string `toml:"user"` // references User.Name where User.Scheme == Scheme
Model string `toml:"model"` // from GET <baseURL>/models
}
// DefaultConfig returns a minimal valid TUIConfig.
func DefaultConfig() *TUIConfig {
return &TUIConfig{
Version: "1.0",
Model: Model{Type: "provider"},
Provider: Provider{
Schemes: []Scheme{},
Users: []User{},
Current: ProviderCurrent{},
},
}
}
// Load reads the TUI config from path. Returns a default config if the file does not exist.
func Load(path string) (*TUIConfig, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return DefaultConfig(), nil
}
if err != nil {
return nil, fmt.Errorf("failed to read config file %q: %w", path, err)
}
cfg := DefaultConfig()
if _, err := toml.Decode(string(data), cfg); err != nil {
return nil, fmt.Errorf("failed to parse config file %q: %w", path, err)
}
applyDefaults(cfg)
return cfg, nil
}
// Save writes cfg to path atomically (safe for flash / SD storage).
func Save(path string, cfg *TUIConfig) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
var buf bytes.Buffer
enc := toml.NewEncoder(&buf)
if err := enc.Encode(cfg); err != nil {
return fmt.Errorf("failed to encode config: %w", err)
}
if err := fileutil.WriteFileAtomic(path, buf.Bytes(), 0o600); err != nil {
return fmt.Errorf("failed to write config file %q: %w", path, err)
}
return nil
}
func applyDefaults(cfg *TUIConfig) {
if cfg.Version == "" {
cfg.Version = "1.0"
}
if cfg.Model.Type == "" {
cfg.Model.Type = "provider"
}
for i := range cfg.Provider.Schemes {
if cfg.Provider.Schemes[i].Type == "" {
cfg.Provider.Schemes[i].Type = "openai-compatible"
}
}
for i := range cfg.Provider.Users {
if cfg.Provider.Users[i].Type == "" {
cfg.Provider.Users[i].Type = "key"
}
}
}
// SchemeByName returns the first Scheme whose Name matches, or nil.
func (p *Provider) SchemeByName(name string) *Scheme {
for i := range p.Schemes {
if p.Schemes[i].Name == name {
return &p.Schemes[i]
}
}
return nil
}
// UsersForScheme returns all users whose Scheme field matches schemeName.
func (p *Provider) UsersForScheme(schemeName string) []User {
var out []User
for _, u := range p.Users {
if u.Scheme == schemeName {
out = append(out, u)
}
}
return out
}
// SyncSelectedModelToMainConfig syncs the currently selected model to ~/.picoclaw/config.json
// Adds/replaces a "tui-prefer" model entry and sets it as the default model.
// Preserves all other existing fields in the config file unchanged.
func SyncSelectedModelToMainConfig(scheme Scheme, user User, modelID string) error {
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
mainConfigPath := filepath.Join(home, ".picoclaw", "config.json")
var cfg map[string]any
if data, readErr := os.ReadFile(mainConfigPath); readErr == nil {
if unmarshalErr := json.Unmarshal(data, &cfg); unmarshalErr != nil {
cfg = make(map[string]any)
}
} else {
cfg = make(map[string]any)
}
if _, ok := cfg["agents"]; !ok {
cfg["agents"] = make(map[string]any)
}
agents, ok := cfg["agents"].(map[string]any)
if ok {
if _, ok := agents["defaults"]; !ok {
agents["defaults"] = make(map[string]any)
}
defaults, ok := agents["defaults"].(map[string]any)
if ok {
defaults["model"] = "tui-prefer"
}
}
tuiModel := map[string]any{
"model_name": "tui-prefer",
"model": modelID,
"api_key": user.Key,
"api_base": scheme.BaseURL,
}
modelList := []any{}
if ml, ok := cfg["model_list"].([]any); ok {
modelList = ml
}
found := false
for i, m := range modelList {
if entry, ok := m.(map[string]any); ok {
if name, ok := entry["model_name"].(string); ok && name == "tui-prefer" {
modelList[i] = tuiModel
found = true
break
}
}
}
if !found {
modelList = append(modelList, tuiModel)
}
cfg["model_list"] = modelList
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(mainConfigPath), 0o700); err != nil {
return err
}
return os.WriteFile(mainConfigPath, data, 0o600)
}
func (cfg *TUIConfig) CurrentModelLabel() string {
cur := cfg.Provider.Current
if cur.Model == "" {
return "(not configured)"
}
label := cur.Scheme
if label != "" {
label += " / "
}
return label + cur.Model
}

View file

@ -0,0 +1,48 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/ui"
)
func main() {
configPath := tuicfg.DefaultConfigPath()
if len(os.Args) > 1 {
configPath = os.Args[1]
}
configDir := filepath.Dir(configPath)
if _, err := os.Stat(configDir); os.IsNotExist(err) {
cmd := exec.Command("picoclaw", "onboard")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
}
cfg, err := tuicfg.Load(configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err)
os.Exit(1)
}
app := ui.New(cfg, configPath)
// Bind model selection hook to sync to main config
app.OnModelSelected = func(scheme tuicfg.Scheme, user tuicfg.User, modelID string) {
_ = tuicfg.SyncSelectedModelToMainConfig(scheme, user, modelID)
}
if err := app.Run(); err != nil {
fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err)
os.Exit(1)
}
}

View file

@ -0,0 +1,325 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"fmt"
"sync"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
)
// App is the root TUI application.
type App struct {
tapp *tview.Application
pages *tview.Pages
pageStack []string
cfg *tuicfg.TUIConfig
configPath string
pageRefreshFns map[string]func()
headerModelTV *tview.TextView
modalOpen map[string]bool
// OnModelSelected is called when a model is selected in the UI.
// Can be nil to disable.
OnModelSelected func(scheme tuicfg.Scheme, user tuicfg.User, modelID string)
modelCache map[string][]modelEntry
modelCacheMu sync.RWMutex
refreshMu sync.Mutex
}
// cacheKey returns the map key for a (scheme, user) pair.
func cacheKey(schemeName, userName string) string {
return fmt.Sprintf("%s/%s", schemeName, userName)
}
// cachedModels returns a defensive copy of the cached model list for a user (may be nil).
func (a *App) cachedModels(schemeName, userName string) []modelEntry {
a.modelCacheMu.RLock()
defer a.modelCacheMu.RUnlock()
entries := a.modelCache[cacheKey(schemeName, userName)]
return append([]modelEntry(nil), entries...)
}
// refreshModelCache fetches models for every user in the config concurrently.
// Serialized by refreshMu so concurrent calls don't race on the cache map.
// When all fetches complete it calls onDone via QueueUpdateDraw.
func (a *App) refreshModelCache(onDone func()) {
go func() {
a.refreshMu.Lock()
defer a.refreshMu.Unlock()
users := a.cfg.Provider.Users
schemes := a.cfg.Provider.Schemes
schemeURL := make(map[string]string, len(schemes))
for _, s := range schemes {
schemeURL[s.Name] = s.BaseURL
}
var wg sync.WaitGroup
for _, u := range users {
baseURL, ok := schemeURL[u.Scheme]
if !ok || baseURL == "" {
continue
}
if u.Key == "" {
a.modelCacheMu.Lock()
if a.modelCache == nil {
a.modelCache = make(map[string][]modelEntry)
}
a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
a.modelCacheMu.Unlock()
continue
}
wg.Add(1)
bURL := baseURL
go func() {
defer wg.Done()
entries, err := fetchModels(bURL, u.Key)
a.modelCacheMu.Lock()
if a.modelCache == nil {
a.modelCache = make(map[string][]modelEntry)
}
if err != nil || len(entries) == 0 {
a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
} else {
a.modelCache[cacheKey(u.Scheme, u.Name)] = entries
}
a.modelCacheMu.Unlock()
}()
}
wg.Wait()
if onDone != nil {
a.tapp.QueueUpdateDraw(onDone)
}
}()
}
// New creates and wires up the TUI application.
func New(cfg *tuicfg.TUIConfig, configPath string) *App {
// Cyberpunk Theme Colors
// Dark background
tview.Styles.PrimitiveBackgroundColor = tcell.NewHexColor(0x050510) // Deep Void
tview.Styles.ContrastBackgroundColor = tcell.NewHexColor(0x1a1a2e) // Dark Indigo
tview.Styles.MoreContrastBackgroundColor = tcell.NewHexColor(0x2a2a40)
// Borders and Titles
tview.Styles.BorderColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
tview.Styles.TitleColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
tview.Styles.GraphicsColor = tcell.NewHexColor(0xff00ff) // Neon Magenta
// Text
tview.Styles.PrimaryTextColor = tcell.NewHexColor(0xe0e0e0) // Off-white
tview.Styles.SecondaryTextColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
tview.Styles.TertiaryTextColor = tcell.NewHexColor(0x39ff14) // Neon Lime
tview.Styles.InverseTextColor = tcell.NewHexColor(0x000000) // Black
tview.Styles.ContrastSecondaryTextColor = tcell.NewHexColor(0xff00ff) // Neon Magenta
a := &App{
tapp: tview.NewApplication(),
pages: tview.NewPages(),
pageStack: []string{},
cfg: cfg,
configPath: configPath,
pageRefreshFns: make(map[string]func()),
modalOpen: make(map[string]bool),
}
a.tapp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
if len(a.modalOpen) > 0 {
return event
}
return a.goBack()
}
return event
})
a.buildPages()
return a
}
// Run starts the TUI event loop.
func (a *App) Run() error {
return a.tapp.SetRoot(a.pages, true).EnableMouse(true).Run()
}
func (a *App) buildPages() {
a.pages.AddPage("home", a.newHomePage(), true, true)
a.pageStack = []string{"home"}
}
func (a *App) navigateTo(name string, page tview.Primitive) {
a.pages.RemovePage(name)
a.pages.AddPage(name, page, true, false)
a.pageStack = append(a.pageStack, name)
a.pages.SwitchToPage(name)
}
func (a *App) goBack() *tcell.EventKey {
if len(a.pageStack) <= 1 {
return nil
}
popped := a.pageStack[len(a.pageStack)-1]
a.pageStack = a.pageStack[:len(a.pageStack)-1]
a.pages.RemovePage(popped)
prev := a.pageStack[len(a.pageStack)-1]
if fn, ok := a.pageRefreshFns[prev]; ok {
fn()
}
if prev == "home" && a.headerModelTV != nil {
a.headerModelTV.SetText(a.cfg.CurrentModelLabel() + " ")
}
a.pages.SwitchToPage(prev)
return nil
}
func (a *App) showModal(name string, primitive tview.Primitive) {
a.modalOpen[name] = true
a.pages.AddPage(name, primitive, true, true)
}
func (a *App) hideModal(name string) {
delete(a.modalOpen, name)
a.pages.HidePage(name)
a.pages.RemovePage(name)
}
func (a *App) save() {
if err := tuicfg.Save(a.configPath, a.cfg); err != nil {
a.showError("save failed: " + err.Error())
}
}
func (a *App) showError(msg string) {
modal := tview.NewModal().
SetText(" [red::b]ERROR[-::-]\n\n" + msg).
AddButtons([]string{"OK"}).
SetDoneFunc(func(_ int, _ string) {
a.hideModal("error")
})
// Cyberpunk Modal Style
modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White
modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red
modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White
a.showModal("error", modal)
}
func (a *App) confirmDelete(label string, onConfirm func()) {
modal := tview.NewModal().
SetText(" [red::b]DELETE WARNING[-::-]\n\nDelete " + label + "?\n[gray]This action cannot be undone.[-]").
AddButtons([]string{"Delete", "Cancel"}).
SetDoneFunc(func(_ int, buttonLabel string) {
a.hideModal("confirm-delete")
if buttonLabel == "Delete" {
onConfirm()
}
})
// Cyberpunk Modal Style
modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White
modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red for danger
modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White
a.showModal("confirm-delete", modal)
}
func centeredForm(form *tview.Form, widthPct, height int) tview.Primitive {
return tview.NewFlex().
AddItem(tview.NewBox(), 0, 1, false).
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(tview.NewBox(), 0, 1, false).
AddItem(form, height, 1, true).
AddItem(tview.NewBox(), 0, 1, false), 0, widthPct, true).
AddItem(tview.NewBox(), 0, 1, false)
}
func hintBar(text string) *tview.TextView {
tv := tview.NewTextView().
SetText(text).
SetDynamicColors(true).
SetTextAlign(tview.AlignCenter).
SetTextColor(tcell.NewHexColor(0x00f0ff)) // Neon Cyan
tv.SetBackgroundColor(tcell.NewHexColor(0x2a2a40)) // Darker Indigo
return tv
}
func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tview.Primitive {
var modelTV *tview.TextView
if pageID == "home" {
if a.headerModelTV == nil {
a.headerModelTV = tview.NewTextView()
a.headerModelTV.SetTextAlign(tview.AlignRight).
SetTextColor(tcell.NewHexColor(0x39ff14)). // Neon Lime
SetDynamicColors(true).
SetBackgroundColor(tcell.NewHexColor(0x050510))
}
modelTV = a.headerModelTV
modelTV.SetText("MODEL: " + a.cfg.CurrentModelLabel() + " ")
} else {
modelTV = tview.NewTextView()
modelTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
}
headerLeft := tview.NewTextView().
SetText(" [#ff00ff::b]///[#00f0ff] PICOCLAW LAUNCHER [#ff00ff]///").
SetDynamicColors(true).
SetBackgroundColor(tcell.NewHexColor(0x050510))
header := tview.NewFlex().
AddItem(headerLeft, 0, 1, false).
AddItem(modelTV, 0, 1, false)
sidebar := tview.NewTextView().
SetDynamicColors(true).
SetWrap(false)
sidebar.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
// Cyberpunk Sidebar Styling
activePrefix := "[#39ff14::b]>> " // Neon Lime arrow
activeSuffix := "[-]"
inactivePrefix := "[#808080] "
inactiveSuffix := "[-]"
sbText := "\n\n" // Top padding
menuItem := func(id, label string) string {
if pageID == id {
return activePrefix + label + activeSuffix + "\n\n"
}
return inactivePrefix + label + inactiveSuffix + "\n\n"
}
sbText += menuItem("home", "HOME")
sbText += menuItem("schemes", "SCHEMES")
sbText += menuItem("users", "USERS")
sbText += menuItem("models", "MODELS")
sbText += menuItem("channels", "CHANNELS")
sbText += menuItem("gateway", "GATEWAY")
sidebar.SetText(sbText)
footer := hintBar(hint)
grid := tview.NewGrid().
SetRows(1, 0, 1).
SetColumns(20, 0). // Slightly wider sidebar
AddItem(header, 0, 0, 1, 2, 0, 0, false).
AddItem(sidebar, 1, 0, 1, 1, 0, 0, false).
AddItem(content, 1, 1, 1, 1, 0, 0, true).
AddItem(footer, 2, 0, 1, 2, 0, 0, false)
// Add a border around the content area if possible, or ensure content has its own border
// grid.SetBorders(false) // Grid borders usually look bad, handled by components
return grid
}

View file

@ -0,0 +1,202 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"strconv"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
func (a *App) newChannelsPage() tview.Primitive {
list := tview.NewList()
list.SetBorder(true).
SetTitle(" [#00f0ff::b] COMMUNICATION CHANNELS ").
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
list.SetSelectedStyle(
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0x050510)),
)
list.SetHighlightFullLine(true)
list.SetBackgroundColor(tcell.NewHexColor(0x050510))
rebuild := func() {
sel := list.GetCurrentItem()
list.Clear()
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
configPath := filepath.Join(home, ".picoclaw", "config.json")
var cfg map[string]any
if data, err := os.ReadFile(configPath); err == nil {
_ = json.Unmarshal(data, &cfg)
}
if chRaw, ok := cfg["channels"].(map[string]any); ok {
for name, ch := range chRaw {
chMap, ok := ch.(map[string]any)
enabled := "disabled"
if ok {
if e, ok := chMap["enabled"].(bool); ok && e {
enabled = "enabled"
}
}
list.AddItem(name, fmt.Sprintf("Status: %s", enabled), 0, func() {
a.showChannelEditForm(configPath, name, chMap)
})
}
}
if sel >= 0 && sel < list.GetItemCount() {
list.SetCurrentItem(sel)
}
}
rebuild()
a.pageRefreshFns["channels"] = rebuild
list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
return a.goBack()
}
return event
})
return a.buildShell("channels", list, " [#ff00ff]Enter:[-] edit [#ff2a2a]ESC:[-] back ")
}
func (a *App) showChannelEditForm(configPath, channelName string, existing map[string]any) {
form := tview.NewForm()
form.SetBorder(true).
SetTitle(" [::b]EDIT CHANNEL ").
SetTitleColor(tcell.NewHexColor(0x39ff14)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
fields := make(map[string]*tview.InputField)
var nameField *tview.InputField
if channelName == "" {
nameField = tview.NewInputField().
SetLabel("Channel Name").
SetText("").
SetFieldWidth(28)
form.AddFormItem(nameField)
}
for k, v := range existing {
if reflect.ValueOf(v).Kind() == reflect.Map || reflect.ValueOf(v).Kind() == reflect.Slice {
continue
}
valStr := fmt.Sprintf("%v", v)
field := tview.NewInputField().
SetLabel(k).
SetText(valStr).
SetFieldWidth(28)
form.AddFormItem(field)
fields[k] = field
}
form.AddButton("SAVE", func() {
var cfg map[string]any
if data, err := os.ReadFile(configPath); err == nil {
if err := json.Unmarshal(data, &cfg); err != nil {
cfg = make(map[string]any)
}
} else {
cfg = make(map[string]any)
}
if _, ok := cfg["channels"]; !ok {
cfg["channels"] = make(map[string]any)
}
channels, ok := cfg["channels"].(map[string]any)
if !ok {
channels = make(map[string]any)
cfg["channels"] = channels
}
finalName := channelName
if channelName == "" {
if nameField == nil || nameField.GetText() == "" {
a.showError("Channel name is required")
return
}
finalName = nameField.GetText()
}
updated := make(map[string]any)
if existing != nil {
for k, v := range existing {
updated[k] = v
}
}
for k, field := range fields {
val := field.GetText()
if val == "true" {
updated[k] = true
} else if val == "false" {
updated[k] = false
} else if num, err := strconv.Atoi(val); err == nil {
updated[k] = num
} else {
updated[k] = val
}
}
if channelName != "" && finalName != channelName {
delete(channels, channelName)
}
channels[finalName] = updated
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
a.showError(fmt.Sprintf("Failed to save config: %v", err))
return
}
if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
a.showError(fmt.Sprintf("Failed to create config directory: %v", err))
return
}
if err := os.WriteFile(configPath, data, 0o600); err != nil {
a.showError(fmt.Sprintf("Failed to write config: %v", err))
return
}
a.hideModal("channel-edit")
a.goBack()
})
form.AddButton("CANCEL", func() {
a.hideModal("channel-edit")
})
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
a.hideModal("channel-edit")
return nil
}
return event
})
a.showModal("channel-edit", centeredForm(form, 4, 20))
}

View file

@ -0,0 +1,229 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"fmt"
"os/exec"
"runtime"
"strconv"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"github.com/sipeed/picoclaw/pkg/config"
ppid "github.com/sipeed/picoclaw/pkg/pid"
)
type gatewayStatus struct {
running bool
pid int
version string
}
func picoHome() string {
return config.GetHome()
}
func getGatewayStatus() gatewayStatus {
data := ppid.ReadPidFileWithCheck(picoHome())
if data == nil {
return gatewayStatus{running: false}
}
return gatewayStatus{
running: true,
pid: data.PID,
version: data.Version,
}
}
func startGateway() error {
status := getGatewayStatus()
if status.running {
return fmt.Errorf("gateway is already running (PID: %d)", status.pid)
}
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.Command("cmd", "/C", "start /B picoclaw gateway > NUL 2>&1")
} else {
cmd = exec.Command("sh", "-c", "nohup picoclaw gateway > /dev/null 2>&1 &")
}
err := cmd.Start()
if err != nil {
return err
}
time.Sleep(1 * time.Second)
if runtime.GOOS == "windows" {
cmd := exec.Command(
"wmic",
"process",
"where",
"name='picoclaw.exe' and commandline like '%gateway%'",
"get",
"processid",
)
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to get gateway PID: %w", err)
}
lines := strings.Split(string(output), "\n")
for _, line := range lines[1:] {
line = strings.TrimSpace(line)
if line == "" {
continue
}
_, err := strconv.Atoi(line)
if err == nil {
break
}
}
}
status = getGatewayStatus()
if !status.running {
return fmt.Errorf("failed to start gateway")
}
return nil
}
func stopGateway() error {
status := getGatewayStatus()
if !status.running {
return fmt.Errorf("gateway is not running")
}
var err error
if runtime.GOOS == "windows" {
err = exec.Command("taskkill", "/F", "/PID", strconv.Itoa(status.pid)).Run()
} else {
err = exec.Command("kill", strconv.Itoa(status.pid)).Run()
}
if err != nil {
return err
}
// Wait for process to stop (ReadPidFileWithCheck cleans up stale pid file)
for i := 0; i < 5; i++ {
if !getGatewayStatus().running {
break
}
time.Sleep(200 * time.Millisecond)
}
return nil
}
func (a *App) newGatewayPage() tview.Primitive {
flex := tview.NewFlex().SetDirection(tview.FlexRow)
flex.SetBorder(true).
SetTitle(" [#00f0ff::b] GATEWAY MANAGEMENT ").
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
flex.SetBackgroundColor(tcell.NewHexColor(0x050510))
statusTV := tview.NewTextView().
SetDynamicColors(true).
SetTextAlign(tview.AlignCenter).
SetText("Checking status...")
statusTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
var updateStatus func()
// 使用List作为按钮保证显示和交互正常
buttons := tview.NewList()
buttons.SetBackgroundColor(tcell.NewHexColor(0x050510))
buttons.SetMainTextColor(tcell.ColorWhite)
buttons.SetSelectedBackgroundColor(tcell.NewHexColor(0xff00ff))
buttons.SetSelectedTextColor(tcell.ColorBlack)
buttons.AddItem(" [lime]START[white] ", "", 0, func() {
if !getGatewayStatus().running {
err := startGateway()
if err != nil {
a.showError(err.Error())
}
updateStatus()
}
})
buttons.AddItem(" [red]STOP[white] ", "", 0, func() {
if getGatewayStatus().running {
err := stopGateway()
if err != nil {
a.showError(err.Error())
}
updateStatus()
}
})
buttonFlex := tview.NewFlex().SetDirection(tview.FlexColumn)
buttonFlex.
AddItem(tview.NewBox(), 0, 1, false).
AddItem(buttons, 20, 1, true).
AddItem(tview.NewBox(), 0, 1, false)
flex.
AddItem(tview.NewBox(), 0, 1, false).
AddItem(statusTV, 3, 1, false).
AddItem(tview.NewBox(), 0, 1, false).
AddItem(buttonFlex, 4, 1, true).
AddItem(tview.NewBox(), 0, 1, false)
updateStatus = func() {
status := getGatewayStatus()
if status.running {
versionInfo := ""
if status.version != "" {
versionInfo = fmt.Sprintf("\nVersion: %s", status.version)
}
statusTV.SetText(fmt.Sprintf("[#39ff14::b]GATEWAY RUNNING[-]\n\nPID: %d%s", status.pid, versionInfo))
buttons.SetItemText(0, " [gray]START[white] ", "")
buttons.SetItemText(1, " [red]STOP[white] ", "")
} else {
statusTV.SetText("[#ff2a2a::b]GATEWAY STOPPED[-]\n\nPID: N/A")
buttons.SetItemText(0, " [lime]START[white] ", "")
buttons.SetItemText(1, " [gray]STOP[white] ", "")
}
}
updateStatus()
done := make(chan struct{})
go func() {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
a.tapp.QueueUpdateDraw(updateStatus)
case <-done:
return
}
}
}()
originalInputCapture := flex.GetInputCapture()
flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
close(done)
return a.goBack()
}
if originalInputCapture != nil {
return originalInputCapture(event)
}
return event
})
a.pageRefreshFns["gateway"] = updateStatus
return a.buildShell("gateway", flex, " [#39ff14]Enter:[-] select [#ff2a2a]ESC:[-] back ")
}

View file

@ -0,0 +1,70 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"os"
"os/exec"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
func (a *App) newHomePage() tview.Primitive {
list := tview.NewList()
list.SetBorder(true).
SetTitle(" [#00f0ff::b] ACTIVE CONFIGURATION ").
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
list.SetSelectedStyle(
tcell.StyleDefault.Background(tcell.NewHexColor(0x39ff14)).Foreground(tcell.NewHexColor(0x050510)),
)
list.SetHighlightFullLine(true)
list.SetBackgroundColor(tcell.NewHexColor(0x050510))
rebuildList := func() {
sel := list.GetCurrentItem()
list.Clear()
list.AddItem("MODEL: "+a.cfg.CurrentModelLabel(), "Select to configure AI model", 'm', func() {
a.navigateTo("schemes", a.newSchemesPage())
})
list.AddItem(
"CHANNELS: Configure communication channels",
"Manage Telegram/Discord/WeChat channels",
'n',
func() {
a.navigateTo("channels", a.newChannelsPage())
},
)
list.AddItem("GATEWAY MANAGEMENT", "Manage PicoClaw gateway daemon", 'g', func() {
a.navigateTo("gateway", a.newGatewayPage())
})
list.AddItem("CHAT: Start AI agent chat", "Launch interactive chat session", 'c', func() {
a.tapp.Suspend(func() {
cmd := exec.Command("picoclaw", "agent")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
})
})
list.AddItem("QUIT SYSTEM", "Exit PicoClaw Launcher", 'q', func() { a.tapp.Stop() })
if sel >= 0 && sel < list.GetItemCount() {
list.SetCurrentItem(sel)
}
}
rebuildList()
a.pageRefreshFns["home"] = rebuildList
return a.buildShell(
"home",
list,
" [#00f0ff]m:[-] model [#00f0ff]n:[-] channels [#00f0ff]g:[-] gateway [#00f0ff]c:[-] chat [#ff2a2a]q:[-] quit ",
)
}

View file

@ -0,0 +1,200 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
)
type modelsAPIResponse struct {
Data []modelEntry `json:"data"`
}
type modelEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitive {
table := tview.NewTable().
SetBorders(false).
SetSelectable(true, false).
SetFixed(0, 0)
table.SetBorder(true).
SetTitle(fmt.Sprintf(" [#00f0ff::b] MODELS · %s / %s ", schemeName, userName)).
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
table.SetSelectedStyle(
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
)
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
var modelIDs []string
status := tview.NewTextView().
SetTextAlign(tview.AlignCenter).
SetDynamicColors(true).
SetText("[#ffff00]FETCHING MODELS...[-]")
status.SetBackgroundColor(tcell.NewHexColor(0x050510))
flex := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(status, 1, 0, false).
AddItem(table, 0, 1, false)
apiKey := a.resolveKey(schemeName, userName)
go func() {
var entries []modelEntry
var err error
if apiKey == "" {
err = fmt.Errorf("key is required")
} else {
entries, err = fetchModels(baseURL, apiKey)
}
a.modelCacheMu.Lock()
if a.modelCache == nil {
a.modelCache = make(map[string][]modelEntry)
}
if err == nil && len(entries) > 0 {
a.modelCache[cacheKey(schemeName, userName)] = entries
} else {
a.modelCache[cacheKey(schemeName, userName)] = nil
}
a.modelCacheMu.Unlock()
a.tapp.QueueUpdateDraw(func() {
if err != nil {
status.SetText(fmt.Sprintf("[#ff2a2a]ERROR: %s[-]", err.Error()))
table.SetCell(0, 0, tview.NewTableCell(" (failed to load models)"))
a.tapp.SetFocus(table)
return
}
if len(entries) == 0 {
status.SetText("[#ff2a2a]NO MODELS RETURNED[-]")
table.SetCell(0, 0, tview.NewTableCell(" (no models available)"))
a.tapp.SetFocus(table)
return
}
status.SetText(fmt.Sprintf("[#39ff14]%d MODEL(S) LOADED[-]", len(entries)))
for i, m := range entries {
modelIDs = append(modelIDs, m.ID)
table.SetCell(i, 0,
tview.NewTableCell(fmt.Sprintf("%3d", i+1)).
SetAlign(tview.AlignRight).
SetTextColor(tcell.NewHexColor(0x808080)).
SetSelectable(false),
)
table.SetCell(i, 1,
tview.NewTableCell(" "+m.ID).
SetAlign(tview.AlignLeft).
SetExpansion(1).
SetTextColor(tcell.NewHexColor(0xe0e0e0)),
)
}
a.tapp.SetFocus(table)
})
}()
table.SetSelectedFunc(func(row, _ int) {
if row < 0 || row >= len(modelIDs) {
return
}
a.cfg.Provider.Current = tuicfg.ProviderCurrent{
Scheme: schemeName,
User: userName,
Model: modelIDs[row],
}
a.save()
// Trigger model selected callback if set
if a.OnModelSelected != nil && a.cfg.Model.Type == "provider" {
scheme := a.cfg.Provider.SchemeByName(schemeName)
if scheme == nil {
a.goBack()
return
}
var user tuicfg.User
for _, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName && u.Name == userName {
user = u
break
}
}
a.OnModelSelected(*scheme, user, modelIDs[row])
}
a.goBack()
})
return a.buildShell("models", flex, " [#39ff14]Enter:[-] select [#ff00ff]ESC:[-] back ")
}
func (a *App) resolveKey(schemeName, userName string) string {
for _, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName && u.Name == userName {
return u.Key
}
}
return ""
}
func fetchModels(baseURL, apiKey string) ([]modelEntry, error) {
url := strings.TrimRight(baseURL, "/") + "/models"
client := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
if apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
var result modelsAPIResponse
if err := json.Unmarshal(body, &result); err == nil && len(result.Data) > 0 {
return result.Data, nil
}
var arr []modelEntry
if err := json.Unmarshal(body, &arr); err == nil {
return arr, nil
}
return nil, fmt.Errorf(
"decode response: unrecognized shape: %s",
strings.TrimSpace(string(body[:min(len(body), 256)])),
)
}

View file

@ -0,0 +1,252 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"fmt"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
)
func (a *App) newSchemesPage() tview.Primitive {
table := tview.NewTable().
SetBorders(false).
SetSelectable(true, false)
table.SetBorder(true).
SetTitle(" [#00f0ff::b] PROVIDER SCHEMES ").
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
table.SetSelectedStyle(
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
)
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
rowToIdx := func(row int) int { return row / 2 }
selectedSchemeName := func() string {
row, _ := table.GetSelection()
idx := rowToIdx(row)
schemes := a.cfg.Provider.Schemes
if idx >= 0 && idx < len(schemes) {
return schemes[idx].Name
}
return ""
}
rebuild := func() {
selName := selectedSchemeName()
table.Clear()
schemes := a.cfg.Provider.Schemes
for i, s := range schemes {
nameRow := i * 2
detailRow := nameRow + 1
table.SetCell(nameRow, 0,
tview.NewTableCell(" "+s.Name).
SetTextColor(tcell.NewHexColor(0xe0e0e0)).
SetExpansion(1).
SetSelectable(true),
)
users := a.cfg.Provider.UsersForScheme(s.Name)
n := len(users)
m := 0
for _, u := range users {
if models := a.cachedModels(s.Name, u.Name); len(models) > 0 {
m++
}
}
table.SetCell(detailRow, 0,
tview.NewTableCell(fmt.Sprintf(" [#808080](%d/%d) %s", m, n, s.BaseURL)).
SetTextColor(tcell.NewHexColor(0x808080)).
SetExpansion(1).
SetSelectable(false),
)
table.SetCell(detailRow, 1,
tview.NewTableCell("[#00f0ff]"+s.Type+" ").
SetAlign(tview.AlignRight).
SetSelectable(false),
)
}
if selName != "" {
for i, s := range schemes {
if s.Name == selName {
table.Select(i*2, 0)
return
}
}
}
if table.GetRowCount() > 0 {
table.Select(0, 0)
}
}
rebuild()
a.refreshModelCache(rebuild)
a.pageRefreshFns["schemes"] = func() { a.refreshModelCache(rebuild) }
table.SetSelectedFunc(func(row, _ int) {
idx := rowToIdx(row)
schemes := a.cfg.Provider.Schemes
if idx < 0 || idx >= len(schemes) {
return
}
name := schemes[idx].Name
a.navigateTo("users", a.newUsersPage(name))
})
table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
row, _ := table.GetSelection()
idx := rowToIdx(row)
schemes := a.cfg.Provider.Schemes
switch event.Rune() {
case 'a':
a.showSchemeForm(nil, func(s tuicfg.Scheme) {
a.cfg.Provider.Schemes = append(a.cfg.Provider.Schemes, s)
a.save()
a.refreshModelCache(rebuild)
})
return nil
case 'e':
if idx < 0 || idx >= len(schemes) {
return nil
}
origName := schemes[idx].Name
orig := schemes[idx]
a.showSchemeForm(&orig, func(s tuicfg.Scheme) {
current := a.cfg.Provider.Schemes
for i, sc := range current {
if sc.Name == origName {
a.cfg.Provider.Schemes[i] = s
break
}
}
a.save()
a.refreshModelCache(func() {
rebuild()
for i, sc := range a.cfg.Provider.Schemes {
if sc.Name == s.Name {
table.Select(i*2, 0)
break
}
}
})
})
return nil
case 'd':
if idx < 0 || idx >= len(schemes) {
return nil
}
name := schemes[idx].Name
a.confirmDelete(fmt.Sprintf("scheme %q", name), func() {
current := a.cfg.Provider.Schemes
newSchemes := make([]tuicfg.Scheme, 0, len(current))
for _, sc := range current {
if sc.Name != name {
newSchemes = append(newSchemes, sc)
}
}
a.cfg.Provider.Schemes = newSchemes
existing := a.cfg.Provider.Users
filtered := make([]tuicfg.User, 0, len(existing))
for _, u := range existing {
if u.Scheme != name {
filtered = append(filtered, u)
}
}
a.cfg.Provider.Users = filtered
a.save()
a.refreshModelCache(rebuild)
})
return nil
}
return event
})
return a.buildShell(
"schemes",
table,
" [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] open [#ff00ff]ESC:[-] back ",
)
}
func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) {
name := ""
baseURL := ""
schemeType := "openai-compatible"
title := " ADD SCHEME "
if existing != nil {
name = existing.Name
baseURL = existing.BaseURL
schemeType = existing.Type
title = " EDIT SCHEME "
}
typeOptions := []string{"openai-compatible", "anthropic"}
typeIdx := 0
for i, t := range typeOptions {
if t == schemeType {
typeIdx = i
break
}
}
form := tview.NewForm()
form.
AddInputField("Name", name, 20, nil, func(text string) { name = text }).
AddInputField("Base URL", baseURL, 28, nil, func(text string) { baseURL = text }).
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }).
AddButton("SAVE", func() {
if name == "" {
a.showError("Name is required")
return
}
if baseURL == "" {
a.showError("Base URL is required")
return
}
if existing == nil {
for _, s := range a.cfg.Provider.Schemes {
if s.Name == name {
a.showError(fmt.Sprintf("Scheme name %q already exists", name))
return
}
}
}
a.hideModal("scheme-form")
onSave(tuicfg.Scheme{Name: name, BaseURL: baseURL, Type: schemeType})
}).
AddButton("CANCEL", func() {
a.hideModal("scheme-form")
})
form.SetBorder(true).
SetTitle(" [::b]" + title + " ").
SetTitleColor(tcell.NewHexColor(0x39ff14)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
a.hideModal("scheme-form")
return nil
}
return event
})
a.showModal("scheme-form", centeredForm(form, 4, 12))
}

View file

@ -0,0 +1,261 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"fmt"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
)
func (a *App) newUsersPage(schemeName string) tview.Primitive {
table := tview.NewTable().
SetBorders(false).
SetSelectable(true, false)
table.SetBorder(true).
SetTitle(fmt.Sprintf(" [#00f0ff::b] USERS · %s ", schemeName)).
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
table.SetSelectedStyle(
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
)
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
visibleUsers := func() []tuicfg.User {
var out []tuicfg.User
for _, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName {
out = append(out, u)
}
}
return out
}
findUserGlobalIdx := func(userName string) int {
for i, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName && u.Name == userName {
return i
}
}
return -1
}
rowToVisIdx := func(row int) int { return row / 2 }
selectedUserName := func() string {
row, _ := table.GetSelection()
users := visibleUsers()
visIdx := rowToVisIdx(row)
if visIdx >= 0 && visIdx < len(users) {
return users[visIdx].Name
}
return ""
}
rebuild := func() {
selName := selectedUserName()
table.Clear()
users := visibleUsers()
for i, u := range users {
nameRow := i * 2
detailRow := nameRow + 1
table.SetCell(nameRow, 0,
tview.NewTableCell(" "+u.Name).
SetTextColor(tcell.NewHexColor(0xe0e0e0)).
SetExpansion(1).
SetSelectable(true),
)
table.SetCell(nameRow, 1,
tview.NewTableCell("").
SetSelectable(false),
)
models := a.cachedModels(schemeName, u.Name)
var detailText string
if len(models) > 0 {
detailText = fmt.Sprintf(" [#39ff14]%d models available[-]", len(models))
} else {
detailText = " [#ff2a2a]Inactive / No Access[-]"
}
table.SetCell(detailRow, 0,
tview.NewTableCell(detailText).
SetTextColor(tcell.NewHexColor(0x808080)).
SetExpansion(1).
SetSelectable(false),
)
table.SetCell(detailRow, 1,
tview.NewTableCell("[#00f0ff]"+u.Type+" ").
SetAlign(tview.AlignRight).
SetSelectable(false),
)
}
if selName != "" {
for i, u := range users {
if u.Name == selName {
table.Select(i*2, 0)
return
}
}
}
if table.GetRowCount() > 0 {
table.Select(0, 0)
}
}
rebuild()
a.refreshModelCache(rebuild)
a.pageRefreshFns["users"] = func() { a.refreshModelCache(rebuild) }
table.SetSelectedFunc(func(row, _ int) {
visIdx := rowToVisIdx(row)
users := visibleUsers()
if visIdx < 0 || visIdx >= len(users) {
return
}
uName := users[visIdx].Name
scheme := a.cfg.Provider.SchemeByName(schemeName)
if scheme == nil {
a.showError(fmt.Sprintf("Scheme %q not found", schemeName))
return
}
a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL))
})
table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
row, _ := table.GetSelection()
visIdx := rowToVisIdx(row)
users := visibleUsers()
switch event.Rune() {
case 'a':
a.showUserForm(schemeName, nil, func(u tuicfg.User) {
a.cfg.Provider.Users = append(a.cfg.Provider.Users, u)
a.save()
a.refreshModelCache(rebuild)
})
return nil
case 'e':
if visIdx < 0 || visIdx >= len(users) {
return nil
}
origName := users[visIdx].Name
orig := a.cfg.Provider.Users[findUserGlobalIdx(origName)]
a.showUserForm(schemeName, &orig, func(u tuicfg.User) {
cfgIdx := findUserGlobalIdx(origName)
if cfgIdx < 0 {
a.showError(fmt.Sprintf("User %q no longer exists", origName))
return
}
a.cfg.Provider.Users[cfgIdx] = u
a.save()
a.refreshModelCache(func() {
rebuild()
for i, usr := range visibleUsers() {
if usr.Name == u.Name {
table.Select(i*2, 0)
break
}
}
})
})
return nil
case 'd':
if visIdx < 0 || visIdx >= len(users) {
return nil
}
uName := users[visIdx].Name
a.confirmDelete(fmt.Sprintf("user %q", uName), func() {
cfgIdx := findUserGlobalIdx(uName)
if cfgIdx < 0 {
return
}
all := a.cfg.Provider.Users
a.cfg.Provider.Users = append(all[:cfgIdx], all[cfgIdx+1:]...)
a.save()
a.refreshModelCache(rebuild)
})
return nil
}
return event
})
return a.buildShell(
"users",
table,
" [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] models [#ff00ff]ESC:[-] back ",
)
}
func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) {
name := ""
userType := "key"
key := ""
title := " ADD USER "
if existing != nil {
name = existing.Name
userType = existing.Type
key = existing.Key
title = " EDIT USER "
}
typeOptions := []string{"key", "OAuth"}
typeIdx := 0
for i, t := range typeOptions {
if t == userType {
typeIdx = i
break
}
}
form := tview.NewForm()
form.
AddInputField("Name", name, 20, nil, func(text string) { name = text }).
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }).
AddPasswordField("Key", key, 28, '*', func(text string) { key = text }).
AddButton("SAVE", func() {
if name == "" {
a.showError("Name is required")
return
}
if existing == nil {
for _, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName && u.Name == name {
a.showError(fmt.Sprintf("User name %q already exists for this scheme", name))
return
}
}
}
a.hideModal("user-form")
onSave(tuicfg.User{Name: name, Scheme: schemeName, Type: userType, Key: key})
}).
AddButton("CANCEL", func() {
a.hideModal("user-form")
})
form.SetBorder(true).
SetTitle(" [::b]" + title + " ").
SetTitleColor(tcell.NewHexColor(0x39ff14)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
a.hideModal("user-form")
return nil
}
return event
})
a.showModal("user-form", centeredForm(form, 4, 13))
}

View file

@ -0,0 +1,64 @@
package main
import (
"context"
"net"
"net/http"
"os"
"strings"
"sync/atomic"
"time"
)
func init() {
// 仅在 /etc/resolv.conf 不存在时才覆盖(即 Android 环境)
if _, err := os.Stat("/etc/resolv.conf"); err == nil {
return
}
// 从环境变量获取 DNS server 列表,多个用 ; 隔开
// 例如: PICOCLAW_DNS_SERVER="8.8.8.8:53;1.1.1.1:53;223.5.5.5:53"
dnsEnv := os.Getenv("PICOCLAW_DNS_SERVER")
if dnsEnv == "" {
dnsEnv = "8.8.8.8:53;1.1.1.1:53"
}
var dnsServers []string
for _, s := range strings.Split(dnsEnv, ";") {
s = strings.TrimSpace(s)
if s != "" {
// 如果没有带端口号,自动补上 :53
if _, _, err := net.SplitHostPort(s); err != nil {
s = s + ":53"
}
dnsServers = append(dnsServers, s)
}
}
// 轮询索引,在多个 DNS 服务器之间轮转
var idx uint64
customResolver := &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{Timeout: 5 * time.Second}
// Round-robin: 依次尝试不同的 DNS 服务器
server := dnsServers[atomic.AddUint64(&idx, 1)%uint64(len(dnsServers))]
return d.DialContext(ctx, "udp", server)
},
}
// 覆盖全局 DefaultResolver
net.DefaultResolver = customResolver
// 覆盖 http.DefaultTransport 使用自定义 DNS 解析的 DialContext
dialer := &net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
Resolver: customResolver,
}
if tr, ok := http.DefaultTransport.(*http.Transport); ok {
tr.DialContext = dialer.DialContext
}
}

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

@ -0,0 +1,165 @@
package agent
import (
"bufio"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/ergochat/readline"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
)
func agentCmd(message, sessionKey, model string, debug bool) error {
if sessionKey == "" {
sessionKey = "cli:default"
}
cfg, err := internal.LoadConfig()
if err != nil {
return fmt.Errorf("error loading config: %w", err)
}
logger.ConfigureFromEnv()
if debug {
logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled")
}
if model != "" {
cfg.Agents.Defaults.ModelName = model
}
provider, modelID, err := providers.CreateProvider(cfg)
if err != nil {
return fmt.Errorf("error creating provider: %w", err)
}
// Use the resolved model ID from provider creation
if modelID != "" {
cfg.Agents.Defaults.ModelName = modelID
}
msgBus := bus.NewMessageBus()
defer msgBus.Close()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
defer agentLoop.Close()
// Print agent startup info (only for interactive mode)
startupInfo := agentLoop.GetStartupInfo()
logger.InfoCF("agent", "Agent initialized",
map[string]any{
"tools_count": startupInfo["tools"].(map[string]any)["count"],
"skills_total": startupInfo["skills"].(map[string]any)["total"],
"skills_available": startupInfo["skills"].(map[string]any)["available"],
})
if message != "" {
ctx := context.Background()
response, err := agentLoop.ProcessDirect(ctx, message, sessionKey)
if err != nil {
return fmt.Errorf("error processing message: %w", err)
}
fmt.Printf("\n%s %s\n", internal.Logo, response)
return nil
}
fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", internal.Logo)
interactiveMode(agentLoop, sessionKey)
return nil
}
func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
prompt := fmt.Sprintf("%s You: ", internal.Logo)
rl, err := readline.NewEx(&readline.Config{
Prompt: prompt,
HistoryFile: filepath.Join(os.TempDir(), ".picoclaw_history"),
HistoryLimit: 100,
InterruptPrompt: "^C",
EOFPrompt: "exit",
})
if err != nil {
fmt.Printf("Error initializing readline: %v\n", err)
fmt.Println("Falling back to simple input mode...")
simpleInteractiveMode(agentLoop, sessionKey)
return
}
defer rl.Close()
for {
line, err := rl.Readline()
if err != nil {
if err == readline.ErrInterrupt || err == io.EOF {
fmt.Println("\nGoodbye!")
return
}
fmt.Printf("Error reading input: %v\n", err)
continue
}
input := strings.TrimSpace(line)
if input == "" {
continue
}
if input == "exit" || input == "quit" {
fmt.Println("Goodbye!")
return
}
ctx := context.Background()
response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
if err != nil {
fmt.Printf("Error: %v\n", err)
continue
}
fmt.Printf("\n%s %s\n\n", internal.Logo, response)
}
}
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
reader := bufio.NewReader(os.Stdin)
for {
fmt.Print(fmt.Sprintf("%s You: ", internal.Logo))
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
fmt.Println("\nGoodbye!")
return
}
fmt.Printf("Error reading input: %v\n", err)
continue
}
input := strings.TrimSpace(line)
if input == "" {
continue
}
if input == "exit" || input == "quit" {
fmt.Println("Goodbye!")
return
}
ctx := context.Background()
response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
if err != nil {
fmt.Printf("Error: %v\n", err)
continue
}
fmt.Printf("\n%s %s\n\n", internal.Logo, response)
}
}

View file

@ -0,0 +1,24 @@
package auth
import "github.com/spf13/cobra"
func NewAuthCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "auth",
Short: "Manage authentication (login, logout, status)",
RunE: func(cmd *cobra.Command, _ []string) error {
return cmd.Help()
},
}
cmd.AddCommand(
newLoginCommand(),
newLogoutCommand(),
newStatusCommand(),
newModelsCommand(),
newWeixinCommand(),
newWeComCommand(),
)
return cmd
}

View file

@ -0,0 +1,57 @@
package auth
import (
"slices"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewAuthCommand(t *testing.T) {
cmd := NewAuthCommand()
require.NotNil(t, cmd)
assert.Equal(t, "auth", cmd.Use)
assert.Equal(t, "Manage authentication (login, logout, status)", cmd.Short)
assert.Len(t, cmd.Aliases, 0)
assert.Nil(t, cmd.Run)
assert.NotNil(t, cmd.RunE)
assert.Nil(t, cmd.PersistentPreRun)
assert.Nil(t, cmd.PersistentPostRun)
assert.False(t, cmd.HasFlags())
assert.True(t, cmd.HasSubCommands())
allowedCommands := []string{
"login",
"logout",
"status",
"models",
"weixin",
"wecom",
}
subcommands := cmd.Commands()
assert.Len(t, subcommands, len(allowedCommands))
for _, subcmd := range subcommands {
found := slices.Contains(allowedCommands, subcmd.Name())
assert.True(t, found, "unexpected subcommand %q", subcmd.Name())
assert.Len(t, subcmd.Aliases, 0)
assert.False(t, subcmd.Hidden)
assert.False(t, subcmd.HasSubCommands())
assert.Nil(t, subcmd.Run)
assert.NotNil(t, subcmd.RunE)
assert.Nil(t, subcmd.PersistentPreRun)
assert.Nil(t, subcmd.PersistentPostRun)
}
}

View file

@ -0,0 +1,505 @@
package auth
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
)
const (
supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
defaultAnthropicModel = "claude-sonnet-4.6"
)
func authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error {
switch provider {
case "openai":
return authLoginOpenAI(useDeviceCode)
case "anthropic":
return authLoginAnthropic(useOauth)
case "google-antigravity", "antigravity":
return authLoginGoogleAntigravity()
default:
return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg)
}
}
func authLoginOpenAI(useDeviceCode bool) error {
cfg := auth.OpenAIOAuthConfig()
var cred *auth.AuthCredential
var err error
if useDeviceCode {
cred, err = auth.LoginDeviceCode(cfg)
} else {
cred, err = auth.LoginBrowser(cfg)
}
if err != nil {
return fmt.Errorf("login failed: %w", err)
}
if err = auth.SetCredential("openai", cred); err != nil {
return fmt.Errorf("failed to save credentials: %w", err)
}
appCfg, err := internal.LoadConfig()
if err == nil {
// Update or add openai in ModelList
foundOpenAI := false
for i := range appCfg.ModelList {
if isOpenAIModel(appCfg.ModelList[i].Model) {
appCfg.ModelList[i].AuthMethod = "oauth"
foundOpenAI = true
break
}
}
// If no openai in ModelList, add it
if !foundOpenAI {
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
ModelName: "gpt-5.4",
Model: "openai/gpt-5.4",
AuthMethod: "oauth",
})
}
// Update default model to use OpenAI
appCfg.Agents.Defaults.ModelName = "gpt-5.4"
if err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
return fmt.Errorf("could not update config: %w", err)
}
}
fmt.Println("Login successful!")
if cred.AccountID != "" {
fmt.Printf("Account: %s\n", cred.AccountID)
}
fmt.Println("Default model set to: gpt-5.4")
return nil
}
func authLoginGoogleAntigravity() error {
cfg := auth.GoogleAntigravityOAuthConfig()
cred, err := auth.LoginBrowser(cfg)
if err != nil {
return fmt.Errorf("login failed: %w", err)
}
cred.Provider = "google-antigravity"
// Fetch user email from Google userinfo
email, err := fetchGoogleUserEmail(cred.AccessToken)
if err != nil {
fmt.Printf("Warning: could not fetch email: %v\n", err)
} else {
cred.Email = email
fmt.Printf("Email: %s\n", email)
}
// Fetch Cloud Code Assist project ID
projectID, err := providers.FetchAntigravityProjectID(cred.AccessToken)
if err != nil {
fmt.Printf("Warning: could not fetch project ID: %v\n", err)
fmt.Println("You may need Google Cloud Code Assist enabled on your account.")
} else {
cred.ProjectID = projectID
fmt.Printf("Project: %s\n", projectID)
}
if err = auth.SetCredential("google-antigravity", cred); err != nil {
return fmt.Errorf("failed to save credentials: %w", err)
}
appCfg, err := internal.LoadConfig()
if err == nil {
// Update or add antigravity in ModelList
foundAntigravity := false
for i := range appCfg.ModelList {
if isAntigravityModel(appCfg.ModelList[i].Model) {
appCfg.ModelList[i].AuthMethod = "oauth"
foundAntigravity = true
break
}
}
// If no antigravity in ModelList, add it
if !foundAntigravity {
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
ModelName: "gemini-flash",
Model: "antigravity/gemini-3-flash",
AuthMethod: "oauth",
})
}
// Update default model
appCfg.Agents.Defaults.ModelName = "gemini-flash"
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
fmt.Printf("Warning: could not update config: %v\n", err)
}
}
fmt.Println("\n✓ Google Antigravity login successful!")
fmt.Println("Default model set to: gemini-flash")
fmt.Println("Try it: picoclaw agent -m \"Hello world\"")
return nil
}
func authLoginAnthropic(useOauth bool) error {
if useOauth {
return authLoginAnthropicSetupToken()
}
fmt.Println("Anthropic login method:")
fmt.Println(" 1) Setup token (from `claude setup-token`) (Recommended)")
fmt.Println(" 2) API key (from console.anthropic.com)")
scanner := bufio.NewScanner(os.Stdin)
for {
fmt.Print("Choose [1]: ")
choice := "1"
if scanner.Scan() {
text := strings.TrimSpace(scanner.Text())
if text != "" {
choice = text
}
}
switch choice {
case "1":
return authLoginAnthropicSetupToken()
case "2":
return authLoginPasteToken("anthropic")
default:
fmt.Printf("Invalid choice: %s. Please enter 1 or 2.\n", choice)
}
}
}
func authLoginAnthropicSetupToken() error {
cred, err := auth.LoginSetupToken(os.Stdin)
if err != nil {
return fmt.Errorf("login failed: %w", err)
}
if err = auth.SetCredential("anthropic", cred); err != nil {
return fmt.Errorf("failed to save credentials: %w", err)
}
appCfg, err := internal.LoadConfig()
if err == nil {
found := false
for i := range appCfg.ModelList {
if isAnthropicModel(appCfg.ModelList[i].Model) {
appCfg.ModelList[i].AuthMethod = "oauth"
found = true
break
}
}
if !found {
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
ModelName: defaultAnthropicModel,
Model: "anthropic/" + defaultAnthropicModel,
AuthMethod: "oauth",
})
// Only set default model if user has no default configured yet
if appCfg.Agents.Defaults.GetModelName() == "" {
appCfg.Agents.Defaults.ModelName = defaultAnthropicModel
}
}
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
return fmt.Errorf("could not update config: %w", err)
}
}
fmt.Println("Setup token saved for Anthropic!")
return nil
}
func fetchGoogleUserEmail(accessToken string) (string, error) {
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("reading userinfo response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("userinfo request failed: %s", string(body))
}
var userInfo struct {
Email string `json:"email"`
}
if err := json.Unmarshal(body, &userInfo); err != nil {
return "", err
}
return userInfo.Email, nil
}
func authLoginPasteToken(provider string) error {
cred, err := auth.LoginPasteToken(provider, os.Stdin)
if err != nil {
return fmt.Errorf("login failed: %w", err)
}
if err = auth.SetCredential(provider, cred); err != nil {
return fmt.Errorf("failed to save credentials: %w", err)
}
appCfg, err := internal.LoadConfig()
if err == nil {
switch provider {
case "anthropic":
// Update ModelList
found := false
for i := range appCfg.ModelList {
if isAnthropicModel(appCfg.ModelList[i].Model) {
appCfg.ModelList[i].AuthMethod = "token"
found = true
break
}
}
if !found {
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
ModelName: defaultAnthropicModel,
Model: "anthropic/" + defaultAnthropicModel,
AuthMethod: "token",
})
appCfg.Agents.Defaults.ModelName = defaultAnthropicModel
}
case "openai":
// Update ModelList
found := false
for i := range appCfg.ModelList {
if isOpenAIModel(appCfg.ModelList[i].Model) {
appCfg.ModelList[i].AuthMethod = "token"
found = true
break
}
}
if !found {
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
ModelName: "gpt-5.4",
Model: "openai/gpt-5.4",
AuthMethod: "token",
})
}
// Update default model
appCfg.Agents.Defaults.ModelName = "gpt-5.4"
}
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
return fmt.Errorf("could not update config: %w", err)
}
}
fmt.Printf("Token saved for %s!\n", provider)
if appCfg != nil {
fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.GetModelName())
}
return nil
}
func authLogoutCmd(provider string) error {
if provider != "" {
if err := auth.DeleteCredential(provider); err != nil {
return fmt.Errorf("failed to remove credentials: %w", err)
}
appCfg, err := internal.LoadConfig()
if err == nil {
// Clear AuthMethod in ModelList
for i := range appCfg.ModelList {
switch provider {
case "openai":
if isOpenAIModel(appCfg.ModelList[i].Model) {
appCfg.ModelList[i].AuthMethod = ""
}
case "anthropic":
if isAnthropicModel(appCfg.ModelList[i].Model) {
appCfg.ModelList[i].AuthMethod = ""
}
case "google-antigravity", "antigravity":
if isAntigravityModel(appCfg.ModelList[i].Model) {
appCfg.ModelList[i].AuthMethod = ""
}
}
}
config.SaveConfig(internal.GetConfigPath(), appCfg)
}
fmt.Printf("Logged out from %s\n", provider)
return nil
}
if err := auth.DeleteAllCredentials(); err != nil {
return fmt.Errorf("failed to remove credentials: %w", err)
}
appCfg, err := internal.LoadConfig()
if err == nil {
// Clear all AuthMethods in ModelList
for i := range appCfg.ModelList {
appCfg.ModelList[i].AuthMethod = ""
}
config.SaveConfig(internal.GetConfigPath(), appCfg)
}
fmt.Println("Logged out from all providers")
return nil
}
func authStatusCmd() error {
store, err := auth.LoadStore()
if err != nil {
return fmt.Errorf("failed to load auth store: %w", err)
}
if len(store.Credentials) == 0 {
fmt.Println("No authenticated providers.")
fmt.Println("Run: picoclaw auth login --provider <name>")
return nil
}
fmt.Println("\nAuthenticated Providers:")
fmt.Println("------------------------")
for provider, cred := range store.Credentials {
status := "active"
if cred.IsExpired() {
status = "expired"
} else if cred.NeedsRefresh() {
status = "needs refresh"
}
fmt.Printf(" %s:\n", provider)
fmt.Printf(" Method: %s\n", cred.AuthMethod)
fmt.Printf(" Status: %s\n", status)
if cred.AccountID != "" {
fmt.Printf(" Account: %s\n", cred.AccountID)
}
if cred.Email != "" {
fmt.Printf(" Email: %s\n", cred.Email)
}
if cred.ProjectID != "" {
fmt.Printf(" Project: %s\n", cred.ProjectID)
}
if !cred.ExpiresAt.IsZero() {
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
}
if provider == "anthropic" && cred.AuthMethod == "oauth" {
usage, err := auth.FetchAnthropicUsage(cred.AccessToken)
if err != nil {
fmt.Printf(" Usage: unavailable (%v)\n", err)
} else {
fmt.Printf(" Usage (5h): %.1f%%\n", usage.FiveHourUtilization*100)
fmt.Printf(" Usage (7d): %.1f%%\n", usage.SevenDayUtilization*100)
}
}
}
return nil
}
func authModelsCmd() error {
cred, err := auth.GetCredential("google-antigravity")
if err != nil || cred == nil {
return fmt.Errorf(
"not logged in to Google Antigravity.\nrun: picoclaw auth login --provider google-antigravity",
)
}
// Refresh token if needed
if cred.NeedsRefresh() && cred.RefreshToken != "" {
oauthCfg := auth.GoogleAntigravityOAuthConfig()
refreshed, refreshErr := auth.RefreshAccessToken(cred, oauthCfg)
if refreshErr == nil {
cred = refreshed
_ = auth.SetCredential("google-antigravity", cred)
}
}
projectID := cred.ProjectID
if projectID == "" {
return fmt.Errorf("no project id stored. Try logging in again")
}
fmt.Printf("Fetching models for project: %s\n\n", projectID)
models, err := providers.FetchAntigravityModels(cred.AccessToken, projectID)
if err != nil {
return fmt.Errorf("error fetching models: %w", err)
}
if len(models) == 0 {
return fmt.Errorf("no models available")
}
fmt.Println("Available Antigravity Models:")
fmt.Println("-----------------------------")
for _, m := range models {
status := "✓"
if m.IsExhausted {
status = "✗ (quota exhausted)"
}
name := m.ID
if m.DisplayName != "" {
name = fmt.Sprintf("%s (%s)", m.ID, m.DisplayName)
}
fmt.Printf(" %s %s\n", status, name)
}
return nil
}
// isAntigravityModel checks if a model string belongs to antigravity provider
func isAntigravityModel(model string) bool {
return model == "antigravity" ||
model == "google-antigravity" ||
strings.HasPrefix(model, "antigravity/") ||
strings.HasPrefix(model, "google-antigravity/")
}
// isOpenAIModel checks if a model string belongs to openai provider
func isOpenAIModel(model string) bool {
return model == "openai" ||
strings.HasPrefix(model, "openai/")
}
// isAnthropicModel checks if a model string belongs to anthropic provider
func isAnthropicModel(model string) bool {
return model == "anthropic" ||
strings.HasPrefix(model, "anthropic/")
}

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,407 @@
package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"runtime"
"strconv"
"strings"
"time"
"github.com/mdp/qrterminal/v3"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
)
const (
wecomQRSourceID = "picoclaw"
wecomQRGenerateEndpoint = "https://work.weixin.qq.com/ai/qc/generate"
wecomQRQueryEndpoint = "https://work.weixin.qq.com/ai/qc/query_result"
wecomQRPageEndpoint = "https://work.weixin.qq.com/ai/qc/gen"
wecomQRHTTPTimeout = 15 * time.Second
wecomQRPollInterval = 3 * time.Second
wecomQRPollTimeout = 5 * time.Minute
wecomDefaultWebSocketURL = "wss://openws.work.weixin.qq.com"
)
type wecomQRScanner func(context.Context, wecomQRFlowOptions) (wecomQRBotInfo, error)
type wecomQRFlowOptions struct {
HTTPClient *http.Client
GenerateURL string
QueryURL string
QRCodePageURL string
SourceID string
PollInterval time.Duration
PollTimeout time.Duration
Writer io.Writer
}
type wecomQRBotInfo struct {
BotID string
Secret string
}
type wecomQRSession struct {
SCode string
AuthURL string
}
type wecomQRGenerateResponse struct {
ErrCode int `json:"errcode,omitempty"`
ErrMsg string `json:"errmsg,omitempty"`
Data struct {
SCode string `json:"scode"`
AuthURL string `json:"auth_url"`
} `json:"data"`
}
type wecomQRQueryResponse struct {
ErrCode int `json:"errcode,omitempty"`
ErrMsg string `json:"errmsg,omitempty"`
Data struct {
Status string `json:"status"`
BotInfo struct {
BotID string `json:"botid"`
Secret string `json:"secret"`
} `json:"bot_info"`
} `json:"data"`
}
func newWeComCommand() *cobra.Command {
var timeout time.Duration
cmd := &cobra.Command{
Use: "wecom",
Short: "Scan a WeCom QR code and configure channels.wecom",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error {
return authWeComCmd(timeout)
},
}
cmd.Flags().DurationVar(&timeout, "timeout", wecomQRPollTimeout, "How long to wait for QR confirmation")
return cmd
}
func authWeComCmd(timeout time.Duration) error {
return authWeComCmdWithScanner(context.Background(), os.Stdout, timeout, scanWeComQRCodeInteractive)
}
func authWeComCmdWithScanner(
ctx context.Context,
writer io.Writer,
timeout time.Duration,
scanner wecomQRScanner,
) error {
if scanner == nil {
return fmt.Errorf("wecom QR scanner is nil")
}
if writer == nil {
writer = os.Stdout
}
cfg, err := internal.LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
opts := defaultWeComQRFlowOptions(timeout)
opts.Writer = writer
botInfo, err := scanner(ctx, opts)
if err != nil {
return err
}
applyWeComAuthResult(cfg, botInfo)
if saveErr := config.SaveConfig(internal.GetConfigPath(), cfg); saveErr != nil {
return fmt.Errorf("failed to save config: %w", saveErr)
}
fmt.Fprintln(writer)
fmt.Fprintln(writer, "WeCom connected.")
fmt.Fprintf(writer, "Bot ID: %s\n", botInfo.BotID)
fmt.Fprintf(writer, "Config: %s\n", internal.GetConfigPath())
return nil
}
func defaultWeComQRFlowOptions(timeout time.Duration) wecomQRFlowOptions {
if timeout <= 0 {
timeout = wecomQRPollTimeout
}
return wecomQRFlowOptions{
HTTPClient: &http.Client{Timeout: wecomQRHTTPTimeout},
GenerateURL: wecomQRGenerateEndpoint,
QueryURL: wecomQRQueryEndpoint,
QRCodePageURL: wecomQRPageEndpoint,
SourceID: wecomQRSourceID,
PollInterval: wecomQRPollInterval,
PollTimeout: timeout,
Writer: os.Stdout,
}
}
func applyWeComAuthResult(cfg *config.Config, botInfo wecomQRBotInfo) {
cfg.Channels.WeCom.Enabled = true
cfg.Channels.WeCom.BotID = botInfo.BotID
cfg.Channels.WeCom.SetSecret(botInfo.Secret)
if strings.TrimSpace(cfg.Channels.WeCom.WebSocketURL) == "" {
cfg.Channels.WeCom.WebSocketURL = wecomDefaultWebSocketURL
}
}
func scanWeComQRCodeInteractive(ctx context.Context, opts wecomQRFlowOptions) (wecomQRBotInfo, error) {
opts = normalizeWeComQRFlowOptions(opts)
fmt.Fprintln(opts.Writer, "Requesting WeCom QR code...")
session, err := fetchWeComQRCode(ctx, opts)
if err != nil {
return wecomQRBotInfo{}, err
}
fmt.Fprintln(opts.Writer)
fmt.Fprintln(opts.Writer, "=======================================================")
fmt.Fprintln(opts.Writer, "Please scan the following QR code with WeCom:")
fmt.Fprintln(opts.Writer, "=======================================================")
fmt.Fprintln(opts.Writer)
qrterminal.GenerateWithConfig(session.AuthURL, qrterminal.Config{
Level: qrterminal.L,
Writer: opts.Writer,
HalfBlocks: true,
})
pageURL, err := buildWeComQRCodePageURL(opts.QRCodePageURL, opts.SourceID, session.SCode)
if err != nil {
return wecomQRBotInfo{}, err
}
fmt.Fprintln(opts.Writer)
fmt.Fprintf(opts.Writer, "QR Code Link: %s\n", pageURL)
fmt.Fprintln(opts.Writer)
fmt.Fprintln(opts.Writer, "Waiting for scan...")
return pollWeComQRCodeResult(ctx, opts, session.SCode)
}
func normalizeWeComQRFlowOptions(opts wecomQRFlowOptions) wecomQRFlowOptions {
if opts.HTTPClient == nil {
opts.HTTPClient = &http.Client{Timeout: wecomQRHTTPTimeout}
}
if strings.TrimSpace(opts.GenerateURL) == "" {
opts.GenerateURL = wecomQRGenerateEndpoint
}
if strings.TrimSpace(opts.QueryURL) == "" {
opts.QueryURL = wecomQRQueryEndpoint
}
if strings.TrimSpace(opts.QRCodePageURL) == "" {
opts.QRCodePageURL = wecomQRPageEndpoint
}
if strings.TrimSpace(opts.SourceID) == "" {
opts.SourceID = wecomQRSourceID
}
if opts.PollInterval <= 0 {
opts.PollInterval = wecomQRPollInterval
}
if opts.PollTimeout <= 0 {
opts.PollTimeout = wecomQRPollTimeout
}
if opts.Writer == nil {
opts.Writer = os.Stdout
}
return opts
}
func fetchWeComQRCode(ctx context.Context, opts wecomQRFlowOptions) (wecomQRSession, error) {
generateURL, err := buildWeComQRGenerateURL(opts.GenerateURL, opts.SourceID, wecomPlatformCode())
if err != nil {
return wecomQRSession{}, err
}
var resp wecomQRGenerateResponse
if err := doWeComJSONGet(ctx, opts.HTTPClient, generateURL, &resp); err != nil {
return wecomQRSession{}, fmt.Errorf("failed to get WeCom QR code: %w", err)
}
if resp.ErrCode != 0 {
return wecomQRSession{}, fmt.Errorf(
"failed to get WeCom QR code: errcode=%d errmsg=%s",
resp.ErrCode,
resp.ErrMsg,
)
}
if resp.Data.SCode == "" || resp.Data.AuthURL == "" {
return wecomQRSession{}, fmt.Errorf("failed to get WeCom QR code: response missing scode or auth_url")
}
return wecomQRSession{
SCode: resp.Data.SCode,
AuthURL: resp.Data.AuthURL,
}, nil
}
func pollWeComQRCodeResult(ctx context.Context, opts wecomQRFlowOptions, scode string) (wecomQRBotInfo, error) {
if strings.TrimSpace(scode) == "" {
return wecomQRBotInfo{}, fmt.Errorf("missing WeCom QR scode")
}
timeoutCtx, cancel := context.WithTimeout(ctx, opts.PollTimeout)
defer cancel()
var scannedPrinted bool
for {
status, err := queryWeComQRCodeStatus(timeoutCtx, opts, scode)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) {
return wecomQRBotInfo{}, fmt.Errorf("WeCom QR scan timed out after %s", opts.PollTimeout)
}
return wecomQRBotInfo{}, err
}
switch strings.ToLower(status.Data.Status) {
case "success":
if status.Data.BotInfo.BotID == "" || status.Data.BotInfo.Secret == "" {
return wecomQRBotInfo{}, fmt.Errorf("WeCom QR scan succeeded but bot credentials are missing")
}
return wecomQRBotInfo{
BotID: status.Data.BotInfo.BotID,
Secret: status.Data.BotInfo.Secret,
}, nil
case "expired":
return wecomQRBotInfo{}, fmt.Errorf("WeCom QR code expired, please retry")
case "scaned", "scanned":
if !scannedPrinted {
fmt.Fprintln(opts.Writer, "QR code scanned. Confirm the login in WeCom.")
scannedPrinted = true
}
}
select {
case <-timeoutCtx.Done():
if errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) {
return wecomQRBotInfo{}, fmt.Errorf("WeCom QR scan timed out after %s", opts.PollTimeout)
}
return wecomQRBotInfo{}, timeoutCtx.Err()
case <-time.After(opts.PollInterval):
}
}
}
func queryWeComQRCodeStatus(ctx context.Context, opts wecomQRFlowOptions, scode string) (wecomQRQueryResponse, error) {
queryURL, err := buildWeComQRQueryURL(opts.QueryURL, scode)
if err != nil {
return wecomQRQueryResponse{}, err
}
var resp wecomQRQueryResponse
if err := doWeComJSONGet(ctx, opts.HTTPClient, queryURL, &resp); err != nil {
return wecomQRQueryResponse{}, fmt.Errorf("failed to query WeCom QR result: %w", err)
}
if resp.ErrCode != 0 {
return wecomQRQueryResponse{}, fmt.Errorf(
"failed to query WeCom QR result: errcode=%d errmsg=%s",
resp.ErrCode,
resp.ErrMsg,
)
}
return resp, nil
}
func buildWeComQRGenerateURL(baseURL, sourceID string, platformCode int) (string, error) {
u, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("invalid WeCom QR generate URL: %w", err)
}
query := u.Query()
query.Set("source", sourceID)
query.Set("sourceID", sourceID)
query.Set("plat", strconv.Itoa(platformCode))
u.RawQuery = query.Encode()
return u.String(), nil
}
func buildWeComQRQueryURL(baseURL, scode string) (string, error) {
u, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("invalid WeCom QR query URL: %w", err)
}
query := u.Query()
query.Set("scode", scode)
u.RawQuery = query.Encode()
return u.String(), nil
}
func buildWeComQRCodePageURL(baseURL, sourceID, scode string) (string, error) {
u, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("invalid WeCom QR page URL: %w", err)
}
query := u.Query()
query.Set("source", sourceID)
query.Set("sourceID", sourceID)
query.Set("scode", scode)
u.RawQuery = query.Encode()
return u.String(), nil
}
func doWeComJSONGet(ctx context.Context, client *http.Client, targetURL string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 8192))
if readErr != nil {
return fmt.Errorf("unexpected status %s", resp.Status)
}
return fmt.Errorf("unexpected status %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("decode JSON response: %w", err)
}
return nil
}
func wecomPlatformCode() int {
switch runtime.GOOS {
case "darwin":
return 1
case "windows":
return 2
case "linux":
return 3
default:
return 0
}
}

View file

@ -0,0 +1,157 @@
package auth
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestNewWeComCommand(t *testing.T) {
cmd := newWeComCommand()
require.NotNil(t, cmd)
assert.Equal(t, "wecom", cmd.Use)
assert.Equal(t, "Scan a WeCom QR code and configure channels.wecom", cmd.Short)
assert.NotNil(t, cmd.Flags().Lookup("timeout"))
}
func TestBuildWeComQRGenerateURL(t *testing.T) {
rawURL, err := buildWeComQRGenerateURL("https://example.com/ai/qc/generate", wecomQRSourceID, 3)
require.NoError(t, err)
parsed, err := url.Parse(rawURL)
require.NoError(t, err)
assert.Equal(t, wecomQRSourceID, parsed.Query().Get("source"))
assert.Equal(t, wecomQRSourceID, parsed.Query().Get("sourceID"))
assert.Equal(t, "3", parsed.Query().Get("plat"))
}
func TestBuildWeComQRCodePageURL(t *testing.T) {
rawURL, err := buildWeComQRCodePageURL("https://example.com/ai/qc/gen", wecomQRSourceID, "scode-1")
require.NoError(t, err)
parsed, err := url.Parse(rawURL)
require.NoError(t, err)
assert.Equal(t, wecomQRSourceID, parsed.Query().Get("source"))
assert.Equal(t, wecomQRSourceID, parsed.Query().Get("sourceID"))
assert.Equal(t, "scode-1", parsed.Query().Get("scode"))
}
func TestFetchWeComQRCode(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/generate", r.URL.Path)
assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("source"))
assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("sourceID"))
assert.Equal(t, strconv.Itoa(wecomPlatformCode()), r.URL.Query().Get("plat"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"scode":"scode-1","auth_url":"https://example.com/qr"}}`))
}))
defer server.Close()
opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{
HTTPClient: server.Client(),
GenerateURL: server.URL + "/generate",
Writer: bytes.NewBuffer(nil),
})
session, err := fetchWeComQRCode(context.Background(), opts)
require.NoError(t, err)
assert.Equal(t, "scode-1", session.SCode)
assert.Equal(t, "https://example.com/qr", session.AuthURL)
}
func TestPollWeComQRCodeResult(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
call := calls.Add(1)
assert.Equal(t, "/query", r.URL.Path)
assert.Equal(t, "scode-1", r.URL.Query().Get("scode"))
w.Header().Set("Content-Type", "application/json")
switch call {
case 1:
_, _ = w.Write([]byte(`{"data":{"status":"wait"}}`))
case 2:
_, _ = w.Write([]byte(`{"data":{"status":"scaned"}}`))
default:
_, _ = w.Write([]byte(`{"data":{"status":"success","bot_info":{"botid":"bot-1","secret":"secret-1"}}}`))
}
}))
defer server.Close()
var output bytes.Buffer
opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{
HTTPClient: server.Client(),
QueryURL: server.URL + "/query",
PollInterval: time.Millisecond,
PollTimeout: time.Second,
Writer: &output,
})
botInfo, err := pollWeComQRCodeResult(context.Background(), opts, "scode-1")
require.NoError(t, err)
assert.Equal(t, "bot-1", botInfo.BotID)
assert.Equal(t, "secret-1", botInfo.Secret)
assert.Contains(t, output.String(), "QR code scanned. Confirm the login in WeCom.")
}
func TestApplyWeComAuthResult(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Channels.WeCom.WebSocketURL = ""
applyWeComAuthResult(cfg, wecomQRBotInfo{
BotID: "bot-1",
Secret: "secret-1",
})
assert.True(t, cfg.Channels.WeCom.Enabled)
assert.Equal(t, "bot-1", cfg.Channels.WeCom.BotID)
assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret.String())
assert.Equal(t, wecomDefaultWebSocketURL, cfg.Channels.WeCom.WebSocketURL)
}
func TestAuthWeComCmdWithScanner(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
t.Setenv(config.EnvHome, tmpDir)
t.Setenv(config.EnvConfig, configPath)
var output bytes.Buffer
err := authWeComCmdWithScanner(
context.Background(),
&output,
time.Second,
func(_ context.Context, opts wecomQRFlowOptions) (wecomQRBotInfo, error) {
assert.Equal(t, wecomQRSourceID, opts.SourceID)
return wecomQRBotInfo{
BotID: "bot-1",
Secret: "secret-1",
}, nil
},
)
require.NoError(t, err)
cfg, err := config.LoadConfig(internal.GetConfigPath())
require.NoError(t, err)
assert.True(t, cfg.Channels.WeCom.Enabled)
assert.Equal(t, "bot-1", cfg.Channels.WeCom.BotID)
assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret.String())
assert.Equal(t, wecomDefaultWebSocketURL, cfg.Channels.WeCom.WebSocketURL)
assert.Contains(t, output.String(), "WeCom connected.")
}

View file

@ -0,0 +1,124 @@
package auth
import (
"context"
"fmt"
"time"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/channels/weixin"
"github.com/sipeed/picoclaw/pkg/config"
)
func newWeixinCommand() *cobra.Command {
var baseURL string
var proxy string
var timeout int
cmd := &cobra.Command{
Use: "weixin",
Short: "Connect a WeChat personal account via QR code",
Long: `Start the interactive Weixin (WeChat personal) QR code login flow.
A QR code is displayed in the terminal. Scan it with the WeChat mobile app
to authorize your account. On success, the bot token is saved to the picoclaw
config so you can start the gateway immediately.
Example:
picoclaw auth weixin`,
RunE: func(cmd *cobra.Command, _ []string) error {
return runWeixinOnboard(baseURL, proxy, time.Duration(timeout)*time.Second)
},
}
cmd.Flags().StringVar(&baseURL, "base-url", "https://ilinkai.weixin.qq.com/", "iLink API base URL")
cmd.Flags().StringVar(&proxy, "proxy", "", "HTTP proxy URL (e.g. http://localhost:7890)")
cmd.Flags().IntVar(&timeout, "timeout", 300, "Login timeout in seconds")
return cmd
}
func runWeixinOnboard(baseURL, proxy string, timeout time.Duration) error {
fmt.Println("Starting Weixin (WeChat personal) login...")
fmt.Println()
botToken, userID, accountID, returnedBaseURL, err := weixin.PerformLoginInteractive(
context.Background(),
weixin.AuthFlowOpts{
BaseURL: baseURL,
Timeout: timeout,
Proxy: proxy,
},
)
if err != nil {
return fmt.Errorf("login failed: %w", err)
}
fmt.Println()
fmt.Println("✅ Login successful!")
fmt.Printf(" Account ID : %s\n", accountID)
if userID != "" {
fmt.Printf(" User ID : %s\n", userID)
}
fmt.Println()
// Prefer the server-returned base URL (may be region-specific)
effectiveBaseURL := returnedBaseURL
if effectiveBaseURL == "" {
effectiveBaseURL = baseURL
}
if err := saveWeixinConfig(botToken, effectiveBaseURL, proxy); err != nil {
fmt.Printf("⚠️ Could not auto-save to config: %v\n", err)
printManualWeixinConfig(botToken, effectiveBaseURL)
return nil
}
fmt.Println("✓ Config updated. Start the gateway with:")
fmt.Println()
fmt.Println(" picoclaw gateway")
fmt.Println()
fmt.Println("To restrict which WeChat users can send messages, add their user IDs")
fmt.Println("to channels.weixin.allow_from in your config.")
return nil
}
// saveWeixinConfig patches channels.weixin in the config and saves it.
func saveWeixinConfig(token, baseURL, proxy string) error {
cfgPath := internal.GetConfigPath()
cfg, err := config.LoadConfig(cfgPath)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
cfg.Channels.Weixin.Enabled = true
cfg.Channels.Weixin.SetToken(token)
const defaultBase = "https://ilinkai.weixin.qq.com/"
if baseURL != "" && baseURL != defaultBase {
cfg.Channels.Weixin.BaseURL = baseURL
}
if proxy != "" {
cfg.Channels.Weixin.Proxy = proxy
}
return config.SaveConfig(cfgPath, cfg)
}
func printManualWeixinConfig(token, baseURL string) {
fmt.Println()
fmt.Println("Add the following to the channels section of your picoclaw config:")
fmt.Println()
fmt.Println(` "weixin": {`)
fmt.Println(` "enabled": true,`)
fmt.Printf(" \"token\": %q,\n", token)
const defaultBase = "https://ilinkai.weixin.qq.com/"
if baseURL != "" && baseURL != defaultBase {
fmt.Printf(" \"base_url\": %q,\n", baseURL)
}
fmt.Println(` "allow_from": []`)
fmt.Println(` }`)
}

View file

@ -0,0 +1,147 @@
// Package cliui renders human-oriented CLI output: bordered panels and columns
// on wide interactive terminals. Layout (boxes/columns) is independent of ANSI
// color: use --no-color or NO_COLOR to disable colors only; narrow or non-TTY
// stdout falls back to plain line-oriented output.
package cliui
import (
"os"
"sync"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
"golang.org/x/term"
)
// Minimum terminal width (columns) for bordered / structured layout.
// Below this, plain line-oriented output is used so boxes do not wrap badly.
const minWidthFancy = 88
// Minimum width to lay out some views in two columns (e.g. status providers).
const minWidthColumns = 104
var initMu sync.Mutex
// Init configures lipgloss for this process. When disableAnsiColors is true
// (e.g. --no-color, NO_COLOR, or TERM=dumb), only color is turned off; Unicode
// borders still render when UseFancyLayout() is true.
func Init(disableAnsiColors bool) {
initMu.Lock()
defer initMu.Unlock()
if disableAnsiColors {
lipgloss.SetColorProfile(termenv.Ascii)
return
}
lipgloss.SetColorProfile(termenv.EnvColorProfile())
}
// StdoutWidth returns the terminal width or a sane default if unknown.
func StdoutWidth() int {
w, _, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil || w < 20 {
return 80
}
return w
}
// UseFancyLayout is true when styled boxes/columns should be used.
func UseFancyLayout() bool {
if !term.IsTerminal(int(os.Stdout.Fd())) {
return false
}
return StdoutWidth() >= minWidthFancy
}
// UseColumnLayout is true when a second content column is viable.
func UseColumnLayout() bool {
return UseFancyLayout() && StdoutWidth() >= minWidthColumns
}
// InnerWidth is the target content width inside borders/margins.
func InnerWidth() int {
w := StdoutWidth()
// Rounded border + horizontal padding (lipgloss borders ~= 2 cols each side + padding).
const borderBudget = 8
if w > borderBudget+48 {
return w - borderBudget
}
return 48
}
// StderrWidth returns stderr terminal width or a sane default.
func StderrWidth() int {
w, _, err := term.GetSize(int(os.Stderr.Fd()))
if err != nil || w < 20 {
return 80
}
return w
}
// UseFancyStderr is true when stderr can show boxed errors without ugly wraps.
func UseFancyStderr() bool {
if !term.IsTerminal(int(os.Stderr.Fd())) {
return false
}
return StderrWidth() >= minWidthFancy
}
// InnerStderrWidth mirrors InnerWidth but for stderr.
func InnerStderrWidth() int {
w := StderrWidth()
const borderBudget = 8
if w > borderBudget+48 {
return w - borderBudget
}
return 48
}
var (
accentBlue = lipgloss.Color("#3E5DB9")
accentRed = lipgloss.Color("#D54646")
colorMuted = lipgloss.Color("#6B6B6B")
colorOK = lipgloss.Color("#2E7D32")
)
func borderStyle() lipgloss.Style {
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(accentBlue).
Padding(0, 1)
}
func titleBarStyle() lipgloss.Style {
return lipgloss.NewStyle().
Foreground(accentRed).
Bold(true)
}
func mutedStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(colorMuted)
}
func bodyStyle() lipgloss.Style {
return lipgloss.NewStyle()
}
func kvKeyStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
}
func kvValStyle() lipgloss.Style {
return lipgloss.NewStyle()
}
// helpIntroStyle is the top tagline (PicoClaw blue, matches ASCII banner left side).
func helpIntroStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
}
// helpIdentStyle is the left column for commands and flags (blue identifiers).
func helpIdentStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
}
// helpPlaceholderStyle highlights <placeholders> in usage lines (red accent).
func helpPlaceholderStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(accentRed).Bold(true)
}

View file

@ -0,0 +1,180 @@
package cliui
import (
"testing"
flag "github.com/spf13/pflag"
)
func init() {
// Disable ANSI colors in tests so output is predictable plain text.
Init(true)
}
// ---------------------------------------------------------------------------
// showErrHint
// ---------------------------------------------------------------------------
func TestShowErrHint(t *testing.T) {
cases := []struct {
msg string
want bool
}{
// Cobra flag errors — should show hint
{"unknown flag: --foo", true},
{"unknown shorthand flag: 'f' in -f", true},
{"flag needs an argument: --output", true},
{"required flag(s) \"model\" not set", true},
// Generic invalid-argument errors — should show hint
{"invalid argument \"abc\" for --count", true},
// required flag errors — should show hint
{"required flag(s) \"model\" not set", true},
// usage: in message — should show hint
{"bad input\nusage: picoclaw ...", true},
// Should NOT false-positive on broad words
{"connection flagged by remote", false},
{"feature flag not set", false},
{"invalid API key provided", false},
{"authentication required", false},
// Unrelated messages — no hint
{"something went wrong", false},
{"network timeout", false},
}
for _, tc := range cases {
got := showErrHint(tc.msg)
if got != tc.want {
t.Errorf("showErrHint(%q) = %v, want %v", tc.msg, got, tc.want)
}
}
}
// ---------------------------------------------------------------------------
// styleUsageTokens
// ---------------------------------------------------------------------------
func TestStyleUsageTokensContainsTokens(t *testing.T) {
cases := []struct {
input string
contains []string // substrings that must appear in plain output
}{
{
"picoclaw agent <message>",
[]string{"picoclaw agent", "<message>"},
},
{
"picoclaw [command] [flags]",
[]string{"picoclaw", "[command]", "[flags]"},
},
{
"picoclaw",
[]string{"picoclaw"},
},
{
"cmd <arg1> [--flag]",
[]string{"cmd", "<arg1>", "[--flag]"},
},
}
for _, tc := range cases {
out := styleUsageTokens(tc.input)
for _, sub := range tc.contains {
if !containsStripped(out, sub) {
t.Errorf("styleUsageTokens(%q): output %q does not contain %q", tc.input, out, sub)
}
}
}
}
// containsStripped checks whether plain contains sub after stripping ANSI escapes.
// Since Init(true) sets Ascii profile, lipgloss emits no escape codes in tests,
// so this is just a plain substring check.
func containsStripped(plain, sub string) bool {
return len(plain) >= len(sub) && findSubstring(plain, sub)
}
func findSubstring(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
// ---------------------------------------------------------------------------
// collectFlagRows
// ---------------------------------------------------------------------------
func TestCollectFlagRows_Empty(t *testing.T) {
fs := flag.NewFlagSet("test", flag.ContinueOnError)
rows := collectFlagRows(fs)
if len(rows) != 0 {
t.Fatalf("expected 0 rows for empty FlagSet, got %d", len(rows))
}
}
func TestCollectFlagRows_BasicFlags(t *testing.T) {
fs := flag.NewFlagSet("test", flag.ContinueOnError)
fs.String("output", "", "output file path")
fs.Bool("verbose", false, "enable verbose mode")
fs.Int("count", 1, "number of items")
rows := collectFlagRows(fs)
if len(rows) != 3 {
t.Fatalf("expected 3 rows, got %d", len(rows))
}
// Rows must be sorted alphabetically by flag name.
names := make([]string, 0, len(rows))
for _, r := range rows {
names = append(names, r[0])
}
if names[0] > names[1] || names[1] > names[2] {
t.Errorf("rows not sorted: %v", names)
}
}
func TestCollectFlagRows_Shorthand(t *testing.T) {
fs := flag.NewFlagSet("test", flag.ContinueOnError)
fs.StringP("model", "m", "", "model name")
rows := collectFlagRows(fs)
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
left := rows[0][0]
if !findSubstring(left, "-m") || !findSubstring(left, "--model") {
t.Errorf("expected shorthand and long form in %q", left)
}
}
func TestCollectFlagRows_HiddenFlagsExcluded(t *testing.T) {
fs := flag.NewFlagSet("test", flag.ContinueOnError)
fs.String("visible", "", "this shows up")
hidden := fs.String("hidden", "", "this should not show up")
_ = hidden
_ = fs.MarkHidden("hidden")
rows := collectFlagRows(fs)
if len(rows) != 1 {
t.Fatalf("expected 1 row (hidden excluded), got %d", len(rows))
}
if !findSubstring(rows[0][0], "visible") {
t.Errorf("expected visible flag in rows, got %q", rows[0][0])
}
}
func TestCollectFlagRows_UsageInRightColumn(t *testing.T) {
fs := flag.NewFlagSet("test", flag.ContinueOnError)
fs.String("format", "json", "output format: json or text")
rows := collectFlagRows(fs)
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0][1] != "output format: json or text" {
t.Errorf("expected usage in right column, got %q", rows[0][1])
}
}

View file

@ -0,0 +1,298 @@
package cliui
import (
"fmt"
"sort"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
flag "github.com/spf13/pflag"
)
// RenderCommandHelp builds Ruff-style sectioned, two-column help when
// UseFancyLayout(); otherwise plain Cobra-style text.
func RenderCommandHelp(c *cobra.Command) string {
if !UseFancyLayout() {
return plainCommandHelp(c)
}
syncFlags(c)
var b strings.Builder
head, sub := helpIntro(c)
if head != "" {
b.WriteString(helpIntroStyle().Render(head))
b.WriteString("\n")
}
if sub != "" {
b.WriteString(mutedStyle().Render(sub))
b.WriteString("\n")
}
if head != "" || sub != "" {
b.WriteString("\n")
}
inner := InnerWidth()
contentW := inner - 6
if contentW < 36 {
contentW = 36
}
// Usage
usageBody := bodyStyle().MaxWidth(contentW).Render(styleUsageTokens(c.UseLine()))
b.WriteString(sectionPanel("Usage", usageBody, inner))
b.WriteString("\n")
// Examples
if ex := strings.TrimSpace(c.Example); ex != "" {
exBody := bodyStyle().Width(contentW).Render(ex)
b.WriteString(sectionPanel("Examples", exBody, inner))
b.WriteString("\n")
}
// Subcommands
subs := visibleSubcommands(c)
if len(subs) > 0 {
rows := make([][2]string, 0, len(subs))
for _, sub := range subs {
left := sub.Name()
if a := sub.Aliases; len(a) > 0 {
left += " (" + strings.Join(a, ", ") + ")"
}
rows = append(rows, [2]string{left, sub.Short})
}
b.WriteString(sectionPanel("Commands", renderTwoColPairs(rows, contentW), inner))
b.WriteString("\n")
}
// Local options
local := c.LocalFlags()
opts := collectFlagRows(local)
if len(opts) > 0 {
title := "Options"
if !c.HasParent() {
title = "Flags"
}
b.WriteString(sectionPanel(title, renderTwoColPairs(opts, contentW), inner))
b.WriteString("\n")
}
// Global (inherited) options
if c.HasAvailableInheritedFlags() {
inh := collectFlagRows(c.InheritedFlags())
if len(inh) > 0 {
b.WriteString(sectionPanel("Global options", renderTwoColPairs(inh, contentW), inner))
b.WriteString("\n")
}
}
return b.String()
}
// RenderCommandQuickRef prints the same Usage / Flags / Global sections as help,
// for embedding after errors (stderr). outerW is typically InnerStderrWidth().
func RenderCommandQuickRef(c *cobra.Command, outerW int) string {
if c == nil || outerW < 40 {
return ""
}
syncFlags(c)
contentW := outerW - 6
if contentW < 36 {
contentW = 36
}
var b strings.Builder
usageBody := bodyStyle().MaxWidth(contentW).Render(styleUsageTokens(c.UseLine()))
b.WriteString(sectionPanel("Usage", usageBody, outerW))
b.WriteString("\n")
if len(c.Aliases) > 0 {
al := "Aliases: " + strings.Join(c.Aliases, ", ")
alBody := mutedStyle().MaxWidth(contentW).Render(al)
b.WriteString(sectionPanel("Aliases", alBody, outerW))
b.WriteString("\n")
}
opts := collectFlagRows(c.LocalFlags())
if len(opts) > 0 {
title := "Options"
if !c.HasParent() {
title = "Flags"
}
b.WriteString(sectionPanel(title, renderTwoColPairs(opts, contentW), outerW))
b.WriteString("\n")
}
if c.HasAvailableInheritedFlags() {
inh := collectFlagRows(c.InheritedFlags())
if len(inh) > 0 {
b.WriteString(sectionPanel("Global options", renderTwoColPairs(inh, contentW), outerW))
b.WriteString("\n")
}
}
return b.String()
}
func syncFlags(c *cobra.Command) {
_ = c.LocalFlags()
if c.HasAvailableInheritedFlags() {
_ = c.InheritedFlags()
}
}
func plainCommandHelp(c *cobra.Command) string {
desc := c.Long
if desc == "" {
desc = c.Short
}
desc = strings.TrimRight(desc, " \t\n\r")
var b strings.Builder
if desc != "" {
fmt.Fprintln(&b, desc)
fmt.Fprintln(&b)
}
if c.Runnable() || c.HasSubCommands() {
b.WriteString(c.UsageString())
}
return b.String()
}
func helpIntro(c *cobra.Command) (head, sub string) {
head = strings.TrimSpace(c.Short)
long := strings.TrimSpace(c.Long)
if long == "" || long == head {
return head, ""
}
lines := strings.Split(long, "\n")
var rest []string
for i, ln := range lines {
ln = strings.TrimSpace(ln)
if ln == "" {
continue
}
if i == 0 && ln == head {
continue
}
rest = append(rest, ln)
}
sub = strings.Join(rest, "\n")
return head, sub
}
func visibleSubcommands(c *cobra.Command) []*cobra.Command {
var out []*cobra.Command
for _, sub := range c.Commands() {
if sub.Hidden {
continue
}
out = append(out, sub)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
return out
}
func sectionPanel(title, body string, width int) string {
head := titleBarStyle().Render(title) + "\n\n"
return borderStyle().Width(width).Render(head + body)
}
// styleUsageTokens highlights PicoClaw-blue command tokens and red <placeholders>/[groups].
func styleUsageTokens(s string) string {
var b strings.Builder
for len(s) > 0 {
ia := strings.Index(s, "<")
ib := strings.Index(s, "[")
next, kind := -1, 0 // 1 = angle, 2 = bracket
switch {
case ia >= 0 && (ib < 0 || ia < ib):
next, kind = ia, 1
case ib >= 0:
next, kind = ib, 2
}
if next < 0 {
b.WriteString(helpIdentStyle().Render(s))
break
}
if next > 0 {
b.WriteString(helpIdentStyle().Render(s[:next]))
}
s = s[next:]
if kind == 1 {
j := strings.Index(s, ">")
if j < 0 {
b.WriteString(helpIdentStyle().Render(s))
break
}
b.WriteString(helpPlaceholderStyle().Render(s[:j+1]))
s = s[j+1:]
continue
}
j := strings.Index(s, "]")
if j < 0 {
b.WriteString(helpIdentStyle().Render(s))
break
}
b.WriteString(helpPlaceholderStyle().Render(s[:j+1]))
s = s[j+1:]
}
return b.String()
}
func collectFlagRows(fs *flag.FlagSet) [][2]string {
var names []string
seen := map[string][2]string{}
fs.VisitAll(func(f *flag.Flag) {
if f.Hidden {
return
}
left := formatFlagLeft(f)
right := f.Usage
if f.Deprecated != "" {
right += " (deprecated: " + f.Deprecated + ")"
}
names = append(names, f.Name)
seen[f.Name] = [2]string{left, right}
})
sort.Strings(names)
rows := make([][2]string, 0, len(names))
for _, n := range names {
rows = append(rows, seen[n])
}
return rows
}
func formatFlagLeft(f *flag.Flag) string {
if len(f.Shorthand) > 0 {
return "-" + f.Shorthand + ", --" + f.Name
}
return "--" + f.Name
}
func renderTwoColPairs(rows [][2]string, contentW int) string {
if len(rows) == 0 {
return ""
}
leftW := 0
for _, r := range rows {
if w := lipgloss.Width(r[0]); w > leftW {
leftW = w
}
}
const minLeft, maxLeft = 16, 34
if leftW < minLeft {
leftW = minLeft
}
if leftW > maxLeft {
leftW = maxLeft
}
gap := " "
rightW := contentW - leftW - lipgloss.Width(gap)
if rightW < 24 {
rightW = 24
}
var b strings.Builder
for _, r := range rows {
left := helpIdentStyle().Width(leftW).Align(lipgloss.Left).Render(r[0])
right := bodyStyle().Width(rightW).Render(strings.TrimSpace(r[1]))
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, left, gap, right))
b.WriteString("\n")
}
return strings.TrimRight(b.String(), "\n")
}

View file

@ -0,0 +1,75 @@
package cliui
import (
"strings"
"github.com/spf13/cobra"
)
// FormatCLIError formats errors with the same boxed sections as help. When ctx
// is the command that was running when the error occurred, Usage / Flags panels
// are appended so styling matches picoclaw -h.
func FormatCLIError(msg string, ctx *cobra.Command) string {
msg = strings.TrimRight(msg, "\n")
if !UseFancyStderr() {
s := "Error: " + msg + "\n"
if ctx != nil && showErrHint(msg) {
s += "\n" + plainCommandHelp(ctx)
}
return s
}
w := InnerStderrWidth()
contentW := w - 6
if contentW < 36 {
contentW = 36
}
title := titleBarStyle().Render("Error") + "\n\n"
paras := strings.Split(msg, "\n")
var body strings.Builder
for i, p := range paras {
p = strings.TrimRight(p, " ")
if p == "" {
continue
}
st := bodyStyle().Width(contentW)
if i > 0 {
body.WriteString("\n")
}
if i == 0 {
body.WriteString(st.Render(p))
} else {
body.WriteString(mutedStyle().Width(contentW).Render(p))
}
}
foot := ""
if showErrHint(msg) {
if ctx != nil {
foot = "\n\n" + mutedStyle().Width(contentW).
Render("Full command help: "+ctx.CommandPath()+" --help")
} else {
foot = "\n\n" + mutedStyle().Width(contentW).
Render("Tip: picoclaw --help · picoclaw <command> --help")
}
}
out := borderStyle().Width(w).Render(title+body.String()+foot) + "\n"
if ctx != nil && showErrHint(msg) {
if ref := RenderCommandQuickRef(ctx, w); ref != "" {
out += "\n" + ref
}
}
return out
}
func showErrHint(msg string) bool {
m := strings.ToLower(msg)
return strings.Contains(m, "unknown flag") ||
strings.Contains(m, "unknown shorthand flag") ||
strings.Contains(m, "flag needs an argument") ||
strings.Contains(m, "invalid argument") ||
strings.Contains(m, "required flag") ||
strings.Contains(m, "usage:")
}

View file

@ -0,0 +1,110 @@
package cliui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
// PrintOnboardComplete prints the post-onboard “ready” message and next steps.
func PrintOnboardComplete(logo string, encrypt bool, configPath string) {
if !UseFancyLayout() {
printOnboardPlain(logo, encrypt, configPath)
return
}
printOnboardFancy(logo, encrypt, configPath)
}
func printOnboardPlain(logo string, encrypt bool, configPath string) {
fmt.Printf("\n%s picoclaw is ready!\n", logo)
fmt.Println("\nNext steps:")
if encrypt {
fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:")
fmt.Println(" export PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Linux/macOS")
fmt.Println(" set PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Windows cmd")
fmt.Println("")
fmt.Println(" 2. Add your API key to", configPath)
} else {
fmt.Println(" 1. Add your API key to", configPath)
}
fmt.Println("")
fmt.Println(" Recommended:")
fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
fmt.Println(" - Ollama: https://ollama.com (local, free)")
fmt.Println("")
fmt.Println(" See README.md for 17+ supported providers.")
fmt.Println("")
if encrypt {
fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"")
} else {
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
}
}
func printOnboardFancy(logo string, encrypt bool, configPath string) {
inner := InnerWidth()
box := borderStyle().MaxWidth(inner + 8)
ready := titleBarStyle().Render(logo+" picoclaw is ready!") + "\n"
fmt.Println()
fmt.Println(box.Width(inner).Render(strings.TrimSpace(ready)))
fmt.Println()
steps := buildOnboardingSteps(encrypt, configPath)
rec := recommendedBlock()
chat := chatStep(encrypt)
if UseColumnLayout() {
leftW := min(inner/2-2, 52)
rightW := inner - leftW - 4
if rightW < 36 {
rightW = 36
}
leftBlock := borderStyle().MaxWidth(leftW + 8).Width(leftW).
Render(titleBarStyle().Render("Next steps") + "\n\n" + bodyStyle().Width(leftW).Render(steps))
rightBlock := borderStyle().MaxWidth(rightW + 8).Width(rightW).
Render(mutedStyle().Bold(true).Render("Recommended") + "\n\n" + bodyStyle().Width(rightW).Render(rec))
gap := strings.Repeat(" ", 2)
fmt.Println(lipgloss.JoinHorizontal(lipgloss.Top, leftBlock, gap, rightBlock))
fmt.Println()
full := borderStyle().Width(inner).Render(bodyStyle().Width(inner - 4).Render(chat))
fmt.Println(full)
return
}
// Same order as plain output: numbered steps → recommended → chat line.
next := titleBarStyle().Render("Next steps") + "\n\n" +
bodyStyle().Width(inner-4).Render(steps+"\n\n"+rec+"\n\n"+chat)
fmt.Println(borderStyle().Width(inner).Render(next))
}
func buildOnboardingSteps(encrypt bool, configPath string) string {
var b strings.Builder
if encrypt {
b.WriteString("1. Set your encryption passphrase before starting picoclaw:\n")
b.WriteString(" export PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Linux/macOS\n")
b.WriteString(" set PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Windows cmd\n\n")
b.WriteString("2. Add your API key to\n ")
b.WriteString(configPath)
b.WriteString("\n")
} else {
b.WriteString("1. Add your API key to\n ")
b.WriteString(configPath)
b.WriteString("\n")
}
return b.String()
}
func recommendedBlock() string {
return "• OpenRouter: https://openrouter.ai/keys\n (access 100+ models)\n\n" +
"• Ollama: https://ollama.com\n (local, free)\n\n" +
"See README.md for 17+ supported providers."
}
func chatStep(encrypt bool) string {
if encrypt {
return "3. Chat:\n picoclaw agent -m \"Hello!\""
}
return "2. Chat:\n picoclaw agent -m \"Hello!\""
}

View file

@ -0,0 +1,168 @@
package cliui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
// ProviderRow holds one provider's display name and status value.
type ProviderRow struct {
Name string
Val string
}
// StatusReport is a structured status view for PrintStatus.
type StatusReport struct {
Logo string
Version string
Build string
ConfigPath string
ConfigOK bool
WorkspacePath string
WorkspaceOK bool
Model string
Providers []ProviderRow
OAuthLines []string // each full line "provider (method): state"
}
// PrintStatus renders picoclaw status (plain or fancy).
func PrintStatus(r StatusReport) {
if !UseFancyLayout() {
printStatusPlain(r)
return
}
printStatusFancy(r)
}
func printStatusPlain(r StatusReport) {
fmt.Printf("%s picoclaw Status\n", r.Logo)
fmt.Printf("Version: %s\n", r.Version)
if r.Build != "" {
fmt.Printf("Build: %s\n", r.Build)
}
fmt.Println()
printPathLine("Config", r.ConfigPath, r.ConfigOK)
printPathLine("Workspace", r.WorkspacePath, r.WorkspaceOK)
if r.ConfigOK {
fmt.Printf("Model: %s\n", r.Model)
for _, p := range r.Providers {
fmt.Printf("%s: %s\n", p.Name, p.Val)
}
if len(r.OAuthLines) > 0 {
fmt.Println("\nOAuth/Token Auth:")
for _, line := range r.OAuthLines {
fmt.Printf(" %s\n", line)
}
}
}
}
func printPathLine(label, path string, ok bool) {
mark := "✗"
if ok {
mark = "✓"
}
fmt.Println(label+":", path, mark)
}
func printStatusFancy(r StatusReport) {
inner := InnerWidth()
topBox := borderStyle().Width(inner)
var head strings.Builder
head.WriteString(titleBarStyle().Render(r.Logo + " picoclaw Status"))
head.WriteString("\n\n")
head.WriteString(kvKeyStyle().Render("Version") + " " + kvValStyle().Render(r.Version))
if r.Build != "" {
head.WriteString("\n")
head.WriteString(kvKeyStyle().Render("Build") + " " + kvValStyle().Render(r.Build))
}
fmt.Println(topBox.Render(head.String()))
fmt.Println()
if UseColumnLayout() && len(r.Providers) > 0 && r.ConfigOK {
leftW := (inner - 2) / 2
rightW := inner - leftW - 2
pathsNarrow := pathStatusPanel(r, leftW)
prov := providerTablePanel(r, rightW)
gap := strings.Repeat(" ", 2)
fmt.Println(lipgloss.JoinHorizontal(lipgloss.Top, pathsNarrow, gap, prov))
} else {
fmt.Println(pathStatusPanel(r, inner))
if len(r.Providers) > 0 && r.ConfigOK {
fmt.Println(providerTablePanel(r, inner))
}
}
if len(r.OAuthLines) > 0 && r.ConfigOK {
var ob strings.Builder
ob.WriteString(titleBarStyle().Render("OAuth / token auth") + "\n\n")
for _, line := range r.OAuthLines {
ob.WriteString(" • " + line + "\n")
}
fmt.Println()
fmt.Println(borderStyle().Width(inner).Render(ob.String()))
}
}
func pathStatusPanel(r StatusReport, inner int) string {
cfgMark := statusMark(r.ConfigOK)
wsMark := statusMark(r.WorkspaceOK)
var b strings.Builder
b.WriteString(kvKeyStyle().Render("Config") + "\n")
b.WriteString(mutedStyle().Render(r.ConfigPath))
b.WriteString(" " + cfgMark + "\n\n")
b.WriteString(kvKeyStyle().Render("Workspace") + "\n")
b.WriteString(mutedStyle().Render(r.WorkspacePath))
b.WriteString(" " + wsMark + "\n")
if r.ConfigOK {
b.WriteString("\n")
b.WriteString(kvKeyStyle().Render("Model") + " " + kvValStyle().Render(r.Model))
}
return borderStyle().Width(inner).Render(b.String())
}
func statusMark(ok bool) string {
if ok {
return lipgloss.NewStyle().Foreground(colorOK).Render("✓")
}
return lipgloss.NewStyle().Foreground(accentRed).Render("✗")
}
func providerTablePanel(r StatusReport, colW int) string {
if len(r.Providers) == 0 {
return ""
}
keyW := min(22, colW/3)
if keyW < 14 {
keyW = 14
}
valW := colW - keyW - 3
if valW < 12 {
valW = 12
}
var b strings.Builder
b.WriteString(titleBarStyle().Render("Providers & local") + "\n\n")
for _, p := range r.Providers {
k := lipgloss.NewStyle().Foreground(accentBlue).Bold(true).Width(keyW).Render(p.Name)
v := styleProviderVal(p.Val).Width(valW).Render(p.Val)
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, k, " ", v))
b.WriteString("\n")
}
return borderStyle().Width(colW).Render(strings.TrimRight(b.String(), "\n"))
}
func styleProviderVal(s string) lipgloss.Style {
if s == "✓" || strings.HasPrefix(s, "✓ ") {
return lipgloss.NewStyle().Foreground(colorOK)
}
if s == "not set" {
return mutedStyle()
}
return lipgloss.NewStyle()
}

View file

@ -0,0 +1,61 @@
package cliui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
// PrintVersion prints version, optional build info, and Go toolchain line.
func PrintVersion(logo, versionLine string, build, goVer string) {
if !UseFancyLayout() {
fmt.Printf("%s %s\n", logo, versionLine)
if build != "" {
fmt.Printf(" Build: %s\n", build)
}
if goVer != "" {
fmt.Printf(" Go: %s\n", goVer)
}
return
}
inner := InnerWidth()
box := borderStyle().Width(inner)
if UseColumnLayout() {
leftCol := kvKeyStyle().Width(12).Align(lipgloss.Right)
rightW := inner - 16
rightStyle := kvValStyle().Width(rightW)
rows := [][]string{
{leftCol.Render("Version"), rightStyle.Render(versionLine)},
}
if build != "" {
rows = append(rows, []string{leftCol.Render("Build"), rightStyle.Render(build)})
}
if goVer != "" {
rows = append(rows, []string{leftCol.Render("Go"), rightStyle.Render(goVer)})
}
var body strings.Builder
for _, r := range rows {
body.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, r[0], " ", r[1]))
body.WriteString("\n")
}
header := titleBarStyle().Render(logo+" picoclaw") + "\n\n"
fmt.Println(box.Render(header + body.String()))
return
}
var lines []string
lines = append(lines, titleBarStyle().Render(logo+" picoclaw"))
lines = append(lines, "")
lines = append(lines, kvKeyStyle().Render("Version")+" "+kvValStyle().Render(versionLine))
if build != "" {
lines = append(lines, kvKeyStyle().Render("Build")+" "+kvValStyle().Render(build))
}
if goVer != "" {
lines = append(lines, kvKeyStyle().Render("Go")+" "+kvValStyle().Render(goVer))
}
fmt.Println(box.Render(strings.Join(lines, "\n")))
}

View file

@ -0,0 +1,62 @@
package cron
import (
"fmt"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/pkg/cron"
)
func newAddCommand(storePath func() string) *cobra.Command {
var (
name string
message string
every int64
cronExp string
channel string
to string
)
cmd := &cobra.Command{
Use: "add",
Short: "Add a new scheduled job",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if every <= 0 && cronExp == "" {
return fmt.Errorf("either --every or --cron must be specified")
}
var schedule cron.CronSchedule
if every > 0 {
everyMS := every * 1000
schedule = cron.CronSchedule{Kind: "every", EveryMS: &everyMS}
} else {
schedule = cron.CronSchedule{Kind: "cron", Expr: cronExp}
}
cs := cron.NewCronService(storePath(), nil)
job, err := cs.AddJob(name, schedule, message, channel, to)
if err != nil {
return fmt.Errorf("error adding job: %w", err)
}
fmt.Printf("✓ Added job '%s' (%s)\n", job.Name, job.ID)
return nil
},
}
cmd.Flags().StringVarP(&name, "name", "n", "", "Job name")
cmd.Flags().StringVarP(&message, "message", "m", "", "Message for agent")
cmd.Flags().Int64VarP(&every, "every", "e", 0, "Run every N seconds")
cmd.Flags().StringVarP(&cronExp, "cron", "c", "", "Cron expression (e.g. '0 9 * * *')")
cmd.Flags().StringVar(&to, "to", "", "Recipient for delivery")
cmd.Flags().StringVar(&channel, "channel", "", "Channel for delivery")
_ = cmd.MarkFlagRequired("name")
_ = cmd.MarkFlagRequired("message")
cmd.MarkFlagsMutuallyExclusive("every", "cron")
return cmd
}

View file

@ -0,0 +1,56 @@
package cron
import (
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewAddSubcommand(t *testing.T) {
fn := func() string { return "" }
cmd := newAddCommand(fn)
require.NotNil(t, cmd)
assert.Equal(t, "add", cmd.Use)
assert.Equal(t, "Add a new scheduled job", cmd.Short)
assert.True(t, cmd.HasFlags())
assert.NotNil(t, cmd.Flags().Lookup("every"))
assert.NotNil(t, cmd.Flags().Lookup("cron"))
assert.NotNil(t, cmd.Flags().Lookup("to"))
assert.NotNil(t, cmd.Flags().Lookup("channel"))
nameFlag := cmd.Flags().Lookup("name")
require.NotNil(t, nameFlag)
messageFlag := cmd.Flags().Lookup("message")
require.NotNil(t, messageFlag)
val, found := nameFlag.Annotations[cobra.BashCompOneRequiredFlag]
require.True(t, found)
require.NotEmpty(t, val)
assert.Equal(t, "true", val[0])
val, found = messageFlag.Annotations[cobra.BashCompOneRequiredFlag]
require.True(t, found)
require.NotEmpty(t, val)
assert.Equal(t, "true", val[0])
}
func TestNewAddCommandEveryAndCronMutuallyExclusive(t *testing.T) {
cmd := newAddCommand(func() string { return "testing" })
cmd.SetArgs([]string{
"--name", "job",
"--message", "hello",
"--every", "10",
"--cron", "0 9 * * *",
})
err := cmd.Execute()
require.Error(t, err)
}

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,52 @@
package gateway
import (
"fmt"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/gateway"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils"
)
func NewGatewayCommand() *cobra.Command {
var debug bool
var noTruncate bool
var allowEmpty bool
cmd := &cobra.Command{
Use: "gateway",
Aliases: []string{"g"},
Short: "Start picoclaw gateway",
Args: cobra.NoArgs,
PreRunE: func(_ *cobra.Command, _ []string) error {
if noTruncate && !debug {
return fmt.Errorf("the --no-truncate option can only be used in conjunction with --debug (-d)")
}
if noTruncate {
utils.SetDisableTruncation(true)
logger.Info("String truncation is globally disabled via 'no-truncate' flag")
}
return nil
},
RunE: func(_ *cobra.Command, _ []string) error {
return gateway.Run(debug, internal.GetPicoclawHome(), internal.GetConfigPath(), allowEmpty)
},
}
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
cmd.Flags().BoolVarP(&noTruncate, "no-truncate", "T", false, "Disable string truncation in debug logs")
cmd.Flags().BoolVarP(
&allowEmpty,
"allow-empty",
"E",
false,
"Continue starting even when no default model is configured",
)
return cmd
}

View file

@ -0,0 +1,32 @@
package gateway
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewGatewayCommand(t *testing.T) {
cmd := NewGatewayCommand()
require.NotNil(t, cmd)
assert.Equal(t, "gateway", cmd.Use)
assert.Equal(t, "Start picoclaw gateway", cmd.Short)
assert.Len(t, cmd.Aliases, 1)
assert.True(t, cmd.HasAlias("g"))
assert.Nil(t, cmd.Run)
assert.NotNil(t, cmd.RunE)
assert.Nil(t, cmd.PersistentPreRun)
assert.Nil(t, cmd.PersistentPostRun)
assert.False(t, cmd.HasSubCommands())
assert.True(t, cmd.HasFlags())
assert.NotNil(t, cmd.Flags().Lookup("debug"))
assert.NotNil(t, cmd.Flags().Lookup("allow-empty"))
}

View file

@ -0,0 +1,52 @@
package internal
import (
"os"
"path/filepath"
"github.com/sipeed/picoclaw/pkg"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
const Logo = pkg.Logo
// GetPicoclawHome returns the picoclaw home directory.
// Priority: $PICOCLAW_HOME > ~/.picoclaw
func GetPicoclawHome() string {
return config.GetHome()
}
func GetConfigPath() string {
if configPath := os.Getenv(config.EnvConfig); configPath != "" {
return configPath
}
return filepath.Join(GetPicoclawHome(), "config.json")
}
func LoadConfig() (*config.Config, error) {
cfg, err := config.LoadConfig(GetConfigPath())
if err != nil {
return nil, err
}
logger.SetLevelFromString(cfg.Gateway.LogLevel)
return cfg, nil
}
// FormatVersion returns the version string with optional git commit
// Deprecated: Use pkg/config.FormatVersion instead
func FormatVersion() string {
return config.FormatVersion()
}
// FormatBuildInfo returns build time and go version info
// Deprecated: Use pkg/config.FormatBuildInfo instead
func FormatBuildInfo() (string, string) {
return config.FormatBuildInfo()
}
// GetVersion returns the version string
// Deprecated: Use pkg/config.GetVersion instead
func GetVersion() string {
return config.GetVersion()
}

View file

@ -0,0 +1,57 @@
package internal
import (
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestGetConfigPath(t *testing.T) {
t.Setenv("HOME", "/tmp/home")
got := GetConfigPath()
want := filepath.Join("/tmp/home", ".picoclaw", "config.json")
assert.Equal(t, want, got)
}
func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) {
t.Setenv(config.EnvHome, "/custom/picoclaw")
t.Setenv("HOME", "/tmp/home")
got := GetConfigPath()
want := filepath.Join("/custom/picoclaw", "config.json")
assert.Equal(t, want, got)
}
func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) {
t.Setenv("PICOCLAW_CONFIG", "/custom/config.json")
t.Setenv(config.EnvHome, "/custom/picoclaw")
t.Setenv("HOME", "/tmp/home")
got := GetConfigPath()
want := "/custom/config.json"
assert.Equal(t, want, got)
}
func TestGetConfigPath_Windows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("windows-specific HOME behavior varies; run on windows")
}
testUserProfilePath := `C:\Users\Test`
t.Setenv("USERPROFILE", testUserProfilePath)
got := GetConfigPath()
want := filepath.Join(testUserProfilePath, ".picoclaw", "config.json")
require.True(t, strings.EqualFold(got, want), "GetConfigPath() = %q, want %q", got, want)
}

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,128 @@
package model
import (
"fmt"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
)
// LocalModel is a special model name that indicates that the model is local and with or without api_key.
const LocalModel = "local-model"
func NewModelCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "model [model_name]",
Short: "Show or change the default model",
Long: `Show or change the default model configuration.
If no argument is provided, shows the current default model.
If a model name is provided, sets it as the default model.
Examples:
picoclaw model # Show current default model
picoclaw model gpt-5.2 # Set gpt-5.2 as default
picoclaw model claude-sonnet-4.6 # Set claude-sonnet-4.6 as default
picoclaw model local-model # Set local VLLM server as default
Note: 'local-model' is a special value for using a local VLLM server
(running at localhost:8000 by default) which does not require an API key.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
configPath := internal.GetConfigPath()
// Load current config
cfg, err := config.LoadConfig(configPath)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if len(args) == 0 {
// Show current default model
showCurrentModel(cfg)
return nil
}
// Set new default model
modelName := args[0]
return setDefaultModel(configPath, cfg, modelName)
},
}
return cmd
}
func showCurrentModel(cfg *config.Config) {
defaultModel := cfg.Agents.Defaults.ModelName
if defaultModel == "" {
fmt.Println("No default model is currently set.")
fmt.Println("\nAvailable models in your config:")
listAvailableModels(cfg)
} else {
fmt.Printf("Current default model: %s\n", defaultModel)
fmt.Println("\nAvailable models in your config:")
listAvailableModels(cfg)
}
}
func listAvailableModels(cfg *config.Config) {
if len(cfg.ModelList) == 0 {
fmt.Println(" No models configured in model_list")
return
}
defaultModel := cfg.Agents.Defaults.ModelName
for _, model := range cfg.ModelList {
marker := " "
if model.ModelName == defaultModel {
marker = "> "
}
if !model.Enabled {
continue
}
fmt.Printf("%s- %s (%s)\n", marker, model.ModelName, model.Model)
}
}
func setDefaultModel(configPath string, cfg *config.Config, modelName string) error {
// Validate that the model exists in model_list
modelFound := false
for _, model := range cfg.ModelList {
if model.Enabled && model.ModelName == modelName {
modelFound = true
break
}
}
if !modelFound && modelName != LocalModel {
return fmt.Errorf("cannot found model '%s' in config", modelName)
}
// Update the default model
// Clear old model field and set new model_name
oldModel := cfg.Agents.Defaults.ModelName
cfg.Agents.Defaults.ModelName = modelName
// Save config back to file
if err := config.SaveConfig(configPath, cfg); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
fmt.Printf("✓ Default model changed from '%s' to '%s'\n",
formatModelName(oldModel), modelName)
fmt.Println("\nThe new default model will be used for all agent interactions.")
return nil
}
func formatModelName(name string) string {
if name == "" {
return "(none)"
}
return name
}

View file

@ -0,0 +1,408 @@
package model
import (
"bytes"
"io"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg/config"
)
var configPath = ""
func initTest(t *testing.T) {
tmpDir := t.TempDir()
configPath = filepath.Join(tmpDir, "config.json")
_ = os.Setenv("PICOCLAW_CONFIG", configPath)
}
// captureStdout captures stdout during the execution of fn and returns the captured output
func captureStdout(fn func()) string {
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
fn()
w.Close()
os.Stdout = oldStdout
var buf bytes.Buffer
io.Copy(&buf, r)
return buf.String()
}
func TestNewModelCommand(t *testing.T) {
cmd := NewModelCommand()
require.NotNil(t, cmd)
assert.Equal(t, "model [model_name]", cmd.Use)
assert.Equal(t, "Show or change the default model", cmd.Short)
assert.Len(t, cmd.Aliases, 0)
assert.False(t, cmd.HasFlags())
assert.Nil(t, cmd.Run)
assert.NotNil(t, cmd.RunE)
assert.Nil(t, cmd.PersistentPreRunE)
assert.Nil(t, cmd.PersistentPreRun)
assert.Nil(t, cmd.PersistentPostRun)
}
func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "gpt-4",
},
},
ModelList: []*config.ModelConfig{
{
ModelName: "gpt-4",
Model: "openai/gpt-4",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
{
ModelName: "claude-3",
Model: "anthropic/claude-3",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
},
}
output := captureStdout(func() {
showCurrentModel(cfg)
})
assert.Contains(t, output, "Current default model: gpt-4")
assert.Contains(t, output, "Available models in your config:")
assert.Contains(t, output, "gpt-4")
assert.Contains(t, output, "claude-3")
}
func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "",
},
},
ModelList: []*config.ModelConfig{
{
ModelName: "gpt-4",
Model: "openai/gpt-4",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
},
}
output := captureStdout(func() {
showCurrentModel(cfg)
})
assert.Contains(t, output, "No default model is currently set.")
assert.Contains(t, output, "Available models in your config:")
}
func TestListAvailableModels_Empty(t *testing.T) {
cfg := &config.Config{
ModelList: []*config.ModelConfig{},
}
output := captureStdout(func() {
listAvailableModels(cfg)
})
assert.Contains(t, output, "No models configured in model_list")
}
func TestListAvailableModels_WithModels(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "gpt-4",
},
},
ModelList: []*config.ModelConfig{
{
ModelName: "gpt-4",
Model: "openai/gpt-4",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
{
ModelName: "claude-3",
Model: "anthropic/claude-3",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
{ModelName: "no-key-model", Model: "openai/test"},
},
}
output := captureStdout(func() {
listAvailableModels(cfg)
})
assert.NotEmpty(t, output)
assert.Contains(t, output, "> - gpt-4 (openai/gpt-4)")
assert.Contains(t, output, "claude-3 (anthropic/claude-3)")
assert.NotContains(t, output, "no-key-model")
}
func TestSetDefaultModel_ValidModel(t *testing.T) {
initTest(t)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "old-model",
},
},
ModelList: []*config.ModelConfig{
{
ModelName: "new-model",
Model: "openai/new-model",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
{
ModelName: "old-model",
Model: "openai/old-model",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
},
}
output := captureStdout(func() {
err := setDefaultModel(configPath, cfg, "new-model")
assert.NoError(t, err)
})
assert.Contains(t, output, "Default model changed from 'old-model' to 'new-model'")
// Verify config was updated
updatedCfg, err := config.LoadConfig(configPath)
require.NoError(t, err)
assert.Equal(t, "new-model", updatedCfg.Agents.Defaults.ModelName)
}
func TestSetDefaultModel_InvalidModel(t *testing.T) {
initTest(t)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "existing-model",
},
},
ModelList: []*config.ModelConfig{
{
ModelName: "existing-model",
Model: "openai/existing",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
},
}
assert.Error(t, setDefaultModel(configPath, cfg, "nonexistent-model"))
}
func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) {
initTest(t)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "existing-model",
},
},
ModelList: []*config.ModelConfig{
{
ModelName: "existing-model",
Model: "openai/existing",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
{ModelName: "no-key-model", Model: "openai/nokey"},
},
}
assert.Error(t, setDefaultModel(configPath, cfg, "no-key-model"))
}
func TestSetDefaultModel_SaveConfigError(t *testing.T) {
// Use an invalid path to trigger save error
invalidPath := "/nonexistent/directory/config.json"
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "old-model",
},
},
ModelList: []*config.ModelConfig{
{
ModelName: "new-model",
Model: "openai/new-model",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
},
}
err := setDefaultModel(invalidPath, cfg, "new-model")
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to save config")
}
func TestFormatModelName(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"empty string", "", "(none)"},
{"simple model", "gpt-4", "gpt-4"},
{"model with version", "claude-sonnet-4.6", "claude-sonnet-4.6"},
{"model with spaces", "my model", "my model"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := formatModelName(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestModelCommandExecution_Show(t *testing.T) {
initTest(t)
// Create a test config
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "test-model",
},
},
ModelList: []*config.ModelConfig{
{
ModelName: "test-model",
Model: "openai/test",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
},
}
err := config.SaveConfig(configPath, cfg)
require.NoError(t, err)
cmd := NewModelCommand()
output := captureStdout(func() {
err = cmd.RunE(cmd, []string{})
assert.NoError(t, err)
})
assert.Contains(t, output, "Current default model: test-model")
}
func TestModelCommandExecution_Set(t *testing.T) {
initTest(t)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "old-model",
},
},
ModelList: []*config.ModelConfig{
{
ModelName: "old-model",
Model: "openai/old",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
{
ModelName: "new-model",
Model: "openai/new",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
},
}
err := config.SaveConfig(configPath, cfg)
require.NoError(t, err)
cmd := NewModelCommand()
output := captureStdout(func() {
err = cmd.RunE(cmd, []string{"new-model"})
assert.NoError(t, err)
})
assert.Contains(t, output, "Default model changed from 'old-model' to 'new-model'")
}
func TestModelCommandExecution_TooManyArgs(t *testing.T) {
cmd := NewModelCommand()
err := cmd.RunE(cmd, []string{"model1", "model2"})
assert.Error(t, err)
}
func TestListAvailableModels_MarkerLogic(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "middle-model",
},
},
ModelList: []*config.ModelConfig{
{
ModelName: "first-model",
Model: "openai/first",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
{
ModelName: "middle-model",
Model: "openai/middle",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
{
ModelName: "last-model",
Model: "openai/last",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
},
}
output := captureStdout(func() {
listAvailableModels(cfg)
})
assert.Contains(t, output, " - first-model (openai/first)")
assert.Contains(t, output, "> - middle-model (openai/middle)")
assert.Contains(t, output, " - last-model (openai/last)")
}

View file

@ -0,0 +1,34 @@
package onboard
import (
"embed"
"github.com/spf13/cobra"
)
//go:generate cp -r ../../../../workspace .
//go:embed workspace
var embeddedFiles embed.FS
func NewOnboardCommand() *cobra.Command {
var encrypt bool
cmd := &cobra.Command{
Use: "onboard",
Aliases: []string{"o"},
Short: "Initialize picoclaw configuration and workspace",
// Run without subcommands → original onboard flow
Run: func(cmd *cobra.Command, args []string) {
if len(args) == 0 {
onboard(encrypt)
} else {
_ = cmd.Help()
}
},
}
cmd.Flags().BoolVar(&encrypt, "enc", false,
"Enable credential encryption (generates SSH key and prompts for passphrase)")
return cmd
}

View file

@ -0,0 +1,32 @@
package onboard
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewOnboardCommand(t *testing.T) {
cmd := NewOnboardCommand()
require.NotNil(t, cmd)
assert.Equal(t, "onboard", cmd.Use)
assert.Equal(t, "Initialize picoclaw configuration and workspace", cmd.Short)
assert.Len(t, cmd.Aliases, 1)
assert.True(t, cmd.HasAlias("o"))
assert.NotNil(t, cmd.Run)
assert.Nil(t, cmd.RunE)
assert.Nil(t, cmd.PersistentPreRun)
assert.Nil(t, cmd.PersistentPostRun)
assert.True(t, cmd.HasFlags())
encFlag := cmd.Flags().Lookup("enc")
require.NotNil(t, encFlag, "expected --enc flag to be registered")
assert.Equal(t, "false", encFlag.DefValue, "--enc should default to false")
assert.False(t, cmd.HasSubCommands())
}

View file

@ -0,0 +1,193 @@
package onboard
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"golang.org/x/term"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/credential"
)
func onboard(encrypt bool) {
configPath := internal.GetConfigPath()
configExists := false
if _, err := os.Stat(configPath); err == nil {
configExists = true
if encrypt {
// Only ask for confirmation when *both* config and SSH key already exist,
// indicating a full re-onboard that would reset the config to defaults.
sshKeyPath, _ := credential.DefaultSSHKeyPath()
if _, err := os.Stat(sshKeyPath); err == nil {
// Both exist — confirm a full reset.
fmt.Printf("Config already exists at %s\n", configPath)
fmt.Print("Overwrite config with defaults? (y/n): ")
var response string
fmt.Scanln(&response)
if response != "y" {
fmt.Println("Aborted.")
return
}
configExists = false // user agreed to reset; treat as fresh
}
// Config exists but SSH key is missing — keep existing config, only add SSH key.
}
}
var err error
if encrypt {
fmt.Println("\nSet up credential encryption")
fmt.Println("-----------------------------")
passphrase, pErr := promptPassphrase()
if pErr != nil {
fmt.Printf("Error: %v\n", pErr)
os.Exit(1)
}
// Expose the passphrase to credential.PassphraseProvider (which calls
// os.Getenv by default) so that SaveConfig can encrypt api_keys.
// This process is a one-shot CLI tool; the env var is never exposed outside
// the current process and disappears when it exits.
os.Setenv(credential.PassphraseEnvVar, passphrase)
if err = setupSSHKey(); err != nil {
fmt.Printf("Error generating SSH key: %v\n", err)
os.Exit(1)
}
}
var cfg *config.Config
if configExists {
// Preserve the existing config; SaveConfig will re-encrypt api_keys with the new passphrase.
cfg, err = config.LoadConfig(configPath)
if err != nil {
fmt.Printf("Error loading existing config: %v\n", err)
os.Exit(1)
}
} else {
cfg = config.DefaultConfig()
}
if err := config.SaveConfig(configPath, cfg); err != nil {
fmt.Printf("Error saving config: %v\n", err)
os.Exit(1)
}
workspace := cfg.WorkspacePath()
createWorkspaceTemplates(workspace)
cliui.PrintOnboardComplete(internal.Logo, encrypt, configPath)
}
// promptPassphrase reads the encryption passphrase twice from the terminal
// (with echo disabled) and returns it. Returns an error if the passphrase is
// empty or if the two inputs do not match.
func promptPassphrase() (string, error) {
fmt.Print("Enter passphrase for credential encryption: ")
p1, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return "", fmt.Errorf("reading passphrase: %w", err)
}
if len(p1) == 0 {
return "", fmt.Errorf("passphrase must not be empty")
}
fmt.Print("Confirm passphrase: ")
p2, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return "", fmt.Errorf("reading passphrase confirmation: %w", err)
}
if string(p1) != string(p2) {
return "", fmt.Errorf("passphrases do not match")
}
return string(p1), nil
}
// setupSSHKey generates the picoclaw-specific SSH key at ~/.ssh/picoclaw_ed25519.key.
// If the key already exists the user is warned and asked to confirm overwrite.
// Answering anything other than "y" keeps the existing key (not an error).
func setupSSHKey() error {
keyPath, err := credential.DefaultSSHKeyPath()
if err != nil {
return fmt.Errorf("cannot determine SSH key path: %w", err)
}
if _, err := os.Stat(keyPath); err == nil {
fmt.Printf("\n⚠ WARNING: %s already exists.\n", keyPath)
fmt.Println(" Overwriting will invalidate any credentials previously encrypted with this key.")
fmt.Print(" Overwrite? (y/n): ")
var response string
fmt.Scanln(&response)
if response != "y" {
fmt.Println("Keeping existing SSH key.")
return nil
}
}
if err := credential.GenerateSSHKey(keyPath); err != nil {
return err
}
fmt.Printf("SSH key generated: %s\n", keyPath)
return nil
}
func createWorkspaceTemplates(workspace string) {
err := copyEmbeddedToTarget(workspace)
if err != nil {
fmt.Printf("Error copying workspace templates: %v\n", err)
}
}
func copyEmbeddedToTarget(targetDir string) error {
// Ensure target directory exists
if err := os.MkdirAll(targetDir, 0o755); err != nil {
return fmt.Errorf("Failed to create target directory: %w", err)
}
// Walk through all files in embed.FS
err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Skip directories
if d.IsDir() {
return nil
}
// Read embedded file
data, err := embeddedFiles.ReadFile(path)
if err != nil {
return fmt.Errorf("Failed to read embedded file %s: %w", path, err)
}
new_path, err := filepath.Rel("workspace", path)
if err != nil {
return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
}
// Build target file path
targetPath := filepath.Join(targetDir, new_path)
// Ensure target file's directory exists
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err)
}
// Write file
if err := os.WriteFile(targetPath, data, 0o644); err != nil {
return fmt.Errorf("Failed to write file %s: %w", targetPath, err)
}
return nil
})
return err
}

View file

@ -0,0 +1,37 @@
package onboard
import (
"os"
"path/filepath"
"testing"
)
func TestCopyEmbeddedToTargetUsesStructuredAgentFiles(t *testing.T) {
targetDir := t.TempDir()
if err := copyEmbeddedToTarget(targetDir); err != nil {
t.Fatalf("copyEmbeddedToTarget() error = %v", err)
}
agentPath := filepath.Join(targetDir, "AGENT.md")
if _, err := os.Stat(agentPath); err != nil {
t.Fatalf("expected %s to exist: %v", agentPath, err)
}
soulPath := filepath.Join(targetDir, "SOUL.md")
if _, err := os.Stat(soulPath); err != nil {
t.Fatalf("expected %s to exist: %v", soulPath, err)
}
userPath := filepath.Join(targetDir, "USER.md")
if _, err := os.Stat(userPath); err != nil {
t.Fatalf("expected %s to exist: %v", userPath, err)
}
for _, legacyName := range []string{"AGENTS.md", "IDENTITY.md"} {
legacyPath := filepath.Join(targetDir, legacyName)
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
t.Fatalf("expected legacy file %s to be absent, got err=%v", legacyPath, err)
}
}
}

View file

@ -0,0 +1,87 @@
package skills
import (
"fmt"
"path/filepath"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/skills"
)
type deps struct {
workspace string
installer *skills.SkillInstaller
skillsLoader *skills.SkillsLoader
}
func NewSkillsCommand() *cobra.Command {
var d deps
cmd := &cobra.Command{
Use: "skills",
Short: "Manage skills",
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := internal.LoadConfig()
if err != nil {
return fmt.Errorf("error loading config: %w", err)
}
d.workspace = cfg.WorkspacePath()
installer, err := skills.NewSkillInstaller(
d.workspace,
cfg.Tools.Skills.Github.Token.String(),
cfg.Tools.Skills.Github.Proxy,
)
if err != nil {
return fmt.Errorf("error creating skills installer: %w", err)
}
d.installer = installer
// get global config directory and builtin skills directory
globalDir := filepath.Dir(internal.GetConfigPath())
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

@ -0,0 +1,328 @@
package skills
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/utils"
)
const skillsSearchMaxResults = 20
func skillsListCmd(loader *skills.SkillsLoader) {
allSkills := loader.ListSkills()
if len(allSkills) == 0 {
fmt.Println("No skills installed.")
return
}
fmt.Println("\nInstalled Skills:")
fmt.Println("------------------")
for _, skill := range allSkills {
fmt.Printf(" ✓ %s (%s)\n", skill.Name, skill.Source)
if skill.Description != "" {
fmt.Printf(" %s\n", skill.Description)
}
}
}
func skillsInstallCmd(installer *skills.SkillInstaller, repo string) error {
fmt.Printf("Installing skill from %s...\n", repo)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := installer.InstallFromGitHub(ctx, repo); err != nil {
return fmt.Errorf("failed to install skill: %w", err)
}
fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo))
return nil
}
// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).
func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) error {
err := utils.ValidateSkillIdentifier(registryName)
if err != nil {
return fmt.Errorf("✗ invalid registry name: %w", err)
}
err = utils.ValidateSkillIdentifier(slug)
if err != nil {
return fmt.Errorf("✗ invalid slug: %w", err)
}
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
ClawHub: skills.ClawHubConfig{
Enabled: clawHubConfig.Enabled,
BaseURL: clawHubConfig.BaseURL,
AuthToken: clawHubConfig.AuthToken.String(),
SearchPath: clawHubConfig.SearchPath,
SkillsPath: clawHubConfig.SkillsPath,
DownloadPath: clawHubConfig.DownloadPath,
Timeout: clawHubConfig.Timeout,
MaxZipSize: clawHubConfig.MaxZipSize,
MaxResponseSize: clawHubConfig.MaxResponseSize,
},
})
registry := registryMgr.GetRegistry(registryName)
if registry == nil {
return fmt.Errorf("✗ registry '%s' not found or not enabled. check your config.json.", registryName)
}
workspace := cfg.WorkspacePath()
targetDir := filepath.Join(workspace, "skills", slug)
if _, err = os.Stat(targetDir); err == nil {
return fmt.Errorf("\u2717 skill '%s' already installed at %s", slug, targetDir)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err = os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil {
return fmt.Errorf("\u2717 failed to create skills directory: %v", err)
}
result, err := registry.DownloadAndInstall(ctx, slug, "", targetDir)
if err != nil {
rmErr := os.RemoveAll(targetDir)
if rmErr != nil {
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
}
return fmt.Errorf("✗ failed to install skill: %w", err)
}
if result.IsMalwareBlocked {
rmErr := os.RemoveAll(targetDir)
if rmErr != nil {
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
}
return fmt.Errorf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug)
}
if result.IsSuspicious {
fmt.Printf("\u26a0\ufe0f Warning: skill '%s' is flagged as suspicious.\n", slug)
}
fmt.Printf("\u2713 Skill '%s' v%s installed successfully!\n", slug, result.Version)
if result.Summary != "" {
fmt.Printf(" %s\n", result.Summary)
}
return nil
}
func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
fmt.Printf("Removing skill '%s'...\n", skillName)
if err := installer.Uninstall(skillName); err != nil {
fmt.Printf("✗ Failed to remove skill: %v\n", err)
os.Exit(1)
}
fmt.Printf("✓ Skill '%s' removed successfully!\n", skillName)
}
func skillsInstallBuiltinCmd(workspace string) {
builtinSkillsDir := "./picoclaw/skills"
workspaceSkillsDir := filepath.Join(workspace, "skills")
fmt.Printf("Copying builtin skills to workspace...\n")
skillsToInstall := []string{
"weather",
"news",
"stock",
"calculator",
}
for _, skillName := range skillsToInstall {
builtinPath := filepath.Join(builtinSkillsDir, skillName)
workspacePath := filepath.Join(workspaceSkillsDir, skillName)
if _, err := os.Stat(builtinPath); err != nil {
fmt.Printf("⊘ Builtin skill '%s' not found: %v\n", skillName, err)
continue
}
if err := os.MkdirAll(workspacePath, 0o755); err != nil {
fmt.Printf("✗ Failed to create directory for %s: %v\n", skillName, err)
continue
}
if err := copyDirectory(builtinPath, workspacePath); err != nil {
fmt.Printf("✗ Failed to copy %s: %v\n", skillName, err)
}
}
fmt.Println("\n✓ All builtin skills installed!")
fmt.Println("Now you can use them in your workspace.")
}
func skillsListBuiltinCmd() {
cfg, err := internal.LoadConfig()
if err != nil {
fmt.Printf("Error loading config: %v\n", err)
return
}
builtinSkillsDir := filepath.Join(filepath.Dir(cfg.WorkspacePath()), "picoclaw", "skills")
fmt.Println("\nAvailable Builtin Skills:")
fmt.Println("-----------------------")
entries, err := os.ReadDir(builtinSkillsDir)
if err != nil {
fmt.Printf("Error reading builtin skills: %v\n", err)
return
}
if len(entries) == 0 {
fmt.Println("No builtin skills available.")
return
}
for _, entry := range entries {
if entry.IsDir() {
skillName := entry.Name()
skillFile := filepath.Join(builtinSkillsDir, skillName, "SKILL.md")
description := "No description"
if _, err := os.Stat(skillFile); err == nil {
data, err := os.ReadFile(skillFile)
if err == nil {
content := string(data)
if idx := strings.Index(content, "\n"); idx > 0 {
firstLine := content[:idx]
if strings.Contains(firstLine, "description:") {
descLine := strings.Index(content[idx:], "\n")
if descLine > 0 {
description = strings.TrimSpace(content[idx+descLine : idx+descLine])
}
}
}
}
}
status := "✓"
fmt.Printf(" %s %s\n", status, entry.Name())
if description != "" {
fmt.Printf(" %s\n", description)
}
}
}
}
func skillsSearchCmd(query string) {
fmt.Println("Searching for available skills...")
cfg, err := internal.LoadConfig()
if err != nil {
fmt.Printf("✗ Failed to load config: %v\n", err)
return
}
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
ClawHub: skills.ClawHubConfig{
Enabled: clawHubConfig.Enabled,
BaseURL: clawHubConfig.BaseURL,
AuthToken: clawHubConfig.AuthToken.String(),
SearchPath: clawHubConfig.SearchPath,
SkillsPath: clawHubConfig.SkillsPath,
DownloadPath: clawHubConfig.DownloadPath,
Timeout: clawHubConfig.Timeout,
MaxZipSize: clawHubConfig.MaxZipSize,
MaxResponseSize: clawHubConfig.MaxResponseSize,
},
})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
results, err := registryMgr.SearchAll(ctx, query, skillsSearchMaxResults)
if err != nil {
fmt.Printf("✗ Failed to fetch skills list: %v\n", err)
return
}
if len(results) == 0 {
fmt.Println("No skills available.")
return
}
fmt.Printf("\nAvailable Skills (%d):\n", len(results))
fmt.Println("--------------------")
for _, result := range results {
fmt.Printf(" 📦 %s\n", result.DisplayName)
fmt.Printf(" %s\n", result.Summary)
fmt.Printf(" Slug: %s\n", result.Slug)
fmt.Printf(" Registry: %s\n", result.RegistryName)
if result.Version != "" {
fmt.Printf(" Version: %s\n", result.Version)
}
fmt.Println()
}
}
func skillsShowCmd(loader *skills.SkillsLoader, skillName string) {
content, ok := loader.LoadSkill(skillName)
if !ok {
fmt.Printf("✗ Skill '%s' not found\n", skillName)
return
}
fmt.Printf("\n📦 Skill: %s\n", skillName)
fmt.Println("----------------------")
fmt.Println(content)
}
func copyDirectory(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(src, path)
if err != nil {
return err
}
dstPath := filepath.Join(dst, relPath)
if info.IsDir() {
return os.MkdirAll(dstPath, info.Mode())
}
srcFile, err := os.Open(path)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
if err != nil {
return err
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
return err
})
}

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

@ -0,0 +1,143 @@
package status
import (
"fmt"
"os"
"strings"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
)
func statusCmd() {
cfg, err := internal.LoadConfig()
if err != nil {
fmt.Printf("Error loading config: %v\n", err)
return
}
configPath := internal.GetConfigPath()
build, _ := config.FormatBuildInfo()
_, configStatErr := os.Stat(configPath)
configOK := configStatErr == nil
workspace := cfg.WorkspacePath()
_, wsErr := os.Stat(workspace)
wsOK := wsErr == nil
report := cliui.StatusReport{
Logo: internal.Logo,
Version: config.FormatVersion(),
Build: build,
ConfigPath: configPath,
ConfigOK: configOK,
WorkspacePath: workspace,
WorkspaceOK: wsOK,
Model: cfg.Agents.Defaults.GetModelName(),
}
if configOK {
// PicoClaw moved to a model-centric configuration (model_list). Status should
// not depend on a legacy cfg.Providers field (which may not exist under some
// build tags). We infer provider availability from model_list entries.
hasProtocolKey := func(protocol string) bool {
prefix := protocol + "/"
for _, m := range cfg.ModelList {
if m == nil {
continue
}
if strings.HasPrefix(m.Model, prefix) && m.APIKey() != "" {
return true
}
}
return false
}
findLocalModelBase := func(modelName string) (string, bool) {
for _, m := range cfg.ModelList {
if m == nil {
continue
}
if m.ModelName == modelName && m.APIBase != "" {
return m.APIBase, true
}
}
return "", false
}
findProtocolBase := func(protocol string) (string, bool) {
prefix := protocol + "/"
for _, m := range cfg.ModelList {
if m == nil {
continue
}
if strings.HasPrefix(m.Model, prefix) && m.APIBase != "" {
return m.APIBase, true
}
}
return "", false
}
hasOpenRouter := hasProtocolKey("openrouter")
hasAnthropic := hasProtocolKey("anthropic")
hasOpenAI := hasProtocolKey("openai")
hasGemini := hasProtocolKey("gemini")
hasZhipu := hasProtocolKey("zhipu")
hasQwen := hasProtocolKey("qwen")
hasGroq := hasProtocolKey("groq")
hasMoonshot := hasProtocolKey("moonshot")
hasDeepSeek := hasProtocolKey("deepseek")
hasVolcEngine := hasProtocolKey("volcengine")
hasNvidia := hasProtocolKey("nvidia")
// Local endpoints: allow both the special reserved name and protocol-based entries.
vllmBase, hasVLLM := findLocalModelBase("local-model")
if !hasVLLM {
vllmBase, hasVLLM = findProtocolBase("vllm")
}
ollamaBase, hasOllama := findProtocolBase("ollama")
val := func(enabled bool, extra ...string) string {
if enabled {
if len(extra) > 0 && extra[0] != "" {
return "✓ " + extra[0]
}
return "✓"
}
return "not set"
}
report.Providers = []cliui.ProviderRow{
{Name: "OpenRouter API", Val: val(hasOpenRouter)},
{Name: "Anthropic API", Val: val(hasAnthropic)},
{Name: "OpenAI API", Val: val(hasOpenAI)},
{Name: "Gemini API", Val: val(hasGemini)},
{Name: "Zhipu API", Val: val(hasZhipu)},
{Name: "Qwen API", Val: val(hasQwen)},
{Name: "Groq API", Val: val(hasGroq)},
{Name: "Moonshot API", Val: val(hasMoonshot)},
{Name: "DeepSeek API", Val: val(hasDeepSeek)},
{Name: "VolcEngine API", Val: val(hasVolcEngine)},
{Name: "Nvidia API", Val: val(hasNvidia)},
{Name: "vLLM / local", Val: val(hasVLLM, vllmBase)},
{Name: "Ollama", Val: val(hasOllama, ollamaBase)},
}
store, _ := auth.LoadStore()
if store != nil && len(store.Credentials) > 0 {
for provider, cred := range store.Credentials {
st := "authenticated"
if cred.IsExpired() {
st = "expired"
} else if cred.NeedsRefresh() {
st = "needs refresh"
}
report.OAuthLines = append(report.OAuthLines,
fmt.Sprintf("%s (%s): %s", provider, cred.AuthMethod, st))
}
}
}
cliui.PrintStatus(report)
}

View file

@ -0,0 +1,27 @@
package version
import (
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/pkg/config"
)
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() {
build, goVer := config.FormatBuildInfo()
cliui.PrintVersion(internal.Logo, "picoclaw "+config.FormatVersion(), build, 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

@ -0,0 +1,151 @@
// PicoClaw - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package main
import (
"fmt"
"os"
"time"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/model"
"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"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/updater"
)
var rootNoColor bool
func syncCliUIColor(root *cobra.Command) {
no, _ := root.PersistentFlags().GetBool("no-color")
cliui.Init(no || os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb")
}
// earlyColorDisabled matches lipgloss/banner behavior from env and argv before Cobra parses flags.
func earlyColorDisabled() bool {
if os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" {
return true
}
for i := 1; i < len(os.Args); i++ {
arg := os.Args[i]
if arg == "--no-color" || arg == "--no-color=true" || arg == "--no-color=1" {
return true
}
}
return false
}
func NewPicoclawCommand() *cobra.Command {
short := fmt.Sprintf("%s PicoClaw — personal AI assistant", internal.Logo)
long := fmt.Sprintf(`%s PicoClaw is a lightweight personal AI assistant.
Version: %s`, internal.Logo, config.FormatVersion())
cmd := &cobra.Command{
Use: "picoclaw",
Short: short,
Long: long,
Example: `picoclaw version
picoclaw onboard
picoclaw --no-color status`,
SilenceErrors: true,
// Avoid plain UsageString() on stderr/stdout when a command fails; cliui
// renders matching panels on stderr instead.
SilenceUsage: true,
PersistentPreRun: func(c *cobra.Command, _ []string) {
syncCliUIColor(c.Root())
},
}
cmd.PersistentFlags().BoolVar(&rootNoColor, "no-color", false,
"Disable colors (boxed layout unchanged)")
cmd.SetHelpFunc(func(c *cobra.Command, _ []string) {
syncCliUIColor(c.Root())
fmt.Fprint(c.OutOrStdout(), cliui.RenderCommandHelp(c))
})
cmd.AddCommand(
onboard.NewOnboardCommand(),
agent.NewAgentCommand(),
auth.NewAuthCommand(),
gateway.NewGatewayCommand(),
status.NewStatusCommand(),
cron.NewCronCommand(),
migrate.NewMigrateCommand(),
skills.NewSkillsCommand(),
model.NewModelCommand(),
updater.NewUpdateCommand("picoclaw"),
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"
plainBanner = "\r\n" +
"██████╗ ██╗ ██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗\n" +
"██╔══██╗██║██╔════╝██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║\n" +
"██████╔╝██║██║ ██║ ██║██║ ██║ ███████║██║ █╗ ██║\n" +
"██╔═══╝ ██║██║ ██║ ██║██║ ██║ ██╔══██║██║███╗██║\n" +
"██║ ██║╚██████╗╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
"╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
"\r\n"
)
func main() {
cliui.Init(earlyColorDisabled())
if earlyColorDisabled() {
fmt.Print(plainBanner)
} else {
fmt.Printf("%s", banner)
}
tzEnv := os.Getenv("TZ")
if tzEnv != "" {
fmt.Println("TZ environment:", tzEnv)
zoneinfoEnv := os.Getenv("ZONEINFO")
fmt.Println("ZONEINFO environment:", zoneinfoEnv)
loc, err := time.LoadLocation(tzEnv)
if err != nil {
fmt.Println("Error loading time zone:", err)
} else {
fmt.Println("Time zone loaded successfully:", loc)
time.Local = loc //nolint:gosmopolitan // We intentionally set local timezone from TZ env
}
}
cmd := NewPicoclawCommand()
last, err := cmd.ExecuteC()
if err != nil {
syncCliUIColor(cmd)
fmt.Fprint(os.Stderr, cliui.FormatCLIError(err.Error(), last))
os.Exit(1)
}
}

View file

@ -0,0 +1,62 @@
package main
import (
"fmt"
"slices"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestNewPicoclawCommand(t *testing.T) {
cmd := NewPicoclawCommand()
require.NotNil(t, cmd)
short := fmt.Sprintf("%s PicoClaw — personal AI assistant", internal.Logo)
longHas := strings.Contains(cmd.Long, config.FormatVersion())
assert.Equal(t, "picoclaw", cmd.Use)
assert.Equal(t, short, cmd.Short)
assert.True(t, longHas)
assert.True(t, cmd.HasSubCommands())
assert.True(t, cmd.HasAvailableSubCommands())
assert.True(t, cmd.PersistentFlags().Lookup("no-color") != nil)
assert.Nil(t, cmd.Run)
assert.Nil(t, cmd.RunE)
assert.NotNil(t, cmd.PersistentPreRun)
assert.Nil(t, cmd.PersistentPostRun)
allowedCommands := []string{
"agent",
"auth",
"cron",
"gateway",
"migrate",
"model",
"onboard",
"skills",
"status",
"update",
"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

@ -0,0 +1,90 @@
# ProtoAgent CLI
Interface de linha de comando para o ProtoAgent - ferramenta de prototipagem de comportamentos.
## Estrutura
```
cmd/protoagent-cli/
└── main.go # CLI completa com comandos generate, validate, version e help
```
## Comandos
### generate
Gera artefatos a partir de um arquivo de requisitos JSON.
```bash
protoagent-cli generate requirements.json [opções]
```
**Opções:**
- `-o, --output <dir>` - Diretório de saída (padrão: ./output)
- `-w, --workspace <dir>` - Diretório do workspace (padrão: .)
- `--opa` - Habilitar geração de políticas OPA
- `--ai` - Habilitar geração assistida por IA
- `--dry-run` - Preview sem escrever arquivos
- `-v, --verbose` - Output detalhado
### validate
Valida um arquivo de requisitos.
```bash
protoagent-cli validate requirements.json
```
### version
Mostra informações de versão.
```bash
protoagent-cli version
```
### help
Mostra ajuda detalhada.
```bash
protoagent-cli help
```
## Exemplos
```bash
# Gerar artefatos com políticas OPA
protoagent-cli generate travel-experience-platform.json -o ./output --opa --verbose
# Validar requisitos
protoagent-cli validate cafeteria-loyalty-system.json
# Dry run (preview)
protoagent-cli generate requirements.json --dry-run --verbose
```
## Artefatos Gerados
O CLI gera os seguintes arquivos no diretório de saída:
- `AGENT.json` / `AGENT.md` - Configuração do agente
- `schema_*.json` / `schema_*.sql` - Schemas de banco de dados
- `policy_*.rego.json` / `policy_*.rego` - Políticas OPA
- `interfaces.json` - Definições de interfaces
- `channels.json` - Configurações de canais
- `skills.json` / `skill_*.go` - Skills geradas
- `tools.json` - Tools configuradas
- `mcp_config.json` - Configuração MCP
- `validation_report.json` - Relatório de validação
## Requisitos
- Go 1.21+ (para suporte a log/slog e slices)
- Arquivo de requisitos em formato JSON
## Build
```bash
go build -o protoagent-cli ./cmd/protoagent-cli
```

View file

@ -0,0 +1,500 @@
// Package main provides the CLI for protoagent.
// This CLI tool allows users to generate agent artifacts from requirements via command line.
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/protoagent"
)
func main() {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
command := os.Args[1]
switch command {
case "generate":
runGenerate(os.Args[2:])
case "validate":
runValidate(os.Args[2:])
case "version":
printVersion()
case "help", "-h", "--help":
printUsage()
default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command)
printUsage()
os.Exit(1)
}
}
func printUsage() {
fmt.Println(`ProtoAgent CLI - Generate agent artifacts from requirements
Usage:
protoagent-cli <command> [options]
Commands:
generate Generate artifacts from requirements file
validate Validate a requirements file
version Show version information
help Show this help message
Generate Options:
protoagent-cli generate <requirements.json|yaml> [options]
-o, --output <dir> Output directory (default: ./output)
-w, --workspace <dir> Workspace directory (default: .)
--opa Enable OPA policy generation
--ai Enable AI-assisted generation
--dry-run Preview without writing files
-v, --verbose Verbose output
Validate Options:
protoagent-cli validate <requirements.json|yaml>
Examples:
protoagent-cli generate requirements.json -o ./output --opa
protoagent-cli validate requirements.json
protoagent-cli generate travel-experience-platform.json --verbose
`)
}
func printVersion() {
fmt.Println("protoagent-cli version 0.1.0")
}
func runGenerate(args []string) {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "Error: Requirements file is required")
fmt.Fprintln(os.Stderr, "Usage: protoagent-cli generate <requirements.json|yaml> [options]")
os.Exit(1)
}
reqFile := args[0]
outputDir := "./output"
workspace := "."
enableOPA := false
enableAI := false
dryRun := false
verbose := false
// Parse arguments
for i := 1; i < len(args); i++ {
switch args[i] {
case "-o", "--output":
if i+1 < len(args) {
outputDir = args[i+1]
i++
}
case "-w", "--workspace":
if i+1 < len(args) {
workspace = args[i+1]
i++
}
case "--opa":
enableOPA = true
case "--ai":
enableAI = true
case "--dry-run":
dryRun = true
case "-v", "--verbose":
verbose = true
}
}
if verbose {
fmt.Printf("📄 Reading requirements from: %s\n", reqFile)
}
// Load requirements
reqs, err := loadRequirements(reqFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading requirements: %v\n", err)
os.Exit(1)
}
if verbose {
fmt.Printf("📋 Loaded %d functional requirements and %d non-functional requirements\n",
len(reqs.FunctionalRequirements), len(reqs.NonFunctionalRequirements))
}
// Configure engine
config := protoagent.EngineConfig{
OutputDir: outputDir,
Workspace: workspace,
EnableOPA: enableOPA,
EnableAI: enableAI,
DryRun: dryRun,
Verbose: verbose,
}
engine := protoagent.NewEngine(config)
if verbose {
fmt.Println("🚀 Processing requirements...")
}
// Process requirements
ctx := context.Background()
artifacts, err := engine.ProcessRequirements(ctx, reqs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error processing requirements: %v\n", err)
os.Exit(1)
}
if dryRun {
fmt.Println("🔍 Dry run mode - no files written")
printArtifactsSummary(artifacts)
return
}
// Save artifacts
if err := saveArtifacts(artifacts, outputDir, verbose); err != nil {
fmt.Fprintf(os.Stderr, "Error saving artifacts: %v\n", err)
os.Exit(1)
}
if verbose {
printArtifactsSummary(artifacts)
}
fmt.Println("\n✅ Artifacts generated successfully!")
}
func runValidate(args []string) {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "Error: Requirements file is required")
fmt.Fprintln(os.Stderr, "Usage: protoagent-cli validate <requirements.json|yaml>")
os.Exit(1)
}
reqFile := args[0]
// Load requirements
reqs, err := loadRequirements(reqFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading requirements: %v\n", err)
os.Exit(1)
}
// Create a minimal engine for validation
config := protoagent.EngineConfig{
DryRun: true,
Verbose: true,
}
engine := protoagent.NewEngine(config)
ctx := context.Background()
_, err = engine.ProcessRequirements(ctx, reqs)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Validation failed: %v\n", err)
os.Exit(1)
}
fmt.Println("✅ Requirements validation passed!")
}
func loadRequirements(filename string) (*protoagent.RequirementsDocument, error) {
data, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
var reqs protoagent.RequirementsDocument
// Try JSON first
if err := json.Unmarshal(data, &reqs); err == nil {
return &reqs, nil
}
// Try YAML if JSON fails
// Note: YAML support would require adding gopkg.in/yaml.v3 dependency
return nil, fmt.Errorf("failed to parse requirements file (JSON format expected)")
}
func saveArtifacts(artifacts *protoagent.GeneratedArtifacts, outputDir string, verbose bool) error {
// Create output directory
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
// Save AGENT.md
if artifacts.AgentConfig != nil {
agentJSON, _ := json.MarshalIndent(artifacts.AgentConfig, "", " ")
if err := os.WriteFile(filepath.Join(outputDir, "AGENT.json"), agentJSON, 0644); err != nil {
return fmt.Errorf("failed to save AGENT.json: %w", err)
}
agentMD := fmt.Sprintf("# %s Agent\n\n%s\n", artifacts.AgentConfig.Name, artifacts.AgentConfig.Body)
if err := os.WriteFile(filepath.Join(outputDir, "AGENT.md"), []byte(agentMD), 0644); err != nil {
return fmt.Errorf("failed to save AGENT.md: %w", err)
}
if verbose {
fmt.Println("📄 AGENT.json and AGENT.md saved")
}
}
// Save database schemas
for i, schema := range artifacts.DatabaseSchemas {
schemaJSON, _ := json.MarshalIndent(schema, "", " ")
filename := filepath.Join(outputDir, fmt.Sprintf("schema_%d_%s.json", i, sanitizeName(schema.Name)))
if err := os.WriteFile(filename, schemaJSON, 0644); err != nil {
return fmt.Errorf("failed to save schema: %w", err)
}
// Generate SQL DDL for SQL schemas
if schema.Type == "sql" && len(schema.Tables) > 0 {
sqlDDL := generateSQLDDL(schema)
sqlFilename := filepath.Join(outputDir, fmt.Sprintf("schema_%d_%s.sql", i, sanitizeName(schema.Name)))
if err := os.WriteFile(sqlFilename, []byte(sqlDDL), 0644); err != nil {
return fmt.Errorf("failed to save SQL: %w", err)
}
if verbose {
fmt.Printf("📄 Schema %s saved (JSON + SQL)\n", schema.Name)
}
} else if verbose {
fmt.Printf("📄 Schema %s saved\n", schema.Name)
}
}
// Save OPA policies
for i, policy := range artifacts.Policies {
policyJSON, _ := json.MarshalIndent(policy, "", " ")
filename := filepath.Join(outputDir, fmt.Sprintf("policy_%d_%s.rego.json", i, sanitizeName(policy.Name)))
if err := os.WriteFile(filename, policyJSON, 0644); err != nil {
return fmt.Errorf("failed to save policy: %w", err)
}
// Save pure Rego code
regoFilename := filepath.Join(outputDir, fmt.Sprintf("policy_%d_%s.rego", i, sanitizeName(policy.Name)))
if err := os.WriteFile(regoFilename, []byte(policy.Rego), 0644); err != nil {
return fmt.Errorf("failed to save rego: %w", err)
}
if verbose {
fmt.Printf("📄 Policy %s saved (JSON + Rego)\n", policy.Name)
}
}
// Save interfaces
if len(artifacts.Interfaces) > 0 {
interfacesJSON, _ := json.MarshalIndent(artifacts.Interfaces, "", " ")
if err := os.WriteFile(filepath.Join(outputDir, "interfaces.json"), interfacesJSON, 0644); err != nil {
return fmt.Errorf("failed to save interfaces: %w", err)
}
if verbose {
fmt.Println("📄 interfaces.json saved")
}
}
// Save channels
if len(artifacts.Channels) > 0 {
channelsJSON, _ := json.MarshalIndent(artifacts.Channels, "", " ")
if err := os.WriteFile(filepath.Join(outputDir, "channels.json"), channelsJSON, 0644); err != nil {
return fmt.Errorf("failed to save channels: %w", err)
}
if verbose {
fmt.Println("📄 channels.json saved")
}
}
// Save skills
if len(artifacts.Skills) > 0 {
skillsJSON, _ := json.MarshalIndent(artifacts.Skills, "", " ")
if err := os.WriteFile(filepath.Join(outputDir, "skills.json"), skillsJSON, 0644); err != nil {
return fmt.Errorf("failed to save skills: %w", err)
}
// Save each skill's code
for i, skill := range artifacts.Skills {
skillFile := filepath.Join(outputDir, fmt.Sprintf("skill_%d_%s.go", i, sanitizeName(skill.Name)))
if err := os.WriteFile(skillFile, []byte(skill.Code), 0644); err != nil {
return fmt.Errorf("failed to save skill code: %w", err)
}
}
if verbose {
fmt.Println("📄 skills.json and skill codes saved")
}
}
// Save tools
if len(artifacts.Tools) > 0 {
toolsJSON, _ := json.MarshalIndent(artifacts.Tools, "", " ")
if err := os.WriteFile(filepath.Join(outputDir, "tools.json"), toolsJSON, 0644); err != nil {
return fmt.Errorf("failed to save tools: %w", err)
}
if verbose {
fmt.Println("📄 tools.json saved")
}
}
// Save MCP configuration
if artifacts.MCPConfig != nil && len(artifacts.MCPConfig.Servers) > 0 {
mcpJSON, _ := json.MarshalIndent(artifacts.MCPConfig, "", " ")
if err := os.WriteFile(filepath.Join(outputDir, "mcp_config.json"), mcpJSON, 0644); err != nil {
return fmt.Errorf("failed to save mcp_config: %w", err)
}
if verbose {
fmt.Println("📄 mcp_config.json saved")
}
}
// Save validation report
if artifacts.ValidationReport != nil {
reportJSON, _ := json.MarshalIndent(artifacts.ValidationReport, "", " ")
if err := os.WriteFile(filepath.Join(outputDir, "validation_report.json"), reportJSON, 0644); err != nil {
return fmt.Errorf("failed to save validation report: %w", err)
}
if verbose {
fmt.Println("📄 validation_report.json saved")
}
}
return nil
}
func printArtifactsSummary(artifacts *protoagent.GeneratedArtifacts) {
fmt.Println("\n📦 Generated Artifacts Summary:")
fmt.Println(strings.Repeat("=", 50))
if artifacts.AgentConfig != nil {
fmt.Printf("🤖 Agent: %s\n", artifacts.AgentConfig.Name)
fmt.Printf(" Description: %s\n", artifacts.AgentConfig.Description)
fmt.Printf(" Tools: %v\n", artifacts.AgentConfig.Tools)
fmt.Printf(" Skills: %v\n", artifacts.AgentConfig.Skills)
}
if len(artifacts.DatabaseSchemas) > 0 {
fmt.Printf("\n💾 Database Schemas: %d\n", len(artifacts.DatabaseSchemas))
for _, schema := range artifacts.DatabaseSchemas {
fmt.Printf(" - %s (%s)\n", schema.Name, schema.Type)
if len(schema.Tables) > 0 {
for _, table := range schema.Tables {
fmt.Printf(" Table: %s (%d columns)\n", table.Name, len(table.Columns))
}
}
}
}
if len(artifacts.Interfaces) > 0 {
fmt.Printf("\n🖥 Interfaces: %d\n", len(artifacts.Interfaces))
for _, iface := range artifacts.Interfaces {
fmt.Printf(" - %s (%s)\n", iface.Name, iface.Type)
if iface.Type == "api" && len(iface.Endpoints) > 0 {
fmt.Printf(" Endpoints: %d\n", len(iface.Endpoints))
}
if iface.Type == "web" && len(iface.Screens) > 0 {
fmt.Printf(" Screens: %d\n", len(iface.Screens))
}
}
}
if len(artifacts.Channels) > 0 {
fmt.Printf("\n📱 Communication Channels: %d\n", len(artifacts.Channels))
for _, channel := range artifacts.Channels {
fmt.Printf(" - %s (%s) - Enabled: %v\n", channel.Name, channel.Type, channel.Enabled)
}
}
if len(artifacts.Policies) > 0 {
fmt.Printf("\n🔐 OPA Policies: %d\n", len(artifacts.Policies))
for _, policy := range artifacts.Policies {
fmt.Printf(" - %s (%s)\n", policy.Name, policy.Package)
}
}
if len(artifacts.Skills) > 0 {
fmt.Printf("\n🎯 Skills: %d\n", len(artifacts.Skills))
for _, skill := range artifacts.Skills {
fmt.Printf(" - %s\n", skill.Name)
}
}
if len(artifacts.Tools) > 0 {
fmt.Printf("\n🔧 Tools: %d\n", len(artifacts.Tools))
for _, tool := range artifacts.Tools {
fmt.Printf(" - %s (%s)\n", tool.Name, tool.Type)
}
}
if artifacts.MCPConfig != nil && len(artifacts.MCPConfig.Servers) > 0 {
fmt.Printf("\n🔌 MCP Servers: %d\n", len(artifacts.MCPConfig.Servers))
for _, server := range artifacts.MCPConfig.Servers {
fmt.Printf(" - %s (%s)\n", server.Name, server.Type)
}
}
if artifacts.ValidationReport != nil {
fmt.Printf("\n✅ Validation: %v\n", artifacts.ValidationReport.Valid)
if len(artifacts.ValidationReport.Errors) > 0 {
fmt.Printf(" ❌ Errors: %d\n", len(artifacts.ValidationReport.Errors))
}
if len(artifacts.ValidationReport.Warnings) > 0 {
fmt.Printf(" ⚠️ Warnings: %d\n", len(artifacts.ValidationReport.Warnings))
}
if len(artifacts.ValidationReport.Suggestions) > 0 {
fmt.Printf(" 💡 Suggestions: %d\n", len(artifacts.ValidationReport.Suggestions))
}
}
}
func generateSQLDDL(schema protoagent.DatabaseSchema) string {
var ddl strings.Builder
ddl.WriteString(fmt.Sprintf("-- Schema: %s\n", schema.Name))
ddl.WriteString(fmt.Sprintf("-- Type: %s\n\n", schema.Type))
for _, table := range schema.Tables {
ddl.WriteString(fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (\n", table.Name))
columns := make([]string, 0, len(table.Columns))
for _, col := range table.Columns {
colDef := fmt.Sprintf(" %s %s", col.Name, col.Type)
if col.PrimaryKey {
colDef += " PRIMARY KEY"
}
if !col.Nullable {
colDef += " NOT NULL"
}
if col.Unique {
colDef += " UNIQUE"
}
if col.Default != "" {
colDef += fmt.Sprintf(" DEFAULT %s", col.Default)
}
columns = append(columns, colDef)
}
ddl.WriteString(strings.Join(columns, ",\n"))
ddl.WriteString("\n);\n\n")
// Create indexes
for _, idx := range table.Indexes {
ddl.WriteString(fmt.Sprintf("CREATE INDEX ON %s (%s);\n", table.Name, idx))
}
}
return ddl.String()
}
func sanitizeName(name string) string {
// Replace invalid filename characters with underscores
result := strings.ReplaceAll(name, " ", "_")
result = strings.ReplaceAll(result, "-", "_")
result = strings.ToLower(result)
return result
}
var _ = time.Now // Avoid unused import error

145
picoclaw/go.mod Normal file
View file

@ -0,0 +1,145 @@
module github.com/sipeed/picoclaw
go 1.19
require (
fyne.io/systray v1.12.0
github.com/BurntSushi/toml v1.6.0
github.com/SevereCloud/vksdk/v3 v3.3.1
github.com/adhocore/gronx v1.19.6
github.com/anthropics/anthropic-sdk-go v1.26.0
github.com/atc0005/go-teams-notify/v2 v2.14.0
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/config v1.32.14
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4
github.com/bwmarrin/discordgo v0.29.0
github.com/caarlos0/env/v11 v11.4.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/creack/pty v1.1.24
github.com/ergochat/irc-go v0.6.0
github.com/ergochat/readline v0.1.3
github.com/gdamore/tcell/v2 v2.13.8
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/h2non/filetype v1.1.3
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/mdp/qrterminal/v3 v3.2.1
github.com/minio/selfupdate v0.6.0
github.com/modelcontextprotocol/go-sdk v1.5.0
github.com/muesli/termenv v0.16.0
github.com/mymmrac/telego v1.8.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/openai/openai-go/v3 v3.22.0
github.com/pion/rtp v1.10.1
github.com/pion/webrtc/v3 v3.3.6
github.com/rivo/tview v0.42.0
github.com/rs/zerolog v1.35.0
github.com/slack-go/slack v0.17.3
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
github.com/tencent-connect/botgo v0.2.1
go.mau.fi/util v0.9.7
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
golang.org/x/oauth2 v0.36.0
golang.org/x/term v0.41.0
golang.org/x/time v0.15.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.26.4
modernc.org/sqlite v1.48.2
rsc.io/qr v0.2.0
)
require (
aead.dev/minisign v0.2.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beeper/argo-go v1.1.2 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.mau.fi/libsignal v0.2.1 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/text v0.35.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/github/copilot-sdk/go v0.2.0
github.com/go-resty/resty/v2 v2.17.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/grbit/go-json v0.11.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.69.0 // indirect
github.com/valyala/fastjson v1.6.10 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/arch v0.24.0 // indirect
golang.org/x/crypto v0.49.0
golang.org/x/net v0.52.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.42.0
)
replace github.com/bwmarrin/discordgo => github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532

487
picoclaw/go.sum Normal file
View file

@ -0,0 +1,487 @@
aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk=
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/SevereCloud/vksdk/v3 v3.3.1 h1:O86zsp5LQnHE+O5acvuXM/s6S1LyxzVTkF6+Lup0Jyg=
github.com/SevereCloud/vksdk/v3 v3.3.1/go.mod h1:c6WaA5aocUYsXfkcUbg2qy45V9M1VDcqHHmHIN14NAw=
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
github.com/atc0005/go-teams-notify/v2 v2.14.0 h1:7N+xw+COnYANLREaAveQ65rsNQ12nIZJED9nMLyscCo=
github.com/atc0005/go-teams-notify/v2 v2.14.0/go.mod h1:EECsWM2b0Hvoz7O+QdlsvyN2KCUOFQCGj8bUBXv3A3Q=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 h1:W6tKfa/s37faUnwJ71pGqsBO7/wfUX1L7tVprupQGo4=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4/go.mod h1:BZ+9thH0QOTDUwE8KAv/ZwUzsNC7CSMJXj/wtnZMs5k=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc=
github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
github.com/ergochat/irc-go v0.6.0 h1:Y0AGV76aeihJfCtLaQh+OyJKFiKGrYC0VTkeMZ6XW28=
github.com/ergochat/irc-go v0.6.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
github.com/ergochat/readline v0.1.3 h1:/DytGTmwdUJcLAe3k3VJgowh5vNnsdifYT6uVaf4pSo=
github.com/ergochat/readline v0.1.3/go.mod h1:o3ux9QLHLm77bq7hDB21UTm6HlV2++IPDMfIfKDuOgY=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
github.com/github/copilot-sdk/go v0.2.0 h1:RnrIIirmtp4wGgqSQFJ2k9phbeveIxOtYZqDogoNEa0=
github.com/github/copilot-sdk/go v0.2.0/go.mod h1:uGWkjVYcp2DV9DgtqYihh5tEoJjNqxIFaUNnrwY4FxM=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg=
github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/mymmrac/telego v1.8.0 h1:EvIprWo9Cn0MHgumvvqNXPAXO1yJj3pu2cdCCeDxbow=
github.com/mymmrac/telego v1.8.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 h1:rh2lKw/P/EqHa724vYH2+VVQ1YnW4u6EOXl0PMAovZE=
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA=
github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
github.com/pion/webrtc/v3 v3.3.6 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE=
github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tencent-connect/botgo v0.2.1 h1:+BrTt9Zh+awL28GWC4g5Na3nQaGRWb0N5IctS8WqBCk=
github.com/tencent-connect/botgo v0.2.1/go.mod h1:oO1sG9ybhXNickvt+CVym5khwQ+uKhTR+IhTqEfOVsI=
github.com/tidwall/gjson v1.9.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532 h1:gxFHYeUDGziRb0zXYEqBFohC+NJbIW9L0tddaXMWr2o=
github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532/go.mod h1:A0FcMFJKJ9fRjgSuZ2o+pIQ6mPS81SVuiLN2vYTa7Ao=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
go.mau.fi/util v0.9.7 h1:AWGNbJfz1zRcQOKeOEYhKUG2fT+/26Gy6kyqcH8tnBg=
go.mau.fi/util v0.9.7/go.mod h1:5T2f3ZWZFAGgmFwg3dGw7YK6kIsb9lryDzvynoR98pE=
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ=
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.26.4 h1:enHSnkf0L2V9+VnfJfNhKSReSW6pBKS/x3Su+v+Vovs=
maunium.net/go/mautrix v0.26.4/go.mod h1:YWw8NWTszsbyFAznboicBObwHPgTSLcuTbVX2kY7U2M=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=

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