diff --git a/README.md b/README.md index 4eae34b..79f5a34 100644 --- a/README.md +++ b/README.md @@ -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 ``` +Both accept `--repo ` / `--ref ` for config-free personal use — +see [Configuration](#configuration) below. + Deployment mapping (OpenCode target): | Type | Destination | @@ -149,9 +152,22 @@ an unreachable repository never leaves a half-deployed machine. ### Configuration -The client config is admin-provided (fleet rollout), JSON at -`C:\ProgramData\agent-lib\config.json` on Windows, `/etc/agent-lib/config.json` -elsewhere. Override per run with `--config` or `AGENT_LIB_CONFIG`: +The repository to pull comes from the first source that provides one: + +1. `--repo ` flag (works with **no config file at all**) +2. `--config ` 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 { @@ -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 -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 @@ -192,6 +219,7 @@ flag that drives the non-zero exit code — suitable for headless drift checks. | 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 `. | | `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 =` (add) or fix the rename map (update). | | `validate` reports tree/lockfile divergence | Someone edited `external/` by hand. Re-run `vendor update ` to restore the pinned state. | diff --git a/internal/cli/status.go b/internal/cli/status.go index 6bceef8..02906f5 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -10,7 +10,7 @@ import ( ) func newStatusCmd() *cobra.Command { - var configPath string + var configPath, repoURL, ref string var asJSON bool cmd := &cobra.Command{ Use: "status", @@ -20,7 +20,8 @@ func newStatusCmd() *cobra.Command { "agent-lib version is included in the report.", Args: cobra.NoArgs, 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 { return fmt.Errorf("status: %w", err) } @@ -37,7 +38,9 @@ func newStatusCmd() *cobra.Command { 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") return cmd } diff --git a/internal/cli/sync.go b/internal/cli/sync.go index 9b830af..f7bd823 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -9,22 +9,26 @@ import ( ) func newSyncCmd() *cobra.Command { - var configPath string + var configPath, repoURL, ref string cmd := &cobra.Command{ Use: "sync", Short: "Pull the work repository and deploy its content to OpenCode", - Long: "Pulls the fleet-configured work repository (config from --config, " + - "AGENT_LIB_CONFIG, or the admin-protected platform default) and deploys " + - "skills, commands and agents into the OpenCode runtime paths. " + + Long: "Pulls the work repository and deploys skills, commands and agents " + + "into the OpenCode runtime paths. The repository comes from --repo, " + + "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.", Args: cobra.NoArgs, 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 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 } diff --git a/internal/client/config.go b/internal/client/config.go index 3402b31..75ce949 100644 --- a/internal/client/config.go +++ b/internal/client/config.go @@ -8,9 +8,13 @@ import ( "os" "path/filepath" "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 { RepoURL string `json:"repo_url"` Ref string `json:"ref,omitempty"` @@ -20,14 +24,8 @@ type ClientConfig struct { // ConfigEnv names the override environment variables. const ( - ConfigEnv = "AGENT_LIB_CONFIG" - StateDirEnv = "AGENT_LIB_STATE_DIR" - defaultConfig = `{ - "repo_url": "", - "ref": "", - "token": "", - "token_user": "" -}` + ConfigEnv = "AGENT_LIB_CONFIG" + StateDirEnv = "AGENT_LIB_STATE_DIR" ) // DefaultConfigPath returns the admin-protected config location per platform: @@ -42,16 +40,128 @@ func DefaultConfigPath() string { return "/etc/agent-lib/config.json" } -// LoadClientConfig reads the config from flagPath, the override env, or the -// platform default — in that order. +// 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 := flagPath - if path == "" { - path = os.Getenv(ConfigEnv) + path, ok, err := resolveConfigPath(flagPath) + if err != nil { + return nil, err } - if path == "" { - path = DefaultConfigPath() + 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) @@ -66,6 +176,19 @@ func LoadClientConfig(flagPath string) (*ClientConfig, error) { 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) { diff --git a/internal/client/config_test.go b/internal/client/config_test.go new file mode 100644 index 0000000..d4e4abb --- /dev/null +++ b/internal/client/config_test.go @@ -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) + } +} diff --git a/internal/client/sync.go b/internal/client/sync.go index a655c17..265e647 100644 --- a/internal/client/sync.go +++ b/internal/client/sync.go @@ -10,9 +10,13 @@ import ( "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 { ConfigPath string + RepoURL string + Ref string } // 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 // front half of a client run. 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 { 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 } +// 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 // (inventory, manifest, disk facts) and executes the plan. Validation and // hashing happen before the first mutation; unreachable repositories fail diff --git a/internal/e2e/config_test.go b/internal/e2e/config_test.go new file mode 100644 index 0000000..5ade16f --- /dev/null +++ b/internal/e2e/config_test.go @@ -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//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) + } +}