aboutsummaryrefslogtreecommitdiffstats
path: root/workspace.go
diff options
context:
space:
mode:
authorSimen A. W. Olsen <hello@simenandre.no>2026-10-25 12:41:28 +0100
committerSimen A. W. Olsen <hello@simenandre.no>2026-10-25 12:41:28 +0100
commitc596038adc7cdbd64988e61fee856d533b58c25d (patch)
treea02fde25835416d143ff56b807e640f6a7e9cf82 /workspace.go
downloadskills-c596038adc7cdbd64988e61fee856d533b58c25d.tar.gz
skills-c596038adc7cdbd64988e61fee856d533b58c25d.zip
feat: skills, a package manager for agent skillsHEADv0.1.0main
Diffstat (limited to 'workspace.go')
-rw-r--r--workspace.go186
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)
+}