Feat/update migrate (#910)
* * update migrate * * rename handlers to sources * * delete dead code * * fix go test error
This commit is contained in:
parent
27e988c484
commit
8207c1c7e6
13 changed files with 3035 additions and 1472 deletions
|
|
@ -11,19 +11,21 @@ func NewMigrateCommand() *cobra.Command {
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "migrate",
|
Use: "migrate",
|
||||||
Short: "Migrate from OpenClaw to PicoClaw",
|
Short: "Migrate from xxxclaw(openclaw, etc.) to picoclaw",
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
Example: ` picoclaw migrate
|
Example: ` picoclaw migrate
|
||||||
|
picoclaw migrate --from openclaw
|
||||||
picoclaw migrate --dry-run
|
picoclaw migrate --dry-run
|
||||||
picoclaw migrate --refresh
|
picoclaw migrate --refresh
|
||||||
picoclaw migrate --force`,
|
picoclaw migrate --force`,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
result, err := migrate.Run(opts)
|
m := migrate.NewMigrateInstance(opts)
|
||||||
|
result, err := m.Run(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !opts.DryRun {
|
if !opts.DryRun {
|
||||||
migrate.PrintSummary(result)
|
m.PrintSummary(result)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
|
@ -31,6 +33,8 @@ func NewMigrateCommand() *cobra.Command {
|
||||||
|
|
||||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false,
|
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false,
|
||||||
"Show what would be migrated without making changes")
|
"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,
|
cmd.Flags().BoolVar(&opts.Refresh, "refresh", false,
|
||||||
"Re-sync workspace files from OpenClaw (repeatable)")
|
"Re-sync workspace files from OpenClaw (repeatable)")
|
||||||
cmd.Flags().BoolVar(&opts.ConfigOnly, "config-only", false,
|
cmd.Flags().BoolVar(&opts.ConfigOnly, "config-only", false,
|
||||||
|
|
@ -39,10 +43,10 @@ func NewMigrateCommand() *cobra.Command {
|
||||||
"Only migrate workspace files, skip config")
|
"Only migrate workspace files, skip config")
|
||||||
cmd.Flags().BoolVar(&opts.Force, "force", false,
|
cmd.Flags().BoolVar(&opts.Force, "force", false,
|
||||||
"Skip confirmation prompts")
|
"Skip confirmation prompts")
|
||||||
cmd.Flags().StringVar(&opts.OpenClawHome, "openclaw-home", "",
|
cmd.Flags().StringVar(&opts.SourceHome, "source-home", "",
|
||||||
"Override OpenClaw home directory (default: ~/.openclaw)")
|
"Override source home directory (default: ~/.openclaw)")
|
||||||
cmd.Flags().StringVar(&opts.PicoClawHome, "picoclaw-home", "",
|
cmd.Flags().StringVar(&opts.TargetHome, "target-home", "",
|
||||||
"Override PicoClaw home directory (default: ~/.picoclaw)")
|
"Override target home directory (default: ~/.picoclaw)")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ func TestNewMigrateCommand(t *testing.T) {
|
||||||
require.NotNil(t, cmd)
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
assert.Equal(t, "migrate", cmd.Use)
|
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)
|
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("config-only"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("workspace-only"))
|
assert.NotNil(t, cmd.Flags().Lookup("workspace-only"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("force"))
|
assert.NotNil(t, cmd.Flags().Lookup("force"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("openclaw-home"))
|
assert.NotNil(t, cmd.Flags().Lookup("source-home"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("picoclaw-home"))
|
assert.NotNil(t, cmd.Flags().Lookup("target-home"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +1,50 @@
|
||||||
package migrate
|
package internal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
var migrateableFiles = []string{
|
func ResolveTargetHome(override string) (string, error) {
|
||||||
"AGENTS.md",
|
if override != "" {
|
||||||
"SOUL.md",
|
return ExpandHome(override), nil
|
||||||
"USER.md",
|
}
|
||||||
"TOOLS.md",
|
if envHome := os.Getenv("PICOCLAW_HOME"); envHome != "" {
|
||||||
"HEARTBEAT.md",
|
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{
|
func ExpandHome(path string) string {
|
||||||
"memory",
|
if path == "" {
|
||||||
"skills",
|
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
|
var actions []Action
|
||||||
|
|
||||||
for _, filename := range migrateableFiles {
|
for _, filename := range migrateableFiles {
|
||||||
|
|
@ -50,7 +76,7 @@ func planFileCopy(src, dst string, force bool) Action {
|
||||||
return Action{
|
return Action{
|
||||||
Type: ActionSkip,
|
Type: ActionSkip,
|
||||||
Source: src,
|
Source: src,
|
||||||
Destination: dst,
|
Target: dst,
|
||||||
Description: "source file not found",
|
Description: "source file not found",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -60,7 +86,7 @@ func planFileCopy(src, dst string, force bool) Action {
|
||||||
return Action{
|
return Action{
|
||||||
Type: ActionBackup,
|
Type: ActionBackup,
|
||||||
Source: src,
|
Source: src,
|
||||||
Destination: dst,
|
Target: dst,
|
||||||
Description: "destination exists, will backup and overwrite",
|
Description: "destination exists, will backup and overwrite",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -68,7 +94,7 @@ func planFileCopy(src, dst string, force bool) Action {
|
||||||
return Action{
|
return Action{
|
||||||
Type: ActionCopy,
|
Type: ActionCopy,
|
||||||
Source: src,
|
Source: src,
|
||||||
Destination: dst,
|
Target: dst,
|
||||||
Description: "copy file",
|
Description: "copy file",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -91,7 +117,7 @@ func planDirCopy(srcDir, dstDir string, force bool) ([]Action, error) {
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
actions = append(actions, Action{
|
actions = append(actions, Action{
|
||||||
Type: ActionCreateDir,
|
Type: ActionCreateDir,
|
||||||
Destination: dst,
|
Target: dst,
|
||||||
Description: "create directory",
|
Description: "create directory",
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -104,3 +130,33 @@ func planDirCopy(srcDir, dstDir string, force bool) ([]Action, error) {
|
||||||
|
|
||||||
return actions, err
|
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
|
||||||
|
}
|
||||||
195
pkg/migrate/internal/common_test.go
Normal file
195
pkg/migrate/internal/common_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
52
pkg/migrate/internal/types.go
Normal file
52
pkg/migrate/internal/types.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -2,53 +2,73 @@ package migrate
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"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 (
|
const (
|
||||||
ActionCopy ActionType = iota
|
ActionCopy = internal.ActionCopy
|
||||||
ActionSkip
|
ActionSkip = internal.ActionSkip
|
||||||
ActionBackup
|
ActionBackup = internal.ActionBackup
|
||||||
ActionConvertConfig
|
ActionConvertConfig = internal.ActionConvertConfig
|
||||||
ActionCreateDir
|
ActionCreateDir = internal.ActionCreateDir
|
||||||
ActionMergeConfig
|
ActionMergeConfig = internal.ActionMergeConfig
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct {
|
type MigrateInstance struct {
|
||||||
DryRun bool
|
options Options
|
||||||
ConfigOnly bool
|
handlers map[string]Operation
|
||||||
WorkspaceOnly bool
|
|
||||||
Force bool
|
|
||||||
Refresh bool
|
|
||||||
OpenClawHome string
|
|
||||||
PicoClawHome string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Action struct {
|
func NewMigrateInstance(opts Options) *MigrateInstance {
|
||||||
Type ActionType
|
instance := &MigrateInstance{
|
||||||
Source string
|
options: opts,
|
||||||
Destination string
|
handlers: make(map[string]Operation),
|
||||||
Description string
|
}
|
||||||
|
|
||||||
|
openclaw_handler, err := openclaw.NewOpenclawHandler(opts)
|
||||||
|
if err == nil {
|
||||||
|
instance.Register(openclaw_handler.GetSourceName(), openclaw_handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance
|
||||||
}
|
}
|
||||||
|
|
||||||
type Result struct {
|
func (m *MigrateInstance) Register(moduleName string, module Operation) {
|
||||||
FilesCopied int
|
m.handlers[moduleName] = module
|
||||||
FilesSkipped int
|
|
||||||
BackupsCreated int
|
|
||||||
ConfigMigrated bool
|
|
||||||
DirsCreated int
|
|
||||||
Warnings []string
|
|
||||||
Errors []error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
if opts.ConfigOnly && opts.WorkspaceOnly {
|
||||||
return nil, fmt.Errorf("--config-only and --workspace-only are mutually exclusive")
|
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
|
opts.WorkspaceOnly = true
|
||||||
}
|
}
|
||||||
|
|
||||||
openclawHome, err := resolveOpenClawHome(opts.OpenClawHome)
|
sourceHome, err := handler.GetSourceHome()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
picoClawHome, err := resolvePicoClawHome(opts.PicoClawHome)
|
targetHome, err := internal.ResolveTargetHome(opts.TargetHome)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err = os.Stat(openclawHome); os.IsNotExist(err) {
|
if _, err = os.Stat(sourceHome); os.IsNotExist(err) {
|
||||||
return nil, fmt.Errorf("OpenClaw installation not found at %s", openclawHome)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Migrating from OpenClaw to PicoClaw")
|
fmt.Println("Migrating from Source to PicoClaw")
|
||||||
fmt.Printf(" Source: %s\n", openclawHome)
|
fmt.Printf(" Source: %s\n", sourceHome)
|
||||||
fmt.Printf(" Destination: %s\n", picoClawHome)
|
fmt.Printf(" Target: %s\n", targetHome)
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
if opts.DryRun {
|
if opts.DryRun {
|
||||||
|
|
@ -95,19 +115,23 @@ func Run(opts Options) (*Result, error) {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
}
|
}
|
||||||
|
|
||||||
result := Execute(actions, openclawHome, picoClawHome)
|
result := m.Execute(actions, sourceHome, targetHome)
|
||||||
result.Warnings = warnings
|
result.Warnings = warnings
|
||||||
return result, nil
|
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 actions []Action
|
||||||
var warnings []string
|
var warnings []string
|
||||||
|
handler, err := m.getCurrentHandler()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
force := opts.Force || opts.Refresh
|
force := opts.Force || opts.Refresh
|
||||||
|
|
||||||
if !opts.WorkspaceOnly {
|
if !opts.WorkspaceOnly {
|
||||||
configPath, err := findOpenClawConfig(openclawHome)
|
configPath, err := handler.GetSourceConfigFile()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if opts.ConfigOnly {
|
if opts.ConfigOnly {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
|
|
@ -117,91 +141,95 @@ func Plan(opts Options, openclawHome, picoClawHome string) ([]Action, []string,
|
||||||
actions = append(actions, Action{
|
actions = append(actions, Action{
|
||||||
Type: ActionConvertConfig,
|
Type: ActionConvertConfig,
|
||||||
Source: configPath,
|
Source: configPath,
|
||||||
Destination: filepath.Join(picoClawHome, "config.json"),
|
Target: filepath.Join(targetHome, "config.json"),
|
||||||
Description: "convert OpenClaw config to PicoClaw format",
|
Description: "convert Source config to PicoClaw format",
|
||||||
})
|
})
|
||||||
|
|
||||||
data, err := LoadOpenClawConfig(configPath)
|
|
||||||
if err == nil {
|
|
||||||
_, configWarnings, _ := ConvertConfig(data)
|
|
||||||
warnings = append(warnings, configWarnings...)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !opts.ConfigOnly {
|
if !opts.ConfigOnly {
|
||||||
srcWorkspace := resolveWorkspace(openclawHome)
|
srcWorkspace, err := handler.GetSourceWorkspace()
|
||||||
dstWorkspace := resolveWorkspace(picoClawHome)
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("getting source workspace: %w", err)
|
||||||
|
}
|
||||||
|
dstWorkspace := internal.ResolveWorkspace(targetHome)
|
||||||
|
|
||||||
if _, err := os.Stat(srcWorkspace); err == nil {
|
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 {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("planning workspace migration: %w", err)
|
return nil, nil, fmt.Errorf("planning workspace migration: %w", err)
|
||||||
}
|
}
|
||||||
actions = append(actions, wsActions...)
|
actions = append(actions, wsActions...)
|
||||||
} else {
|
} 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
|
return actions, warnings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Execute(actions []Action, openclawHome, picoClawHome string) *Result {
|
func (m *MigrateInstance) Execute(actions []Action, sourceHome, targetHome string) *Result {
|
||||||
result := &Result{}
|
result := &Result{}
|
||||||
|
handler, err := m.getCurrentHandler()
|
||||||
|
if err != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
for _, action := range actions {
|
for _, action := range actions {
|
||||||
switch action.Type {
|
switch action.Type {
|
||||||
case ActionConvertConfig:
|
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))
|
result.Errors = append(result.Errors, fmt.Errorf("config migration: %w", err))
|
||||||
fmt.Printf(" ✗ Config migration failed: %v\n", err)
|
fmt.Printf(" ✗ Config migration failed: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
result.ConfigMigrated = true
|
result.ConfigMigrated = true
|
||||||
fmt.Printf(" ✓ Converted config: %s\n", action.Destination)
|
fmt.Printf(" ✓ Converted config: %s\n", action.Target)
|
||||||
}
|
}
|
||||||
case ActionCreateDir:
|
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)
|
result.Errors = append(result.Errors, err)
|
||||||
} else {
|
} else {
|
||||||
result.DirsCreated++
|
result.DirsCreated++
|
||||||
}
|
}
|
||||||
case ActionBackup:
|
case ActionBackup:
|
||||||
bakPath := action.Destination + ".bak"
|
bakPath := action.Target + ".bak"
|
||||||
if err := copyFile(action.Destination, bakPath); err != nil {
|
if err := internal.CopyFile(action.Target, bakPath); err != nil {
|
||||||
result.Errors = append(result.Errors, fmt.Errorf("backup %s: %w", action.Destination, err))
|
result.Errors = append(result.Errors, fmt.Errorf("backup %s: %w", action.Target, err))
|
||||||
fmt.Printf(" ✗ Backup failed: %s\n", action.Destination)
|
fmt.Printf(" ✗ Backup failed: %s\n", action.Target)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result.BackupsCreated++
|
result.BackupsCreated++
|
||||||
fmt.Printf(
|
fmt.Printf(
|
||||||
" ✓ Backed up %s -> %s.bak\n",
|
" ✓ Backed up %s -> %s.bak\n",
|
||||||
filepath.Base(action.Destination),
|
filepath.Base(action.Target),
|
||||||
filepath.Base(action.Destination),
|
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)
|
result.Errors = append(result.Errors, err)
|
||||||
continue
|
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))
|
result.Errors = append(result.Errors, fmt.Errorf("copy %s: %w", action.Source, err))
|
||||||
fmt.Printf(" ✗ Copy failed: %s\n", action.Source)
|
fmt.Printf(" ✗ Copy failed: %s\n", action.Source)
|
||||||
} else {
|
} else {
|
||||||
result.FilesCopied++
|
result.FilesCopied++
|
||||||
fmt.Printf(" ✓ Copied %s\n", relPath(action.Source, openclawHome))
|
fmt.Printf(" ✓ Copied %s\n", internal.RelPath(action.Source, sourceHome))
|
||||||
}
|
}
|
||||||
case ActionCopy:
|
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)
|
result.Errors = append(result.Errors, err)
|
||||||
continue
|
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))
|
result.Errors = append(result.Errors, fmt.Errorf("copy %s: %w", action.Source, err))
|
||||||
fmt.Printf(" ✗ Copy failed: %s\n", action.Source)
|
fmt.Printf(" ✗ Copy failed: %s\n", action.Source)
|
||||||
} else {
|
} else {
|
||||||
result.FilesCopied++
|
result.FilesCopied++
|
||||||
fmt.Printf(" ✓ Copied %s\n", relPath(action.Source, openclawHome))
|
fmt.Printf(" ✓ Copied %s\n", internal.RelPath(action.Source, sourceHome))
|
||||||
}
|
}
|
||||||
case ActionSkip:
|
case ActionSkip:
|
||||||
result.FilesSkipped++
|
result.FilesSkipped++
|
||||||
|
|
@ -211,31 +239,6 @@ func Execute(actions []Action, openclawHome, picoClawHome string) *Result {
|
||||||
return 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 {
|
func Confirm() bool {
|
||||||
fmt.Print("Proceed with migration? (y/n): ")
|
fmt.Print("Proceed with migration? (y/n): ")
|
||||||
var response string
|
var response string
|
||||||
|
|
@ -243,49 +246,7 @@ func Confirm() bool {
|
||||||
return strings.ToLower(strings.TrimSpace(response)) == "y"
|
return strings.ToLower(strings.TrimSpace(response)) == "y"
|
||||||
}
|
}
|
||||||
|
|
||||||
func PrintPlan(actions []Action, warnings []string) {
|
func (m *MigrateInstance) PrintSummary(result *Result) {
|
||||||
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) {
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
parts := []string{}
|
parts := []string{}
|
||||||
if result.FilesCopied > 0 {
|
if result.FilesCopied > 0 {
|
||||||
|
|
@ -316,83 +277,44 @@ func PrintSummary(result *Result) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveOpenClawHome(override string) (string, error) {
|
func PrintPlan(actions []Action, warnings []string) {
|
||||||
if override != "" {
|
fmt.Println("Planned actions:")
|
||||||
return expandHome(override), nil
|
copies := 0
|
||||||
}
|
skips := 0
|
||||||
if envHome := os.Getenv("OPENCLAW_HOME"); envHome != "" {
|
backups := 0
|
||||||
return expandHome(envHome), nil
|
configCount := 0
|
||||||
}
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("resolving home directory: %w", err)
|
|
||||||
}
|
|
||||||
return filepath.Join(home, ".openclaw"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolvePicoClawHome(override string) (string, error) {
|
for _, action := range actions {
|
||||||
if override != "" {
|
switch action.Type {
|
||||||
return expandHome(override), nil
|
case ActionConvertConfig:
|
||||||
}
|
fmt.Printf(" [config] %s -> %s\n", action.Source, action.Target)
|
||||||
if envHome := os.Getenv("PICOCLAW_HOME"); envHome != "" {
|
configCount++
|
||||||
return expandHome(envHome), nil
|
case ActionCopy:
|
||||||
}
|
fmt.Printf(" [copy] %s\n", filepath.Base(action.Source))
|
||||||
home, err := os.UserHomeDir()
|
copies++
|
||||||
if err != nil {
|
case ActionBackup:
|
||||||
return "", fmt.Errorf("resolving home directory: %w", err)
|
fmt.Printf(" [backup] %s (exists, will backup and overwrite)\n", filepath.Base(action.Target))
|
||||||
}
|
backups++
|
||||||
return filepath.Join(home, ".picoclaw"), nil
|
copies++
|
||||||
}
|
case ActionSkip:
|
||||||
|
if action.Description != "" {
|
||||||
func resolveWorkspace(homeDir string) string {
|
fmt.Printf(" [skip] %s (%s)\n", filepath.Base(action.Source), action.Description)
|
||||||
return filepath.Join(homeDir, "workspace")
|
}
|
||||||
}
|
skips++
|
||||||
|
case ActionCreateDir:
|
||||||
func expandHome(path string) string {
|
fmt.Printf(" [mkdir] %s\n", action.Target)
|
||||||
if path == "" {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
if path[0] == '~' {
|
|
||||||
home, _ := os.UserHomeDir()
|
|
||||||
if len(path) > 1 && path[1] == '/' {
|
|
||||||
return home + path[1:]
|
|
||||||
}
|
}
|
||||||
return home
|
|
||||||
}
|
}
|
||||||
return path
|
|
||||||
}
|
if len(warnings) > 0 {
|
||||||
|
fmt.Println()
|
||||||
func backupFile(path string) error {
|
fmt.Println("Warnings:")
|
||||||
bakPath := path + ".bak"
|
for _, w := range warnings {
|
||||||
return copyFile(path, bakPath)
|
fmt.Printf(" - %s\n", w)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
func copyFile(src, dst string) error {
|
|
||||||
srcFile, err := os.Open(src)
|
fmt.Println()
|
||||||
if err != nil {
|
fmt.Printf("%d files to copy, %d configs to convert, %d backups needed, %d skipped\n",
|
||||||
return err
|
copies, configCount, backups, skips)
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
29
pkg/migrate/sources/openclaw/common.go
Normal file
29
pkg/migrate/sources/openclaw/common.go
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
1074
pkg/migrate/sources/openclaw/openclaw_config.go
Normal file
1074
pkg/migrate/sources/openclaw/openclaw_config.go
Normal file
File diff suppressed because it is too large
Load diff
714
pkg/migrate/sources/openclaw/openclaw_config_test.go
Normal file
714
pkg/migrate/sources/openclaw/openclaw_config_test.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
148
pkg/migrate/sources/openclaw/openclaw_handler.go
Normal file
148
pkg/migrate/sources/openclaw/openclaw_handler.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
247
pkg/migrate/sources/openclaw/openclaw_handler_test.go
Normal file
247
pkg/migrate/sources/openclaw/openclaw_handler_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue