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