diff options
Diffstat (limited to 'install.go')
| -rw-r--r-- | install.go | 173 |
1 files changed, 173 insertions, 0 deletions
diff --git a/install.go b/install.go new file mode 100644 index 0000000..083dd41 --- /dev/null +++ b/install.go @@ -0,0 +1,173 @@ +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 +} |
