From b84efbb3c4c739d1e6bb98e5b6dfdf634828aeb1 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Thu, 5 Mar 2026 20:33:02 +0100 Subject: [PATCH] feat: respect XDG Base Directory Specification for config, data, and cache Implement XDG Base Directory Specification support so that cagent stores files in the standard platform directories instead of everything under ~/.cagent: - Config: $XDG_CONFIG_HOME/cagent (default ~/.config/cagent) - Data: $XDG_DATA_HOME/cagent (default ~/.local/share/cagent) - Cache: $XDG_CACHE_HOME/cagent (default ~/.cache/cagent) For backward compatibility, if the legacy ~/.cagent directory exists and the new XDG directory does not, the legacy path is used automatically. Changes: - pkg/paths: Rewrite GetDataDir, GetConfigDir, GetCacheDir to resolve XDG paths with legacy fallback - pkg/modelsdev: Use paths.GetCacheDir() instead of hardcoded ~/.cagent - pkg/content: Use paths.GetDataDir() instead of hardcoded ~/.cagent/store - pkg/gateway: Use paths.GetCacheDir() instead of hardcoded ~/.cagent - pkg/history: Use paths.GetDataDir() instead of hardcoded ~/.cagent; WithBaseDir now takes a data dir directly - cmd/root: Session DB default now uses paths.GetDataDir() - Updated help text and comments to reference XDG dirs Closes #1638 Assisted-By: cagent --- cmd/root/acp.go | 2 +- cmd/root/root.go | 8 +-- cmd/root/run.go | 2 +- pkg/content/store.go | 9 +-- pkg/gateway/catalog.go | 8 +-- pkg/history/history.go | 23 ++++--- pkg/history/history_test.go | 6 +- pkg/modelsdev/store.go | 9 +-- pkg/paths/paths.go | 110 ++++++++++++++++++++++++-------- pkg/paths/paths_test.go | 68 ++++++++++++++++++++ pkg/toolinstall/paths.go | 2 +- pkg/tui/styles/theme.go | 8 +-- pkg/tui/styles/theme_watcher.go | 4 +- pkg/userconfig/userconfig.go | 2 +- 14 files changed, 189 insertions(+), 72 deletions(-) diff --git a/cmd/root/acp.go b/cmd/root/acp.go index 3a7945626..4738deccb 100644 --- a/cmd/root/acp.go +++ b/cmd/root/acp.go @@ -30,7 +30,7 @@ func newACPCmd() *cobra.Command { RunE: flags.runACPCommand, } - cmd.Flags().StringVarP(&flags.sessionDB, "session-db", "s", filepath.Join(paths.GetHomeDir(), ".cagent", "session.db"), "Path to the session database") + cmd.Flags().StringVarP(&flags.sessionDB, "session-db", "s", filepath.Join(paths.GetDataDir(), "session.db"), "Path to the session database") addRuntimeConfigFlags(cmd, &flags.runConfig) return cmd diff --git a/cmd/root/root.go b/cmd/root/root.go index f1284ac18..3e1cea8e2 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -110,10 +110,10 @@ func NewRootCmd() *cobra.Command { // Add persistent debug flag available to all commands cmd.PersistentFlags().BoolVarP(&flags.debugMode, "debug", "d", false, "Enable debug logging") cmd.PersistentFlags().BoolVarP(&flags.enableOtel, "otel", "o", false, "Enable OpenTelemetry tracing") - cmd.PersistentFlags().StringVar(&flags.logFilePath, "log-file", "", "Path to debug log file (default: ~/.cagent/cagent.debug.log; only used with --debug)") - cmd.PersistentFlags().StringVar(&flags.cacheDir, "cache-dir", "", "Override the cache directory (default: ~/Library/Caches/cagent on macOS)") - cmd.PersistentFlags().StringVar(&flags.configDir, "config-dir", "", "Override the config directory (default: ~/.config/cagent)") - cmd.PersistentFlags().StringVar(&flags.dataDir, "data-dir", "", "Override the data directory (default: ~/.cagent)") + cmd.PersistentFlags().StringVar(&flags.logFilePath, "log-file", "", "Path to debug log file (default: /cagent.debug.log; only used with --debug)") + cmd.PersistentFlags().StringVar(&flags.cacheDir, "cache-dir", "", "Override the cache directory") + cmd.PersistentFlags().StringVar(&flags.configDir, "config-dir", "", "Override the config directory") + cmd.PersistentFlags().StringVar(&flags.dataDir, "data-dir", "", "Override the data directory") cmd.AddCommand(newVersionCmd()) cmd.AddCommand(newRunCmd()) diff --git a/cmd/root/run.go b/cmd/root/run.go index 9150f6e00..aba043287 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -98,7 +98,7 @@ func addRunOrExecFlags(cmd *cobra.Command, flags *runExecFlags) { cmd.PersistentFlags().BoolVar(&flags.dryRun, "dry-run", false, "Initialize the agent without executing anything") cmd.PersistentFlags().StringVar(&flags.remoteAddress, "remote", "", "Use remote runtime with specified address") cmd.PersistentFlags().BoolVar(&flags.connectRPC, "connect-rpc", false, "Use Connect-RPC protocol for remote communication (requires --remote)") - cmd.PersistentFlags().StringVarP(&flags.sessionDB, "session-db", "s", filepath.Join(paths.GetHomeDir(), ".cagent", "session.db"), "Path to the session database") + cmd.PersistentFlags().StringVarP(&flags.sessionDB, "session-db", "s", filepath.Join(paths.GetDataDir(), "session.db"), "Path to the session database") cmd.PersistentFlags().StringVar(&flags.sessionID, "session", "", "Continue from a previous session by ID or relative offset (e.g., -1 for last session)") cmd.PersistentFlags().StringVar(&flags.fakeResponses, "fake", "", "Replay AI responses from cassette file (for testing)") cmd.PersistentFlags().IntVar(&flags.fakeStreamDelay, "fake-stream", 0, "Simulate streaming with delay in ms between chunks (default 15ms if no value given)") diff --git a/pkg/content/store.go b/pkg/content/store.go index 657b85bf4..9cc6d75f4 100644 --- a/pkg/content/store.go +++ b/pkg/content/store.go @@ -16,6 +16,8 @@ import ( "github.com/google/go-containerregistry/pkg/crane" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/tarball" + + "github.com/docker/cagent/pkg/paths" ) // ErrStoreCorrupted indicates that the local artifact store is in an @@ -55,12 +57,7 @@ func NewStore(opts ...Opt) (*Store, error) { } if store.baseDir == "" { - homeDir, err := os.UserHomeDir() - if err != nil { - return nil, fmt.Errorf("getting home directory: %w", err) - } - - store.baseDir = filepath.Join(homeDir, ".cagent", "store") + store.baseDir = filepath.Join(paths.GetDataDir(), "store") } if err := os.MkdirAll(store.baseDir, 0o755); err != nil { diff --git a/pkg/gateway/catalog.go b/pkg/gateway/catalog.go index bf9b807b8..35c3aacdd 100644 --- a/pkg/gateway/catalog.go +++ b/pkg/gateway/catalog.go @@ -11,6 +11,8 @@ import ( "strings" "sync" "time" + + "github.com/docker/cagent/pkg/paths" ) const ( @@ -169,11 +171,7 @@ func refreshCatalogFromNetwork() bool { } func getCacheFilePath() string { - homeDir, err := os.UserHomeDir() - if err != nil { - return "" - } - return filepath.Join(homeDir, ".cagent", catalogCacheFileName) + return filepath.Join(paths.GetCacheDir(), catalogCacheFileName) } func loadCatalogFromCache(cacheFile string) (Catalog, time.Duration, error) { diff --git a/pkg/history/history.go b/pkg/history/history.go index 1200de810..0229c6dc3 100644 --- a/pkg/history/history.go +++ b/pkg/history/history.go @@ -7,6 +7,8 @@ import ( "path/filepath" "slices" "strings" + + "github.com/docker/cagent/pkg/paths" ) type History struct { @@ -17,14 +19,14 @@ type History struct { } type options struct { - homeDir string + dataDir string } type Opt func(*options) func WithBaseDir(dir string) Opt { return func(o *options) { - o.homeDir = dir + o.dataDir = dir } } @@ -34,20 +36,17 @@ func New(opts ...Opt) (*History, error) { opt(o) } - homeDir := o.homeDir - if homeDir == "" { - var err error - if homeDir, err = os.UserHomeDir(); err != nil { - return nil, err - } + dataDir := o.dataDir + if dataDir == "" { + dataDir = paths.GetDataDir() } h := &History{ - path: filepath.Join(homeDir, ".cagent", "history"), + path: filepath.Join(dataDir, "history"), current: -1, } - if err := h.migrateOldHistory(homeDir); err != nil { + if err := h.migrateOldHistory(dataDir); err != nil { return nil, err } @@ -58,8 +57,8 @@ func New(opts ...Opt) (*History, error) { return h, nil } -func (h *History) migrateOldHistory(homeDir string) error { - oldPath := filepath.Join(homeDir, ".cagent", "history.json") +func (h *History) migrateOldHistory(dataDir string) error { + oldPath := filepath.Join(dataDir, "history.json") data, err := os.ReadFile(oldPath) if os.IsNotExist(err) { diff --git a/pkg/history/history_test.go b/pkg/history/history_test.go index d12b9cc95..f05fd429b 100644 --- a/pkg/history/history_test.go +++ b/pkg/history/history_test.go @@ -141,10 +141,10 @@ func TestHistory_MultilineMessage(t *testing.T) { func TestHistory_MigrateOldFormat(t *testing.T) { tmpDir := t.TempDir() - err := os.MkdirAll(filepath.Join(tmpDir, ".cagent"), 0o755) + err := os.MkdirAll(tmpDir, 0o755) require.NoError(t, err) - oldHistFile := filepath.Join(tmpDir, ".cagent", "history.json") - newHistFile := filepath.Join(tmpDir, ".cagent", "history") + oldHistFile := filepath.Join(tmpDir, "history.json") + newHistFile := filepath.Join(tmpDir, "history") require.NoError(t, os.WriteFile(oldHistFile, []byte(`{"messages":["old1","old2","old3"]}`), 0o644)) diff --git a/pkg/modelsdev/store.go b/pkg/modelsdev/store.go index b92534575..3e57bf773 100644 --- a/pkg/modelsdev/store.go +++ b/pkg/modelsdev/store.go @@ -12,6 +12,8 @@ import ( "strings" "sync" "time" + + "github.com/docker/cagent/pkg/paths" ) const ( @@ -31,12 +33,7 @@ type Store struct { // NewStore creates a new models.dev store. // The database is loaded on first access via GetDatabase. func NewStore() (*Store, error) { - homeDir, err := os.UserHomeDir() - if err != nil { - return nil, fmt.Errorf("failed to get user home directory: %w", err) - } - - cacheDir := filepath.Join(homeDir, ".cagent") + cacheDir := paths.GetCacheDir() if err := os.MkdirAll(cacheDir, 0o755); err != nil { return nil, fmt.Errorf("failed to create cache directory: %w", err) } diff --git a/pkg/paths/paths.go b/pkg/paths/paths.go index 2047bb4c0..721af09f6 100644 --- a/pkg/paths/paths.go +++ b/pkg/paths/paths.go @@ -51,19 +51,16 @@ func SetDataDir(dir string) { dataDirOverride.Set(dir) } // // If an override has been set via [SetCacheDir] it is returned instead. // -// On Linux this follows XDG: $XDG_CACHE_HOME/cagent (default ~/.cache/cagent). -// On macOS this uses ~/Library/Caches/cagent. -// On Windows this uses %LocalAppData%/cagent. +// The default location follows the XDG Base Directory Specification: +// - $XDG_CACHE_HOME/cagent (Linux, default ~/.cache/cagent) +// - ~/Library/Caches/cagent (macOS) +// - %LocalAppData%/cagent (Windows) // -// If the cache directory cannot be determined, it falls back to a directory -// under the system temporary directory. +// For backward compatibility, if the legacy ~/.cagent directory exists and +// the XDG directory does not, the legacy path is used instead. func GetCacheDir() string { return cacheDirOverride.get(func() string { - cacheDir, err := os.UserCacheDir() - if err != nil { - return filepath.Clean(filepath.Join(os.TempDir(), ".cagent-cache")) - } - return filepath.Clean(filepath.Join(cacheDir, "cagent")) + return resolveWithLegacyFallback(xdgCacheDir()) }) } @@ -71,32 +68,36 @@ func GetCacheDir() string { // // If an override has been set via [SetConfigDir] it is returned instead. // -// If the home directory cannot be determined, it falls back to a directory -// under the system temporary directory. This is a best-effort fallback and -// not intended to be a security boundary. +// The default location is the OS-standard user config directory +// (as returned by [os.UserConfigDir]) with a "cagent" subdirectory: +// - $XDG_CONFIG_HOME/cagent on Linux (default ~/.config/cagent) +// - ~/Library/Application Support/cagent on macOS +// - %AppData%/cagent on Windows +// +// For backward compatibility, if the legacy ~/.cagent directory exists and +// the standard directory does not, the legacy path is used instead. func GetConfigDir() string { return configDirOverride.get(func() string { - homeDir, err := os.UserHomeDir() - if err != nil { - return filepath.Clean(filepath.Join(os.TempDir(), ".cagent-config")) - } - return filepath.Clean(filepath.Join(homeDir, ".config", "cagent")) + return resolveWithLegacyFallback(xdgConfigDir()) }) } -// GetDataDir returns the user's data directory for cagent (caches, content, logs). +// GetDataDir returns the user's data directory for cagent (sessions, history, +// installed tools, OCI store, etc.). // // If an override has been set via [SetDataDir] it is returned instead. // -// If the home directory cannot be determined, it falls back to a directory -// under the system temporary directory. +// The default location follows the XDG Base Directory Specification on Linux: +// - $XDG_DATA_HOME/cagent (default ~/.local/share/cagent) +// +// On macOS and Windows the same Linux-style path is used for consistency +// (~/.local/share/cagent), since Go does not provide an os.UserDataDir. +// +// For backward compatibility, if the legacy ~/.cagent directory exists and +// the XDG directory does not, the legacy path is used instead. func GetDataDir() string { return dataDirOverride.get(func() string { - homeDir, err := os.UserHomeDir() - if err != nil { - return filepath.Clean(filepath.Join(os.TempDir(), ".cagent")) - } - return filepath.Clean(filepath.Join(homeDir, ".cagent")) + return resolveWithLegacyFallback(xdgDataDir()) }) } @@ -110,3 +111,60 @@ func GetHomeDir() string { } return filepath.Clean(homeDir) } + +// --- XDG directory helpers --- + +func xdgCacheDir() string { + cacheDir, err := os.UserCacheDir() + if err != nil { + return filepath.Join(os.TempDir(), ".cagent-cache") + } + return filepath.Join(cacheDir, "cagent") +} + +func xdgConfigDir() string { + configDir, err := os.UserConfigDir() + if err != nil { + return filepath.Join(os.TempDir(), ".cagent-config") + } + return filepath.Join(configDir, "cagent") +} + +func xdgDataDir() string { + if dir := os.Getenv("XDG_DATA_HOME"); dir != "" { + return filepath.Join(dir, "cagent") + } + homeDir, err := os.UserHomeDir() + if err != nil { + return filepath.Join(os.TempDir(), ".cagent") + } + return filepath.Join(homeDir, ".local", "share", "cagent") +} + +// --- Legacy fallback --- + +// resolveWithLegacyFallback returns the legacy ~/.cagent path when it exists +// and xdgDir does not yet exist, preserving data for existing users. +// Otherwise it returns xdgDir. +func resolveWithLegacyFallback(xdgDir string) string { + if legacy := legacyDir(); legacy != "" && dirExists(legacy) && !dirExists(xdgDir) { + return filepath.Clean(legacy) + } + return filepath.Clean(xdgDir) +} + +// legacyDir returns the legacy ~/.cagent directory path, or empty string +// if the home directory cannot be determined. +func legacyDir() string { + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(homeDir, ".cagent") +} + +// dirExists reports whether dir exists and is a directory. +func dirExists(dir string) bool { + info, err := os.Stat(dir) + return err == nil && info.IsDir() +} diff --git a/pkg/paths/paths_test.go b/pkg/paths/paths_test.go index 6f2b7a098..95bb5e4d8 100644 --- a/pkg/paths/paths_test.go +++ b/pkg/paths/paths_test.go @@ -1,9 +1,12 @@ package paths_test import ( + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/docker/cagent/pkg/paths" ) @@ -47,3 +50,68 @@ func TestGetHomeDir(t *testing.T) { assert.NotEmpty(t, paths.GetHomeDir()) } + +func TestXDGDirs(t *testing.T) { + tmpDir := t.TempDir() + + t.Setenv("HOME", tmpDir) + t.Setenv("XDG_DATA_HOME", filepath.Join(tmpDir, "xdg")) + + // DataDir always respects XDG_DATA_HOME on all platforms. + paths.SetDataDir("") + assert.Equal(t, filepath.Join(tmpDir, "xdg", "cagent"), paths.GetDataDir()) + + // ConfigDir uses os.UserConfigDir which respects XDG_CONFIG_HOME + // only on Linux. On macOS it returns ~/Library/Application Support. + paths.SetConfigDir("") + expectedConfigDir, err := os.UserConfigDir() + require.NoError(t, err) + assert.Equal(t, filepath.Join(expectedConfigDir, "cagent"), paths.GetConfigDir()) +} + +func TestLegacyFallback(t *testing.T) { + tests := []struct { + name string + set func(string) + get func() string + }{ + {"DataDir", paths.SetDataDir, paths.GetDataDir}, + {"ConfigDir", paths.SetConfigDir, paths.GetConfigDir}, + {"CacheDir", paths.SetCacheDir, paths.GetCacheDir}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + + // Create legacy ~/.cagent dir, but no XDG dir. + legacyDir := filepath.Join(tmpDir, ".cagent") + require.NoError(t, os.MkdirAll(legacyDir, 0o755)) + + t.Setenv("HOME", tmpDir) + // Force XDG vars to a non-existent path so the fallback triggers. + t.Setenv("XDG_DATA_HOME", filepath.Join(tmpDir, "nonexistent")) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "nonexistent")) + tt.set("") // clear any override + + assert.Equal(t, legacyDir, tt.get()) + }) + } +} + +func TestXDGOverridesLegacy_WhenBothExist(t *testing.T) { + tmpDir := t.TempDir() + + // Create both legacy and XDG dirs. + legacyDir := filepath.Join(tmpDir, ".cagent") + xdgDataDir := filepath.Join(tmpDir, "xdg_data", "cagent") + require.NoError(t, os.MkdirAll(legacyDir, 0o755)) + require.NoError(t, os.MkdirAll(xdgDataDir, 0o755)) + + t.Setenv("HOME", tmpDir) + t.Setenv("XDG_DATA_HOME", filepath.Join(tmpDir, "xdg_data")) + paths.SetDataDir("") // clear any override + + // When both exist, XDG wins. + assert.Equal(t, xdgDataDir, paths.GetDataDir()) +} diff --git a/pkg/toolinstall/paths.go b/pkg/toolinstall/paths.go index 4f7373a50..26f7cb13b 100644 --- a/pkg/toolinstall/paths.go +++ b/pkg/toolinstall/paths.go @@ -9,7 +9,7 @@ import ( ) // ToolsDir returns the base directory for installed tools. -// Checks DOCKER_AGENT_TOOLS_DIR env var, defaults to ~/.cagent/tools/ +// Checks DOCKER_AGENT_TOOLS_DIR env var, defaults to /tools/ func ToolsDir() string { if dir := os.Getenv("DOCKER_AGENT_TOOLS_DIR"); dir != "" { return filepath.Clean(dir) diff --git a/pkg/tui/styles/theme.go b/pkg/tui/styles/theme.go index a90eec3a9..34a47dbd8 100644 --- a/pkg/tui/styles/theme.go +++ b/pkg/tui/styles/theme.go @@ -216,7 +216,7 @@ func DefaultTheme() *Theme { const UserThemePrefix = "user:" // ListThemeRefs returns the list of available theme references. -// It includes all built-in themes (including "default") and user themes from ~/.cagent/themes/. +// It includes all built-in themes (including "default") and user themes from /themes/. // User themes with names matching built-in themes are prefixed with "user:" to distinguish them. // The "default" theme is always listed first for UX purposes. func ListThemeRefs() ([]string, error) { @@ -292,15 +292,15 @@ func listBuiltinThemeRefs() ([]string, error) { return refs, nil } -// listUserThemeRefs returns the list of user theme references from ~/.cagent/themes/. +// listUserThemeRefs returns the list of user theme references from /themes/. func listUserThemeRefs() ([]string, error) { return listThemeRefsFrom(ThemesDir()) } // UserThemeExists returns true if a user theme file exists for the given ref -// in the user themes directory (typically ~/.cagent/themes/). +// in the user themes directory (typically /themes/). // -// This handles the "user:" prefix - "user:nord" checks for ~/.cagent/themes/nord.yaml. +// This handles the "user:" prefix - "user:nord" checks for /themes/nord.yaml. func UserThemeExists(ref string) bool { if ref == "" { return false diff --git a/pkg/tui/styles/theme_watcher.go b/pkg/tui/styles/theme_watcher.go index 8b6377401..37e9db5f7 100644 --- a/pkg/tui/styles/theme_watcher.go +++ b/pkg/tui/styles/theme_watcher.go @@ -37,8 +37,8 @@ func NewThemeWatcher(onThemeChanged func(themeRef string)) *ThemeWatcher { } // Watch starts watching the theme file for the given theme reference. -// Only watches if a user theme file exists for this ref (in ~/.cagent/themes/). -// Handles "user:" prefix - e.g., "user:nord" watches ~/.cagent/themes/nord.yaml. +// Only watches if a user theme file exists for this ref (in /themes/). +// Handles "user:" prefix - e.g., "user:nord" watches /themes/nord.yaml. // If the theme is the built-in default or no user file exists, no watching occurs. func (tw *ThemeWatcher) Watch(themeRef string) error { tw.mu.Lock() diff --git a/pkg/userconfig/userconfig.go b/pkg/userconfig/userconfig.go index 6e8910942..c4489a8f4 100644 --- a/pkg/userconfig/userconfig.go +++ b/pkg/userconfig/userconfig.go @@ -45,7 +45,7 @@ type Settings struct { // Defaults to true when not set. SplitDiffView *bool `yaml:"split_diff_view,omitempty"` // Theme is the default theme reference (e.g., "dark", "light") - // Theme files are loaded from ~/.cagent/themes/.yaml + // Theme files are loaded from /themes/.yaml Theme string `yaml:"theme,omitempty"` // YOLO enables auto-approve mode for all tool calls globally YOLO bool `yaml:"YOLO,omitempty"`