// Package client implements the employee side: config loading, work-repo // pull, inventory, manifest and deployment into the OpenCode target. package client import ( "encoding/json" "fmt" "os" "path/filepath" "runtime" "strings" "github.com/m3tam3re/agent-lib/internal/gitsource" ) // ClientConfig is the client configuration for sync: fleet-provided or // personal, discovered via the search chain or overridden inline. type ClientConfig struct { RepoURL string `json:"repo_url"` Ref string `json:"ref,omitempty"` Token string `json:"token,omitempty"` TokenUser string `json:"token_user,omitempty"` } // ConfigEnv names the override environment variables. const ( ConfigEnv = "AGENT_LIB_CONFIG" StateDirEnv = "AGENT_LIB_STATE_DIR" ) // DefaultConfigPath returns the admin-protected config location per platform: // ProgramData on Windows, /etc on Linux/macOS. func DefaultConfigPath() string { if runtime.GOOS == "windows" { if pd := os.Getenv("ProgramData"); pd != "" { return filepath.Join(pd, "agent-lib", "config.json") } return `C:\ProgramData\agent-lib\config.json` } return "/etc/agent-lib/config.json" } // UserConfigPath returns the user-level config location per platform: // XDG_CONFIG_HOME or ~/.config on Linux/macOS, AppData on Windows. func UserConfigPath() string { if runtime.GOOS == "windows" { if ad := os.Getenv("AppData"); ad != "" { return filepath.Join(ad, "agent-lib", "config.json") } if home, err := os.UserHomeDir(); err == nil { return filepath.Join(home, "AppData", "Roaming", "agent-lib", "config.json") } return "" } if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { return filepath.Join(xdg, "agent-lib", "config.json") } home, err := os.UserHomeDir() if err != nil { return "" } return filepath.Join(home, ".config", "agent-lib", "config.json") } // configCandidate is one link in the config search chain. type configCandidate struct { path string desc string } // ConfigSearchChain returns the ordered locations a client config is looked // up in: the --config flag, the AGENT_LIB_CONFIG env, the user-level config // and the admin-protected platform default. The flag and env entries are // only included when actually set. func ConfigSearchChain(flagPath string) []configCandidate { chain := make([]configCandidate, 0, 4) if flagPath != "" { chain = append(chain, configCandidate{path: flagPath, desc: "--config flag"}) } if envPath := os.Getenv(ConfigEnv); envPath != "" { chain = append(chain, configCandidate{path: envPath, desc: ConfigEnv + " env"}) } if p := UserConfigPath(); p != "" { chain = append(chain, configCandidate{path: p, desc: "user config"}) } chain = append(chain, configCandidate{path: DefaultConfigPath(), desc: "admin default"}) return chain } // MissingConfigError reports that no client config exists anywhere in the // search chain — carrying the searched locations and the way out. type MissingConfigError struct { Chain []configCandidate } func (e *MissingConfigError) Error() string { var b strings.Builder b.WriteString("no client config found. Searched (in order):\n") for _, c := range e.Chain { fmt.Fprintf(&b, " - %s (%s)\n", c.path, c.desc) } b.WriteString("\nCreate one — minimal working example:\n\n") b.WriteString(` {"repo_url": "https://git.example.com/company/agent-content.git", "ref": "main"}` + "\n") if p := UserConfigPath(); p != "" { fmt.Fprintf(&b, "\nat %s (personal) or %s (fleet rollout).\n", p, DefaultConfigPath()) } else { fmt.Fprintf(&b, "\nat %s (fleet rollout).\n", DefaultConfigPath()) } b.WriteString("\nOr skip the config entirely:\n\n agent-lib sync --repo [--ref ]\n") return b.String() } // LoadClientConfig resolves and reads the client config via the search // chain: --config flag, AGENT_LIB_CONFIG env, user-level config, admin // default — first existing file wins. Explicit flag/env paths that do not // exist are hard errors; a completely empty chain yields *MissingConfigError. func LoadClientConfig(flagPath string) (*ClientConfig, error) { path, ok, err := resolveConfigPath(flagPath) if err != nil { return nil, err } if !ok { return nil, &MissingConfigError{Chain: ConfigSearchChain(flagPath)} } return readClientConfig(path) } // LoadOptionalConfig behaves like LoadClientConfig but returns (nil, nil) // when no config exists anywhere — for runs that carry their own --repo. // Malformed existing configs are still hard errors. func LoadOptionalConfig(flagPath string) (*ClientConfig, error) { path, ok, err := resolveConfigPath(flagPath) if err != nil { return nil, err } if !ok { return nil, nil } return readClientConfig(path) } // resolveConfigPath walks the search chain and returns the config to read. // The flag and env legs must exist when set (explicit intent); the user and // admin legs are skipped when absent. func resolveConfigPath(flagPath string) (path string, found bool, err error) { if flagPath != "" { return flagPath, true, nil } if envPath := os.Getenv(ConfigEnv); envPath != "" { return envPath, true, nil } if p := UserConfigPath(); p != "" { if _, statErr := os.Stat(p); statErr == nil { return p, true, nil } } admin := DefaultConfigPath() if _, statErr := os.Stat(admin); statErr == nil { return admin, true, nil } return "", false, nil } func readClientConfig(path string) (*ClientConfig, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("reading client config %s: %w", path, err) } var cfg ClientConfig if err := json.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("client config %s is not valid JSON: %w", path, err) } if cfg.RepoURL == "" { return nil, fmt.Errorf("client config %s: repo_url is required", path) } return &cfg, nil } // FillTokenAuth completes the config's credentials from the environment: // AGENT_LIB_GIT_TOKEN / AGENT_LIB_GIT_USER are only consulted when the // discovered config carries no token — env never overrides a config token. func (c *ClientConfig) FillTokenAuth() { if c == nil || c.Token != "" { return } if token := os.Getenv(gitsource.TokenEnv); token != "" { c.Token = token c.TokenUser = os.Getenv(gitsource.UserEnv) } } // StateDir returns the client state directory: the override env, then the // platform default (LocalAppData on Windows, XDG state elsewhere). func StateDir() (string, error) { if d := os.Getenv(StateDirEnv); d != "" { return d, nil } if runtime.GOOS == "windows" { base := os.Getenv("LocalAppData") if base == "" { return "", fmt.Errorf("LocalAppData is not set") } return filepath.Join(base, "agent-lib"), nil } if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" { return filepath.Join(xdg, "agent-lib"), nil } home, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(home, ".local", "state", "agent-lib"), nil } // StatePaths bundles the well-known files inside the state dir. type StatePaths struct { Root string CacheDir string Manifest string LogFile string } func ResolveStatePaths() (StatePaths, error) { root, err := StateDir() if err != nil { return StatePaths{}, err } return StatePaths{ Root: root, CacheDir: filepath.Join(root, "work-repo"), Manifest: filepath.Join(root, "manifest.json"), LogFile: filepath.Join(root, "sync.log"), }, nil }