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:
Chun Hu 2026-02-17 14:13:07 +08:00
parent 57dac394c5
commit c34ce9d128
10 changed files with 357 additions and 22 deletions

View file

@ -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
View 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)
}
}

View file

@ -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
View 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)
}
}

View file

@ -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) {

View file

@ -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)
}
}

View file

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

View file

@ -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
View 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
View 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"))
}
}