feat(client): config search chain, --repo zero-config override, actionable missing-config error

sync/status now resolve the work repository from a fallback chain:
--repo flag > --config flag > AGENT_LIB_CONFIG env > user-level config
(XDG / AppData) > admin platform default. --repo works without any
config file; tokens fall back to AGENT_LIB_GIT_TOKEN/AGENT_LIB_GIT_USER
without ever overriding a configured token. A completely empty chain
yields an error listing every searched location, a minimal config
example and the --repo shortcut.

Closes beads: agent-lib-9o8
This commit is contained in:
2026-08-23 15:08:27 +02:00
parent 1b2fcb6fbb
commit 7070d8cc25
7 changed files with 627 additions and 31 deletions
+32 -4
View File
@@ -123,6 +123,9 @@ agent-lib sync # pull the work repository and deploy
agent-lib status # report local state; exit 1 when updates are blocked agent-lib status # report local state; exit 1 when updates are blocked
``` ```
Both accept `--repo <url>` / `--ref <ref>` for config-free personal use —
see [Configuration](#configuration) below.
Deployment mapping (OpenCode target): Deployment mapping (OpenCode target):
| Type | Destination | | Type | Destination |
@@ -149,9 +152,22 @@ an unreachable repository never leaves a half-deployed machine.
### Configuration ### Configuration
The client config is admin-provided (fleet rollout), JSON at The repository to pull comes from the first source that provides one:
`C:\ProgramData\agent-lib\config.json` on Windows, `/etc/agent-lib/config.json`
elsewhere. Override per run with `--config` or `AGENT_LIB_CONFIG`: 1. `--repo <url>` flag (works with **no config file at all**)
2. `--config <path>` flag
3. `AGENT_LIB_CONFIG` env
4. user-level config (personal machines)
5. admin-protected platform default (fleet rollout)
| Source | Linux / macOS | Windows |
|---|---|---|
| user-level config | `$XDG_CONFIG_HOME/agent-lib/config.json` (default `~/.config/agent-lib/config.json`) | `%AppData%\agent-lib\config.json` |
| admin default | `/etc/agent-lib/config.json` | `%ProgramData%\agent-lib\config.json` |
`--repo` overrides a `repo_url` from any discovered config; `--ref` narrows it
to a branch or tag. When no config exists anywhere, the error lists every
searched location, a minimal working example, and the `--repo` shortcut.
```json ```json
{ {
@@ -163,7 +179,18 @@ elsewhere. Override per run with `--config` or `AGENT_LIB_CONFIG`:
``` ```
The token is used for basic auth only. It never appears in the lockfile, the The token is used for basic auth only. It never appears in the lockfile, the
manifest, logs or error messages. manifest, logs or error messages. On `--repo` runs (or when the discovered
config carries no token) `AGENT_LIB_GIT_TOKEN` / `AGENT_LIB_GIT_USER` supply
credentials instead — env never overrides a configured token.
**Personal quickstart (no fleet, no config):**
```sh
agent-lib sync --repo https://github.com/you/your-agent-content.git
```
The work repository must be a committed git repository (sync pulls it
read-only via go-git; local uncommitted state is ignored).
### Notifications ### Notifications
@@ -192,6 +219,7 @@ flag that drives the non-zero exit code — suitable for headless drift checks.
| Symptom | Cause / fix | | Symptom | Cause / fix |
|---|---| |---|---|
| `error: … no client config found. Searched (in order): …` | No config anywhere. Create one where the error suggests, or skip it: `agent-lib sync --repo <url>`. |
| `error: … include entry "x" not found upstream` | Typo, or the item was renamed/removed upstream. Fix the lockfile include list, or re-add with a corrected `--include`. | | `error: … include entry "x" not found upstream` | Typo, or the item was renamed/removed upstream. Fix the lockfile include list, or re-add with a corrected `--include`. |
| `error: … collides with own skills "x"` | Deployed-name collision. Add `--rename <upstream>=<deployed>` (add) or fix the rename map (update). | | `error: … collides with own skills "x"` | Deployed-name collision. Add `--rename <upstream>=<deployed>` (add) or fix the rename map (update). |
| `validate` reports tree/lockfile divergence | Someone edited `external/` by hand. Re-run `vendor update <source>` to restore the pinned state. | | `validate` reports tree/lockfile divergence | Someone edited `external/` by hand. Re-run `vendor update <source>` to restore the pinned state. |
+6 -3
View File
@@ -10,7 +10,7 @@ import (
) )
func newStatusCmd() *cobra.Command { func newStatusCmd() *cobra.Command {
var configPath string var configPath, repoURL, ref string
var asJSON bool var asJSON bool
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "status", Use: "status",
@@ -20,7 +20,8 @@ func newStatusCmd() *cobra.Command {
"agent-lib version is included in the report.", "agent-lib version is included in the report.",
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
report, err := client.Status(client.SyncOptions{ConfigPath: configPath}) opts := client.SyncOptions{ConfigPath: configPath, RepoURL: repoURL, Ref: ref}
report, err := client.Status(opts)
if err != nil { if err != nil {
return fmt.Errorf("status: %w", err) return fmt.Errorf("status: %w", err)
} }
@@ -37,7 +38,9 @@ func newStatusCmd() *cobra.Command {
return nil return nil
}, },
} }
cmd.Flags().StringVar(&configPath, "config", "", "client config path (default: "+client.ConfigEnv+" env or platform default)") cmd.Flags().StringVar(&configPath, "config", "", "client config path (default: "+client.ConfigEnv+" env, user config, or platform default)")
cmd.Flags().StringVar(&repoURL, "repo", "", "work repository URL or path — overrides any config, works without one")
cmd.Flags().StringVar(&ref, "ref", "", "branch or tag to pull (requires --repo)")
cmd.Flags().BoolVar(&asJSON, "json", false, "machine-readable output") cmd.Flags().BoolVar(&asJSON, "json", false, "machine-readable output")
return cmd return cmd
} }
+10 -6
View File
@@ -9,22 +9,26 @@ import (
) )
func newSyncCmd() *cobra.Command { func newSyncCmd() *cobra.Command {
var configPath string var configPath, repoURL, ref string
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "sync", Use: "sync",
Short: "Pull the work repository and deploy its content to OpenCode", Short: "Pull the work repository and deploy its content to OpenCode",
Long: "Pulls the fleet-configured work repository (config from --config, " + Long: "Pulls the work repository and deploys skills, commands and agents " +
"AGENT_LIB_CONFIG, or the admin-protected platform default) and deploys " + "into the OpenCode runtime paths. The repository comes from --repo, " +
"skills, commands and agents into the OpenCode runtime paths. " + "or from the config chain: --config, " + client.ConfigEnv + " env, the " +
"user-level config, or the admin-protected platform default. " +
"Idempotent: re-running changes nothing when content and disk match.", "Idempotent: re-running changes nothing when content and disk match.",
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if _, err := client.Sync(client.SyncOptions{ConfigPath: configPath}, cmd.OutOrStdout()); err != nil { opts := client.SyncOptions{ConfigPath: configPath, RepoURL: repoURL, Ref: ref}
if _, err := client.Sync(opts, cmd.OutOrStdout()); err != nil {
return fmt.Errorf("sync: %w", err) return fmt.Errorf("sync: %w", err)
} }
return nil return nil
}, },
} }
cmd.Flags().StringVar(&configPath, "config", "", "client config path (default: "+client.ConfigEnv+" env or platform default)") cmd.Flags().StringVar(&configPath, "config", "", "client config path (default: "+client.ConfigEnv+" env, user config, or platform default)")
cmd.Flags().StringVar(&repoURL, "repo", "", "work repository URL or path — overrides any config, works without one")
cmd.Flags().StringVar(&ref, "ref", "", "branch or tag to pull (requires --repo)")
return cmd return cmd
} }
+137 -14
View File
@@ -8,9 +8,13 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings"
"github.com/m3tam3re/agent-lib/internal/gitsource"
) )
// ClientConfig is the admin-protected fleet configuration for sync. // ClientConfig is the client configuration for sync: fleet-provided or
// personal, discovered via the search chain or overridden inline.
type ClientConfig struct { type ClientConfig struct {
RepoURL string `json:"repo_url"` RepoURL string `json:"repo_url"`
Ref string `json:"ref,omitempty"` Ref string `json:"ref,omitempty"`
@@ -22,12 +26,6 @@ type ClientConfig struct {
const ( const (
ConfigEnv = "AGENT_LIB_CONFIG" ConfigEnv = "AGENT_LIB_CONFIG"
StateDirEnv = "AGENT_LIB_STATE_DIR" StateDirEnv = "AGENT_LIB_STATE_DIR"
defaultConfig = `{
"repo_url": "",
"ref": "",
"token": "",
"token_user": ""
}`
) )
// DefaultConfigPath returns the admin-protected config location per platform: // DefaultConfigPath returns the admin-protected config location per platform:
@@ -42,16 +40,128 @@ func DefaultConfigPath() string {
return "/etc/agent-lib/config.json" return "/etc/agent-lib/config.json"
} }
// LoadClientConfig reads the config from flagPath, the override env, or the // UserConfigPath returns the user-level config location per platform:
// platform default — in that order. // 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 <url> [--ref <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) { func LoadClientConfig(flagPath string) (*ClientConfig, error) {
path := flagPath path, ok, err := resolveConfigPath(flagPath)
if path == "" { if err != nil {
path = os.Getenv(ConfigEnv) return nil, err
} }
if path == "" { if !ok {
path = DefaultConfigPath() 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) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, fmt.Errorf("reading client config %s: %w", path, err) return nil, fmt.Errorf("reading client config %s: %w", path, err)
@@ -66,6 +176,19 @@ func LoadClientConfig(flagPath string) (*ClientConfig, error) {
return &cfg, nil 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 // StateDir returns the client state directory: the override env, then the
// platform default (LocalAppData on Windows, XDG state elsewhere). // platform default (LocalAppData on Windows, XDG state elsewhere).
func StateDir() (string, error) { func StateDir() (string, error) {
+165
View File
@@ -0,0 +1,165 @@
package client
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func writeConfigFile(t *testing.T, path, repoURL string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
data, err := json.Marshal(map[string]string{"repo_url": repoURL, "ref": "main"})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
// runIsolated points HOME/XDG at empty temp dirs and clears the override env
// so chain tests never see the real machine's config.
func runIsolated(t *testing.T, fn func()) {
t.Helper()
base := t.TempDir()
home := filepath.Join(base, "home")
xdg := filepath.Join(base, "xdg-config")
for _, d := range []string{home, xdg} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", xdg)
t.Setenv(ConfigEnv, "")
fn()
}
func TestUserConfigPathHonorsXDG(t *testing.T) {
runIsolated(t, func() {
base := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", base)
want := filepath.Join(base, "agent-lib", "config.json")
if got := UserConfigPath(); got != want {
t.Errorf("UserConfigPath() = %q, want %q", got, want)
}
})
}
func TestLoadClientConfigChainOrder(t *testing.T) {
runIsolated(t, func() {
base := t.TempDir()
flagCfg := filepath.Join(base, "flag.json")
envCfg := filepath.Join(base, "env.json")
userCfg := filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "agent-lib", "config.json")
writeConfigFile(t, flagCfg, "https://flag.example/repo")
writeConfigFile(t, envCfg, "https://env.example/repo")
writeConfigFile(t, userCfg, "https://user.example/repo")
assertRepo := func(name, got, want string) {
t.Helper()
if got != want {
t.Errorf("%s: repo_url = %q, want %q", name, got, want)
}
}
cfg, err := LoadClientConfig(flagCfg)
if err != nil {
t.Fatal(err)
}
assertRepo("flag beats env and user", cfg.RepoURL, "https://flag.example/repo")
t.Setenv(ConfigEnv, envCfg)
cfg, err = LoadClientConfig("")
if err != nil {
t.Fatal(err)
}
assertRepo("env beats user", cfg.RepoURL, "https://env.example/repo")
t.Setenv(ConfigEnv, "")
cfg, err = LoadClientConfig("")
if err != nil {
t.Fatal(err)
}
assertRepo("user picked up without flags", cfg.RepoURL, "https://user.example/repo")
})
}
func TestLoadClientConfigMissingIsActionable(t *testing.T) {
runIsolated(t, func() {
_, err := LoadClientConfig("")
if err == nil {
t.Fatal("empty chain must fail")
}
var missing *MissingConfigError
if e, ok := err.(*MissingConfigError); !ok {
t.Fatalf("error must be MissingConfigError, got %T: %v", err, err)
} else {
missing = e
}
msg := missing.Error()
userPath := UserConfigPath()
for _, want := range []string{
"no client config found",
userPath,
DefaultConfigPath(),
`"repo_url"`,
"--repo",
} {
if !strings.Contains(msg, want) {
t.Errorf("error message must mention %q:\n%s", want, msg)
}
}
})
}
func TestLoadOptionalConfigNilWhenEmpty(t *testing.T) {
runIsolated(t, func() {
cfg, err := LoadOptionalConfig("")
if err != nil {
t.Fatalf("empty chain must be (nil, nil), got %v", err)
}
if cfg != nil {
t.Errorf("cfg must be nil, got %+v", cfg)
}
})
}
func TestLoadOptionalConfigMalformedStillFails(t *testing.T) {
runIsolated(t, func() {
userCfg := filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "agent-lib", "config.json")
writeConfigFile(t, userCfg, "")
if _, err := LoadOptionalConfig(""); err == nil {
t.Error("user config with empty repo_url must fail even in optional mode")
}
})
}
func TestLoadClientConfigExplicitFlagMustExist(t *testing.T) {
runIsolated(t, func() {
_, err := LoadClientConfig(filepath.Join(t.TempDir(), "nope.json"))
if err == nil || !strings.Contains(err.Error(), "nope.json") {
t.Errorf("explicit --config pointing nowhere must name the path, got %v", err)
}
})
}
func TestFillTokenAuthEnvOnlyFillsEmptyToken(t *testing.T) {
t.Setenv("AGENT_LIB_GIT_TOKEN", "env-token")
t.Setenv("AGENT_LIB_GIT_USER", "env-user")
fromEnv := &ClientConfig{}
fromEnv.FillTokenAuth()
if fromEnv.Token != "env-token" || fromEnv.TokenUser != "env-user" {
t.Errorf("empty token must be filled from env: %+v", fromEnv)
}
configured := &ClientConfig{Token: "config-token", TokenUser: "config-user"}
configured.FillTokenAuth()
if configured.Token != "config-token" || configured.TokenUser != "config-user" {
t.Errorf("env must never override a config token: %+v", configured)
}
}
+36 -2
View File
@@ -10,9 +10,13 @@ import (
"github.com/m3tam3re/agent-lib/internal/target" "github.com/m3tam3re/agent-lib/internal/target"
) )
// SyncOptions parameterize a client sync run. // SyncOptions parameterize a client sync run. RepoURL (with optional Ref)
// is the inline --repo override: it works without any config file and beats
// a repo_url discovered in the config chain.
type SyncOptions struct { type SyncOptions struct {
ConfigPath string ConfigPath string
RepoURL string
Ref string
} }
// prepared is everything sync and status share: the pulled repository, the // prepared is everything sync and status share: the pulled repository, the
@@ -28,7 +32,10 @@ type prepared struct {
// prepare pulls, inventories, hashes and plans — the complete read-only // prepare pulls, inventories, hashes and plans — the complete read-only
// front half of a client run. // front half of a client run.
func prepare(opts SyncOptions) (*prepared, error) { func prepare(opts SyncOptions) (*prepared, error) {
cfg, err := LoadClientConfig(opts.ConfigPath) if opts.RepoURL == "" && opts.Ref != "" {
return nil, fmt.Errorf("--ref requires --repo (it only overrides the repository to pull)")
}
cfg, err := resolveRunConfig(opts)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -82,6 +89,33 @@ func prepare(opts SyncOptions) (*prepared, error) {
return &prepared{paths: paths, pull: pull, manifest: manifest, items: items, plan: plan}, nil return &prepared{paths: paths, pull: pull, manifest: manifest, items: items, plan: plan}, nil
} }
// resolveRunConfig applies the --repo inline override to the config chain:
// with --repo, any discovered config only contributes auth (and ref, unless
// --ref is also given); without --repo, a config with a repo_url is required.
func resolveRunConfig(opts SyncOptions) (*ClientConfig, error) {
if opts.RepoURL == "" {
cfg, err := LoadClientConfig(opts.ConfigPath)
if err != nil {
return nil, err
}
cfg.FillTokenAuth()
return cfg, nil
}
cfg, err := LoadOptionalConfig(opts.ConfigPath)
if err != nil {
return nil, err
}
if cfg == nil {
cfg = &ClientConfig{}
}
cfg.RepoURL = opts.RepoURL
if opts.Ref != "" {
cfg.Ref = opts.Ref
}
cfg.FillTokenAuth()
return cfg, nil
}
// Sync pulls the work repository, plans the sync as a pure function over // Sync pulls the work repository, plans the sync as a pure function over
// (inventory, manifest, disk facts) and executes the plan. Validation and // (inventory, manifest, disk facts) and executes the plan. Validation and
// hashing happen before the first mutation; unreachable repositories fail // hashing happen before the first mutation; unreachable repositories fail
+239
View File
@@ -0,0 +1,239 @@
package e2e
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// configChainEnv isolates HOME/XDG/AGENT_LIB_* so config-chain e2e tests see
// only the fixture paths they explicitly create.
type configChainEnv struct {
home string
xdg string
stateDir string
}
func newConfigChainEnv(t *testing.T) *configChainEnv {
t.Helper()
base := t.TempDir()
env := &configChainEnv{
home: filepath.Join(base, "home"),
xdg: filepath.Join(base, "xdg"),
stateDir: filepath.Join(base, "state"),
}
for _, d := range []string{env.home, env.xdg, env.stateDir} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
return env
}
func (e *configChainEnv) run(t *testing.T, extraEnv map[string]string, args ...string) (string, error) {
t.Helper()
cmd := agentLibCmd(args...)
scrubbed := []string{}
for _, kv := range os.Environ() {
if strings.HasPrefix(kv, "AGENT_LIB_") || strings.HasPrefix(kv, "XDG_CONFIG_HOME=") || strings.HasPrefix(kv, "HOME=") {
continue
}
scrubbed = append(scrubbed, kv)
}
cmd.Env = append(scrubbed,
"HOME="+e.home, "USERPROFILE="+e.home,
"XDG_CONFIG_HOME="+e.xdg,
"AGENT_LIB_STATE_DIR="+e.stateDir,
)
for k, v := range extraEnv {
cmd.Env = append(cmd.Env, k+"="+v)
}
out, err := cmd.CombinedOutput()
return string(out), err
}
func (e *configChainEnv) userConfigPath() string {
return filepath.Join(e.xdg, "agent-lib", "config.json")
}
func (e *configChainEnv) writeUserConfig(t *testing.T, repoURL string) {
t.Helper()
writeJSONConfig(t, e.userConfigPath(), repoURL)
}
func writeJSONConfig(t *testing.T, path, repoURL string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
data, err := json.Marshal(map[string]string{"repo_url": repoURL, "ref": "main"})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
// buildNamedRepo creates a committed work repo whose skills/<id>/SKILL.md
// carries a distinctive id, so the deployed skill identifies the winning
// config source.
func buildNamedRepo(t *testing.T, h *harness, dir, skillID string) {
t.Helper()
h.git(t, dir, "init", "-b", "main")
p := filepath.Join(dir, "skills", skillID, "SKILL.md")
os.MkdirAll(filepath.Dir(p), 0o755)
os.WriteFile(p, []byte("---\nname: "+skillID+"\n---\n# "+skillID+"\n"), 0o644)
h.git(t, dir, "add", "-A")
h.git(t, dir, "commit", "-m", "repo with "+skillID)
}
func deployedSkillPath(home, skillID string) string {
return filepath.Join(home, ".agents", "skills", skillID, "SKILL.md")
}
func TestSyncRepoFlagZeroConfig(t *testing.T) {
h := newHarness(t)
env := newConfigChainEnv(t)
repo := filepath.Join(t.TempDir(), "work")
os.MkdirAll(repo, 0o755)
buildNamedRepo(t, h, repo, "zero-config-skill")
out, err := env.run(t, nil, "sync", "--repo", repo)
if err != nil {
t.Fatalf("zero-config sync via --repo failed: %v\n%s", err, out)
}
if _, err := os.Stat(deployedSkillPath(env.home, "zero-config-skill")); err != nil {
t.Error("--repo must deploy without any config file")
}
if _, err := os.Stat(filepath.Join(env.stateDir, "manifest.json")); err != nil {
t.Error("--repo run must write a manifest")
}
}
func TestSyncRepoFlagOverridesConfiguredRepo(t *testing.T) {
h := newHarness(t)
env := newConfigChainEnv(t)
repoA := filepath.Join(t.TempDir(), "repo-a")
repoB := filepath.Join(t.TempDir(), "repo-b")
os.MkdirAll(repoA, 0o755)
os.MkdirAll(repoB, 0o755)
buildNamedRepo(t, h, repoA, "from-config-skill")
buildNamedRepo(t, h, repoB, "from-flag-skill")
env.writeUserConfig(t, repoA)
out, err := env.run(t, nil, "sync", "--repo", repoB)
if err != nil {
t.Fatalf("sync with --repo over config failed: %v\n%s", err, out)
}
if _, err := os.Stat(deployedSkillPath(env.home, "from-flag-skill")); err != nil {
t.Error("--repo must beat the configured repo_url")
}
if _, err := os.Stat(deployedSkillPath(env.home, "from-config-skill")); err == nil {
t.Error("configured repo must not also deploy")
}
}
func TestSyncUserConfigDiscoveredWithoutFlags(t *testing.T) {
h := newHarness(t)
env := newConfigChainEnv(t)
repo := filepath.Join(t.TempDir(), "work")
os.MkdirAll(repo, 0o755)
buildNamedRepo(t, h, repo, "user-config-skill")
env.writeUserConfig(t, repo)
out, err := env.run(t, nil, "sync")
if err != nil {
t.Fatalf("sync with only a user-level config failed: %v\n%s", err, out)
}
if _, err := os.Stat(deployedSkillPath(env.home, "user-config-skill")); err != nil {
t.Error("user-level config must be picked up without flags or env")
}
}
func TestSyncConfigSearchOrder(t *testing.T) {
h := newHarness(t)
env := newConfigChainEnv(t)
flagRepo := filepath.Join(t.TempDir(), "flag-repo")
envRepo := filepath.Join(t.TempDir(), "env-repo")
os.MkdirAll(flagRepo, 0o755)
os.MkdirAll(envRepo, 0o755)
buildNamedRepo(t, h, flagRepo, "flag-wins-skill")
buildNamedRepo(t, h, envRepo, "env-wins-skill")
flagCfg := filepath.Join(t.TempDir(), "flag.json")
envCfg := filepath.Join(t.TempDir(), "env.json")
writeJSONConfig(t, flagCfg, flagRepo)
writeJSONConfig(t, envCfg, envRepo)
env.writeUserConfig(t, envRepo)
out, err := env.run(t, map[string]string{"AGENT_LIB_CONFIG": envCfg}, "sync", "--config", flagCfg)
if err != nil {
t.Fatalf("flag>env sync failed: %v\n%s", err, out)
}
if _, err := os.Stat(deployedSkillPath(env.home, "flag-wins-skill")); err != nil {
t.Error("--config flag must beat AGENT_LIB_CONFIG")
}
out, err = env.run(t, map[string]string{"AGENT_LIB_CONFIG": envCfg}, "sync")
if err != nil {
t.Fatalf("env>user sync failed: %v\n%s", err, out)
}
if _, err := os.Stat(deployedSkillPath(env.home, "env-wins-skill")); err != nil {
t.Error("AGENT_LIB_CONFIG must beat the user config")
}
}
func TestSyncMissingConfigErrorIsActionable(t *testing.T) {
newHarness(t)
env := newConfigChainEnv(t)
out, err := env.run(t, nil, "sync")
if err == nil {
t.Fatalf("sync without any config must fail:\n%s", out)
}
for _, want := range []string{
"no client config found",
"user config",
"admin default",
env.userConfigPath(),
`"repo_url"`,
"--repo",
} {
if !contains(out, want) {
t.Errorf("missing-config error must mention %q:\n%s", want, out)
}
}
}
func TestSyncRefWithoutRepoIsError(t *testing.T) {
newHarness(t)
env := newConfigChainEnv(t)
out, err := env.run(t, nil, "sync", "--ref", "main")
if err == nil {
t.Fatalf("--ref without --repo must fail:\n%s", out)
}
if !contains(out, "--ref requires --repo") {
t.Errorf("error must explain the --ref/--repo relationship:\n%s", out)
}
}
func TestStatusRepoFlagZeroConfig(t *testing.T) {
h := newHarness(t)
env := newConfigChainEnv(t)
repo := filepath.Join(t.TempDir(), "work")
os.MkdirAll(repo, 0o755)
buildNamedRepo(t, h, repo, "status-repo-skill")
if out, err := env.run(t, nil, "sync", "--repo", repo); err != nil {
t.Fatalf("sync failed: %v\n%s", err, out)
}
out, err := env.run(t, nil, "status", "--repo", repo, "--json")
if err != nil {
t.Fatalf("status --repo --json failed: %v\n%s", err, out)
}
if !contains(out, `"in_sync": true`) {
t.Errorf("status via --repo must report in sync:\n%s", out)
}
}