From f4ec7103fe34560579d63cddedfd5f923b269d9c Mon Sep 17 00:00:00 2001 From: ZanzyTHEbar Date: Thu, 19 Feb 2026 12:44:09 +0000 Subject: [PATCH] feat(config): add XDG platform path helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ConfigDir, DataDir, CacheDir, DefaultDBPath, DefaultConfigPath to pkg/config — following XDG Base Directory spec on Linux, and the stdlib os.UserConfigDir / os.UserCacheDir platform conventions on macOS/Windows. All four directory helpers call MkdirAll(0700) so callers never need to pre-create directories. DefaultDBPath returns /picoclaw.db and DefaultConfigPath returns /config.json. Wire into cmd/picoclaw: - getConfigPath() now delegates to config.DefaultConfigPath() with a legacy fallback (~/.picoclaw/config.json) for systems where XDG fails - setupSecureBus() resolves secrets.json via config.ConfigDir() with the same fallback, removing the hard-coded os.UserHomeDir + join pattern Extracted and extended from Budgetsmith internal/infrastructure/config/paths.go. --- cmd/picoclaw/main.go | 17 ++++++++--- pkg/config/config.go | 73 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 2f55fba22..5694ef62e 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -1533,6 +1533,10 @@ func daemonStatus() { } func getConfigPath() string { + if p, err := config.DefaultConfigPath(); err == nil { + return p + } + // Fallback: legacy ~/.picoclaw/config.json for systems where XDG resolution fails. home, _ := os.UserHomeDir() return filepath.Join(home, ".picoclaw", "config.json") } @@ -1568,13 +1572,18 @@ func loadConfig() (*config.Config, error) { // returns without attaching the bus — the agent continues in direct-execution mode. // The returned closer must be called on shutdown when the bus is non-nil. func setupSecureBus(agentLoop *agent.AgentLoop) (closer func()) { - home, err := os.UserHomeDir() + cfgDir, err := config.ConfigDir() if err != nil { - logger.WarnC("itr", "SecureBus: cannot determine home dir — running without ITR") - return func() {} + // Fallback to ~/.picoclaw if XDG resolution fails. + home, herr := os.UserHomeDir() + if herr != nil { + logger.WarnC("itr", "SecureBus: cannot determine config dir — running without ITR") + return func() {} + } + cfgDir = filepath.Join(home, ".picoclaw") } - secretsPath := filepath.Join(home, ".picoclaw", "secrets.json") + secretsPath := filepath.Join(cfgDir, "secrets.json") // Use NoopKeyring by default; EnvKeyring when PICOCLAW_MASTER_KEY is set. var keyring security.KeyringProvider diff --git a/pkg/config/config.go b/pkg/config/config.go index 5d69916ba..659dffb6e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -602,3 +602,76 @@ func expandHome(path string) string { } return path } + +// ─── XDG / platform path helpers ───────────────────────────────────────────── + +const appName = "picoclaw" + +// ConfigDir returns the platform-appropriate user configuration directory for +// picoclaw, following XDG Base Directory spec on Linux +// (~/.config/picoclaw), Library/Application Support on macOS, and +// %AppData%\picoclaw on Windows. The directory is created if it does not exist. +func ConfigDir() (string, error) { + base, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("resolve user config dir: %w", err) + } + dir := filepath.Join(base, appName) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("create config dir %q: %w", dir, err) + } + return dir, nil +} + +// DataDir returns the platform-appropriate user data directory for picoclaw. +// On Linux this is ~/.local/share/picoclaw (XDG_DATA_HOME); on macOS and +// Windows it falls back to the same base as ConfigDir. The directory is +// created if it does not exist. +func DataDir() (string, error) { + // XDG_DATA_HOME is Linux-standard; os.UserHomeDir gives us the root we need. + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home dir: %w", err) + } + dir := filepath.Join(home, ".local", "share", appName) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("create data dir %q: %w", dir, err) + } + return dir, nil +} + +// CacheDir returns the platform-appropriate user cache directory for picoclaw +// (XDG_CACHE_HOME on Linux → ~/.cache/picoclaw). The directory is created if +// it does not exist. +func CacheDir() (string, error) { + base, err := os.UserCacheDir() + if err != nil { + return "", fmt.Errorf("resolve user cache dir: %w", err) + } + dir := filepath.Join(base, appName) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("create cache dir %q: %w", dir, err) + } + return dir, nil +} + +// DefaultDBPath returns the canonical SQLite database path inside DataDir. +// Callers that want to override this should check for a CLI flag or the +// PICOCLAW_DB_PATH environment variable before falling back to this value. +func DefaultDBPath() (string, error) { + dataDir, err := DataDir() + if err != nil { + return "", err + } + return filepath.Join(dataDir, appName+".db"), nil +} + +// DefaultConfigPath returns the path to the primary JSON config file inside +// ConfigDir (picoclaw/config.json). +func DefaultConfigPath() (string, error) { + cfgDir, err := ConfigDir() + if err != nil { + return "", err + } + return filepath.Join(cfgDir, "config.json"), nil +}