diff options
| author | Simen A. W. Olsen <hello@simenandre.no> | 2026-10-25 12:41:28 +0100 |
|---|---|---|
| committer | Simen A. W. Olsen <hello@simenandre.no> | 2026-10-25 12:41:28 +0100 |
| commit | c596038adc7cdbd64988e61fee856d533b58c25d (patch) | |
| tree | a02fde25835416d143ff56b807e640f6a7e9cf82 /workspace.go | |
| download | skills-main.tar.gz skills-main.zip | |
Diffstat (limited to 'workspace.go')
| -rw-r--r-- | workspace.go | 186 |
1 files changed, 186 insertions, 0 deletions
diff --git a/workspace.go b/workspace.go new file mode 100644 index 0000000..e43a699 --- /dev/null +++ b/workspace.go @@ -0,0 +1,186 @@ +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) +} |
