summaryrefslogtreecommitdiffstats
path: root/install.go
diff options
context:
space:
mode:
Diffstat (limited to 'install.go')
-rw-r--r--install.go173
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
+}