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 + + + + + + + + + +
+
+ +

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
+
+
+
+
+ OpenAI + Not logged in +
+
+
+ +
+
+
+
+ Anthropic + Not logged in +
+
+
+ +
+
+
+
+ Google Antigravity + Not logged in +
+
+
+ +
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
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.
+
+ config.json + - +
+
+ + + +
+
+ +
+
+
+
+
+
+
+
+ + + + + + + + + + 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{