diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 10b53948b..7e9e252b6 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -120,6 +120,20 @@ func copyDirectory(src, dst string) error { } func main() { + parsedArgs, configOverride, err := parseGlobalFlags(os.Args) + if err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } + + if configOverride != "" { + if err := os.Setenv(config.EnvPicoClawConfig, configOverride); err != nil { + fmt.Printf("Error setting %s: %v\n", config.EnvPicoClawConfig, err) + os.Exit(1) + } + } + os.Args = parsedArgs + if len(os.Args) < 2 { printHelp() os.Exit(1) @@ -158,10 +172,9 @@ func main() { workspace := cfg.WorkspacePath() installer := skills.NewSkillInstaller(workspace) - // 获取全局配置目录和内置 skills 目录 - globalDir := filepath.Dir(getConfigPath()) - globalSkillsDir := filepath.Join(globalDir, "skills") - builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills") + paths := config.ResolveRuntimePaths() + globalSkillsDir := paths.GlobalSkillsDir + builtinSkillsDir := filepath.Join(paths.HomeDir, "picoclaw", "skills") skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir) switch subcommand { @@ -200,9 +213,45 @@ func main() { } } +func parseGlobalFlags(args []string) ([]string, string, error) { + if len(args) == 0 { + return args, "", nil + } + + filtered := make([]string, 0, len(args)) + filtered = append(filtered, args[0]) + var configOverride string + + for i := 1; i < len(args); i++ { + arg := args[i] + + switch { + case arg == "--config": + if i+1 >= len(args) || strings.TrimSpace(args[i+1]) == "" { + return nil, "", fmt.Errorf("--config requires a path") + } + configOverride = args[i+1] + i++ + case strings.HasPrefix(arg, "--config="): + value := strings.TrimSpace(strings.TrimPrefix(arg, "--config=")) + if value == "" { + return nil, "", fmt.Errorf("--config requires a path") + } + configOverride = value + default: + filtered = append(filtered, arg) + } + } + + return filtered, configOverride, nil +} + func printHelp() { fmt.Printf("%s picoclaw - Personal AI Assistant v%s\n\n", logo, version) - fmt.Println("Usage: picoclaw ") + fmt.Println("Usage: picoclaw [--config ] ") + fmt.Println() + fmt.Println("Global options:") + fmt.Println(" --config Use a custom config file path") fmt.Println() fmt.Println("Commands:") fmt.Println(" onboard Initialize picoclaw configuration and workspace") @@ -983,8 +1032,7 @@ func authStatusCmd() { } func getConfigPath() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".picoclaw", "config.json") + return config.ResolveRuntimePaths().ConfigPath } func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool) *cron.CronService { diff --git a/cmd/picoclaw/main_test.go b/cmd/picoclaw/main_test.go new file mode 100644 index 000000000..5830d7c7f --- /dev/null +++ b/cmd/picoclaw/main_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestParseGlobalFlags_ConfigPair(t *testing.T) { + args := []string{"picoclaw", "--config", "/tmp/config.json", "gateway", "--debug"} + + filtered, override, err := parseGlobalFlags(args) + if err != nil { + t.Fatalf("parseGlobalFlags() error: %v", err) + } + if override != "/tmp/config.json" { + t.Errorf("override = %q, want %q", override, "/tmp/config.json") + } + + want := []string{"picoclaw", "gateway", "--debug"} + if !reflect.DeepEqual(filtered, want) { + t.Errorf("filtered args = %#v, want %#v", filtered, want) + } +} + +func TestParseGlobalFlags_ConfigEqualsSyntax(t *testing.T) { + args := []string{"picoclaw", "--config=/tmp/config.json", "status"} + + filtered, override, err := parseGlobalFlags(args) + if err != nil { + t.Fatalf("parseGlobalFlags() error: %v", err) + } + if override != "/tmp/config.json" { + t.Errorf("override = %q, want %q", override, "/tmp/config.json") + } + + want := []string{"picoclaw", "status"} + if !reflect.DeepEqual(filtered, want) { + t.Errorf("filtered args = %#v, want %#v", filtered, want) + } +} + +func TestParseGlobalFlags_MissingValue(t *testing.T) { + tests := [][]string{ + {"picoclaw", "--config"}, + {"picoclaw", "--config", ""}, + {"picoclaw", "--config= "}, + } + + for _, tt := range tests { + _, _, err := parseGlobalFlags(tt) + if err == nil { + t.Errorf("parseGlobalFlags(%#v) expected error, got nil", tt) + } + } +} + +func TestParseGlobalFlags_DoesNotConsumeCronShortFlag(t *testing.T) { + args := []string{"picoclaw", "cron", "add", "-c", "* * * * *"} + + filtered, override, err := parseGlobalFlags(args) + if err != nil { + t.Fatalf("parseGlobalFlags() error: %v", err) + } + if override != "" { + t.Errorf("override = %q, want empty", override) + } + if !reflect.DeepEqual(filtered, args) { + t.Errorf("filtered args = %#v, want %#v", filtered, args) + } +} diff --git a/pkg/agent/context.go b/pkg/agent/context.go index cf5ce2913..4025f8690 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/skills" @@ -22,11 +23,7 @@ type ContextBuilder struct { } func getGlobalConfigDir() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return filepath.Join(home, ".picoclaw") + return config.ResolveRuntimePaths().HomeDir } func NewContextBuilder(workspace string) *ContextBuilder { diff --git a/pkg/agent/context_test.go b/pkg/agent/context_test.go new file mode 100644 index 000000000..00eb5235e --- /dev/null +++ b/pkg/agent/context_test.go @@ -0,0 +1,31 @@ +package agent + +import ( + "path/filepath" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestGetGlobalConfigDir_UsesPicoClawHomeOverride(t *testing.T) { + homeOverride := filepath.Join(t.TempDir(), "pico-home") + t.Setenv(config.EnvPicoClawConfig, "") + t.Setenv(config.EnvPicoClawHome, homeOverride) + + if got := getGlobalConfigDir(); got != homeOverride { + t.Errorf("getGlobalConfigDir() = %q, want %q", got, homeOverride) + } +} + +func TestGetGlobalConfigDir_ConfigOverrideTakesPrecedence(t *testing.T) { + homeOverride := filepath.Join(t.TempDir(), "pico-home") + configDir := filepath.Join(t.TempDir(), "custom-config-dir") + configPath := filepath.Join(configDir, "config.json") + + t.Setenv(config.EnvPicoClawHome, homeOverride) + t.Setenv(config.EnvPicoClawConfig, configPath) + + if got := getGlobalConfigDir(); got != configDir { + t.Errorf("getGlobalConfigDir() = %q, want %q", got, configDir) + } +} diff --git a/pkg/auth/store.go b/pkg/auth/store.go index 20724929a..0bf622a2e 100644 --- a/pkg/auth/store.go +++ b/pkg/auth/store.go @@ -5,6 +5,8 @@ import ( "os" "path/filepath" "time" + + "github.com/sipeed/picoclaw/pkg/config" ) type AuthCredential struct { @@ -35,8 +37,7 @@ func (c *AuthCredential) NeedsRefresh() bool { } func authFilePath() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".picoclaw", "auth.json") + return config.ResolveRuntimePaths().AuthPath } func LoadStore() (*AuthStore, error) { diff --git a/pkg/auth/store_test.go b/pkg/auth/store_test.go index d96b460a1..57228f497 100644 --- a/pkg/auth/store_test.go +++ b/pkg/auth/store_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "testing" "time" + + "github.com/sipeed/picoclaw/pkg/config" ) func TestAuthCredentialIsExpired(t *testing.T) { @@ -102,7 +104,7 @@ func TestStoreFilePermissions(t *testing.T) { t.Fatalf("SetCredential() error: %v", err) } - path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + path := config.ResolveRuntimePaths().AuthPath info, err := os.Stat(path) if err != nil { t.Fatalf("Stat() error: %v", err) @@ -187,3 +189,53 @@ func TestLoadStoreEmpty(t *testing.T) { t.Errorf("expected empty credentials, got %d", len(store.Credentials)) } } + +func TestStoreUsesPicoClawHomeOverride(t *testing.T) { + baseDir := t.TempDir() + home := filepath.Join(baseDir, "home") + override := filepath.Join(baseDir, "pico-home") + + t.Setenv("HOME", home) + t.Setenv(config.EnvPicoClawConfig, "") + t.Setenv(config.EnvPicoClawHome, override) + + cred := &AuthCredential{ + AccessToken: "override-token", + Provider: "openai", + AuthMethod: "oauth", + } + if err := SetCredential("openai", cred); err != nil { + t.Fatalf("SetCredential() error: %v", err) + } + + expectedPath := filepath.Join(override, "auth.json") + if _, err := os.Stat(expectedPath); err != nil { + t.Fatalf("expected auth store at %s, got stat error: %v", expectedPath, err) + } +} + +func TestStoreUsesConfigDirectoryWhenConfigOverrideSet(t *testing.T) { + baseDir := t.TempDir() + home := filepath.Join(baseDir, "home") + homeOverride := filepath.Join(baseDir, "pico-home") + configDir := filepath.Join(baseDir, "custom-config-dir") + configPath := filepath.Join(configDir, "config.json") + + t.Setenv("HOME", home) + t.Setenv(config.EnvPicoClawHome, homeOverride) + t.Setenv(config.EnvPicoClawConfig, configPath) + + cred := &AuthCredential{ + AccessToken: "config-override-token", + Provider: "openai", + AuthMethod: "oauth", + } + if err := SetCredential("openai", cred); err != nil { + t.Fatalf("SetCredential() error: %v", err) + } + + expectedPath := filepath.Join(configDir, "auth.json") + if _, err := os.Stat(expectedPath); err != nil { + t.Fatalf("expected auth store at %s, got stat error: %v", expectedPath, err) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index d189ff00b..edb3893be 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -339,14 +339,13 @@ func LoadConfig(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { - if os.IsNotExist(err) { - return cfg, nil + if !os.IsNotExist(err) { + return nil, err + } + } else { + if err := json.Unmarshal(data, cfg); err != nil { + return nil, err } - return nil, err - } - - if err := json.Unmarshal(data, cfg); err != nil { - return nil, err } if err := env.Parse(cfg); err != nil { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index febfd0456..d2c23d696 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -204,3 +204,17 @@ func TestConfig_Complete(t *testing.T) { t.Error("Heartbeat should be enabled by default") } } + +func TestLoadConfig_AppliesEnvWithoutConfigFile(t *testing.T) { + t.Setenv("PICOCLAW_AGENTS_DEFAULTS_MODEL", "env-only-model") + + missingPath := filepath.Join(t.TempDir(), "missing-config.json") + cfg, err := LoadConfig(missingPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + + if cfg.Agents.Defaults.Model != "env-only-model" { + t.Errorf("Model = %q, want %q", cfg.Agents.Defaults.Model, "env-only-model") + } +} diff --git a/pkg/config/paths.go b/pkg/config/paths.go new file mode 100644 index 000000000..a67a17360 --- /dev/null +++ b/pkg/config/paths.go @@ -0,0 +1,49 @@ +package config + +import ( + "os" + "path/filepath" + "strings" +) + +const ( + EnvPicoClawConfig = "PICOCLAW_CONFIG" + EnvPicoClawHome = "PICOCLAW_HOME" +) + +type RuntimePaths struct { + HomeDir string + ConfigPath string + AuthPath string + GlobalSkillsDir string +} + +func ResolveRuntimePaths() RuntimePaths { + if configPath := expandHome(strings.TrimSpace(os.Getenv(EnvPicoClawConfig))); configPath != "" { + return buildRuntimePaths(filepath.Dir(configPath), configPath) + } + + homeDir := expandHome(strings.TrimSpace(os.Getenv(EnvPicoClawHome))) + if homeDir == "" { + homeDir = defaultPicoClawHome() + } + + return buildRuntimePaths(homeDir, filepath.Join(homeDir, "config.json")) +} + +func defaultPicoClawHome() string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return ".picoclaw" + } + return filepath.Join(home, ".picoclaw") +} + +func buildRuntimePaths(homeDir, configPath string) RuntimePaths { + return RuntimePaths{ + HomeDir: homeDir, + ConfigPath: configPath, + AuthPath: filepath.Join(homeDir, "auth.json"), + GlobalSkillsDir: filepath.Join(homeDir, "skills"), + } +} diff --git a/pkg/config/paths_test.go b/pkg/config/paths_test.go new file mode 100644 index 000000000..d307a66f9 --- /dev/null +++ b/pkg/config/paths_test.go @@ -0,0 +1,74 @@ +package config + +import ( + "path/filepath" + "testing" +) + +func TestResolveRuntimePaths_Default(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv(EnvPicoClawConfig, "") + t.Setenv(EnvPicoClawHome, "") + + paths := ResolveRuntimePaths() + wantHome := filepath.Join(home, ".picoclaw") + + if paths.HomeDir != wantHome { + t.Errorf("HomeDir = %q, want %q", paths.HomeDir, wantHome) + } + if paths.ConfigPath != filepath.Join(wantHome, "config.json") { + t.Errorf("ConfigPath = %q, want %q", paths.ConfigPath, filepath.Join(wantHome, "config.json")) + } + if paths.AuthPath != filepath.Join(wantHome, "auth.json") { + t.Errorf("AuthPath = %q, want %q", paths.AuthPath, filepath.Join(wantHome, "auth.json")) + } + if paths.GlobalSkillsDir != filepath.Join(wantHome, "skills") { + t.Errorf("GlobalSkillsDir = %q, want %q", paths.GlobalSkillsDir, filepath.Join(wantHome, "skills")) + } +} + +func TestResolveRuntimePaths_UsesPicoClawHomeOverride(t *testing.T) { + homeOverride := filepath.Join(t.TempDir(), "pico-home") + t.Setenv(EnvPicoClawConfig, "") + t.Setenv(EnvPicoClawHome, homeOverride) + + paths := ResolveRuntimePaths() + + if paths.HomeDir != homeOverride { + t.Errorf("HomeDir = %q, want %q", paths.HomeDir, homeOverride) + } + if paths.ConfigPath != filepath.Join(homeOverride, "config.json") { + t.Errorf("ConfigPath = %q, want %q", paths.ConfigPath, filepath.Join(homeOverride, "config.json")) + } + if paths.AuthPath != filepath.Join(homeOverride, "auth.json") { + t.Errorf("AuthPath = %q, want %q", paths.AuthPath, filepath.Join(homeOverride, "auth.json")) + } + if paths.GlobalSkillsDir != filepath.Join(homeOverride, "skills") { + t.Errorf("GlobalSkillsDir = %q, want %q", paths.GlobalSkillsDir, filepath.Join(homeOverride, "skills")) + } +} + +func TestResolveRuntimePaths_ConfigOverrideTakesPrecedence(t *testing.T) { + homeOverride := filepath.Join(t.TempDir(), "pico-home") + configDir := filepath.Join(t.TempDir(), "custom-config-dir") + configPath := filepath.Join(configDir, "config.json") + + t.Setenv(EnvPicoClawHome, homeOverride) + t.Setenv(EnvPicoClawConfig, configPath) + + paths := ResolveRuntimePaths() + + if paths.ConfigPath != configPath { + t.Errorf("ConfigPath = %q, want %q", paths.ConfigPath, configPath) + } + if paths.HomeDir != configDir { + t.Errorf("HomeDir = %q, want %q", paths.HomeDir, configDir) + } + if paths.AuthPath != filepath.Join(configDir, "auth.json") { + t.Errorf("AuthPath = %q, want %q", paths.AuthPath, filepath.Join(configDir, "auth.json")) + } + if paths.GlobalSkillsDir != filepath.Join(configDir, "skills") { + t.Errorf("GlobalSkillsDir = %q, want %q", paths.GlobalSkillsDir, filepath.Join(configDir, "skills")) + } +}