package main import ( "errors" "fmt" "os" "path/filepath" "sort" "strings" ) type agent struct { Name string // Home is the agent's own config directory; finding it means the agent // is in use here. Home string } func (a agent) skillsDir() string { return filepath.Join(a.Home, "skills") } func knownAgents() []agent { home, _ := os.UserHomeDir() env := func(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } xdgConfig := env("XDG_CONFIG_HOME", filepath.Join(home, ".config")) return []agent{ {"claude", env("CLAUDE_CONFIG_DIR", filepath.Join(home, ".claude"))}, {"codex", env("CODEX_HOME", filepath.Join(home, ".codex"))}, {"opencode", filepath.Join(xdgConfig, "opencode")}, } } func agentNames() string { var names []string for _, a := range knownAgents() { names = append(names, a.Name) } return strings.Join(names, ", ") } // targetAgents returns the agents named in the manifest, or every known agent // found on this machine. func (ws *workspace) targetAgents() ([]agent, error) { known := knownAgents() if len(ws.manifest.Agents) > 0 { var out []agent for _, n := range ws.manifest.Agents { i := indexAgent(known, n) if i < 0 { return nil, fmt.Errorf("%s names unknown agent %q (known: %s)", ws.manifestPath, n, agentNames()) } out = append(out, known[i]) } return out, nil } var out []agent for _, a := range known { if fi, err := os.Stat(a.Home); err == nil && fi.IsDir() { out = append(out, a) } } if len(out) == 0 { return nil, fmt.Errorf("found no agents to install into (looked for %s); set \"agents\" in %s", agentNames(), ws.manifestPath) } return out, nil } func indexAgent(agents []agent, name string) int { for i, a := range agents { if a.Name == name { return i } } return -1 } type skillState int const ( stateMissing skillState = iota stateInstalled stateStale // a link of ours to another checkout stateConflict // something we did not put there ) func (s skillState) String() string { return [...]string{"missing", "installed", "outdated", "conflict"}[s] } func linkState(path, want string) skillState { dest, err := os.Readlink(path) switch { case err == nil && dest == want: return stateInstalled case err == nil && ownedLink(dest): return stateStale case errors.Is(err, os.ErrNotExist): return stateMissing } if _, statErr := os.Lstat(path); errors.Is(statErr, os.ErrNotExist) { return stateMissing } return stateConflict } func ownedLink(dest string) bool { return strings.HasPrefix(dest, filepath.Join(dataDir(), "checkouts")+string(filepath.Separator)) } type change struct { Agent, Skill, Action string } // link makes each agent's skills directory hold exactly the locked skills, // as symlinks into their checkouts. It leaves anything it did not create // alone and reports it as a conflict. func (ws *workspace) link(dirs map[string]string, agents []agent) ([]change, []string, error) { var changes []change var conflicts []string for _, a := range agents { dir := a.skillsDir() if err := os.MkdirAll(dir, 0o755); err != nil { return changes, conflicts, err } names := make([]string, 0, len(dirs)) for n := range dirs { names = append(names, n) } sort.Strings(names) for _, n := range names { path, want := filepath.Join(dir, n), dirs[n] switch linkState(path, want) { case stateInstalled: continue case stateConflict: conflicts = append(conflicts, path) continue case stateStale: if err := os.Remove(path); err != nil { return changes, conflicts, err } changes = append(changes, change{a.Name, n, "updated"}) default: changes = append(changes, change{a.Name, n, "installed"}) } if err := os.Symlink(want, path); err != nil { return changes, conflicts, err } } entries, err := os.ReadDir(dir) if err != nil { return changes, conflicts, err } for _, e := range entries { if _, ok := dirs[e.Name()]; ok { continue } path := filepath.Join(dir, e.Name()) if dest, err := os.Readlink(path); err == nil && ownedLink(dest) { if err := os.Remove(path); err != nil { return changes, conflicts, err } changes = append(changes, change{a.Name, e.Name(), "removed"}) } } } return changes, conflicts, nil }