diff --git a/.gitignore b/.gitignore
index 3ff195fbf..02ef18d1f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,3 +44,6 @@ tasks/
# Added by goreleaser init:
dist/
+
+# Windows Application Icon/Resource
+*.syso
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index 90bdc8437..d531d106b 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -5,7 +5,9 @@ version: 2
before:
hooks:
- 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:
- id: picoclaw
@@ -31,12 +33,67 @@ builds:
- loong64
- arm
goarm:
+ - "6"
- "7"
main: ./cmd/picoclaw
ignore:
- goos: windows
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:
- id: picoclaw
dockerfile: docker/Dockerfile.goreleaser
@@ -72,6 +129,10 @@ archives:
nfpms:
- id: picoclaw
+ builds:
+ - picoclaw
+ - picoclaw-launcher
+ - picoclaw-launcher-tui
package_name: picoclaw
file_name_template: >-
{{ .PackageName }}_
diff --git a/cmd/picoclaw-launcher-tui/internal/config/store.go b/cmd/picoclaw-launcher-tui/internal/config/store.go
new file mode 100644
index 000000000..0236de19f
--- /dev/null
+++ b/cmd/picoclaw-launcher-tui/internal/config/store.go
@@ -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)
+}
diff --git a/cmd/picoclaw-launcher-tui/internal/ui/app.go b/cmd/picoclaw-launcher-tui/internal/ui/app.go
new file mode 100644
index 000000000..4947d6aea
--- /dev/null
+++ b/cmd/picoclaw-launcher-tui/internal/ui/app.go
@@ -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)
+}
diff --git a/cmd/picoclaw-launcher-tui/internal/ui/channel.go b/cmd/picoclaw-launcher-tui/internal/ui/channel.go
new file mode 100644
index 000000000..ad9171424
--- /dev/null
+++ b/cmd/picoclaw-launcher-tui/internal/ui/channel.go
@@ -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
+}
diff --git a/cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go b/cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go
new file mode 100644
index 000000000..bc874f7f2
--- /dev/null
+++ b/cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go
@@ -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()
+}
diff --git a/cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go b/cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go
new file mode 100644
index 000000000..7067a5c13
--- /dev/null
+++ b/cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go
@@ -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()
+}
diff --git a/cmd/picoclaw-launcher-tui/internal/ui/menu.go b/cmd/picoclaw-launcher-tui/internal/ui/menu.go
new file mode 100644
index 000000000..9f2132c5a
--- /dev/null
+++ b/cmd/picoclaw-launcher-tui/internal/ui/menu.go
@@ -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)
+ }
+}
diff --git a/cmd/picoclaw-launcher-tui/internal/ui/model.go b/cmd/picoclaw-launcher-tui/internal/ui/model.go
new file mode 100644
index 000000000..ba91f5b09
--- /dev/null
+++ b/cmd/picoclaw-launcher-tui/internal/ui/model.go
@@ -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))),
+ )
+}
diff --git a/cmd/picoclaw-launcher-tui/internal/ui/style.go b/cmd/picoclaw-launcher-tui/internal/ui/style.go
new file mode 100644
index 000000000..ff4f8b1a8
--- /dev/null
+++ b/cmd/picoclaw-launcher-tui/internal/ui/style.go
@@ -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
+}
diff --git a/cmd/picoclaw-launcher-tui/main.go b/cmd/picoclaw-launcher-tui/main.go
new file mode 100644
index 000000000..0e8cce415
--- /dev/null
+++ b/cmd/picoclaw-launcher-tui/main.go
@@ -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)
+ }
+}
diff --git a/cmd/picoclaw-launcher/README.md b/cmd/picoclaw-launcher/README.md
new file mode 100644
index 000000000..641279bb1
--- /dev/null
+++ b/cmd/picoclaw-launcher/README.md
@@ -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/
+```
diff --git a/cmd/picoclaw-launcher/README.zh.md b/cmd/picoclaw-launcher/README.zh.md
new file mode 100644
index 000000000..320de75a5
--- /dev/null
+++ b/cmd/picoclaw-launcher/README.zh.md
@@ -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/
+```
diff --git a/cmd/picoclaw-launcher/icon.ico b/cmd/picoclaw-launcher/icon.ico
new file mode 100644
index 000000000..4f6539414
Binary files /dev/null and b/cmd/picoclaw-launcher/icon.ico differ
diff --git a/cmd/picoclaw-launcher/internal/server/auth_config.go b/cmd/picoclaw-launcher/internal/server/auth_config.go
new file mode 100644
index 000000000..f75e8fff0
--- /dev/null
+++ b/cmd/picoclaw-launcher/internal/server/auth_config.go
@@ -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/")
+}
diff --git a/cmd/picoclaw-launcher/internal/server/auth_config_test.go b/cmd/picoclaw-launcher/internal/server/auth_config_test.go
new file mode 100644
index 000000000..92158d011
--- /dev/null
+++ b/cmd/picoclaw-launcher/internal/server/auth_config_test.go
@@ -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)
+ }
+ }
+}
diff --git a/cmd/picoclaw-launcher/internal/server/auth_handlers.go b/cmd/picoclaw-launcher/internal/server/auth_handlers.go
new file mode 100644
index 000000000..1e9b8be0a
--- /dev/null
+++ b/cmd/picoclaw-launcher/internal/server/auth_handlers.go
@@ -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,
+ `
Authentication failed
%s
You can close this window.
`,
+ 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,
+ `Authentication failed
%s
You can close this window.
`,
+ 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, `Failed to save credentials
%s
`, 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, `
+ Authentication successful!
+ Redirecting back to Config Editor...
+
+ `)
+}
+
+// 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
+}
diff --git a/cmd/picoclaw-launcher/internal/server/logbuffer.go b/cmd/picoclaw-launcher/internal/server/logbuffer.go
new file mode 100644
index 000000000..4d70f6466
--- /dev/null
+++ b/cmd/picoclaw-launcher/internal/server/logbuffer.go
@@ -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
+}
diff --git a/cmd/picoclaw-launcher/internal/server/logbuffer_test.go b/cmd/picoclaw-launcher/internal/server/logbuffer_test.go
new file mode 100644
index 000000000..dc525be16
--- /dev/null
+++ b/cmd/picoclaw-launcher/internal/server/logbuffer_test.go
@@ -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())
+}
diff --git a/cmd/picoclaw-launcher/internal/server/process.go b/cmd/picoclaw-launcher/internal/server/process.go
new file mode 100644
index 000000000..bc2129bf5
--- /dev/null
+++ b/cmd/picoclaw-launcher/internal/server/process.go
@@ -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"
+}
diff --git a/cmd/picoclaw-launcher/internal/server/server.go b/cmd/picoclaw-launcher/internal/server/server.go
new file mode 100644
index 000000000..4fc68f04c
--- /dev/null
+++ b/cmd/picoclaw-launcher/internal/server/server.go
@@ -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)
+}
diff --git a/cmd/picoclaw-launcher/internal/server/server_test.go b/cmd/picoclaw-launcher/internal/server/server_test.go
new file mode 100644
index 000000000..c87e93d8c
--- /dev/null
+++ b/cmd/picoclaw-launcher/internal/server/server_test.go
@@ -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)
+ }
+ }
+}
diff --git a/cmd/picoclaw-launcher/internal/server/utils.go b/cmd/picoclaw-launcher/internal/server/utils.go
new file mode 100644
index 000000000..a46adbece
--- /dev/null
+++ b/cmd/picoclaw-launcher/internal/server/utils.go
@@ -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 ""
+}
diff --git a/cmd/picoclaw-launcher/internal/ui/index.html b/cmd/picoclaw-launcher/internal/ui/index.html
new file mode 100644
index 000000000..93893fd75
--- /dev/null
+++ b/cmd/picoclaw-launcher/internal/ui/index.html
@@ -0,0 +1,1999 @@
+
+
+
+
+
+
+
+ PicoClaw Config
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Models
+
Manage LLM model configurations. Models without an API key are grayed out. Only available models can be set as primary.
+
+
+
+
+
+
+
+
Provider Authentication
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Gateway Logs
+
Real-time output from the gateway process.
+
+
+
No logs available. Start the gateway to see output here.
+
+
+
+
+
+
Raw JSON
+
Directly edit the configuration file.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/picoclaw-launcher/main.go b/cmd/picoclaw-launcher/main.go
new file mode 100644
index 000000000..3323c31a8
--- /dev/null
+++ b/cmd/picoclaw-launcher/main.go
@@ -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
+}
diff --git a/cmd/picoclaw-launcher/winres/winres.json b/cmd/picoclaw-launcher/winres/winres.json
new file mode 100644
index 000000000..01ea7364c
--- /dev/null
+++ b/cmd/picoclaw-launcher/winres/winres.json
@@ -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
+ }
+ }
+ }
+}
diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go
index baa489b92..747f7d44e 100644
--- a/cmd/picoclaw/internal/gateway/helpers.go
+++ b/cmd/picoclaw/internal/gateway/helpers.go
@@ -3,6 +3,7 @@ package gateway
import (
"context"
"fmt"
+ "log"
"os"
"os/signal"
"path/filepath"
@@ -175,6 +176,7 @@ func gatewayCmd(debug bool) error {
if err := channelManager.StartAll(ctx); err != nil {
fmt.Printf("Error starting channels: %v\n", err)
+ return err
}
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
@@ -222,7 +224,11 @@ func setupCronTool(
cronService := cron.NewCronService(cronStorePath, nil)
// 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)
// Set the onJob handler
diff --git a/cmd/picoclaw/internal/migrate/command.go b/cmd/picoclaw/internal/migrate/command.go
index fb1cee164..76352c9db 100644
--- a/cmd/picoclaw/internal/migrate/command.go
+++ b/cmd/picoclaw/internal/migrate/command.go
@@ -11,19 +11,21 @@ func NewMigrateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "migrate",
- Short: "Migrate from OpenClaw to PicoClaw",
+ 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 {
- result, err := migrate.Run(opts)
+ m := migrate.NewMigrateInstance(opts)
+ result, err := m.Run(opts)
if err != nil {
return err
}
if !opts.DryRun {
- migrate.PrintSummary(result)
+ m.PrintSummary(result)
}
return nil
},
@@ -31,6 +33,8 @@ func NewMigrateCommand() *cobra.Command {
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,
@@ -39,10 +43,10 @@ func NewMigrateCommand() *cobra.Command {
"Only migrate workspace files, skip config")
cmd.Flags().BoolVar(&opts.Force, "force", false,
"Skip confirmation prompts")
- cmd.Flags().StringVar(&opts.OpenClawHome, "openclaw-home", "",
- "Override OpenClaw home directory (default: ~/.openclaw)")
- cmd.Flags().StringVar(&opts.PicoClawHome, "picoclaw-home", "",
- "Override PicoClaw home directory (default: ~/.picoclaw)")
+ 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
}
diff --git a/cmd/picoclaw/internal/migrate/command_test.go b/cmd/picoclaw/internal/migrate/command_test.go
index 1948aa327..5110249a2 100644
--- a/cmd/picoclaw/internal/migrate/command_test.go
+++ b/cmd/picoclaw/internal/migrate/command_test.go
@@ -13,7 +13,7 @@ func TestNewMigrateCommand(t *testing.T) {
require.NotNil(t, cmd)
assert.Equal(t, "migrate", cmd.Use)
- assert.Equal(t, "Migrate from OpenClaw to PicoClaw", cmd.Short)
+ assert.Equal(t, "Migrate from xxxclaw(openclaw, etc.) to picoclaw", cmd.Short)
assert.Len(t, cmd.Aliases, 0)
@@ -33,6 +33,6 @@ func TestNewMigrateCommand(t *testing.T) {
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("openclaw-home"))
- assert.NotNil(t, cmd.Flags().Lookup("picoclaw-home"))
+ assert.NotNil(t, cmd.Flags().Lookup("source-home"))
+ assert.NotNil(t, cmd.Flags().Lookup("target-home"))
}
diff --git a/go.mod b/go.mod
index d7f9b1901..7892cade6 100644
--- a/go.mod
+++ b/go.mod
@@ -33,13 +33,18 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
+ github.com/gdamore/encoding v1.0.1 // indirect
+ github.com/gdamore/tcell/v2 v2.13.8 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/rivo/tview v0.42.0 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
diff --git a/go.sum b/go.sum
index 941ab67ce..d1ee1d629 100644
--- a/go.sum
+++ b/go.sum
@@ -50,6 +50,10 @@ github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
+github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
+github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
+github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
+github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0=
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
@@ -113,6 +117,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
+github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
+github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -148,6 +154,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
+github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go
index 097cb4efb..fe3ea670c 100644
--- a/pkg/agent/instance.go
+++ b/pkg/agent/instance.go
@@ -1,6 +1,7 @@
package agent
import (
+ "log"
"os"
"path/filepath"
"strings"
@@ -51,7 +52,12 @@ func NewAgentInstance(
toolsRegistry.Register(tools.NewReadFileTool(workspace, restrict))
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict))
toolsRegistry.Register(tools.NewListDirTool(workspace, restrict))
- toolsRegistry.Register(tools.NewExecToolWithConfig(workspace, restrict, cfg))
+ execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg)
+ if err != nil {
+ log.Fatalf("Critical error: unable to initialize exec tool: %v", err)
+ }
+ toolsRegistry.Register(execTool)
+
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict))
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict))
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index bc43695a8..069e267da 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -9,6 +9,7 @@ package agent
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"path/filepath"
"strings"
@@ -586,11 +587,36 @@ func (al *AgentLoop) handleReasoning(ctx context.Context, reasoningContent, chan
return
}
- al.bus.PublishOutbound(ctx, bus.OutboundMessage{
+ // Use a short timeout so the goroutine does not block indefinitely when
+ // the outbound bus is full. Reasoning output is best-effort; dropping it
+ // is acceptable to avoid goroutine accumulation.
+ pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second)
+ defer pubCancel()
+
+ if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: channelName,
ChatID: channelID,
Content: reasoningContent,
- })
+ }); err != nil {
+ // Treat context.DeadlineExceeded / context.Canceled as expected
+ // (bus full under load, or parent canceled). Check the error
+ // itself rather than ctx.Err(), because pubCtx may time out
+ // (5 s) while the parent ctx is still active.
+ // Also treat ErrBusClosed as expected — it occurs during normal
+ // shutdown when the bus is closed before all goroutines finish.
+ if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) ||
+ errors.Is(err, bus.ErrBusClosed) {
+ logger.DebugCF("agent", "Reasoning publish skipped (timeout/cancel)", map[string]any{
+ "channel": channelName,
+ "error": err.Error(),
+ })
+ } else {
+ logger.WarnCF("agent", "Failed to publish reasoning (best-effort)", map[string]any{
+ "channel": channelName,
+ "error": err.Error(),
+ })
+ }
+ }
}
// runLLMIteration executes the LLM call loop with tool handling.
@@ -678,10 +704,35 @@ func (al *AgentLoop) runLLMIteration(
}
errMsg := strings.ToLower(err.Error())
- isContextError := strings.Contains(errMsg, "token") ||
- strings.Contains(errMsg, "context") ||
+
+ // Check if this is a network/HTTP timeout — not a context window error.
+ isTimeoutError := errors.Is(err, context.DeadlineExceeded) ||
+ strings.Contains(errMsg, "deadline exceeded") ||
+ strings.Contains(errMsg, "client.timeout") ||
+ strings.Contains(errMsg, "timed out") ||
+ strings.Contains(errMsg, "timeout exceeded")
+
+ // Detect real context window / token limit errors, excluding network timeouts.
+ isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") ||
+ strings.Contains(errMsg, "context window") ||
+ strings.Contains(errMsg, "maximum context length") ||
+ strings.Contains(errMsg, "token limit") ||
+ strings.Contains(errMsg, "too many tokens") ||
+ strings.Contains(errMsg, "max_tokens") ||
strings.Contains(errMsg, "invalidparameter") ||
- strings.Contains(errMsg, "length")
+ strings.Contains(errMsg, "prompt is too long") ||
+ strings.Contains(errMsg, "request too large"))
+
+ if isTimeoutError && retry < maxRetries {
+ backoff := time.Duration(retry+1) * 5 * time.Second
+ logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{
+ "error": err.Error(),
+ "retry": retry,
+ "backoff": backoff.String(),
+ })
+ time.Sleep(backoff)
+ continue
+ }
if isContextError && retry < maxRetries {
logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{
diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go
index 6dfc7ef3e..801b6a46e 100644
--- a/pkg/agent/loop_test.go
+++ b/pkg/agent/loop_test.go
@@ -797,4 +797,57 @@ func TestHandleReasoning(t *testing.T) {
t.Fatalf("expected no outbound message, got %+v", msg)
}
})
+
+ t.Run("returns promptly when bus is full", func(t *testing.T) {
+ al, msgBus := newLoop(t)
+
+ // Fill the outbound bus buffer until a publish would block.
+ // Use a short timeout to detect when the buffer is full,
+ // rather than hardcoding the buffer size.
+ for i := 0; ; i++ {
+ fillCtx, fillCancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ err := msgBus.PublishOutbound(fillCtx, bus.OutboundMessage{
+ Channel: "filler",
+ ChatID: "filler",
+ Content: fmt.Sprintf("filler-%d", i),
+ })
+ fillCancel()
+ if err != nil {
+ // Buffer is full (timed out trying to send).
+ break
+ }
+ }
+
+ // Use a short-deadline parent context to bound the test.
+ ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
+ defer cancel()
+
+ start := time.Now()
+ al.handleReasoning(ctx, "should timeout", "slack", "channel-full")
+ elapsed := time.Since(start)
+
+ // handleReasoning uses a 5s internal timeout, but the parent ctx
+ // expires in 500ms. It should return within ~500ms, not 5s.
+ if elapsed > 2*time.Second {
+ t.Fatalf("handleReasoning blocked too long (%v); expected prompt return", elapsed)
+ }
+
+ // Drain the bus and verify the reasoning message was NOT published
+ // (it should have been dropped due to timeout).
+ drainCtx, drainCancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer drainCancel()
+ foundReasoning := false
+ for {
+ msg, ok := msgBus.SubscribeOutbound(drainCtx)
+ if !ok {
+ break
+ }
+ if msg.Content == "should timeout" {
+ foundReasoning = true
+ }
+ }
+ if foundReasoning {
+ t.Fatal("expected reasoning message to be dropped when bus is full, but it was published")
+ }
+ })
}
diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go
index ba757ffd4..91c9e25c5 100644
--- a/pkg/auth/oauth.go
+++ b/pkg/auth/oauth.go
@@ -66,7 +66,8 @@ func decodeBase64(s string) string {
return string(data)
}
-func generateState() (string, error) {
+// GenerateState generates a random state string for OAuth CSRF protection.
+func GenerateState() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
@@ -80,7 +81,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
return nil, fmt.Errorf("generating PKCE: %w", err)
}
- state, err := generateState()
+ state, err := GenerateState()
if err != nil {
return nil, fmt.Errorf("generating state: %w", err)
}
@@ -127,7 +128,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
fmt.Printf("Open this URL to authenticate:\n\n%s\n\n", authURL)
- if err := openBrowser(authURL); err != nil {
+ if err := OpenBrowser(authURL); err != nil {
fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL)
}
@@ -153,7 +154,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
if result.err != nil {
return nil, result.err
}
- return exchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
+ return ExchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
case manualInput := <-manualCh:
if manualInput == "" {
return nil, fmt.Errorf("manual input canceled")
@@ -169,7 +170,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
if code == "" {
return nil, fmt.Errorf("could not find authorization code in input")
}
- return exchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI)
+ return ExchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI)
case <-time.After(5 * time.Minute):
return nil, fmt.Errorf("authentication timed out after 5 minutes")
}
@@ -186,6 +187,59 @@ type deviceCodeResponse struct {
Interval int
}
+// DeviceCodeInfo holds the device code information returned by the OAuth provider.
+type DeviceCodeInfo struct {
+ DeviceAuthID string `json:"device_auth_id"`
+ UserCode string `json:"user_code"`
+ VerifyURL string `json:"verify_url"`
+ Interval int `json:"interval"`
+}
+
+// RequestDeviceCode requests a device code from the OAuth provider.
+// Returns the info needed for the user to authenticate in a browser.
+func RequestDeviceCode(cfg OAuthProviderConfig) (*DeviceCodeInfo, error) {
+ reqBody, _ := json.Marshal(map[string]string{
+ "client_id": cfg.ClientID,
+ })
+
+ resp, err := http.Post(
+ cfg.Issuer+"/api/accounts/deviceauth/usercode",
+ "application/json",
+ strings.NewReader(string(reqBody)),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("requesting device code: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("device code request failed: %s", string(body))
+ }
+
+ deviceResp, err := parseDeviceCodeResponse(body)
+ if err != nil {
+ return nil, fmt.Errorf("parsing device code response: %w", err)
+ }
+
+ if deviceResp.Interval < 1 {
+ deviceResp.Interval = 5
+ }
+
+ return &DeviceCodeInfo{
+ DeviceAuthID: deviceResp.DeviceAuthID,
+ UserCode: deviceResp.UserCode,
+ VerifyURL: cfg.Issuer + "/codex/device",
+ Interval: deviceResp.Interval,
+ }, nil
+}
+
+// PollDeviceCodeOnce makes a single poll attempt to check if the user has authenticated.
+// Returns (credential, nil) on success, (nil, nil) if still pending, or (nil, err) on failure.
+func PollDeviceCodeOnce(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*AuthCredential, error) {
+ return pollDeviceCode(cfg, deviceAuthID, userCode)
+}
+
func parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) {
var raw struct {
DeviceAuthID string `json:"device_auth_id"`
@@ -318,7 +372,7 @@ func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*Au
}
redirectURI := cfg.Issuer + "/deviceauth/callback"
- return exchangeCodeForTokens(cfg, tokenResp.AuthorizationCode, tokenResp.CodeVerifier, redirectURI)
+ return ExchangeCodeForTokens(cfg, tokenResp.AuthorizationCode, tokenResp.CodeVerifier, redirectURI)
}
func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCredential, error) {
@@ -410,7 +464,8 @@ func buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectU
return cfg.Issuer + "/oauth/authorize?" + params.Encode()
}
-func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) {
+// ExchangeCodeForTokens exchanges an authorization code for tokens.
+func ExchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) {
data := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
@@ -552,7 +607,8 @@ func base64URLDecode(s string) ([]byte, error) {
return base64.StdEncoding.DecodeString(s)
}
-func openBrowser(url string) error {
+// OpenBrowser opens the given URL in the user's default browser.
+func OpenBrowser(url string) error {
switch runtime.GOOS {
case "darwin":
return exec.Command("open", url).Start()
diff --git a/pkg/auth/oauth_test.go b/pkg/auth/oauth_test.go
index 0cb589069..230ac7c2a 100644
--- a/pkg/auth/oauth_test.go
+++ b/pkg/auth/oauth_test.go
@@ -219,9 +219,9 @@ func TestExchangeCodeForTokens(t *testing.T) {
Port: 1455,
}
- cred, err := exchangeCodeForTokens(cfg, "test-code", "test-verifier", "http://localhost:1455/auth/callback")
+ cred, err := ExchangeCodeForTokens(cfg, "test-code", "test-verifier", "http://localhost:1455/auth/callback")
if err != nil {
- t.Fatalf("exchangeCodeForTokens() error: %v", err)
+ t.Fatalf("ExchangeCodeForTokens() error: %v", err)
}
if cred.AccessToken != "mock-access-token" {
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index 31af9672c..155e50b39 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -313,7 +313,7 @@ func (m *Manager) StartAll(ctx context.Context) error {
if len(m.channels) == 0 {
logger.WarnC("channels", "No channels enabled")
- return nil
+ return errors.New("no channels enabled")
}
logger.InfoC("channels", "Starting all channels")
diff --git a/pkg/channels/whatsapp_native/whatsapp_native.go b/pkg/channels/whatsapp_native/whatsapp_native.go
index 23115bda7..188a7c8fa 100644
--- a/pkg/channels/whatsapp_native/whatsapp_native.go
+++ b/pkg/channels/whatsapp_native/whatsapp_native.go
@@ -15,6 +15,7 @@ import (
"path/filepath"
"strings"
"sync"
+ "sync/atomic"
"time"
"github.com/mdp/qrterminal/v3"
@@ -56,6 +57,8 @@ type WhatsAppNativeChannel struct {
runCancel context.CancelFunc
reconnectMu sync.Mutex
reconnecting bool
+ stopping atomic.Bool // set once Stop begins; prevents new wg.Add calls
+ wg sync.WaitGroup // tracks background goroutines (QR handler, reconnect)
}
// NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection.
@@ -80,6 +83,14 @@ func NewWhatsAppNativeChannel(
func (c *WhatsAppNativeChannel) Start(ctx context.Context) error {
logger.InfoCF("whatsapp", "Starting WhatsApp native channel (whatsmeow)", map[string]any{"store": c.storePath})
+ // Reset lifecycle state from any previous Stop() so a restarted channel
+ // behaves correctly. Use reconnectMu to be consistent with eventHandler
+ // and Stop() which coordinate under the same lock.
+ c.reconnectMu.Lock()
+ c.stopping.Store(false)
+ c.reconnecting = false
+ c.reconnectMu.Unlock()
+
if err := os.MkdirAll(c.storePath, 0o700); err != nil {
return fmt.Errorf("create session store dir: %w", err)
}
@@ -112,6 +123,12 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error {
}
client := whatsmeow.NewClient(deviceStore, waLogger)
+
+ // Create runCtx/runCancel BEFORE registering event handler and starting
+ // goroutines so that Stop() can cancel them at any time, including during
+ // the QR-login flow.
+ c.runCtx, c.runCancel = context.WithCancel(ctx)
+
client.AddEventHandler(c.eventHandler)
c.mu.Lock()
@@ -119,36 +136,75 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error {
c.client = client
c.mu.Unlock()
+ // cleanupOnError clears struct references and releases resources when
+ // Start() fails after fields are already assigned. This prevents
+ // Stop() from operating on stale references (double-close, disconnect
+ // of a partially-initialized client, or stray event handler callbacks).
+ startOK := false
+ defer func() {
+ if startOK {
+ return
+ }
+ c.runCancel()
+ client.Disconnect()
+ c.mu.Lock()
+ c.client = nil
+ c.container = nil
+ c.mu.Unlock()
+ _ = container.Close()
+ }()
+
if client.Store.ID == nil {
- qrChan, err := client.GetQRChannel(ctx)
+ qrChan, err := client.GetQRChannel(c.runCtx)
if err != nil {
- _ = container.Close()
return fmt.Errorf("get QR channel: %w", err)
}
if err := client.Connect(); err != nil {
- _ = container.Close()
return fmt.Errorf("connect: %w", err)
}
- for evt := range qrChan {
- if evt.Event == "code" {
- logger.InfoCF("whatsapp", "Scan this QR code with WhatsApp (Linked Devices):", nil)
- qrterminal.GenerateWithConfig(evt.Code, qrterminal.Config{
- Level: qrterminal.L,
- Writer: os.Stdout,
- HalfBlocks: true,
- })
- } else {
- logger.InfoCF("whatsapp", "WhatsApp login event", map[string]any{"event": evt.Event})
- }
+ // Handle QR events in a background goroutine so Start() returns
+ // promptly. The goroutine is tracked via c.wg and respects
+ // c.runCtx for cancellation.
+ // Guard wg.Add with reconnectMu + stopping check (same protocol
+ // as eventHandler) so a concurrent Stop() cannot enter wg.Wait()
+ // while we call wg.Add(1).
+ c.reconnectMu.Lock()
+ if c.stopping.Load() {
+ c.reconnectMu.Unlock()
+ return fmt.Errorf("channel stopped during QR setup")
}
+ c.wg.Add(1)
+ c.reconnectMu.Unlock()
+ go func() {
+ defer c.wg.Done()
+ for {
+ select {
+ case <-c.runCtx.Done():
+ return
+ case evt, ok := <-qrChan:
+ if !ok {
+ return
+ }
+ if evt.Event == "code" {
+ logger.InfoCF("whatsapp", "Scan this QR code with WhatsApp (Linked Devices):", nil)
+ qrterminal.GenerateWithConfig(evt.Code, qrterminal.Config{
+ Level: qrterminal.L,
+ Writer: os.Stdout,
+ HalfBlocks: true,
+ })
+ } else {
+ logger.InfoCF("whatsapp", "WhatsApp login event", map[string]any{"event": evt.Event})
+ }
+ }
+ }
+ }()
} else {
if err := client.Connect(); err != nil {
- _ = container.Close()
return fmt.Errorf("connect: %w", err)
}
}
- c.runCtx, c.runCancel = context.WithCancel(ctx)
+ startOK = true
c.SetRunning(true)
logger.InfoC("whatsapp", "WhatsApp native channel connected")
return nil
@@ -156,19 +212,53 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error {
func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error {
logger.InfoC("whatsapp", "Stopping WhatsApp native channel")
+
+ // Mark as stopping under reconnectMu so the flag is visible to
+ // eventHandler atomically with respect to its wg.Add(1) call.
+ // This closes the TOCTOU window where eventHandler could check
+ // stopping (false), then Stop sets it true + enters wg.Wait,
+ // then eventHandler calls wg.Add(1) — causing a panic.
+ c.reconnectMu.Lock()
+ c.stopping.Store(true)
+ c.reconnectMu.Unlock()
+
if c.runCancel != nil {
c.runCancel()
}
+
+ // Disconnect the client first so any blocking Connect()/reconnect loops
+ // can be interrupted before we wait on the goroutines.
c.mu.Lock()
client := c.client
container := c.container
- c.client = nil
- c.container = nil
c.mu.Unlock()
if client != nil {
client.Disconnect()
}
+
+ // Wait for background goroutines (QR handler, reconnect) to finish in a
+ // context-aware way so Stop can be bounded by ctx.
+ done := make(chan struct{})
+ go func() {
+ c.wg.Wait()
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ // All goroutines have finished.
+ case <-ctx.Done():
+ // Context canceled or timed out; log and proceed with best-effort cleanup.
+ logger.WarnC("whatsapp", fmt.Sprintf("Stop context canceled before all goroutines finished: %v", ctx.Err()))
+ }
+
+ // Now it is safe to clear and close resources.
+ c.mu.Lock()
+ c.client = nil
+ c.container = nil
+ c.mu.Unlock()
+
if container != nil {
_ = container.Close()
}
@@ -187,9 +277,20 @@ func (c *WhatsAppNativeChannel) eventHandler(evt any) {
c.reconnectMu.Unlock()
return
}
+ // Check stopping while holding the lock so the check and wg.Add
+ // are atomic with respect to Stop() setting the flag + calling
+ // wg.Wait(). This prevents the TOCTOU race.
+ if c.stopping.Load() {
+ c.reconnectMu.Unlock()
+ return
+ }
c.reconnecting = true
+ c.wg.Add(1)
c.reconnectMu.Unlock()
- go c.reconnectWithBackoff()
+ go func() {
+ defer c.wg.Done()
+ c.reconnectWithBackoff()
+ }()
}
}
@@ -313,6 +414,12 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag
return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
}
+ // Detect unpaired state: the client is connected (to WhatsApp servers)
+ // but has not completed QR-login yet, so sending would fail.
+ if client.Store.ID == nil {
+ return fmt.Errorf("whatsapp not yet paired (QR login pending): %w", channels.ErrTemporary)
+ }
+
to, err := parseJID(msg.ChatID)
if err != nil {
return fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err)
diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go
deleted file mode 100644
index ea91565e8..000000000
--- a/pkg/migrate/config.go
+++ /dev/null
@@ -1,414 +0,0 @@
-package migrate
-
-import (
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "unicode"
-
- "github.com/sipeed/picoclaw/pkg/config"
-)
-
-var supportedProviders = map[string]bool{
- "anthropic": true,
- "openai": true,
- "openrouter": true,
- "groq": true,
- "zhipu": true,
- "vllm": true,
- "gemini": true,
- "qwen": true,
- "deepseek": true,
- "github_copilot": true,
- "mistral": true,
-}
-
-var supportedChannels = map[string]bool{
- "telegram": true,
- "discord": true,
- "whatsapp": true,
- "feishu": true,
- "qq": true,
- "dingtalk": true,
- "maixcam": true,
-}
-
-func findOpenClawConfig(openclawHome string) (string, error) {
- candidates := []string{
- filepath.Join(openclawHome, "openclaw.json"),
- filepath.Join(openclawHome, "config.json"),
- }
- for _, p := range candidates {
- if _, err := os.Stat(p); err == nil {
- return p, nil
- }
- }
- return "", fmt.Errorf("no config file found in %s (tried openclaw.json, config.json)", openclawHome)
-}
-
-func LoadOpenClawConfig(configPath string) (map[string]any, error) {
- data, err := os.ReadFile(configPath)
- if err != nil {
- return nil, fmt.Errorf("reading OpenClaw config: %w", err)
- }
-
- var raw map[string]any
- if err := json.Unmarshal(data, &raw); err != nil {
- return nil, fmt.Errorf("parsing OpenClaw config: %w", err)
- }
-
- converted := convertKeysToSnake(raw)
- result, ok := converted.(map[string]any)
- if !ok {
- return nil, fmt.Errorf("unexpected config format")
- }
- return result, nil
-}
-
-func ConvertConfig(data map[string]any) (*config.Config, []string, error) {
- cfg := config.DefaultConfig()
- var warnings []string
-
- if agents, ok := getMap(data, "agents"); ok {
- if defaults, ok := getMap(agents, "defaults"); ok {
- // Prefer model_name, fallback to model for backward compatibility
- if v, ok := getString(defaults, "model_name"); ok {
- cfg.Agents.Defaults.ModelName = v
- } else if v, ok := getString(defaults, "model"); ok {
- cfg.Agents.Defaults.Model = v
- }
- if v, ok := getFloat(defaults, "max_tokens"); ok {
- cfg.Agents.Defaults.MaxTokens = int(v)
- }
- if v, ok := getFloat(defaults, "temperature"); ok {
- cfg.Agents.Defaults.Temperature = &v
- }
- if v, ok := getFloat(defaults, "max_tool_iterations"); ok {
- cfg.Agents.Defaults.MaxToolIterations = int(v)
- }
- if v, ok := getString(defaults, "workspace"); ok {
- cfg.Agents.Defaults.Workspace = rewriteWorkspacePath(v)
- }
- }
- }
-
- if providers, ok := getMap(data, "providers"); ok {
- for name, val := range providers {
- pMap, ok := val.(map[string]any)
- if !ok {
- continue
- }
- apiKey, _ := getString(pMap, "api_key")
- apiBase, _ := getString(pMap, "api_base")
-
- if !supportedProviders[name] {
- if apiKey != "" || apiBase != "" {
- warnings = append(warnings, fmt.Sprintf("Provider '%s' not supported in PicoClaw, skipping", name))
- }
- continue
- }
-
- pc := config.ProviderConfig{APIKey: apiKey, APIBase: apiBase}
- switch name {
- case "anthropic":
- cfg.Providers.Anthropic = pc
- case "openai":
- cfg.Providers.OpenAI = config.OpenAIProviderConfig{
- ProviderConfig: pc,
- WebSearch: getBoolOrDefault(pMap, "web_search", true),
- }
- case "openrouter":
- cfg.Providers.OpenRouter = pc
- case "groq":
- cfg.Providers.Groq = pc
- case "zhipu":
- cfg.Providers.Zhipu = pc
- case "vllm":
- cfg.Providers.VLLM = pc
- case "gemini":
- cfg.Providers.Gemini = pc
- }
- }
- }
-
- if channels, ok := getMap(data, "channels"); ok {
- for name, val := range channels {
- cMap, ok := val.(map[string]any)
- if !ok {
- continue
- }
- if !supportedChannels[name] {
- warnings = append(warnings, fmt.Sprintf("Channel '%s' not supported in PicoClaw, skipping", name))
- continue
- }
- enabled, _ := getBool(cMap, "enabled")
- allowFrom := getStringSlice(cMap, "allow_from")
-
- switch name {
- case "telegram":
- cfg.Channels.Telegram.Enabled = enabled
- cfg.Channels.Telegram.AllowFrom = allowFrom
- if v, ok := getString(cMap, "token"); ok {
- cfg.Channels.Telegram.Token = v
- }
- case "discord":
- cfg.Channels.Discord.Enabled = enabled
- cfg.Channels.Discord.AllowFrom = allowFrom
- if v, ok := getString(cMap, "token"); ok {
- cfg.Channels.Discord.Token = v
- }
- case "whatsapp":
- cfg.Channels.WhatsApp.Enabled = enabled
- cfg.Channels.WhatsApp.AllowFrom = allowFrom
- if v, ok := getString(cMap, "bridge_url"); ok {
- cfg.Channels.WhatsApp.BridgeURL = v
- }
- if v, ok := getBool(cMap, "use_native"); ok {
- cfg.Channels.WhatsApp.UseNative = v
- }
- if v, ok := getString(cMap, "session_store_path"); ok {
- cfg.Channels.WhatsApp.SessionStorePath = v
- }
- case "feishu":
- cfg.Channels.Feishu.Enabled = enabled
- cfg.Channels.Feishu.AllowFrom = allowFrom
- if v, ok := getString(cMap, "app_id"); ok {
- cfg.Channels.Feishu.AppID = v
- }
- if v, ok := getString(cMap, "app_secret"); ok {
- cfg.Channels.Feishu.AppSecret = v
- }
- if v, ok := getString(cMap, "encrypt_key"); ok {
- cfg.Channels.Feishu.EncryptKey = v
- }
- if v, ok := getString(cMap, "verification_token"); ok {
- cfg.Channels.Feishu.VerificationToken = v
- }
- case "qq":
- cfg.Channels.QQ.Enabled = enabled
- cfg.Channels.QQ.AllowFrom = allowFrom
- if v, ok := getString(cMap, "app_id"); ok {
- cfg.Channels.QQ.AppID = v
- }
- if v, ok := getString(cMap, "app_secret"); ok {
- cfg.Channels.QQ.AppSecret = v
- }
- case "dingtalk":
- cfg.Channels.DingTalk.Enabled = enabled
- cfg.Channels.DingTalk.AllowFrom = allowFrom
- if v, ok := getString(cMap, "client_id"); ok {
- cfg.Channels.DingTalk.ClientID = v
- }
- if v, ok := getString(cMap, "client_secret"); ok {
- cfg.Channels.DingTalk.ClientSecret = v
- }
- case "maixcam":
- cfg.Channels.MaixCam.Enabled = enabled
- cfg.Channels.MaixCam.AllowFrom = allowFrom
- if v, ok := getString(cMap, "host"); ok {
- cfg.Channels.MaixCam.Host = v
- }
- if v, ok := getFloat(cMap, "port"); ok {
- cfg.Channels.MaixCam.Port = int(v)
- }
- }
- }
- }
-
- if gateway, ok := getMap(data, "gateway"); ok {
- if v, ok := getString(gateway, "host"); ok {
- cfg.Gateway.Host = v
- }
- if v, ok := getFloat(gateway, "port"); ok {
- cfg.Gateway.Port = int(v)
- }
- }
-
- if tools, ok := getMap(data, "tools"); ok {
- if web, ok := getMap(tools, "web"); ok {
- // Migrate old "search" config to "brave" if api_key is present
- if search, ok := getMap(web, "search"); ok {
- if v, ok := getString(search, "api_key"); ok {
- cfg.Tools.Web.Brave.APIKey = v
- if v != "" {
- cfg.Tools.Web.Brave.Enabled = true
- }
- }
- if v, ok := getFloat(search, "max_results"); ok {
- cfg.Tools.Web.Brave.MaxResults = int(v)
- cfg.Tools.Web.DuckDuckGo.MaxResults = int(v)
- }
- }
- }
- }
-
- return cfg, warnings, nil
-}
-
-func MergeConfig(existing, incoming *config.Config) *config.Config {
- if existing.Providers.Anthropic.APIKey == "" {
- existing.Providers.Anthropic = incoming.Providers.Anthropic
- }
- if existing.Providers.OpenAI.APIKey == "" {
- existing.Providers.OpenAI = incoming.Providers.OpenAI
- }
- if existing.Providers.OpenRouter.APIKey == "" {
- existing.Providers.OpenRouter = incoming.Providers.OpenRouter
- }
- if existing.Providers.Groq.APIKey == "" {
- existing.Providers.Groq = incoming.Providers.Groq
- }
- if existing.Providers.Zhipu.APIKey == "" {
- existing.Providers.Zhipu = incoming.Providers.Zhipu
- }
- if existing.Providers.VLLM.APIKey == "" && existing.Providers.VLLM.APIBase == "" {
- existing.Providers.VLLM = incoming.Providers.VLLM
- }
- if existing.Providers.Gemini.APIKey == "" {
- existing.Providers.Gemini = incoming.Providers.Gemini
- }
- if existing.Providers.DeepSeek.APIKey == "" {
- existing.Providers.DeepSeek = incoming.Providers.DeepSeek
- }
- if existing.Providers.GitHubCopilot.APIBase == "" {
- existing.Providers.GitHubCopilot = incoming.Providers.GitHubCopilot
- }
- if existing.Providers.Qwen.APIKey == "" {
- existing.Providers.Qwen = incoming.Providers.Qwen
- }
-
- if !existing.Channels.Telegram.Enabled && incoming.Channels.Telegram.Enabled {
- existing.Channels.Telegram = incoming.Channels.Telegram
- }
- if !existing.Channels.Discord.Enabled && incoming.Channels.Discord.Enabled {
- existing.Channels.Discord = incoming.Channels.Discord
- }
- if !existing.Channels.WhatsApp.Enabled && incoming.Channels.WhatsApp.Enabled {
- existing.Channels.WhatsApp = incoming.Channels.WhatsApp
- }
- if !existing.Channels.Feishu.Enabled && incoming.Channels.Feishu.Enabled {
- existing.Channels.Feishu = incoming.Channels.Feishu
- }
- if !existing.Channels.QQ.Enabled && incoming.Channels.QQ.Enabled {
- existing.Channels.QQ = incoming.Channels.QQ
- }
- if !existing.Channels.DingTalk.Enabled && incoming.Channels.DingTalk.Enabled {
- existing.Channels.DingTalk = incoming.Channels.DingTalk
- }
- if !existing.Channels.MaixCam.Enabled && incoming.Channels.MaixCam.Enabled {
- existing.Channels.MaixCam = incoming.Channels.MaixCam
- }
-
- if existing.Tools.Web.Brave.APIKey == "" {
- existing.Tools.Web.Brave = incoming.Tools.Web.Brave
- }
-
- return existing
-}
-
-func camelToSnake(s string) string {
- var result strings.Builder
- for i, r := range s {
- if unicode.IsUpper(r) {
- if i > 0 {
- prev := rune(s[i-1])
- if unicode.IsLower(prev) || unicode.IsDigit(prev) {
- result.WriteRune('_')
- } else if unicode.IsUpper(prev) && i+1 < len(s) && unicode.IsLower(rune(s[i+1])) {
- result.WriteRune('_')
- }
- }
- result.WriteRune(unicode.ToLower(r))
- } else {
- result.WriteRune(r)
- }
- }
- return result.String()
-}
-
-func convertKeysToSnake(data any) any {
- switch v := data.(type) {
- case map[string]any:
- result := make(map[string]any, len(v))
- for key, val := range v {
- result[camelToSnake(key)] = convertKeysToSnake(val)
- }
- return result
- case []any:
- result := make([]any, len(v))
- for i, val := range v {
- result[i] = convertKeysToSnake(val)
- }
- return result
- default:
- return data
- }
-}
-
-func rewriteWorkspacePath(path string) string {
- path = strings.Replace(path, ".openclaw", ".picoclaw", 1)
- return path
-}
-
-func getMap(data map[string]any, key string) (map[string]any, bool) {
- v, ok := data[key]
- if !ok {
- return nil, false
- }
- m, ok := v.(map[string]any)
- return m, ok
-}
-
-func getString(data map[string]any, key string) (string, bool) {
- v, ok := data[key]
- if !ok {
- return "", false
- }
- s, ok := v.(string)
- return s, ok
-}
-
-func getFloat(data map[string]any, key string) (float64, bool) {
- v, ok := data[key]
- if !ok {
- return 0, false
- }
- f, ok := v.(float64)
- return f, ok
-}
-
-func getBool(data map[string]any, key string) (bool, bool) {
- v, ok := data[key]
- if !ok {
- return false, false
- }
- b, ok := v.(bool)
- return b, ok
-}
-
-func getBoolOrDefault(data map[string]any, key string, defaultVal bool) bool {
- if v, ok := getBool(data, key); ok {
- return v
- }
- return defaultVal
-}
-
-func getStringSlice(data map[string]any, key string) []string {
- v, ok := data[key]
- if !ok {
- return []string{}
- }
- arr, ok := v.([]any)
- if !ok {
- return []string{}
- }
- result := make([]string, 0, len(arr))
- for _, item := range arr {
- if s, ok := item.(string); ok {
- result = append(result, s)
- }
- }
- return result
-}
diff --git a/pkg/migrate/workspace.go b/pkg/migrate/internal/common.go
similarity index 55%
rename from pkg/migrate/workspace.go
rename to pkg/migrate/internal/common.go
index f45748fac..c77ab9f26 100644
--- a/pkg/migrate/workspace.go
+++ b/pkg/migrate/internal/common.go
@@ -1,24 +1,50 @@
-package migrate
+package internal
import (
+ "fmt"
+ "io"
"os"
"path/filepath"
)
-var migrateableFiles = []string{
- "AGENTS.md",
- "SOUL.md",
- "USER.md",
- "TOOLS.md",
- "HEARTBEAT.md",
+func ResolveTargetHome(override string) (string, error) {
+ if override != "" {
+ return ExpandHome(override), nil
+ }
+ if envHome := os.Getenv("PICOCLAW_HOME"); envHome != "" {
+ return ExpandHome(envHome), nil
+ }
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("resolving home directory: %w", err)
+ }
+ return filepath.Join(home, ".picoclaw"), nil
}
-var migrateableDirs = []string{
- "memory",
- "skills",
+func ExpandHome(path string) string {
+ if path == "" {
+ return path
+ }
+ if path[0] == '~' {
+ home, _ := os.UserHomeDir()
+ if len(path) > 1 && path[1] == '/' {
+ return home + path[1:]
+ }
+ return home
+ }
+ return path
}
-func PlanWorkspaceMigration(srcWorkspace, dstWorkspace string, force bool) ([]Action, error) {
+func ResolveWorkspace(homeDir string) string {
+ return filepath.Join(homeDir, "workspace")
+}
+
+func PlanWorkspaceMigration(
+ srcWorkspace, dstWorkspace string,
+ migrateableFiles []string,
+ migrateableDirs []string,
+ force bool,
+) ([]Action, error) {
var actions []Action
for _, filename := range migrateableFiles {
@@ -50,7 +76,7 @@ func planFileCopy(src, dst string, force bool) Action {
return Action{
Type: ActionSkip,
Source: src,
- Destination: dst,
+ Target: dst,
Description: "source file not found",
}
}
@@ -60,7 +86,7 @@ func planFileCopy(src, dst string, force bool) Action {
return Action{
Type: ActionBackup,
Source: src,
- Destination: dst,
+ Target: dst,
Description: "destination exists, will backup and overwrite",
}
}
@@ -68,7 +94,7 @@ func planFileCopy(src, dst string, force bool) Action {
return Action{
Type: ActionCopy,
Source: src,
- Destination: dst,
+ Target: dst,
Description: "copy file",
}
}
@@ -91,7 +117,7 @@ func planDirCopy(srcDir, dstDir string, force bool) ([]Action, error) {
if info.IsDir() {
actions = append(actions, Action{
Type: ActionCreateDir,
- Destination: dst,
+ Target: dst,
Description: "create directory",
})
return nil
@@ -104,3 +130,33 @@ func planDirCopy(srcDir, dstDir string, force bool) ([]Action, error) {
return actions, err
}
+
+func RelPath(path, base string) string {
+ rel, err := filepath.Rel(base, path)
+ if err != nil {
+ return filepath.Base(path)
+ }
+ return rel
+}
+
+func CopyFile(src, dst string) error {
+ srcFile, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer srcFile.Close()
+
+ info, err := srcFile.Stat()
+ if err != nil {
+ return err
+ }
+
+ dstFile, err := os.OpenFile(dst, 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
+}
diff --git a/pkg/migrate/internal/common_test.go b/pkg/migrate/internal/common_test.go
new file mode 100644
index 000000000..a089157f5
--- /dev/null
+++ b/pkg/migrate/internal/common_test.go
@@ -0,0 +1,195 @@
+package internal
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestExpandHome(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"", ""},
+ {"/absolute/path", "/absolute/path"},
+ {"relative/path", "relative/path"},
+ }
+
+ for _, tt := range tests {
+ result := ExpandHome(tt.input)
+ assert.Equal(t, tt.expected, result)
+ }
+}
+
+func TestExpandHomeWithTilde(t *testing.T) {
+ home, err := os.UserHomeDir()
+ require.NoError(t, err)
+
+ result := ExpandHome("~/path")
+ assert.Equal(t, home+"/path", result)
+
+ result = ExpandHome("~")
+ assert.Equal(t, home, result)
+}
+
+func TestResolveWorkspace(t *testing.T) {
+ result := ResolveWorkspace("/home/user/.picoclaw")
+ assert.Equal(t, "/home/user/.picoclaw/workspace", result)
+}
+
+func TestRelPath(t *testing.T) {
+ result := RelPath("/home/user/.picoclaw/workspace/file.txt", "/home/user/.picoclaw")
+ assert.Equal(t, "workspace/file.txt", result)
+}
+
+func TestRelPathError(t *testing.T) {
+ result := RelPath("relative/path", "/different/base")
+ assert.Equal(t, "path", result)
+}
+
+func TestResolveTargetHome(t *testing.T) {
+ home, err := os.UserHomeDir()
+ require.NoError(t, err)
+
+ result, err := ResolveTargetHome("")
+ require.NoError(t, err)
+ assert.Equal(t, filepath.Join(home, ".picoclaw"), result)
+}
+
+func TestResolveTargetHomeWithOverride(t *testing.T) {
+ result, err := ResolveTargetHome("/custom/path")
+ require.NoError(t, err)
+ assert.Equal(t, "/custom/path", result)
+}
+
+func TestCopyFile(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ sourceFile := filepath.Join(tmpDir, "source.txt")
+ err := os.WriteFile(sourceFile, []byte("test content"), 0o644)
+ require.NoError(t, err)
+
+ dstFile := filepath.Join(tmpDir, "dest.txt")
+ err = CopyFile(sourceFile, dstFile)
+ require.NoError(t, err)
+
+ content, err := os.ReadFile(dstFile)
+ require.NoError(t, err)
+ assert.Equal(t, "test content", string(content))
+}
+
+func TestCopyFileSourceNotFound(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ err := CopyFile(filepath.Join(tmpDir, "nonexistent.txt"), filepath.Join(tmpDir, "dest.txt"))
+ require.Error(t, err)
+}
+
+func TestPlanWorkspaceMigration(t *testing.T) {
+ tmpDir := t.TempDir()
+ srcWorkspace := filepath.Join(tmpDir, "src", "workspace")
+ dstWorkspace := filepath.Join(tmpDir, "dst", "workspace")
+
+ err := os.MkdirAll(srcWorkspace, 0o755)
+ require.NoError(t, err)
+
+ err = os.WriteFile(filepath.Join(srcWorkspace, "file1.txt"), []byte("content"), 0o644)
+ require.NoError(t, err)
+
+ err = os.MkdirAll(filepath.Join(srcWorkspace, "subdir"), 0o755)
+ require.NoError(t, err)
+
+ err = os.WriteFile(filepath.Join(srcWorkspace, "subdir", "file2.txt"), []byte("content"), 0o644)
+ require.NoError(t, err)
+
+ actions, err := PlanWorkspaceMigration(
+ srcWorkspace,
+ dstWorkspace,
+ []string{"file1.txt"},
+ []string{"subdir"},
+ false,
+ )
+ require.NoError(t, err)
+
+ assert.GreaterOrEqual(t, len(actions), 1)
+}
+
+func TestPlanWorkspaceMigrationWithExistingDestination(t *testing.T) {
+ tmpDir := t.TempDir()
+ srcWorkspace := filepath.Join(tmpDir, "src", "workspace")
+ dstWorkspace := filepath.Join(tmpDir, "dst", "workspace")
+
+ err := os.MkdirAll(srcWorkspace, 0o755)
+ require.NoError(t, err)
+
+ err = os.MkdirAll(dstWorkspace, 0o755)
+ require.NoError(t, err)
+
+ err = os.WriteFile(filepath.Join(srcWorkspace, "file1.txt"), []byte("source"), 0o644)
+ require.NoError(t, err)
+
+ err = os.WriteFile(filepath.Join(dstWorkspace, "file1.txt"), []byte("existing"), 0o644)
+ require.NoError(t, err)
+
+ actions, err := PlanWorkspaceMigration(
+ srcWorkspace,
+ dstWorkspace,
+ []string{"file1.txt"},
+ []string{},
+ false,
+ )
+ require.NoError(t, err)
+
+ require.GreaterOrEqual(t, len(actions), 1)
+ assert.Equal(t, ActionBackup, actions[0].Type)
+}
+
+func TestPlanWorkspaceMigrationForce(t *testing.T) {
+ tmpDir := t.TempDir()
+ srcWorkspace := filepath.Join(tmpDir, "src", "workspace")
+ dstWorkspace := filepath.Join(tmpDir, "dst", "workspace")
+
+ err := os.MkdirAll(srcWorkspace, 0o755)
+ require.NoError(t, err)
+
+ err = os.MkdirAll(dstWorkspace, 0o755)
+ require.NoError(t, err)
+
+ err = os.WriteFile(filepath.Join(srcWorkspace, "file1.txt"), []byte("source"), 0o644)
+ require.NoError(t, err)
+
+ err = os.WriteFile(filepath.Join(dstWorkspace, "file1.txt"), []byte("existing"), 0o644)
+ require.NoError(t, err)
+
+ actions, err := PlanWorkspaceMigration(
+ srcWorkspace,
+ dstWorkspace,
+ []string{"file1.txt"},
+ []string{},
+ true,
+ )
+ require.NoError(t, err)
+
+ require.GreaterOrEqual(t, len(actions), 1)
+ assert.Equal(t, ActionCopy, actions[0].Type)
+}
+
+func TestPlanWorkspaceMigrationNonExistentSource(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ actions, err := PlanWorkspaceMigration(
+ filepath.Join(tmpDir, "nonexistent"),
+ filepath.Join(tmpDir, "dst", "workspace"),
+ []string{"file1.txt"},
+ []string{},
+ false,
+ )
+ require.NoError(t, err)
+ require.Len(t, actions, 1)
+ assert.Equal(t, ActionSkip, actions[0].Type)
+ assert.Contains(t, actions[0].Description, "source file not found")
+}
diff --git a/pkg/migrate/internal/types.go b/pkg/migrate/internal/types.go
new file mode 100644
index 000000000..e86a4dea1
--- /dev/null
+++ b/pkg/migrate/internal/types.go
@@ -0,0 +1,52 @@
+package internal
+
+type Options struct {
+ DryRun bool
+ ConfigOnly bool
+ WorkspaceOnly bool
+ Force bool
+ Refresh bool
+ Source string
+ SourceHome string
+ TargetHome string
+}
+
+type Operation interface {
+ GetSourceName() string
+ GetSourceHome() (string, error)
+ GetSourceWorkspace() (string, error)
+ GetSourceConfigFile() (string, error)
+ ExecuteConfigMigration(srcConfigPath, dstConfigPath string) error
+ GetMigrateableFiles() []string
+ GetMigrateableDirs() []string
+}
+
+type HandlerFactory func(opts Options) Operation
+
+type ActionType int
+
+const (
+ ActionCopy ActionType = iota
+ ActionSkip
+ ActionBackup
+ ActionConvertConfig
+ ActionCreateDir
+ ActionMergeConfig
+)
+
+type Action struct {
+ Type ActionType
+ Source string
+ Target string
+ Description string
+}
+
+type Result struct {
+ FilesCopied int
+ FilesSkipped int
+ BackupsCreated int
+ ConfigMigrated bool
+ DirsCreated int
+ Warnings []string
+ Errors []error
+}
diff --git a/pkg/migrate/migrate.go b/pkg/migrate/migrate.go
index cfa82b7d7..51fecf438 100644
--- a/pkg/migrate/migrate.go
+++ b/pkg/migrate/migrate.go
@@ -2,53 +2,73 @@ package migrate
import (
"fmt"
- "io"
"os"
"path/filepath"
"strings"
- "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/migrate/internal"
+ "github.com/sipeed/picoclaw/pkg/migrate/sources/openclaw"
)
-type ActionType int
+type (
+ Options = internal.Options
+ Operation = internal.Operation
+ ActionType = internal.ActionType
+ Action = internal.Action
+ Result = internal.Result
+ HandlerFactory = internal.HandlerFactory
+)
const (
- ActionCopy ActionType = iota
- ActionSkip
- ActionBackup
- ActionConvertConfig
- ActionCreateDir
- ActionMergeConfig
+ ActionCopy = internal.ActionCopy
+ ActionSkip = internal.ActionSkip
+ ActionBackup = internal.ActionBackup
+ ActionConvertConfig = internal.ActionConvertConfig
+ ActionCreateDir = internal.ActionCreateDir
+ ActionMergeConfig = internal.ActionMergeConfig
)
-type Options struct {
- DryRun bool
- ConfigOnly bool
- WorkspaceOnly bool
- Force bool
- Refresh bool
- OpenClawHome string
- PicoClawHome string
+type MigrateInstance struct {
+ options Options
+ handlers map[string]Operation
}
-type Action struct {
- Type ActionType
- Source string
- Destination string
- Description string
+func NewMigrateInstance(opts Options) *MigrateInstance {
+ instance := &MigrateInstance{
+ options: opts,
+ handlers: make(map[string]Operation),
+ }
+
+ openclaw_handler, err := openclaw.NewOpenclawHandler(opts)
+ if err == nil {
+ instance.Register(openclaw_handler.GetSourceName(), openclaw_handler)
+ }
+
+ return instance
}
-type Result struct {
- FilesCopied int
- FilesSkipped int
- BackupsCreated int
- ConfigMigrated bool
- DirsCreated int
- Warnings []string
- Errors []error
+func (m *MigrateInstance) Register(moduleName string, module Operation) {
+ m.handlers[moduleName] = module
}
-func Run(opts Options) (*Result, error) {
+func (m *MigrateInstance) getCurrentHandler() (Operation, error) {
+ source := m.options.Source
+ if source == "" {
+ source = "openclaw"
+ }
+ handler, ok := m.handlers[source]
+ if !ok {
+ return nil, fmt.Errorf("Source '%s' not found", source)
+ }
+ return handler, nil
+}
+
+func (m *MigrateInstance) Run(opts Options) (*Result, error) {
+ handler, err := m.getCurrentHandler()
+ if err != nil {
+ return nil, err
+ }
+
if opts.ConfigOnly && opts.WorkspaceOnly {
return nil, fmt.Errorf("--config-only and --workspace-only are mutually exclusive")
}
@@ -57,28 +77,28 @@ func Run(opts Options) (*Result, error) {
opts.WorkspaceOnly = true
}
- openclawHome, err := resolveOpenClawHome(opts.OpenClawHome)
+ sourceHome, err := handler.GetSourceHome()
if err != nil {
return nil, err
}
- picoClawHome, err := resolvePicoClawHome(opts.PicoClawHome)
+ targetHome, err := internal.ResolveTargetHome(opts.TargetHome)
if err != nil {
return nil, err
}
- if _, err = os.Stat(openclawHome); os.IsNotExist(err) {
- return nil, fmt.Errorf("OpenClaw installation not found at %s", openclawHome)
+ if _, err = os.Stat(sourceHome); os.IsNotExist(err) {
+ return nil, fmt.Errorf("Source installation not found at %s", sourceHome)
}
- actions, warnings, err := Plan(opts, openclawHome, picoClawHome)
+ actions, warnings, err := m.Plan(opts, sourceHome, targetHome)
if err != nil {
return nil, err
}
- fmt.Println("Migrating from OpenClaw to PicoClaw")
- fmt.Printf(" Source: %s\n", openclawHome)
- fmt.Printf(" Destination: %s\n", picoClawHome)
+ fmt.Println("Migrating from Source to PicoClaw")
+ fmt.Printf(" Source: %s\n", sourceHome)
+ fmt.Printf(" Target: %s\n", targetHome)
fmt.Println()
if opts.DryRun {
@@ -95,19 +115,23 @@ func Run(opts Options) (*Result, error) {
fmt.Println()
}
- result := Execute(actions, openclawHome, picoClawHome)
+ result := m.Execute(actions, sourceHome, targetHome)
result.Warnings = warnings
return result, nil
}
-func Plan(opts Options, openclawHome, picoClawHome string) ([]Action, []string, error) {
+func (m *MigrateInstance) Plan(opts Options, sourceHome, targetHome string) ([]Action, []string, error) {
var actions []Action
var warnings []string
+ handler, err := m.getCurrentHandler()
+ if err != nil {
+ return nil, nil, err
+ }
force := opts.Force || opts.Refresh
if !opts.WorkspaceOnly {
- configPath, err := findOpenClawConfig(openclawHome)
+ configPath, err := handler.GetSourceConfigFile()
if err != nil {
if opts.ConfigOnly {
return nil, nil, err
@@ -117,91 +141,95 @@ func Plan(opts Options, openclawHome, picoClawHome string) ([]Action, []string,
actions = append(actions, Action{
Type: ActionConvertConfig,
Source: configPath,
- Destination: filepath.Join(picoClawHome, "config.json"),
- Description: "convert OpenClaw config to PicoClaw format",
+ Target: filepath.Join(targetHome, "config.json"),
+ Description: "convert Source config to PicoClaw format",
})
-
- data, err := LoadOpenClawConfig(configPath)
- if err == nil {
- _, configWarnings, _ := ConvertConfig(data)
- warnings = append(warnings, configWarnings...)
- }
}
}
if !opts.ConfigOnly {
- srcWorkspace := resolveWorkspace(openclawHome)
- dstWorkspace := resolveWorkspace(picoClawHome)
+ srcWorkspace, err := handler.GetSourceWorkspace()
+ if err != nil {
+ return nil, nil, fmt.Errorf("getting source workspace: %w", err)
+ }
+ dstWorkspace := internal.ResolveWorkspace(targetHome)
if _, err := os.Stat(srcWorkspace); err == nil {
- wsActions, err := PlanWorkspaceMigration(srcWorkspace, dstWorkspace, force)
+ wsActions, err := internal.PlanWorkspaceMigration(srcWorkspace, dstWorkspace,
+ handler.GetMigrateableFiles(),
+ handler.GetMigrateableDirs(),
+ force)
if err != nil {
return nil, nil, fmt.Errorf("planning workspace migration: %w", err)
}
actions = append(actions, wsActions...)
} else {
- warnings = append(warnings, "OpenClaw workspace directory not found, skipping workspace migration")
+ warnings = append(warnings, "Source workspace directory not found, skipping workspace migration")
}
}
return actions, warnings, nil
}
-func Execute(actions []Action, openclawHome, picoClawHome string) *Result {
+func (m *MigrateInstance) Execute(actions []Action, sourceHome, targetHome string) *Result {
result := &Result{}
+ handler, err := m.getCurrentHandler()
+ if err != nil {
+ return result
+ }
for _, action := range actions {
switch action.Type {
case ActionConvertConfig:
- if err := executeConfigMigration(action.Source, action.Destination, picoClawHome); err != nil {
+ if err := handler.ExecuteConfigMigration(action.Source, action.Target); err != nil {
result.Errors = append(result.Errors, fmt.Errorf("config migration: %w", err))
fmt.Printf(" ✗ Config migration failed: %v\n", err)
} else {
result.ConfigMigrated = true
- fmt.Printf(" ✓ Converted config: %s\n", action.Destination)
+ fmt.Printf(" ✓ Converted config: %s\n", action.Target)
}
case ActionCreateDir:
- if err := os.MkdirAll(action.Destination, 0o755); err != nil {
+ if err := os.MkdirAll(action.Target, 0o755); err != nil {
result.Errors = append(result.Errors, err)
} else {
result.DirsCreated++
}
case ActionBackup:
- bakPath := action.Destination + ".bak"
- if err := copyFile(action.Destination, bakPath); err != nil {
- result.Errors = append(result.Errors, fmt.Errorf("backup %s: %w", action.Destination, err))
- fmt.Printf(" ✗ Backup failed: %s\n", action.Destination)
+ bakPath := action.Target + ".bak"
+ if err := internal.CopyFile(action.Target, bakPath); err != nil {
+ result.Errors = append(result.Errors, fmt.Errorf("backup %s: %w", action.Target, err))
+ fmt.Printf(" ✗ Backup failed: %s\n", action.Target)
continue
}
result.BackupsCreated++
fmt.Printf(
" ✓ Backed up %s -> %s.bak\n",
- filepath.Base(action.Destination),
- filepath.Base(action.Destination),
+ filepath.Base(action.Target),
+ filepath.Base(action.Target),
)
- if err := os.MkdirAll(filepath.Dir(action.Destination), 0o755); err != nil {
+ if err := os.MkdirAll(filepath.Dir(action.Target), 0o755); err != nil {
result.Errors = append(result.Errors, err)
continue
}
- if err := copyFile(action.Source, action.Destination); err != nil {
+ if err := internal.CopyFile(action.Source, action.Target); err != nil {
result.Errors = append(result.Errors, fmt.Errorf("copy %s: %w", action.Source, err))
fmt.Printf(" ✗ Copy failed: %s\n", action.Source)
} else {
result.FilesCopied++
- fmt.Printf(" ✓ Copied %s\n", relPath(action.Source, openclawHome))
+ fmt.Printf(" ✓ Copied %s\n", internal.RelPath(action.Source, sourceHome))
}
case ActionCopy:
- if err := os.MkdirAll(filepath.Dir(action.Destination), 0o755); err != nil {
+ if err := os.MkdirAll(filepath.Dir(action.Target), 0o755); err != nil {
result.Errors = append(result.Errors, err)
continue
}
- if err := copyFile(action.Source, action.Destination); err != nil {
+ if err := internal.CopyFile(action.Source, action.Target); err != nil {
result.Errors = append(result.Errors, fmt.Errorf("copy %s: %w", action.Source, err))
fmt.Printf(" ✗ Copy failed: %s\n", action.Source)
} else {
result.FilesCopied++
- fmt.Printf(" ✓ Copied %s\n", relPath(action.Source, openclawHome))
+ fmt.Printf(" ✓ Copied %s\n", internal.RelPath(action.Source, sourceHome))
}
case ActionSkip:
result.FilesSkipped++
@@ -211,31 +239,6 @@ func Execute(actions []Action, openclawHome, picoClawHome string) *Result {
return result
}
-func executeConfigMigration(srcConfigPath, dstConfigPath, picoClawHome string) error {
- data, err := LoadOpenClawConfig(srcConfigPath)
- if err != nil {
- return err
- }
-
- incoming, _, err := ConvertConfig(data)
- if err != nil {
- return err
- }
-
- if _, err := os.Stat(dstConfigPath); err == nil {
- existing, err := config.LoadConfig(dstConfigPath)
- if err != nil {
- return fmt.Errorf("loading existing PicoClaw config: %w", err)
- }
- incoming = MergeConfig(existing, incoming)
- }
-
- if err := os.MkdirAll(filepath.Dir(dstConfigPath), 0o755); err != nil {
- return err
- }
- return config.SaveConfig(dstConfigPath, incoming)
-}
-
func Confirm() bool {
fmt.Print("Proceed with migration? (y/n): ")
var response string
@@ -243,49 +246,7 @@ func Confirm() bool {
return strings.ToLower(strings.TrimSpace(response)) == "y"
}
-func PrintPlan(actions []Action, warnings []string) {
- fmt.Println("Planned actions:")
- copies := 0
- skips := 0
- backups := 0
- configCount := 0
-
- for _, action := range actions {
- switch action.Type {
- case ActionConvertConfig:
- fmt.Printf(" [config] %s -> %s\n", action.Source, action.Destination)
- configCount++
- case ActionCopy:
- fmt.Printf(" [copy] %s\n", filepath.Base(action.Source))
- copies++
- case ActionBackup:
- fmt.Printf(" [backup] %s (exists, will backup and overwrite)\n", filepath.Base(action.Destination))
- backups++
- copies++
- case ActionSkip:
- if action.Description != "" {
- fmt.Printf(" [skip] %s (%s)\n", filepath.Base(action.Source), action.Description)
- }
- skips++
- case ActionCreateDir:
- fmt.Printf(" [mkdir] %s\n", action.Destination)
- }
- }
-
- if len(warnings) > 0 {
- fmt.Println()
- fmt.Println("Warnings:")
- for _, w := range warnings {
- fmt.Printf(" - %s\n", w)
- }
- }
-
- fmt.Println()
- fmt.Printf("%d files to copy, %d configs to convert, %d backups needed, %d skipped\n",
- copies, configCount, backups, skips)
-}
-
-func PrintSummary(result *Result) {
+func (m *MigrateInstance) PrintSummary(result *Result) {
fmt.Println()
parts := []string{}
if result.FilesCopied > 0 {
@@ -316,83 +277,44 @@ func PrintSummary(result *Result) {
}
}
-func resolveOpenClawHome(override string) (string, error) {
- if override != "" {
- return expandHome(override), nil
- }
- if envHome := os.Getenv("OPENCLAW_HOME"); envHome != "" {
- return expandHome(envHome), nil
- }
- home, err := os.UserHomeDir()
- if err != nil {
- return "", fmt.Errorf("resolving home directory: %w", err)
- }
- return filepath.Join(home, ".openclaw"), nil
-}
+func PrintPlan(actions []Action, warnings []string) {
+ fmt.Println("Planned actions:")
+ copies := 0
+ skips := 0
+ backups := 0
+ configCount := 0
-func resolvePicoClawHome(override string) (string, error) {
- if override != "" {
- return expandHome(override), nil
- }
- if envHome := os.Getenv("PICOCLAW_HOME"); envHome != "" {
- return expandHome(envHome), nil
- }
- home, err := os.UserHomeDir()
- if err != nil {
- return "", fmt.Errorf("resolving home directory: %w", err)
- }
- return filepath.Join(home, ".picoclaw"), nil
-}
-
-func resolveWorkspace(homeDir string) string {
- return filepath.Join(homeDir, "workspace")
-}
-
-func expandHome(path string) string {
- if path == "" {
- return path
- }
- if path[0] == '~' {
- home, _ := os.UserHomeDir()
- if len(path) > 1 && path[1] == '/' {
- return home + path[1:]
+ for _, action := range actions {
+ switch action.Type {
+ case ActionConvertConfig:
+ fmt.Printf(" [config] %s -> %s\n", action.Source, action.Target)
+ configCount++
+ case ActionCopy:
+ fmt.Printf(" [copy] %s\n", filepath.Base(action.Source))
+ copies++
+ case ActionBackup:
+ fmt.Printf(" [backup] %s (exists, will backup and overwrite)\n", filepath.Base(action.Target))
+ backups++
+ copies++
+ case ActionSkip:
+ if action.Description != "" {
+ fmt.Printf(" [skip] %s (%s)\n", filepath.Base(action.Source), action.Description)
+ }
+ skips++
+ case ActionCreateDir:
+ fmt.Printf(" [mkdir] %s\n", action.Target)
}
- return home
}
- return path
-}
-
-func backupFile(path string) error {
- bakPath := path + ".bak"
- return copyFile(path, bakPath)
-}
-
-func copyFile(src, dst string) error {
- srcFile, err := os.Open(src)
- if err != nil {
- return err
- }
- defer srcFile.Close()
-
- info, err := srcFile.Stat()
- if err != nil {
- return err
- }
-
- dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
- if err != nil {
- return err
- }
- defer dstFile.Close()
-
- _, err = io.Copy(dstFile, srcFile)
- return err
-}
-
-func relPath(path, base string) string {
- rel, err := filepath.Rel(base, path)
- if err != nil {
- return filepath.Base(path)
- }
- return rel
+
+ if len(warnings) > 0 {
+ fmt.Println()
+ fmt.Println("Warnings:")
+ for _, w := range warnings {
+ fmt.Printf(" - %s\n", w)
+ }
+ }
+
+ fmt.Println()
+ fmt.Printf("%d files to copy, %d configs to convert, %d backups needed, %d skipped\n",
+ copies, configCount, backups, skips)
}
diff --git a/pkg/migrate/migrate_test.go b/pkg/migrate/migrate_test.go
index 9216442bb..fc9c2c3a7 100644
--- a/pkg/migrate/migrate_test.go
+++ b/pkg/migrate/migrate_test.go
@@ -1,875 +1,411 @@
package migrate
import (
- "encoding/json"
"os"
"path/filepath"
"testing"
- "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
-func TestCamelToSnake(t *testing.T) {
- tests := []struct {
- name string
- input string
- want string
- }{
- {"simple", "apiKey", "api_key"},
- {"two words", "apiBase", "api_base"},
- {"three words", "maxToolIterations", "max_tool_iterations"},
- {"already snake", "api_key", "api_key"},
- {"single word", "enabled", "enabled"},
- {"all lower", "model", "model"},
- {"consecutive caps", "apiURL", "api_url"},
- {"starts upper", "Model", "model"},
- {"bridge url", "bridgeUrl", "bridge_url"},
- {"client id", "clientId", "client_id"},
- {"app secret", "appSecret", "app_secret"},
- {"verification token", "verificationToken", "verification_token"},
- {"allow from", "allowFrom", "allow_from"},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := camelToSnake(tt.input)
- if got != tt.want {
- t.Errorf("camelToSnake(%q) = %q, want %q", tt.input, got, tt.want)
- }
- })
+func TestNewMigrateInstance(t *testing.T) {
+ opts := Options{
+ Source: "openclaw",
}
+ instance := NewMigrateInstance(opts)
+ require.NotNil(t, instance)
+ assert.Equal(t, "openclaw", instance.options.Source)
}
-func TestConvertKeysToSnake(t *testing.T) {
- input := map[string]any{
- "apiKey": "test-key",
- "apiBase": "https://example.com",
- "nested": map[string]any{
- "maxTokens": float64(8192),
- "allowFrom": []any{"user1", "user2"},
- "deeperLevel": map[string]any{
- "clientId": "abc",
- },
- },
- }
+func TestMigrateInstanceRegister(t *testing.T) {
+ instance := NewMigrateInstance(Options{})
+ require.NotNil(t, instance)
- result := convertKeysToSnake(input)
- m, ok := result.(map[string]any)
- if !ok {
- t.Fatal("expected map[string]interface{}")
- }
+ mockHandler := &mockOperation{}
+ instance.Register("test-source", mockHandler)
- if _, ok = m["api_key"]; !ok {
- t.Error("expected key 'api_key' after conversion")
- }
- if _, ok = m["api_base"]; !ok {
- t.Error("expected key 'api_base' after conversion")
- }
-
- nested, ok := m["nested"].(map[string]any)
- if !ok {
- t.Fatal("expected nested map")
- }
- if _, ok = nested["max_tokens"]; !ok {
- t.Error("expected key 'max_tokens' in nested map")
- }
- if _, ok = nested["allow_from"]; !ok {
- t.Error("expected key 'allow_from' in nested map")
- }
-
- deeper, ok := nested["deeper_level"].(map[string]any)
- if !ok {
- t.Fatal("expected deeper_level map")
- }
- if _, ok := deeper["client_id"]; !ok {
- t.Error("expected key 'client_id' in deeper level")
- }
+ handler, ok := instance.handlers["test-source"]
+ require.True(t, ok)
+ assert.Equal(t, mockHandler, handler)
}
-func TestLoadOpenClawConfig(t *testing.T) {
+func TestMigrateInstanceGetCurrentHandler(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "openclaw.json")
+ err := os.WriteFile(configPath, []byte("{}"), 0o644)
+ require.NoError(t, err)
- openclawConfig := map[string]any{
- "providers": map[string]any{
- "anthropic": map[string]any{
- "apiKey": "sk-ant-test123",
- "apiBase": "https://api.anthropic.com",
- },
- },
- "agents": map[string]any{
- "defaults": map[string]any{
- "maxTokens": float64(4096),
- "model": "claude-3-opus",
- },
- },
- }
+ instance := NewMigrateInstance(Options{SourceHome: tmpDir})
+ require.NotNil(t, instance)
- data, err := json.Marshal(openclawConfig)
- if err != nil {
- t.Fatal(err)
- }
- if err = os.WriteFile(configPath, data, 0o644); err != nil {
- t.Fatal(err)
- }
-
- result, err := LoadOpenClawConfig(configPath)
- if err != nil {
- t.Fatalf("LoadOpenClawConfig: %v", err)
- }
-
- providers, ok := result["providers"].(map[string]any)
- if !ok {
- t.Fatal("expected providers map")
- }
- anthropic, ok := providers["anthropic"].(map[string]any)
- if !ok {
- t.Fatal("expected anthropic map")
- }
- if anthropic["api_key"] != "sk-ant-test123" {
- t.Errorf("api_key = %v, want sk-ant-test123", anthropic["api_key"])
- }
-
- agents, ok := result["agents"].(map[string]any)
- if !ok {
- t.Fatal("expected agents map")
- }
- defaults, ok := agents["defaults"].(map[string]any)
- if !ok {
- t.Fatal("expected defaults map")
- }
- if defaults["max_tokens"] != float64(4096) {
- t.Errorf("max_tokens = %v, want 4096", defaults["max_tokens"])
- }
+ handler, err := instance.getCurrentHandler()
+ require.NoError(t, err)
+ require.NotNil(t, handler)
+ assert.Equal(t, "openclaw", handler.GetSourceName())
}
-func TestConvertConfig(t *testing.T) {
- t.Run("providers mapping", func(t *testing.T) {
- data := map[string]any{
- "providers": map[string]any{
- "anthropic": map[string]any{
- "api_key": "sk-ant-test",
- "api_base": "https://api.anthropic.com",
- },
- "openrouter": map[string]any{
- "api_key": "sk-or-test",
- },
- "groq": map[string]any{
- "api_key": "gsk-test",
- },
- },
- }
-
- cfg, warnings, err := ConvertConfig(data)
- if err != nil {
- t.Fatalf("ConvertConfig: %v", err)
- }
- if len(warnings) != 0 {
- t.Errorf("expected no warnings, got %v", warnings)
- }
- if cfg.Providers.Anthropic.APIKey != "sk-ant-test" {
- t.Errorf("Anthropic.APIKey = %q, want %q", cfg.Providers.Anthropic.APIKey, "sk-ant-test")
- }
- if cfg.Providers.OpenRouter.APIKey != "sk-or-test" {
- t.Errorf("OpenRouter.APIKey = %q, want %q", cfg.Providers.OpenRouter.APIKey, "sk-or-test")
- }
- if cfg.Providers.Groq.APIKey != "gsk-test" {
- t.Errorf("Groq.APIKey = %q, want %q", cfg.Providers.Groq.APIKey, "gsk-test")
- }
- })
-
- t.Run("unsupported provider warning", func(t *testing.T) {
- data := map[string]any{
- "providers": map[string]any{
- "unknown_provider": map[string]any{
- "api_key": "sk-test",
- },
- },
- }
-
- _, warnings, err := ConvertConfig(data)
- if err != nil {
- t.Fatalf("ConvertConfig: %v", err)
- }
- if len(warnings) != 1 {
- t.Fatalf("expected 1 warning, got %d", len(warnings))
- }
- if warnings[0] != "Provider 'unknown_provider' not supported in PicoClaw, skipping" {
- t.Errorf("unexpected warning: %s", warnings[0])
- }
- })
-
- t.Run("channels mapping", func(t *testing.T) {
- data := map[string]any{
- "channels": map[string]any{
- "telegram": map[string]any{
- "enabled": true,
- "token": "tg-token-123",
- "allow_from": []any{"user1"},
- },
- "discord": map[string]any{
- "enabled": true,
- "token": "disc-token-456",
- },
- },
- }
-
- cfg, _, err := ConvertConfig(data)
- if err != nil {
- t.Fatalf("ConvertConfig: %v", err)
- }
- if !cfg.Channels.Telegram.Enabled {
- t.Error("Telegram should be enabled")
- }
- if cfg.Channels.Telegram.Token != "tg-token-123" {
- t.Errorf("Telegram.Token = %q, want %q", cfg.Channels.Telegram.Token, "tg-token-123")
- }
- if len(cfg.Channels.Telegram.AllowFrom) != 1 || cfg.Channels.Telegram.AllowFrom[0] != "user1" {
- t.Errorf("Telegram.AllowFrom = %v, want [user1]", cfg.Channels.Telegram.AllowFrom)
- }
- if !cfg.Channels.Discord.Enabled {
- t.Error("Discord should be enabled")
- }
- })
-
- t.Run("unsupported channel warning", func(t *testing.T) {
- data := map[string]any{
- "channels": map[string]any{
- "email": map[string]any{
- "enabled": true,
- },
- },
- }
-
- _, warnings, err := ConvertConfig(data)
- if err != nil {
- t.Fatalf("ConvertConfig: %v", err)
- }
- if len(warnings) != 1 {
- t.Fatalf("expected 1 warning, got %d", len(warnings))
- }
- if warnings[0] != "Channel 'email' not supported in PicoClaw, skipping" {
- t.Errorf("unexpected warning: %s", warnings[0])
- }
- })
-
- t.Run("agent defaults", func(t *testing.T) {
- data := map[string]any{
- "agents": map[string]any{
- "defaults": map[string]any{
- "model": "claude-3-opus",
- "max_tokens": float64(4096),
- "temperature": 0.5,
- "max_tool_iterations": float64(10),
- "workspace": "~/.openclaw/workspace",
- },
- },
- }
-
- cfg, _, err := ConvertConfig(data)
- if err != nil {
- t.Fatalf("ConvertConfig: %v", err)
- }
- if cfg.Agents.Defaults.Model != "claude-3-opus" {
- t.Errorf("Model = %q, want %q", cfg.Agents.Defaults.Model, "claude-3-opus")
- }
- if cfg.Agents.Defaults.MaxTokens != 4096 {
- t.Errorf("MaxTokens = %d, want %d", cfg.Agents.Defaults.MaxTokens, 4096)
- }
- if cfg.Agents.Defaults.Temperature == nil {
- t.Fatalf("Temperature is nil, want %f", 0.5)
- }
- if *cfg.Agents.Defaults.Temperature != 0.5 {
- t.Errorf("Temperature = %f, want %f", *cfg.Agents.Defaults.Temperature, 0.5)
- }
- if cfg.Agents.Defaults.Workspace != "~/.picoclaw/workspace" {
- t.Errorf("Workspace = %q, want %q", cfg.Agents.Defaults.Workspace, "~/.picoclaw/workspace")
- }
- })
-
- t.Run("empty config", func(t *testing.T) {
- data := map[string]any{}
-
- cfg, warnings, err := ConvertConfig(data)
- if err != nil {
- t.Fatalf("ConvertConfig: %v", err)
- }
- if len(warnings) != 0 {
- t.Errorf("expected no warnings, got %v", warnings)
- }
- if cfg.Agents.Defaults.Model != "" {
- t.Errorf("default model should be nil, got %q", cfg.Agents.Defaults.Model)
- }
- })
-}
-
-func TestSupportedProvidersCompatibility(t *testing.T) {
- expected := []string{
- "anthropic",
- "openai",
- "openrouter",
- "groq",
- "zhipu",
- "vllm",
- "gemini",
- }
-
- for _, provider := range expected {
- if !supportedProviders[provider] {
- t.Fatalf("supportedProviders missing expected key %q", provider)
- }
- }
-}
-
-func TestMergeConfig(t *testing.T) {
- t.Run("fills empty fields", func(t *testing.T) {
- existing := config.DefaultConfig()
- incoming := config.DefaultConfig()
- incoming.Providers.Anthropic.APIKey = "sk-ant-incoming"
- incoming.Providers.OpenRouter.APIKey = "sk-or-incoming"
-
- result := MergeConfig(existing, incoming)
- if result.Providers.Anthropic.APIKey != "sk-ant-incoming" {
- t.Errorf("Anthropic.APIKey = %q, want %q", result.Providers.Anthropic.APIKey, "sk-ant-incoming")
- }
- if result.Providers.OpenRouter.APIKey != "sk-or-incoming" {
- t.Errorf("OpenRouter.APIKey = %q, want %q", result.Providers.OpenRouter.APIKey, "sk-or-incoming")
- }
- })
-
- t.Run("preserves existing non-empty fields", func(t *testing.T) {
- existing := config.DefaultConfig()
- existing.Providers.Anthropic.APIKey = "sk-ant-existing"
-
- incoming := config.DefaultConfig()
- incoming.Providers.Anthropic.APIKey = "sk-ant-incoming"
- incoming.Providers.OpenAI.APIKey = "sk-oai-incoming"
-
- result := MergeConfig(existing, incoming)
- if result.Providers.Anthropic.APIKey != "sk-ant-existing" {
- t.Errorf("Anthropic.APIKey should be preserved, got %q", result.Providers.Anthropic.APIKey)
- }
- if result.Providers.OpenAI.APIKey != "sk-oai-incoming" {
- t.Errorf("OpenAI.APIKey should be filled, got %q", result.Providers.OpenAI.APIKey)
- }
- })
-
- t.Run("merges enabled channels", func(t *testing.T) {
- existing := config.DefaultConfig()
- incoming := config.DefaultConfig()
- incoming.Channels.Telegram.Enabled = true
- incoming.Channels.Telegram.Token = "tg-token"
-
- result := MergeConfig(existing, incoming)
- if !result.Channels.Telegram.Enabled {
- t.Error("Telegram should be enabled after merge")
- }
- if result.Channels.Telegram.Token != "tg-token" {
- t.Errorf("Telegram.Token = %q, want %q", result.Channels.Telegram.Token, "tg-token")
- }
- })
-
- t.Run("preserves existing enabled channels", func(t *testing.T) {
- existing := config.DefaultConfig()
- existing.Channels.Telegram.Enabled = true
- existing.Channels.Telegram.Token = "existing-token"
-
- incoming := config.DefaultConfig()
- incoming.Channels.Telegram.Enabled = true
- incoming.Channels.Telegram.Token = "incoming-token"
-
- result := MergeConfig(existing, incoming)
- if result.Channels.Telegram.Token != "existing-token" {
- t.Errorf("Telegram.Token should be preserved, got %q", result.Channels.Telegram.Token)
- }
- })
-}
-
-func TestPlanWorkspaceMigration(t *testing.T) {
- t.Run("copies available files", func(t *testing.T) {
- srcDir := t.TempDir()
- dstDir := t.TempDir()
-
- os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0o644)
- os.WriteFile(filepath.Join(srcDir, "SOUL.md"), []byte("# Soul"), 0o644)
- os.WriteFile(filepath.Join(srcDir, "USER.md"), []byte("# User"), 0o644)
-
- actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
- if err != nil {
- t.Fatalf("PlanWorkspaceMigration: %v", err)
- }
-
- copyCount := 0
- skipCount := 0
- for _, a := range actions {
- if a.Type == ActionCopy {
- copyCount++
- }
- if a.Type == ActionSkip {
- skipCount++
- }
- }
- if copyCount != 3 {
- t.Errorf("expected 3 copies, got %d", copyCount)
- }
- if skipCount != 2 {
- t.Errorf("expected 2 skips (TOOLS.md, HEARTBEAT.md), got %d", skipCount)
- }
- })
-
- t.Run("plans backup for existing destination files", func(t *testing.T) {
- srcDir := t.TempDir()
- dstDir := t.TempDir()
-
- os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0o644)
- os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing Agents"), 0o644)
-
- actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
- if err != nil {
- t.Fatalf("PlanWorkspaceMigration: %v", err)
- }
-
- backupCount := 0
- for _, a := range actions {
- if a.Type == ActionBackup && filepath.Base(a.Destination) == "AGENTS.md" {
- backupCount++
- }
- }
- if backupCount != 1 {
- t.Errorf("expected 1 backup action for AGENTS.md, got %d", backupCount)
- }
- })
-
- t.Run("force skips backup", func(t *testing.T) {
- srcDir := t.TempDir()
- dstDir := t.TempDir()
-
- os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0o644)
- os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing"), 0o644)
-
- actions, err := PlanWorkspaceMigration(srcDir, dstDir, true)
- if err != nil {
- t.Fatalf("PlanWorkspaceMigration: %v", err)
- }
-
- for _, a := range actions {
- if a.Type == ActionBackup {
- t.Error("expected no backup actions with force=true")
- }
- }
- })
-
- t.Run("handles memory directory", func(t *testing.T) {
- srcDir := t.TempDir()
- dstDir := t.TempDir()
-
- memDir := filepath.Join(srcDir, "memory")
- os.MkdirAll(memDir, 0o755)
- os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory"), 0o644)
-
- actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
- if err != nil {
- t.Fatalf("PlanWorkspaceMigration: %v", err)
- }
-
- hasCopy := false
- hasDir := false
- for _, a := range actions {
- if a.Type == ActionCopy && filepath.Base(a.Source) == "MEMORY.md" {
- hasCopy = true
- }
- if a.Type == ActionCreateDir {
- hasDir = true
- }
- }
- if !hasCopy {
- t.Error("expected copy action for memory/MEMORY.md")
- }
- if !hasDir {
- t.Error("expected create dir action for memory/")
- }
- })
-
- t.Run("handles skills directory", func(t *testing.T) {
- srcDir := t.TempDir()
- dstDir := t.TempDir()
-
- skillDir := filepath.Join(srcDir, "skills", "weather")
- os.MkdirAll(skillDir, 0o755)
- os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Weather"), 0o644)
-
- actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
- if err != nil {
- t.Fatalf("PlanWorkspaceMigration: %v", err)
- }
-
- hasCopy := false
- for _, a := range actions {
- if a.Type == ActionCopy && filepath.Base(a.Source) == "SKILL.md" {
- hasCopy = true
- }
- }
- if !hasCopy {
- t.Error("expected copy action for skills/weather/SKILL.md")
- }
- })
-}
-
-func TestFindOpenClawConfig(t *testing.T) {
- t.Run("finds openclaw.json", func(t *testing.T) {
- tmpDir := t.TempDir()
- configPath := filepath.Join(tmpDir, "openclaw.json")
- os.WriteFile(configPath, []byte("{}"), 0o644)
-
- found, err := findOpenClawConfig(tmpDir)
- if err != nil {
- t.Fatalf("findOpenClawConfig: %v", err)
- }
- if found != configPath {
- t.Errorf("found %q, want %q", found, configPath)
- }
- })
-
- t.Run("falls back to config.json", func(t *testing.T) {
- tmpDir := t.TempDir()
- configPath := filepath.Join(tmpDir, "config.json")
- os.WriteFile(configPath, []byte("{}"), 0o644)
-
- found, err := findOpenClawConfig(tmpDir)
- if err != nil {
- t.Fatalf("findOpenClawConfig: %v", err)
- }
- if found != configPath {
- t.Errorf("found %q, want %q", found, configPath)
- }
- })
-
- t.Run("prefers openclaw.json over config.json", func(t *testing.T) {
- tmpDir := t.TempDir()
- openclawPath := filepath.Join(tmpDir, "openclaw.json")
- os.WriteFile(openclawPath, []byte("{}"), 0o644)
- os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0o644)
-
- found, err := findOpenClawConfig(tmpDir)
- if err != nil {
- t.Fatalf("findOpenClawConfig: %v", err)
- }
- if found != openclawPath {
- t.Errorf("should prefer openclaw.json, got %q", found)
- }
- })
-
- t.Run("error when no config found", func(t *testing.T) {
- tmpDir := t.TempDir()
-
- _, err := findOpenClawConfig(tmpDir)
- if err == nil {
- t.Fatal("expected error when no config found")
- }
- })
-}
-
-func TestRewriteWorkspacePath(t *testing.T) {
- tests := []struct {
- name string
- input string
- want string
- }{
- {"default path", "~/.openclaw/workspace", "~/.picoclaw/workspace"},
- {"custom path", "/custom/path", "/custom/path"},
- {"empty", "", ""},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := rewriteWorkspacePath(tt.input)
- if got != tt.want {
- t.Errorf("rewriteWorkspacePath(%q) = %q, want %q", tt.input, got, tt.want)
- }
- })
- }
-}
-
-func TestRunDryRun(t *testing.T) {
- openclawHome := t.TempDir()
- picoClawHome := t.TempDir()
-
- wsDir := filepath.Join(openclawHome, "workspace")
- os.MkdirAll(wsDir, 0o755)
- os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644)
- os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents"), 0o644)
-
- configData := map[string]any{
- "providers": map[string]any{
- "anthropic": map[string]any{
- "apiKey": "test-key",
- },
- },
- }
- data, _ := json.Marshal(configData)
- os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
+func TestMigrateInstanceGetCurrentHandlerWithSource(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "openclaw.json")
+ err := os.WriteFile(configPath, []byte("{}"), 0o644)
+ require.NoError(t, err)
opts := Options{
- DryRun: true,
- OpenClawHome: openclawHome,
- PicoClawHome: picoClawHome,
+ Source: "openclaw",
+ SourceHome: tmpDir,
}
+ instance := NewMigrateInstance(opts)
- result, err := Run(opts)
- if err != nil {
- t.Fatalf("Run: %v", err)
- }
-
- picoWs := filepath.Join(picoClawHome, "workspace")
- if _, err := os.Stat(filepath.Join(picoWs, "SOUL.md")); !os.IsNotExist(err) {
- t.Error("dry run should not create files")
- }
- if _, err := os.Stat(filepath.Join(picoClawHome, "config.json")); !os.IsNotExist(err) {
- t.Error("dry run should not create config")
- }
-
- _ = result
+ handler, err := instance.getCurrentHandler()
+ require.NoError(t, err)
+ require.NotNil(t, handler)
+ assert.Equal(t, "openclaw", handler.GetSourceName())
}
-func TestRunFullMigration(t *testing.T) {
- openclawHome := t.TempDir()
- picoClawHome := t.TempDir()
-
- wsDir := filepath.Join(openclawHome, "workspace")
- os.MkdirAll(wsDir, 0o755)
- os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul from OpenClaw"), 0o644)
- os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0o644)
- os.WriteFile(filepath.Join(wsDir, "USER.md"), []byte("# User from OpenClaw"), 0o644)
-
- memDir := filepath.Join(wsDir, "memory")
- os.MkdirAll(memDir, 0o755)
- os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory notes"), 0o644)
-
- configData := map[string]any{
- "providers": map[string]any{
- "anthropic": map[string]any{
- "apiKey": "sk-ant-migrate-test",
- },
- "openrouter": map[string]any{
- "apiKey": "sk-or-migrate-test",
- },
- },
- "channels": map[string]any{
- "telegram": map[string]any{
- "enabled": true,
- "token": "tg-migrate-test",
- },
- },
- }
- data, _ := json.Marshal(configData)
- os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
-
- opts := Options{
- Force: true,
- OpenClawHome: openclawHome,
- PicoClawHome: picoClawHome,
+func TestMigrateInstanceGetCurrentHandlerNotFound(t *testing.T) {
+ instance := &MigrateInstance{
+ options: Options{},
+ handlers: make(map[string]Operation),
}
- result, err := Run(opts)
- if err != nil {
- t.Fatalf("Run: %v", err)
- }
-
- picoWs := filepath.Join(picoClawHome, "workspace")
-
- soulData, err := os.ReadFile(filepath.Join(picoWs, "SOUL.md"))
- if err != nil {
- t.Fatalf("reading SOUL.md: %v", err)
- }
- if string(soulData) != "# Soul from OpenClaw" {
- t.Errorf("SOUL.md content = %q, want %q", string(soulData), "# Soul from OpenClaw")
- }
-
- agentsData, err := os.ReadFile(filepath.Join(picoWs, "AGENTS.md"))
- if err != nil {
- t.Fatalf("reading AGENTS.md: %v", err)
- }
- if string(agentsData) != "# Agents from OpenClaw" {
- t.Errorf("AGENTS.md content = %q", string(agentsData))
- }
-
- memData, err := os.ReadFile(filepath.Join(picoWs, "memory", "MEMORY.md"))
- if err != nil {
- t.Fatalf("reading memory/MEMORY.md: %v", err)
- }
- if string(memData) != "# Memory notes" {
- t.Errorf("MEMORY.md content = %q", string(memData))
- }
-
- picoConfig, err := config.LoadConfig(filepath.Join(picoClawHome, "config.json"))
- if err != nil {
- t.Fatalf("loading PicoClaw config: %v", err)
- }
- if picoConfig.Providers.Anthropic.APIKey != "sk-ant-migrate-test" {
- t.Errorf("Anthropic.APIKey = %q, want %q", picoConfig.Providers.Anthropic.APIKey, "sk-ant-migrate-test")
- }
- if picoConfig.Providers.OpenRouter.APIKey != "sk-or-migrate-test" {
- t.Errorf("OpenRouter.APIKey = %q, want %q", picoConfig.Providers.OpenRouter.APIKey, "sk-or-migrate-test")
- }
- if !picoConfig.Channels.Telegram.Enabled {
- t.Error("Telegram should be enabled")
- }
- if picoConfig.Channels.Telegram.Token != "tg-migrate-test" {
- t.Errorf("Telegram.Token = %q, want %q", picoConfig.Channels.Telegram.Token, "tg-migrate-test")
- }
-
- if result.FilesCopied < 3 {
- t.Errorf("expected at least 3 files copied, got %d", result.FilesCopied)
- }
- if !result.ConfigMigrated {
- t.Error("config should have been migrated")
- }
- if len(result.Errors) > 0 {
- t.Errorf("expected no errors, got %v", result.Errors)
- }
+ _, err := instance.getCurrentHandler()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "not found")
}
-func TestRunOpenClawNotFound(t *testing.T) {
- opts := Options{
- OpenClawHome: "/nonexistent/path/to/openclaw",
- PicoClawHome: t.TempDir(),
+func TestMigrateInstancePlanWithInvalidSource(t *testing.T) {
+ instance := &MigrateInstance{
+ options: Options{},
+ handlers: make(map[string]Operation),
}
- _, err := Run(opts)
- if err == nil {
- t.Fatal("expected error when OpenClaw not found")
- }
+ _, _, err := instance.Plan(Options{}, "/tmp/source", "/tmp/target")
+ require.Error(t, err)
}
-func TestRunMutuallyExclusiveFlags(t *testing.T) {
- opts := Options{
+func TestMigrateInstancePlanConfigOnlyAndWorkspaceOnlyMutuallyExclusive(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "openclaw.json")
+ err := os.WriteFile(configPath, []byte("{}"), 0o644)
+ require.NoError(t, err)
+
+ instance := NewMigrateInstance(Options{SourceHome: tmpDir})
+ require.NotNil(t, instance)
+
+ _, err = instance.Run(Options{
ConfigOnly: true,
WorkspaceOnly: true,
- }
-
- _, err := Run(opts)
- if err == nil {
- t.Fatal("expected error for mutually exclusive flags")
- }
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "mutually exclusive")
}
-func TestBackupFile(t *testing.T) {
+func TestMigrateInstancePlanRefreshSetsWorkspaceOnly(t *testing.T) {
+ opts := Options{
+ Refresh: true,
+ SourceHome: "/tmp/nonexistent",
+ }
+ instance := NewMigrateInstance(opts)
+ require.NotNil(t, instance)
+
+ _, err := instance.Run(opts)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "not found")
+}
+
+func TestMigrateInstancePlanSourceNotFound(t *testing.T) {
+ opts := Options{
+ SourceHome: "/tmp/nonexistent-source-home",
+ }
+ instance := NewMigrateInstance(opts)
+
+ _, err := instance.Run(opts)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "not found")
+}
+
+func TestMigrateInstanceExecute(t *testing.T) {
tmpDir := t.TempDir()
- filePath := filepath.Join(tmpDir, "test.md")
- os.WriteFile(filePath, []byte("original content"), 0o644)
+ sourceDir := filepath.Join(tmpDir, "source")
+ targetDir := filepath.Join(tmpDir, "target")
+ workspaceDir := filepath.Join(sourceDir, "workspace")
- if err := backupFile(filePath); err != nil {
- t.Fatalf("backupFile: %v", err)
+ err := os.MkdirAll(workspaceDir, 0o755)
+ require.NoError(t, err)
+
+ err = os.WriteFile(filepath.Join(workspaceDir, "test.txt"), []byte("test"), 0o644)
+ require.NoError(t, err)
+
+ instance := &MigrateInstance{
+ options: Options{Source: "mock"},
+ handlers: make(map[string]Operation),
}
+ instance.Register("mock", &mockOperation{sourceHome: sourceDir, sourceWs: workspaceDir})
- bakPath := filePath + ".bak"
- bakData, err := os.ReadFile(bakPath)
- if err != nil {
- t.Fatalf("reading backup: %v", err)
- }
- if string(bakData) != "original content" {
- t.Errorf("backup content = %q, want %q", string(bakData), "original content")
- }
-}
-
-func TestCopyFile(t *testing.T) {
- tmpDir := t.TempDir()
- srcPath := filepath.Join(tmpDir, "src.md")
- dstPath := filepath.Join(tmpDir, "dst.md")
-
- os.WriteFile(srcPath, []byte("file content"), 0o644)
-
- if err := copyFile(srcPath, dstPath); err != nil {
- t.Fatalf("copyFile: %v", err)
- }
-
- data, err := os.ReadFile(dstPath)
- if err != nil {
- t.Fatalf("reading copy: %v", err)
- }
- if string(data) != "file content" {
- t.Errorf("copy content = %q, want %q", string(data), "file content")
- }
-}
-
-func TestRunConfigOnly(t *testing.T) {
- openclawHome := t.TempDir()
- picoClawHome := t.TempDir()
-
- wsDir := filepath.Join(openclawHome, "workspace")
- os.MkdirAll(wsDir, 0o755)
- os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644)
-
- configData := map[string]any{
- "providers": map[string]any{
- "anthropic": map[string]any{
- "apiKey": "sk-config-only",
- },
+ actions := []Action{
+ {
+ Type: ActionCopy,
+ Source: filepath.Join(workspaceDir, "test.txt"),
+ Target: filepath.Join(targetDir, "workspace", "test.txt"),
+ Description: "copy file",
},
}
- data, _ := json.Marshal(configData)
- os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
- opts := Options{
- Force: true,
- ConfigOnly: true,
- OpenClawHome: openclawHome,
- PicoClawHome: picoClawHome,
- }
+ result := instance.Execute(actions, workspaceDir, targetDir)
+ require.NotNil(t, result)
+ assert.Equal(t, 1, result.FilesCopied)
- result, err := Run(opts)
- if err != nil {
- t.Fatalf("Run: %v", err)
- }
-
- if !result.ConfigMigrated {
- t.Error("config should have been migrated")
- }
-
- picoWs := filepath.Join(picoClawHome, "workspace")
- if _, err := os.Stat(filepath.Join(picoWs, "SOUL.md")); !os.IsNotExist(err) {
- t.Error("config-only should not copy workspace files")
- }
+ _, err = os.Stat(filepath.Join(targetDir, "workspace", "test.txt"))
+ assert.NoError(t, err)
}
-func TestRunWorkspaceOnly(t *testing.T) {
- openclawHome := t.TempDir()
- picoClawHome := t.TempDir()
+func TestMigrateInstanceExecuteWithInvalidSource(t *testing.T) {
+ tmpDir := t.TempDir()
+ sourceDir := filepath.Join(tmpDir, "source")
+ err := os.MkdirAll(sourceDir, 0o755)
+ require.NoError(t, err)
- wsDir := filepath.Join(openclawHome, "workspace")
- os.MkdirAll(wsDir, 0o755)
- os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644)
+ instance := &MigrateInstance{
+ options: Options{Source: "mock"},
+ handlers: make(map[string]Operation),
+ }
+ instance.Register("mock", &mockOperation{sourceHome: sourceDir})
- configData := map[string]any{
- "providers": map[string]any{
- "anthropic": map[string]any{
- "apiKey": "sk-ws-only",
- },
+ actions := []Action{
+ {
+ Type: ActionCopy,
+ Source: filepath.Join(sourceDir, "nonexistent.txt"),
+ Target: filepath.Join(tmpDir, "target.txt"),
+ Description: "copy file",
},
}
- data, _ := json.Marshal(configData)
- os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644)
- opts := Options{
- Force: true,
- WorkspaceOnly: true,
- OpenClawHome: openclawHome,
- PicoClawHome: picoClawHome,
- }
-
- result, err := Run(opts)
- if err != nil {
- t.Fatalf("Run: %v", err)
- }
-
- if result.ConfigMigrated {
- t.Error("workspace-only should not migrate config")
- }
-
- picoWs := filepath.Join(picoClawHome, "workspace")
- soulData, err := os.ReadFile(filepath.Join(picoWs, "SOUL.md"))
- if err != nil {
- t.Fatalf("reading SOUL.md: %v", err)
- }
- if string(soulData) != "# Soul" {
- t.Errorf("SOUL.md content = %q", string(soulData))
- }
+ result := instance.Execute(actions, sourceDir, tmpDir)
+ require.NotNil(t, result)
+ assert.Equal(t, 0, result.FilesCopied)
+ assert.Greater(t, len(result.Errors), 0)
+}
+
+func TestMigrateInstanceExecuteCreateDir(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ instance := &MigrateInstance{
+ options: Options{Source: "mock"},
+ handlers: make(map[string]Operation),
+ }
+ instance.Register("mock", &mockOperation{})
+
+ actions := []Action{
+ {
+ Type: ActionCreateDir,
+ Target: filepath.Join(tmpDir, "new", "dir"),
+ Description: "create directory",
+ },
+ }
+
+ result := instance.Execute(actions, "", "")
+ require.NotNil(t, result)
+ assert.Equal(t, 1, result.DirsCreated)
+
+ _, err := os.Stat(filepath.Join(tmpDir, "new", "dir"))
+ assert.NoError(t, err)
+}
+
+func TestMigrateInstanceExecuteBackup(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ sourceFile := filepath.Join(tmpDir, "source.txt")
+ targetFile := filepath.Join(tmpDir, "target.txt")
+
+ err := os.WriteFile(sourceFile, []byte("source"), 0o644)
+ require.NoError(t, err)
+
+ err = os.WriteFile(targetFile, []byte("target"), 0o644)
+ require.NoError(t, err)
+
+ instance := &MigrateInstance{
+ options: Options{Source: "mock"},
+ handlers: make(map[string]Operation),
+ }
+ instance.Register("mock", &mockOperation{})
+
+ actions := []Action{
+ {
+ Type: ActionBackup,
+ Source: sourceFile,
+ Target: targetFile,
+ Description: "backup and overwrite",
+ },
+ }
+
+ result := instance.Execute(actions, tmpDir, tmpDir)
+ require.NotNil(t, result)
+ assert.Equal(t, 1, result.BackupsCreated)
+ assert.Equal(t, 1, result.FilesCopied)
+
+ bakFile := targetFile + ".bak"
+ _, err = os.Stat(bakFile)
+ assert.NoError(t, err)
+
+ content, err := os.ReadFile(targetFile)
+ assert.NoError(t, err)
+ assert.Equal(t, "source", string(content))
+}
+
+func TestMigrateInstanceExecuteSkip(t *testing.T) {
+ instance := &MigrateInstance{
+ options: Options{Source: "mock"},
+ handlers: make(map[string]Operation),
+ }
+ instance.Register("mock", &mockOperation{})
+
+ actions := []Action{
+ {
+ Type: ActionSkip,
+ Source: "/tmp/source.txt",
+ Target: "/tmp/target.txt",
+ Description: "skip file",
+ },
+ }
+
+ result := instance.Execute(actions, "", "")
+ require.NotNil(t, result)
+ assert.Equal(t, 1, result.FilesSkipped)
+}
+
+func TestMigrateInstancePrintSummary(t *testing.T) {
+ instance := NewMigrateInstance(Options{})
+
+ result := &Result{
+ FilesCopied: 5,
+ ConfigMigrated: true,
+ BackupsCreated: 2,
+ FilesSkipped: 3,
+ Warnings: []string{"warning 1"},
+ Errors: []error{},
+ }
+
+ instance.PrintSummary(result)
+}
+
+func TestMigrateInstancePrintSummaryWithErrors(t *testing.T) {
+ instance := NewMigrateInstance(Options{})
+
+ result := &Result{
+ FilesCopied: 0,
+ ConfigMigrated: false,
+ BackupsCreated: 0,
+ FilesSkipped: 0,
+ Warnings: []string{},
+ Errors: []error{assert.AnError},
+ }
+
+ instance.PrintSummary(result)
+}
+
+func TestMigrateInstancePrintSummaryNoActions(t *testing.T) {
+ instance := NewMigrateInstance(Options{})
+
+ result := &Result{
+ FilesCopied: 0,
+ ConfigMigrated: false,
+ BackupsCreated: 0,
+ FilesSkipped: 0,
+ Warnings: []string{},
+ Errors: []error{},
+ }
+
+ instance.PrintSummary(result)
+}
+
+func TestPrintPlan(t *testing.T) {
+ actions := []Action{
+ {
+ Type: ActionConvertConfig,
+ Source: "/source/config.json",
+ Target: "/target/config.json",
+ Description: "convert config",
+ },
+ {
+ Type: ActionCopy,
+ Source: "/source/file.txt",
+ Target: "/target/file.txt",
+ Description: "copy file",
+ },
+ {
+ Type: ActionBackup,
+ Source: "/source/existing.txt",
+ Target: "/target/existing.txt",
+ Description: "backup and overwrite",
+ },
+ {
+ Type: ActionSkip,
+ Source: "/source/skipped.txt",
+ Target: "/target/skipped.txt",
+ Description: "skip file",
+ },
+ {
+ Type: ActionCreateDir,
+ Target: "/target/newdir",
+ Description: "create directory",
+ },
+ }
+
+ warnings := []string{
+ "Warning: source directory not found",
+ }
+
+ PrintPlan(actions, warnings)
+}
+
+func TestPrintPlanEmpty(t *testing.T) {
+ PrintPlan([]Action{}, []string{})
+}
+
+type mockOperation struct {
+ sourceHome string
+ sourceConfig string
+ sourceWs string
+ migrateFiles []string
+ migrateDirs []string
+}
+
+func (m *mockOperation) GetSourceName() string { return "mock" }
+func (m *mockOperation) GetSourceHome() (string, error) {
+ if m.sourceHome != "" {
+ return m.sourceHome, nil
+ }
+ return "/tmp/mock", nil
+}
+
+func (m *mockOperation) GetSourceWorkspace() (string, error) {
+ if m.sourceWs != "" {
+ return m.sourceWs, nil
+ }
+ if m.sourceHome != "" {
+ return filepath.Join(m.sourceHome, "workspace"), nil
+ }
+ return "/tmp/mock/workspace", nil
+}
+
+func (m *mockOperation) GetSourceConfigFile() (string, error) {
+ if m.sourceConfig != "" {
+ return m.sourceConfig, nil
+ }
+ return "/tmp/mock/config.json", nil
+}
+func (m *mockOperation) ExecuteConfigMigration(src, dst string) error { return nil }
+func (m *mockOperation) GetMigrateableFiles() []string {
+ if m.migrateFiles != nil {
+ return m.migrateFiles
+ }
+ return []string{}
+}
+
+func (m *mockOperation) GetMigrateableDirs() []string {
+ if m.migrateDirs != nil {
+ return m.migrateDirs
+ }
+ return []string{}
}
diff --git a/pkg/migrate/sources/openclaw/common.go b/pkg/migrate/sources/openclaw/common.go
new file mode 100644
index 000000000..dddd98089
--- /dev/null
+++ b/pkg/migrate/sources/openclaw/common.go
@@ -0,0 +1,29 @@
+package openclaw
+
+var migrateableFiles = []string{
+ "AGENTS.md",
+ "SOUL.md",
+ "USER.md",
+ "TOOLS.md",
+ "HEARTBEAT.md",
+}
+
+var migrateableDirs = []string{
+ "memory",
+ "skills",
+}
+
+var supportedChannels = map[string]bool{
+ "whatsapp": true,
+ "telegram": true,
+ "feishu": true,
+ "discord": true,
+ "maixcam": true,
+ "qq": true,
+ "dingtalk": true,
+ "slack": true,
+ "line": true,
+ "onebot": true,
+ "wecom": true,
+ "wecom_app": true,
+}
diff --git a/pkg/migrate/sources/openclaw/openclaw_config.go b/pkg/migrate/sources/openclaw/openclaw_config.go
new file mode 100644
index 000000000..39ad48fad
--- /dev/null
+++ b/pkg/migrate/sources/openclaw/openclaw_config.go
@@ -0,0 +1,1074 @@
+package openclaw
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+type OpenClawConfig struct {
+ Auth *OpenClawAuth `json:"auth"`
+ Models *OpenClawModels `json:"models"`
+ Agents *OpenClawAgents `json:"agents"`
+ Tools *OpenClawTools `json:"tools"`
+ Channels *OpenClawChannels `json:"channels"`
+ Cron json.RawMessage `json:"cron"`
+ Hooks json.RawMessage `json:"hooks"`
+ Skills *OpenClawSkills `json:"skills"`
+ Memory json.RawMessage `json:"memory"`
+ Session json.RawMessage `json:"session"`
+}
+
+type OpenClawAuth struct {
+ Profiles json.RawMessage `json:"profiles"`
+ Order json.RawMessage `json:"order"`
+}
+
+type OpenClawModels struct {
+ Providers map[string]json.RawMessage `json:"providers"`
+}
+
+type ProviderConfig struct {
+ BaseUrl string `json:"baseUrl"`
+ Api string `json:"api"`
+ Models []ModelConfig `json:"models"`
+ ApiKey string `json:"apiKey"`
+}
+
+type OpenClawModelConfig struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Reasoning bool `json:"reasoning"`
+ Input []string `json:"input"`
+ Cost Cost `json:"cost"`
+ ContextWindow int `json:"contextWindow"`
+ MaxTokens int `json:"maxTokens"`
+ Api string `json:"api,omitempty"`
+}
+
+type Cost struct {
+ Input float64 `json:"input"`
+ Output float64 `json:"output"`
+ CacheRead float64 `json:"cacheRead"`
+ CacheWrite float64 `json:"cacheWrite"`
+}
+
+type OpenClawTools struct {
+ Profile *string `json:"profile"`
+ Allow []string `json:"allow"`
+ Deny []string `json:"deny"`
+}
+
+type OpenClawAgents struct {
+ Defaults *OpenClawAgentDefaults `json:"defaults"`
+ List []OpenClawAgentEntry `json:"list"`
+}
+
+type OpenClawAgentDefaults struct {
+ Model *OpenClawAgentModel `json:"model"`
+ Workspace *string `json:"workspace"`
+ Tools *OpenClawAgentTools `json:"tools"`
+ Identity *string `json:"identity"`
+}
+
+type OpenClawAgentModel struct {
+ Simple string `json:"-"`
+ Primary *string `json:"primary"`
+ Fallbacks []string `json:"fallbacks"`
+}
+
+func (m *OpenClawAgentModel) GetPrimary() string {
+ if m.Simple != "" {
+ return m.Simple
+ }
+ if m.Primary != nil {
+ return *m.Primary
+ }
+ return ""
+}
+
+func (m *OpenClawAgentModel) GetFallbacks() []string {
+ return m.Fallbacks
+}
+
+type OpenClawAgentEntry struct {
+ ID string `json:"id"`
+ Name *string `json:"name"`
+ Model *OpenClawAgentModel `json:"model"`
+ Tools *OpenClawAgentTools `json:"tools"`
+ Workspace *string `json:"workspace"`
+ Skills []string `json:"skills"`
+ Identity *string `json:"identity"`
+}
+
+type OpenClawAgentTools struct {
+ Profile *string `json:"profile"`
+ Allow []string `json:"allow"`
+ Deny []string `json:"deny"`
+ AlsoAllow []string `json:"alsoAllow"`
+}
+
+type OpenClawChannels struct {
+ Telegram *OpenClawTelegramConfig `json:"telegram"`
+ Discord *OpenClawDiscordConfig `json:"discord"`
+ Slack *OpenClawSlackConfig `json:"slack"`
+ WhatsApp *OpenClawWhatsAppConfig `json:"whatsapp"`
+ Signal *OpenClawSignalConfig `json:"signal"`
+ Matrix *OpenClawMatrixConfig `json:"matrix"`
+ GoogleChat *OpenClawGoogleChatConfig `json:"googlechat"`
+ Teams *OpenClawTeamsConfig `json:"msteams"`
+ IRC *OpenClawIrcConfig `json:"irc"`
+ Mattermost *OpenClawMattermostConfig `json:"mattermost"`
+ Feishu *OpenClawFeishuConfig `json:"feishu"`
+ IMessage *OpenClawIMessageConfig `json:"imessage"`
+ BlueBubbles *OpenClawBlueBubblesConfig `json:"bluebubbles"`
+ QQ *OpenClawQQConfig `json:"qq"`
+ DingTalk *OpenClawDingTalkConfig `json:"dingtalk"`
+ MaixCam *OpenClawMaixCamConfig `json:"maixcam"`
+}
+
+type OpenClawTelegramConfig struct {
+ BotToken *string `json:"botToken"`
+ AllowFrom []string `json:"allowFrom"`
+ GroupPolicy *string `json:"groupPolicy"`
+ DmPolicy *string `json:"dmPolicy"`
+ Enabled *bool `json:"enabled"`
+}
+
+type OpenClawDiscordConfig struct {
+ Token *string `json:"token"`
+ Guilds json.RawMessage `json:"guilds"`
+ DmPolicy *string `json:"dmPolicy"`
+ GroupPolicy *string `json:"groupPolicy"`
+ AllowFrom []string `json:"allowFrom"`
+ Enabled *bool `json:"enabled"`
+}
+
+type OpenClawSlackConfig struct {
+ BotToken *string `json:"botToken"`
+ AppToken *string `json:"appToken"`
+ DmPolicy *string `json:"dmPolicy"`
+ GroupPolicy *string `json:"groupPolicy"`
+ AllowFrom []string `json:"allowFrom"`
+ Enabled *bool `json:"enabled"`
+}
+
+type OpenClawWhatsAppConfig struct {
+ AuthDir *string `json:"authDir"`
+ DmPolicy *string `json:"dmPolicy"`
+ AllowFrom []string `json:"allowFrom"`
+ GroupPolicy *string `json:"groupPolicy"`
+ Enabled *bool `json:"enabled"`
+ BridgeURL *string `json:"bridgeUrl"`
+}
+
+type OpenClawSignalConfig struct {
+ HttpUrl *string `json:"httpUrl"`
+ HttpHost *string `json:"httpHost"`
+ HttpPort *int `json:"httpPort"`
+ Account *string `json:"account"`
+ DmPolicy *string `json:"dmPolicy"`
+ AllowFrom []string `json:"allowFrom"`
+ Enabled *bool `json:"enabled"`
+}
+
+type OpenClawMatrixConfig struct {
+ Homeserver *string `json:"homeserver"`
+ UserID *string `json:"userId"`
+ AccessToken *string `json:"accessToken"`
+ Rooms []string `json:"rooms"`
+ DmPolicy *string `json:"dmPolicy"`
+ AllowFrom []string `json:"allowFrom"`
+ Enabled *bool `json:"enabled"`
+}
+
+type OpenClawGoogleChatConfig struct {
+ ServiceAccountFile *string `json:"serviceAccountFile"`
+ WebhookPath *string `json:"webhookPath"`
+ BotUser *string `json:"botUser"`
+ DmPolicy *string `json:"dmPolicy"`
+ Enabled *bool `json:"enabled"`
+}
+
+type OpenClawTeamsConfig struct {
+ AppID *string `json:"appId"`
+ AppPassword *string `json:"appPassword"`
+ TenantID *string `json:"tenantId"`
+ DmPolicy *string `json:"dmPolicy"`
+ AllowFrom []string `json:"allowFrom"`
+ Enabled *bool `json:"enabled"`
+}
+
+type OpenClawIrcConfig struct {
+ Host *string `json:"host"`
+ Port *int `json:"port"`
+ TLS *bool `json:"tls"`
+ Nick *string `json:"nick"`
+ Password *string `json:"password"`
+ Channels []string `json:"channels"`
+ DmPolicy *string `json:"dmPolicy"`
+ AllowFrom []string `json:"allowFrom"`
+ Enabled *bool `json:"enabled"`
+}
+
+type OpenClawMattermostConfig struct {
+ BotToken *string `json:"botToken"`
+ BaseURL *string `json:"baseUrl"`
+ DmPolicy *string `json:"dmPolicy"`
+ AllowFrom []string `json:"allowFrom"`
+ Enabled *bool `json:"enabled"`
+}
+
+type OpenClawFeishuConfig struct {
+ AppID *string `json:"appId"`
+ AppSecret *string `json:"appSecret"`
+ Domain *string `json:"domain"`
+ DmPolicy *string `json:"dmPolicy"`
+ Enabled *bool `json:"enabled"`
+ VerificationToken *string `json:"verificationToken"`
+ EncryptKey *string `json:"encryptKey"`
+ AllowFrom []string `json:"allowFrom"`
+}
+
+type OpenClawIMessageConfig struct {
+ CliPath *string `json:"cliPath"`
+ DbPath *string `json:"dbPath"`
+ DmPolicy *string `json:"dmPolicy"`
+ AllowFrom []string `json:"allowFrom"`
+ Enabled *bool `json:"enabled"`
+}
+
+type OpenClawBlueBubblesConfig struct {
+ ServerURL *string `json:"serverUrl"`
+ Password *string `json:"password"`
+ DmPolicy *string `json:"dmPolicy"`
+ AllowFrom []string `json:"allowFrom"`
+ Enabled *bool `json:"enabled"`
+}
+
+type OpenClawQQConfig struct {
+ AppID *string `json:"appId"`
+ AppSecret *string `json:"appSecret"`
+ DmPolicy *string `json:"dmPolicy"`
+ AllowFrom []string `json:"allowFrom"`
+ Enabled *bool `json:"enabled"`
+}
+
+type OpenClawDingTalkConfig struct {
+ AppID *string `json:"appId"`
+ AppSecret *string `json:"appSecret"`
+ DmPolicy *string `json:"dmPolicy"`
+ AllowFrom []string `json:"allowFrom"`
+ Enabled *bool `json:"enabled"`
+}
+
+type OpenClawMaixCamConfig struct {
+ Host *string `json:"host"`
+ Port *int `json:"port"`
+ DmPolicy *string `json:"dmPolicy"`
+ AllowFrom []string `json:"allowFrom"`
+ Enabled *bool `json:"enabled"`
+}
+
+type OpenClawSkills struct {
+ Entries map[string]json.RawMessage `json:"entries"`
+ Load json.RawMessage `json:"load"`
+}
+
+type OpenClawProviderConfig struct {
+ APIKey string `json:"api_key"`
+ BaseURL string `json:"base_url"`
+}
+
+func (c *OpenClawConfig) GetEnabled() bool {
+ return true
+}
+
+func LoadOpenClawConfig(path string) (*OpenClawConfig, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read config: %w", err)
+ }
+
+ var config OpenClawConfig
+ if err := json.Unmarshal(data, &config); err != nil {
+ return nil, fmt.Errorf("failed to parse JSON: %w", err)
+ }
+
+ return &config, nil
+}
+
+func LoadOpenClawConfigFromDir(dir string) (*OpenClawConfig, error) {
+ candidates := []string{
+ filepath.Join(dir, "openclaw.json"),
+ filepath.Join(dir, "config.json"),
+ }
+
+ for _, p := range candidates {
+ if _, err := os.Stat(p); err == nil {
+ return LoadOpenClawConfig(p)
+ }
+ }
+
+ return nil, fmt.Errorf("no config file found in %s", dir)
+}
+
+func GetProviderConfig(models *OpenClawModels) map[string]OpenClawProviderConfig {
+ result := make(map[string]OpenClawProviderConfig)
+ if models == nil || models.Providers == nil {
+ return result
+ }
+
+ for name, raw := range models.Providers {
+ var prov OpenClawProviderConfig
+ if err := json.Unmarshal(raw, &prov); err != nil {
+ continue
+ }
+ mappedName := mapProvider(name)
+ result[mappedName] = prov
+ }
+
+ return result
+}
+
+func GetProviderConfigFromDir(dir string) map[string]ProviderConfig {
+ result := make(map[string]ProviderConfig)
+ p := filepath.Join(dir, "agents", "main", "agent", "models.json")
+
+ if _, err := os.Stat(p); err != nil {
+ return result
+ }
+
+ data, err := os.ReadFile(p)
+ if err != nil {
+ return result
+ }
+ var models OpenClawModels
+ if err := json.Unmarshal(data, &models); err != nil {
+ return result
+ }
+
+ for name, raw := range models.Providers {
+ var prov ProviderConfig
+ if err := json.Unmarshal(raw, &prov); err != nil {
+ continue
+ }
+ mappedName := mapProvider(name)
+ result[mappedName] = prov
+ }
+ return result
+}
+
+func (c *OpenClawConfig) IsChannelEnabled(name string) bool {
+ switch name {
+ case "telegram":
+ return c.Channels.Telegram == nil || c.Channels.Telegram.Enabled == nil || *c.Channels.Telegram.Enabled
+ case "discord":
+ return c.Channels.Discord == nil || c.Channels.Discord.Enabled == nil || *c.Channels.Discord.Enabled
+ case "slack":
+ return c.Channels.Slack == nil || c.Channels.Slack.Enabled == nil || *c.Channels.Slack.Enabled
+ case "whatsapp":
+ return c.Channels.WhatsApp == nil || c.Channels.WhatsApp.Enabled == nil || *c.Channels.WhatsApp.Enabled
+ case "feishu":
+ return c.Channels.Feishu == nil || c.Channels.Feishu.Enabled == nil || *c.Channels.Feishu.Enabled
+ default:
+ return false
+ }
+}
+
+func GetChannelAllowFrom(ch any) []string {
+ switch c := ch.(type) {
+ case *OpenClawTelegramConfig:
+ if c == nil {
+ return nil
+ }
+ return c.AllowFrom
+ case *OpenClawDiscordConfig:
+ if c == nil {
+ return nil
+ }
+ return c.AllowFrom
+ case *OpenClawSlackConfig:
+ if c == nil {
+ return nil
+ }
+ return c.AllowFrom
+ case *OpenClawWhatsAppConfig:
+ if c == nil {
+ return nil
+ }
+ return c.AllowFrom
+ case *OpenClawFeishuConfig:
+ if c == nil {
+ return nil
+ }
+ return c.AllowFrom
+ default:
+ return nil
+ }
+}
+
+func (c *OpenClawConfig) GetDefaultModel() (provider, model string) {
+ if c.Agents == nil || c.Agents.Defaults == nil || c.Agents.Defaults.Model == nil {
+ return "anthropic", "claude-sonnet-4-20250514"
+ }
+
+ primary := c.Agents.Defaults.Model.GetPrimary()
+ if primary == "" {
+ return "anthropic", "claude-sonnet-4-20250514"
+ }
+
+ parts := strings.Split(primary, "/")
+ if len(parts) > 1 {
+ return mapProvider(parts[0]), parts[1]
+ }
+
+ return "anthropic", primary
+}
+
+func (c *OpenClawConfig) GetDefaultWorkspace() string {
+ if c.Agents == nil || c.Agents.Defaults == nil || c.Agents.Defaults.Workspace == nil {
+ return ""
+ }
+ return rewriteWorkspacePath(*c.Agents.Defaults.Workspace)
+}
+
+func (c *OpenClawConfig) GetAgents() []OpenClawAgentEntry {
+ if c.Agents == nil {
+ return nil
+ }
+ return c.Agents.List
+}
+
+func (c *OpenClawConfig) HasSkills() bool {
+ return c.Skills != nil && c.Skills.Entries != nil && len(c.Skills.Entries) > 0
+}
+
+func (c *OpenClawConfig) HasMemory() bool {
+ return c.Memory != nil && len(c.Memory) > 0
+}
+
+func (c *OpenClawConfig) HasCron() bool {
+ return c.Cron != nil && len(c.Cron) > 0
+}
+
+func (c *OpenClawConfig) HasHooks() bool {
+ return c.Hooks != nil && len(c.Hooks) > 0
+}
+
+func (c *OpenClawConfig) HasSession() bool {
+ return c.Session != nil && len(c.Session) > 0
+}
+
+func (c *OpenClawConfig) HasAuthProfiles() bool {
+ return c.Auth != nil && c.Auth.Profiles != nil && len(c.Auth.Profiles) > 0
+}
+
+func (c *OpenClawConfig) ConvertToPicoClaw(sourceHome string) (*PicoClawConfig, []string, error) {
+ cfg := &PicoClawConfig{}
+ var warnings []string
+
+ provider, modelName := c.GetDefaultModel()
+ cfg.Agents.Defaults.Workspace = c.GetDefaultWorkspace()
+ cfg.Agents.Defaults.ModelName = modelName
+
+ providerConfigs := GetProviderConfigFromDir(sourceHome)
+ defaultAPIKey := ""
+ defaultBaseURL := ""
+
+ if provCfg, ok := providerConfigs[provider]; ok {
+ defaultAPIKey = provCfg.ApiKey
+ defaultBaseURL = provCfg.BaseUrl
+ }
+
+ cfg.ModelList = []ModelConfig{
+ {
+ ModelName: modelName,
+ Model: fmt.Sprintf("%s/%s", provider, modelName),
+ APIKey: defaultAPIKey,
+ APIBase: defaultBaseURL,
+ },
+ }
+
+ for provName, provCfg := range providerConfigs {
+ if provName == provider {
+ continue
+ }
+ if provCfg.ApiKey != "" {
+ continue
+ }
+ cfg.ModelList = append(cfg.ModelList, ModelConfig{
+ ModelName: fmt.Sprintf("%s", provName),
+ Model: fmt.Sprintf("%s/%s", provName, provName),
+ APIKey: provCfg.ApiKey,
+ APIBase: provCfg.BaseUrl,
+ })
+ }
+
+ cfg.Channels = c.convertChannels(&warnings)
+
+ agentList := c.convertAgents(&warnings)
+ if len(agentList) > 0 {
+ cfg.Agents.List = agentList
+ }
+
+ if c.HasSkills() {
+ warnings = append(
+ warnings,
+ fmt.Sprintf(
+ "Skills (%d entries) not automatically migrated - reinstall via picoclaw CLI",
+ len(c.Skills.Entries),
+ ),
+ )
+ }
+ if c.HasMemory() {
+ warnings = append(warnings, "Memory backend config not migrated - PicoClaw uses SQLite with vector embeddings")
+ }
+ if c.HasCron() {
+ warnings = append(
+ warnings,
+ "Cron job scheduling not supported in PicoClaw - consider using external schedulers",
+ )
+ }
+ if c.HasHooks() {
+ warnings = append(warnings, "Webhook hooks not supported in PicoClaw - use event system instead")
+ }
+ if c.HasSession() {
+ warnings = append(warnings, "Session scope config differs - PicoClaw uses per-agent sessions by default")
+ }
+ if c.HasAuthProfiles() {
+ warnings = append(
+ warnings,
+ "Auth profiles (API keys, OAuth tokens) not migrated for security - set env vars manually",
+ )
+ }
+
+ return cfg, warnings, nil
+}
+
+type ModelConfig struct {
+ ModelName string `json:"model_name"`
+ Model string `json:"model"`
+ APIBase string `json:"api_base,omitempty"`
+ APIKey string `json:"api_key"`
+ Proxy string `json:"proxy,omitempty"`
+}
+
+type PicoClawConfig struct {
+ Agents AgentsConfig `json:"agents"`
+ Bindings []AgentBinding `json:"bindings,omitempty"`
+ Channels ChannelsConfig `json:"channels"`
+ ModelList []ModelConfig `json:"model_list"`
+ Gateway GatewayConfig `json:"gateway"`
+ Tools ToolsConfig `json:"tools"`
+}
+
+type AgentsConfig struct {
+ Defaults AgentDefaults `json:"defaults"`
+ List []AgentConfig `json:"list,omitempty"`
+}
+
+type AgentDefaults struct {
+ Workspace string `json:"workspace"`
+ RestrictToWorkspace bool `json:"restrict_to_workspace"`
+ Provider string `json:"provider"`
+ ModelName string `json:"model_name"`
+ Model string `json:"model,omitempty"`
+ ModelFallbacks []string `json:"model_fallbacks,omitempty"`
+ ImageModel string `json:"image_model,omitempty"`
+ ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
+ MaxTokens int `json:"max_tokens"`
+ Temperature *float64 `json:"temperature,omitempty"`
+ MaxToolIterations int `json:"max_tool_iterations"`
+}
+
+type AgentConfig struct {
+ ID string `json:"id"`
+ Default bool `json:"default,omitempty"`
+ Name string `json:"name,omitempty"`
+ Workspace string `json:"workspace,omitempty"`
+ Model *AgentModelConfig `json:"model,omitempty"`
+ Skills []string `json:"skills,omitempty"`
+}
+
+type AgentModelConfig struct {
+ Primary string `json:"primary,omitempty"`
+ Fallbacks []string `json:"fallbacks,omitempty"`
+}
+
+type AgentBinding struct {
+ AgentID string `json:"agent_id"`
+ Match BindingMatch `json:"match"`
+}
+
+type BindingMatch struct {
+ Channel string `json:"channel"`
+ AccountID string `json:"account_id,omitempty"`
+ Peer *PeerMatch `json:"peer,omitempty"`
+ GuildID string `json:"guild_id,omitempty"`
+ TeamID string `json:"team_id,omitempty"`
+}
+
+type PeerMatch struct {
+ Kind string `json:"kind"`
+ ID string `json:"id"`
+}
+
+type ChannelsConfig struct {
+ WhatsApp WhatsAppConfig `json:"whatsapp"`
+ Telegram TelegramConfig `json:"telegram"`
+ Feishu FeishuConfig `json:"feishu"`
+ Discord DiscordConfig `json:"discord"`
+ MaixCam MaixCamConfig `json:"maixcam"`
+ QQ QQConfig `json:"qq"`
+ DingTalk DingTalkConfig `json:"dingtalk"`
+ Slack SlackConfig `json:"slack"`
+ LINE LINEConfig `json:"line"`
+}
+
+type WhatsAppConfig struct {
+ Enabled bool `json:"enabled"`
+ BridgeURL string `json:"bridge_url"`
+ AllowFrom []string `json:"allow_from"`
+}
+
+type TelegramConfig struct {
+ Enabled bool `json:"enabled"`
+ Token string `json:"token"`
+ Proxy string `json:"proxy"`
+ AllowFrom []string `json:"allow_from"`
+}
+
+type FeishuConfig struct {
+ Enabled bool `json:"enabled"`
+ AppID string `json:"app_id"`
+ AppSecret string `json:"app_secret"`
+ EncryptKey string `json:"encrypt_key"`
+ VerificationToken string `json:"verification_token"`
+ AllowFrom []string `json:"allow_from"`
+}
+
+type DiscordConfig struct {
+ Enabled bool `json:"enabled"`
+ Token string `json:"token"`
+ MentionOnly bool `json:"mention_only"`
+ AllowFrom []string `json:"allow_from"`
+}
+
+type MaixCamConfig struct {
+ Enabled bool `json:"enabled"`
+ Host string `json:"host"`
+ Port int `json:"port"`
+ AllowFrom []string `json:"allow_from"`
+}
+
+type QQConfig struct {
+ Enabled bool `json:"enabled"`
+ AppID string `json:"app_id"`
+ AppSecret string `json:"app_secret"`
+ AllowFrom []string `json:"allow_from"`
+}
+
+type DingTalkConfig struct {
+ Enabled bool `json:"enabled"`
+ ClientID string `json:"client_id"`
+ ClientSecret string `json:"client_secret"`
+ AllowFrom []string `json:"allow_from"`
+}
+
+type SlackConfig struct {
+ Enabled bool `json:"enabled"`
+ BotToken string `json:"bot_token"`
+ AppToken string `json:"app_token"`
+ AllowFrom []string `json:"allow_from"`
+}
+
+type LINEConfig struct {
+ Enabled bool `json:"enabled"`
+ ChannelSecret string `json:"channel_secret"`
+ ChannelAccessToken string `json:"channel_access_token"`
+ WebhookHost string `json:"webhook_host"`
+ WebhookPort int `json:"webhook_port"`
+ WebhookPath string `json:"webhook_path"`
+ AllowFrom []string `json:"allow_from"`
+}
+
+type GatewayConfig struct {
+ Host string `json:"host"`
+ Port int `json:"port"`
+}
+
+type ToolsConfig struct {
+ Web WebToolsConfig `json:"web"`
+ Cron CronConfig `json:"cron"`
+ Exec ExecConfig `json:"exec"`
+}
+
+type WebToolsConfig struct {
+ Brave BraveConfig `json:"brave"`
+ Tavily TavilyConfig `json:"tavily"`
+ DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
+ Perplexity PerplexityConfig `json:"perplexity"`
+ Proxy string `json:"proxy,omitempty"`
+}
+
+type BraveConfig struct {
+ Enabled bool `json:"enabled"`
+ APIKey string `json:"api_key"`
+ MaxResults int `json:"max_results"`
+}
+
+type TavilyConfig struct {
+ Enabled bool `json:"enabled"`
+ APIKey string `json:"api_key"`
+ BaseURL string `json:"base_url"`
+ MaxResults int `json:"max_results"`
+}
+
+type DuckDuckGoConfig struct {
+ Enabled bool `json:"enabled"`
+ MaxResults int `json:"max_results"`
+}
+
+type PerplexityConfig struct {
+ Enabled bool `json:"enabled"`
+ APIKey string `json:"api_key"`
+ MaxResults int `json:"max_results"`
+}
+
+type CronConfig struct {
+ ExecTimeoutMinutes int `json:"exec_timeout_minutes"`
+}
+
+type ExecConfig struct {
+ EnableDenyPatterns bool `json:"enable_deny_patterns"`
+ CustomDenyPatterns []string `json:"custom_deny_patterns"`
+}
+
+func (c *OpenClawConfig) convertChannels(warnings *[]string) ChannelsConfig {
+ channels := ChannelsConfig{}
+
+ if c.Channels == nil {
+ return channels
+ }
+
+ if c.Channels.Telegram != nil {
+ enabled := c.Channels.Telegram.Enabled == nil || *c.Channels.Telegram.Enabled
+ channels.Telegram = TelegramConfig{
+ Enabled: enabled,
+ AllowFrom: c.Channels.Telegram.AllowFrom,
+ }
+ if c.Channels.Telegram.BotToken != nil {
+ channels.Telegram.Token = *c.Channels.Telegram.BotToken
+ }
+ }
+
+ if c.Channels.Discord != nil {
+ enabled := c.Channels.Discord.Enabled == nil || *c.Channels.Discord.Enabled
+ channels.Discord = DiscordConfig{
+ Enabled: enabled,
+ AllowFrom: c.Channels.Discord.AllowFrom,
+ }
+ if c.Channels.Discord.Token != nil {
+ channels.Discord.Token = *c.Channels.Discord.Token
+ }
+ }
+
+ if c.Channels.Slack != nil {
+ enabled := c.Channels.Slack.Enabled == nil || *c.Channels.Slack.Enabled
+ channels.Slack = SlackConfig{
+ Enabled: enabled,
+ AllowFrom: c.Channels.Slack.AllowFrom,
+ }
+ if c.Channels.Slack.BotToken != nil {
+ channels.Slack.BotToken = *c.Channels.Slack.BotToken
+ }
+ if c.Channels.Slack.AppToken != nil {
+ channels.Slack.AppToken = *c.Channels.Slack.AppToken
+ }
+ }
+
+ if c.Channels.WhatsApp != nil {
+ enabled := c.Channels.WhatsApp.Enabled == nil || *c.Channels.WhatsApp.Enabled
+ channels.WhatsApp = WhatsAppConfig{
+ Enabled: enabled,
+ AllowFrom: c.Channels.WhatsApp.AllowFrom,
+ }
+ if c.Channels.WhatsApp.BridgeURL != nil {
+ channels.WhatsApp.BridgeURL = *c.Channels.WhatsApp.BridgeURL
+ }
+ }
+
+ if c.Channels.Feishu != nil {
+ enabled := c.Channels.Feishu.Enabled == nil || *c.Channels.Feishu.Enabled
+ channels.Feishu = FeishuConfig{
+ Enabled: enabled,
+ AllowFrom: c.Channels.Feishu.AllowFrom,
+ }
+ if c.Channels.Feishu.AppID != nil {
+ channels.Feishu.AppID = *c.Channels.Feishu.AppID
+ }
+ if c.Channels.Feishu.AppSecret != nil {
+ channels.Feishu.AppSecret = *c.Channels.Feishu.AppSecret
+ }
+ if c.Channels.Feishu.EncryptKey != nil {
+ channels.Feishu.EncryptKey = *c.Channels.Feishu.EncryptKey
+ }
+ if c.Channels.Feishu.VerificationToken != nil {
+ channels.Feishu.VerificationToken = *c.Channels.Feishu.VerificationToken
+ }
+ }
+
+ if c.Channels.QQ != nil && supportedChannels["qq"] {
+ channels.QQ = QQConfig{
+ Enabled: true,
+ AllowFrom: c.Channels.QQ.AllowFrom,
+ }
+ if c.Channels.QQ.AppID != nil {
+ channels.QQ.AppID = *c.Channels.QQ.AppID
+ }
+ if c.Channels.QQ.AppSecret != nil {
+ channels.QQ.AppSecret = *c.Channels.QQ.AppSecret
+ }
+ }
+
+ if c.Channels.DingTalk != nil && supportedChannels["dingtalk"] {
+ channels.DingTalk = DingTalkConfig{
+ Enabled: true,
+ AllowFrom: c.Channels.DingTalk.AllowFrom,
+ }
+ if c.Channels.DingTalk.AppID != nil {
+ channels.DingTalk.ClientID = *c.Channels.DingTalk.AppID
+ }
+ if c.Channels.DingTalk.AppSecret != nil {
+ channels.DingTalk.ClientSecret = *c.Channels.DingTalk.AppSecret
+ }
+ }
+
+ if c.Channels.MaixCam != nil && supportedChannels["maixcam"] {
+ channels.MaixCam = MaixCamConfig{
+ Enabled: true,
+ AllowFrom: c.Channels.MaixCam.AllowFrom,
+ }
+ if c.Channels.MaixCam.Host != nil {
+ channels.MaixCam.Host = *c.Channels.MaixCam.Host
+ }
+ if c.Channels.MaixCam.Port != nil {
+ channels.MaixCam.Port = *c.Channels.MaixCam.Port
+ }
+ }
+
+ if c.Channels.Signal != nil {
+ *warnings = append(*warnings, "Channel 'signal': No PicoClaw adapter available")
+ }
+ if c.Channels.Matrix != nil {
+ *warnings = append(*warnings, "Channel 'matrix': No PicoClaw adapter available")
+ }
+ if c.Channels.IRC != nil {
+ *warnings = append(*warnings, "Channel 'irc': No PicoClaw adapter available")
+ }
+ if c.Channels.Mattermost != nil {
+ *warnings = append(*warnings, "Channel 'mattermost': No PicoClaw adapter available")
+ }
+ if c.Channels.IMessage != nil {
+ *warnings = append(*warnings, "Channel 'imessage': macOS-only channel - requires manual setup")
+ }
+ if c.Channels.BlueBubbles != nil {
+ *warnings = append(
+ *warnings,
+ "Channel 'bluebubbles': No PicoClaw adapter available - consider iMessage instead",
+ )
+ }
+
+ return channels
+}
+
+func (c *OpenClawConfig) convertAgents(warnings *[]string) []AgentConfig {
+ var agents []AgentConfig
+
+ if c.Agents == nil {
+ return agents
+ }
+
+ for _, entry := range c.Agents.List {
+ agentID := entry.ID
+ if agentID == "" {
+ continue
+ }
+
+ agentName := agentID
+ if entry.Name != nil {
+ agentName = *entry.Name
+ }
+
+ agentCfg := AgentConfig{
+ ID: agentID,
+ Name: agentName,
+ Default: len(agents) == 0,
+ }
+
+ if entry.Workspace != nil {
+ agentCfg.Workspace = rewriteWorkspacePath(*entry.Workspace)
+ }
+
+ if entry.Model != nil {
+ primary := entry.Model.GetPrimary()
+ if primary != "" {
+ agentCfg.Model = &AgentModelConfig{
+ Primary: primary,
+ Fallbacks: entry.Model.GetFallbacks(),
+ }
+ }
+ }
+
+ if len(entry.Skills) > 0 {
+ agentCfg.Skills = entry.Skills
+ }
+
+ agents = append(agents, agentCfg)
+ }
+
+ return agents
+}
+
+func (c *PicoClawConfig) ToStandardConfig() *config.Config {
+ cfg := config.DefaultConfig()
+
+ cfg.Agents.Defaults.Workspace = c.Agents.Defaults.Workspace
+ cfg.Agents.Defaults.Provider = c.Agents.Defaults.Provider
+ cfg.Agents.Defaults.ModelName = c.Agents.Defaults.ModelName
+ cfg.Agents.Defaults.ModelFallbacks = c.Agents.Defaults.ModelFallbacks
+
+ for _, m := range c.ModelList {
+ cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
+ ModelName: m.ModelName,
+ Model: m.Model,
+ APIBase: m.APIBase,
+ APIKey: m.APIKey,
+ Proxy: m.Proxy,
+ })
+ }
+
+ cfg.Channels = c.Channels.ToStandardChannels()
+ cfg.Gateway = c.Gateway.ToStandardGateway()
+ cfg.Tools = c.Tools.ToStandardTools()
+
+ cfg.Agents.List = make([]config.AgentConfig, len(c.Agents.List))
+ for i, a := range c.Agents.List {
+ cfg.Agents.List[i] = config.AgentConfig{
+ ID: a.ID,
+ Default: a.Default,
+ Name: a.Name,
+ Workspace: a.Workspace,
+ Skills: a.Skills,
+ }
+ if a.Model != nil {
+ cfg.Agents.List[i].Model = &config.AgentModelConfig{
+ Primary: a.Model.Primary,
+ Fallbacks: a.Model.Fallbacks,
+ }
+ }
+ }
+
+ return cfg
+}
+
+func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
+ return config.ChannelsConfig{
+ WhatsApp: config.WhatsAppConfig{
+ Enabled: c.WhatsApp.Enabled,
+ BridgeURL: c.WhatsApp.BridgeURL,
+ },
+ Telegram: config.TelegramConfig{
+ Enabled: c.Telegram.Enabled,
+ Token: c.Telegram.Token,
+ Proxy: c.Telegram.Proxy,
+ },
+ Feishu: config.FeishuConfig{
+ Enabled: c.Feishu.Enabled,
+ AppID: c.Feishu.AppID,
+ AppSecret: c.Feishu.AppSecret,
+ EncryptKey: c.Feishu.EncryptKey,
+ VerificationToken: c.Feishu.VerificationToken,
+ },
+ Discord: config.DiscordConfig{
+ Enabled: c.Discord.Enabled,
+ Token: c.Discord.Token,
+ MentionOnly: c.Discord.MentionOnly,
+ },
+ MaixCam: config.MaixCamConfig{
+ Enabled: c.MaixCam.Enabled,
+ Host: c.MaixCam.Host,
+ Port: c.MaixCam.Port,
+ },
+ QQ: config.QQConfig{
+ Enabled: c.QQ.Enabled,
+ AppID: c.QQ.AppID,
+ AppSecret: c.QQ.AppSecret,
+ },
+ DingTalk: config.DingTalkConfig{
+ Enabled: c.DingTalk.Enabled,
+ ClientID: c.DingTalk.ClientID,
+ ClientSecret: c.DingTalk.ClientSecret,
+ },
+ Slack: config.SlackConfig{
+ Enabled: c.Slack.Enabled,
+ BotToken: c.Slack.BotToken,
+ AppToken: c.Slack.AppToken,
+ },
+ LINE: config.LINEConfig{
+ Enabled: c.LINE.Enabled,
+ ChannelSecret: c.LINE.ChannelSecret,
+ ChannelAccessToken: c.LINE.ChannelAccessToken,
+ WebhookHost: c.LINE.WebhookHost,
+ WebhookPort: c.LINE.WebhookPort,
+ WebhookPath: c.LINE.WebhookPath,
+ },
+ }
+}
+
+func (c GatewayConfig) ToStandardGateway() config.GatewayConfig {
+ return config.GatewayConfig{
+ Host: c.Host,
+ Port: c.Port,
+ }
+}
+
+func (c ToolsConfig) ToStandardTools() config.ToolsConfig {
+ return config.ToolsConfig{
+ Web: config.WebToolsConfig{
+ Brave: config.BraveConfig{
+ Enabled: c.Web.Brave.Enabled,
+ APIKey: c.Web.Brave.APIKey,
+ MaxResults: c.Web.Brave.MaxResults,
+ },
+ Tavily: config.TavilyConfig{
+ Enabled: c.Web.Tavily.Enabled,
+ APIKey: c.Web.Tavily.APIKey,
+ BaseURL: c.Web.Tavily.BaseURL,
+ MaxResults: c.Web.Tavily.MaxResults,
+ },
+ DuckDuckGo: config.DuckDuckGoConfig{
+ Enabled: c.Web.DuckDuckGo.Enabled,
+ MaxResults: c.Web.DuckDuckGo.MaxResults,
+ },
+ Perplexity: config.PerplexityConfig{
+ Enabled: c.Web.Perplexity.Enabled,
+ APIKey: c.Web.Perplexity.APIKey,
+ MaxResults: c.Web.Perplexity.MaxResults,
+ },
+ Proxy: c.Web.Proxy,
+ },
+ Cron: config.CronToolsConfig{
+ ExecTimeoutMinutes: c.Cron.ExecTimeoutMinutes,
+ },
+ Exec: config.ExecConfig{
+ EnableDenyPatterns: c.Exec.EnableDenyPatterns,
+ CustomDenyPatterns: c.Exec.CustomDenyPatterns,
+ },
+ }
+}
diff --git a/pkg/migrate/sources/openclaw/openclaw_config_test.go b/pkg/migrate/sources/openclaw/openclaw_config_test.go
new file mode 100644
index 000000000..7d884522c
--- /dev/null
+++ b/pkg/migrate/sources/openclaw/openclaw_config_test.go
@@ -0,0 +1,714 @@
+package openclaw
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestLoadOpenClawConfig(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "openclaw.json")
+
+ testConfig := `{
+ "agents": {
+ "defaults": {
+ "model": {
+ "primary": "anthropic/claude-sonnet-4-20250514"
+ },
+ "workspace": "~/.openclaw/workspace"
+ },
+ "list": [
+ {
+ "id": "main",
+ "name": "Main Agent",
+ "model": {
+ "primary": "openai/gpt-4o",
+ "fallbacks": ["claude-3-opus"]
+ }
+ }
+ ]
+ },
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "botToken": "test-token",
+ "allowFrom": ["user1", "user2"]
+ },
+ "discord": {
+ "enabled": true,
+ "token": "discord-token"
+ }
+ },
+ "models": {
+ "providers": {
+ "anthropic": {
+ "api_key": "sk-ant-test",
+ "base_url": "https://api.anthropic.com"
+ },
+ "openai": {
+ "api_key": "sk-test"
+ }
+ }
+ }
+ }`
+
+ err := os.WriteFile(configPath, []byte(testConfig), 0o644)
+ if err != nil {
+ t.Fatalf("failed to write test config: %v", err)
+ }
+
+ cfg, err := LoadOpenClawConfig(configPath)
+ if err != nil {
+ t.Fatalf("failed to load config: %v", err)
+ }
+
+ if cfg.Agents == nil {
+ t.Error("agents should not be nil")
+ }
+
+ if cfg.Agents.Defaults == nil {
+ t.Error("agents.defaults should not be nil")
+ }
+
+ provider, model := cfg.GetDefaultModel()
+ if provider != "anthropic" {
+ t.Errorf("expected provider 'anthropic', got '%s'", provider)
+ }
+ if model != "claude-sonnet-4-20250514" {
+ t.Errorf("expected model 'claude-sonnet-4-20250514', got '%s'", model)
+ }
+
+ workspace := cfg.GetDefaultWorkspace()
+ if workspace != "~/.picoclaw/workspace" {
+ t.Errorf("expected workspace '~/.picoclaw/workspace', got '%s'", workspace)
+ }
+
+ agents := cfg.GetAgents()
+ if len(agents) != 1 {
+ t.Errorf("expected 1 agent, got %d", len(agents))
+ }
+ if agents[0].ID != "main" {
+ t.Errorf("expected agent id 'main', got '%s'", agents[0].ID)
+ }
+
+ if cfg.Channels == nil {
+ t.Error("channels should not be nil")
+ }
+ if cfg.Channels.Telegram == nil {
+ t.Error("telegram channel should not be nil")
+ }
+ if cfg.Channels.Telegram.BotToken == nil || *cfg.Channels.Telegram.BotToken != "test-token" {
+ t.Error("telegram bot token not parsed correctly")
+ }
+}
+
+func TestGetProviderConfig(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "openclaw.json")
+
+ testConfig := `{
+ "models": {
+ "providers": {
+ "anthropic": {
+ "api_key": "sk-ant-test",
+ "base_url": "https://api.anthropic.com",
+ "max_tokens": 4096
+ },
+ "openai": {
+ "api_key": "sk-test",
+ "base_url": "https://api.openai.com"
+ }
+ }
+ }
+ }`
+
+ err := os.WriteFile(configPath, []byte(testConfig), 0o644)
+ if err != nil {
+ t.Fatalf("failed to write test config: %v", err)
+ }
+
+ cfg, err := LoadOpenClawConfig(configPath)
+ if err != nil {
+ t.Fatalf("failed to load config: %v", err)
+ }
+
+ providers := GetProviderConfig(cfg.Models)
+ if len(providers) != 2 {
+ t.Errorf("expected 2 providers, got %d", len(providers))
+ }
+
+ if anthropic, ok := providers["anthropic"]; ok {
+ if anthropic.APIKey != "sk-ant-test" {
+ t.Errorf("expected anthropic api_key 'sk-ant-test', got '%s'", anthropic.APIKey)
+ }
+ if anthropic.BaseURL != "https://api.anthropic.com" {
+ t.Errorf("expected anthropic base_url 'https://api.anthropic.com', got '%s'", anthropic.BaseURL)
+ }
+ } else {
+ t.Error("anthropic provider not found")
+ }
+
+ if openai, ok := providers["openai"]; ok {
+ if openai.APIKey != "sk-test" {
+ t.Errorf("expected openai api_key 'sk-test', got '%s'", openai.APIKey)
+ }
+ } else {
+ t.Error("openai provider not found")
+ }
+}
+
+func TestConvertToPicoClaw(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "openclaw.json")
+
+ testConfig := `{
+ "agents": {
+ "defaults": {
+ "model": {
+ "primary": "anthropic/claude-sonnet-4-20250514"
+ },
+ "workspace": "~/.openclaw/workspace"
+ },
+ "list": [
+ {
+ "id": "main",
+ "name": "Main Agent"
+ },
+ {
+ "id": "assistant",
+ "name": "Assistant",
+ "skills": ["skill1", "skill2"]
+ }
+ ]
+ },
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "botToken": "test-token",
+ "allowFrom": ["user1", "user2"]
+ },
+ "discord": {
+ "enabled": false,
+ "token": "discord-token"
+ },
+ "whatsapp": {
+ "enabled": true,
+ "bridgeUrl": "http://localhost:3000"
+ },
+ "feishu": {
+ "enabled": true,
+ "appId": "app-id",
+ "appSecret": "app-secret",
+ "allowFrom": ["user3"]
+ },
+ "signal": {
+ "enabled": true
+ }
+ },
+ "models": {
+ "providers": {
+ "anthropic": {
+ "api_key": "sk-ant-test"
+ },
+ "openai": {
+ "api_key": "sk-test"
+ }
+ }
+ },
+ "skills": {
+ "entries": {
+ "skill1": {}
+ }
+ },
+ "memory": {"enabled": true},
+ "cron": {"enabled": true}
+ }`
+
+ err := os.WriteFile(configPath, []byte(testConfig), 0o644)
+ if err != nil {
+ t.Fatalf("failed to write test config: %v", err)
+ }
+
+ cfg, err := LoadOpenClawConfig(configPath)
+ if err != nil {
+ t.Fatalf("failed to load config: %v", err)
+ }
+
+ picoCfg, warnings, err := cfg.ConvertToPicoClaw("")
+ if err != nil {
+ t.Fatalf("failed to convert config: %v", err)
+ }
+
+ if picoCfg.Agents.Defaults.ModelName != "claude-sonnet-4-20250514" {
+ t.Errorf("expected model 'claude-sonnet-4-20250514', got '%s'", picoCfg.Agents.Defaults.ModelName)
+ }
+ if picoCfg.Agents.Defaults.Workspace != "~/.picoclaw/workspace" {
+ t.Errorf("expected workspace '~/.picoclaw/workspace', got '%s'", picoCfg.Agents.Defaults.Workspace)
+ }
+
+ if len(picoCfg.Agents.List) != 2 {
+ t.Errorf("expected 2 agents, got %d", len(picoCfg.Agents.List))
+ }
+ if picoCfg.Agents.List[0].ID != "main" {
+ t.Errorf("expected first agent id 'main', got '%s'", picoCfg.Agents.List[0].ID)
+ }
+ if picoCfg.Agents.List[1].Skills == nil || len(picoCfg.Agents.List[1].Skills) != 2 {
+ t.Errorf("expected 2 skills for assistant agent")
+ }
+
+ if !picoCfg.Channels.Telegram.Enabled {
+ t.Error("telegram should be enabled")
+ }
+ if picoCfg.Channels.Telegram.Token != "test-token" {
+ t.Errorf("expected telegram token 'test-token', got '%s'", picoCfg.Channels.Telegram.Token)
+ }
+
+ if picoCfg.Channels.WhatsApp.BridgeURL != "http://localhost:3000" {
+ t.Errorf("expected whatsapp bridge URL 'http://localhost:3000', got '%s'", picoCfg.Channels.WhatsApp.BridgeURL)
+ }
+
+ if picoCfg.Channels.Feishu.AppID != "app-id" {
+ t.Errorf("expected feishu app ID 'app-id', got '%s'", picoCfg.Channels.Feishu.AppID)
+ }
+
+ if len(picoCfg.ModelList) != 1 {
+ t.Errorf("expected 1 model config (no models.json provided), got %d", len(picoCfg.ModelList))
+ }
+
+ foundWarning := false
+ for _, w := range warnings {
+ if len(w) > 0 {
+ foundWarning = true
+ break
+ }
+ }
+ if !foundWarning {
+ t.Log("warnings should be generated for skills, memory, cron, and unsupported channels")
+ }
+}
+
+func TestConvertToPicoClawWithQQAndDingTalk(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "openclaw.json")
+
+ testConfig := `{
+ "agents": {
+ "defaults": {
+ "model": {
+ "primary": "anthropic/claude-sonnet-4-20250514"
+ }
+ }
+ },
+ "channels": {
+ "qq": {
+ "enabled": true,
+ "appId": "qq-app-id",
+ "appSecret": "qq-app-secret"
+ },
+ "dingtalk": {
+ "enabled": true,
+ "appId": "ding-app-id",
+ "appSecret": "ding-app-secret"
+ },
+ "maixcam": {
+ "enabled": true,
+ "host": "192.168.1.100",
+ "port": 9000
+ },
+ "slack": {
+ "enabled": true,
+ "botToken": "xoxb-test",
+ "appToken": "xapp-test"
+ }
+ }
+ }`
+
+ err := os.WriteFile(configPath, []byte(testConfig), 0o644)
+ if err != nil {
+ t.Fatalf("failed to write test config: %v", err)
+ }
+
+ cfg, err := LoadOpenClawConfig(configPath)
+ if err != nil {
+ t.Fatalf("failed to load config: %v", err)
+ }
+
+ picoCfg, _, err := cfg.ConvertToPicoClaw("")
+ if err != nil {
+ t.Fatalf("failed to convert config: %v", err)
+ }
+
+ if !picoCfg.Channels.QQ.Enabled {
+ t.Error("qq should be enabled")
+ }
+ if picoCfg.Channels.QQ.AppID != "qq-app-id" {
+ t.Errorf("expected qq app ID 'qq-app-id', got '%s'", picoCfg.Channels.QQ.AppID)
+ }
+
+ if !picoCfg.Channels.DingTalk.Enabled {
+ t.Error("dingtalk should be enabled")
+ }
+ if picoCfg.Channels.DingTalk.ClientID != "ding-app-id" {
+ t.Errorf("expected dingtalk client ID 'ding-app-id', got '%s'", picoCfg.Channels.DingTalk.ClientID)
+ }
+
+ if !picoCfg.Channels.MaixCam.Enabled {
+ t.Error("maixcam should be enabled")
+ }
+ if picoCfg.Channels.MaixCam.Host != "192.168.1.100" {
+ t.Errorf("expected maixcam host '192.168.1.100', got '%s'", picoCfg.Channels.MaixCam.Host)
+ }
+ if picoCfg.Channels.MaixCam.Port != 9000 {
+ t.Errorf("expected maixcam port 9000, got %d", picoCfg.Channels.MaixCam.Port)
+ }
+
+ if !picoCfg.Channels.Slack.Enabled {
+ t.Error("slack should be enabled")
+ }
+ if picoCfg.Channels.Slack.BotToken != "xoxb-test" {
+ t.Errorf("expected slack bot token 'xoxb-test', got '%s'", picoCfg.Channels.Slack.BotToken)
+ }
+ if picoCfg.Channels.Slack.AppToken != "xapp-test" {
+ t.Errorf("expected slack app token 'xapp-test', got '%s'", picoCfg.Channels.Slack.AppToken)
+ }
+}
+
+func TestOpenClawAgentModel(t *testing.T) {
+ model := &OpenClawAgentModel{
+ Primary: strPtr("anthropic/claude-3-opus"),
+ Fallbacks: []string{"claude-3-sonnet", "claude-3-haiku"},
+ }
+
+ primary := model.GetPrimary()
+ if primary != "anthropic/claude-3-opus" {
+ t.Errorf("expected primary 'anthropic/claude-3-opus', got '%s'", primary)
+ }
+
+ fallbacks := model.GetFallbacks()
+ if len(fallbacks) != 2 {
+ t.Errorf("expected 2 fallbacks, got %d", len(fallbacks))
+ }
+
+ model2 := &OpenClawAgentModel{
+ Simple: "claude-3-opus",
+ }
+
+ primary2 := model2.GetPrimary()
+ if primary2 != "claude-3-opus" {
+ t.Errorf("expected primary 'claude-3-opus' from Simple, got '%s'", primary2)
+ }
+}
+
+func TestChannelEnabled(t *testing.T) {
+ cfg := &OpenClawConfig{
+ Channels: &OpenClawChannels{
+ Telegram: &OpenClawTelegramConfig{
+ Enabled: boolPtr(true),
+ },
+ Discord: &OpenClawDiscordConfig{
+ Enabled: boolPtr(false),
+ },
+ Slack: &OpenClawSlackConfig{
+ Enabled: boolPtr(true),
+ },
+ },
+ }
+
+ if !cfg.IsChannelEnabled("telegram") {
+ t.Error("telegram should be enabled")
+ }
+ if cfg.IsChannelEnabled("discord") {
+ t.Error("discord should be disabled")
+ }
+ if !cfg.IsChannelEnabled("slack") {
+ t.Error("slack should be enabled (explicitly set)")
+ }
+ if cfg.IsChannelEnabled("line") {
+ t.Error("line should return false (not in switch cases)")
+ }
+}
+
+func TestGetDefaultModel(t *testing.T) {
+ cfg := &OpenClawConfig{
+ Agents: &OpenClawAgents{
+ Defaults: &OpenClawAgentDefaults{
+ Model: &OpenClawAgentModel{
+ Primary: strPtr("openai/gpt-4"),
+ },
+ },
+ },
+ }
+
+ provider, model := cfg.GetDefaultModel()
+ if provider != "openai" {
+ t.Errorf("expected provider 'openai', got '%s'", provider)
+ }
+ if model != "gpt-4" {
+ t.Errorf("expected model 'gpt-4', got '%s'", model)
+ }
+}
+
+func TestGetDefaultModelWithNoDefaults(t *testing.T) {
+ cfg := &OpenClawConfig{}
+
+ provider, model := cfg.GetDefaultModel()
+ if provider != "anthropic" {
+ t.Errorf("expected default provider 'anthropic', got '%s'", provider)
+ }
+ if model != "claude-sonnet-4-20250514" {
+ t.Errorf("expected default model 'claude-sonnet-4-20250514', got '%s'", model)
+ }
+}
+
+func TestHasFunctions(t *testing.T) {
+ cfg := &OpenClawConfig{
+ Skills: &OpenClawSkills{Entries: map[string]json.RawMessage{"skill1": nil}},
+ Memory: json.RawMessage(`{"enabled": true}`),
+ Cron: json.RawMessage(`{"enabled": true}`),
+ Hooks: json.RawMessage(`{"enabled": true}`),
+ Session: json.RawMessage(`{"enabled": true}`),
+ Auth: &OpenClawAuth{Profiles: json.RawMessage(`{"profile1": {}}`)},
+ }
+
+ if !cfg.HasSkills() {
+ t.Error("should have skills")
+ }
+ if !cfg.HasMemory() {
+ t.Error("should have memory")
+ }
+ if !cfg.HasCron() {
+ t.Error("should have cron")
+ }
+ if !cfg.HasHooks() {
+ t.Error("should have hooks")
+ }
+ if !cfg.HasSession() {
+ t.Error("should have session")
+ }
+ if !cfg.HasAuthProfiles() {
+ t.Error("should have auth profiles")
+ }
+
+ cfg2 := &OpenClawConfig{}
+ if cfg2.HasSkills() {
+ t.Error("should not have skills")
+ }
+ if cfg2.HasMemory() {
+ t.Error("should not have memory")
+ }
+}
+
+func TestLoadOpenClawConfigFromDir(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "openclaw.json")
+
+ testConfig := `{"agents": {}}`
+ err := os.WriteFile(configPath, []byte(testConfig), 0o644)
+ if err != nil {
+ t.Fatalf("failed to write test config: %v", err)
+ }
+
+ cfg, err := LoadOpenClawConfigFromDir(tmpDir)
+ if err != nil {
+ t.Fatalf("failed to load config from dir: %v", err)
+ }
+
+ if cfg.Agents == nil {
+ t.Error("agents should not be nil")
+ }
+
+ _, err = LoadOpenClawConfigFromDir("/nonexistent/dir")
+ if err == nil {
+ t.Error("should return error for nonexistent dir")
+ }
+}
+
+func TestToStandardConfig(t *testing.T) {
+ picoCfg := &PicoClawConfig{
+ Agents: AgentsConfig{
+ Defaults: AgentDefaults{
+ Provider: "anthropic",
+ ModelName: "claude-sonnet-4-20250514",
+ Workspace: "~/.picoclaw/workspace",
+ },
+ List: []AgentConfig{
+ {
+ ID: "main",
+ Name: "Main Agent",
+ Default: true,
+ },
+ },
+ },
+ ModelList: []ModelConfig{
+ {
+ ModelName: "claude-sonnet-4-20250514",
+ Model: "anthropic/claude-sonnet-4-20250514",
+ APIKey: "sk-ant-test",
+ },
+ },
+ Channels: ChannelsConfig{
+ Telegram: TelegramConfig{
+ Enabled: true,
+ Token: "test-token",
+ AllowFrom: []string{"user1"},
+ },
+ WhatsApp: WhatsAppConfig{
+ Enabled: true,
+ BridgeURL: "http://localhost:3000",
+ },
+ },
+ Gateway: GatewayConfig{
+ Host: "0.0.0.0",
+ Port: 8080,
+ },
+ }
+
+ stdCfg := picoCfg.ToStandardConfig()
+
+ if stdCfg.Agents.Defaults.Provider != "anthropic" {
+ t.Errorf("expected provider 'anthropic', got '%s'", stdCfg.Agents.Defaults.Provider)
+ }
+ if stdCfg.Agents.Defaults.ModelName != "claude-sonnet-4-20250514" {
+ t.Errorf("expected model name 'claude-sonnet-4-20250514', got '%s'", stdCfg.Agents.Defaults.ModelName)
+ }
+ if stdCfg.Agents.Defaults.Workspace != "~/.picoclaw/workspace" {
+ t.Errorf("expected workspace '~/.picoclaw/workspace', got '%s'", stdCfg.Agents.Defaults.Workspace)
+ }
+
+ if len(stdCfg.Agents.List) != 1 {
+ t.Errorf("expected 1 agent, got %d", len(stdCfg.Agents.List))
+ }
+ if stdCfg.Agents.List[0].ID != "main" {
+ t.Errorf("expected agent id 'main', got '%s'", stdCfg.Agents.List[0].ID)
+ }
+
+ foundModel := false
+ var foundAPIKey string
+ for _, m := range stdCfg.ModelList {
+ if m.ModelName == "claude-sonnet-4-20250514" {
+ foundModel = true
+ foundAPIKey = m.APIKey
+ break
+ }
+ }
+ if !foundModel {
+ t.Error("expected to find claude-sonnet-4-20250514 model config")
+ }
+ if foundAPIKey != "sk-ant-test" {
+ t.Errorf("expected api key 'sk-ant-test', got '%s'", foundAPIKey)
+ }
+
+ if !stdCfg.Channels.Telegram.Enabled {
+ t.Error("telegram should be enabled")
+ }
+ if stdCfg.Channels.Telegram.Token != "test-token" {
+ t.Errorf("expected token 'test-token', got '%s'", stdCfg.Channels.Telegram.Token)
+ }
+
+ if stdCfg.Gateway.Port != 8080 {
+ t.Errorf("expected gateway port 8080, got %d", stdCfg.Gateway.Port)
+ }
+}
+
+func TestLoadProviderConfigFromAgentsDir(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ agentsDir := filepath.Join(tmpDir, "agents", "main", "agent")
+ err := os.MkdirAll(agentsDir, 0o755)
+ if err != nil {
+ t.Fatalf("failed to create agents dir: %v", err)
+ }
+
+ modelsJSON := `{
+ "providers": {
+ "anthropic": {
+ "baseUrl": "https://api.anthropic.com",
+ "api": "anthropic",
+ "apiKey": "sk-ant-from-models",
+ "models": [
+ {
+ "id": "claude-sonnet-4-20250514",
+ "name": "Claude Sonnet 4"
+ }
+ ]
+ },
+ "openai": {
+ "baseUrl": "https://api.openai.com",
+ "api": "openai",
+ "apiKey": "sk-from-models",
+ "models": [
+ {
+ "id": "gpt-4o",
+ "name": "GPT-4o"
+ }
+ ]
+ },
+ "zhipu": {
+ "baseUrl": "https://open.bigmodel.cn/api/paas/v4",
+ "api": "openai",
+ "apiKey": "zhipu-key",
+ "models": []
+ }
+ }
+ }`
+
+ err = os.WriteFile(filepath.Join(agentsDir, "models.json"), []byte(modelsJSON), 0o644)
+ if err != nil {
+ t.Fatalf("failed to write models.json: %v", err)
+ }
+
+ providers := GetProviderConfigFromDir(tmpDir)
+ if len(providers) != 3 {
+ t.Errorf("expected 3 providers, got %d", len(providers))
+ }
+
+ if anthropic, ok := providers["anthropic"]; ok {
+ if anthropic.ApiKey != "sk-ant-from-models" {
+ t.Errorf("expected anthropic apiKey 'sk-ant-from-models', got '%s'", anthropic.ApiKey)
+ }
+ if anthropic.BaseUrl != "https://api.anthropic.com" {
+ t.Errorf("expected anthropic baseUrl 'https://api.anthropic.com', got '%s'", anthropic.BaseUrl)
+ }
+ } else {
+ t.Error("anthropic provider not found")
+ }
+
+ if openai, ok := providers["openai"]; ok {
+ if openai.ApiKey != "sk-from-models" {
+ t.Errorf("expected openai apiKey 'sk-from-models', got '%s'", openai.ApiKey)
+ }
+ if openai.BaseUrl != "https://api.openai.com" {
+ t.Errorf("expected openai baseUrl 'https://api.openai.com', got '%s'", openai.BaseUrl)
+ }
+ } else {
+ t.Error("openai provider not found")
+ }
+
+ if zhipu, ok := providers["zhipu"]; ok {
+ if zhipu.ApiKey != "zhipu-key" {
+ t.Errorf("expected zhipu apiKey 'zhipu-key', got '%s'", zhipu.ApiKey)
+ }
+ if zhipu.BaseUrl != "https://open.bigmodel.cn/api/paas/v4" {
+ t.Errorf("expected zhipu baseUrl 'https://open.bigmodel.cn/api/paas/v4', got '%s'", zhipu.BaseUrl)
+ }
+ } else {
+ t.Error("zhipu provider not found")
+ }
+}
+
+func TestGetProviderConfigFromDirNotExist(t *testing.T) {
+ providers := GetProviderConfigFromDir("/nonexistent/path")
+ if len(providers) != 0 {
+ t.Errorf("expected 0 providers for nonexistent path, got %d", len(providers))
+ }
+}
+
+func strPtr(s string) *string {
+ return &s
+}
+
+func boolPtr(b bool) *bool {
+ return &b
+}
diff --git a/pkg/migrate/sources/openclaw/openclaw_handler.go b/pkg/migrate/sources/openclaw/openclaw_handler.go
new file mode 100644
index 000000000..aaff119f1
--- /dev/null
+++ b/pkg/migrate/sources/openclaw/openclaw_handler.go
@@ -0,0 +1,148 @@
+package openclaw
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/migrate/internal"
+)
+
+var providerMapping = map[string]string{
+ "anthropic": "anthropic",
+ "claude": "anthropic",
+ "openai": "openai",
+ "gpt": "openai",
+ "groq": "groq",
+ "ollama": "ollama",
+ "openrouter": "openrouter",
+ "deepseek": "deepseek",
+ "together": "together",
+ "mistral": "mistral",
+ "fireworks": "fireworks",
+ "google": "google",
+ "gemini": "google",
+ "xai": "xai",
+ "grok": "xai",
+ "cerebras": "cerebras",
+ "sambanova": "sambanova",
+}
+
+type OpenclawHandler struct {
+ opts Options
+ sourceConfigFile string
+ sourceWorkspace string
+}
+
+type (
+ Options = internal.Options
+ Action = internal.Action
+ Result = internal.Result
+ Operation = internal.Operation
+)
+
+func NewOpenclawHandler(opts Options) (Operation, error) {
+ home, err := resolveSourceHome(opts.SourceHome)
+ if err != nil {
+ return nil, err
+ }
+ opts.SourceHome = home
+
+ configFile, err := findSourceConfig(home)
+ if err != nil {
+ return nil, err
+ }
+ return &OpenclawHandler{
+ opts: opts,
+ sourceWorkspace: filepath.Join(opts.SourceHome, "workspace"),
+ sourceConfigFile: configFile,
+ }, nil
+}
+
+func (o *OpenclawHandler) GetSourceName() string {
+ return "openclaw"
+}
+
+func (o *OpenclawHandler) GetSourceHome() (string, error) {
+ return o.opts.SourceHome, nil
+}
+
+func (o *OpenclawHandler) GetSourceWorkspace() (string, error) {
+ return o.sourceWorkspace, nil
+}
+
+func (o *OpenclawHandler) GetSourceConfigFile() (string, error) {
+ return o.sourceConfigFile, nil
+}
+
+func (o *OpenclawHandler) GetMigrateableFiles() []string {
+ return migrateableFiles
+}
+
+func (o *OpenclawHandler) GetMigrateableDirs() []string {
+ return migrateableDirs
+}
+
+func (o *OpenclawHandler) ExecuteConfigMigration(srcConfigPath, dstConfigPath string) error {
+ openclawCfg, err := LoadOpenClawConfig(srcConfigPath)
+ if err != nil {
+ return err
+ }
+
+ picoCfg, warnings, err := openclawCfg.ConvertToPicoClaw(o.opts.SourceHome)
+ if err != nil {
+ return err
+ }
+
+ for _, w := range warnings {
+ fmt.Printf(" Warning: %s\n", w)
+ }
+
+ incoming := picoCfg.ToStandardConfig()
+ if err := os.MkdirAll(filepath.Dir(dstConfigPath), 0o755); err != nil {
+ return err
+ }
+
+ return config.SaveConfig(dstConfigPath, incoming)
+}
+
+func resolveSourceHome(override string) (string, error) {
+ if override != "" {
+ return internal.ExpandHome(override), nil
+ }
+ if envHome := os.Getenv("OPENCLAW_HOME"); envHome != "" {
+ return internal.ExpandHome(envHome), nil
+ }
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("resolving home directory: %w", err)
+ }
+ return filepath.Join(home, ".openclaw"), nil
+}
+
+func findSourceConfig(sourceHome string) (string, error) {
+ candidates := []string{
+ filepath.Join(sourceHome, "openclaw.json"),
+ filepath.Join(sourceHome, "config.json"),
+ }
+ for _, p := range candidates {
+ if _, err := os.Stat(p); err == nil {
+ return p, nil
+ }
+ }
+ return "", fmt.Errorf("no config file found in %s (tried openclaw.json, config.json)", sourceHome)
+}
+
+func rewriteWorkspacePath(path string) string {
+ path = strings.Replace(path, ".openclaw", ".picoclaw", 1)
+ return path
+}
+
+func mapProvider(provider string) string {
+ if mapped, ok := providerMapping[strings.ToLower(provider)]; ok {
+ return mapped
+ }
+ return strings.ToLower(provider)
+}
diff --git a/pkg/migrate/sources/openclaw/openclaw_handler_test.go b/pkg/migrate/sources/openclaw/openclaw_handler_test.go
new file mode 100644
index 000000000..35bd09be0
--- /dev/null
+++ b/pkg/migrate/sources/openclaw/openclaw_handler_test.go
@@ -0,0 +1,247 @@
+package openclaw
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewOpenclawHandler(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "openclaw.json")
+ err := os.WriteFile(configPath, []byte("{}"), 0o644)
+ require.NoError(t, err)
+
+ handler, err := NewOpenclawHandler(Options{
+ SourceHome: tmpDir,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, handler)
+}
+
+func TestNewOpenclawHandlerNoConfig(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ _, err := NewOpenclawHandler(Options{
+ SourceHome: tmpDir,
+ })
+ require.Error(t, err)
+}
+
+func TestOpenclawHandlerGetSourceName(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "openclaw.json")
+ err := os.WriteFile(configPath, []byte("{}"), 0o644)
+ require.NoError(t, err)
+
+ handler, err := NewOpenclawHandler(Options{
+ SourceHome: tmpDir,
+ })
+ require.NoError(t, err)
+
+ assert.Equal(t, "openclaw", handler.GetSourceName())
+}
+
+func TestOpenclawHandlerGetSourceHome(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "openclaw.json")
+ err := os.WriteFile(configPath, []byte("{}"), 0o644)
+ require.NoError(t, err)
+
+ handler, err := NewOpenclawHandler(Options{
+ SourceHome: tmpDir,
+ })
+ require.NoError(t, err)
+
+ home, err := handler.GetSourceHome()
+ require.NoError(t, err)
+ assert.Equal(t, tmpDir, home)
+}
+
+func TestOpenclawHandlerGetSourceWorkspace(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "openclaw.json")
+ err := os.WriteFile(configPath, []byte("{}"), 0o644)
+ require.NoError(t, err)
+
+ handler, err := NewOpenclawHandler(Options{
+ SourceHome: tmpDir,
+ })
+ require.NoError(t, err)
+
+ workspace, err := handler.GetSourceWorkspace()
+ require.NoError(t, err)
+ assert.Equal(t, filepath.Join(tmpDir, "workspace"), workspace)
+}
+
+func TestOpenclawHandlerGetSourceConfigFile(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "openclaw.json")
+ err := os.WriteFile(configPath, []byte("{}"), 0o644)
+ require.NoError(t, err)
+
+ handler, err := NewOpenclawHandler(Options{
+ SourceHome: tmpDir,
+ })
+ require.NoError(t, err)
+
+ configFile, err := handler.GetSourceConfigFile()
+ require.NoError(t, err)
+ assert.Equal(t, configPath, configFile)
+}
+
+func TestOpenclawHandlerGetSourceConfigFileWithConfigJson(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.json")
+ err := os.WriteFile(configPath, []byte("{}"), 0o644)
+ require.NoError(t, err)
+
+ handler, err := NewOpenclawHandler(Options{
+ SourceHome: tmpDir,
+ })
+ require.NoError(t, err)
+
+ configFile, err := handler.GetSourceConfigFile()
+ require.NoError(t, err)
+ assert.Equal(t, configPath, configFile)
+}
+
+func TestOpenclawHandlerGetMigrateableFiles(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "openclaw.json")
+ err := os.WriteFile(configPath, []byte("{}"), 0o644)
+ require.NoError(t, err)
+
+ handler, err := NewOpenclawHandler(Options{
+ SourceHome: tmpDir,
+ })
+ require.NoError(t, err)
+
+ files := handler.GetMigrateableFiles()
+ assert.NotEmpty(t, files)
+ assert.Contains(t, files, "AGENTS.md")
+ assert.Contains(t, files, "SOUL.md")
+ assert.Contains(t, files, "USER.md")
+}
+
+func TestOpenclawHandlerGetMigrateableDirs(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "openclaw.json")
+ err := os.WriteFile(configPath, []byte("{}"), 0o644)
+ require.NoError(t, err)
+
+ handler, err := NewOpenclawHandler(Options{
+ SourceHome: tmpDir,
+ })
+ require.NoError(t, err)
+
+ dirs := handler.GetMigrateableDirs()
+ assert.NotEmpty(t, dirs)
+ assert.Contains(t, dirs, "memory")
+ assert.Contains(t, dirs, "skills")
+}
+
+func TestResolveSourceHome(t *testing.T) {
+ result, err := resolveSourceHome("/custom/path")
+ require.NoError(t, err)
+ assert.Equal(t, "/custom/path", result)
+}
+
+func TestResolveSourceHomeWithEnvVar(t *testing.T) {
+ t.Setenv("OPENCLAW_HOME", "/env/path")
+
+ result, err := resolveSourceHome("")
+ require.NoError(t, err)
+ assert.Equal(t, "/env/path", result)
+}
+
+func TestResolveSourceHomeWithTilde(t *testing.T) {
+ home, err := os.UserHomeDir()
+ require.NoError(t, err)
+
+ result, err := resolveSourceHome("~/openclaw")
+ require.NoError(t, err)
+ assert.Equal(t, filepath.Join(home, "openclaw"), result)
+}
+
+func TestFindSourceConfig(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "openclaw.json")
+ err := os.WriteFile(configPath, []byte("{}"), 0o644)
+ require.NoError(t, err)
+
+ result, err := findSourceConfig(tmpDir)
+ require.NoError(t, err)
+ assert.Equal(t, configPath, result)
+}
+
+func TestFindSourceConfigWithConfigJson(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.json")
+ err := os.WriteFile(configPath, []byte("{}"), 0o644)
+ require.NoError(t, err)
+
+ result, err := findSourceConfig(tmpDir)
+ require.NoError(t, err)
+ assert.Equal(t, configPath, result)
+}
+
+func TestFindSourceConfigNotFound(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ _, err := findSourceConfig(tmpDir)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "no config file found")
+}
+
+func TestMapProvider(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"anthropic", "anthropic"},
+ {"claude", "anthropic"},
+ {"openai", "openai"},
+ {"gpt", "openai"},
+ {"groq", "groq"},
+ {"ollama", "ollama"},
+ {"openrouter", "openrouter"},
+ {"deepseek", "deepseek"},
+ {"together", "together"},
+ {"mistral", "mistral"},
+ {"fireworks", "fireworks"},
+ {"google", "google"},
+ {"gemini", "google"},
+ {"xai", "xai"},
+ {"grok", "xai"},
+ {"cerebras", "cerebras"},
+ {"sambanova", "sambanova"},
+ {"unknown", "unknown"},
+ {"", ""},
+ }
+
+ for _, tt := range tests {
+ result := mapProvider(tt.input)
+ assert.Equal(t, tt.expected, result, "mapProvider(%q)", tt.input)
+ }
+}
+
+func TestRewriteWorkspacePath(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"~/.openclaw/workspace", "~/.picoclaw/workspace"},
+ {"/home/user/.openclaw/workspace", "/home/user/.picoclaw/workspace"},
+ {"/path/without/openclaw/change", "/path/without/openclaw/change"},
+ {"", ""},
+ }
+
+ for _, tt := range tests {
+ result := rewriteWorkspacePath(tt.input)
+ assert.Equal(t, tt.expected, result, "rewriteWorkspacePath(%q)", tt.input)
+ }
+}
diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go
index 52f914622..f91397c5d 100644
--- a/pkg/tools/cron.go
+++ b/pkg/tools/cron.go
@@ -33,15 +33,19 @@ type CronTool struct {
func NewCronTool(
cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool,
execTimeout time.Duration, config *config.Config,
-) *CronTool {
- execTool := NewExecToolWithConfig(workspace, restrict, config)
+) (*CronTool, error) {
+ execTool, err := NewExecToolWithConfig(workspace, restrict, config)
+ if err != nil {
+ return nil, fmt.Errorf("unable to configure exec tool: %w", err)
+ }
+
execTool.SetTimeout(execTimeout)
return &CronTool{
cronService: cronService,
executor: executor,
msgBus: msgBus,
execTool: execTool,
- }
+ }, nil
}
// Name returns the tool name
diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go
index ad1664b5b..b52433b6f 100644
--- a/pkg/tools/shell.go
+++ b/pkg/tools/shell.go
@@ -69,11 +69,11 @@ var defaultDenyPatterns = []*regexp.Regexp{
regexp.MustCompile(`\bsource\s+.*\.sh\b`),
}
-func NewExecTool(workingDir string, restrict bool) *ExecTool {
+func NewExecTool(workingDir string, restrict bool) (*ExecTool, error) {
return NewExecToolWithConfig(workingDir, restrict, nil)
}
-func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) *ExecTool {
+func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) (*ExecTool, error) {
denyPatterns := make([]*regexp.Regexp, 0)
if config != nil {
@@ -86,8 +86,7 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf
for _, pattern := range execConfig.CustomDenyPatterns {
re, err := regexp.Compile(pattern)
if err != nil {
- fmt.Printf("Invalid custom deny pattern %q: %v\n", pattern, err)
- continue
+ return nil, fmt.Errorf("invalid custom deny pattern %q: %w", pattern, err)
}
denyPatterns = append(denyPatterns, re)
}
@@ -106,7 +105,7 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf
denyPatterns: denyPatterns,
allowPatterns: nil,
restrictToWorkspace: restrict,
- }
+ }, nil
}
func (t *ExecTool) Name() string {
diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go
index 6d35815e8..1a179547a 100644
--- a/pkg/tools/shell_test.go
+++ b/pkg/tools/shell_test.go
@@ -11,7 +11,10 @@ import (
// TestShellTool_Success verifies successful command execution
func TestShellTool_Success(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, err := NewExecTool("", false)
+ if err != nil {
+ t.Errorf("unable to configure exec tool: %s", err)
+ }
ctx := context.Background()
args := map[string]any{
@@ -38,7 +41,10 @@ func TestShellTool_Success(t *testing.T) {
// TestShellTool_Failure verifies failed command execution
func TestShellTool_Failure(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, err := NewExecTool("", false)
+ if err != nil {
+ t.Errorf("unable to configure exec tool: %s", err)
+ }
ctx := context.Background()
args := map[string]any{
@@ -65,7 +71,11 @@ func TestShellTool_Failure(t *testing.T) {
// TestShellTool_Timeout verifies command timeout handling
func TestShellTool_Timeout(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, err := NewExecTool("", false)
+ if err != nil {
+ t.Errorf("unable to configure exec tool: %s", err)
+ }
+
tool.SetTimeout(100 * time.Millisecond)
ctx := context.Background()
@@ -93,7 +103,10 @@ func TestShellTool_WorkingDir(t *testing.T) {
testFile := filepath.Join(tmpDir, "test.txt")
os.WriteFile(testFile, []byte("test content"), 0o644)
- tool := NewExecTool("", false)
+ tool, err := NewExecTool("", false)
+ if err != nil {
+ t.Errorf("unable to configure exec tool: %s", err)
+ }
ctx := context.Background()
args := map[string]any{
@@ -114,7 +127,10 @@ func TestShellTool_WorkingDir(t *testing.T) {
// TestShellTool_DangerousCommand verifies safety guard blocks dangerous commands
func TestShellTool_DangerousCommand(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, err := NewExecTool("", false)
+ if err != nil {
+ t.Errorf("unable to configure exec tool: %s", err)
+ }
ctx := context.Background()
args := map[string]any{
@@ -135,7 +151,10 @@ func TestShellTool_DangerousCommand(t *testing.T) {
// TestShellTool_MissingCommand verifies error handling for missing command
func TestShellTool_MissingCommand(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, err := NewExecTool("", false)
+ if err != nil {
+ t.Errorf("unable to configure exec tool: %s", err)
+ }
ctx := context.Background()
args := map[string]any{}
@@ -150,7 +169,10 @@ func TestShellTool_MissingCommand(t *testing.T) {
// TestShellTool_StderrCapture verifies stderr is captured and included
func TestShellTool_StderrCapture(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, err := NewExecTool("", false)
+ if err != nil {
+ t.Errorf("unable to configure exec tool: %s", err)
+ }
ctx := context.Background()
args := map[string]any{
@@ -170,7 +192,10 @@ func TestShellTool_StderrCapture(t *testing.T) {
// TestShellTool_OutputTruncation verifies long output is truncated
func TestShellTool_OutputTruncation(t *testing.T) {
- tool := NewExecTool("", false)
+ tool, err := NewExecTool("", false)
+ if err != nil {
+ t.Errorf("unable to configure exec tool: %s", err)
+ }
ctx := context.Background()
// Generate long output (>10000 chars)
@@ -198,7 +223,11 @@ func TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) {
t.Fatalf("failed to create outside dir: %v", err)
}
- tool := NewExecTool(workspace, true)
+ tool, err := NewExecTool(workspace, true)
+ if err != nil {
+ t.Errorf("unable to configure exec tool: %s", err)
+ }
+
result := tool.Execute(context.Background(), map[string]any{
"command": "pwd",
"working_dir": outsideDir,
@@ -232,7 +261,11 @@ func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) {
t.Skipf("symlinks not supported in this environment: %v", err)
}
- tool := NewExecTool(workspace, true)
+ tool, err := NewExecTool(workspace, true)
+ if err != nil {
+ t.Errorf("unable to configure exec tool: %s", err)
+ }
+
result := tool.Execute(context.Background(), map[string]any{
"command": "cat secret.txt",
"working_dir": link,
@@ -249,7 +282,11 @@ func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) {
// TestShellTool_RestrictToWorkspace verifies workspace restriction
func TestShellTool_RestrictToWorkspace(t *testing.T) {
tmpDir := t.TempDir()
- tool := NewExecTool(tmpDir, false)
+ tool, err := NewExecTool(tmpDir, false)
+ if err != nil {
+ t.Errorf("unable to configure exec tool: %s", err)
+ }
+
tool.SetRestrictToWorkspace(true)
ctx := context.Background()
diff --git a/pkg/tools/shell_timeout_unix_test.go b/pkg/tools/shell_timeout_unix_test.go
index 04ef8e441..357e1276e 100644
--- a/pkg/tools/shell_timeout_unix_test.go
+++ b/pkg/tools/shell_timeout_unix_test.go
@@ -22,7 +22,11 @@ func processExists(pid int) bool {
}
func TestShellTool_TimeoutKillsChildProcess(t *testing.T) {
- tool := NewExecTool(t.TempDir(), false)
+ tool, err := NewExecTool(t.TempDir(), false)
+ if err != nil {
+ t.Errorf("unable to configure exec tool: %s", err)
+ }
+
tool.SetTimeout(500 * time.Millisecond)
args := map[string]any{