package main import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" ) const lockVersion = 1 type Manifest struct { // Agents to install into; empty means every agent found on this machine. Agents []string `json:"agents,omitempty"` Sources map[string]Source `json:"sources"` } type Source struct { // Source is the source as the user gave it, such as getadaa/plugins#main. Source string `json:"source"` // Skills limits the source to these skills; empty means all of them. Skills []string `json:"skills,omitempty"` } type Lock struct { Version int `json:"version"` Sources map[string]LockedSource `json:"sources"` } type LockedSource struct { URL string `json:"url"` Rev string `json:"rev"` // NarHash lets Nix find the source in its store without fetching it // again. Only recorded when SKILLS_MANAGED_BY is set. NarHash string `json:"narHash,omitempty"` // Skills maps each skill's name to its directory within the source. Skills map[string]string `json:"skills"` } func (l LockedSource) shortRev() string { if len(l.Rev) >= 7 { return l.Rev[:7] } return l.Rev } type workspace struct { manifestPath string lockPath string manifest Manifest lock Lock } func manifestPath(flag string) (string, error) { if flag != "" { return filepath.Abs(flag) } if p := os.Getenv("SKILLS_MANIFEST"); p != "" { return p, nil } return filepath.Join(configDir(), "skills.json"), nil } func openWorkspace(path string) (*workspace, error) { ws := &workspace{ manifestPath: path, lockPath: filepath.Join(filepath.Dir(path), "skills.lock"), manifest: Manifest{Sources: map[string]Source{}}, lock: Lock{Version: lockVersion, Sources: map[string]LockedSource{}}, } if err := readJSON(ws.manifestPath, &ws.manifest); err != nil { return nil, err } if err := readJSON(ws.lockPath, &ws.lock); err != nil { return nil, err } if ws.lock.Version != lockVersion { return nil, fmt.Errorf("%s has lock version %d, but this skills understands %d; upgrade skills", ws.lockPath, ws.lock.Version, lockVersion) } if ws.manifest.Sources == nil { ws.manifest.Sources = map[string]Source{} } if ws.lock.Sources == nil { ws.lock.Sources = map[string]LockedSource{} } return ws, nil } func (ws *workspace) save() error { if err := os.MkdirAll(filepath.Dir(ws.manifestPath), 0o755); err != nil { return err } if err := writeJSON(ws.lockPath, ws.lock); err != nil { return err } return writeJSON(ws.manifestPath, ws.manifest) } // sourceNames returns the named sources, or every source when none are named. func (ws *workspace) sourceNames(only []string) ([]string, error) { if len(only) > 0 { for _, n := range only { if _, ok := ws.manifest.Sources[n]; !ok { return nil, fmt.Errorf("no source named %q (see `skills list`)", n) } } return only, nil } names := make([]string, 0, len(ws.manifest.Sources)) for n := range ws.manifest.Sources { names = append(names, n) } sort.Strings(names) return names, nil } // checkCollisions fails if another source already provides one of skills, // since both would be installed under the same directory name. func (ws *workspace) checkCollisions(source string, skills []Skill) error { for other, l := range ws.lock.Sources { if other == source { continue } for _, s := range skills { if _, ok := l.Skills[s.Name]; ok { return fmt.Errorf("skill %q is already provided by source %q", s.Name, other) } } } return nil } // wanted maps every locked skill to its source. func (ws *workspace) wanted() map[string]string { m := map[string]string{} for src, l := range ws.lock.Sources { for s := range l.Skills { m[s] = src } } return m } func configDir() string { if d := os.Getenv("XDG_CONFIG_HOME"); d != "" { return filepath.Join(d, "skills") } home, _ := os.UserHomeDir() return filepath.Join(home, ".config", "skills") } func dataDir() string { if d := os.Getenv("XDG_DATA_HOME"); d != "" { return filepath.Join(d, "skills") } home, _ := os.UserHomeDir() return filepath.Join(home, ".local", "share", "skills") } func readJSON(path string, v any) error { data, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } if err := json.Unmarshal(data, v); err != nil { return fmt.Errorf("%s is not valid: %w", path, err) } return nil } func writeJSON(path string, v any) error { data, err := json.MarshalIndent(v, "", " ") if err != nil { return err } tmp := path + ".tmp" if err := os.WriteFile(tmp, append(data, '\n'), 0o644); err != nil { return err } return os.Rename(tmp, path) }