fix: unify runtime path resolution for procd services
Centralize config/auth/global skills path derivation with PICOCLAW_CONFIG and PICOCLAW_HOME precedence so non-interactive OpenWrt services load the intended files. Always apply env overrides even when the config file is missing, and add tests for global --config parsing plus resolver precedence. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
57dac394c5
commit
c34ce9d128
10 changed files with 357 additions and 22 deletions
|
|
@ -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 <command>")
|
||||
fmt.Println("Usage: picoclaw [--config <path>] <command>")
|
||||
fmt.Println()
|
||||
fmt.Println("Global options:")
|
||||
fmt.Println(" --config <path> 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 {
|
||||
|
|
|
|||
70
cmd/picoclaw/main_test.go
Normal file
70
cmd/picoclaw/main_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
31
pkg/agent/context_test.go
Normal file
31
pkg/agent/context_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -339,15 +339,14 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
if err := env.Parse(cfg); err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
49
pkg/config/paths.go
Normal file
49
pkg/config/paths.go
Normal file
|
|
@ -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"),
|
||||
}
|
||||
}
|
||||
74
pkg/config/paths_test.go
Normal file
74
pkg/config/paths_test.go
Normal file
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue