mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-08-04 01:19:36 +00:00
Merge branch 'main' into telegram-bot-commands
# Conflicts: # pkg/channels/telegram.go
This commit is contained in:
@@ -16,10 +16,5 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: fmt
|
|
||||||
run: |
|
|
||||||
make fmt
|
|
||||||
git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1)
|
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: make build-all
|
run: make build-all
|
||||||
|
|||||||
@@ -24,25 +24,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: v2.10.1
|
version: v2.10.1
|
||||||
|
|
||||||
# TODO: Remove once linter is properly configured
|
|
||||||
vet:
|
|
||||||
name: Vet
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Setup Go
|
|
||||||
uses: actions/setup-go@v6
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
|
|
||||||
- name: Run go generate
|
|
||||||
run: go generate ./...
|
|
||||||
|
|
||||||
- name: Run go vet
|
|
||||||
run: go vet ./...
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Tests
|
name: Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
+4
-1
@@ -10,7 +10,7 @@ build/
|
|||||||
*.out
|
*.out
|
||||||
/picoclaw
|
/picoclaw
|
||||||
/picoclaw-test
|
/picoclaw-test
|
||||||
cmd/picoclaw/workspace
|
cmd/**/workspace
|
||||||
|
|
||||||
# Picoclaw specific
|
# Picoclaw specific
|
||||||
|
|
||||||
@@ -44,3 +44,6 @@ tasks/
|
|||||||
|
|
||||||
# Added by goreleaser init:
|
# Added by goreleaser init:
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# Windows Application Icon/Resource
|
||||||
|
*.syso
|
||||||
|
|||||||
+3
-11
@@ -28,9 +28,7 @@ linters:
|
|||||||
- wsl_v5
|
- wsl_v5
|
||||||
|
|
||||||
# TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step)
|
# TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step)
|
||||||
- bodyclose
|
|
||||||
- contextcheck
|
- contextcheck
|
||||||
- dogsled
|
|
||||||
- embeddedstructfieldcheck
|
- embeddedstructfieldcheck
|
||||||
- errcheck
|
- errcheck
|
||||||
- errchkjson
|
- errchkjson
|
||||||
@@ -45,33 +43,24 @@ linters:
|
|||||||
- gocritic
|
- gocritic
|
||||||
- gocyclo
|
- gocyclo
|
||||||
- godox
|
- godox
|
||||||
- goprintffuncname
|
|
||||||
- gosec
|
- gosec
|
||||||
- govet
|
|
||||||
- ineffassign
|
- ineffassign
|
||||||
- lll
|
- lll
|
||||||
- maintidx
|
- maintidx
|
||||||
- misspell
|
|
||||||
- mnd
|
- mnd
|
||||||
- modernize
|
- modernize
|
||||||
- nakedret
|
|
||||||
- nestif
|
- nestif
|
||||||
- nilnil
|
- nilnil
|
||||||
- paralleltest
|
- paralleltest
|
||||||
- perfsprint
|
- perfsprint
|
||||||
- prealloc
|
|
||||||
- predeclared
|
|
||||||
- revive
|
- revive
|
||||||
- staticcheck
|
- staticcheck
|
||||||
- tagalign
|
- tagalign
|
||||||
- testifylint
|
- testifylint
|
||||||
- thelper
|
- thelper
|
||||||
- unparam
|
- unparam
|
||||||
- unused
|
|
||||||
- usestdlibvars
|
- usestdlibvars
|
||||||
- usetesting
|
- usetesting
|
||||||
- wastedassign
|
|
||||||
- whitespace
|
|
||||||
settings:
|
settings:
|
||||||
errcheck:
|
errcheck:
|
||||||
check-type-assertions: true
|
check-type-assertions: true
|
||||||
@@ -153,6 +142,9 @@ linters:
|
|||||||
- gocognit
|
- gocognit
|
||||||
- gocyclo
|
- gocyclo
|
||||||
path: _test\.go$
|
path: _test\.go$
|
||||||
|
- linters:
|
||||||
|
- nolintlint
|
||||||
|
path: 'pkg/tools/(i2c\.go|spi\.go)$'
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
|
|||||||
+91
-8
@@ -5,7 +5,9 @@ version: 2
|
|||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
- go generate ./cmd/picoclaw
|
- go generate ./...
|
||||||
|
- go install github.com/tc-hib/go-winres@latest
|
||||||
|
- go-winres make --in cmd/picoclaw-launcher/winres/winres.json --out cmd/picoclaw-launcher/rsrc --product-version={{ .Version }} --file-version={{ .Version }}
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
@@ -15,10 +17,10 @@ builds:
|
|||||||
- stdjson
|
- stdjson
|
||||||
ldflags:
|
ldflags:
|
||||||
- -s -w
|
- -s -w
|
||||||
- -X main.version={{ .Version }}
|
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.version={{ .Version }}
|
||||||
- -X main.gitCommit={{ .ShortCommit }}
|
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.gitCommit={{ .ShortCommit }}
|
||||||
- -X main.buildTime={{ .Date }}
|
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.buildTime={{ .Date }}
|
||||||
- -X main.goVersion={{ .Env.GOVERSION }}
|
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.goVersion={{ .Env.GOVERSION }}
|
||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
- windows
|
- windows
|
||||||
@@ -28,17 +30,75 @@ builds:
|
|||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
- riscv64
|
- riscv64
|
||||||
- s390x
|
- loong64
|
||||||
- mips64
|
|
||||||
- arm
|
- arm
|
||||||
|
goarm:
|
||||||
|
- "6"
|
||||||
|
- "7"
|
||||||
main: ./cmd/picoclaw
|
main: ./cmd/picoclaw
|
||||||
ignore:
|
ignore:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: arm
|
goarch: arm
|
||||||
|
|
||||||
|
- id: picoclaw-launcher
|
||||||
|
binary: picoclaw-launcher
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
tags:
|
||||||
|
- stdjson
|
||||||
|
ldflags:
|
||||||
|
- -s -w
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
- windows
|
||||||
|
- darwin
|
||||||
|
- freebsd
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
- riscv64
|
||||||
|
- loong64
|
||||||
|
- arm
|
||||||
|
goarm:
|
||||||
|
- "6"
|
||||||
|
- "7"
|
||||||
|
main: ./cmd/picoclaw-launcher
|
||||||
|
ignore:
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm
|
||||||
|
|
||||||
|
- id: picoclaw-launcher-tui
|
||||||
|
binary: picoclaw-launcher-tui
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
tags:
|
||||||
|
- stdjson
|
||||||
|
ldflags:
|
||||||
|
- -s -w
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
- windows
|
||||||
|
- darwin
|
||||||
|
- freebsd
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
- riscv64
|
||||||
|
- loong64
|
||||||
|
- arm
|
||||||
|
goarm:
|
||||||
|
- "6"
|
||||||
|
- "7"
|
||||||
|
main: ./cmd/picoclaw-launcher-tui
|
||||||
|
ignore:
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm
|
||||||
|
|
||||||
dockers_v2:
|
dockers_v2:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
dockerfile: Dockerfile.goreleaser
|
dockerfile: docker/Dockerfile.goreleaser
|
||||||
|
extra_files:
|
||||||
|
- docker/entrypoint.sh
|
||||||
ids:
|
ids:
|
||||||
- picoclaw
|
- picoclaw
|
||||||
images:
|
images:
|
||||||
@@ -67,6 +127,29 @@ archives:
|
|||||||
- goos: windows
|
- goos: windows
|
||||||
formats: [zip]
|
formats: [zip]
|
||||||
|
|
||||||
|
nfpms:
|
||||||
|
- id: picoclaw
|
||||||
|
builds:
|
||||||
|
- picoclaw
|
||||||
|
- picoclaw-launcher
|
||||||
|
- picoclaw-launcher-tui
|
||||||
|
package_name: picoclaw
|
||||||
|
file_name_template: >-
|
||||||
|
{{ .PackageName }}_
|
||||||
|
{{- if eq .Arch "amd64" }}x86_64
|
||||||
|
{{- else if eq .Arch "arm64" }}aarch64
|
||||||
|
{{- else if eq .Arch "arm" }}armv{{ .Arm }}
|
||||||
|
{{- else }}{{ .Arch }}{{ end }}
|
||||||
|
vendor: picoclaw
|
||||||
|
homepage: https://github.com/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw
|
||||||
|
maintainer: picoclaw contributors
|
||||||
|
description: picoclaw - a tool for managing and running tasks
|
||||||
|
license: MIT
|
||||||
|
formats:
|
||||||
|
- rpm
|
||||||
|
- deb
|
||||||
|
bindir: /usr/bin
|
||||||
|
|
||||||
changelog:
|
changelog:
|
||||||
sort: asc
|
sort: asc
|
||||||
filters:
|
filters:
|
||||||
|
|||||||
+302
@@ -0,0 +1,302 @@
|
|||||||
|
# Contributing to PicoClaw
|
||||||
|
|
||||||
|
Thank you for your interest in contributing to PicoClaw! This project is a community-driven effort to build the lightweight and versatile personal AI assistant. We welcome contributions of all kinds: bug fixes, features, documentation, translations, and testing.
|
||||||
|
|
||||||
|
PicoClaw itself was substantially developed with AI assistance — we embrace this approach and have built our contribution process around it.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Code of Conduct](#code-of-conduct)
|
||||||
|
- [Ways to Contribute](#ways-to-contribute)
|
||||||
|
- [Getting Started](#getting-started)
|
||||||
|
- [Development Setup](#development-setup)
|
||||||
|
- [Making Changes](#making-changes)
|
||||||
|
- [AI-Assisted Contributions](#ai-assisted-contributions)
|
||||||
|
- [Pull Request Process](#pull-request-process)
|
||||||
|
- [Branch Strategy](#branch-strategy)
|
||||||
|
- [Code Review](#code-review)
|
||||||
|
- [Communication](#communication)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
|
||||||
|
We are committed to maintaining a welcoming and respectful community. Be kind, constructive, and assume good faith. Harassment or discrimination of any kind will not be tolerated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ways to Contribute
|
||||||
|
|
||||||
|
- **Bug reports** — Open an issue using the bug report template.
|
||||||
|
- **Feature requests** — Open an issue using the feature request template; discuss before implementing.
|
||||||
|
- **Code** — Fix bugs or implement features. See the workflow below.
|
||||||
|
- **Documentation** — Improve READMEs, docs, inline comments, or translations.
|
||||||
|
- **Testing** — Run PicoClaw on new hardware, channels, or LLM providers and report your results.
|
||||||
|
|
||||||
|
For substantial new features, please open an issue first to discuss the design before writing code. This prevents wasted effort and ensures alignment with the project's direction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. **Fork** the repository on GitHub.
|
||||||
|
2. **Clone** your fork locally:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/<your-username>/picoclaw.git
|
||||||
|
cd picoclaw
|
||||||
|
```
|
||||||
|
3. Add the upstream remote:
|
||||||
|
```bash
|
||||||
|
git remote add upstream https://github.com/sipeed/picoclaw.git
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Go 1.25 or later
|
||||||
|
- `make`
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build # Build binary (runs go generate first)
|
||||||
|
make generate # Run go generate only
|
||||||
|
make check # Full pre-commit check: deps + fmt + vet + test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test # Run all tests
|
||||||
|
go test -run TestName -v ./pkg/session/ # Run a single test
|
||||||
|
go test -bench=. -benchmem -run='^$' ./... # Run benchmarks
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make fmt # Format code
|
||||||
|
make vet # Static analysis
|
||||||
|
make lint # Full linter run
|
||||||
|
```
|
||||||
|
|
||||||
|
All CI checks must pass before a PR can be merged. Run `make check` locally before pushing to catch issues early.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Making Changes
|
||||||
|
|
||||||
|
### Branching
|
||||||
|
|
||||||
|
Always branch off `main` and target `main` in your PR. Never push directly to `main` or any `release/*` branch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout main
|
||||||
|
git pull upstream main
|
||||||
|
git checkout -b your-feature-branch
|
||||||
|
```
|
||||||
|
|
||||||
|
Use descriptive branch names, e.g. `fix/telegram-timeout`, `feat/ollama-provider`, `docs/contributing-guide`.
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
|
||||||
|
- Write clear, concise commit messages in English.
|
||||||
|
- Use the imperative mood: "Add retry logic" not "Added retry logic".
|
||||||
|
- Reference the related issue when relevant: `Fix session leak (#123)`.
|
||||||
|
- Keep commits focused. One logical change per commit is preferred.
|
||||||
|
- For minor cleanups or typo fixes, squash them into a single commit before opening a PR.
|
||||||
|
- Refer to https://www.conventionalcommits.org/zh-hans/v1.0.0/
|
||||||
|
|
||||||
|
### Keeping Up to Date
|
||||||
|
|
||||||
|
Rebase your branch onto upstream `main` before opening a PR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch upstream
|
||||||
|
git rebase upstream/main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AI-Assisted Contributions
|
||||||
|
|
||||||
|
PicoClaw was built with substantial AI assistance, and we fully embrace AI-assisted development. However, contributors must understand their responsibilities when using AI tools.
|
||||||
|
|
||||||
|
### Disclosure Is Required
|
||||||
|
|
||||||
|
Every PR must disclose AI involvement using the PR template's **🤖 AI Code Generation** section. There are three levels:
|
||||||
|
|
||||||
|
| Level | Description |
|
||||||
|
|---|---|
|
||||||
|
| 🤖 Fully AI-generated | AI wrote the code; contributor reviewed and validated it |
|
||||||
|
| 🛠️ Mostly AI-generated | AI produced the draft; contributor made significant modifications |
|
||||||
|
| 👨💻 Mostly Human-written | Contributor led; AI provided suggestions or none at all |
|
||||||
|
|
||||||
|
Honest disclosure is expected. There is no stigma attached to any level — what matters is the quality of the contribution.
|
||||||
|
|
||||||
|
### You Are Responsible for What You Submit
|
||||||
|
|
||||||
|
Using AI to generate code does not reduce your responsibility as the contributor. Before opening a PR with AI-generated code, you must:
|
||||||
|
|
||||||
|
- **Read and understand** every line of the generated code.
|
||||||
|
- **Test it** in a real environment (see the Test Environment section of the PR template).
|
||||||
|
- **Check for security issues** — AI models can generate subtly insecure code (e.g., path traversal, injection, credential exposure). Review carefully.
|
||||||
|
- **Verify correctness** — AI-generated logic can be plausible-sounding but wrong. Validate the behavior, not just the syntax.
|
||||||
|
|
||||||
|
PRs where it is clear the contributor has not read or tested the AI-generated code will be closed without review.
|
||||||
|
|
||||||
|
### AI-Generated Code Quality Standards
|
||||||
|
|
||||||
|
AI-generated contributions are held to the **same quality bar** as human-written code:
|
||||||
|
|
||||||
|
- It must pass all CI checks (`make check`).
|
||||||
|
- It must be idiomatic Go and consistent with the existing codebase style.
|
||||||
|
- It must not introduce unnecessary abstractions, dead code, or over-engineering.
|
||||||
|
- It must include or update tests where appropriate.
|
||||||
|
|
||||||
|
### Security Review
|
||||||
|
|
||||||
|
AI-generated code requires extra security scrutiny. Pay special attention to:
|
||||||
|
|
||||||
|
- File path handling and sandbox escapes (see commit `244eb0b` for a real example)
|
||||||
|
- External input validation in channel handlers and tool implementations
|
||||||
|
- Credential or secret handling
|
||||||
|
- Command execution (`exec.Command`, shell invocations)
|
||||||
|
|
||||||
|
If you are unsure whether a piece of AI-generated code is safe, say so in the PR — reviewers will help.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pull Request Process
|
||||||
|
|
||||||
|
### Before Opening a PR
|
||||||
|
|
||||||
|
- [ ] Run `make check` and ensure it passes locally.
|
||||||
|
- [ ] Fill in the PR template completely, including the AI disclosure section.
|
||||||
|
- [ ] Link any related issue(s) in the PR description.
|
||||||
|
- [ ] Keep the PR focused. Avoid bundling unrelated changes together.
|
||||||
|
|
||||||
|
### PR Template Sections
|
||||||
|
|
||||||
|
The PR template asks for:
|
||||||
|
|
||||||
|
- **Description** — What does this change do and why?
|
||||||
|
- **Type of Change** — Bug fix, feature, docs, or refactor.
|
||||||
|
- **AI Code Generation** — Disclosure of AI involvement (required).
|
||||||
|
- **Related Issue** — Link to the issue this addresses.
|
||||||
|
- **Technical Context** — Reference URLs and reasoning (skip for pure docs PRs).
|
||||||
|
- **Test Environment** — Hardware, OS, model/provider, and channels used for testing.
|
||||||
|
- **Evidence** — Optional logs or screenshots demonstrating the change works.
|
||||||
|
- **Checklist** — Self-review confirmation.
|
||||||
|
|
||||||
|
### PR Size
|
||||||
|
|
||||||
|
Prefer small, reviewable PRs. A PR that changes 200 lines across 5 files is much easier to review than one that changes 2000 lines across 30 files. If your feature is large, consider splitting it into a series of smaller, logically complete PRs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branch Strategy
|
||||||
|
|
||||||
|
### Long-Lived Branches
|
||||||
|
|
||||||
|
- **`main`** — the active development branch. All feature PRs target `main`. The branch is protected: direct pushes are not permitted, and at least one maintainer approval is required before merging.
|
||||||
|
- **`release/x.y`** — stable release branches, cut from `main` when a version is ready to ship. These branches are more strictly protected than `main`.
|
||||||
|
|
||||||
|
### Requirements to Merge into `main`
|
||||||
|
|
||||||
|
A PR can only be merged when all of the following are satisfied:
|
||||||
|
|
||||||
|
1. **CI passes** — All GitHub Actions workflows (lint, test, build) must be green.
|
||||||
|
2. **Reviewer approval** — At least one maintainer has approved the PR.
|
||||||
|
3. **No unresolved review comments** — All review threads must be resolved.
|
||||||
|
4. **PR template is complete** — Including AI disclosure and test environment.
|
||||||
|
|
||||||
|
### Who Can Merge
|
||||||
|
|
||||||
|
Only maintainers can merge PRs. Contributors cannot merge their own PRs, even if they have write access.
|
||||||
|
|
||||||
|
### Merge Strategy
|
||||||
|
|
||||||
|
We use **squash merge** for most PRs to keep the `main` history clean and readable. Each merged PR becomes a single commit referencing the PR number, e.g.:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: Add Ollama provider support (#491)
|
||||||
|
```
|
||||||
|
|
||||||
|
If a PR consists of multiple independent, well-separated commits that tell a clear story, a regular merge may be used at the maintainer's discretion.
|
||||||
|
|
||||||
|
### Release Branches
|
||||||
|
|
||||||
|
When a version is ready, maintainers cut a `release/x.y` branch from `main`. After that point:
|
||||||
|
|
||||||
|
- **New features are not backported.** The release branch receives no new functionality after it is cut.
|
||||||
|
- **Security fixes and critical bug fixes are cherry-picked.** If a fix in `main` qualifies (security vulnerability, data loss, crash), maintainers will cherry-pick the relevant commit(s) onto the affected `release/x.y` branch and issue a patch release.
|
||||||
|
|
||||||
|
If you believe a fix in `main` should be backported to a release branch, note it in the PR description or open a separate issue. The decision rests with the maintainers.
|
||||||
|
|
||||||
|
Release branches have stricter protections than `main` and are never directly pushed to under any circumstances.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Review
|
||||||
|
|
||||||
|
### For Contributors
|
||||||
|
|
||||||
|
- Respond to review comments within a reasonable time. If you need more time, say so.
|
||||||
|
- When you update a PR in response to feedback, briefly note what changed (e.g., "Updated to use `sync.RWMutex` as suggested").
|
||||||
|
- If you disagree with feedback, engage respectfully. Explain your reasoning; reviewers can be wrong too.
|
||||||
|
- Do not force-push after a review has started — it makes it harder for reviewers to see what changed. Use additional commits instead; the maintainer will squash on merge.
|
||||||
|
|
||||||
|
### For Reviewers
|
||||||
|
|
||||||
|
Review for:
|
||||||
|
|
||||||
|
1. **Correctness** — Does the code do what it claims? Are there edge cases?
|
||||||
|
2. **Security** — Especially for AI-generated code, tool implementations, and channel handlers.
|
||||||
|
3. **Architecture** — Is the approach consistent with the existing design?
|
||||||
|
4. **Simplicity** — Is there a simpler solution? Does this add unnecessary complexity?
|
||||||
|
5. **Tests** — Are the changes covered by tests? Are existing tests still meaningful?
|
||||||
|
|
||||||
|
Be constructive and specific. "This could have a race condition if two goroutines call this concurrently — consider using a mutex here" is better than "this looks wrong".
|
||||||
|
|
||||||
|
|
||||||
|
### Reviewer List
|
||||||
|
Once your PR is submitted, you can reach out to the assigned reviewers listed in the following table.
|
||||||
|
|
||||||
|
|Function| Reviewer|
|
||||||
|
|--- |--- |
|
||||||
|
|Provider|@yinwm |
|
||||||
|
|Channel |@yinwm |
|
||||||
|
|Agent |@lxowalle|
|
||||||
|
|Tools |@lxowalle|
|
||||||
|
|SKill ||
|
||||||
|
|MCP ||
|
||||||
|
|Optimization|@lxowalle|
|
||||||
|
|Security||
|
||||||
|
|AI CI |@imguoguo|
|
||||||
|
|UX ||
|
||||||
|
|Document||
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Communication
|
||||||
|
|
||||||
|
- **GitHub Issues** — Bug reports, feature requests, design discussions.
|
||||||
|
- **GitHub Discussions** — General questions, ideas, community conversation.
|
||||||
|
- **Pull Request comments** — Code-specific feedback.
|
||||||
|
- **Wechat&Discord** — We will invite you when you have at least one merged PR
|
||||||
|
|
||||||
|
When in doubt, open an issue before writing code. It costs little and prevents wasted effort.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A Note on the Project's AI-Driven Origin
|
||||||
|
|
||||||
|
PicoClaw's architecture was substantially designed and implemented with AI assistance, guided by human oversight. If you find something that looks odd or over-engineered, it may be an artifact of that process — opening an issue to discuss it is always welcome.
|
||||||
|
|
||||||
|
We believe AI-assisted development done responsibly produces great results. We also believe humans must remain accountable for what they ship. These two beliefs are not in conflict.
|
||||||
|
|
||||||
|
Thank you for contributing!
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
# 参与贡献 PicoClaw
|
||||||
|
|
||||||
|
感谢你对 PicoClaw 的关注!本项目是一个社区驱动的开源项目,目标是构建 轻量灵活,人人可用 的个人AI助手。我们欢迎一切形式的贡献:Bug 修复、新功能、文档、翻译和测试。
|
||||||
|
|
||||||
|
PicoClaw 本身在很大程度上是借助 AI 辅助开发的——我们拥抱这种方式,并围绕它构建了贡献流程。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [行为准则](#行为准则)
|
||||||
|
- [贡献方式](#贡献方式)
|
||||||
|
- [快速开始](#快速开始)
|
||||||
|
- [开发环境配置](#开发环境配置)
|
||||||
|
- [提交修改](#提交修改)
|
||||||
|
- [AI 辅助贡献](#ai-辅助贡献)
|
||||||
|
- [Pull Request 流程](#pull-request-流程)
|
||||||
|
- [分支策略](#分支策略)
|
||||||
|
- [代码审查](#代码审查)
|
||||||
|
- [沟通渠道](#沟通渠道)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 行为准则
|
||||||
|
|
||||||
|
我们致力于维护一个友好、互相尊重的社区环境。请保持善意、建设性的态度,并善意地理解他人。任何形式的骚扰或歧视均不被接受。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 贡献方式
|
||||||
|
|
||||||
|
- **Bug 反馈** — 使用 Bug 报告模板提交 Issue。
|
||||||
|
- **功能建议** — 使用功能请求模板提交 Issue,建议在开始实现前先进行讨论。
|
||||||
|
- **代码贡献** — 修复 Bug 或实现新功能,参见下方工作流程。
|
||||||
|
- **文档改进** — 完善 README、文档、代码注释或翻译。
|
||||||
|
- **测试与验证** — 在新硬件、新渠道或新 LLM 提供商上运行 PicoClaw 并反馈结果。
|
||||||
|
|
||||||
|
对于较大的新功能,请先提交 Issue 讨论设计方案,再动手写代码。这能避免无效投入,也确保与项目方向保持一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
1. 在 GitHub 上 **Fork** 本仓库。
|
||||||
|
2. 将你的 Fork **克隆**到本地:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/<你的用户名>/picoclaw.git
|
||||||
|
cd picoclaw
|
||||||
|
```
|
||||||
|
3. 添加上游远程仓库:
|
||||||
|
```bash
|
||||||
|
git remote add upstream https://github.com/sipeed/picoclaw.git
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 开发环境配置
|
||||||
|
|
||||||
|
### 前置依赖
|
||||||
|
|
||||||
|
- Go 1.25 或更高版本
|
||||||
|
- `make`
|
||||||
|
|
||||||
|
### 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build # 构建二进制文件(会先执行 go generate)
|
||||||
|
make generate # 仅执行 go generate
|
||||||
|
make check # 完整的提交前检查:deps + fmt + vet + test
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test # 运行所有测试
|
||||||
|
go test -run TestName -v ./pkg/session/ # 运行单个测试
|
||||||
|
go test -bench=. -benchmem -run='^$' ./... # 运行基准测试
|
||||||
|
```
|
||||||
|
|
||||||
|
### 代码风格
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make fmt # 格式化代码
|
||||||
|
make vet # 静态分析
|
||||||
|
make lint # 完整的 lint 检查
|
||||||
|
```
|
||||||
|
|
||||||
|
所有 CI 检查通过后 PR 才能被合并。推送代码前请先在本地运行 `make check`,提前发现问题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 提交修改
|
||||||
|
|
||||||
|
### 分支管理
|
||||||
|
|
||||||
|
始终从 `main` 分支切出,并在 PR 中以 `main` 为目标分支。不要直接向 `main` 或任何 `release/*` 分支推送代码:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout main
|
||||||
|
git pull upstream main
|
||||||
|
git checkout -b 你的功能分支名
|
||||||
|
```
|
||||||
|
|
||||||
|
请使用描述性的分支名,例如:`fix/telegram-timeout`、`feat/ollama-provider`、`docs/contributing-guide`。
|
||||||
|
|
||||||
|
### Commit 规范
|
||||||
|
|
||||||
|
- 使用英文撰写清晰、简洁的 commit 信息。
|
||||||
|
- 使用祈使句:写 "Add retry logic",而不是 "Added retry logic"。
|
||||||
|
- 有关联 Issue 时请引用:`Fix session leak (#123)`。
|
||||||
|
- 保持 commit 专注,每个 commit 只做一件事。
|
||||||
|
- 对于小的清理或拼写修正,提 PR 前请将其合并为一个 commit。
|
||||||
|
- 按照 https://www.conventionalcommits.org/zh-hans/v1.0.0/ 规范来撰写
|
||||||
|
|
||||||
|
### 保持与上游同步
|
||||||
|
|
||||||
|
提 PR 前,请将你的分支变基到上游 `main`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch upstream
|
||||||
|
git rebase upstream/main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AI 辅助贡献
|
||||||
|
|
||||||
|
PicoClaw 在很大程度上借助 AI 辅助开发,我们完全拥抱这种开发方式。但贡献者必须清楚地了解自己在使用 AI 工具时所承担的责任。
|
||||||
|
|
||||||
|
### 必须披露 AI 使用情况
|
||||||
|
|
||||||
|
每个 PR 都必须通过 PR 模板中的 **🤖 AI 代码生成** 部分披露 AI 参与情况,共分三个级别:
|
||||||
|
|
||||||
|
| 级别 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 🤖 完全由 AI 生成 | AI 编写代码,贡献者负责审查和验证 |
|
||||||
|
| 🛠️ 主要由 AI 生成 | AI 起草,贡献者做了较大修改 |
|
||||||
|
| 👨💻 主要由人工编写 | 贡献者主导,AI 仅提供辅助或未使用 AI |
|
||||||
|
|
||||||
|
我们期望你诚实填写。三种级别均可接受,没有任何歧视——重要的是贡献的质量。
|
||||||
|
|
||||||
|
### 你对提交的代码负全责
|
||||||
|
|
||||||
|
使用 AI 生成代码并不能减轻你作为贡献者的责任。在提交含有 AI 生成代码的 PR 之前,你必须:
|
||||||
|
|
||||||
|
- **逐行阅读并理解**生成的代码。
|
||||||
|
- **在真实环境中测试**(参见 PR 模板中的测试环境部分)。
|
||||||
|
- **检查安全问题** — AI 模型可能生成存在安全隐患的代码(如路径穿越、注入攻击、凭据泄露等),请仔细审查。
|
||||||
|
- **验证正确性** — AI 生成的逻辑可能听起来合理但实际上是错误的,请验证行为,而不仅仅是语法。
|
||||||
|
|
||||||
|
如果明显可以看出贡献者没有阅读或测试 AI 生成的代码,该 PR 将被直接关闭,不予审查。
|
||||||
|
|
||||||
|
### AI 生成代码的质量标准
|
||||||
|
|
||||||
|
AI 生成的代码与人工编写的代码遵循**相同的质量要求**:
|
||||||
|
|
||||||
|
- 必须通过所有 CI 检查(`make check`)。
|
||||||
|
- 必须符合 Go 惯用写法,并与现有代码库的风格保持一致。
|
||||||
|
- 不得引入不必要的抽象、死代码或过度设计。
|
||||||
|
- 须在适当的地方包含或更新测试。
|
||||||
|
|
||||||
|
### 安全审查
|
||||||
|
|
||||||
|
AI 生成的代码需要格外仔细的安全审查。请特别关注以下方面:
|
||||||
|
|
||||||
|
- 文件路径处理与沙箱逃逸(项目历史中的 commit `244eb0b` 就是真实案例)
|
||||||
|
- channel 处理器和 tool 实现中的外部输入校验
|
||||||
|
- 凭据或密钥的处理
|
||||||
|
- 命令执行(`exec.Command`、shell 调用等)
|
||||||
|
|
||||||
|
如果你不确定某段 AI 生成代码是否安全,请在 PR 中说明——审查者会帮助判断。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pull Request 流程
|
||||||
|
|
||||||
|
### 提 PR 前的检查
|
||||||
|
|
||||||
|
- [ ] 在本地运行 `make check` 并确认通过。
|
||||||
|
- [ ] 完整填写 PR 模板,包括 AI 披露部分。
|
||||||
|
- [ ] 在 PR 描述中关联相关 Issue。
|
||||||
|
- [ ] 保持 PR 专注,避免将不相关的修改混在一起。
|
||||||
|
|
||||||
|
### PR 模板各部分说明
|
||||||
|
|
||||||
|
PR 模板要求填写:
|
||||||
|
|
||||||
|
- **描述** — 这个改动做了什么,为什么要做?
|
||||||
|
- **变更类型** — Bug 修复、新功能、文档或重构。
|
||||||
|
- **AI 代码生成** — AI 参与情况披露(必填)。
|
||||||
|
- **关联 Issue** — 此 PR 解决的 Issue 链接。
|
||||||
|
- **技术背景** — 参考链接和设计理由(纯文档类 PR 可跳过)。
|
||||||
|
- **测试环境** — 用于测试的硬件、操作系统、模型/提供商和渠道。
|
||||||
|
- **验证证据** — 可选的日志或截图,用于证明改动有效。
|
||||||
|
- **检查清单** — 自我审查确认。
|
||||||
|
|
||||||
|
### PR 规模
|
||||||
|
|
||||||
|
请尽量提交小而易于审查的 PR。一个涉及 5 个文件共 200 行改动的 PR,远比涉及 30 个文件共 2000 行改动的 PR 容易审查。如果你的功能较大,可以考虑将其拆分为一系列逻辑完整的小 PR。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 分支策略
|
||||||
|
|
||||||
|
### 长期分支
|
||||||
|
|
||||||
|
- **`main`** — 活跃开发分支。所有功能 PR 均以 `main` 为目标。该分支受保护:禁止直接推送,合并前必须获得至少一名维护者的批准。
|
||||||
|
- **`release/x.y`** — 稳定发布分支,在某个版本准备发布时从 `main` 切出。这些分支的保护级别高于 `main`。
|
||||||
|
|
||||||
|
### 合并到 `main` 的前提条件
|
||||||
|
|
||||||
|
PR 必须同时满足以下所有条件,才能被合并:
|
||||||
|
|
||||||
|
1. **CI 全部通过** — 所有 GitHub Actions 工作流(lint、test、build)均为绿色。
|
||||||
|
2. **获得审查者批准** — 至少一名维护者已批准该 PR。
|
||||||
|
3. **无未解决的审查意见** — 所有审查讨论线程均已关闭。
|
||||||
|
4. **PR 模板填写完整** — 包括 AI 披露和测试环境信息。
|
||||||
|
|
||||||
|
### 谁可以合并
|
||||||
|
|
||||||
|
只有维护者才能合并 PR。贡献者不能合并自己的 PR,即使拥有写权限也不行。
|
||||||
|
|
||||||
|
### 合并策略
|
||||||
|
|
||||||
|
为保持 `main` 历史清晰可读,我们对大多数 PR 使用 **Squash Merge**。每个合并的 PR 变为一个包含 PR 编号的单独 commit,例如:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: Add Ollama provider support (#491)
|
||||||
|
```
|
||||||
|
|
||||||
|
如果一个 PR 包含多个独立、结构清晰、能讲述完整故事的 commit,维护者可视情况使用普通 merge。
|
||||||
|
|
||||||
|
### Release 分支
|
||||||
|
|
||||||
|
当某个版本准备就绪时,维护者会从 `main` 切出 `release/x.y` 分支。此后:
|
||||||
|
|
||||||
|
- **新功能不会被回溯(backport)。** Release 分支切出后,不再接收任何新功能。
|
||||||
|
- **安全修复和关键 Bug 修复会被 cherry-pick 进来。** 若 `main` 上的某个修复属于安全漏洞、数据丢失或崩溃类问题,维护者会将相关 commit cherry-pick 到受影响的 `release/x.y` 分支,并发布补丁版本。
|
||||||
|
|
||||||
|
如果你认为 `main` 上的某个修复应该被回溯到某个 release 分支,请在 PR 描述中注明,或单独开一个 Issue 说明。最终决定由维护者做出。
|
||||||
|
|
||||||
|
Release 分支的保护级别高于 `main`,在任何情况下均不允许直接推送。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 代码审查
|
||||||
|
|
||||||
|
### 对贡献者的建议
|
||||||
|
|
||||||
|
- 在合理时间内回复审查意见。如果需要更多时间,请告知。
|
||||||
|
- 更新 PR 以响应反馈时,简要说明改动内容(例如:"按建议改用了 `sync.RWMutex`")。
|
||||||
|
- 如果你不同意某条反馈,请礼貌地阐述你的理由——审查者也可能有判断失误的时候。
|
||||||
|
- 审查开始后请不要 force push——这会让审查者难以追踪变化。请使用额外的 commit,维护者在合并时会进行 squash。
|
||||||
|
|
||||||
|
### 对审查者的建议
|
||||||
|
|
||||||
|
审查重点:
|
||||||
|
|
||||||
|
1. **正确性** — 代码是否实现了其声称的功能?是否存在边界情况?
|
||||||
|
2. **安全性** — 对 AI 生成代码、tool 实现和 channel 处理器尤其需要关注。
|
||||||
|
3. **架构** — 实现方式是否与现有设计一致?
|
||||||
|
4. **简洁性** — 是否有更简单的方案?是否引入了不必要的复杂度?
|
||||||
|
5. **测试** — 改动是否有测试覆盖?现有测试是否仍然有意义?
|
||||||
|
|
||||||
|
请给出建设性且具体的反馈。"如果两个 goroutine 同时调用这个函数可能会有竞态条件,建议在这里加一个 mutex" 远比 "这里看起来有问题" 更有帮助。
|
||||||
|
|
||||||
|
### 审查者列表
|
||||||
|
提交对应PR后,可以参考下表联系对应的审查人员沟通
|
||||||
|
|
||||||
|
|Function| Reviewer|
|
||||||
|
|--- |--- |
|
||||||
|
|Provider|@yinwm |
|
||||||
|
|Channel |@yinwm |
|
||||||
|
|Agent |@lxowalle|
|
||||||
|
|Tools |@lxowalle|
|
||||||
|
|SKill ||
|
||||||
|
|MCP ||
|
||||||
|
|Optimization|@lxowalle|
|
||||||
|
|Security||
|
||||||
|
|AI CI |@imguoguo|
|
||||||
|
|UX ||
|
||||||
|
|Document||
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 沟通渠道
|
||||||
|
|
||||||
|
- **GitHub Issues** — Bug 报告、功能建议、设计讨论。
|
||||||
|
- **GitHub Discussions** — 一般性问题、想法交流、社区讨论。
|
||||||
|
- **Pull Request 评论** — 与具体代码相关的反馈。
|
||||||
|
- **Wechat&Discord** — 当你有至少一个已合并的PR后,我们会邀请你加入开发者交流群
|
||||||
|
|
||||||
|
有疑问时,请先开 Issue 讨论,再动手写代码。这几乎没有成本,却能避免大量无效投入。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关于本项目的 AI 驱动起源
|
||||||
|
|
||||||
|
PicoClaw 的架构在人工监督下,经由 AI 辅助完成了大量设计和实现工作。如果你发现某处看起来奇怪或过度设计,这可能是该过程留下的痕迹——欢迎提 Issue 讨论。
|
||||||
|
|
||||||
|
我们相信,负责任地使用 AI 辅助开发能产生优秀的成果。我们同样相信,人类必须对自己提交的内容负责。这两点并不矛盾。
|
||||||
|
|
||||||
|
感谢你的贡献!
|
||||||
@@ -11,10 +11,11 @@ VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
|||||||
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
|
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
|
||||||
BUILD_TIME=$(shell date +%FT%T%z)
|
BUILD_TIME=$(shell date +%FT%T%z)
|
||||||
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
|
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
|
||||||
LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION) -s -w"
|
INTERNAL=github.com/sipeed/picoclaw/cmd/picoclaw/internal
|
||||||
|
LDFLAGS=-ldflags "-X $(INTERNAL).version=$(VERSION) -X $(INTERNAL).gitCommit=$(GIT_COMMIT) -X $(INTERNAL).buildTime=$(BUILD_TIME) -X $(INTERNAL).goVersion=$(GO_VERSION) -s -w"
|
||||||
|
|
||||||
# Go variables
|
# Go variables
|
||||||
GO?=go
|
GO?=CGO_ENABLED=0 go
|
||||||
GOFLAGS?=-v -tags stdjson
|
GOFLAGS?=-v -tags stdjson
|
||||||
|
|
||||||
# Golangci-lint
|
# Golangci-lint
|
||||||
@@ -24,6 +25,7 @@ GOLANGCI_LINT?=golangci-lint
|
|||||||
INSTALL_PREFIX?=$(HOME)/.local
|
INSTALL_PREFIX?=$(HOME)/.local
|
||||||
INSTALL_BIN_DIR=$(INSTALL_PREFIX)/bin
|
INSTALL_BIN_DIR=$(INSTALL_PREFIX)/bin
|
||||||
INSTALL_MAN_DIR=$(INSTALL_PREFIX)/share/man/man1
|
INSTALL_MAN_DIR=$(INSTALL_PREFIX)/share/man/man1
|
||||||
|
INSTALL_TMP_SUFFIX=.new
|
||||||
|
|
||||||
# Workspace and Skills
|
# Workspace and Skills
|
||||||
PICOCLAW_HOME?=$(HOME)/.picoclaw
|
PICOCLAW_HOME?=$(HOME)/.picoclaw
|
||||||
@@ -42,6 +44,8 @@ ifeq ($(UNAME_S),Linux)
|
|||||||
ARCH=amd64
|
ARCH=amd64
|
||||||
else ifeq ($(UNAME_M),aarch64)
|
else ifeq ($(UNAME_M),aarch64)
|
||||||
ARCH=arm64
|
ARCH=arm64
|
||||||
|
else ifeq ($(UNAME_M),armv81)
|
||||||
|
ARCH=arm64
|
||||||
else ifeq ($(UNAME_M),loongarch64)
|
else ifeq ($(UNAME_M),loongarch64)
|
||||||
ARCH=loong64
|
ARCH=loong64
|
||||||
else ifeq ($(UNAME_M),riscv64)
|
else ifeq ($(UNAME_M),riscv64)
|
||||||
@@ -83,14 +87,50 @@ build: generate
|
|||||||
@echo "Build complete: $(BINARY_PATH)"
|
@echo "Build complete: $(BINARY_PATH)"
|
||||||
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||||
|
|
||||||
|
## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
|
||||||
|
build-whatsapp-native: generate
|
||||||
|
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
|
||||||
|
@echo "Building for multiple platforms..."
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
GOOS=linux GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||||
|
GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||||
|
GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||||
|
## @$(GO) build $(GOFLAGS) -tags whatsapp_native $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||||
|
@echo "Build complete"
|
||||||
|
## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||||
|
|
||||||
|
## build-linux-arm: Build for Linux ARMv7 (e.g. Raspberry Pi Zero 2 W 32-bit)
|
||||||
|
build-linux-arm: generate
|
||||||
|
@echo "Building for linux/arm (GOARM=7)..."
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||||
|
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm"
|
||||||
|
|
||||||
|
## build-linux-arm64: Build for Linux ARM64 (e.g. Raspberry Pi Zero 2 W 64-bit)
|
||||||
|
build-linux-arm64: generate
|
||||||
|
@echo "Building for linux/arm64..."
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
|
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
|
||||||
|
|
||||||
|
## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit)
|
||||||
|
build-pi-zero: build-linux-arm build-linux-arm64
|
||||||
|
@echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)"
|
||||||
|
|
||||||
## build-all: Build picoclaw for all platforms
|
## build-all: Build picoclaw for all platforms
|
||||||
build-all: generate
|
build-all: generate
|
||||||
@echo "Building for multiple platforms..."
|
@echo "Building for multiple platforms..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
|
||||||
GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||||
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||||
@echo "All builds complete"
|
@echo "All builds complete"
|
||||||
@@ -99,8 +139,10 @@ build-all: generate
|
|||||||
install: build
|
install: build
|
||||||
@echo "Installing $(BINARY_NAME)..."
|
@echo "Installing $(BINARY_NAME)..."
|
||||||
@mkdir -p $(INSTALL_BIN_DIR)
|
@mkdir -p $(INSTALL_BIN_DIR)
|
||||||
@cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
|
# Copy binary with temporary suffix to ensure atomic update
|
||||||
@chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)
|
@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 "Installed binary to $(INSTALL_BIN_DIR)/$(BINARY_NAME)"
|
||||||
@echo "Installation complete!"
|
@echo "Installation complete!"
|
||||||
|
|
||||||
@@ -141,6 +183,10 @@ fmt:
|
|||||||
lint:
|
lint:
|
||||||
@$(GOLANGCI_LINT) run
|
@$(GOLANGCI_LINT) run
|
||||||
|
|
||||||
|
## fix: Fix linting issues
|
||||||
|
fix:
|
||||||
|
@$(GOLANGCI_LINT) run --fix
|
||||||
|
|
||||||
## deps: Download dependencies
|
## deps: Download dependencies
|
||||||
deps:
|
deps:
|
||||||
@$(GO) mod download
|
@$(GO) mod download
|
||||||
@@ -166,7 +212,7 @@ help:
|
|||||||
@echo " make [target]"
|
@echo " make [target]"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Targets:"
|
@echo "Targets:"
|
||||||
@grep -E '^## ' $(MAKEFILE_LIST) | sed 's/## / /'
|
@grep -E '^## ' $(MAKEFILE_LIST) | sort | awk -F': ' '{printf " %-16s %s\n", substr($$1, 4), $$2}'
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Examples:"
|
@echo "Examples:"
|
||||||
@echo " make build # Build for current platform"
|
@echo " make build # Build for current platform"
|
||||||
|
|||||||
+39
-16
@@ -50,7 +50,7 @@
|
|||||||
|
|
||||||
## 📢 Actualités
|
## 📢 Actualités
|
||||||
|
|
||||||
2026-02-16 🎉 PicoClaw a atteint 12K étoiles en une semaine ! Merci à tous pour votre soutien ! PicoClaw grandit plus vite que nous ne l'avions jamais imaginé. Vu le volume élevé de PR, nous avons un besoin urgent de mainteneurs communautaires. Nos rôles de bénévoles et notre feuille de route sont officiellement publiés [ici](docs/picoclaw_community_roadmap_260216.md) — nous avons hâte de vous accueillir !
|
2026-02-16 🎉 PicoClaw a atteint 12K étoiles en une semaine ! Merci à tous pour votre soutien ! PicoClaw grandit plus vite que nous ne l'avions jamais imaginé. Vu le volume élevé de PR, nous avons un besoin urgent de mainteneurs communautaires. Nos rôles de bénévoles et notre feuille de route sont officiellement publiés [ici](docs/ROADMAP.md) — nous avons hâte de vous accueillir !
|
||||||
|
|
||||||
2026-02-13 🎉 PicoClaw a atteint 5000 étoiles en 4 jours ! Merci à la communauté ! Nous finalisons la **Feuille de Route du Projet** et mettons en place le **Groupe de Développeurs** pour accélérer le développement de PicoClaw.
|
2026-02-13 🎉 PicoClaw a atteint 5000 étoiles en 4 jours ! Merci à la communauté ! Nous finalisons la **Feuille de Route du Projet** et mettons en place le **Groupe de Développeurs** pour accélérer le développement de PicoClaw.
|
||||||
🚀 **Appel à l'action :** Soumettez vos demandes de fonctionnalités dans les GitHub Discussions. Nous les examinerons et les prioriserons lors de notre prochaine réunion hebdomadaire.
|
🚀 **Appel à l'action :** Soumettez vos demandes de fonctionnalités dans les GitHub Discussions. Nous les examinerons et les prioriserons lors de notre prochaine réunion hebdomadaire.
|
||||||
@@ -164,35 +164,43 @@ Vous pouvez également exécuter PicoClaw avec Docker Compose sans rien installe
|
|||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. Configurez vos clés API
|
# 2. Premier lancement — génère docker/data/config.json puis s'arrête
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # Configurez DISCORD_BOT_TOKEN, clés API, etc.
|
# Le conteneur affiche "First-run setup complete." puis s'arrête.
|
||||||
|
|
||||||
# 3. Compiler & Démarrer
|
# 3. Configurez vos clés API
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # Clés API du fournisseur, tokens de bot, etc.
|
||||||
|
|
||||||
# 4. Voir les logs
|
# 4. Démarrer
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
# 5. Arrêter
|
> [!TIP]
|
||||||
docker compose --profile gateway down
|
> **Utilisateurs Docker** : Par défaut, le Gateway écoute sur `127.0.0.1`, ce qui n'est pas accessible depuis l'hôte. Si vous avez besoin d'accéder aux endpoints de santé ou d'exposer des ports, définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` dans votre environnement ou mettez à jour `config.json`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. Voir les logs
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
|
# 6. Arrêter
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
```
|
```
|
||||||
|
|
||||||
### Mode Agent (exécution unique)
|
### Mode Agent (exécution unique)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Poser une question
|
# Poser une question
|
||||||
docker compose run --rm picoclaw-agent -m "Combien font 2+2 ?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Combien font 2+2 ?"
|
||||||
|
|
||||||
# Mode interactif
|
# Mode interactif
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
### Recompiler
|
### Mettre à jour
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 Démarrage Rapide
|
### 🚀 Démarrage Rapide
|
||||||
@@ -217,12 +225,13 @@ picoclaw onboard
|
|||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gpt4"
|
"model_name": "gpt4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
@@ -248,6 +257,9 @@ picoclaw onboard
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Nouveau** : Le format de configuration `model_list` permet d'ajouter des fournisseurs sans modifier le code. Voir [Configuration de Modèle](#configuration-de-modèle-model_list) pour plus de détails.
|
||||||
|
> `request_timeout` est optionnel et s'exprime en secondes. S'il est omis ou défini à `<= 0`, PicoClaw utilise le délai d'expiration par défaut (120s).
|
||||||
|
|
||||||
**3. Obtenir des Clés API**
|
**3. Obtenir des Clés API**
|
||||||
|
|
||||||
* **Fournisseur LLM** : [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
* **Fournisseur LLM** : [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
@@ -975,6 +987,17 @@ Cette conception permet également le **support multi-agent** avec une sélectio
|
|||||||
```
|
```
|
||||||
> Exécutez `picoclaw auth login --provider anthropic` pour configurer les identifiants OAuth.
|
> Exécutez `picoclaw auth login --provider anthropic` pour configurer les identifiants OAuth.
|
||||||
|
|
||||||
|
**Proxy/API personnalisée**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "my-custom-model",
|
||||||
|
"model": "openai/custom-model",
|
||||||
|
"api_base": "https://my-proxy.com/v1",
|
||||||
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### Équilibrage de Charge
|
#### Équilibrage de Charge
|
||||||
|
|
||||||
Configurez plusieurs points de terminaison pour le même nom de modèle—PicoClaw utilisera automatiquement le round-robin entre eux :
|
Configurez plusieurs points de terminaison pour le même nom de modèle—PicoClaw utilisera automatiquement le round-robin entre eux :
|
||||||
|
|||||||
+63
-19
@@ -126,35 +126,43 @@ Docker Compose を使えば、ローカルにインストールせずに PicoCla
|
|||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. API キーを設定
|
# 2. 初回起動 — docker/data/config.json を自動生成して終了
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # DISCORD_BOT_TOKEN, プロバイダーの API キーを設定
|
# コンテナが "First-run setup complete." を表示して停止します。
|
||||||
|
|
||||||
# 3. ビルドと起動
|
# 3. API キーを設定
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # プロバイダー API キー、Bot トークンなどを設定
|
||||||
|
|
||||||
# 4. ログ確認
|
# 4. 起動
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
# 5. 停止
|
> [!TIP]
|
||||||
docker compose --profile gateway down
|
> **Docker ユーザー**: デフォルトでは、Gateway は `127.0.0.1` でリッスンしており、ホストからアクセスできません。ヘルスチェックエンドポイントにアクセスしたり、ポートを公開したりする必要がある場合は、環境変数で `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`config.json` を更新してください。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. ログ確認
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
|
# 6. 停止
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
```
|
```
|
||||||
|
|
||||||
### Agent モード(ワンショット)
|
### Agent モード(ワンショット)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 質問を投げる
|
# 質問を投げる
|
||||||
docker compose run --rm picoclaw-agent -m "What is 2+2?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?"
|
||||||
|
|
||||||
# インタラクティブモード
|
# インタラクティブモード
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
### リビルド
|
### アップデート
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 クイックスタート(ネイティブ)
|
### 🚀 クイックスタート(ネイティブ)
|
||||||
@@ -162,7 +170,7 @@ docker compose --profile gateway up -d
|
|||||||
> [!TIP]
|
> [!TIP]
|
||||||
> `~/.picoclaw/config.json` に API キーを設定してください。
|
> `~/.picoclaw/config.json` に API キーを設定してください。
|
||||||
> API キーの取得先: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
> API キーの取得先: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
||||||
> Web 検索は **任意** です - 無料の [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)
|
> Web 検索は **任意** です - 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)
|
||||||
|
|
||||||
**1. 初期化**
|
**1. 初期化**
|
||||||
|
|
||||||
@@ -179,12 +187,13 @@ picoclaw onboard
|
|||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gpt4"
|
"model_name": "gpt4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
@@ -193,14 +202,37 @@ picoclaw onboard
|
|||||||
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"search": {
|
||||||
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"tavily": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "YOUR_TAVILY_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cron": {
|
||||||
|
"exec_timeout_minutes": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"heartbeat": {
|
||||||
|
"enabled": true,
|
||||||
|
"interval": 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **新機能**: `model_list` 形式により、プロバイダーをコード変更なしで追加できます。詳細は [モデル設定](#モデル設定-model_list) を参照してください。
|
||||||
|
> `request_timeout` は任意の秒単位設定です。省略または `<= 0` の場合、PicoClaw はデフォルトのタイムアウト(120秒)を使用します。
|
||||||
|
|
||||||
**3. API キーの取得**
|
**3. API キーの取得**
|
||||||
|
|
||||||
- **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) · [Qwen](https://dashscope.console.aliyun.com)
|
- **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
- **Web 検索**(任意): [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト)
|
- **Web 検索**(任意): [Tavily](https://tavily.com) - AI エージェント向けに最適化 (月 1000 リクエスト) · [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト)
|
||||||
|
|
||||||
> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。
|
> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。
|
||||||
|
|
||||||
@@ -894,6 +926,17 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
|||||||
```
|
```
|
||||||
> OAuth認証を設定するには、`picoclaw auth login --provider anthropic` を実行してください。
|
> OAuth認証を設定するには、`picoclaw auth login --provider anthropic` を実行してください。
|
||||||
|
|
||||||
|
**カスタムプロキシ/API**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "my-custom-model",
|
||||||
|
"model": "openai/custom-model",
|
||||||
|
"api_base": "https://my-proxy.com/v1",
|
||||||
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### ロードバランシング
|
#### ロードバランシング
|
||||||
|
|
||||||
同じモデル名で複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンで分散します:
|
同じモデル名で複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンで分散します:
|
||||||
@@ -985,7 +1028,7 @@ Discord: https://discord.gg/V4sAZ9XWpN
|
|||||||
検索 API キーをまだ設定していない場合、これは正常です。PicoClaw は手動検索用の便利なリンクを提供します。
|
検索 API キーをまだ設定していない場合、これは正常です。PicoClaw は手動検索用の便利なリンクを提供します。
|
||||||
|
|
||||||
Web 検索を有効にするには:
|
Web 検索を有効にするには:
|
||||||
1. [https://brave.com/search/api](https://brave.com/search/api) で無料の API キーを取得(月 2000 クエリ無料)
|
1. [https://tavily.com](https://tavily.com) (月 1000 クエリ無料) または [https://brave.com/search/api](https://brave.com/search/api) で無料の API キーを取得(月 2000 クエリ無料)
|
||||||
2. `~/.picoclaw/config.json` に追加:
|
2. `~/.picoclaw/config.json` に追加:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -1023,5 +1066,6 @@ Web 検索を有効にするには:
|
|||||||
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
|
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
|
||||||
| **Qwen** | 無料枠あり | 通義千問 (Qwen) |
|
| **Qwen** | 無料枠あり | 通義千問 (Qwen) |
|
||||||
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
|
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
|
||||||
|
| **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 |
|
||||||
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
|
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
|
||||||
| **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) |
|
| **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) |
|
||||||
|
|||||||
@@ -12,9 +12,13 @@
|
|||||||
<br>
|
<br>
|
||||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||||
|
<br>
|
||||||
|
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||||
|
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English**
|
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English**
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -42,16 +46,17 @@
|
|||||||
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
|
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
|
||||||
>
|
>
|
||||||
> * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**.
|
> * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**.
|
||||||
|
>
|
||||||
> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
|
> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
|
||||||
> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties.
|
> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties.
|
||||||
> * **Warning:** picoclaw is in early development now and may have unresolved network security issues. Do not deploy to production environments before the v1.0 release.
|
> * **Warning:** picoclaw is in early development now and may have unresolved network security issues. Do not deploy to production environments before the v1.0 release.
|
||||||
> * **Note:** picoclaw has recently merged a lot of PRs, which may result in a larger memory footprint (10–20MB) in the latest versions. We plan to prioritize resource optimization as soon as the current feature set reaches a stable state.
|
> * **Note:** picoclaw has recently merged a lot of PRs, which may result in a larger memory footprint (10–20MB) in the latest versions. We plan to prioritize resource optimization as soon as the current feature set reaches a stable state.
|
||||||
|
|
||||||
|
|
||||||
## 📢 News
|
## 📢 News
|
||||||
2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](docs/picoclaw_community_roadmap_260216.md) —we can’t wait to have you on board!
|
|
||||||
|
|
||||||
2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs&issues come in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
|
2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](docs/ROADMAP.md) —we can’t wait to have you on board!
|
||||||
|
|
||||||
|
2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs & issues coming in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
|
||||||
🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting.
|
🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting.
|
||||||
|
|
||||||
2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go!
|
2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go!
|
||||||
@@ -100,9 +105,12 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
### 📱 Run on old Android Phones
|
### 📱 Run on old Android Phones
|
||||||
|
|
||||||
Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start:
|
Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start:
|
||||||
|
|
||||||
1. **Install Termux** (Available on F-Droid or Google Play).
|
1. **Install Termux** (Available on F-Droid or Google Play).
|
||||||
2. **Execute cmds**
|
2. **Execute cmds**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Note: Replace v0.1.1 with the latest version from the Releases page
|
# Note: Replace v0.1.1 with the latest version from the Releases page
|
||||||
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
|
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
|
||||||
@@ -110,6 +118,7 @@ chmod +x picoclaw-linux-arm64
|
|||||||
pkg install proot
|
pkg install proot
|
||||||
termux-chroot ./picoclaw-linux-arm64 onboard
|
termux-chroot ./picoclaw-linux-arm64 onboard
|
||||||
```
|
```
|
||||||
|
|
||||||
And then follow the instructions in the "Quick Start" section to complete the configuration!
|
And then follow the instructions in the "Quick Start" section to complete the configuration!
|
||||||
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
@@ -145,10 +154,15 @@ make build
|
|||||||
# Build for multiple platforms
|
# Build for multiple platforms
|
||||||
make build-all
|
make build-all
|
||||||
|
|
||||||
|
# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||||
|
make build-pi-zero
|
||||||
|
|
||||||
# Build And Install
|
# Build And Install
|
||||||
make install
|
make install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Raspberry Pi Zero 2 W:** Use the binary that matches your OS: 32-bit Raspberry Pi OS → `make build-linux-arm` (output: `build/picoclaw-linux-arm`); 64-bit → `make build-linux-arm64` (output: `build/picoclaw-linux-arm64`). Or run `make build-pi-zero` to build both.
|
||||||
|
|
||||||
## 🐳 Docker Compose
|
## 🐳 Docker Compose
|
||||||
|
|
||||||
You can also run PicoClaw using Docker Compose without installing anything locally.
|
You can also run PicoClaw using Docker Compose without installing anything locally.
|
||||||
@@ -158,35 +172,43 @@ You can also run PicoClaw using Docker Compose without installing anything local
|
|||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. Set your API keys
|
# 2. First run — auto-generates docker/data/config.json then exits
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # Set DISCORD_BOT_TOKEN, API keys, etc.
|
# The container prints "First-run setup complete." and stops.
|
||||||
|
|
||||||
# 3. Build & Start
|
# 3. Set your API keys
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # Set provider API keys, bot tokens, etc.
|
||||||
|
|
||||||
# 4. Check logs
|
# 4. Start
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
# 5. Stop
|
> [!TIP]
|
||||||
docker compose --profile gateway down
|
> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. Check logs
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
|
# 6. Stop
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
```
|
```
|
||||||
|
|
||||||
### Agent Mode (One-shot)
|
### Agent Mode (One-shot)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Ask a question
|
# Ask a question
|
||||||
docker compose run --rm picoclaw-agent -m "What is 2+2?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?"
|
||||||
|
|
||||||
# Interactive mode
|
# Interactive mode
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
### Rebuild
|
### Update
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 Quick Start
|
### 🚀 Quick Start
|
||||||
@@ -194,7 +216,7 @@ docker compose --profile gateway up -d
|
|||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Set your API key in `~/.picoclaw/config.json`.
|
> Set your API key in `~/.picoclaw/config.json`.
|
||||||
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
||||||
> Web search is **optional** - get free [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
|
> Web Search is **optional** - get free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
|
||||||
|
|
||||||
**1. Initialize**
|
**1. Initialize**
|
||||||
|
|
||||||
@@ -209,7 +231,7 @@ picoclaw onboard
|
|||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"workspace": "~/.picoclaw/workspace",
|
"workspace": "~/.picoclaw/workspace",
|
||||||
"model": "gpt4",
|
"model_name": "gpt4",
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tool_iterations": 20
|
"max_tool_iterations": 20
|
||||||
@@ -219,7 +241,8 @@ picoclaw onboard
|
|||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
"api_key": "your-api-key"
|
"api_key": "your-api-key",
|
||||||
|
"request_timeout": 300
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
@@ -234,6 +257,11 @@ picoclaw onboard
|
|||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
|
"tavily": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "YOUR_TAVILY_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
"duckduckgo": {
|
"duckduckgo": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
@@ -244,11 +272,12 @@ picoclaw onboard
|
|||||||
```
|
```
|
||||||
|
|
||||||
> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
|
> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
|
||||||
|
> `request_timeout` is optional and uses seconds. If omitted or set to `<= 0`, PicoClaw uses the default timeout (120s).
|
||||||
|
|
||||||
**3. Get API Keys**
|
**3. Get API Keys**
|
||||||
|
|
||||||
* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
* **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
|
* **Web Search** (optional): [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month) · [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
|
||||||
|
|
||||||
> **Note**: See `config.example.json` for a complete configuration template.
|
> **Note**: See `config.example.json` for a complete configuration template.
|
||||||
|
|
||||||
@@ -264,12 +293,13 @@ That's it! You have a working AI assistant in 2 minutes.
|
|||||||
|
|
||||||
## 💬 Chat Apps
|
## 💬 Chat Apps
|
||||||
|
|
||||||
Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, or WeCom
|
Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or WeCom
|
||||||
|
|
||||||
| Channel | Setup |
|
| Channel | Setup |
|
||||||
| ------------ | ---------------------------------- |
|
| ------------ | ---------------------------------- |
|
||||||
| **Telegram** | Easy (just a token) |
|
| **Telegram** | Easy (just a token) |
|
||||||
| **Discord** | Easy (bot token + intents) |
|
| **Discord** | Easy (bot token + intents) |
|
||||||
|
| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
|
||||||
| **QQ** | Easy (AppID + AppSecret) |
|
| **QQ** | Easy (AppID + AppSecret) |
|
||||||
| **DingTalk** | Medium (app credentials) |
|
| **DingTalk** | Medium (app credentials) |
|
||||||
| **LINE** | Medium (credentials + webhook URL) |
|
| **LINE** | Medium (credentials + webhook URL) |
|
||||||
@@ -323,7 +353,6 @@ picoclaw gateway
|
|||||||
* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
|
* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
|
||||||
|
|
||||||
**3. Get your User ID**
|
**3. Get your User ID**
|
||||||
|
|
||||||
* Discord Settings → Advanced → enable **Developer Mode**
|
* Discord Settings → Advanced → enable **Developer Mode**
|
||||||
* Right-click your avatar → **Copy User ID**
|
* Right-click your avatar → **Copy User ID**
|
||||||
|
|
||||||
@@ -361,6 +390,33 @@ picoclaw gateway
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>WhatsApp</b> (native via whatsmeow)</summary>
|
||||||
|
|
||||||
|
PicoClaw can connect to WhatsApp in two ways:
|
||||||
|
|
||||||
|
- **Native (recommended):** In-process using [whatsmeow](https://github.com/tulir/whatsmeow). No separate bridge. Set `"use_native": true` and leave `bridge_url` empty. On first run, scan the QR code with WhatsApp (Linked Devices). Session is stored under your workspace (e.g. `workspace/whatsapp/`). The native channel is **optional** to keep the default binary small; build with `-tags whatsapp_native` (e.g. `make build-whatsapp-native` or `go build -tags whatsapp_native ./cmd/...`).
|
||||||
|
- **Bridge:** Connect to an external WebSocket bridge. Set `bridge_url` (e.g. `ws://localhost:3001`) and keep `use_native` false.
|
||||||
|
|
||||||
|
**Configure (native)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"whatsapp": {
|
||||||
|
"enabled": true,
|
||||||
|
"use_native": true,
|
||||||
|
"session_store_path": "",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `session_store_path` is empty, the session is stored in `<workspace>/whatsapp/`. Run `picoclaw gateway`; on first run, scan the QR code printed in the terminal with WhatsApp → Linked Devices.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>QQ</b></summary>
|
<summary><b>QQ</b></summary>
|
||||||
|
|
||||||
@@ -425,7 +481,6 @@ picoclaw gateway
|
|||||||
```bash
|
```bash
|
||||||
picoclaw gateway
|
picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -521,7 +576,6 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
|
|||||||
* Go to WeCom Admin Console → App Management → Create App
|
* Go to WeCom Admin Console → App Management → Create App
|
||||||
* Copy **AgentId** and **Secret**
|
* Copy **AgentId** and **Secret**
|
||||||
* Go to "My Company" page, copy **CorpID**
|
* Go to "My Company" page, copy **CorpID**
|
||||||
|
|
||||||
**2. Configure receive message**
|
**2. Configure receive message**
|
||||||
|
|
||||||
* In App details, click "Receive Message" → "Set API"
|
* In App details, click "Receive Message" → "Set API"
|
||||||
@@ -606,7 +660,7 @@ PicoClaw runs in a sandboxed environment by default. The agent can only access f
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
|--------|---------|-------------|
|
| ----------------------- | ----------------------- | ----------------------------------------- |
|
||||||
| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent |
|
| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent |
|
||||||
| `restrict_to_workspace` | `true` | Restrict file/command access to workspace |
|
| `restrict_to_workspace` | `true` | Restrict file/command access to workspace |
|
||||||
|
|
||||||
@@ -615,7 +669,7 @@ PicoClaw runs in a sandboxed environment by default. The agent can only access f
|
|||||||
When `restrict_to_workspace: true`, the following tools are sandboxed:
|
When `restrict_to_workspace: true`, the following tools are sandboxed:
|
||||||
|
|
||||||
| Tool | Function | Restriction |
|
| Tool | Function | Restriction |
|
||||||
|------|----------|-------------|
|
| ------------- | ---------------- | -------------------------------------- |
|
||||||
| `read_file` | Read files | Only files within workspace |
|
| `read_file` | Read files | Only files within workspace |
|
||||||
| `write_file` | Write files | Only files within workspace |
|
| `write_file` | Write files | Only files within workspace |
|
||||||
| `list_dir` | List directories | Only directories within workspace |
|
| `list_dir` | List directories | Only directories within workspace |
|
||||||
@@ -675,7 +729,7 @@ export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
|
|||||||
The `restrict_to_workspace` setting applies consistently across all execution paths:
|
The `restrict_to_workspace` setting applies consistently across all execution paths:
|
||||||
|
|
||||||
| Execution Path | Security Boundary |
|
| Execution Path | Security Boundary |
|
||||||
|----------------|-------------------|
|
| ---------------- | ---------------------------- |
|
||||||
| Main Agent | `restrict_to_workspace` ✅ |
|
| Main Agent | `restrict_to_workspace` ✅ |
|
||||||
| Subagent / Spawn | Inherits same restriction ✅ |
|
| Subagent / Spawn | Inherits same restriction ✅ |
|
||||||
| Heartbeat tasks | Inherits same restriction ✅ |
|
| Heartbeat tasks | Inherits same restriction ✅ |
|
||||||
@@ -704,9 +758,11 @@ For long-running tasks (web search, API calls), use the `spawn` tool to create a
|
|||||||
# Periodic Tasks
|
# Periodic Tasks
|
||||||
|
|
||||||
## Quick Tasks (respond directly)
|
## Quick Tasks (respond directly)
|
||||||
|
|
||||||
- Report current time
|
- Report current time
|
||||||
|
|
||||||
## Long Tasks (use spawn for async)
|
## Long Tasks (use spawn for async)
|
||||||
|
|
||||||
- Search the web for AI news and summarize
|
- Search the web for AI news and summarize
|
||||||
- Check email and report important messages
|
- Check email and report important messages
|
||||||
```
|
```
|
||||||
@@ -714,7 +770,7 @@ For long-running tasks (web search, API calls), use the `spawn` tool to create a
|
|||||||
**Key behaviors:**
|
**Key behaviors:**
|
||||||
|
|
||||||
| Feature | Description |
|
| Feature | Description |
|
||||||
|---------|-------------|
|
| ----------------------- | --------------------------------------------------------- |
|
||||||
| **spawn** | Creates async subagent, doesn't block heartbeat |
|
| **spawn** | Creates async subagent, doesn't block heartbeat |
|
||||||
| **Independent context** | Subagent has its own context, no session history |
|
| **Independent context** | Subagent has its own context, no session history |
|
||||||
| **message tool** | Subagent communicates with user directly via message tool |
|
| **message tool** | Subagent communicates with user directly via message tool |
|
||||||
@@ -750,7 +806,7 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
|--------|---------|-------------|
|
| ---------- | ------- | ---------------------------------- |
|
||||||
| `enabled` | `true` | Enable/disable heartbeat |
|
| `enabled` | `true` | Enable/disable heartbeat |
|
||||||
| `interval` | `30` | Check interval in minutes (min: 5) |
|
| `interval` | `30` | Check interval in minutes (min: 5) |
|
||||||
|
|
||||||
@@ -765,9 +821,9 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
|
|||||||
> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
|
> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
|
||||||
|
|
||||||
| Provider | Purpose | Get API Key |
|
| Provider | Purpose | Get API Key |
|
||||||
| -------------------------- | --------------------------------------- | ------------------------------------------------------ |
|
| -------------------------- | --------------------------------------- | -------------------------------------------------------------------- |
|
||||||
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
||||||
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) |
|
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) |
|
||||||
| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
||||||
| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||||
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
||||||
@@ -790,7 +846,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||||||
#### 📋 All Supported Vendors
|
#### 📋 All Supported Vendors
|
||||||
|
|
||||||
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
|
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
|
||||||
|--------|----------------|------------------|----------|---------|
|
| ------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- |
|
||||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
|
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
|
||||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
||||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||||
@@ -841,6 +897,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||||||
#### Vendor-Specific Examples
|
#### Vendor-Specific Examples
|
||||||
|
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.2",
|
||||||
@@ -850,6 +907,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||||||
```
|
```
|
||||||
|
|
||||||
**智谱 AI (GLM)**
|
**智谱 AI (GLM)**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
@@ -859,6 +917,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||||||
```
|
```
|
||||||
|
|
||||||
**DeepSeek**
|
**DeepSeek**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "deepseek-chat",
|
"model_name": "deepseek-chat",
|
||||||
@@ -868,6 +927,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Anthropic (with API key)**
|
**Anthropic (with API key)**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
@@ -875,9 +935,11 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||||||
"api_key": "sk-ant-your-key"
|
"api_key": "sk-ant-your-key"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> Run `picoclaw auth login --provider anthropic` to paste your API token.
|
> Run `picoclaw auth login --provider anthropic` to paste your API token.
|
||||||
|
|
||||||
**Ollama (local)**
|
**Ollama (local)**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "llama3",
|
"model_name": "llama3",
|
||||||
@@ -886,12 +948,14 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Custom Proxy/API**
|
**Custom Proxy/API**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "my-custom-model",
|
"model_name": "my-custom-model",
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -923,6 +987,7 @@ Configure multiple endpoints for the same model name—PicoClaw will automatical
|
|||||||
The old `providers` configuration is **deprecated** but still supported for backward compatibility.
|
The old `providers` configuration is **deprecated** but still supported for backward compatibility.
|
||||||
|
|
||||||
**Old Config (deprecated):**
|
**Old Config (deprecated):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"providers": {
|
"providers": {
|
||||||
@@ -941,6 +1006,7 @@ The old `providers` configuration is **deprecated** but still supported for back
|
|||||||
```
|
```
|
||||||
|
|
||||||
**New Config (recommended):**
|
**New Config (recommended):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
@@ -1037,7 +1103,11 @@ picoclaw agent -m "Hello"
|
|||||||
"allow_from": [""]
|
"allow_from": [""]
|
||||||
},
|
},
|
||||||
"whatsapp": {
|
"whatsapp": {
|
||||||
"enabled": false
|
"enabled": false,
|
||||||
|
"bridge_url": "ws://localhost:3001",
|
||||||
|
"use_native": false,
|
||||||
|
"session_store_path": "",
|
||||||
|
"allow_from": []
|
||||||
},
|
},
|
||||||
"feishu": {
|
"feishu": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
@@ -1105,9 +1175,9 @@ Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically.
|
|||||||
|
|
||||||
PRs welcome! The codebase is intentionally small and readable. 🤗
|
PRs welcome! The codebase is intentionally small and readable. 🤗
|
||||||
|
|
||||||
Roadmap coming soon...
|
See our full [Community Roadmap](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md).
|
||||||
|
|
||||||
Developer group building, Entry Requirement: At least 1 Merged PR.
|
Developer group building, join after your first merged PR!
|
||||||
|
|
||||||
User Groups:
|
User Groups:
|
||||||
|
|
||||||
@@ -1117,7 +1187,7 @@ discord: <https://discord.gg/V4sAZ9XWpN>
|
|||||||
|
|
||||||
## 🐛 Troubleshooting
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
### Web search says "API 配置问题"
|
### Web search says "API key configuration issue"
|
||||||
|
|
||||||
This is normal if you haven't configured a search API key yet. PicoClaw will provide helpful links for manual searching.
|
This is normal if you haven't configured a search API key yet. PicoClaw will provide helpful links for manual searching.
|
||||||
|
|
||||||
|
|||||||
+39
-16
@@ -50,7 +50,7 @@
|
|||||||
|
|
||||||
## 📢 Novidades
|
## 📢 Novidades
|
||||||
|
|
||||||
2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw está crescendo mais rápido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papéis de voluntários e roadmap foram publicados oficialmente [aqui](docs/picoclaw_community_roadmap_260216.md) — estamos ansiosos para ter você a bordo!
|
2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw está crescendo mais rápido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papéis de voluntários e roadmap foram publicados oficialmente [aqui](docs/ROADMAP.md) — estamos ansiosos para ter você a bordo!
|
||||||
|
|
||||||
2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Obrigado à comunidade! Estamos finalizando o **Roadmap do Projeto** e configurando o **Grupo de Desenvolvedores** para acelerar o desenvolvimento do PicoClaw.
|
2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Obrigado à comunidade! Estamos finalizando o **Roadmap do Projeto** e configurando o **Grupo de Desenvolvedores** para acelerar o desenvolvimento do PicoClaw.
|
||||||
|
|
||||||
@@ -165,35 +165,43 @@ Você tambêm pode rodar o PicoClaw usando Docker Compose sem instalar nada loca
|
|||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. Configure suas API keys
|
# 2. Primeiro uso — gera docker/data/config.json automaticamente e para
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # Configure DISCORD_BOT_TOKEN, API keys, etc.
|
# O contêiner exibe "First-run setup complete." e para.
|
||||||
|
|
||||||
# 3. Build & Iniciar
|
# 3. Configure suas API keys
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # Chaves de API do provedor, tokens de bot, etc.
|
||||||
|
|
||||||
# 4. Ver logs
|
# 4. Iniciar
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
# 5. Parar
|
> [!TIP]
|
||||||
docker compose --profile gateway down
|
> **Usuários Docker**: Por padrão, o Gateway ouve em `127.0.0.1`, o que não é acessível a partir do host. Se você precisar acessar os endpoints de integridade ou expor portas, defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` em seu ambiente ou atualize o `config.json`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. Ver logs
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
|
# 6. Parar
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
```
|
```
|
||||||
|
|
||||||
### Modo Agente (Execução única)
|
### Modo Agente (Execução única)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Fazer uma pergunta
|
# Fazer uma pergunta
|
||||||
docker compose run --rm picoclaw-agent -m "Quanto e 2+2?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Quanto e 2+2?"
|
||||||
|
|
||||||
# Modo interativo
|
# Modo interativo
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
### Rebuild
|
### Atualizar
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 Início Rápido
|
### 🚀 Início Rápido
|
||||||
@@ -218,12 +226,13 @@ picoclaw onboard
|
|||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gpt4"
|
"model_name": "gpt4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
@@ -242,6 +251,9 @@ picoclaw onboard
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Novo**: O formato de configuração `model_list` permite adicionar provedores sem alterar código. Veja [Configuração de Modelo](#configuração-de-modelo-model_list) para detalhes.
|
||||||
|
> `request_timeout` é opcional e usa segundos. Se omitido ou definido como `<= 0`, o PicoClaw usa o timeout padrão (120s).
|
||||||
|
|
||||||
**3. Obter API Keys**
|
**3. Obter API Keys**
|
||||||
|
|
||||||
* **Provedor de LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
* **Provedor de LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
@@ -969,6 +981,17 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
|
|||||||
```
|
```
|
||||||
> Execute `picoclaw auth login --provider anthropic` para configurar credenciais OAuth.
|
> Execute `picoclaw auth login --provider anthropic` para configurar credenciais OAuth.
|
||||||
|
|
||||||
|
**Proxy/API personalizada**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "my-custom-model",
|
||||||
|
"model": "openai/custom-model",
|
||||||
|
"api_base": "https://my-proxy.com/v1",
|
||||||
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### Balanceamento de Carga
|
#### Balanceamento de Carga
|
||||||
|
|
||||||
Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-robin automaticamente entre eles:
|
Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-robin automaticamente entre eles:
|
||||||
|
|||||||
+39
-16
@@ -50,7 +50,7 @@
|
|||||||
|
|
||||||
## 📢 Tin tức
|
## 📢 Tin tức
|
||||||
|
|
||||||
2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/picoclaw_community_roadmap_260216.md) — rất mong đón nhận sự tham gia của bạn!
|
2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/ROADMAP.md) — rất mong đón nhận sự tham gia của bạn!
|
||||||
|
|
||||||
2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Cảm ơn cộng đồng! Chúng tôi đang hoàn thiện **Lộ trình dự án (Roadmap)** và thiết lập **Nhóm phát triển** để đẩy nhanh tốc độ phát triển PicoClaw.
|
2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Cảm ơn cộng đồng! Chúng tôi đang hoàn thiện **Lộ trình dự án (Roadmap)** và thiết lập **Nhóm phát triển** để đẩy nhanh tốc độ phát triển PicoClaw.
|
||||||
🚀 **Kêu gọi hành động:** Vui lòng gửi yêu cầu tính năng tại GitHub Discussions. Chúng tôi sẽ xem xét và ưu tiên trong cuộc họp hàng tuần.
|
🚀 **Kêu gọi hành động:** Vui lòng gửi yêu cầu tính năng tại GitHub Discussions. Chúng tôi sẽ xem xét và ưu tiên trong cuộc họp hàng tuần.
|
||||||
@@ -145,35 +145,43 @@ Bạn cũng có thể chạy PicoClaw bằng Docker Compose mà không cần cà
|
|||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. Thiết lập API Key
|
# 2. Lần chạy đầu tiên — tự tạo docker/data/config.json rồi dừng lại
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # Thiết lập DISCORD_BOT_TOKEN, API keys, v.v.
|
# Container hiển thị "First-run setup complete." rồi tự dừng.
|
||||||
|
|
||||||
# 3. Build & Khởi động
|
# 3. Thiết lập API Key
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # API key của provider, bot token, v.v.
|
||||||
|
|
||||||
# 4. Xem logs
|
# 4. Khởi động
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
# 5. Dừng
|
> [!TIP]
|
||||||
docker compose --profile gateway down
|
> **Người dùng Docker**: Theo mặc định, Gateway lắng nghe trên `127.0.0.1`, không thể truy cập từ máy chủ. Nếu bạn cần truy cập các endpoint kiểm tra sức khỏe hoặc mở cổng, hãy đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` trong môi trường của bạn hoặc cập nhật `config.json`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. Xem logs
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
|
# 6. Dừng
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
```
|
```
|
||||||
|
|
||||||
### Chế độ Agent (chạy một lần)
|
### Chế độ Agent (chạy một lần)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Đặt câu hỏi
|
# Đặt câu hỏi
|
||||||
docker compose run --rm picoclaw-agent -m "2+2 bằng mấy?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2 bằng mấy?"
|
||||||
|
|
||||||
# Chế độ tương tác
|
# Chế độ tương tác
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build lại
|
### Cập nhật
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 Bắt đầu nhanh
|
### 🚀 Bắt đầu nhanh
|
||||||
@@ -198,12 +206,13 @@ picoclaw onboard
|
|||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gpt4"
|
"model_name": "gpt4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
@@ -216,6 +225,9 @@ picoclaw onboard
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Mới**: Định dạng cấu hình `model_list` cho phép thêm nhà cung cấp mà không cần thay đổi mã nguồn. Xem [Cấu hình Mô hình](#cấu-hình-mô-hình-model_list) để biết chi tiết.
|
||||||
|
> `request_timeout` là tùy chọn và dùng đơn vị giây. Nếu bỏ qua hoặc đặt `<= 0`, PicoClaw sẽ dùng timeout mặc định (120s).
|
||||||
|
|
||||||
**3. Lấy API Key**
|
**3. Lấy API Key**
|
||||||
|
|
||||||
* **Nhà cung cấp LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
* **Nhà cung cấp LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
@@ -940,6 +952,17 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
|
|||||||
```
|
```
|
||||||
> Chạy `picoclaw auth login --provider anthropic` để thiết lập thông tin xác thực OAuth.
|
> Chạy `picoclaw auth login --provider anthropic` để thiết lập thông tin xác thực OAuth.
|
||||||
|
|
||||||
|
**Proxy/API tùy chỉnh**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "my-custom-model",
|
||||||
|
"model": "openai/custom-model",
|
||||||
|
"api_base": "https://my-proxy.com/v1",
|
||||||
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### Cân bằng Tải tải
|
#### Cân bằng Tải tải
|
||||||
|
|
||||||
Định cấu hình nhiều endpoint cho cùng một tên mô hình—PicoClaw sẽ tự động phân phối round-robin giữa chúng:
|
Định cấu hình nhiều endpoint cho cùng một tên mô hình—PicoClaw sẽ tự động phân phối round-robin giữa chúng:
|
||||||
|
|||||||
+91
-308
@@ -15,6 +15,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
|
**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -42,15 +43,16 @@
|
|||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
|
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
|
||||||
> * **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。
|
>
|
||||||
> * **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
|
> - **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。
|
||||||
> * **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。
|
> - **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
|
||||||
> * **注意:** picoclaw正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在1.0正式版发布前,请不要将其部署到生产环境中
|
> - **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。
|
||||||
> * **注意:** picoclaw最近合并了大量PRs,近期版本可能内存占用较大(10~20MB),我们将在功能较为收敛后进行资源占用优化.
|
> - **注意:** picoclaw正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在1.0正式版发布前,请不要将其部署到生产环境中
|
||||||
|
> - **注意:** picoclaw最近合并了大量PRs,近期版本可能内存占用较大(10~20MB),我们将在功能较为收敛后进行资源占用优化.
|
||||||
|
|
||||||
## 📢 新闻 (News)
|
## 📢 新闻 (News)
|
||||||
2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/picoclaw_community_roadmap_260216.md), 期待你的参与!
|
|
||||||
|
2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/ROADMAP.md), 期待你的参与!
|
||||||
|
|
||||||
2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。
|
2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。
|
||||||
🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。
|
🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。
|
||||||
@@ -70,7 +72,7 @@
|
|||||||
🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。
|
🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。
|
||||||
|
|
||||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||||
| --- | --- | --- | --- |
|
| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
|
||||||
| **语言** | TypeScript | Python | **Go** |
|
| **语言** | TypeScript | Python | **Go** |
|
||||||
| **RAM** | >1GB | >100MB | **< 10MB** |
|
| **RAM** | >1GB | >100MB | **< 10MB** |
|
||||||
| **启动时间**</br>(0.8GHz core) | >500s | >30s | **<1s** |
|
| **启动时间**</br>(0.8GHz core) | >500s | >30s | **<1s** |
|
||||||
@@ -101,9 +103,12 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
### 📱 在手机上轻松运行
|
### 📱 在手机上轻松运行
|
||||||
|
|
||||||
picoclaw 可以将你10年前的老旧手机废物利用,变身成为你的AI助理!快速指南:
|
picoclaw 可以将你10年前的老旧手机废物利用,变身成为你的AI助理!快速指南:
|
||||||
|
|
||||||
1. 先去应用商店下载安装Termux
|
1. 先去应用商店下载安装Termux
|
||||||
2. 打开后执行指令
|
2. 打开后执行指令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 注意: 下面的v0.1.1 可以换为你实际看到的最新版本
|
# 注意: 下面的v0.1.1 可以换为你实际看到的最新版本
|
||||||
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
|
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
|
||||||
@@ -111,19 +116,17 @@ chmod +x picoclaw-linux-arm64
|
|||||||
pkg install proot
|
pkg install proot
|
||||||
termux-chroot ./picoclaw-linux-arm64 onboard
|
termux-chroot ./picoclaw-linux-arm64 onboard
|
||||||
```
|
```
|
||||||
|
|
||||||
然后跟随下面的“快速开始”章节继续配置picoclaw即可使用!
|
然后跟随下面的“快速开始”章节继续配置picoclaw即可使用!
|
||||||
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### 🐜 创新的低占用部署
|
### 🐜 创新的低占用部署
|
||||||
|
|
||||||
PicoClaw 几乎可以部署在任何 Linux 设备上!
|
PicoClaw 几乎可以部署在任何 Linux 设备上!
|
||||||
|
|
||||||
* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手。
|
- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手。
|
||||||
* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维。
|
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维。
|
||||||
* $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控。
|
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控。
|
||||||
|
|
||||||
[https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4](https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4)
|
[https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4](https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4)
|
||||||
|
|
||||||
@@ -163,38 +166,43 @@ make install
|
|||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. 设置 API Key
|
# 2. 首次运行 — 自动生成 docker/data/config.json 后退出
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # 设置 DISCORD_BOT_TOKEN, API keys 等
|
# 容器打印 "First-run setup complete." 后自动停止
|
||||||
|
|
||||||
# 3. 构建并启动
|
# 3. 填写 API Key 等配置
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # 设置 provider API key、Bot Token 等
|
||||||
|
|
||||||
# 4. 查看日志
|
# 4. 正式启动
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
# 5. 停止
|
> [!TIP]
|
||||||
docker compose --profile gateway down
|
> **Docker 用户**: 默认情况下, Gateway 监听 `127.0.0.1`,该端口不会暴露到容器外。如果需要通过端口映射访问健康检查接口,请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json`。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. 查看日志
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
|
# 6. 停止
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
```
|
```
|
||||||
|
|
||||||
### Agent 模式 (一次性运行)
|
### Agent 模式 (一次性运行)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 提问
|
# 提问
|
||||||
docker compose run --rm picoclaw-agent -m "2+2 等于几?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2 等于几?"
|
||||||
|
|
||||||
# 交互模式
|
# 交互模式
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 重新构建
|
### 更新镜像
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 快速开始
|
### 🚀 快速开始
|
||||||
@@ -202,7 +210,7 @@ docker compose --profile gateway up -d
|
|||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。
|
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。
|
||||||
> 获取 API Key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
> 获取 API Key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
||||||
> 网络搜索是 **可选的** - 获取免费的 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
|
> 网络搜索是 **可选的** - 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
|
||||||
|
|
||||||
**1. 初始化 (Initialize)**
|
**1. 初始化 (Initialize)**
|
||||||
|
|
||||||
@@ -218,7 +226,7 @@ picoclaw onboard
|
|||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"workspace": "~/.picoclaw/workspace",
|
"workspace": "~/.picoclaw/workspace",
|
||||||
"model": "gpt4",
|
"model_name": "gpt4",
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tool_iterations": 20
|
"max_tool_iterations": 20
|
||||||
@@ -228,7 +236,8 @@ picoclaw onboard
|
|||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
"api_key": "your-api-key"
|
"api_key": "your-api-key",
|
||||||
|
"request_timeout": 300
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
@@ -243,8 +252,9 @@ picoclaw onboard
|
|||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"duckduckgo": {
|
"tavily": {
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
|
"api_key": "YOUR_TAVILY_API_KEY",
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -253,15 +263,15 @@ picoclaw onboard
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。
|
> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。
|
||||||
|
> `request_timeout` 为可选项,单位为秒。若省略或设置为 `<= 0`,PicoClaw 使用默认超时(120 秒)。
|
||||||
|
|
||||||
**3. 获取 API Key**
|
**3. 获取 API Key**
|
||||||
|
|
||||||
* **LLM 提供商**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
* **LLM 提供商**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
* **网络搜索** (可选): [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月)
|
* **网络搜索** (可选): [Tavily](https://tavily.com) - 专为 AI Agent 优化 (1000 请求/月) · [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月)
|
||||||
|
|
||||||
> **注意**: 完整的配置模板请参考 `config.example.json`。
|
> **注意**: 完整的配置模板请参考 `config.example.json`。
|
||||||
|
|
||||||
@@ -278,260 +288,28 @@ picoclaw agent -m "2+2 等于几?"
|
|||||||
|
|
||||||
## 💬 聊天应用集成 (Chat Apps)
|
## 💬 聊天应用集成 (Chat Apps)
|
||||||
|
|
||||||
通过 Telegram, Discord, 钉钉或企业微信与您的 PicoClaw 对话。
|
PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。
|
||||||
|
|
||||||
| 渠道 | 设置难度 |
|
### 核心渠道
|
||||||
| --- | --- |
|
|
||||||
| **Telegram** | 简单 (仅需 token) |
|
| 渠道 | 设置难度 | 特性说明 | 文档链接 |
|
||||||
| **Discord** | 简单 (bot token + intents) |
|
| -------------------- | ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||||
| **QQ** | 简单 (AppID + AppSecret) |
|
| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](docs/channels/telegram/README.zh.md) |
|
||||||
| **钉钉 (DingTalk)** | 中等 (应用凭证) |
|
| **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](docs/channels/discord/README.zh.md) |
|
||||||
| **企业微信 (WeCom)** | 中等 (企业ID + Webhook配置) |
|
| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) |
|
||||||
|
| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) |
|
||||||
<details>
|
| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) |
|
||||||
<summary><b>Telegram</b> (推荐)</summary>
|
| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)和自建应用(API) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) |
|
||||||
|
| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](docs/channels/feishu/README.zh.md) |
|
||||||
**1. 创建机器人**
|
| **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) |
|
||||||
|
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
|
||||||
* 打开 Telegram,搜索 `@BotFather`
|
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
|
||||||
* 发送 `/newbot`,按照提示操作
|
|
||||||
* 复制 token
|
|
||||||
|
|
||||||
**2. 配置**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"channels": {
|
|
||||||
"telegram": {
|
|
||||||
"enabled": true,
|
|
||||||
"token": "YOUR_BOT_TOKEN",
|
|
||||||
"allow_from": ["YOUR_USER_ID"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
> 从 Telegram 上的 `@userinfobot` 获取您的用户 ID。
|
|
||||||
|
|
||||||
**3. 运行**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
picoclaw gateway
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Discord</b></summary>
|
|
||||||
|
|
||||||
**1. 创建机器人**
|
|
||||||
|
|
||||||
* 前往 [https://discord.com/developers/applications](https://discord.com/developers/applications)
|
|
||||||
* Create an application → Bot → Add Bot
|
|
||||||
* 复制 bot token
|
|
||||||
|
|
||||||
**2. 开启 Intents**
|
|
||||||
|
|
||||||
* 在 Bot 设置中,开启 **MESSAGE CONTENT INTENT**
|
|
||||||
* (可选) 如果计划基于成员数据使用白名单,开启 **SERVER MEMBERS INTENT**
|
|
||||||
|
|
||||||
**3. 获取您的 User ID**
|
|
||||||
|
|
||||||
* Discord 设置 → Advanced → 开启 **Developer Mode**
|
|
||||||
* 右键点击您的头像 → **Copy User ID**
|
|
||||||
|
|
||||||
**4. 配置**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"channels": {
|
|
||||||
"discord": {
|
|
||||||
"enabled": true,
|
|
||||||
"token": "YOUR_BOT_TOKEN",
|
|
||||||
"allow_from": ["YOUR_USER_ID"],
|
|
||||||
"mention_only": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
**5. 邀请机器人**
|
|
||||||
|
|
||||||
* OAuth2 → URL Generator
|
|
||||||
* Scopes: `bot`
|
|
||||||
* Bot Permissions: `Send Messages`, `Read Message History`
|
|
||||||
* 打开生成的邀请 URL,将机器人添加到您的服务器
|
|
||||||
|
|
||||||
**6. 运行**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
picoclaw gateway
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>QQ</b></summary>
|
|
||||||
|
|
||||||
**1. 创建机器人**
|
|
||||||
|
|
||||||
* 前往 [QQ 开放平台](https://q.qq.com/#)
|
|
||||||
* 创建应用 → 获取 **AppID** 和 **AppSecret**
|
|
||||||
|
|
||||||
**2. 配置**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"channels": {
|
|
||||||
"qq": {
|
|
||||||
"enabled": true,
|
|
||||||
"app_id": "YOUR_APP_ID",
|
|
||||||
"app_secret": "YOUR_APP_SECRET",
|
|
||||||
"allow_from": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
> 将 `allow_from` 设为空以允许所有用户,或指定 QQ 号以限制访问。
|
|
||||||
|
|
||||||
**3. 运行**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
picoclaw gateway
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>钉钉 (DingTalk)</b></summary>
|
|
||||||
|
|
||||||
**1. 创建机器人**
|
|
||||||
|
|
||||||
* 前往 [开放平台](https://open.dingtalk.com/)
|
|
||||||
* 创建内部应用
|
|
||||||
* 复制 Client ID 和 Client Secret
|
|
||||||
|
|
||||||
**2. 配置**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"channels": {
|
|
||||||
"dingtalk": {
|
|
||||||
"enabled": true,
|
|
||||||
"client_id": "YOUR_CLIENT_ID",
|
|
||||||
"client_secret": "YOUR_CLIENT_SECRET",
|
|
||||||
"allow_from": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
> 将 `allow_from` 设为空以允许所有用户,或指定 ID 以限制访问。
|
|
||||||
|
|
||||||
**3. 运行**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
picoclaw gateway
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>企业微信 (WeCom)</b></summary>
|
|
||||||
|
|
||||||
PicoClaw 支持两种企业微信集成方式:
|
|
||||||
|
|
||||||
**选项1: 智能机器人 (WeCom Bot)** - 设置更简单,支持群聊
|
|
||||||
**选项2: 自建应用 (WeCom App)** - 功能更丰富,支持主动推送消息
|
|
||||||
|
|
||||||
详见 [企业微信自建应用配置指南](docs/wecom-app-configuration.md)。
|
|
||||||
|
|
||||||
**快速设置 - 智能机器人:**
|
|
||||||
|
|
||||||
**1. 创建机器人**
|
|
||||||
|
|
||||||
* 前往企业微信管理后台 → 群聊 → 添加群机器人
|
|
||||||
* 复制 Webhook URL (格式: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
|
|
||||||
|
|
||||||
**2. 配置**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"channels": {
|
|
||||||
"wecom": {
|
|
||||||
"enabled": true,
|
|
||||||
"token": "YOUR_TOKEN",
|
|
||||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
|
||||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18793,
|
|
||||||
"webhook_path": "/webhook/wecom",
|
|
||||||
"allow_from": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**快速设置 - 自建应用:**
|
|
||||||
|
|
||||||
**1. 创建应用**
|
|
||||||
|
|
||||||
* 前往企业微信管理后台 → 应用管理 → 创建应用
|
|
||||||
* 复制 **AgentId** 和 **Secret**
|
|
||||||
* 前往"我的企业"页面,复制 **CorpID**
|
|
||||||
|
|
||||||
**2. 配置接收消息**
|
|
||||||
|
|
||||||
* 在应用详情页,点击"接收消息" → "设置API"
|
|
||||||
* 设置 URL 为 `http://your-server:18792/webhook/wecom-app`
|
|
||||||
* 生成 **Token** 和 **EncodingAESKey**
|
|
||||||
|
|
||||||
**3. 配置**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"channels": {
|
|
||||||
"wecom_app": {
|
|
||||||
"enabled": true,
|
|
||||||
"corp_id": "wwxxxxxxxxxxxxxxxx",
|
|
||||||
"corp_secret": "YOUR_CORP_SECRET",
|
|
||||||
"agent_id": 1000002,
|
|
||||||
"token": "YOUR_TOKEN",
|
|
||||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
|
||||||
"webhook_host": "0.0.0.0",
|
|
||||||
"webhook_port": 18792,
|
|
||||||
"webhook_path": "/webhook/wecom-app",
|
|
||||||
"allow_from": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**4. 运行**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
picoclaw gateway
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
> **注意**: 自建应用需要开放 18792 端口用于接收 Webhook 回调。生产环境建议使用反向代理配置 HTTPS。
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
||||||
|
|
||||||
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
||||||
|
|
||||||
**阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai](https://clawdchat.ai)
|
\*\*阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai](https://clawdchat.ai)
|
||||||
|
|
||||||
## ⚙️ 配置详解
|
## ⚙️ 配置详解
|
||||||
|
|
||||||
@@ -567,7 +345,6 @@ PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md`
|
|||||||
- Check my email for important messages
|
- Check my email for important messages
|
||||||
- Review my calendar for upcoming events
|
- Review my calendar for upcoming events
|
||||||
- Check the weather forecast
|
- Check the weather forecast
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具执行任务。
|
Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具执行任务。
|
||||||
@@ -580,18 +357,19 @@ Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具
|
|||||||
# Periodic Tasks
|
# Periodic Tasks
|
||||||
|
|
||||||
## Quick Tasks (respond directly)
|
## Quick Tasks (respond directly)
|
||||||
|
|
||||||
- Report current time
|
- Report current time
|
||||||
|
|
||||||
## Long Tasks (use spawn for async)
|
## Long Tasks (use spawn for async)
|
||||||
|
|
||||||
- Search the web for AI news and summarize
|
- Search the web for AI news and summarize
|
||||||
- Check email and report important messages
|
- Check email and report important messages
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**关键行为:**
|
**关键行为:**
|
||||||
|
|
||||||
| 特性 | 描述 |
|
| 特性 | 描述 |
|
||||||
| --- | --- |
|
| ---------------- | ---------------------------------------- |
|
||||||
| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 |
|
| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 |
|
||||||
| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 |
|
| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 |
|
||||||
| **message tool** | 子 Agent 通过 message 工具直接与用户通信 |
|
| **message tool** | 子 Agent 通过 message 工具直接与用户通信 |
|
||||||
@@ -625,18 +403,17 @@ Agent 读取 HEARTBEAT.md
|
|||||||
"interval": 30
|
"interval": 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| 选项 | 默认值 | 描述 |
|
| 选项 | 默认值 | 描述 |
|
||||||
| --- | --- | --- |
|
| ---------- | ------ | ---------------------------- |
|
||||||
| `enabled` | `true` | 启用/禁用心跳 |
|
| `enabled` | `true` | 启用/禁用心跳 |
|
||||||
| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) |
|
| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) |
|
||||||
|
|
||||||
**环境变量:**
|
**环境变量:**
|
||||||
|
|
||||||
* `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用
|
- `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用
|
||||||
* `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔
|
- `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔
|
||||||
|
|
||||||
### 提供商 (Providers)
|
### 提供商 (Providers)
|
||||||
|
|
||||||
@@ -644,7 +421,7 @@ Agent 读取 HEARTBEAT.md
|
|||||||
> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,Telegram 语音消息将被自动转录为文字。
|
> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,Telegram 语音消息将被自动转录为文字。
|
||||||
|
|
||||||
| 提供商 | 用途 | 获取 API Key |
|
| 提供商 | 用途 | 获取 API Key |
|
||||||
| --- | --- | --- |
|
| -------------------- | ---------------------------- | -------------------------------------------------------------------- |
|
||||||
| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
|
| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
|
||||||
| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) |
|
| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) |
|
||||||
| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
|
| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
|
||||||
@@ -669,7 +446,7 @@ Agent 读取 HEARTBEAT.md
|
|||||||
#### 📋 所有支持的厂商
|
#### 📋 所有支持的厂商
|
||||||
|
|
||||||
| 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key |
|
| 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key |
|
||||||
|------|-------------|---------------|------|--------------|
|
| ------------------- | ----------------- | --------------------------------------------------- | --------- | ----------------------------------------------------------------- |
|
||||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) |
|
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) |
|
||||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) |
|
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) |
|
||||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||||
@@ -720,6 +497,7 @@ Agent 读取 HEARTBEAT.md
|
|||||||
#### 各厂商配置示例
|
#### 各厂商配置示例
|
||||||
|
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.2",
|
||||||
@@ -729,6 +507,7 @@ Agent 读取 HEARTBEAT.md
|
|||||||
```
|
```
|
||||||
|
|
||||||
**智谱 AI (GLM)**
|
**智谱 AI (GLM)**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
@@ -738,6 +517,7 @@ Agent 读取 HEARTBEAT.md
|
|||||||
```
|
```
|
||||||
|
|
||||||
**DeepSeek**
|
**DeepSeek**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "deepseek-chat",
|
"model_name": "deepseek-chat",
|
||||||
@@ -747,6 +527,7 @@ Agent 读取 HEARTBEAT.md
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Anthropic (使用 OAuth)**
|
**Anthropic (使用 OAuth)**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
@@ -754,9 +535,11 @@ Agent 读取 HEARTBEAT.md
|
|||||||
"auth_method": "oauth"
|
"auth_method": "oauth"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。
|
> 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。
|
||||||
|
|
||||||
**Ollama (本地)**
|
**Ollama (本地)**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "llama3",
|
"model_name": "llama3",
|
||||||
@@ -765,12 +548,14 @@ Agent 读取 HEARTBEAT.md
|
|||||||
```
|
```
|
||||||
|
|
||||||
**自定义代理/API**
|
**自定义代理/API**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "my-custom-model",
|
"model_name": "my-custom-model",
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -802,6 +587,7 @@ Agent 读取 HEARTBEAT.md
|
|||||||
旧的 `providers` 配置格式**已弃用**,但为向后兼容仍支持。
|
旧的 `providers` 配置格式**已弃用**,但为向后兼容仍支持。
|
||||||
|
|
||||||
**旧配置(已弃用):**
|
**旧配置(已弃用):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"providers": {
|
"providers": {
|
||||||
@@ -820,6 +606,7 @@ Agent 读取 HEARTBEAT.md
|
|||||||
```
|
```
|
||||||
|
|
||||||
**新配置(推荐):**
|
**新配置(推荐):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
@@ -844,7 +631,7 @@ Agent 读取 HEARTBEAT.md
|
|||||||
|
|
||||||
**1. 获取 API key 和 base URL**
|
**1. 获取 API key 和 base URL**
|
||||||
|
|
||||||
* 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
|
- 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
|
||||||
|
|
||||||
**2. 配置**
|
**2. 配置**
|
||||||
|
|
||||||
@@ -866,7 +653,6 @@ Agent 读取 HEARTBEAT.md
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**3. 运行**
|
**3. 运行**
|
||||||
@@ -946,7 +732,6 @@ picoclaw agent -m "你好"
|
|||||||
"interval": 30
|
"interval": 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -954,7 +739,7 @@ picoclaw agent -m "你好"
|
|||||||
## CLI 命令行参考
|
## CLI 命令行参考
|
||||||
|
|
||||||
| 命令 | 描述 |
|
| 命令 | 描述 |
|
||||||
| --- | --- |
|
| ------------------------- | ------------------ |
|
||||||
| `picoclaw onboard` | 初始化配置和工作区 |
|
| `picoclaw onboard` | 初始化配置和工作区 |
|
||||||
| `picoclaw agent -m "..."` | 与 Agent 对话 |
|
| `picoclaw agent -m "..."` | 与 Agent 对话 |
|
||||||
| `picoclaw agent` | 交互式聊天模式 |
|
| `picoclaw agent` | 交互式聊天模式 |
|
||||||
@@ -967,9 +752,9 @@ picoclaw agent -m "你好"
|
|||||||
|
|
||||||
PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
|
PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
|
||||||
|
|
||||||
* **一次性提醒**: "Remind me in 10 minutes" (10分钟后提醒我) → 10分钟后触发一次
|
- **一次性提醒**: "Remind me in 10 minutes" (10分钟后提醒我) → 10分钟后触发一次
|
||||||
* **重复任务**: "Remind me every 2 hours" (每2小时提醒我) → 每2小时触发
|
- **重复任务**: "Remind me every 2 hours" (每2小时提醒我) → 每2小时触发
|
||||||
* **Cron 表达式**: "Remind me at 9am daily" (每天上午9点提醒我) → 使用 cron 表达式
|
- **Cron 表达式**: "Remind me at 9am daily" (每天上午9点提醒我) → 使用 cron 表达式
|
||||||
|
|
||||||
任务存储在 `~/.picoclaw/workspace/cron/` 中并自动处理。
|
任务存储在 `~/.picoclaw/workspace/cron/` 中并自动处理。
|
||||||
|
|
||||||
@@ -995,8 +780,9 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
|||||||
|
|
||||||
启用网络搜索:
|
启用网络搜索:
|
||||||
|
|
||||||
1. 在 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (每月 2000 次免费查询)
|
1. 在 [https://tavily.com](https://tavily.com) (1000 次免费) 或 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (2000 次免费)
|
||||||
2. 添加到 `~/.picoclaw/config.json`:
|
2. 添加到 `~/.picoclaw/config.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"tools": {
|
"tools": {
|
||||||
@@ -1013,11 +799,8 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### 遇到内容过滤错误 (Content Filtering Errors)
|
### 遇到内容过滤错误 (Content Filtering Errors)
|
||||||
|
|
||||||
某些提供商(如智谱)有严格的内容过滤。尝试改写您的问题或使用其他模型。
|
某些提供商(如智谱)有严格的内容过滤。尝试改写您的问题或使用其他模型。
|
||||||
@@ -1035,5 +818,5 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
|||||||
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
|
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
|
||||||
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
|
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
|
||||||
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
|
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
|
||||||
|
| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
|
||||||
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
|
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
|
||||||
| **Cerebras** | 提供免费层级 | 极速推理 (Llama, Qwen 等) |
|
|
||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 366 KiB |
@@ -0,0 +1,49 @@
|
|||||||
|
package configstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
configDirName = ".picoclaw"
|
||||||
|
configFileName = "config.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ConfigPath() (string, error) {
|
||||||
|
dir, err := ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, configFileName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConfigDir() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(home, configDirName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (*picoclawconfig.Config, error) {
|
||||||
|
path, err := ConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return picoclawconfig.LoadConfig(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Save(cfg *picoclawconfig.Config) error {
|
||||||
|
if cfg == nil {
|
||||||
|
return errors.New("config is nil")
|
||||||
|
}
|
||||||
|
path, err := ConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return picoclawconfig.SaveConfig(path, cfg)
|
||||||
|
}
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
|
configstore "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/config"
|
||||||
|
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type appState struct {
|
||||||
|
app *tview.Application
|
||||||
|
pages *tview.Pages
|
||||||
|
stack []string
|
||||||
|
config *picoclawconfig.Config
|
||||||
|
configPath string
|
||||||
|
gatewayCmd *exec.Cmd
|
||||||
|
menus map[string]*Menu
|
||||||
|
original []byte
|
||||||
|
hasOriginal bool
|
||||||
|
backupPath string
|
||||||
|
dirty bool
|
||||||
|
logPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run() error {
|
||||||
|
applyStyles()
|
||||||
|
cfg, err := configstore.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path, err := configstore.ConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = picoclawconfig.DefaultConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
originalData, hasOriginal := loadOriginalConfig(path)
|
||||||
|
backupPath := path + ".bak"
|
||||||
|
if hasOriginal {
|
||||||
|
_ = writeBackupConfig(backupPath, originalData)
|
||||||
|
}
|
||||||
|
|
||||||
|
logPath := filepath.Join(filepath.Dir(path), "gateway.log")
|
||||||
|
state := &appState{
|
||||||
|
app: tview.NewApplication(),
|
||||||
|
pages: tview.NewPages(),
|
||||||
|
config: cfg,
|
||||||
|
configPath: path,
|
||||||
|
menus: map[string]*Menu{},
|
||||||
|
original: originalData,
|
||||||
|
hasOriginal: hasOriginal,
|
||||||
|
backupPath: backupPath,
|
||||||
|
logPath: logPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
state.push("main", state.mainMenu())
|
||||||
|
|
||||||
|
root := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||||
|
root.AddItem(bannerView(), 6, 0, false)
|
||||||
|
root.AddItem(state.pages, 0, 1, true)
|
||||||
|
|
||||||
|
if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) push(name string, primitive tview.Primitive) {
|
||||||
|
s.pages.AddPage(name, primitive, true, true)
|
||||||
|
s.stack = append(s.stack, name)
|
||||||
|
s.pages.SwitchToPage(name)
|
||||||
|
if menu, ok := primitive.(*Menu); ok {
|
||||||
|
s.menus[name] = menu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) pop() {
|
||||||
|
if len(s.stack) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
last := s.stack[len(s.stack)-1]
|
||||||
|
s.pages.RemovePage(last)
|
||||||
|
s.stack = s.stack[:len(s.stack)-1]
|
||||||
|
if len(s.stack) == 0 {
|
||||||
|
s.app.Stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current := s.stack[len(s.stack)-1]
|
||||||
|
s.pages.SwitchToPage(current)
|
||||||
|
if menu, ok := s.menus[current]; ok {
|
||||||
|
s.refreshMenu(current, menu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) mainMenu() tview.Primitive {
|
||||||
|
menu := NewMenu("Config Menu", nil)
|
||||||
|
refreshMainMenu(menu, s)
|
||||||
|
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
switch event.Key() {
|
||||||
|
case tcell.KeyEsc:
|
||||||
|
s.requestExit()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if event.Rune() == 'q' {
|
||||||
|
s.requestExit()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) refreshMenu(name string, menu *Menu) {
|
||||||
|
switch name {
|
||||||
|
case "main":
|
||||||
|
refreshMainMenu(menu, s)
|
||||||
|
case "model":
|
||||||
|
refreshModelMenuFromState(menu, s)
|
||||||
|
case "channel":
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshMainMenuIfPresent(s *appState) {
|
||||||
|
if menu, ok := s.menus["main"]; ok {
|
||||||
|
refreshMainMenu(menu, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshMainMenu(menu *Menu, s *appState) {
|
||||||
|
selectedModel := s.selectedModelName()
|
||||||
|
modelReady := selectedModel != ""
|
||||||
|
channelReady := s.hasEnabledChannel()
|
||||||
|
gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning()
|
||||||
|
|
||||||
|
gatewayLabel := "Start Gateway"
|
||||||
|
gatewayDescription := "Launch gateway for channels"
|
||||||
|
if gatewayRunning {
|
||||||
|
gatewayLabel = "Stop Gateway"
|
||||||
|
gatewayDescription = "Gateway running"
|
||||||
|
}
|
||||||
|
|
||||||
|
items := []MenuItem{
|
||||||
|
{
|
||||||
|
Label: rootModelLabel(selectedModel),
|
||||||
|
Description: rootModelDescription(selectedModel),
|
||||||
|
Action: func() {
|
||||||
|
s.push("model", s.modelMenu())
|
||||||
|
},
|
||||||
|
MainColor: func() *tcell.Color {
|
||||||
|
if modelReady {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
color := tcell.ColorGray
|
||||||
|
return &color
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: rootChannelLabel(channelReady),
|
||||||
|
Description: rootChannelDescription(channelReady),
|
||||||
|
Action: func() {
|
||||||
|
s.push("channel", s.channelMenu())
|
||||||
|
},
|
||||||
|
MainColor: func() *tcell.Color {
|
||||||
|
if channelReady {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
color := tcell.ColorGray
|
||||||
|
return &color
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: "Start Talk",
|
||||||
|
Description: "Open picoclaw agent in terminal",
|
||||||
|
Action: func() {
|
||||||
|
s.requestStartTalk()
|
||||||
|
},
|
||||||
|
Disabled: !modelReady,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: gatewayLabel,
|
||||||
|
Description: gatewayDescription,
|
||||||
|
Action: func() {
|
||||||
|
if gatewayRunning {
|
||||||
|
s.stopGateway()
|
||||||
|
} else {
|
||||||
|
s.requestStartGateway()
|
||||||
|
}
|
||||||
|
refreshMainMenu(menu, s)
|
||||||
|
},
|
||||||
|
Disabled: !gatewayRunning && (!modelReady || !channelReady),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: "View Gateway Log",
|
||||||
|
Description: "Open gateway.log",
|
||||||
|
Action: func() {
|
||||||
|
s.viewGatewayLog()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: "Exit",
|
||||||
|
Description: "Exit the TUI",
|
||||||
|
Action: func() {
|
||||||
|
s.requestExit()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
menu.applyItems(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) applyChangesValidated() bool {
|
||||||
|
if err := s.config.ValidateModelList(); err != nil {
|
||||||
|
s.showMessage("Validation failed", err.Error())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := s.validateAgentModel(); err != nil {
|
||||||
|
s.showMessage("Validation failed", err.Error())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := configstore.Save(s.config); err != nil {
|
||||||
|
s.showMessage("Save failed", err.Error())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if data, err := os.ReadFile(s.configPath); err == nil {
|
||||||
|
s.original = data
|
||||||
|
s.hasOriginal = true
|
||||||
|
_ = writeBackupConfig(s.backupPath, data)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) requestExit() {
|
||||||
|
if s.dirty {
|
||||||
|
s.confirmApplyOrDiscard(func() {
|
||||||
|
s.app.Stop()
|
||||||
|
}, func() {
|
||||||
|
s.discardChanges()
|
||||||
|
s.app.Stop()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.app.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) requestStartTalk() {
|
||||||
|
if s.dirty {
|
||||||
|
s.confirmApplyOrDiscard(func() {
|
||||||
|
s.startTalk()
|
||||||
|
}, func() {
|
||||||
|
s.startTalk()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.startTalk()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) requestStartGateway() {
|
||||||
|
if s.dirty {
|
||||||
|
s.confirmApplyOrDiscard(func() {
|
||||||
|
s.startGateway()
|
||||||
|
}, func() {
|
||||||
|
s.startGateway()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.startGateway()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) viewGatewayLog() {
|
||||||
|
data, err := os.ReadFile(s.logPath)
|
||||||
|
if err != nil {
|
||||||
|
s.showMessage("Log not found", "gateway.log not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text := tview.NewTextView()
|
||||||
|
text.SetBorder(true).SetTitle("Gateway Log")
|
||||||
|
text.SetText(string(data))
|
||||||
|
text.SetDoneFunc(func(key tcell.Key) {
|
||||||
|
s.pages.RemovePage("log")
|
||||||
|
})
|
||||||
|
text.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEsc {
|
||||||
|
s.pages.RemovePage("log")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
s.pages.AddPage("log", text, true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) selectedModelName() string {
|
||||||
|
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||||
|
if modelName == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !s.isActiveModelValid() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return modelName
|
||||||
|
}
|
||||||
|
|
||||||
|
func rootModelLabel(selected string) string {
|
||||||
|
if selected == "" {
|
||||||
|
return "Model (no model selected)"
|
||||||
|
}
|
||||||
|
return "Model (" + selected + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
func rootModelDescription(selected string) string {
|
||||||
|
if selected == "" {
|
||||||
|
return "no model selected"
|
||||||
|
}
|
||||||
|
return "selected"
|
||||||
|
}
|
||||||
|
|
||||||
|
func rootChannelLabel(valid bool) string {
|
||||||
|
if !valid {
|
||||||
|
return "Channel (no channel enabled)"
|
||||||
|
}
|
||||||
|
return "Channel"
|
||||||
|
}
|
||||||
|
|
||||||
|
func rootChannelDescription(valid bool) string {
|
||||||
|
if !valid {
|
||||||
|
return "no channel enabled"
|
||||||
|
}
|
||||||
|
return "enabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) startTalk() {
|
||||||
|
if !s.isActiveModelValid() {
|
||||||
|
s.showMessage("Model required", "Select a valid model before starting talk")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.applyChangesValidated() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.app.Suspend(func() {
|
||||||
|
cmd := exec.Command("picoclaw", "agent")
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
_ = cmd.Run()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) startGateway() {
|
||||||
|
if !s.isActiveModelValid() {
|
||||||
|
s.showMessage("Model required", "Select a valid model before starting gateway")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.hasEnabledChannel() {
|
||||||
|
s.showMessage("Channel required", "Enable at least one channel before starting gateway")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.applyChangesValidated() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = stopGatewayProcess()
|
||||||
|
cmd := exec.Command("picoclaw", "gateway")
|
||||||
|
logFile, err := os.OpenFile(s.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
s.showMessage("Gateway failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmd.Stdout = logFile
|
||||||
|
cmd.Stderr = logFile
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
s.showMessage("Gateway failed", err.Error())
|
||||||
|
_ = logFile.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = logFile.Close()
|
||||||
|
s.gatewayCmd = cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) stopGateway() {
|
||||||
|
_ = stopGatewayProcess()
|
||||||
|
if s.gatewayCmd != nil && s.gatewayCmd.Process != nil {
|
||||||
|
_ = s.gatewayCmd.Process.Kill()
|
||||||
|
}
|
||||||
|
s.gatewayCmd = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) isGatewayRunning() bool {
|
||||||
|
return isGatewayProcessRunning()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) validateAgentModel() error {
|
||||||
|
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||||
|
if modelName == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := s.config.GetModelConfig(modelName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) isActiveModelValid() bool {
|
||||||
|
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||||
|
if modelName == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cfg, err := s.config.GetModelConfig(modelName)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hasKey := strings.TrimSpace(cfg.APIKey) != "" || strings.TrimSpace(cfg.AuthMethod) == "oauth"
|
||||||
|
hasModel := strings.TrimSpace(cfg.Model) != ""
|
||||||
|
return hasKey && hasModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) hasEnabledChannel() bool {
|
||||||
|
c := s.config.Channels
|
||||||
|
return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled ||
|
||||||
|
c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled ||
|
||||||
|
c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) confirmApplyOrDiscard(onApply func(), onDiscard func()) {
|
||||||
|
if s.pages.HasPage("apply") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modal := tview.NewModal().
|
||||||
|
SetText("Apply changes or discard before continuing?").
|
||||||
|
AddButtons([]string{"Cancel", "Discard", "Apply"}).
|
||||||
|
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||||
|
s.pages.RemovePage("apply")
|
||||||
|
switch buttonLabel {
|
||||||
|
case "Discard":
|
||||||
|
s.discardChanges()
|
||||||
|
if onDiscard != nil {
|
||||||
|
onDiscard()
|
||||||
|
}
|
||||||
|
case "Apply":
|
||||||
|
if s.applyChangesValidated() {
|
||||||
|
s.dirty = false
|
||||||
|
if onApply != nil {
|
||||||
|
onApply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
modal.SetBorder(true)
|
||||||
|
s.pages.AddPage("apply", modal, true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) discardChanges() {
|
||||||
|
if s.hasOriginal {
|
||||||
|
_ = writeOriginalConfig(s.configPath, s.original)
|
||||||
|
} else {
|
||||||
|
_ = os.Remove(s.configPath)
|
||||||
|
}
|
||||||
|
_ = os.Remove(s.backupPath)
|
||||||
|
if cfg, err := configstore.Load(); err == nil && cfg != nil {
|
||||||
|
s.config = cfg
|
||||||
|
}
|
||||||
|
s.dirty = false
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) showMessage(title, message string) {
|
||||||
|
if s.pages.HasPage("message") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modal := tview.NewModal().
|
||||||
|
SetText(strings.TrimSpace(message)).
|
||||||
|
AddButtons([]string{"OK"}).
|
||||||
|
SetDoneFunc(func(_ int, _ string) {
|
||||||
|
s.pages.RemovePage("message")
|
||||||
|
})
|
||||||
|
modal.SetTitle(title).SetBorder(true)
|
||||||
|
modal.SetBackgroundColor(tview.Styles.ContrastBackgroundColor)
|
||||||
|
modal.SetTextColor(tview.Styles.PrimaryTextColor)
|
||||||
|
modal.SetButtonBackgroundColor(tcell.NewRGBColor(112, 102, 255))
|
||||||
|
modal.SetButtonTextColor(tview.Styles.PrimaryTextColor)
|
||||||
|
s.pages.AddPage("message", modal, true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOriginalConfig(path string) ([]byte, bool) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return data, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeOriginalConfig(path string, data []byte) error {
|
||||||
|
return os.WriteFile(path, data, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeBackupConfig(path string, data []byte) error {
|
||||||
|
return os.WriteFile(path, data, 0o600)
|
||||||
|
}
|
||||||
@@ -0,0 +1,574 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
|
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *appState) channelMenu() tview.Primitive {
|
||||||
|
items := []MenuItem{
|
||||||
|
{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||||
|
channelItem(
|
||||||
|
"Telegram",
|
||||||
|
"Telegram bot settings",
|
||||||
|
s.config.Channels.Telegram.Enabled,
|
||||||
|
func() { s.push("channel-telegram", s.telegramForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"Discord",
|
||||||
|
"Discord bot settings",
|
||||||
|
s.config.Channels.Discord.Enabled,
|
||||||
|
func() { s.push("channel-discord", s.discordForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"QQ",
|
||||||
|
"QQ bot settings",
|
||||||
|
s.config.Channels.QQ.Enabled,
|
||||||
|
func() { s.push("channel-qq", s.qqForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"MaixCam",
|
||||||
|
"MaixCam gateway",
|
||||||
|
s.config.Channels.MaixCam.Enabled,
|
||||||
|
func() { s.push("channel-maixcam", s.maixcamForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"WhatsApp",
|
||||||
|
"WhatsApp bridge",
|
||||||
|
s.config.Channels.WhatsApp.Enabled,
|
||||||
|
func() { s.push("channel-whatsapp", s.whatsappForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"Feishu",
|
||||||
|
"Feishu bot settings",
|
||||||
|
s.config.Channels.Feishu.Enabled,
|
||||||
|
func() { s.push("channel-feishu", s.feishuForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"DingTalk",
|
||||||
|
"DingTalk bot settings",
|
||||||
|
s.config.Channels.DingTalk.Enabled,
|
||||||
|
func() { s.push("channel-dingtalk", s.dingtalkForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"Slack",
|
||||||
|
"Slack bot settings",
|
||||||
|
s.config.Channels.Slack.Enabled,
|
||||||
|
func() { s.push("channel-slack", s.slackForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"LINE",
|
||||||
|
"LINE bot settings",
|
||||||
|
s.config.Channels.LINE.Enabled,
|
||||||
|
func() { s.push("channel-line", s.lineForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"OneBot",
|
||||||
|
"OneBot settings",
|
||||||
|
s.config.Channels.OneBot.Enabled,
|
||||||
|
func() { s.push("channel-onebot", s.onebotForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"WeCom",
|
||||||
|
"WeCom bot settings",
|
||||||
|
s.config.Channels.WeCom.Enabled,
|
||||||
|
func() { s.push("channel-wecom", s.wecomForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"WeCom App",
|
||||||
|
"WeCom App settings",
|
||||||
|
s.config.Channels.WeComApp.Enabled,
|
||||||
|
func() { s.push("channel-wecomapp", s.wecomAppForm()) },
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
menu := NewMenu("Channels", items)
|
||||||
|
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEsc {
|
||||||
|
s.pop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if event.Rune() == 'q' {
|
||||||
|
s.pop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshChannelMenuFromState(menu *Menu, s *appState) {
|
||||||
|
items := []MenuItem{
|
||||||
|
{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||||
|
channelItem(
|
||||||
|
"Telegram",
|
||||||
|
"Telegram bot settings",
|
||||||
|
s.config.Channels.Telegram.Enabled,
|
||||||
|
func() { s.push("channel-telegram", s.telegramForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"Discord",
|
||||||
|
"Discord bot settings",
|
||||||
|
s.config.Channels.Discord.Enabled,
|
||||||
|
func() { s.push("channel-discord", s.discordForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"QQ",
|
||||||
|
"QQ bot settings",
|
||||||
|
s.config.Channels.QQ.Enabled,
|
||||||
|
func() { s.push("channel-qq", s.qqForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"MaixCam",
|
||||||
|
"MaixCam gateway",
|
||||||
|
s.config.Channels.MaixCam.Enabled,
|
||||||
|
func() { s.push("channel-maixcam", s.maixcamForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"WhatsApp",
|
||||||
|
"WhatsApp bridge",
|
||||||
|
s.config.Channels.WhatsApp.Enabled,
|
||||||
|
func() { s.push("channel-whatsapp", s.whatsappForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"Feishu",
|
||||||
|
"Feishu bot settings",
|
||||||
|
s.config.Channels.Feishu.Enabled,
|
||||||
|
func() { s.push("channel-feishu", s.feishuForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"DingTalk",
|
||||||
|
"DingTalk bot settings",
|
||||||
|
s.config.Channels.DingTalk.Enabled,
|
||||||
|
func() { s.push("channel-dingtalk", s.dingtalkForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"Slack",
|
||||||
|
"Slack bot settings",
|
||||||
|
s.config.Channels.Slack.Enabled,
|
||||||
|
func() { s.push("channel-slack", s.slackForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"LINE",
|
||||||
|
"LINE bot settings",
|
||||||
|
s.config.Channels.LINE.Enabled,
|
||||||
|
func() { s.push("channel-line", s.lineForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"OneBot",
|
||||||
|
"OneBot settings",
|
||||||
|
s.config.Channels.OneBot.Enabled,
|
||||||
|
func() { s.push("channel-onebot", s.onebotForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"WeCom",
|
||||||
|
"WeCom bot settings",
|
||||||
|
s.config.Channels.WeCom.Enabled,
|
||||||
|
func() { s.push("channel-wecom", s.wecomForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"WeCom App",
|
||||||
|
"WeCom App settings",
|
||||||
|
s.config.Channels.WeComApp.Enabled,
|
||||||
|
func() { s.push("channel-wecomapp", s.wecomAppForm()) },
|
||||||
|
),
|
||||||
|
}
|
||||||
|
menu.applyItems(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) telegramForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.Telegram
|
||||||
|
form := baseChannelForm("Telegram", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
||||||
|
cfg.Token = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Proxy", cfg.Proxy, 128, nil, func(text string) {
|
||||||
|
cfg.Proxy = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) discordForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.Discord
|
||||||
|
form := baseChannelForm("Discord", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
||||||
|
cfg.Token = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddCheckbox("Mention Only", cfg.MentionOnly, func(checked bool) {
|
||||||
|
cfg.MentionOnly = checked
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) qqForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.QQ
|
||||||
|
form := baseChannelForm("QQ", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
|
||||||
|
cfg.AppID = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
|
||||||
|
cfg.AppSecret = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) maixcamForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.MaixCam
|
||||||
|
form := baseChannelForm("MaixCam", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Host", cfg.Host, 64, nil, func(text string) {
|
||||||
|
cfg.Host = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
addIntField(form, "Port", cfg.Port, func(value int) { cfg.Port = value })
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) whatsappForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.WhatsApp
|
||||||
|
form := baseChannelForm("WhatsApp", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Bridge URL", cfg.BridgeURL, 128, nil, func(text string) {
|
||||||
|
cfg.BridgeURL = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) feishuForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.Feishu
|
||||||
|
form := baseChannelForm("Feishu", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
|
||||||
|
cfg.AppID = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
|
||||||
|
cfg.AppSecret = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Encrypt Key", cfg.EncryptKey, 128, nil, func(text string) {
|
||||||
|
cfg.EncryptKey = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Verification Token", cfg.VerificationToken, 128, nil, func(text string) {
|
||||||
|
cfg.VerificationToken = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) dingtalkForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.DingTalk
|
||||||
|
form := baseChannelForm("DingTalk", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Client ID", cfg.ClientID, 64, nil, func(text string) {
|
||||||
|
cfg.ClientID = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Client Secret", cfg.ClientSecret, 128, nil, func(text string) {
|
||||||
|
cfg.ClientSecret = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) slackForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.Slack
|
||||||
|
form := baseChannelForm("Slack", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Bot Token", cfg.BotToken, 128, nil, func(text string) {
|
||||||
|
cfg.BotToken = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("App Token", cfg.AppToken, 128, nil, func(text string) {
|
||||||
|
cfg.AppToken = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) lineForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.LINE
|
||||||
|
form := baseChannelForm("LINE", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Channel Secret", cfg.ChannelSecret, 128, nil, func(text string) {
|
||||||
|
cfg.ChannelSecret = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Channel Access Token", cfg.ChannelAccessToken, 128, nil, func(text string) {
|
||||||
|
cfg.ChannelAccessToken = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
||||||
|
cfg.WebhookHost = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
|
||||||
|
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
|
||||||
|
cfg.WebhookPath = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) onebotForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.OneBot
|
||||||
|
form := baseChannelForm("OneBot", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("WS URL", cfg.WSUrl, 128, nil, func(text string) {
|
||||||
|
cfg.WSUrl = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
|
||||||
|
cfg.AccessToken = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
addIntField(
|
||||||
|
form,
|
||||||
|
"Reconnect Interval",
|
||||||
|
cfg.ReconnectInterval,
|
||||||
|
func(value int) { cfg.ReconnectInterval = value },
|
||||||
|
)
|
||||||
|
form.AddInputField(
|
||||||
|
"Group Trigger Prefix",
|
||||||
|
strings.Join(cfg.GroupTriggerPrefix, ","),
|
||||||
|
128,
|
||||||
|
nil,
|
||||||
|
func(text string) {
|
||||||
|
cfg.GroupTriggerPrefix = splitCSV(text)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) wecomForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.WeCom
|
||||||
|
form := baseChannelForm("WeCom", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
||||||
|
cfg.Token = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
|
||||||
|
cfg.EncodingAESKey = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Webhook URL", cfg.WebhookURL, 128, nil, func(text string) {
|
||||||
|
cfg.WebhookURL = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
||||||
|
cfg.WebhookHost = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
|
||||||
|
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
|
||||||
|
cfg.WebhookPath = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
addIntField(
|
||||||
|
form,
|
||||||
|
"Reply Timeout",
|
||||||
|
cfg.ReplyTimeout,
|
||||||
|
func(value int) { cfg.ReplyTimeout = value },
|
||||||
|
)
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) wecomAppForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.WeComApp
|
||||||
|
form := baseChannelForm("WeCom App", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Corp ID", cfg.CorpID, 64, nil, func(text string) {
|
||||||
|
cfg.CorpID = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Corp Secret", cfg.CorpSecret, 128, nil, func(text string) {
|
||||||
|
cfg.CorpSecret = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
addInt64Field(form, "Agent ID", cfg.AgentID, func(value int64) { cfg.AgentID = value })
|
||||||
|
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
||||||
|
cfg.Token = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
|
||||||
|
cfg.EncodingAESKey = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
||||||
|
cfg.WebhookHost = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
|
||||||
|
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
|
||||||
|
cfg.WebhookPath = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
addIntField(
|
||||||
|
form,
|
||||||
|
"Reply Timeout",
|
||||||
|
cfg.ReplyTimeout,
|
||||||
|
func(value int) { cfg.ReplyTimeout = value },
|
||||||
|
)
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseChannelForm(title string, enabled bool, onEnabled func(bool)) *tview.Form {
|
||||||
|
form := tview.NewForm()
|
||||||
|
form.SetBorder(true).SetTitle(fmt.Sprintf("Channel: %s", title))
|
||||||
|
form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
|
||||||
|
form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
|
||||||
|
form.AddCheckbox("Enabled", enabled, func(checked bool) {
|
||||||
|
onEnabled(checked)
|
||||||
|
})
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapWithBack(form *tview.Form, s *appState) tview.Primitive {
|
||||||
|
form.AddButton("Back", func() {
|
||||||
|
s.pop()
|
||||||
|
})
|
||||||
|
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEsc {
|
||||||
|
s.pop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCSV(input string) picoclawconfig.FlexibleStringSlice {
|
||||||
|
parts := strings.Split(strings.TrimSpace(input), ",")
|
||||||
|
cleaned := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
value := strings.TrimSpace(part)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cleaned = append(cleaned, value)
|
||||||
|
}
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
func addIntField(form *tview.Form, label string, value int, onChange func(int)) {
|
||||||
|
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
|
||||||
|
var parsed int
|
||||||
|
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
|
||||||
|
onChange(parsed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func addInt64Field(form *tview.Form, label string, value int64, onChange func(int64)) {
|
||||||
|
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
|
||||||
|
var parsed int64
|
||||||
|
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
|
||||||
|
onChange(parsed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func channelItem(label, description string, enabled bool, action MenuAction) MenuItem {
|
||||||
|
item := MenuItem{
|
||||||
|
Label: label,
|
||||||
|
Description: description,
|
||||||
|
Action: action,
|
||||||
|
}
|
||||||
|
if !enabled {
|
||||||
|
color := tcell.ColorGray
|
||||||
|
item.MainColor = &color
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//go:build !windows
|
||||||
|
// +build !windows
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
func isGatewayProcessRunning() bool {
|
||||||
|
cmd := exec.Command("sh", "-c", "pgrep -f 'picoclaw\\s+gateway' >/dev/null 2>&1")
|
||||||
|
return cmd.Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopGatewayProcess() error {
|
||||||
|
cmd := exec.Command("sh", "-c", "pkill -f 'picoclaw\\s+gateway' >/dev/null 2>&1")
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//go:build windows
|
||||||
|
// +build windows
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
func isGatewayProcessRunning() bool {
|
||||||
|
cmd := exec.Command("tasklist", "/FI", "IMAGENAME eq picoclaw.exe")
|
||||||
|
return cmd.Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopGatewayProcess() error {
|
||||||
|
cmd := exec.Command("taskkill", "/F", "/IM", "picoclaw.exe")
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MenuAction func()
|
||||||
|
|
||||||
|
type MenuItem struct {
|
||||||
|
Label string
|
||||||
|
Description string
|
||||||
|
Action MenuAction
|
||||||
|
Disabled bool
|
||||||
|
MainColor *tcell.Color
|
||||||
|
DescColor *tcell.Color
|
||||||
|
}
|
||||||
|
|
||||||
|
type Menu struct {
|
||||||
|
*tview.Table
|
||||||
|
items []MenuItem
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMenu(title string, items []MenuItem) *Menu {
|
||||||
|
table := tview.NewTable().SetSelectable(true, false)
|
||||||
|
table.SetBorder(true).SetTitle(title)
|
||||||
|
table.SetBorders(false)
|
||||||
|
menu := &Menu{Table: table, items: items}
|
||||||
|
menu.applyItems(items)
|
||||||
|
menu.SetSelectedFunc(func(row, _ int) {
|
||||||
|
if row < 0 || row >= len(menu.items) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item := menu.items[row]
|
||||||
|
if item.Disabled || item.Action == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item.Action()
|
||||||
|
})
|
||||||
|
menu.SetSelectedStyle(
|
||||||
|
tcell.StyleDefault.Foreground(tview.Styles.InverseTextColor).
|
||||||
|
Background(tcell.NewRGBColor(189, 147, 249)),
|
||||||
|
)
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Menu) applyItems(items []MenuItem) {
|
||||||
|
m.items = items
|
||||||
|
m.Clear()
|
||||||
|
for row, item := range items {
|
||||||
|
label := item.Label
|
||||||
|
if item.Disabled && label != "" {
|
||||||
|
label = label + " (disabled)"
|
||||||
|
}
|
||||||
|
left := tview.NewTableCell(label)
|
||||||
|
right := tview.NewTableCell(item.Description).SetAlign(tview.AlignRight)
|
||||||
|
if item.MainColor != nil {
|
||||||
|
left.SetTextColor(*item.MainColor)
|
||||||
|
}
|
||||||
|
if item.DescColor != nil {
|
||||||
|
right.SetTextColor(*item.DescColor)
|
||||||
|
} else {
|
||||||
|
right.SetTextColor(tview.Styles.TertiaryTextColor)
|
||||||
|
}
|
||||||
|
if item.Disabled {
|
||||||
|
left.SetTextColor(tcell.ColorGray)
|
||||||
|
right.SetTextColor(tcell.ColorGray)
|
||||||
|
}
|
||||||
|
m.SetCell(row, 0, left)
|
||||||
|
m.SetCell(row, 1, right)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
|
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *appState) modelMenu() tview.Primitive {
|
||||||
|
items := make([]MenuItem, 0, 2+len(s.config.ModelList))
|
||||||
|
items = append(items,
|
||||||
|
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||||
|
MenuItem{
|
||||||
|
Label: "Add model",
|
||||||
|
Description: "Append a new model entry",
|
||||||
|
Action: func() {
|
||||||
|
s.addModel(
|
||||||
|
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
|
||||||
|
)
|
||||||
|
s.push(
|
||||||
|
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
||||||
|
s.modelForm(len(s.config.ModelList)-1),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||||
|
for i := range s.config.ModelList {
|
||||||
|
index := i
|
||||||
|
model := s.config.ModelList[i]
|
||||||
|
isValid := isModelValid(model)
|
||||||
|
desc := model.APIBase
|
||||||
|
if desc == "" {
|
||||||
|
desc = model.AuthMethod
|
||||||
|
}
|
||||||
|
if desc == "" {
|
||||||
|
desc = "api_key required"
|
||||||
|
}
|
||||||
|
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
||||||
|
if model.ModelName == currentModel && currentModel != "" {
|
||||||
|
label = "* " + label
|
||||||
|
}
|
||||||
|
isSelected := model.ModelName == currentModel && currentModel != ""
|
||||||
|
items = append(items, MenuItem{
|
||||||
|
Label: label,
|
||||||
|
Description: desc,
|
||||||
|
MainColor: modelStatusColor(isValid, isSelected),
|
||||||
|
Action: func() {
|
||||||
|
s.push(fmt.Sprintf("model-%d", index), s.modelForm(index))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
menu := NewMenu("Models", items)
|
||||||
|
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEsc {
|
||||||
|
s.pop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if event.Rune() == 'q' {
|
||||||
|
s.pop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if event.Rune() == ' ' {
|
||||||
|
row, _ := menu.GetSelection()
|
||||||
|
if row > 0 && row <= len(s.config.ModelList) {
|
||||||
|
model := s.config.ModelList[row-1]
|
||||||
|
if !isModelValid(model) {
|
||||||
|
s.showMessage(
|
||||||
|
"Invalid model",
|
||||||
|
"Select a model with api_key or oauth auth_method",
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.config.Agents.Defaults.Model = model.ModelName
|
||||||
|
s.dirty = true
|
||||||
|
refreshModelMenu(menu, s.config.Agents.Defaults.Model, s.config.ModelList)
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) modelForm(index int) tview.Primitive {
|
||||||
|
model := &s.config.ModelList[index]
|
||||||
|
form := tview.NewForm()
|
||||||
|
form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
|
||||||
|
form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
|
||||||
|
form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
|
||||||
|
|
||||||
|
addInput(form, "Model Name", model.ModelName, func(value string) {
|
||||||
|
model.ModelName = value
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["model"]; ok {
|
||||||
|
refreshModelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
addInput(form, "Model", model.Model, func(value string) {
|
||||||
|
model.Model = value
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["model"]; ok {
|
||||||
|
refreshModelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
addInput(form, "API Base", model.APIBase, func(value string) {
|
||||||
|
model.APIBase = value
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["model"]; ok {
|
||||||
|
refreshModelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
addInput(form, "API Key", model.APIKey, func(value string) {
|
||||||
|
model.APIKey = value
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["model"]; ok {
|
||||||
|
refreshModelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
addInput(form, "Proxy", model.Proxy, func(value string) {
|
||||||
|
model.Proxy = value
|
||||||
|
})
|
||||||
|
addInput(form, "Auth Method", model.AuthMethod, func(value string) {
|
||||||
|
model.AuthMethod = value
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["model"]; ok {
|
||||||
|
refreshModelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
addInput(form, "Connect Mode", model.ConnectMode, func(value string) {
|
||||||
|
model.ConnectMode = value
|
||||||
|
})
|
||||||
|
addInput(form, "Workspace", model.Workspace, func(value string) {
|
||||||
|
model.Workspace = value
|
||||||
|
})
|
||||||
|
addInput(form, "Max Tokens Field", model.MaxTokensField, func(value string) {
|
||||||
|
model.MaxTokensField = value
|
||||||
|
})
|
||||||
|
addIntInput(form, "RPM", model.RPM, func(value int) {
|
||||||
|
model.RPM = value
|
||||||
|
})
|
||||||
|
addIntInput(form, "Request Timeout", model.RequestTimeout, func(value int) {
|
||||||
|
model.RequestTimeout = value
|
||||||
|
})
|
||||||
|
|
||||||
|
form.AddButton("Delete", func() {
|
||||||
|
s.deleteModel(index)
|
||||||
|
})
|
||||||
|
form.AddButton("Test", func() {
|
||||||
|
s.testModel(model)
|
||||||
|
})
|
||||||
|
form.AddButton("Back", func() {
|
||||||
|
s.pop()
|
||||||
|
})
|
||||||
|
|
||||||
|
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEsc {
|
||||||
|
s.pop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
func addInput(form *tview.Form, label, value string, onChange func(string)) {
|
||||||
|
form.AddInputField(label, value, 128, nil, func(text string) {
|
||||||
|
onChange(strings.TrimSpace(text))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func addIntInput(form *tview.Form, label string, value int, onChange func(int)) {
|
||||||
|
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
|
||||||
|
var parsed int
|
||||||
|
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
|
||||||
|
onChange(parsed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) addModel(model picoclawconfig.ModelConfig) {
|
||||||
|
s.config.ModelList = append(s.config.ModelList, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) deleteModel(index int) {
|
||||||
|
if index < 0 || index >= len(s.config.ModelList) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.config.ModelList = append(s.config.ModelList[:index], s.config.ModelList[index+1:]...)
|
||||||
|
s.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelStatusColor(valid bool, selected bool) *tcell.Color {
|
||||||
|
if valid {
|
||||||
|
color := tview.Styles.PrimaryTextColor
|
||||||
|
return &color
|
||||||
|
}
|
||||||
|
color := tcell.ColorGray
|
||||||
|
return &color
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {
|
||||||
|
for i, model := range models {
|
||||||
|
row := i + 1
|
||||||
|
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
||||||
|
isValid := isModelValid(model)
|
||||||
|
if model.ModelName == currentModel && currentModel != "" {
|
||||||
|
label = "* " + label
|
||||||
|
}
|
||||||
|
cell := menu.GetCell(row, 0)
|
||||||
|
if cell != nil {
|
||||||
|
cell.SetText(label)
|
||||||
|
isSelected := model.ModelName == currentModel && currentModel != ""
|
||||||
|
color := modelStatusColor(isValid, isSelected)
|
||||||
|
if color != nil {
|
||||||
|
cell.SetTextColor(*color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshModelMenuFromState(menu *Menu, s *appState) {
|
||||||
|
items := make([]MenuItem, 0, 2+len(s.config.ModelList))
|
||||||
|
items = append(items,
|
||||||
|
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||||
|
MenuItem{
|
||||||
|
Label: "Add model",
|
||||||
|
Description: "Append a new model entry",
|
||||||
|
Action: func() {
|
||||||
|
s.addModel(
|
||||||
|
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
|
||||||
|
)
|
||||||
|
s.push(
|
||||||
|
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
||||||
|
s.modelForm(len(s.config.ModelList)-1),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||||
|
for i := range s.config.ModelList {
|
||||||
|
index := i
|
||||||
|
model := s.config.ModelList[i]
|
||||||
|
isValid := isModelValid(model)
|
||||||
|
desc := model.APIBase
|
||||||
|
if desc == "" {
|
||||||
|
desc = model.AuthMethod
|
||||||
|
}
|
||||||
|
if desc == "" {
|
||||||
|
desc = "api_key required"
|
||||||
|
}
|
||||||
|
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
||||||
|
if model.ModelName == currentModel && currentModel != "" {
|
||||||
|
label = "* " + label
|
||||||
|
}
|
||||||
|
isSelected := model.ModelName == currentModel && currentModel != ""
|
||||||
|
items = append(items, MenuItem{
|
||||||
|
Label: label,
|
||||||
|
Description: desc,
|
||||||
|
MainColor: modelStatusColor(isValid, isSelected),
|
||||||
|
Action: func() {
|
||||||
|
s.push(fmt.Sprintf("model-%d", index), s.modelForm(index))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
menu.applyItems(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isModelValid(model picoclawconfig.ModelConfig) bool {
|
||||||
|
hasKey := strings.TrimSpace(model.APIKey) != "" ||
|
||||||
|
strings.TrimSpace(model.AuthMethod) == "oauth"
|
||||||
|
hasModel := strings.TrimSpace(model.Model) != ""
|
||||||
|
return hasKey && hasModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
|
||||||
|
if model == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(model.APIKey) == "" {
|
||||||
|
s.showMessage("Missing API Key", "Set api_key before testing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base := strings.TrimSpace(model.APIBase)
|
||||||
|
if base == "" {
|
||||||
|
s.showMessage("Missing API Base", "Set api_base before testing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modelID := strings.TrimSpace(model.Model)
|
||||||
|
if modelID == "" {
|
||||||
|
s.showMessage("Missing Model", "Set model before testing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(modelID, "openai/") {
|
||||||
|
s.showMessage("Unsupported model", "Only openai/* models are supported for test")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modelName := strings.TrimPrefix(modelID, "openai/")
|
||||||
|
endpoint := strings.TrimRight(base, "/") + "/chat/completions"
|
||||||
|
|
||||||
|
payload := fmt.Sprintf(
|
||||||
|
`{"model":"%s","messages":[{"role":"user","content":"ping"}],"max_tokens":1}`,
|
||||||
|
modelName,
|
||||||
|
)
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
request, err := http.NewRequest("POST", endpoint, strings.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
s.showMessage("Test failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(model.APIKey))
|
||||||
|
|
||||||
|
resp, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
s.showMessage("Test failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
|
s.showMessage("Test OK", resp.Status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
s.showMessage(
|
||||||
|
"Test failed",
|
||||||
|
fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
func applyStyles() {
|
||||||
|
tview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(12, 13, 22)
|
||||||
|
tview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(34, 19, 53)
|
||||||
|
tview.Styles.MoreContrastBackgroundColor = tcell.NewRGBColor(18, 18, 32)
|
||||||
|
tview.Styles.BorderColor = tcell.NewRGBColor(112, 102, 255)
|
||||||
|
tview.Styles.TitleColor = tcell.NewRGBColor(255, 121, 198)
|
||||||
|
tview.Styles.GraphicsColor = tcell.NewRGBColor(139, 233, 253)
|
||||||
|
tview.Styles.PrimaryTextColor = tcell.NewRGBColor(241, 250, 255)
|
||||||
|
tview.Styles.SecondaryTextColor = tcell.NewRGBColor(80, 250, 123)
|
||||||
|
tview.Styles.TertiaryTextColor = tcell.NewRGBColor(139, 233, 253)
|
||||||
|
tview.Styles.InverseTextColor = tcell.NewRGBColor(12, 13, 22)
|
||||||
|
tview.Styles.ContrastSecondaryTextColor = tcell.NewRGBColor(189, 147, 249)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bannerView() *tview.TextView {
|
||||||
|
text := tview.NewTextView()
|
||||||
|
text.SetDynamicColors(true)
|
||||||
|
text.SetTextAlign(tview.AlignCenter)
|
||||||
|
text.SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
|
||||||
|
text.SetText(
|
||||||
|
"[::b][#84aaff]██████╗ ██╗ ██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗\n" +
|
||||||
|
"[#84aaff]██╔══██╗██║██╔════╝██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║\n" +
|
||||||
|
"[#84aaff]██████╔╝██║██║ ██║ ██║██║ ██║ ███████║██║ █╗ ██║\n" +
|
||||||
|
"[#84aaff]██╔═══╝ ██║██║ ██║ ██║██║ ██║ ██╔══██║██║███╗██║\n" +
|
||||||
|
"[#84aaff]██║ ██║╚██████╗╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
|
||||||
|
"[#84aaff]╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝",
|
||||||
|
)
|
||||||
|
text.SetBorder(false)
|
||||||
|
return text
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := ui.Run(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
# PicoClaw Launcher
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> This project is a temporary solution and will be refactored in the future to provide a complete web service. Therefore, the APIs in this directory are not stable.
|
||||||
|
|
||||||
|
A standalone launcher for PicoClaw, providing visual JSON editing and OAuth provider authentication management.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 📝 **Config Editor** — Sidebar-based settings UI with model management, channel configuration forms, and a raw JSON editor
|
||||||
|
- 🤖 **Model Management** — Model card grid with availability status (grayed out without API key), primary model selection, add/edit/delete with required/optional field separation
|
||||||
|
- 📡 **Channel Configuration** — Form-based settings for 12 channel types (Telegram, Discord, Slack, WeCom, DingTalk, Feishu, LINE, WhatsApp, QQ, OneBot, MaixCAM, etc.) with documentation links
|
||||||
|
- 🔐 **Provider Auth** — Login to OpenAI (Device Code), Anthropic (API Token), Google Antigravity (Browser OAuth)
|
||||||
|
- 🌐 **Embedded Frontend** — Compiles to a single binary with no external dependencies
|
||||||
|
- 🌍 **i18n** — Chinese/English language switching with browser auto-detection
|
||||||
|
- 🎨 **Theme** — Light / Dark / System theme toggle with localStorage persistence
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build
|
||||||
|
go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
|
||||||
|
|
||||||
|
# Run with default config path (~/.picoclaw/config.json)
|
||||||
|
./picoclaw-launcher
|
||||||
|
|
||||||
|
# Specify a config file
|
||||||
|
./picoclaw-launcher ./config.json
|
||||||
|
|
||||||
|
# Allow LAN access
|
||||||
|
./picoclaw-launcher -public
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:18800` in your browser.
|
||||||
|
|
||||||
|
## CLI Options
|
||||||
|
|
||||||
|
```
|
||||||
|
Usage: picoclaw-config [options] [config.json]
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
config.json Path to the configuration file (default: ~/.picoclaw/config.json)
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-public Listen on all interfaces (0.0.0.0), allowing access from other devices
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
Base URL: `http://localhost:18800`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Static Files
|
||||||
|
|
||||||
|
#### GET /
|
||||||
|
|
||||||
|
Serves the embedded frontend (`index.html`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Config API
|
||||||
|
|
||||||
|
#### GET /api/config
|
||||||
|
|
||||||
|
Reads the current configuration file.
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"config": { ... },
|
||||||
|
"path": "/Users/xiao/.picoclaw/config.json"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### PUT /api/config
|
||||||
|
|
||||||
|
Saves the configuration. The request body must be a complete Config JSON object.
|
||||||
|
|
||||||
|
**Request Body** — `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": { "defaults": { "model_name": "gpt-5.2" } },
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"auth_method": "oauth"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error** `400 Bad Request` — Invalid JSON
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Auth API
|
||||||
|
|
||||||
|
#### GET /api/auth/status
|
||||||
|
|
||||||
|
Returns the authentication status of all providers and any in-progress device code login.
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": [
|
||||||
|
{
|
||||||
|
"provider": "openai",
|
||||||
|
"auth_method": "oauth",
|
||||||
|
"status": "active",
|
||||||
|
"account_id": "user-xxx",
|
||||||
|
"expires_at": "2026-03-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pending_device": {
|
||||||
|
"provider": "openai",
|
||||||
|
"status": "pending",
|
||||||
|
"device_url": "https://auth.openai.com/activate",
|
||||||
|
"user_code": "ABCD-1234"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`status` values: `active` | `expired` | `needs_refresh`
|
||||||
|
|
||||||
|
`pending_device` is only present when a device code login is in progress.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/auth/login
|
||||||
|
|
||||||
|
Initiates a provider login.
|
||||||
|
|
||||||
|
**Request Body** — `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "provider": "openai" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported `provider` values: `openai` | `anthropic` | `google-antigravity`
|
||||||
|
|
||||||
|
##### OpenAI (Device Code Flow)
|
||||||
|
|
||||||
|
Returns device code info. The server polls for completion in the background.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "pending",
|
||||||
|
"device_url": "https://auth.openai.com/activate",
|
||||||
|
"user_code": "ABCD-1234",
|
||||||
|
"message": "Open the URL and enter the code to authenticate."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The user opens `device_url` in a browser and enters `user_code`. Once authenticated, `GET /api/auth/status` will show `pending_device.status` as `success`.
|
||||||
|
|
||||||
|
##### Anthropic (API Token)
|
||||||
|
|
||||||
|
Requires a `token` field in the request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "provider": "anthropic", "token": "sk-ant-xxx" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "success", "message": "Anthropic token saved" }
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Google Antigravity (Browser OAuth)
|
||||||
|
|
||||||
|
Returns an authorization URL for the frontend to open in a new tab:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "redirect",
|
||||||
|
"auth_url": "https://accounts.google.com/o/oauth2/auth?...",
|
||||||
|
"message": "Open the URL to authenticate with Google."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After authentication, Google redirects to `GET /auth/callback`, which saves the credentials and redirects back to the picoclaw-config UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/auth/logout
|
||||||
|
|
||||||
|
Logs out from a provider.
|
||||||
|
|
||||||
|
**Request Body** — `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "provider": "openai" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Omit or leave `provider` empty to log out from all providers.
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### GET /auth/callback
|
||||||
|
|
||||||
|
OAuth browser callback endpoint (used by Google Antigravity). Called by the OAuth provider's redirect — **not invoked directly by the frontend**.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `state` — OAuth state for CSRF validation
|
||||||
|
- `code` — Authorization code
|
||||||
|
|
||||||
|
On success, redirects to `/#auth`.
|
||||||
|
|
||||||
|
|
||||||
|
### Process API
|
||||||
|
|
||||||
|
#### GET /api/process/status
|
||||||
|
|
||||||
|
Gets the running status of the `picoclaw gateway` process.
|
||||||
|
|
||||||
|
**Response** `200 OK` (Running)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"process_status": "running",
|
||||||
|
"status": "ok",
|
||||||
|
"uptime": "1.010814s"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** `200 OK` (Stopped)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"process_status": "stopped",
|
||||||
|
"error": "Get \"http://localhost:18790/health\": dial tcp [::1]:18790: connect: connection refused"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/process/start
|
||||||
|
|
||||||
|
Starts the `picoclaw gateway` process in the background.
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"pid": 12345
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/process/stop
|
||||||
|
|
||||||
|
Stops the running `picoclaw gateway` process.
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -v ./cmd/picoclaw-launcher/
|
||||||
|
```
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# PicoClaw Launcher
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> 该项目属于临时解决方案,后续会重构并提供完整的 Web 服务,因此该目录下的接口并不稳定。
|
||||||
|
|
||||||
|
PicoClaw 的独立启动器,提供可视化 JSON 配置编辑和 OAuth Provider 认证管理。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- 📝 **配置编辑** — 侧边栏式设置 UI,支持模型管理、通道配置表单和原始 JSON 编辑器
|
||||||
|
- 🤖 **模型管理** — 模型卡片网格,可用性状态显示(无 API Key 时灰色),主模型选择,增删改查,必填/选填字段分离
|
||||||
|
- 📡 **通道配置** — 12 种通道类型(Telegram、Discord、Slack、企业微信、钉钉、飞书、LINE、WhatsApp、QQ、OneBot、MaixCAM 等)的表单化配置,附带文档链接
|
||||||
|
- 🔐 **Provider 认证** — 支持 OpenAI (Device Code)、Anthropic (API Token)、Google Antigravity (Browser OAuth) 登录
|
||||||
|
- 🌐 **嵌入式前端** — 编译为单一二进制文件,无需额外依赖
|
||||||
|
- 🌍 **国际化** — 中英文切换,首次访问自动检测浏览器语言
|
||||||
|
- 🎨 **主题** — 亮色 / 暗色 / 跟随系统,偏好保存在 localStorage
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 编译
|
||||||
|
go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
|
||||||
|
|
||||||
|
# 运行(使用默认配置路径 ~/.picoclaw/config.json)
|
||||||
|
./picoclaw-launcher
|
||||||
|
|
||||||
|
# 指定配置文件
|
||||||
|
./picoclaw-launcher ./config.json
|
||||||
|
|
||||||
|
# 允许局域网访问
|
||||||
|
./picoclaw-launcher -public
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后在浏览器中打开 `http://localhost:18800`。
|
||||||
|
|
||||||
|
## 命令行参数
|
||||||
|
|
||||||
|
```
|
||||||
|
Usage: picoclaw-launcher [options] [config.json]
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
config.json 配置文件路径(默认: ~/.picoclaw/config.json)
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-public 监听所有网络接口(0.0.0.0),允许局域网设备访问
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 文档
|
||||||
|
|
||||||
|
Base URL: `http://localhost:18800`
|
||||||
|
|
||||||
|
### 静态文件
|
||||||
|
|
||||||
|
#### GET /
|
||||||
|
|
||||||
|
提供嵌入式前端页面(`index.html`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Config API
|
||||||
|
|
||||||
|
#### GET /api/config
|
||||||
|
|
||||||
|
读取当前配置文件内容。
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"config": { ... },
|
||||||
|
"path": "/Users/xiao/.picoclaw/config.json"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### PUT /api/config
|
||||||
|
|
||||||
|
保存配置。请求体为完整的 Config JSON。
|
||||||
|
|
||||||
|
**Request Body** — `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": { "defaults": { "model_name": "gpt-5.2" } },
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-5.2",
|
||||||
|
"model": "openai/gpt-5.2",
|
||||||
|
"auth_method": "oauth"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error** `400 Bad Request` — 无效 JSON
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Auth API
|
||||||
|
|
||||||
|
#### GET /api/auth/status
|
||||||
|
|
||||||
|
获取所有 Provider 的认证状态和进行中的 Device Code 登录信息。
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": [
|
||||||
|
{
|
||||||
|
"provider": "openai",
|
||||||
|
"auth_method": "oauth",
|
||||||
|
"status": "active",
|
||||||
|
"account_id": "user-xxx",
|
||||||
|
"expires_at": "2026-03-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pending_device": {
|
||||||
|
"provider": "openai",
|
||||||
|
"status": "pending",
|
||||||
|
"device_url": "https://auth.openai.com/activate",
|
||||||
|
"user_code": "ABCD-1234"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`status` 可选值: `active` | `expired` | `needs_refresh`
|
||||||
|
|
||||||
|
`pending_device` 仅在有进行中的 Device Code 登录时返回。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/auth/login
|
||||||
|
|
||||||
|
发起 Provider 登录。
|
||||||
|
|
||||||
|
**Request Body** — `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "provider": "openai" }
|
||||||
|
```
|
||||||
|
|
||||||
|
支持的 `provider` 值: `openai` | `anthropic` | `google-antigravity`
|
||||||
|
|
||||||
|
##### OpenAI (Device Code Flow)
|
||||||
|
|
||||||
|
返回 Device Code 信息,后台自动轮询认证结果:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "pending",
|
||||||
|
"device_url": "https://auth.openai.com/activate",
|
||||||
|
"user_code": "ABCD-1234",
|
||||||
|
"message": "Open the URL and enter the code to authenticate."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
用户在浏览器中打开 `device_url` 并输入 `user_code`。认证完成后通过 `GET /api/auth/status` 的 `pending_device.status` 变为 `success` 通知前端。
|
||||||
|
|
||||||
|
##### Anthropic (API Token)
|
||||||
|
|
||||||
|
需在请求中附带 token:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "provider": "anthropic", "token": "sk-ant-xxx" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "success", "message": "Anthropic token saved" }
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Google Antigravity (Browser OAuth)
|
||||||
|
|
||||||
|
返回授权 URL,前端打开新标签页:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "redirect",
|
||||||
|
"auth_url": "https://accounts.google.com/o/oauth2/auth?...",
|
||||||
|
"message": "Open the URL to authenticate with Google."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
认证完成后 Google 回调至 `GET /auth/callback`,自动保存凭据并重定向回 picoclaw-config 页面。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/auth/logout
|
||||||
|
|
||||||
|
登出 Provider。
|
||||||
|
|
||||||
|
**Request Body** — `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "provider": "openai" }
|
||||||
|
```
|
||||||
|
|
||||||
|
传空字符串或省略 `provider` 则登出所有 Provider。
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### GET /auth/callback
|
||||||
|
|
||||||
|
OAuth Browser 回调端点(Google Antigravity 专用),由 OAuth Provider 重定向调用,**非前端直接使用**。
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `state` — OAuth state 校验
|
||||||
|
- `code` — 授权码
|
||||||
|
|
||||||
|
认证成功后重定向到 `/#auth`。
|
||||||
|
|
||||||
|
### Process API
|
||||||
|
|
||||||
|
#### GET /api/process/status
|
||||||
|
|
||||||
|
获取 `picoclaw gateway` 进程的运行状态。
|
||||||
|
|
||||||
|
**Response** `200 OK` (运行中)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"process_status": "running",
|
||||||
|
"status": "ok",
|
||||||
|
"uptime": "1.010814s"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** `200 OK` (未运行)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"process_status": "stopped",
|
||||||
|
"error": "Get \"http://localhost:18790/health\": dial tcp [::1]:18790: connect: connection refused"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/process/start
|
||||||
|
|
||||||
|
在后台启动 `picoclaw gateway` 进程。
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"pid": 12345
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/process/stop
|
||||||
|
|
||||||
|
停止正在运行的 `picoclaw gateway` 进程。
|
||||||
|
|
||||||
|
**Response** `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -v ./cmd/picoclaw-launcher/
|
||||||
|
```
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,147 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// updateConfigAfterLogin updates config.json after a successful provider login.
|
||||||
|
func updateConfigAfterLogin(configPath, provider string, cred *auth.AuthCredential) {
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: could not load config to update auth_method: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch provider {
|
||||||
|
case "openai":
|
||||||
|
cfg.Providers.OpenAI.AuthMethod = "oauth"
|
||||||
|
found := false
|
||||||
|
for i := range cfg.ModelList {
|
||||||
|
if isOpenAIModel(cfg.ModelList[i].Model) {
|
||||||
|
cfg.ModelList[i].AuthMethod = "oauth"
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||||
|
ModelName: "gpt-5.2",
|
||||||
|
Model: "openai/gpt-5.2",
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
cfg.Agents.Defaults.ModelName = "gpt-5.2"
|
||||||
|
|
||||||
|
case "anthropic":
|
||||||
|
cfg.Providers.Anthropic.AuthMethod = "token"
|
||||||
|
found := false
|
||||||
|
for i := range cfg.ModelList {
|
||||||
|
if isAnthropicModel(cfg.ModelList[i].Model) {
|
||||||
|
cfg.ModelList[i].AuthMethod = "token"
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||||
|
ModelName: "claude-sonnet-4.6",
|
||||||
|
Model: "anthropic/claude-sonnet-4.6",
|
||||||
|
AuthMethod: "token",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
cfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
|
||||||
|
|
||||||
|
case "google-antigravity":
|
||||||
|
cfg.Providers.Antigravity.AuthMethod = "oauth"
|
||||||
|
found := false
|
||||||
|
for i := range cfg.ModelList {
|
||||||
|
if isAntigravityModel(cfg.ModelList[i].Model) {
|
||||||
|
cfg.ModelList[i].AuthMethod = "oauth"
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||||
|
ModelName: "gemini-flash",
|
||||||
|
Model: "antigravity/gemini-3-flash",
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
cfg.Agents.Defaults.ModelName = "gemini-flash"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
log.Printf("Warning: could not update config: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearAuthMethodInConfig clears auth_method for a specific provider in config.json.
|
||||||
|
func clearAuthMethodInConfig(configPath, provider string) {
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range cfg.ModelList {
|
||||||
|
switch provider {
|
||||||
|
case "openai":
|
||||||
|
if isOpenAIModel(cfg.ModelList[i].Model) {
|
||||||
|
cfg.ModelList[i].AuthMethod = ""
|
||||||
|
}
|
||||||
|
case "anthropic":
|
||||||
|
if isAnthropicModel(cfg.ModelList[i].Model) {
|
||||||
|
cfg.ModelList[i].AuthMethod = ""
|
||||||
|
}
|
||||||
|
case "google-antigravity", "antigravity":
|
||||||
|
if isAntigravityModel(cfg.ModelList[i].Model) {
|
||||||
|
cfg.ModelList[i].AuthMethod = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch provider {
|
||||||
|
case "openai":
|
||||||
|
cfg.Providers.OpenAI.AuthMethod = ""
|
||||||
|
case "anthropic":
|
||||||
|
cfg.Providers.Anthropic.AuthMethod = ""
|
||||||
|
case "google-antigravity", "antigravity":
|
||||||
|
cfg.Providers.Antigravity.AuthMethod = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
config.SaveConfig(configPath, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearAllAuthMethodsInConfig clears auth_method for all providers in config.json.
|
||||||
|
func clearAllAuthMethodsInConfig(configPath string) {
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range cfg.ModelList {
|
||||||
|
cfg.ModelList[i].AuthMethod = ""
|
||||||
|
}
|
||||||
|
cfg.Providers.OpenAI.AuthMethod = ""
|
||||||
|
cfg.Providers.Anthropic.AuthMethod = ""
|
||||||
|
cfg.Providers.Antigravity.AuthMethod = ""
|
||||||
|
config.SaveConfig(configPath, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Model identification helpers ─────────────────────────────────
|
||||||
|
|
||||||
|
func isOpenAIModel(model string) bool {
|
||||||
|
return model == "openai" || strings.HasPrefix(model, "openai/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAnthropicModel(model string) bool {
|
||||||
|
return model == "anthropic" || strings.HasPrefix(model, "anthropic/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAntigravityModel(model string) bool {
|
||||||
|
return model == "antigravity" || model == "google-antigravity" ||
|
||||||
|
strings.HasPrefix(model, "antigravity/") || strings.HasPrefix(model, "google-antigravity/")
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Model identification helpers ─────────────────────────────────
|
||||||
|
|
||||||
|
func TestIsOpenAIModel(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
model string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"openai", true},
|
||||||
|
{"openai/gpt-4o", true},
|
||||||
|
{"openai/gpt-5.2", true},
|
||||||
|
{"anthropic", false},
|
||||||
|
{"anthropic/claude-sonnet-4.6", false},
|
||||||
|
{"openai-compatible", false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := isOpenAIModel(tt.model); got != tt.want {
|
||||||
|
t.Errorf("isOpenAIModel(%q) = %v, want %v", tt.model, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAnthropicModel(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
model string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"anthropic", true},
|
||||||
|
{"anthropic/claude-sonnet-4.6", true},
|
||||||
|
{"openai", false},
|
||||||
|
{"openai/gpt-4o", false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := isAnthropicModel(tt.model); got != tt.want {
|
||||||
|
t.Errorf("isAnthropicModel(%q) = %v, want %v", tt.model, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAntigravityModel(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
model string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"antigravity", true},
|
||||||
|
{"google-antigravity", true},
|
||||||
|
{"antigravity/gemini-3-flash", true},
|
||||||
|
{"google-antigravity/gemini-3-flash", true},
|
||||||
|
{"openai", false},
|
||||||
|
{"antigravity-custom", false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := isAntigravityModel(tt.model); got != tt.want {
|
||||||
|
t.Errorf("isAntigravityModel(%q) = %v, want %v", tt.model, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config update helpers ────────────────────────────────────────
|
||||||
|
|
||||||
|
func writeTempConfigViaSave(t *testing.T, cfg *config.Config) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.json")
|
||||||
|
if err := config.SaveConfig(path, cfg); err != nil {
|
||||||
|
t.Fatalf("save config: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTempConfig(t *testing.T, path string) *config.Config {
|
||||||
|
t.Helper()
|
||||||
|
cfg, err := config.LoadConfig(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load config: %v", err)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateConfigAfterLogin_OpenAI_ExistingModel(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{ModelName: "gpt-4o", Model: "openai/gpt-4o"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
path := writeTempConfigViaSave(t, cfg)
|
||||||
|
|
||||||
|
cred := &auth.AuthCredential{AuthMethod: "oauth"}
|
||||||
|
updateConfigAfterLogin(path, "openai", cred)
|
||||||
|
|
||||||
|
result := loadTempConfig(t, path)
|
||||||
|
|
||||||
|
// Model-level auth_method persists through serialization
|
||||||
|
if len(result.ModelList) != 1 {
|
||||||
|
t.Fatalf("expected 1 model, got %d", len(result.ModelList))
|
||||||
|
}
|
||||||
|
if result.ModelList[0].AuthMethod != "oauth" {
|
||||||
|
t.Errorf("expected model auth_method=oauth, got %q", result.ModelList[0].AuthMethod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateConfigAfterLogin_OpenAI_NoExistingModel(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
path := writeTempConfigViaSave(t, cfg)
|
||||||
|
|
||||||
|
cred := &auth.AuthCredential{AuthMethod: "oauth"}
|
||||||
|
updateConfigAfterLogin(path, "openai", cred)
|
||||||
|
|
||||||
|
result := loadTempConfig(t, path)
|
||||||
|
|
||||||
|
if len(result.ModelList) != 2 {
|
||||||
|
t.Fatalf("expected 2 models (original + added), got %d", len(result.ModelList))
|
||||||
|
}
|
||||||
|
if result.ModelList[1].Model != "openai/gpt-5.2" {
|
||||||
|
t.Errorf("expected added model openai/gpt-5.2, got %q", result.ModelList[1].Model)
|
||||||
|
}
|
||||||
|
if result.Agents.Defaults.ModelName != "gpt-5.2" {
|
||||||
|
t.Errorf("expected default model_name=gpt-5.2, got %q", result.Agents.Defaults.ModelName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateConfigAfterLogin_Anthropic(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
path := writeTempConfigViaSave(t, cfg)
|
||||||
|
|
||||||
|
cred := &auth.AuthCredential{AuthMethod: "token"}
|
||||||
|
updateConfigAfterLogin(path, "anthropic", cred)
|
||||||
|
|
||||||
|
result := loadTempConfig(t, path)
|
||||||
|
|
||||||
|
// Model should be added with correct auth_method
|
||||||
|
if len(result.ModelList) != 1 {
|
||||||
|
t.Fatalf("expected 1 model added, got %d", len(result.ModelList))
|
||||||
|
}
|
||||||
|
if result.ModelList[0].Model != "anthropic/claude-sonnet-4.6" {
|
||||||
|
t.Errorf("expected model anthropic/claude-sonnet-4.6, got %q", result.ModelList[0].Model)
|
||||||
|
}
|
||||||
|
if result.ModelList[0].AuthMethod != "token" {
|
||||||
|
t.Errorf("expected model auth_method=token, got %q", result.ModelList[0].AuthMethod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateConfigAfterLogin_GoogleAntigravity(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
path := writeTempConfigViaSave(t, cfg)
|
||||||
|
|
||||||
|
cred := &auth.AuthCredential{AuthMethod: "oauth"}
|
||||||
|
updateConfigAfterLogin(path, "google-antigravity", cred)
|
||||||
|
|
||||||
|
result := loadTempConfig(t, path)
|
||||||
|
|
||||||
|
// Model should be added with correct auth_method
|
||||||
|
if len(result.ModelList) != 1 {
|
||||||
|
t.Fatalf("expected 1 model added, got %d", len(result.ModelList))
|
||||||
|
}
|
||||||
|
if result.ModelList[0].Model != "antigravity/gemini-3-flash" {
|
||||||
|
t.Errorf("expected model antigravity/gemini-3-flash, got %q", result.ModelList[0].Model)
|
||||||
|
}
|
||||||
|
if result.ModelList[0].AuthMethod != "oauth" {
|
||||||
|
t.Errorf("expected model auth_method=oauth, got %q", result.ModelList[0].AuthMethod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClearAuthMethodInConfig(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{ModelName: "gpt-4o", Model: "openai/gpt-4o", AuthMethod: "oauth"},
|
||||||
|
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
path := writeTempConfigViaSave(t, cfg)
|
||||||
|
|
||||||
|
clearAuthMethodInConfig(path, "openai")
|
||||||
|
|
||||||
|
result := loadTempConfig(t, path)
|
||||||
|
|
||||||
|
// Openai model auth_method should be cleared
|
||||||
|
if result.ModelList[0].AuthMethod != "" {
|
||||||
|
t.Errorf("expected openai model auth_method cleared, got %q", result.ModelList[0].AuthMethod)
|
||||||
|
}
|
||||||
|
// Anthropic model should be unchanged
|
||||||
|
if result.ModelList[1].AuthMethod != "token" {
|
||||||
|
t.Errorf("expected anthropic model auth_method unchanged, got %q", result.ModelList[1].AuthMethod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClearAllAuthMethodsInConfig(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{ModelName: "gpt-4o", Model: "openai/gpt-4o", AuthMethod: "oauth"},
|
||||||
|
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
|
||||||
|
{ModelName: "gemini", Model: "antigravity/gemini-3-flash", AuthMethod: "oauth"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
path := writeTempConfigViaSave(t, cfg)
|
||||||
|
|
||||||
|
clearAllAuthMethodsInConfig(path)
|
||||||
|
|
||||||
|
result := loadTempConfig(t, path)
|
||||||
|
|
||||||
|
for i, m := range result.ModelList {
|
||||||
|
if m.AuthMethod != "" {
|
||||||
|
t.Errorf("model[%d] auth_method not cleared, got %q", i, m.AuthMethod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// oauthSession stores in-flight OAuth state for browser-based flows.
|
||||||
|
type oauthSession struct {
|
||||||
|
Provider string
|
||||||
|
PKCE auth.PKCECodes
|
||||||
|
State string
|
||||||
|
RedirectURI string
|
||||||
|
OAuthCfg auth.OAuthProviderConfig
|
||||||
|
ConfigPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceCodeSession stores in-flight device code flow state.
|
||||||
|
type deviceCodeSession struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
Provider string
|
||||||
|
Info *auth.DeviceCodeInfo
|
||||||
|
OAuthCfg auth.OAuthProviderConfig
|
||||||
|
ConfigPath string
|
||||||
|
Status string // "pending", "success", "error"
|
||||||
|
Error string
|
||||||
|
Done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
oauthSessions = map[string]*oauthSession{} // keyed by state
|
||||||
|
oauthSessionsMu sync.Mutex
|
||||||
|
|
||||||
|
activeDeviceSession *deviceCodeSession
|
||||||
|
activeDeviceSessionMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleOpenAILogin starts the OpenAI device code flow and returns device code info to the frontend.
|
||||||
|
func handleOpenAILogin(w http.ResponseWriter, configPath string) {
|
||||||
|
// Check if there's already a pending device code session
|
||||||
|
activeDeviceSessionMu.Lock()
|
||||||
|
if activeDeviceSession != nil {
|
||||||
|
activeDeviceSession.mu.Lock()
|
||||||
|
if !activeDeviceSession.Done {
|
||||||
|
resp := map[string]any{
|
||||||
|
"status": "pending",
|
||||||
|
"device_url": activeDeviceSession.Info.VerifyURL,
|
||||||
|
"user_code": activeDeviceSession.Info.UserCode,
|
||||||
|
"message": "Device code flow already in progress. Enter the code in your browser.",
|
||||||
|
}
|
||||||
|
activeDeviceSession.mu.Unlock()
|
||||||
|
activeDeviceSessionMu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeDeviceSession.mu.Unlock()
|
||||||
|
}
|
||||||
|
activeDeviceSessionMu.Unlock()
|
||||||
|
|
||||||
|
// Request a device code
|
||||||
|
oauthCfg := auth.OpenAIOAuthConfig()
|
||||||
|
info, err := auth.RequestDeviceCode(oauthCfg)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to request device code: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session := &deviceCodeSession{
|
||||||
|
Provider: "openai",
|
||||||
|
Info: info,
|
||||||
|
OAuthCfg: oauthCfg,
|
||||||
|
ConfigPath: configPath,
|
||||||
|
Status: "pending",
|
||||||
|
}
|
||||||
|
|
||||||
|
activeDeviceSessionMu.Lock()
|
||||||
|
activeDeviceSession = session
|
||||||
|
activeDeviceSessionMu.Unlock()
|
||||||
|
|
||||||
|
// Start background polling
|
||||||
|
go func() {
|
||||||
|
deadline := time.After(15 * time.Minute)
|
||||||
|
ticker := time.NewTicker(time.Duration(info.Interval) * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
session.mu.Lock()
|
||||||
|
session.Status = "error"
|
||||||
|
session.Error = "Authentication timed out after 15 minutes"
|
||||||
|
session.Done = true
|
||||||
|
session.mu.Unlock()
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
cred, err := auth.PollDeviceCodeOnce(oauthCfg, info.DeviceAuthID, info.UserCode)
|
||||||
|
if err != nil {
|
||||||
|
continue // Still pending
|
||||||
|
}
|
||||||
|
if cred != nil {
|
||||||
|
if saveErr := auth.SetCredential("openai", cred); saveErr != nil {
|
||||||
|
session.mu.Lock()
|
||||||
|
session.Status = "error"
|
||||||
|
session.Error = saveErr.Error()
|
||||||
|
session.Done = true
|
||||||
|
session.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateConfigAfterLogin(configPath, "openai", cred)
|
||||||
|
session.mu.Lock()
|
||||||
|
session.Status = "success"
|
||||||
|
session.Done = true
|
||||||
|
session.mu.Unlock()
|
||||||
|
log.Printf("OpenAI device code login successful (account: %s)", cred.AccountID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Return device code info to frontend
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"status": "pending",
|
||||||
|
"device_url": info.VerifyURL,
|
||||||
|
"user_code": info.UserCode,
|
||||||
|
"message": "Open the URL and enter the code to authenticate.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAnthropicLogin saves a pasted API token for Anthropic.
|
||||||
|
func handleAnthropicLogin(w http.ResponseWriter, token, configPath string) {
|
||||||
|
if token == "" {
|
||||||
|
http.Error(w, "Token is required for Anthropic login", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cred := &auth.AuthCredential{
|
||||||
|
AccessToken: token,
|
||||||
|
Provider: "anthropic",
|
||||||
|
AuthMethod: "token",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.SetCredential("anthropic", cred); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to save credentials: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConfigAfterLogin(configPath, "anthropic", cred)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Anthropic token saved",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGoogleAntigravityLogin generates a PKCE + auth URL and returns it to the frontend.
|
||||||
|
func handleGoogleAntigravityLogin(w http.ResponseWriter, r *http.Request, configPath string) {
|
||||||
|
oauthCfg := auth.GoogleAntigravityOAuthConfig()
|
||||||
|
|
||||||
|
pkce, err := auth.GeneratePKCE()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to generate PKCE: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := auth.GenerateState()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to generate state: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build redirect URI pointing to picoclaw-launcher's own callback
|
||||||
|
scheme := "http"
|
||||||
|
redirectURI := fmt.Sprintf("%s://%s/auth/callback", scheme, r.Host)
|
||||||
|
|
||||||
|
authURL := auth.BuildAuthorizeURL(oauthCfg, pkce, state, redirectURI)
|
||||||
|
|
||||||
|
// Store session for callback
|
||||||
|
oauthSessionsMu.Lock()
|
||||||
|
oauthSessions[state] = &oauthSession{
|
||||||
|
Provider: "google-antigravity",
|
||||||
|
PKCE: pkce,
|
||||||
|
State: state,
|
||||||
|
RedirectURI: redirectURI,
|
||||||
|
OAuthCfg: oauthCfg,
|
||||||
|
ConfigPath: configPath,
|
||||||
|
}
|
||||||
|
oauthSessionsMu.Unlock()
|
||||||
|
|
||||||
|
// Clean up stale sessions after 10 minutes
|
||||||
|
go func() {
|
||||||
|
time.Sleep(10 * time.Minute)
|
||||||
|
oauthSessionsMu.Lock()
|
||||||
|
delete(oauthSessions, state)
|
||||||
|
oauthSessionsMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"status": "redirect",
|
||||||
|
"auth_url": authURL,
|
||||||
|
"message": "Open the URL to authenticate with Google.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleOAuthCallback processes the OAuth callback from Google Antigravity.
|
||||||
|
func handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
state := r.URL.Query().Get("state")
|
||||||
|
code := r.URL.Query().Get("code")
|
||||||
|
|
||||||
|
oauthSessionsMu.Lock()
|
||||||
|
session, ok := oauthSessions[state]
|
||||||
|
if ok {
|
||||||
|
delete(oauthSessions, state)
|
||||||
|
}
|
||||||
|
oauthSessionsMu.Unlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Invalid or expired OAuth state", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if code == "" {
|
||||||
|
errMsg := r.URL.Query().Get("error")
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
fmt.Fprintf(
|
||||||
|
w,
|
||||||
|
`<html><body><h2>Authentication failed</h2><p>%s</p><p>You can close this window.</p></body></html>`,
|
||||||
|
errMsg,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cred, err := auth.ExchangeCodeForTokens(session.OAuthCfg, code, session.PKCE.CodeVerifier, session.RedirectURI)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
fmt.Fprintf(
|
||||||
|
w,
|
||||||
|
`<html><body><h2>Authentication failed</h2><p>%s</p><p>You can close this window.</p></body></html>`,
|
||||||
|
err.Error(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cred.Provider = session.Provider
|
||||||
|
|
||||||
|
// Fetch user info for Google Antigravity
|
||||||
|
if session.Provider == "google-antigravity" {
|
||||||
|
if email, err := fetchGoogleUserEmail(cred.AccessToken); err == nil {
|
||||||
|
cred.Email = email
|
||||||
|
}
|
||||||
|
if projectID, err := providers.FetchAntigravityProjectID(cred.AccessToken); err == nil {
|
||||||
|
cred.ProjectID = projectID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.SetCredential(session.Provider, cred); err != nil {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
fmt.Fprintf(w, `<html><body><h2>Failed to save credentials</h2><p>%s</p></body></html>`, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConfigAfterLogin(session.ConfigPath, session.Provider, cred)
|
||||||
|
|
||||||
|
// Redirect back to picoclaw-launcher UI
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
fmt.Fprintf(w, `<html><body>
|
||||||
|
<h2>Authentication successful!</h2>
|
||||||
|
<p>Redirecting back to Config Editor...</p>
|
||||||
|
<script>setTimeout(function(){ window.location.href = '/#auth'; }, 1000);</script>
|
||||||
|
</body></html>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchGoogleUserEmail retrieves the user's email from Google's userinfo endpoint.
|
||||||
|
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, _ := io.ReadAll(resp.Body)
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// LogBuffer is a thread-safe ring buffer that stores the most recent N log lines.
|
||||||
|
// It supports incremental reads via LinesSince and tracks a runID that increments
|
||||||
|
// on each Reset (used to detect gateway restarts).
|
||||||
|
type LogBuffer struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
lines []string
|
||||||
|
cap int
|
||||||
|
total int // total lines ever appended in current run
|
||||||
|
runID int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogBuffer creates a LogBuffer with the given capacity.
|
||||||
|
func NewLogBuffer(capacity int) *LogBuffer {
|
||||||
|
return &LogBuffer{
|
||||||
|
lines: make([]string, 0, capacity),
|
||||||
|
cap: capacity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append adds a line to the buffer. If the buffer is full, the oldest line is evicted.
|
||||||
|
func (b *LogBuffer) Append(line string) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
if len(b.lines) < b.cap {
|
||||||
|
b.lines = append(b.lines, line)
|
||||||
|
} else {
|
||||||
|
b.lines[b.total%b.cap] = line
|
||||||
|
}
|
||||||
|
|
||||||
|
b.total++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset clears the buffer and increments the runID. Call this when starting a new gateway process.
|
||||||
|
func (b *LogBuffer) Reset() {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
b.lines = b.lines[:0]
|
||||||
|
b.total = 0
|
||||||
|
b.runID++
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinesSince returns lines appended after the given offset, the current total count, and the runID.
|
||||||
|
// If offset >= total, no lines are returned. If offset is too old (evicted), all buffered lines are returned.
|
||||||
|
func (b *LogBuffer) LinesSince(offset int) (lines []string, total int, runID int) {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
|
||||||
|
total = b.total
|
||||||
|
runID = b.runID
|
||||||
|
|
||||||
|
if offset >= b.total {
|
||||||
|
return nil, total, runID
|
||||||
|
}
|
||||||
|
|
||||||
|
buffered := len(b.lines)
|
||||||
|
|
||||||
|
// How many new lines since offset
|
||||||
|
newCount := b.total - offset
|
||||||
|
if newCount > buffered {
|
||||||
|
newCount = buffered
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]string, newCount)
|
||||||
|
|
||||||
|
if b.total <= b.cap {
|
||||||
|
// Buffer hasn't wrapped yet — simple slice
|
||||||
|
copy(result, b.lines[buffered-newCount:])
|
||||||
|
} else {
|
||||||
|
// Buffer has wrapped — read from ring
|
||||||
|
start := (b.total - newCount) % b.cap
|
||||||
|
for i := range newCount {
|
||||||
|
result[i] = b.lines[(start+i)%b.cap]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, total, runID
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunID returns the current run identifier.
|
||||||
|
func (b *LogBuffer) RunID() int {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
|
||||||
|
return b.runID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total returns the total number of lines appended in the current run.
|
||||||
|
func (b *LogBuffer) Total() int {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
|
||||||
|
return b.total
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLogBuffer_Basic(t *testing.T) {
|
||||||
|
buf := NewLogBuffer(5)
|
||||||
|
|
||||||
|
// Empty buffer
|
||||||
|
lines, total, runID := buf.LinesSince(0)
|
||||||
|
assert.Nil(t, lines)
|
||||||
|
assert.Equal(t, 0, total)
|
||||||
|
assert.Equal(t, 0, runID)
|
||||||
|
|
||||||
|
// Append some lines
|
||||||
|
buf.Append("line1")
|
||||||
|
buf.Append("line2")
|
||||||
|
buf.Append("line3")
|
||||||
|
|
||||||
|
lines, total, runID = buf.LinesSince(0)
|
||||||
|
assert.Equal(t, []string{"line1", "line2", "line3"}, lines)
|
||||||
|
assert.Equal(t, 3, total)
|
||||||
|
assert.Equal(t, 0, runID)
|
||||||
|
|
||||||
|
// Incremental read
|
||||||
|
lines, total, _ = buf.LinesSince(2)
|
||||||
|
assert.Equal(t, []string{"line3"}, lines)
|
||||||
|
assert.Equal(t, 3, total)
|
||||||
|
|
||||||
|
// No new lines
|
||||||
|
lines, total, _ = buf.LinesSince(3)
|
||||||
|
assert.Nil(t, lines)
|
||||||
|
assert.Equal(t, 3, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogBuffer_Wrap(t *testing.T) {
|
||||||
|
buf := NewLogBuffer(3)
|
||||||
|
|
||||||
|
buf.Append("a")
|
||||||
|
buf.Append("b")
|
||||||
|
buf.Append("c")
|
||||||
|
buf.Append("d") // evicts "a"
|
||||||
|
buf.Append("e") // evicts "b"
|
||||||
|
|
||||||
|
lines, total, _ := buf.LinesSince(0)
|
||||||
|
assert.Equal(t, []string{"c", "d", "e"}, lines)
|
||||||
|
assert.Equal(t, 5, total)
|
||||||
|
|
||||||
|
// Incremental after wrap
|
||||||
|
lines, total, _ = buf.LinesSince(3)
|
||||||
|
assert.Equal(t, []string{"d", "e"}, lines)
|
||||||
|
assert.Equal(t, 5, total)
|
||||||
|
|
||||||
|
// Offset too old (before buffer start), get all buffered
|
||||||
|
lines, total, _ = buf.LinesSince(1)
|
||||||
|
assert.Equal(t, []string{"c", "d", "e"}, lines)
|
||||||
|
assert.Equal(t, 5, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogBuffer_Reset(t *testing.T) {
|
||||||
|
buf := NewLogBuffer(5)
|
||||||
|
|
||||||
|
buf.Append("before")
|
||||||
|
assert.Equal(t, 0, buf.RunID())
|
||||||
|
|
||||||
|
buf.Reset()
|
||||||
|
assert.Equal(t, 1, buf.RunID())
|
||||||
|
assert.Equal(t, 0, buf.Total())
|
||||||
|
|
||||||
|
lines, total, runID := buf.LinesSince(0)
|
||||||
|
assert.Nil(t, lines)
|
||||||
|
assert.Equal(t, 0, total)
|
||||||
|
assert.Equal(t, 1, runID)
|
||||||
|
|
||||||
|
buf.Append("after")
|
||||||
|
lines, total, runID = buf.LinesSince(0)
|
||||||
|
assert.Equal(t, []string{"after"}, lines)
|
||||||
|
assert.Equal(t, 1, total)
|
||||||
|
assert.Equal(t, 1, runID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogBuffer_Concurrent(t *testing.T) {
|
||||||
|
buf := NewLogBuffer(100)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// 10 writers
|
||||||
|
for i := range 10 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := range 50 {
|
||||||
|
buf.Append(fmt.Sprintf("writer-%d-line-%d", id, j))
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5 readers
|
||||||
|
for range 5 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for range 100 {
|
||||||
|
buf.LinesSince(0)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
assert.Equal(t, 500, buf.Total())
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gatewayLogs stores captured stdout/stderr from the gateway process launched by the launcher.
|
||||||
|
var gatewayLogs = NewLogBuffer(200)
|
||||||
|
|
||||||
|
// RegisterProcessAPI registers endpoints to start, stop and check status of the picoclaw gateway.
|
||||||
|
func RegisterProcessAPI(mux *http.ServeMux, absPath string) {
|
||||||
|
mux.HandleFunc("GET /api/process/status", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handleStatusGateway(w, r, absPath)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("POST /api/process/start", handleStartGateway)
|
||||||
|
mux.HandleFunc("POST /api/process/stop", handleStopGateway)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStartGateway(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Locate picoclaw executable:
|
||||||
|
// 1. Try same directory as current executable
|
||||||
|
// 2. Fallback to just "picoclaw" (relies on $PATH)
|
||||||
|
execPath := "picoclaw"
|
||||||
|
|
||||||
|
if exe, err := os.Executable(); err == nil {
|
||||||
|
dir := filepath.Dir(exe)
|
||||||
|
candidate := filepath.Join(dir, "picoclaw")
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
candidate += ".exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||||
|
execPath = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(execPath, "gateway")
|
||||||
|
|
||||||
|
stdoutPipe, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create stdout pipe: %v\n", err)
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stderrPipe, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create stderr pipe: %v\n", err)
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear old logs and increment runID before starting
|
||||||
|
gatewayLogs.Reset()
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
log.Printf("Failed to start picoclaw gateway: %v\n", err)
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read stdout and stderr into the log buffer
|
||||||
|
go scanPipe(stdoutPipe, gatewayLogs)
|
||||||
|
go scanPipe(stderrPipe, gatewayLogs)
|
||||||
|
|
||||||
|
// Wait for the process to exit in the background to avoid zombies
|
||||||
|
go func() {
|
||||||
|
if err := cmd.Wait(); err != nil {
|
||||||
|
log.Printf("Gateway process exited: %v\n", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Printf("Started picoclaw gateway (PID: %d) from %s\n", cmd.Process.Pid, execPath)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"status": "ok",
|
||||||
|
"pid": cmd.Process.Pid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanPipe reads lines from r and appends them to buf. It returns when r reaches EOF.
|
||||||
|
func scanPipe(r io.Reader, buf *LogBuffer) {
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // up to 1MB per line
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
buf.Append(scanner.Text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStopGateway(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var err error
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
// Kill via taskkill finding picoclaw.exe (though it might kill this config tool if it's named picoclaw-launcher.exe...? No, /IM does exact match usually, but just to be safe let's stop exactly picoclaw.exe)
|
||||||
|
// Alternatively, we use powershell to kill processes with commandline containing 'gateway'
|
||||||
|
psCmd := `Get-WmiObject Win32_Process | Where-Object { $_.CommandLine -match 'picoclaw.*gateway' } | ForEach-Object { Stop-Process $_.ProcessId -Force }`
|
||||||
|
err = exec.Command("powershell", "-Command", psCmd).Run()
|
||||||
|
} else {
|
||||||
|
// Linux/macOS
|
||||||
|
err = exec.Command("pkill", "-f", "picoclaw gateway").Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Failed to stop gateway (perhaps not running?): %v\n", err)
|
||||||
|
// We still return 200 OK because pkill returns an error if no process was found
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"status": "ok", // or "not_found"
|
||||||
|
"msg": "Stop command executed, but returned error (process might not be running).",
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Stopped picoclaw gateway processes.\n")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"status": "ok",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStatusGateway(w http.ResponseWriter, r *http.Request, absPath string) {
|
||||||
|
cfg, cfgErr := config.LoadConfig(absPath)
|
||||||
|
host := "127.0.0.1"
|
||||||
|
port := 18790
|
||||||
|
if cfgErr == nil && cfg != nil {
|
||||||
|
if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" {
|
||||||
|
host = cfg.Gateway.Host
|
||||||
|
}
|
||||||
|
if cfg.Gateway.Port != 0 {
|
||||||
|
port = cfg.Gateway.Port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||||
|
client := http.Client{Timeout: 2 * time.Second}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
|
||||||
|
// Build the response data map
|
||||||
|
data := map[string]any{}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
data["process_status"] = "stopped"
|
||||||
|
data["error"] = err.Error()
|
||||||
|
} else {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
data["process_status"] = "error"
|
||||||
|
data["status_code"] = resp.StatusCode
|
||||||
|
} else {
|
||||||
|
var healthData map[string]any
|
||||||
|
if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil {
|
||||||
|
data["process_status"] = "error"
|
||||||
|
data["error"] = "invalid response from gateway"
|
||||||
|
} else {
|
||||||
|
// Gateway is running and responded properly — merge health data
|
||||||
|
for k, v := range healthData {
|
||||||
|
data[k] = v
|
||||||
|
}
|
||||||
|
data["process_status"] = "running"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append log data from the buffer
|
||||||
|
appendLogData(r, data)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendLogData reads log_offset and log_run_id query params from the request and
|
||||||
|
// populates the response data map with incremental log lines.
|
||||||
|
func appendLogData(r *http.Request, data map[string]any) {
|
||||||
|
clientOffset := 0
|
||||||
|
clientRunID := -1
|
||||||
|
|
||||||
|
if v := r.URL.Query().Get("log_offset"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
clientOffset = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if v := r.URL.Query().Get("log_run_id"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
clientRunID = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runID := gatewayLogs.RunID()
|
||||||
|
|
||||||
|
// If runID is 0 (never reset = never launched from this launcher), report no source
|
||||||
|
if runID == 0 {
|
||||||
|
data["logs"] = []string{}
|
||||||
|
data["log_total"] = 0
|
||||||
|
data["log_run_id"] = 0
|
||||||
|
data["log_source"] = "none"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the client's runID doesn't match, send all buffered lines (gateway restarted)
|
||||||
|
offset := clientOffset
|
||||||
|
if clientRunID != runID {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
lines, total, runID := gatewayLogs.LinesSince(offset)
|
||||||
|
if lines == nil {
|
||||||
|
lines = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
data["logs"] = lines
|
||||||
|
data["log_total"] = total
|
||||||
|
data["log_run_id"] = runID
|
||||||
|
data["log_source"] = "launcher"
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultPort = "18800"
|
||||||
|
|
||||||
|
// providerStatus represents the auth status of a single provider in API responses.
|
||||||
|
type providerStatus struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
AuthMethod string `json:"auth_method"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
AccountID string `json:"account_id,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
ProjectID string `json:"project_id,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Route registration ───────────────────────────────────────────
|
||||||
|
|
||||||
|
func RegisterConfigAPI(mux *http.ServeMux, absPath string) {
|
||||||
|
// GET /api/config — read config
|
||||||
|
mux.HandleFunc("GET /api/config", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg, err := config.LoadConfig(absPath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
resp := map[string]any{
|
||||||
|
"config": cfg,
|
||||||
|
"path": absPath,
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(w)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
if err := enc.Encode(resp); err != nil {
|
||||||
|
log.Printf("Failed to encode response: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// PUT /api/config — save config
|
||||||
|
mux.HandleFunc("PUT /api/config", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
var cfg config.Config
|
||||||
|
if err := json.Unmarshal(body, &cfg); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.SaveConfig(absPath, &cfg); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterAuthAPI(mux *http.ServeMux, absPath string) {
|
||||||
|
// GET /api/auth/status — all authenticated providers + pending login state
|
||||||
|
mux.HandleFunc("GET /api/auth/status", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
store, err := auth.LoadStore()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to load auth store: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := []providerStatus{}
|
||||||
|
for name, cred := range store.Credentials {
|
||||||
|
status := "active"
|
||||||
|
if cred.IsExpired() {
|
||||||
|
status = "expired"
|
||||||
|
} else if cred.NeedsRefresh() {
|
||||||
|
status = "needs_refresh"
|
||||||
|
}
|
||||||
|
ps := providerStatus{
|
||||||
|
Provider: name,
|
||||||
|
AuthMethod: cred.AuthMethod,
|
||||||
|
Status: status,
|
||||||
|
AccountID: cred.AccountID,
|
||||||
|
Email: cred.Email,
|
||||||
|
ProjectID: cred.ProjectID,
|
||||||
|
}
|
||||||
|
if !cred.ExpiresAt.IsZero() {
|
||||||
|
ps.ExpiresAt = cred.ExpiresAt.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
result = append(result, ps)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include pending device code state
|
||||||
|
var pendingDevice map[string]any
|
||||||
|
activeDeviceSessionMu.Lock()
|
||||||
|
if activeDeviceSession != nil {
|
||||||
|
activeDeviceSession.mu.Lock()
|
||||||
|
pendingDevice = map[string]any{
|
||||||
|
"provider": activeDeviceSession.Provider,
|
||||||
|
"status": activeDeviceSession.Status,
|
||||||
|
"device_url": activeDeviceSession.Info.VerifyURL,
|
||||||
|
"user_code": activeDeviceSession.Info.UserCode,
|
||||||
|
}
|
||||||
|
if activeDeviceSession.Error != "" {
|
||||||
|
pendingDevice["error"] = activeDeviceSession.Error
|
||||||
|
}
|
||||||
|
if activeDeviceSession.Done {
|
||||||
|
activeDeviceSession.mu.Unlock()
|
||||||
|
activeDeviceSession = nil
|
||||||
|
} else {
|
||||||
|
activeDeviceSession.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeDeviceSessionMu.Unlock()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"providers": result,
|
||||||
|
"pending_device": pendingDevice,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/auth/login — initiate provider login
|
||||||
|
mux.HandleFunc("POST /api/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch req.Provider {
|
||||||
|
case "openai":
|
||||||
|
handleOpenAILogin(w, absPath)
|
||||||
|
case "anthropic":
|
||||||
|
handleAnthropicLogin(w, req.Token, absPath)
|
||||||
|
case "google-antigravity", "antigravity":
|
||||||
|
handleGoogleAntigravityLogin(w, r, absPath)
|
||||||
|
default:
|
||||||
|
http.Error(
|
||||||
|
w,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"Unsupported provider: %s (supported: openai, anthropic, google-antigravity)",
|
||||||
|
req.Provider,
|
||||||
|
),
|
||||||
|
http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/auth/logout — logout a provider
|
||||||
|
mux.HandleFunc("POST /api/auth/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Provider == "" {
|
||||||
|
if err := auth.DeleteAllCredentials(); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearAllAuthMethodsInConfig(absPath)
|
||||||
|
} else {
|
||||||
|
if err := auth.DeleteCredential(req.Provider); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearAuthMethodInConfig(absPath, req.Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /auth/callback — OAuth browser callback for Google Antigravity
|
||||||
|
mux.HandleFunc("GET /auth/callback", handleOAuthCallback)
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Config API tests ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
func setupConfigMux(t *testing.T, cfg *config.Config) (*http.ServeMux, string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.json")
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal config: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
RegisterConfigAPI(mux, path)
|
||||||
|
RegisterAuthAPI(mux, path)
|
||||||
|
return mux, path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetConfig(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{ModelName: "gpt-4o", Model: "openai/gpt-4o"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mux, path := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/config", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /api/config: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Config config.Config `json:"config"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Path != path {
|
||||||
|
t.Errorf("expected path %q, got %q", path, resp.Path)
|
||||||
|
}
|
||||||
|
if len(resp.Config.ModelList) != 1 {
|
||||||
|
t.Errorf("expected 1 model, got %d", len(resp.Config.ModelList))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetConfig_MissingFile_ReturnsDefault(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
RegisterConfigAPI(mux, "/tmp/nonexistent-picoclaw-launcher-test/config.json")
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/config", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
// LoadConfig returns a default empty config when file is missing
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200 for missing file (default config), got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPutConfig(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mux, path := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
newCfg := config.Config{
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(newCfg)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("PUT", "/api/config", strings.NewReader(string(body)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("PUT /api/config: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
saved, err := config.LoadConfig(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load saved config: %v", err)
|
||||||
|
}
|
||||||
|
if len(saved.ModelList) != 1 {
|
||||||
|
t.Fatalf("expected 1 model saved, got %d", len(saved.ModelList))
|
||||||
|
}
|
||||||
|
if saved.ModelList[0].Model != "anthropic/claude-sonnet-4.6" {
|
||||||
|
t.Errorf("expected model anthropic/claude-sonnet-4.6, got %q", saved.ModelList[0].Model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPutConfig_InvalidJSON(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mux, _ := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("PUT", "/api/config", strings.NewReader("{invalid"))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for invalid JSON, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auth API tests ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestAuthStatus(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mux, _ := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/auth/status", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /api/auth/status: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Providers []providerStatus `json:"providers"`
|
||||||
|
PendingDevice map[string]any `json:"pending_device"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// providers should be a non-nil list (could be empty)
|
||||||
|
if resp.Providers == nil {
|
||||||
|
t.Error("providers should not be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogin_UnsupportedProvider(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mux, _ := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
body := `{"provider": "unsupported"}`
|
||||||
|
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for unsupported provider, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogin_AnthropicNoToken(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mux, _ := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
body := `{"provider": "anthropic"}`
|
||||||
|
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for anthropic without token, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogin_InvalidBody(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mux, _ := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader("{bad"))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for invalid JSON body, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogout_InvalidBody(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mux, _ := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/auth/logout", strings.NewReader("{bad"))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for invalid body, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOAuthCallback_InvalidState(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
mux, _ := setupConfigMux(t, cfg)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/auth/callback?state=invalid&code=test", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for invalid state, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utility tests ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestDefaultConfigPath(t *testing.T) {
|
||||||
|
path := DefaultConfigPath()
|
||||||
|
if path == "" {
|
||||||
|
t.Error("defaultConfigPath should not return empty")
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(path, filepath.Join(".picoclaw", "config.json")) {
|
||||||
|
t.Errorf("expected path ending with .picoclaw/config.json, got %q", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLocalIP(t *testing.T) {
|
||||||
|
// Just ensure it doesn't panic; IP may or may not be available
|
||||||
|
ip := GetLocalIP()
|
||||||
|
if ip != "" {
|
||||||
|
// If returned, should look like an IP
|
||||||
|
if !strings.Contains(ip, ".") {
|
||||||
|
t.Errorf("getLocalIP returned non-IPv4 looking string: %q", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func DefaultConfigPath() string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "config.json"
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".picoclaw", "config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetLocalIP() string {
|
||||||
|
addrs, err := net.InterfaceAddrs()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, a := range addrs {
|
||||||
|
if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
|
||||||
|
return ipnet.IP.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,127 @@
|
|||||||
|
// PicoClaw Launcher - Standalone HTTP service
|
||||||
|
//
|
||||||
|
// Provides a web-based JSON editor for picoclaw config files,
|
||||||
|
// with OAuth provider authentication support.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
|
||||||
|
// ./picoclaw-launcher [config.json]
|
||||||
|
// ./picoclaw-launcher -public config.json
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed internal/ui/index.html
|
||||||
|
var staticFiles embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
|
||||||
|
flag.Usage = func() {
|
||||||
|
fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n")
|
||||||
|
fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0])
|
||||||
|
fmt.Fprintf(os.Stderr, "Arguments:\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n")
|
||||||
|
fmt.Fprintf(os.Stderr, "Options:\n")
|
||||||
|
flag.PrintDefaults()
|
||||||
|
fmt.Fprintf(os.Stderr, "\nExamples:\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " %s Use default config path\n", os.Args[0])
|
||||||
|
fmt.Fprintf(os.Stderr, " %s ./config.json Specify a config file\n", os.Args[0])
|
||||||
|
fmt.Fprintf(
|
||||||
|
os.Stderr,
|
||||||
|
" %s -public ./config.json Allow access from other devices on the network\n",
|
||||||
|
os.Args[0],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
configPath := server.DefaultConfigPath()
|
||||||
|
if flag.NArg() > 0 {
|
||||||
|
configPath = flag.Arg(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath, err := filepath.Abs(configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to resolve config path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var addr string
|
||||||
|
if *public {
|
||||||
|
addr = "0.0.0.0:" + server.DefaultPort
|
||||||
|
} else {
|
||||||
|
addr = "127.0.0.1:" + server.DefaultPort
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
server.RegisterConfigAPI(mux, absPath)
|
||||||
|
server.RegisterAuthAPI(mux, absPath)
|
||||||
|
server.RegisterProcessAPI(mux, absPath)
|
||||||
|
|
||||||
|
staticFS, err := fs.Sub(staticFiles, "internal/ui")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to create sub filesystem: %v", err)
|
||||||
|
}
|
||||||
|
mux.Handle("/", http.FileServer(http.FS(staticFS)))
|
||||||
|
|
||||||
|
// Print startup banner
|
||||||
|
fmt.Println("=============================================")
|
||||||
|
fmt.Println(" PicoClaw Launcher")
|
||||||
|
fmt.Println("=============================================")
|
||||||
|
fmt.Printf(" Config file : %s\n", absPath)
|
||||||
|
fmt.Printf(" Listen addr : %s\n\n", addr)
|
||||||
|
fmt.Println(" Open the following URL in your browser")
|
||||||
|
fmt.Println(" to view and edit the configuration:")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf(" >> http://localhost:%s <<\n", server.DefaultPort)
|
||||||
|
if *public {
|
||||||
|
if ip := server.GetLocalIP(); ip != "" {
|
||||||
|
fmt.Printf(" >> http://%s:%s <<\n", ip, server.DefaultPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
// fmt.Println("=============================================")
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// Wait briefly to ensure the server is ready before opening the browser
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
url := "http://localhost:" + server.DefaultPort
|
||||||
|
if err := openBrowser(url); err != nil {
|
||||||
|
log.Printf("Warning: Failed to auto-open browser: %v\n", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||||
|
log.Fatalf("Server failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openBrowser automatically opens the given URL in the default browser.
|
||||||
|
func openBrowser(url string) error {
|
||||||
|
var err error
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
err = exec.Command("xdg-open", url).Start()
|
||||||
|
case "windows":
|
||||||
|
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
||||||
|
case "darwin":
|
||||||
|
err = exec.Command("open", url).Start()
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("unsupported platform")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"RT_GROUP_ICON": {
|
||||||
|
"APP": {
|
||||||
|
"0000": "../icon.ico"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RT_MANIFEST": {
|
||||||
|
"#1": {
|
||||||
|
"0409": {
|
||||||
|
"identity": {
|
||||||
|
"name": "PicoClaw Launcher",
|
||||||
|
"version": "0.0.0.0"
|
||||||
|
},
|
||||||
|
"description": "PicoClaw Launcher - Web-based configuration editor",
|
||||||
|
"minimum-os": "win7",
|
||||||
|
"execution-level": "asInvoker",
|
||||||
|
"dpi-awareness": "system",
|
||||||
|
"use-common-controls-v6": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
// PicoClaw - Ultra-lightweight personal AI agent
|
|
||||||
// License: MIT
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/cron"
|
|
||||||
)
|
|
||||||
|
|
||||||
func cronCmd() {
|
|
||||||
if len(os.Args) < 3 {
|
|
||||||
cronHelp()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
subcommand := os.Args[2]
|
|
||||||
|
|
||||||
// Load config to get workspace path
|
|
||||||
cfg, err := loadConfig()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error loading config: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cronStorePath := filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json")
|
|
||||||
|
|
||||||
switch subcommand {
|
|
||||||
case "list":
|
|
||||||
cronListCmd(cronStorePath)
|
|
||||||
case "add":
|
|
||||||
cronAddCmd(cronStorePath)
|
|
||||||
case "remove":
|
|
||||||
if len(os.Args) < 4 {
|
|
||||||
fmt.Println("Usage: picoclaw cron remove <job_id>")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cronRemoveCmd(cronStorePath, os.Args[3])
|
|
||||||
case "enable":
|
|
||||||
cronEnableCmd(cronStorePath, false)
|
|
||||||
case "disable":
|
|
||||||
cronEnableCmd(cronStorePath, true)
|
|
||||||
default:
|
|
||||||
fmt.Printf("Unknown cron command: %s\n", subcommand)
|
|
||||||
cronHelp()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func cronHelp() {
|
|
||||||
fmt.Println("\nCron commands:")
|
|
||||||
fmt.Println(" list List all scheduled jobs")
|
|
||||||
fmt.Println(" add Add a new scheduled job")
|
|
||||||
fmt.Println(" remove <id> Remove a job by ID")
|
|
||||||
fmt.Println(" enable <id> Enable a job")
|
|
||||||
fmt.Println(" disable <id> Disable a job")
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("Add options:")
|
|
||||||
fmt.Println(" -n, --name Job name")
|
|
||||||
fmt.Println(" -m, --message Message for agent")
|
|
||||||
fmt.Println(" -e, --every Run every N seconds")
|
|
||||||
fmt.Println(" -c, --cron Cron expression (e.g. '0 9 * * *')")
|
|
||||||
fmt.Println(" -d, --deliver Deliver response to channel")
|
|
||||||
fmt.Println(" --to Recipient for delivery")
|
|
||||||
fmt.Println(" --channel Channel for delivery")
|
|
||||||
}
|
|
||||||
|
|
||||||
func cronListCmd(storePath string) {
|
|
||||||
cs := cron.NewCronService(storePath, nil)
|
|
||||||
jobs := cs.ListJobs(true) // Show all jobs, including disabled
|
|
||||||
|
|
||||||
if len(jobs) == 0 {
|
|
||||||
fmt.Println("No scheduled jobs.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("\nScheduled Jobs:")
|
|
||||||
fmt.Println("----------------")
|
|
||||||
for _, job := range jobs {
|
|
||||||
var schedule string
|
|
||||||
if job.Schedule.Kind == "every" && job.Schedule.EveryMS != nil {
|
|
||||||
schedule = fmt.Sprintf("every %ds", *job.Schedule.EveryMS/1000)
|
|
||||||
} else if job.Schedule.Kind == "cron" {
|
|
||||||
schedule = job.Schedule.Expr
|
|
||||||
} else {
|
|
||||||
schedule = "one-time"
|
|
||||||
}
|
|
||||||
|
|
||||||
nextRun := "scheduled"
|
|
||||||
if job.State.NextRunAtMS != nil {
|
|
||||||
nextTime := time.UnixMilli(*job.State.NextRunAtMS)
|
|
||||||
nextRun = nextTime.Format("2006-01-02 15:04")
|
|
||||||
}
|
|
||||||
|
|
||||||
status := "enabled"
|
|
||||||
if !job.Enabled {
|
|
||||||
status = "disabled"
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf(" %s (%s)\n", job.Name, job.ID)
|
|
||||||
fmt.Printf(" Schedule: %s\n", schedule)
|
|
||||||
fmt.Printf(" Status: %s\n", status)
|
|
||||||
fmt.Printf(" Next run: %s\n", nextRun)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func cronAddCmd(storePath string) {
|
|
||||||
name := ""
|
|
||||||
message := ""
|
|
||||||
var everySec *int64
|
|
||||||
cronExpr := ""
|
|
||||||
deliver := false
|
|
||||||
channel := ""
|
|
||||||
to := ""
|
|
||||||
|
|
||||||
args := os.Args[3:]
|
|
||||||
for i := 0; i < len(args); i++ {
|
|
||||||
switch args[i] {
|
|
||||||
case "-n", "--name":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
name = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
case "-m", "--message":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
message = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
case "-e", "--every":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
var sec int64
|
|
||||||
fmt.Sscanf(args[i+1], "%d", &sec)
|
|
||||||
everySec = &sec
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
case "-c", "--cron":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
cronExpr = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
case "-d", "--deliver":
|
|
||||||
deliver = true
|
|
||||||
case "--to":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
to = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
case "--channel":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
channel = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if name == "" {
|
|
||||||
fmt.Println("Error: --name is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if message == "" {
|
|
||||||
fmt.Println("Error: --message is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if everySec == nil && cronExpr == "" {
|
|
||||||
fmt.Println("Error: Either --every or --cron must be specified")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var schedule cron.CronSchedule
|
|
||||||
if everySec != nil {
|
|
||||||
everyMS := *everySec * 1000
|
|
||||||
schedule = cron.CronSchedule{
|
|
||||||
Kind: "every",
|
|
||||||
EveryMS: &everyMS,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
schedule = cron.CronSchedule{
|
|
||||||
Kind: "cron",
|
|
||||||
Expr: cronExpr,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := cron.NewCronService(storePath, nil)
|
|
||||||
job, err := cs.AddJob(name, schedule, message, deliver, channel, to)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error adding job: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("✓ Added job '%s' (%s)\n", job.Name, job.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cronRemoveCmd(storePath, jobID string) {
|
|
||||||
cs := cron.NewCronService(storePath, nil)
|
|
||||||
if cs.RemoveJob(jobID) {
|
|
||||||
fmt.Printf("✓ Removed job %s\n", jobID)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("✗ Job %s not found\n", jobID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func cronEnableCmd(storePath string, disable bool) {
|
|
||||||
if len(os.Args) < 4 {
|
|
||||||
fmt.Println("Usage: picoclaw cron enable/disable <job_id>")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
jobID := os.Args[3]
|
|
||||||
cs := cron.NewCronService(storePath, nil)
|
|
||||||
enabled := !disable
|
|
||||||
|
|
||||||
job := cs.EnableJob(jobID, enabled)
|
|
||||||
if job != nil {
|
|
||||||
status := "enabled"
|
|
||||||
if disable {
|
|
||||||
status = "disabled"
|
|
||||||
}
|
|
||||||
fmt.Printf("✓ Job '%s' %s\n", job.Name, status)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("✗ Job %s not found\n", jobID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
// PicoClaw - Ultra-lightweight personal AI agent
|
|
||||||
// License: MIT
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/migrate"
|
|
||||||
)
|
|
||||||
|
|
||||||
func migrateCmd() {
|
|
||||||
if len(os.Args) > 2 && (os.Args[2] == "--help" || os.Args[2] == "-h") {
|
|
||||||
migrateHelp()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
opts := migrate.Options{}
|
|
||||||
|
|
||||||
args := os.Args[2:]
|
|
||||||
for i := 0; i < len(args); i++ {
|
|
||||||
switch args[i] {
|
|
||||||
case "--dry-run":
|
|
||||||
opts.DryRun = true
|
|
||||||
case "--config-only":
|
|
||||||
opts.ConfigOnly = true
|
|
||||||
case "--workspace-only":
|
|
||||||
opts.WorkspaceOnly = true
|
|
||||||
case "--force":
|
|
||||||
opts.Force = true
|
|
||||||
case "--refresh":
|
|
||||||
opts.Refresh = true
|
|
||||||
case "--openclaw-home":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
opts.OpenClawHome = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
case "--picoclaw-home":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
opts.PicoClawHome = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
fmt.Printf("Unknown flag: %s\n", args[i])
|
|
||||||
migrateHelp()
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := migrate.Run(opts)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !opts.DryRun {
|
|
||||||
migrate.PrintSummary(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrateHelp() {
|
|
||||||
fmt.Println("\nMigrate from OpenClaw to PicoClaw")
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("Usage: picoclaw migrate [options]")
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("Options:")
|
|
||||||
fmt.Println(" --dry-run Show what would be migrated without making changes")
|
|
||||||
fmt.Println(" --refresh Re-sync workspace files from OpenClaw (repeatable)")
|
|
||||||
fmt.Println(" --config-only Only migrate config, skip workspace files")
|
|
||||||
fmt.Println(" --workspace-only Only migrate workspace files, skip config")
|
|
||||||
fmt.Println(" --force Skip confirmation prompts")
|
|
||||||
fmt.Println(" --openclaw-home Override OpenClaw home directory (default: ~/.openclaw)")
|
|
||||||
fmt.Println(" --picoclaw-home Override PicoClaw home directory (default: ~/.picoclaw)")
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("Examples:")
|
|
||||||
fmt.Println(" picoclaw migrate Detect and migrate from OpenClaw")
|
|
||||||
fmt.Println(" picoclaw migrate --dry-run Show what would be migrated")
|
|
||||||
fmt.Println(" picoclaw migrate --refresh Re-sync workspace files")
|
|
||||||
fmt.Println(" picoclaw migrate --force Migrate without confirmation")
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
// PicoClaw - Ultra-lightweight personal AI agent
|
package agent
|
||||||
// License: MIT
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@@ -14,62 +11,44 @@ import (
|
|||||||
|
|
||||||
"github.com/chzyer/readline"
|
"github.com/chzyer/readline"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/agent"
|
"github.com/sipeed/picoclaw/pkg/agent"
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
func agentCmd() {
|
func agentCmd(message, sessionKey, model string, debug bool) error {
|
||||||
message := ""
|
if sessionKey == "" {
|
||||||
sessionKey := "cli:default"
|
sessionKey = "cli:default"
|
||||||
modelOverride := ""
|
}
|
||||||
|
|
||||||
args := os.Args[2:]
|
if debug {
|
||||||
for i := 0; i < len(args); i++ {
|
|
||||||
switch args[i] {
|
|
||||||
case "--debug", "-d":
|
|
||||||
logger.SetLevel(logger.DEBUG)
|
logger.SetLevel(logger.DEBUG)
|
||||||
fmt.Println("🔍 Debug mode enabled")
|
fmt.Println("🔍 Debug mode enabled")
|
||||||
case "-m", "--message":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
message = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
case "-s", "--session":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
sessionKey = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
case "--model", "-model":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
modelOverride = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := loadConfig()
|
cfg, err := internal.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error loading config: %v\n", err)
|
return fmt.Errorf("error loading config: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if modelOverride != "" {
|
if model != "" {
|
||||||
cfg.Agents.Defaults.Model = modelOverride
|
cfg.Agents.Defaults.ModelName = model
|
||||||
}
|
}
|
||||||
|
|
||||||
provider, modelID, err := providers.CreateProvider(cfg)
|
provider, modelID, err := providers.CreateProvider(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error creating provider: %v\n", err)
|
return fmt.Errorf("error creating provider: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the resolved model ID from provider creation
|
// Use the resolved model ID from provider creation
|
||||||
if modelID != "" {
|
if modelID != "" {
|
||||||
cfg.Agents.Defaults.Model = modelID
|
cfg.Agents.Defaults.ModelName = modelID
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
|
defer msgBus.Close()
|
||||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
||||||
// Print agent startup info (only for interactive mode)
|
// Print agent startup info (only for interactive mode)
|
||||||
@@ -85,18 +64,20 @@ func agentCmd() {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
response, err := agentLoop.ProcessDirect(ctx, message, sessionKey)
|
response, err := agentLoop.ProcessDirect(ctx, message, sessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error: %v\n", err)
|
return fmt.Errorf("error processing message: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
fmt.Printf("\n%s %s\n", logo, response)
|
fmt.Printf("\n%s %s\n", internal.Logo, response)
|
||||||
} else {
|
return nil
|
||||||
fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", logo)
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", internal.Logo)
|
||||||
interactiveMode(agentLoop, sessionKey)
|
interactiveMode(agentLoop, sessionKey)
|
||||||
}
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||||
prompt := fmt.Sprintf("%s You: ", logo)
|
prompt := fmt.Sprintf("%s You: ", internal.Logo)
|
||||||
|
|
||||||
rl, err := readline.NewEx(&readline.Config{
|
rl, err := readline.NewEx(&readline.Config{
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
@@ -141,14 +122,14 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\n%s %s\n\n", logo, response)
|
fmt.Printf("\n%s %s\n\n", internal.Logo, response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||||
reader := bufio.NewReader(os.Stdin)
|
reader := bufio.NewReader(os.Stdin)
|
||||||
for {
|
for {
|
||||||
fmt.Print(fmt.Sprintf("%s You: ", logo))
|
fmt.Print(fmt.Sprintf("%s You: ", internal.Logo))
|
||||||
line, err := reader.ReadString('\n')
|
line, err := reader.ReadString('\n')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == io.EOF {
|
if err == io.EOF {
|
||||||
@@ -176,6 +157,6 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\n%s %s\n\n", logo, response)
|
fmt.Printf("\n%s %s\n\n", internal.Logo, response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import "github.com/spf13/cobra"
|
||||||
|
|
||||||
|
func NewAuthCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "auth",
|
||||||
|
Short: "Manage authentication (login, logout, status)",
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
return cmd.Help()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(
|
||||||
|
newLoginCommand(),
|
||||||
|
newLogoutCommand(),
|
||||||
|
newStatusCommand(),
|
||||||
|
newModelsCommand(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewAuthCommand(t *testing.T) {
|
||||||
|
cmd := NewAuthCommand()
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "auth", cmd.Use)
|
||||||
|
assert.Equal(t, "Manage authentication (login, logout, status)", cmd.Short)
|
||||||
|
|
||||||
|
assert.Len(t, cmd.Aliases, 0)
|
||||||
|
|
||||||
|
assert.Nil(t, cmd.Run)
|
||||||
|
assert.NotNil(t, cmd.RunE)
|
||||||
|
|
||||||
|
assert.Nil(t, cmd.PersistentPreRun)
|
||||||
|
assert.Nil(t, cmd.PersistentPostRun)
|
||||||
|
|
||||||
|
assert.False(t, cmd.HasFlags())
|
||||||
|
assert.True(t, cmd.HasSubCommands())
|
||||||
|
|
||||||
|
allowedCommands := []string{
|
||||||
|
"login",
|
||||||
|
"logout",
|
||||||
|
"status",
|
||||||
|
"models",
|
||||||
|
}
|
||||||
|
|
||||||
|
subcommands := cmd.Commands()
|
||||||
|
assert.Len(t, subcommands, len(allowedCommands))
|
||||||
|
|
||||||
|
for _, subcmd := range subcommands {
|
||||||
|
found := slices.Contains(allowedCommands, subcmd.Name())
|
||||||
|
assert.True(t, found, "unexpected subcommand %q", subcmd.Name())
|
||||||
|
|
||||||
|
assert.Len(t, subcmd.Aliases, 0)
|
||||||
|
assert.False(t, subcmd.Hidden)
|
||||||
|
|
||||||
|
assert.False(t, subcmd.HasSubCommands())
|
||||||
|
|
||||||
|
assert.Nil(t, subcmd.Run)
|
||||||
|
assert.NotNil(t, subcmd.RunE)
|
||||||
|
|
||||||
|
assert.Nil(t, subcmd.PersistentPreRun)
|
||||||
|
assert.Nil(t, subcmd.PersistentPostRun)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
// PicoClaw - Ultra-lightweight personal AI agent
|
package auth
|
||||||
// License: MIT
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -12,92 +9,28 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/auth"
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
const supportedProvidersMsg = "Supported providers: openai, anthropic, google-antigravity"
|
const supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
|
||||||
|
|
||||||
func authCmd() {
|
|
||||||
if len(os.Args) < 3 {
|
|
||||||
authHelp()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch os.Args[2] {
|
|
||||||
case "login":
|
|
||||||
authLoginCmd()
|
|
||||||
case "logout":
|
|
||||||
authLogoutCmd()
|
|
||||||
case "status":
|
|
||||||
authStatusCmd()
|
|
||||||
case "models":
|
|
||||||
authModelsCmd()
|
|
||||||
default:
|
|
||||||
fmt.Printf("Unknown auth command: %s\n", os.Args[2])
|
|
||||||
authHelp()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func authHelp() {
|
|
||||||
fmt.Println("\nAuth commands:")
|
|
||||||
fmt.Println(" login Login via OAuth or paste token")
|
|
||||||
fmt.Println(" logout Remove stored credentials")
|
|
||||||
fmt.Println(" status Show current auth status")
|
|
||||||
fmt.Println(" models List available Antigravity models")
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("Login options:")
|
|
||||||
fmt.Println(" --provider <name> Provider to login with (openai, anthropic, google-antigravity)")
|
|
||||||
fmt.Println(" --device-code Use device code flow (for headless environments)")
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("Examples:")
|
|
||||||
fmt.Println(" picoclaw auth login --provider openai")
|
|
||||||
fmt.Println(" picoclaw auth login --provider openai --device-code")
|
|
||||||
fmt.Println(" picoclaw auth login --provider anthropic")
|
|
||||||
fmt.Println(" picoclaw auth login --provider google-antigravity")
|
|
||||||
fmt.Println(" picoclaw auth models")
|
|
||||||
fmt.Println(" picoclaw auth logout --provider openai")
|
|
||||||
fmt.Println(" picoclaw auth status")
|
|
||||||
}
|
|
||||||
|
|
||||||
func authLoginCmd() {
|
|
||||||
provider := ""
|
|
||||||
useDeviceCode := false
|
|
||||||
|
|
||||||
args := os.Args[3:]
|
|
||||||
for i := 0; i < len(args); i++ {
|
|
||||||
switch args[i] {
|
|
||||||
case "--provider", "-p":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
provider = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
case "--device-code":
|
|
||||||
useDeviceCode = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if provider == "" {
|
|
||||||
fmt.Println("Error: --provider is required")
|
|
||||||
fmt.Println(supportedProvidersMsg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
func authLoginCmd(provider string, useDeviceCode bool) error {
|
||||||
switch provider {
|
switch provider {
|
||||||
case "openai":
|
case "openai":
|
||||||
authLoginOpenAI(useDeviceCode)
|
return authLoginOpenAI(useDeviceCode)
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
authLoginPasteToken(provider)
|
return authLoginPasteToken(provider)
|
||||||
case "google-antigravity", "antigravity":
|
case "google-antigravity", "antigravity":
|
||||||
authLoginGoogleAntigravity()
|
return authLoginGoogleAntigravity()
|
||||||
default:
|
default:
|
||||||
fmt.Printf("Unsupported provider: %s\n", provider)
|
return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg)
|
||||||
fmt.Println(supportedProvidersMsg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func authLoginOpenAI(useDeviceCode bool) {
|
func authLoginOpenAI(useDeviceCode bool) error {
|
||||||
cfg := auth.OpenAIOAuthConfig()
|
cfg := auth.OpenAIOAuthConfig()
|
||||||
|
|
||||||
var cred *auth.AuthCredential
|
var cred *auth.AuthCredential
|
||||||
@@ -110,16 +43,14 @@ func authLoginOpenAI(useDeviceCode bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Login failed: %v\n", err)
|
return fmt.Errorf("login failed: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := auth.SetCredential("openai", cred); err != nil {
|
if err = auth.SetCredential("openai", cred); err != nil {
|
||||||
fmt.Printf("Failed to save credentials: %v\n", err)
|
return fmt.Errorf("failed to save credentials: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
appCfg, err := loadConfig()
|
appCfg, err := internal.LoadConfig()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Update Providers (legacy format)
|
// Update Providers (legacy format)
|
||||||
appCfg.Providers.OpenAI.AuthMethod = "oauth"
|
appCfg.Providers.OpenAI.AuthMethod = "oauth"
|
||||||
@@ -144,10 +75,10 @@ func authLoginOpenAI(useDeviceCode bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update default model to use OpenAI
|
// Update default model to use OpenAI
|
||||||
appCfg.Agents.Defaults.Model = "gpt-5.2"
|
appCfg.Agents.Defaults.ModelName = "gpt-5.2"
|
||||||
|
|
||||||
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
|
if err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||||
fmt.Printf("Warning: could not update config: %v\n", err)
|
return fmt.Errorf("could not update config: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,15 +87,16 @@ func authLoginOpenAI(useDeviceCode bool) {
|
|||||||
fmt.Printf("Account: %s\n", cred.AccountID)
|
fmt.Printf("Account: %s\n", cred.AccountID)
|
||||||
}
|
}
|
||||||
fmt.Println("Default model set to: gpt-5.2")
|
fmt.Println("Default model set to: gpt-5.2")
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func authLoginGoogleAntigravity() {
|
func authLoginGoogleAntigravity() error {
|
||||||
cfg := auth.GoogleAntigravityOAuthConfig()
|
cfg := auth.GoogleAntigravityOAuthConfig()
|
||||||
|
|
||||||
cred, err := auth.LoginBrowser(cfg)
|
cred, err := auth.LoginBrowser(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Login failed: %v\n", err)
|
return fmt.Errorf("login failed: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cred.Provider = "google-antigravity"
|
cred.Provider = "google-antigravity"
|
||||||
@@ -188,12 +120,11 @@ func authLoginGoogleAntigravity() {
|
|||||||
fmt.Printf("Project: %s\n", projectID)
|
fmt.Printf("Project: %s\n", projectID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := auth.SetCredential("google-antigravity", cred); err != nil {
|
if err = auth.SetCredential("google-antigravity", cred); err != nil {
|
||||||
fmt.Printf("Failed to save credentials: %v\n", err)
|
return fmt.Errorf("failed to save credentials: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
appCfg, err := loadConfig()
|
appCfg, err := internal.LoadConfig()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Update Providers (legacy format, for backward compatibility)
|
// Update Providers (legacy format, for backward compatibility)
|
||||||
appCfg.Providers.Antigravity.AuthMethod = "oauth"
|
appCfg.Providers.Antigravity.AuthMethod = "oauth"
|
||||||
@@ -218,9 +149,9 @@ func authLoginGoogleAntigravity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update default model
|
// Update default model
|
||||||
appCfg.Agents.Defaults.Model = "gemini-flash"
|
appCfg.Agents.Defaults.ModelName = "gemini-flash"
|
||||||
|
|
||||||
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
|
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||||
fmt.Printf("Warning: could not update config: %v\n", err)
|
fmt.Printf("Warning: could not update config: %v\n", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,6 +159,8 @@ func authLoginGoogleAntigravity() {
|
|||||||
fmt.Println("\n✓ Google Antigravity login successful!")
|
fmt.Println("\n✓ Google Antigravity login successful!")
|
||||||
fmt.Println("Default model set to: gemini-flash")
|
fmt.Println("Default model set to: gemini-flash")
|
||||||
fmt.Println("Try it: picoclaw agent -m \"Hello world\"")
|
fmt.Println("Try it: picoclaw agent -m \"Hello world\"")
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchGoogleUserEmail(accessToken string) (string, error) {
|
func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||||
@@ -258,19 +191,17 @@ func fetchGoogleUserEmail(accessToken string) (string, error) {
|
|||||||
return userInfo.Email, nil
|
return userInfo.Email, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func authLoginPasteToken(provider string) {
|
func authLoginPasteToken(provider string) error {
|
||||||
cred, err := auth.LoginPasteToken(provider, os.Stdin)
|
cred, err := auth.LoginPasteToken(provider, os.Stdin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Login failed: %v\n", err)
|
return fmt.Errorf("login failed: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := auth.SetCredential(provider, cred); err != nil {
|
if err = auth.SetCredential(provider, cred); err != nil {
|
||||||
fmt.Printf("Failed to save credentials: %v\n", err)
|
return fmt.Errorf("failed to save credentials: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
appCfg, err := loadConfig()
|
appCfg, err := internal.LoadConfig()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
switch provider {
|
switch provider {
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
@@ -292,7 +223,7 @@ func authLoginPasteToken(provider string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Update default model
|
// Update default model
|
||||||
appCfg.Agents.Defaults.Model = "claude-sonnet-4.6"
|
appCfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
|
||||||
case "openai":
|
case "openai":
|
||||||
appCfg.Providers.OpenAI.AuthMethod = "token"
|
appCfg.Providers.OpenAI.AuthMethod = "token"
|
||||||
// Update ModelList
|
// Update ModelList
|
||||||
@@ -312,38 +243,29 @@ func authLoginPasteToken(provider string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Update default model
|
// Update default model
|
||||||
appCfg.Agents.Defaults.Model = "gpt-5.2"
|
appCfg.Agents.Defaults.ModelName = "gpt-5.2"
|
||||||
}
|
}
|
||||||
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
|
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||||
fmt.Printf("Warning: could not update config: %v\n", err)
|
return fmt.Errorf("could not update config: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Token saved for %s!\n", provider)
|
fmt.Printf("Token saved for %s!\n", provider)
|
||||||
fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.Model)
|
|
||||||
|
if appCfg != nil {
|
||||||
|
fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.GetModelName())
|
||||||
}
|
}
|
||||||
|
|
||||||
func authLogoutCmd() {
|
return nil
|
||||||
provider := ""
|
|
||||||
|
|
||||||
args := os.Args[3:]
|
|
||||||
for i := 0; i < len(args); i++ {
|
|
||||||
switch args[i] {
|
|
||||||
case "--provider", "-p":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
provider = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authLogoutCmd(provider string) error {
|
||||||
if provider != "" {
|
if provider != "" {
|
||||||
if err := auth.DeleteCredential(provider); err != nil {
|
if err := auth.DeleteCredential(provider); err != nil {
|
||||||
fmt.Printf("Failed to remove credentials: %v\n", err)
|
return fmt.Errorf("failed to remove credentials: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
appCfg, err := loadConfig()
|
appCfg, err := internal.LoadConfig()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Clear AuthMethod in ModelList
|
// Clear AuthMethod in ModelList
|
||||||
for i := range appCfg.ModelList {
|
for i := range appCfg.ModelList {
|
||||||
@@ -371,17 +293,19 @@ func authLogoutCmd() {
|
|||||||
case "google-antigravity", "antigravity":
|
case "google-antigravity", "antigravity":
|
||||||
appCfg.Providers.Antigravity.AuthMethod = ""
|
appCfg.Providers.Antigravity.AuthMethod = ""
|
||||||
}
|
}
|
||||||
config.SaveConfig(getConfigPath(), appCfg)
|
config.SaveConfig(internal.GetConfigPath(), appCfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Logged out from %s\n", provider)
|
fmt.Printf("Logged out from %s\n", provider)
|
||||||
} else {
|
|
||||||
if err := auth.DeleteAllCredentials(); err != nil {
|
return nil
|
||||||
fmt.Printf("Failed to remove credentials: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
appCfg, err := loadConfig()
|
if err := auth.DeleteAllCredentials(); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
appCfg, err := internal.LoadConfig()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Clear all AuthMethods in ModelList
|
// Clear all AuthMethods in ModelList
|
||||||
for i := range appCfg.ModelList {
|
for i := range appCfg.ModelList {
|
||||||
@@ -391,24 +315,24 @@ func authLogoutCmd() {
|
|||||||
appCfg.Providers.OpenAI.AuthMethod = ""
|
appCfg.Providers.OpenAI.AuthMethod = ""
|
||||||
appCfg.Providers.Anthropic.AuthMethod = ""
|
appCfg.Providers.Anthropic.AuthMethod = ""
|
||||||
appCfg.Providers.Antigravity.AuthMethod = ""
|
appCfg.Providers.Antigravity.AuthMethod = ""
|
||||||
config.SaveConfig(getConfigPath(), appCfg)
|
config.SaveConfig(internal.GetConfigPath(), appCfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Logged out from all providers")
|
fmt.Println("Logged out from all providers")
|
||||||
}
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func authStatusCmd() {
|
func authStatusCmd() error {
|
||||||
store, err := auth.LoadStore()
|
store, err := auth.LoadStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error loading auth store: %v\n", err)
|
return fmt.Errorf("failed to load auth store: %w", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(store.Credentials) == 0 {
|
if len(store.Credentials) == 0 {
|
||||||
fmt.Println("No authenticated providers.")
|
fmt.Println("No authenticated providers.")
|
||||||
fmt.Println("Run: picoclaw auth login --provider <name>")
|
fmt.Println("Run: picoclaw auth login --provider <name>")
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("\nAuthenticated Providers:")
|
fmt.Println("\nAuthenticated Providers:")
|
||||||
@@ -437,14 +361,16 @@ func authStatusCmd() {
|
|||||||
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
|
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func authModelsCmd() {
|
func authModelsCmd() error {
|
||||||
cred, err := auth.GetCredential("google-antigravity")
|
cred, err := auth.GetCredential("google-antigravity")
|
||||||
if err != nil || cred == nil {
|
if err != nil || cred == nil {
|
||||||
fmt.Println("Not logged in to Google Antigravity.")
|
return fmt.Errorf(
|
||||||
fmt.Println("Run: picoclaw auth login --provider google-antigravity")
|
"not logged in to Google Antigravity.\nrun: picoclaw auth login --provider google-antigravity",
|
||||||
return
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh token if needed
|
// Refresh token if needed
|
||||||
@@ -459,21 +385,18 @@ func authModelsCmd() {
|
|||||||
|
|
||||||
projectID := cred.ProjectID
|
projectID := cred.ProjectID
|
||||||
if projectID == "" {
|
if projectID == "" {
|
||||||
fmt.Println("No project ID stored. Try logging in again.")
|
return fmt.Errorf("no project id stored. Try logging in again")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Fetching models for project: %s\n\n", projectID)
|
fmt.Printf("Fetching models for project: %s\n\n", projectID)
|
||||||
|
|
||||||
models, err := providers.FetchAntigravityModels(cred.AccessToken, projectID)
|
models, err := providers.FetchAntigravityModels(cred.AccessToken, projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error fetching models: %v\n", err)
|
return fmt.Errorf("error fetching models: %w", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(models) == 0 {
|
if len(models) == 0 {
|
||||||
fmt.Println("No models available.")
|
return fmt.Errorf("no models available")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Available Antigravity Models:")
|
fmt.Println("Available Antigravity Models:")
|
||||||
@@ -489,6 +412,8 @@ func authModelsCmd() {
|
|||||||
}
|
}
|
||||||
fmt.Printf(" %s %s\n", status, name)
|
fmt.Printf(" %s %s\n", status, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isAntigravityModel checks if a model string belongs to antigravity provider
|
// isAntigravityModel checks if a model string belongs to antigravity provider
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import "github.com/spf13/cobra"
|
||||||
|
|
||||||
|
func newLoginCommand() *cobra.Command {
|
||||||
|
var (
|
||||||
|
provider string
|
||||||
|
useDeviceCode 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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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.MarkFlagRequired("provider")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package cron
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/cron"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newAddCommand(storePath func() string) *cobra.Command {
|
||||||
|
var (
|
||||||
|
name string
|
||||||
|
message string
|
||||||
|
every int64
|
||||||
|
cronExp string
|
||||||
|
deliver bool
|
||||||
|
channel string
|
||||||
|
to string
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "add",
|
||||||
|
Short: "Add a new scheduled job",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
if every <= 0 && cronExp == "" {
|
||||||
|
return fmt.Errorf("either --every or --cron must be specified")
|
||||||
|
}
|
||||||
|
|
||||||
|
var schedule cron.CronSchedule
|
||||||
|
if every > 0 {
|
||||||
|
everyMS := every * 1000
|
||||||
|
schedule = cron.CronSchedule{Kind: "every", EveryMS: &everyMS}
|
||||||
|
} else {
|
||||||
|
schedule = cron.CronSchedule{Kind: "cron", Expr: cronExp}
|
||||||
|
}
|
||||||
|
|
||||||
|
cs := cron.NewCronService(storePath(), nil)
|
||||||
|
job, err := cs.AddJob(name, schedule, message, deliver, channel, to)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error adding job: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("✓ Added job '%s' (%s)\n", job.Name, job.ID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&name, "name", "n", "", "Job name")
|
||||||
|
cmd.Flags().StringVarP(&message, "message", "m", "", "Message for agent")
|
||||||
|
cmd.Flags().Int64VarP(&every, "every", "e", 0, "Run every N seconds")
|
||||||
|
cmd.Flags().StringVarP(&cronExp, "cron", "c", "", "Cron expression (e.g. '0 9 * * *')")
|
||||||
|
cmd.Flags().BoolVarP(&deliver, "deliver", "d", false, "Deliver response to channel")
|
||||||
|
cmd.Flags().StringVar(&to, "to", "", "Recipient for delivery")
|
||||||
|
cmd.Flags().StringVar(&channel, "channel", "", "Channel for delivery")
|
||||||
|
|
||||||
|
_ = cmd.MarkFlagRequired("name")
|
||||||
|
_ = cmd.MarkFlagRequired("message")
|
||||||
|
cmd.MarkFlagsMutuallyExclusive("every", "cron")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package cron
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewAddSubcommand(t *testing.T) {
|
||||||
|
fn := func() string { return "" }
|
||||||
|
cmd := newAddCommand(fn)
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "add", cmd.Use)
|
||||||
|
assert.Equal(t, "Add a new scheduled job", cmd.Short)
|
||||||
|
|
||||||
|
assert.True(t, cmd.HasFlags())
|
||||||
|
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("every"))
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("cron"))
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("deliver"))
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("to"))
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("channel"))
|
||||||
|
|
||||||
|
nameFlag := cmd.Flags().Lookup("name")
|
||||||
|
require.NotNil(t, nameFlag)
|
||||||
|
|
||||||
|
messageFlag := cmd.Flags().Lookup("message")
|
||||||
|
require.NotNil(t, messageFlag)
|
||||||
|
|
||||||
|
val, found := nameFlag.Annotations[cobra.BashCompOneRequiredFlag]
|
||||||
|
require.True(t, found)
|
||||||
|
require.NotEmpty(t, val)
|
||||||
|
assert.Equal(t, "true", val[0])
|
||||||
|
|
||||||
|
val, found = messageFlag.Annotations[cobra.BashCompOneRequiredFlag]
|
||||||
|
require.True(t, found)
|
||||||
|
require.NotEmpty(t, val)
|
||||||
|
assert.Equal(t, "true", val[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAddCommandEveryAndCronMutuallyExclusive(t *testing.T) {
|
||||||
|
cmd := newAddCommand(func() string { return "testing" })
|
||||||
|
|
||||||
|
cmd.SetArgs([]string{
|
||||||
|
"--name", "job",
|
||||||
|
"--message", "hello",
|
||||||
|
"--every", "10",
|
||||||
|
"--cron", "0 9 * * *",
|
||||||
|
})
|
||||||
|
|
||||||
|
err := cmd.Execute()
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewGatewayCommand() *cobra.Command {
|
||||||
|
var debug bool
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "gateway",
|
||||||
|
Aliases: []string{"g"},
|
||||||
|
Short: "Start picoclaw gateway",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
return gatewayCmd(debug)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewGatewayCommand(t *testing.T) {
|
||||||
|
cmd := NewGatewayCommand()
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "gateway", cmd.Use)
|
||||||
|
assert.Equal(t, "Start picoclaw gateway", cmd.Short)
|
||||||
|
|
||||||
|
assert.Len(t, cmd.Aliases, 1)
|
||||||
|
assert.True(t, cmd.HasAlias("g"))
|
||||||
|
|
||||||
|
assert.Nil(t, cmd.Run)
|
||||||
|
assert.NotNil(t, cmd.RunE)
|
||||||
|
|
||||||
|
assert.Nil(t, cmd.PersistentPreRun)
|
||||||
|
assert.Nil(t, cmd.PersistentPostRun)
|
||||||
|
|
||||||
|
assert.False(t, cmd.HasSubCommands())
|
||||||
|
|
||||||
|
assert.True(t, cmd.HasFlags())
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("debug"))
|
||||||
|
}
|
||||||
@@ -1,57 +1,62 @@
|
|||||||
// PicoClaw - Ultra-lightweight personal AI agent
|
package gateway
|
||||||
// License: MIT
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/agent"
|
"github.com/sipeed/picoclaw/pkg/agent"
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/line"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/pico"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/slack"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/telegram"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/wecom"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/cron"
|
"github.com/sipeed/picoclaw/pkg/cron"
|
||||||
"github.com/sipeed/picoclaw/pkg/devices"
|
"github.com/sipeed/picoclaw/pkg/devices"
|
||||||
"github.com/sipeed/picoclaw/pkg/health"
|
"github.com/sipeed/picoclaw/pkg/health"
|
||||||
"github.com/sipeed/picoclaw/pkg/heartbeat"
|
"github.com/sipeed/picoclaw/pkg/heartbeat"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/state"
|
"github.com/sipeed/picoclaw/pkg/state"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
"github.com/sipeed/picoclaw/pkg/voice"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func gatewayCmd() {
|
func gatewayCmd(debug bool) error {
|
||||||
// Check for --debug flag
|
if debug {
|
||||||
args := os.Args[2:]
|
|
||||||
for _, arg := range args {
|
|
||||||
if arg == "--debug" || arg == "-d" {
|
|
||||||
logger.SetLevel(logger.DEBUG)
|
logger.SetLevel(logger.DEBUG)
|
||||||
fmt.Println("🔍 Debug mode enabled")
|
fmt.Println("🔍 Debug mode enabled")
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := loadConfig()
|
cfg, err := internal.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error loading config: %v\n", err)
|
return fmt.Errorf("error loading config: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
provider, modelID, err := providers.CreateProvider(cfg)
|
provider, modelID, err := providers.CreateProvider(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error creating provider: %v\n", err)
|
return fmt.Errorf("error creating provider: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the resolved model ID from provider creation
|
// Use the resolved model ID from provider creation
|
||||||
if modelID != "" {
|
if modelID != "" {
|
||||||
cfg.Agents.Defaults.Model = modelID
|
cfg.Agents.Defaults.ModelName = modelID
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
@@ -98,7 +103,8 @@ func gatewayCmd() {
|
|||||||
channel, chatID = "cli", "direct"
|
channel, chatID = "cli", "direct"
|
||||||
}
|
}
|
||||||
// Use ProcessHeartbeat - no session history, each heartbeat is independent
|
// Use ProcessHeartbeat - no session history, each heartbeat is independent
|
||||||
response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
|
var response string
|
||||||
|
response, err = agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
|
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
|
||||||
}
|
}
|
||||||
@@ -110,41 +116,23 @@ func gatewayCmd() {
|
|||||||
return tools.SilentResult(response)
|
return tools.SilentResult(response)
|
||||||
})
|
})
|
||||||
|
|
||||||
channelManager, err := channels.NewManager(cfg, msgBus)
|
// Create media store for file lifecycle management with TTL cleanup
|
||||||
|
mediaStore := media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
|
||||||
|
Enabled: cfg.Tools.MediaCleanup.Enabled,
|
||||||
|
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
|
||||||
|
Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,
|
||||||
|
})
|
||||||
|
mediaStore.Start()
|
||||||
|
|
||||||
|
channelManager, err := channels.NewManager(cfg, msgBus, mediaStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error creating channel manager: %v\n", err)
|
mediaStore.Stop()
|
||||||
os.Exit(1)
|
return fmt.Errorf("error creating channel manager: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject channel manager into agent loop for command handling
|
// Inject channel manager and media store into agent loop
|
||||||
agentLoop.SetChannelManager(channelManager)
|
agentLoop.SetChannelManager(channelManager)
|
||||||
|
agentLoop.SetMediaStore(mediaStore)
|
||||||
var transcriber *voice.GroqTranscriber
|
|
||||||
if cfg.Providers.Groq.APIKey != "" {
|
|
||||||
transcriber = voice.NewGroqTranscriber(cfg.Providers.Groq.APIKey)
|
|
||||||
logger.InfoC("voice", "Groq voice transcription enabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
if transcriber != nil {
|
|
||||||
if telegramChannel, ok := channelManager.GetChannel("telegram"); ok {
|
|
||||||
if tc, ok := telegramChannel.(*channels.TelegramChannel); ok {
|
|
||||||
tc.SetTranscriber(transcriber)
|
|
||||||
logger.InfoC("voice", "Groq transcription attached to Telegram channel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if discordChannel, ok := channelManager.GetChannel("discord"); ok {
|
|
||||||
if dc, ok := discordChannel.(*channels.DiscordChannel); ok {
|
|
||||||
dc.SetTranscriber(transcriber)
|
|
||||||
logger.InfoC("voice", "Groq transcription attached to Discord channel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if slackChannel, ok := channelManager.GetChannel("slack"); ok {
|
|
||||||
if sc, ok := slackChannel.(*channels.SlackChannel); ok {
|
|
||||||
sc.SetTranscriber(transcriber)
|
|
||||||
logger.InfoC("voice", "Groq transcription attached to Slack channel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enabledChannels := channelManager.GetEnabledChannels()
|
enabledChannels := channelManager.GetEnabledChannels()
|
||||||
if len(enabledChannels) > 0 {
|
if len(enabledChannels) > 0 {
|
||||||
@@ -181,16 +169,16 @@ func gatewayCmd() {
|
|||||||
fmt.Println("✓ Device event service started")
|
fmt.Println("✓ Device event service started")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup shared HTTP server with health endpoints and webhook handlers
|
||||||
|
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
|
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
|
channelManager.SetupHTTPServer(addr, healthServer)
|
||||||
|
|
||||||
if err := channelManager.StartAll(ctx); err != nil {
|
if err := channelManager.StartAll(ctx); err != nil {
|
||||||
fmt.Printf("Error starting channels: %v\n", err)
|
fmt.Printf("Error starting channels: %v\n", err)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
|
||||||
go func() {
|
|
||||||
if err := healthServer.Start(); err != nil && err != http.ErrServerClosed {
|
|
||||||
logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()})
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
|
|
||||||
go agentLoop.Run(ctx)
|
go agentLoop.Run(ctx)
|
||||||
@@ -200,14 +188,26 @@ func gatewayCmd() {
|
|||||||
<-sigChan
|
<-sigChan
|
||||||
|
|
||||||
fmt.Println("\nShutting down...")
|
fmt.Println("\nShutting down...")
|
||||||
|
if cp, ok := provider.(providers.StatefulProvider); ok {
|
||||||
|
cp.Close()
|
||||||
|
}
|
||||||
cancel()
|
cancel()
|
||||||
healthServer.Stop(context.Background())
|
msgBus.Close()
|
||||||
|
|
||||||
|
// Use a fresh context with timeout for graceful shutdown,
|
||||||
|
// since the original ctx is already canceled.
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer shutdownCancel()
|
||||||
|
|
||||||
|
channelManager.StopAll(shutdownCtx)
|
||||||
deviceService.Stop()
|
deviceService.Stop()
|
||||||
heartbeatService.Stop()
|
heartbeatService.Stop()
|
||||||
cronService.Stop()
|
cronService.Stop()
|
||||||
|
mediaStore.Stop()
|
||||||
agentLoop.Stop()
|
agentLoop.Stop()
|
||||||
channelManager.StopAll(ctx)
|
|
||||||
fmt.Println("✓ Gateway stopped")
|
fmt.Println("✓ Gateway stopped")
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupCronTool(
|
func setupCronTool(
|
||||||
@@ -224,7 +224,11 @@ func setupCronTool(
|
|||||||
cronService := cron.NewCronService(cronStorePath, nil)
|
cronService := cron.NewCronService(cronStorePath, nil)
|
||||||
|
|
||||||
// Create and register CronTool
|
// Create and register CronTool
|
||||||
cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
|
cronTool, err := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Critical error during CronTool initialization: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
agentLoop.RegisterTool(cronTool)
|
agentLoop.RegisterTool(cronTool)
|
||||||
|
|
||||||
// Set the onJob handler
|
// Set the onJob handler
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Logo = "🦞"
|
||||||
|
|
||||||
|
var (
|
||||||
|
version = "dev"
|
||||||
|
gitCommit string
|
||||||
|
buildTime string
|
||||||
|
goVersion string
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetConfigPath() string {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, ".picoclaw", "config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfig() (*config.Config, error) {
|
||||||
|
return config.LoadConfig(GetConfigPath())
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatVersion returns the version string with optional git commit
|
||||||
|
func FormatVersion() string {
|
||||||
|
v := version
|
||||||
|
if gitCommit != "" {
|
||||||
|
v += fmt.Sprintf(" (git: %s)", gitCommit)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatBuildInfo returns build time and go version info
|
||||||
|
func FormatBuildInfo() (string, string) {
|
||||||
|
build := buildTime
|
||||||
|
goVer := goVersion
|
||||||
|
if goVer == "" {
|
||||||
|
goVer = runtime.Version()
|
||||||
|
}
|
||||||
|
return build, goVer
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVersion returns the version string
|
||||||
|
func GetVersion() string {
|
||||||
|
return version
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetConfigPath(t *testing.T) {
|
||||||
|
t.Setenv("HOME", "/tmp/home")
|
||||||
|
|
||||||
|
got := GetConfigPath()
|
||||||
|
want := filepath.Join("/tmp/home", ".picoclaw", "config.json")
|
||||||
|
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatVersion_NoGitCommit(t *testing.T) {
|
||||||
|
oldVersion, oldGit := version, gitCommit
|
||||||
|
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
|
||||||
|
|
||||||
|
version = "1.2.3"
|
||||||
|
gitCommit = ""
|
||||||
|
|
||||||
|
assert.Equal(t, "1.2.3", FormatVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatVersion_WithGitCommit(t *testing.T) {
|
||||||
|
oldVersion, oldGit := version, gitCommit
|
||||||
|
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
|
||||||
|
|
||||||
|
version = "1.2.3"
|
||||||
|
gitCommit = "abc123"
|
||||||
|
|
||||||
|
assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) {
|
||||||
|
oldBuildTime, oldGoVersion := buildTime, goVersion
|
||||||
|
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
|
||||||
|
|
||||||
|
buildTime = "2026-02-20T00:00:00Z"
|
||||||
|
goVersion = "go1.23.0"
|
||||||
|
|
||||||
|
build, goVer := FormatBuildInfo()
|
||||||
|
|
||||||
|
assert.Equal(t, buildTime, build)
|
||||||
|
assert.Equal(t, goVersion, goVer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) {
|
||||||
|
oldBuildTime, oldGoVersion := buildTime, goVersion
|
||||||
|
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
|
||||||
|
|
||||||
|
buildTime = ""
|
||||||
|
goVersion = "go1.23.0"
|
||||||
|
|
||||||
|
build, goVer := FormatBuildInfo()
|
||||||
|
|
||||||
|
assert.Empty(t, build)
|
||||||
|
assert.Equal(t, goVersion, goVer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) {
|
||||||
|
oldBuildTime, oldGoVersion := buildTime, goVersion
|
||||||
|
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
|
||||||
|
|
||||||
|
buildTime = "x"
|
||||||
|
goVersion = ""
|
||||||
|
|
||||||
|
build, goVer := FormatBuildInfo()
|
||||||
|
|
||||||
|
assert.Equal(t, "x", build)
|
||||||
|
assert.Equal(t, runtime.Version(), goVer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetConfigPath_Windows(t *testing.T) {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
t.Skip("windows-specific HOME behavior varies; run on windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
testUserProfilePath := `C:\Users\Test`
|
||||||
|
t.Setenv("USERPROFILE", testUserProfilePath)
|
||||||
|
|
||||||
|
got := GetConfigPath()
|
||||||
|
want := filepath.Join(testUserProfilePath, ".picoclaw", "config.json")
|
||||||
|
|
||||||
|
require.True(t, strings.EqualFold(got, want), "GetConfigPath() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetVersion(t *testing.T) {
|
||||||
|
assert.Equal(t, "dev", GetVersion())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package onboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:generate cp -r ../../../../workspace .
|
||||||
|
//go:embed workspace
|
||||||
|
var embeddedFiles embed.FS
|
||||||
|
|
||||||
|
func NewOnboardCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "onboard",
|
||||||
|
Aliases: []string{"o"},
|
||||||
|
Short: "Initialize picoclaw configuration and workspace",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
onboard()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package onboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewOnboardCommand(t *testing.T) {
|
||||||
|
cmd := NewOnboardCommand()
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "onboard", cmd.Use)
|
||||||
|
assert.Equal(t, "Initialize picoclaw configuration and workspace", cmd.Short)
|
||||||
|
|
||||||
|
assert.Len(t, cmd.Aliases, 1)
|
||||||
|
assert.True(t, cmd.HasAlias("o"))
|
||||||
|
|
||||||
|
assert.NotNil(t, cmd.Run)
|
||||||
|
assert.Nil(t, cmd.RunE)
|
||||||
|
|
||||||
|
assert.Nil(t, cmd.PersistentPreRun)
|
||||||
|
assert.Nil(t, cmd.PersistentPostRun)
|
||||||
|
|
||||||
|
assert.False(t, cmd.HasFlags())
|
||||||
|
assert.False(t, cmd.HasSubCommands())
|
||||||
|
}
|
||||||
@@ -1,24 +1,17 @@
|
|||||||
// PicoClaw - Ultra-lightweight personal AI agent
|
package onboard
|
||||||
// License: MIT
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:generate cp -r ../../workspace .
|
|
||||||
//go:embed workspace
|
|
||||||
var embeddedFiles embed.FS
|
|
||||||
|
|
||||||
func onboard() {
|
func onboard() {
|
||||||
configPath := getConfigPath()
|
configPath := internal.GetConfigPath()
|
||||||
|
|
||||||
if _, err := os.Stat(configPath); err == nil {
|
if _, err := os.Stat(configPath); err == nil {
|
||||||
fmt.Printf("Config already exists at %s\n", configPath)
|
fmt.Printf("Config already exists at %s\n", configPath)
|
||||||
@@ -40,7 +33,7 @@ func onboard() {
|
|||||||
workspace := cfg.WorkspacePath()
|
workspace := cfg.WorkspacePath()
|
||||||
createWorkspaceTemplates(workspace)
|
createWorkspaceTemplates(workspace)
|
||||||
|
|
||||||
fmt.Printf("%s picoclaw is ready!\n", logo)
|
fmt.Printf("%s picoclaw is ready!\n", internal.Logo)
|
||||||
fmt.Println("\nNext steps:")
|
fmt.Println("\nNext steps:")
|
||||||
fmt.Println(" 1. Add your API key to", configPath)
|
fmt.Println(" 1. Add your API key to", configPath)
|
||||||
fmt.Println("")
|
fmt.Println("")
|
||||||
@@ -53,6 +46,13 @@ func onboard() {
|
|||||||
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
|
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func createWorkspaceTemplates(workspace string) {
|
||||||
|
err := copyEmbeddedToTarget(workspace)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error copying workspace templates: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func copyEmbeddedToTarget(targetDir string) error {
|
func copyEmbeddedToTarget(targetDir string) error {
|
||||||
// Ensure target directory exists
|
// Ensure target directory exists
|
||||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||||
@@ -99,10 +99,3 @@ func copyEmbeddedToTarget(targetDir string) error {
|
|||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func createWorkspaceTemplates(workspace string) {
|
|
||||||
err := copyEmbeddedToTarget(workspace)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error copying workspace templates: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
|
)
|
||||||
|
|
||||||
|
type deps struct {
|
||||||
|
workspace string
|
||||||
|
installer *skills.SkillInstaller
|
||||||
|
skillsLoader *skills.SkillsLoader
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSkillsCommand() *cobra.Command {
|
||||||
|
var d deps
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "skills",
|
||||||
|
Short: "Manage skills",
|
||||||
|
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
cfg, err := internal.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error loading config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
d.workspace = cfg.WorkspacePath()
|
||||||
|
d.installer = skills.NewSkillInstaller(d.workspace)
|
||||||
|
|
||||||
|
// get global config directory and builtin skills directory
|
||||||
|
globalDir := filepath.Dir(internal.GetConfigPath())
|
||||||
|
globalSkillsDir := filepath.Join(globalDir, "skills")
|
||||||
|
builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
|
||||||
|
d.skillsLoader = skills.NewSkillsLoader(d.workspace, globalSkillsDir, builtinSkillsDir)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
return cmd.Help()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
installerFn := func() (*skills.SkillInstaller, error) {
|
||||||
|
if d.installer == nil {
|
||||||
|
return nil, fmt.Errorf("skills installer is not initialized")
|
||||||
|
}
|
||||||
|
return d.installer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
loaderFn := func() (*skills.SkillsLoader, error) {
|
||||||
|
if d.skillsLoader == nil {
|
||||||
|
return nil, fmt.Errorf("skills loader is not initialized")
|
||||||
|
}
|
||||||
|
return d.skillsLoader, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaceFn := func() (string, error) {
|
||||||
|
if d.workspace == "" {
|
||||||
|
return "", fmt.Errorf("workspace is not initialized")
|
||||||
|
}
|
||||||
|
return d.workspace, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(
|
||||||
|
newListCommand(loaderFn),
|
||||||
|
newInstallCommand(installerFn),
|
||||||
|
newInstallBuiltinCommand(workspaceFn),
|
||||||
|
newListBuiltinCommand(),
|
||||||
|
newRemoveCommand(installerFn),
|
||||||
|
newSearchCommand(installerFn),
|
||||||
|
newShowCommand(loaderFn),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,40 +1,20 @@
|
|||||||
// PicoClaw - Ultra-lightweight personal AI agent
|
package skills
|
||||||
// License: MIT
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func skillsHelp() {
|
|
||||||
fmt.Println("\nSkills commands:")
|
|
||||||
fmt.Println(" list List installed skills")
|
|
||||||
fmt.Println(" install <repo> Install skill from GitHub")
|
|
||||||
fmt.Println(" install-builtin Install all builtin skills to workspace")
|
|
||||||
fmt.Println(" list-builtin List available builtin skills")
|
|
||||||
fmt.Println(" remove <name> Remove installed skill")
|
|
||||||
fmt.Println(" search Search available skills")
|
|
||||||
fmt.Println(" show <name> Show skill details")
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("Examples:")
|
|
||||||
fmt.Println(" picoclaw skills list")
|
|
||||||
fmt.Println(" picoclaw skills install sipeed/picoclaw-skills/weather")
|
|
||||||
fmt.Println(" picoclaw skills install-builtin")
|
|
||||||
fmt.Println(" picoclaw skills list-builtin")
|
|
||||||
fmt.Println(" picoclaw skills remove weather")
|
|
||||||
fmt.Println(" picoclaw skills install --registry clawhub github")
|
|
||||||
}
|
|
||||||
|
|
||||||
func skillsListCmd(loader *skills.SkillsLoader) {
|
func skillsListCmd(loader *skills.SkillsLoader) {
|
||||||
allSkills := loader.ListSkills()
|
allSkills := loader.ListSkills()
|
||||||
|
|
||||||
@@ -53,53 +33,31 @@ func skillsListCmd(loader *skills.SkillsLoader) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func skillsInstallCmd(installer *skills.SkillInstaller, cfg *config.Config) {
|
func skillsInstallCmd(installer *skills.SkillInstaller, repo string) error {
|
||||||
if len(os.Args) < 4 {
|
|
||||||
fmt.Println("Usage: picoclaw skills install <github-repo>")
|
|
||||||
fmt.Println(" picoclaw skills install --registry <name> <slug>")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for --registry flag.
|
|
||||||
if os.Args[3] == "--registry" {
|
|
||||||
if len(os.Args) < 6 {
|
|
||||||
fmt.Println("Usage: picoclaw skills install --registry <name> <slug>")
|
|
||||||
fmt.Println("Example: picoclaw skills install --registry clawhub github")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
registryName := os.Args[4]
|
|
||||||
slug := os.Args[5]
|
|
||||||
skillsInstallFromRegistry(cfg, registryName, slug)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: install from GitHub (backward compatible).
|
|
||||||
repo := os.Args[3]
|
|
||||||
fmt.Printf("Installing skill from %s...\n", repo)
|
fmt.Printf("Installing skill from %s...\n", repo)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := installer.InstallFromGitHub(ctx, repo); err != nil {
|
if err := installer.InstallFromGitHub(ctx, repo); err != nil {
|
||||||
fmt.Printf("\u2717 Failed to install skill: %v\n", err)
|
return fmt.Errorf("failed to install skill: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo))
|
fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo))
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).
|
// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).
|
||||||
func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
|
func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) error {
|
||||||
err := utils.ValidateSkillIdentifier(registryName)
|
err := utils.ValidateSkillIdentifier(registryName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("\u2717 Invalid registry name: %v\n", err)
|
return fmt.Errorf("✗ invalid registry name: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = utils.ValidateSkillIdentifier(slug)
|
err = utils.ValidateSkillIdentifier(slug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("\u2717 Invalid slug: %v\n", err)
|
return fmt.Errorf("✗ invalid slug: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
|
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
|
||||||
@@ -111,24 +69,21 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
|
|||||||
|
|
||||||
registry := registryMgr.GetRegistry(registryName)
|
registry := registryMgr.GetRegistry(registryName)
|
||||||
if registry == nil {
|
if registry == nil {
|
||||||
fmt.Printf("\u2717 Registry '%s' not found or not enabled. Check your config.json.\n", registryName)
|
return fmt.Errorf("✗ registry '%s' not found or not enabled. check your config.json.", registryName)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
workspace := cfg.WorkspacePath()
|
workspace := cfg.WorkspacePath()
|
||||||
targetDir := filepath.Join(workspace, "skills", slug)
|
targetDir := filepath.Join(workspace, "skills", slug)
|
||||||
|
|
||||||
if _, err := os.Stat(targetDir); err == nil {
|
if _, err = os.Stat(targetDir); err == nil {
|
||||||
fmt.Printf("\u2717 Skill '%s' already installed at %s\n", slug, targetDir)
|
return fmt.Errorf("\u2717 skill '%s' already installed at %s", slug, targetDir)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil {
|
if err = os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil {
|
||||||
fmt.Printf("\u2717 Failed to create skills directory: %v\n", err)
|
return fmt.Errorf("\u2717 failed to create skills directory: %v", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := registry.DownloadAndInstall(ctx, slug, "", targetDir)
|
result, err := registry.DownloadAndInstall(ctx, slug, "", targetDir)
|
||||||
@@ -137,8 +92,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
|
|||||||
if rmErr != nil {
|
if rmErr != nil {
|
||||||
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
|
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
|
||||||
}
|
}
|
||||||
fmt.Printf("\u2717 Failed to install skill: %v\n", err)
|
return fmt.Errorf("✗ failed to install skill: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.IsMalwareBlocked {
|
if result.IsMalwareBlocked {
|
||||||
@@ -146,8 +100,8 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
|
|||||||
if rmErr != nil {
|
if rmErr != nil {
|
||||||
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
|
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
|
||||||
}
|
}
|
||||||
fmt.Printf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug)
|
|
||||||
os.Exit(1)
|
return fmt.Errorf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.IsSuspicious {
|
if result.IsSuspicious {
|
||||||
@@ -158,6 +112,8 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
|
|||||||
if result.Summary != "" {
|
if result.Summary != "" {
|
||||||
fmt.Printf(" %s\n", result.Summary)
|
fmt.Printf(" %s\n", result.Summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
|
func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
|
||||||
@@ -208,7 +164,7 @@ func skillsInstallBuiltinCmd(workspace string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func skillsListBuiltinCmd() {
|
func skillsListBuiltinCmd() {
|
||||||
cfg, err := loadConfig()
|
cfg, err := internal.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error loading config: %v\n", err)
|
fmt.Printf("Error loading config: %v\n", err)
|
||||||
return
|
return
|
||||||
@@ -303,3 +259,37 @@ func skillsShowCmd(loader *skills.SkillsLoader, skillName string) {
|
|||||||
fmt.Println("----------------------")
|
fmt.Println("----------------------")
|
||||||
fmt.Println(content)
|
fmt.Println(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func copyDirectory(src, dst string) error {
|
||||||
|
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath, err := filepath.Rel(src, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dstPath := filepath.Join(dst, relPath)
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
return os.MkdirAll(dstPath, info.Mode())
|
||||||
|
}
|
||||||
|
|
||||||
|
srcFile, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer srcFile.Close()
|
||||||
|
|
||||||
|
dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer dstFile.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(dstFile, srcFile)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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) != 2 {
|
||||||
|
return fmt.Errorf("when --registry is set, exactly 2 arguments are required: <name> <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, args[0], args[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
return skillsInstallCmd(installer, args[0])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(®istry, "registry", "", "Install from registry: --registry <name> <slug>")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newSearchCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "search",
|
||||||
|
Short: "Search available skills",
|
||||||
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
installer, err := installerFn()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
skillsSearchCmd(installer)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -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(nil)
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "search", 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,27 +1,25 @@
|
|||||||
// PicoClaw - Ultra-lightweight personal AI agent
|
package status
|
||||||
// License: MIT
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/auth"
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
func statusCmd() {
|
func statusCmd() {
|
||||||
cfg, err := loadConfig()
|
cfg, err := internal.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error loading config: %v\n", err)
|
fmt.Printf("Error loading config: %v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
configPath := getConfigPath()
|
configPath := internal.GetConfigPath()
|
||||||
|
|
||||||
fmt.Printf("%s picoclaw Status\n", logo)
|
fmt.Printf("%s picoclaw Status\n", internal.Logo)
|
||||||
fmt.Printf("Version: %s\n", formatVersion())
|
fmt.Printf("Version: %s\n", internal.FormatVersion())
|
||||||
build, _ := formatBuildInfo()
|
build, _ := internal.FormatBuildInfo()
|
||||||
if build != "" {
|
if build != "" {
|
||||||
fmt.Printf("Build: %s\n", build)
|
fmt.Printf("Build: %s\n", build)
|
||||||
}
|
}
|
||||||
@@ -41,7 +39,7 @@ func statusCmd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err := os.Stat(configPath); err == nil {
|
if _, err := os.Stat(configPath); err == nil {
|
||||||
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.Model)
|
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName())
|
||||||
|
|
||||||
hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
|
hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
|
||||||
hasAnthropic := cfg.Providers.Anthropic.APIKey != ""
|
hasAnthropic := cfg.Providers.Anthropic.APIKey != ""
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewVersionCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "version",
|
||||||
|
Aliases: []string{"v"},
|
||||||
|
Short: "Show version information",
|
||||||
|
Run: func(_ *cobra.Command, _ []string) {
|
||||||
|
printVersion()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func printVersion() {
|
||||||
|
fmt.Printf("%s picoclaw %s\n", internal.Logo, internal.FormatVersion())
|
||||||
|
build, goVer := internal.FormatBuildInfo()
|
||||||
|
if build != "" {
|
||||||
|
fmt.Printf(" Build: %s\n", build)
|
||||||
|
}
|
||||||
|
if goVer != "" {
|
||||||
|
fmt.Printf(" Go: %s\n", goVer)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user