aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--LICENSE21
-rw-r--r--README.md124
-rw-r--r--commands.go462
-rw-r--r--discover.go125
-rw-r--r--flake.lock27
-rw-r--r--flake.nix34
-rw-r--r--git.go195
-rw-r--r--go.mod10
-rw-r--r--go.sum10
-rw-r--r--install.go173
-rw-r--r--main.go128
-rw-r--r--nix.go41
-rw-r--r--nix/home-manager.nix98
-rw-r--r--nix/package.nix45
-rw-r--r--skill/skills/SKILL.md50
-rw-r--r--skills_test.go95
-rw-r--r--source.go80
-rw-r--r--workspace.go186
19 files changed, 1907 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..09e41da
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+result
+result-*
+/skills
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..ff37b8d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Simen A. W. Olsen
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..386772b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,124 @@
+# skills
+
+A package manager for [agent skills](https://agentskills.io): directories
+holding a `SKILL.md` that teach Claude Code, Codex, OpenCode and other agents
+how to do something.
+
+`skills` installs them from any git repository, locks each one to a commit
+and links it into every agent you use. The skills you added go in a manifest
+and the exact commits in a lock file next to it, so committing both gives you
+the same skills on every machine.
+
+```console
+$ skills add anthropics/skills --skill pdf --skill xlsx
+Added skills at dbd4588: pdf, xlsx
+Installed pdf, xlsx for claude, codex
+
+$ skills list
+skills dbd4588 anthropics/skills
+ pdf installed skills/pdf
+ xlsx installed skills/xlsx
+
+$ skills update
+skills is up to date at dbd4588
+All skills are installed for claude, codex
+```
+
+## Install
+
+You need git. With Go:
+
+```sh
+go install simenandre.no/skills@latest
+```
+
+With Nix:
+
+```sh
+nix profile install git+https://git.simenandre.no/skills
+```
+
+## Use
+
+```sh
+skills add owner/repo # every skill in a GitHub repository
+skills add owner/repo --skill pdf # just one
+skills add owner/repo#v1.2.0 # follow a tag or branch, or pin a commit
+skills add git@host:me/skills.git # any git URL, including private ones
+skills list # what is installed, from where, for whom
+skills update [source...] # move to the newest commits
+skills remove <source>... # uninstall
+skills install # install exactly what is locked
+```
+
+`skills <command> --help` explains each command, with examples.
+
+### Where things go
+
+| What | Where |
+|---|---|
+| Manifest | `--manifest`, else `$SKILLS_MANIFEST`, else `$XDG_CONFIG_HOME/skills/skills.json` |
+| Lock | `skills.lock`, next to the manifest |
+| Checkouts | `$XDG_DATA_HOME/skills` |
+| Installed skills | symlinks in each agent's skills directory |
+
+Agents: `claude` (`$CLAUDE_CONFIG_DIR` or `~/.claude`), `codex` (`$CODEX_HOME`
+or `~/.codex`) and `opencode` (`$XDG_CONFIG_HOME/opencode`). By default
+`skills` installs into every one of them that exists on the machine; to choose,
+list them in the manifest:
+
+```json
+{
+ "agents": ["claude"],
+ "sources": { "...": {} }
+}
+```
+
+`skills` only ever replaces or removes symlinks it created, so skills you
+installed some other way are left alone. If one is in the way, it tells you.
+
+### Scripts
+
+`skills list --json` prints machine-readable output. Progress goes to stderr,
+and only to a terminal; `--quiet` silences everything but errors. Exit codes:
+0 success, 1 failure, 2 invalid usage, 130 interrupted.
+
+## home-manager
+
+The flake has a home-manager module that installs the locked skills itself,
+so they are part of your configuration like anything else:
+
+```nix
+{
+ inputs.skills.url = "git+https://git.simenandre.no/skills";
+
+ # in your home-manager configuration
+ imports = [ inputs.skills.homeManagerModules.default ];
+ programs.skills = {
+ enable = true;
+ lockFile = ./skills.lock;
+ # Lets `skills add` and `skills update` edit this file in your checkout.
+ manifest = "/home/me/dotfiles/skills.json";
+ agents = [ "claude" "codex" ];
+ };
+}
+```
+
+The module sets `SKILLS_MANAGED_BY=home-manager`. `skills` then edits the
+manifest and lock, fetches sources into the Nix store as you, and leaves the
+installing to your next switch. Fetching as you matters for private
+repositories: Nix finds them already in the store by their hash, even when
+root evaluates your configuration without your SSH keys. On a new machine, run
+`skills install` before your first switch for the same reason.
+
+## Agent skill
+
+`skill/skills` teaches agents to use this CLI. Install it with:
+
+```sh
+skills add https://git.simenandre.no/skills --skill skills
+```
+
+## License
+
+MIT
diff --git a/commands.go b/commands.go
new file mode 100644
index 0000000..58036a1
--- /dev/null
+++ b/commands.go
@@ -0,0 +1,462 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "text/tabwriter"
+
+ "github.com/spf13/cobra"
+)
+
+func addCmd(opts *options) *cobra.Command {
+ var name string
+ var only []string
+ cmd := &cobra.Command{
+ Use: "add <source>",
+ Short: "Add the skills in a git repository",
+ Long: `Add the skills in a git repository and install them.
+
+<source> is one of:
+ owner/repo a GitHub repository
+ https://host/path.git any git URL (https, ssh, git or file)
+ user@host:path an SSH remote, as git writes it
+ ./path a local git repository
+optionally followed by #ref to follow a branch or tag, or pin a commit.
+
+Every directory holding a SKILL.md is a skill. Without --skill, all of
+them are added, including ones the repository gains later.`,
+ Example: ` skills add anthropics/skills
+ skills add anthropics/skills --skill pdf --skill xlsx
+ skills add git@example.com:me/skills.git#main --name mine`,
+ Args: exactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ws, err := opts.workspace()
+ if err != nil {
+ return err
+ }
+ spec, err := parseSpec(args[0])
+ if err != nil {
+ return usageError{err}
+ }
+ if name != "" {
+ if _, ok := ws.manifest.Sources[name]; ok {
+ return fmt.Errorf("a source named %q already exists; pick another --name, or `skills update %s`", name, name)
+ }
+ }
+ src := Source{Source: args[0], Skills: only}
+ locked, selected, err := lockSource(cmd.Context(), opts, ws, name, src)
+ if err != nil {
+ return err
+ }
+ if name == "" {
+ name = defaultSourceName(spec, selected)
+ if _, ok := ws.manifest.Sources[name]; ok {
+ return fmt.Errorf("a source named %q already exists; choose a name with --name", name)
+ }
+ if err := ws.checkCollisions(name, selected); err != nil {
+ return err
+ }
+ }
+ ws.manifest.Sources[name] = src
+ ws.lock.Sources[name] = locked
+ if err := ws.save(); err != nil {
+ return err
+ }
+ opts.printf("Added %s at %s: %s\n", name, locked.shortRev(), skillNames(selected))
+ return apply(cmd.Context(), opts, ws)
+ },
+ }
+ cmd.Flags().StringVarP(&name, "name", "n", "", "name for the source (default: its only skill, else the repository)")
+ cmd.Flags().StringArrayVarP(&only, "skill", "s", nil, "add only this skill; repeat for more")
+ return cmd
+}
+
+func removeCmd(opts *options) *cobra.Command {
+ return &cobra.Command{
+ Use: "remove <source>...",
+ Aliases: []string{"rm"},
+ Short: "Remove sources and uninstall their skills",
+ Example: ` skills remove pdf`,
+ Args: minArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ws, err := opts.workspace()
+ if err != nil {
+ return err
+ }
+ names, err := ws.sourceNames(args)
+ if err != nil {
+ return err
+ }
+ for _, n := range names {
+ delete(ws.manifest.Sources, n)
+ delete(ws.lock.Sources, n)
+ opts.printf("Removed %s\n", n)
+ }
+ if err := ws.save(); err != nil {
+ return err
+ }
+ return apply(cmd.Context(), opts, ws)
+ },
+ }
+}
+
+func updateCmd(opts *options) *cobra.Command {
+ return &cobra.Command{
+ Use: "update [<source>...]",
+ Short: "Move sources to the newest commit of their branch or tag",
+ Long: `Move sources to the newest commit of the branch or tag they follow, and
+install the result. With no arguments, every source is updated.`,
+ Example: ` skills update
+ skills update pdf`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ws, err := opts.workspace()
+ if err != nil {
+ return err
+ }
+ names, err := ws.sourceNames(args)
+ if err != nil {
+ return err
+ }
+ changed := false
+ for _, n := range names {
+ old := ws.lock.Sources[n]
+ next, _, err := lockSource(cmd.Context(), opts, ws, n, ws.manifest.Sources[n])
+ if err != nil {
+ return fmt.Errorf("%s: %w", n, err)
+ }
+ if next.Rev == old.Rev && fmt.Sprint(next.Skills) == fmt.Sprint(old.Skills) && next.NarHash == old.NarHash {
+ opts.printf("%s is up to date at %s\n", n, old.shortRev())
+ continue
+ }
+ changed = true
+ ws.lock.Sources[n] = next
+ opts.printf("Updated %s from %s to %s%s\n", n, orNone(old.shortRev()), next.shortRev(), diffSkills(old.Skills, next.Skills))
+ }
+ if changed {
+ if err := ws.save(); err != nil {
+ return err
+ }
+ }
+ return apply(cmd.Context(), opts, ws)
+ },
+ }
+}
+
+func installCmd(opts *options) *cobra.Command {
+ return &cobra.Command{
+ Use: "install",
+ Short: "Install exactly the locked skills",
+ Long: `Install the skills in the lock file at their locked commits, and uninstall
+skills that skills installed but the lock no longer has. Run it after
+cloning a manifest onto a new machine. Nothing else in an agent's skills
+directory is touched.`,
+ Args: exactArgs(0),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ws, err := opts.workspace()
+ if err != nil {
+ return err
+ }
+ return apply(cmd.Context(), opts, ws)
+ },
+ }
+}
+
+func listCmd(opts *options) *cobra.Command {
+ var asJSON bool
+ cmd := &cobra.Command{
+ Use: "list",
+ Aliases: []string{"ls"},
+ Short: "Show sources, their skills and where they are installed",
+ Args: exactArgs(0),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ws, err := opts.workspace()
+ if err != nil {
+ return err
+ }
+ return list(ws, asJSON)
+ },
+ }
+ cmd.Flags().BoolVar(&asJSON, "json", false, "print JSON")
+ return cmd
+}
+
+// lockSource resolves a source to a commit, fetches it and finds its skills.
+// name is empty for a source being added.
+func lockSource(ctx context.Context, opts *options, ws *workspace, name string, src Source) (LockedSource, []Skill, error) {
+ spec, err := parseSpec(src.Source)
+ if err != nil {
+ return LockedSource{}, nil, err
+ }
+ opts.progressf("Fetching %s\n", src.Source)
+ rev, err := resolve(ctx, spec.URL, spec.Ref)
+ if err != nil {
+ return LockedSource{}, nil, err
+ }
+ dir, err := checkout(ctx, spec.URL, rev)
+ if err != nil {
+ return LockedSource{}, nil, err
+ }
+ found, err := discover(dir)
+ if err != nil {
+ return LockedSource{}, nil, err
+ }
+ if len(found) == 0 {
+ return LockedSource{}, nil, fmt.Errorf("found no SKILL.md in %s", src.Source)
+ }
+ selected, err := selectSkills(found, src.Skills)
+ if err != nil {
+ return LockedSource{}, nil, err
+ }
+ if name != "" {
+ if err := ws.checkCollisions(name, selected); err != nil {
+ return LockedSource{}, nil, err
+ }
+ }
+ locked := LockedSource{URL: spec.URL, Rev: rev, Skills: skillPaths(selected)}
+ if managedBy() != "" {
+ if locked.NarHash, err = nixFetch(ctx, spec.URL, rev); err != nil {
+ return LockedSource{}, nil, err
+ }
+ }
+ return locked, selected, nil
+}
+
+// apply makes the installed skills match the lock.
+func apply(ctx context.Context, opts *options, ws *workspace) error {
+ names, _ := ws.sourceNames(nil)
+ if m := managedBy(); m != "" {
+ return applyNix(ctx, opts, ws, names, m)
+ }
+ agents, err := ws.targetAgents()
+ if err != nil {
+ return err
+ }
+ dirs := map[string]string{}
+ for _, n := range names {
+ l, ok := ws.lock.Sources[n]
+ if !ok {
+ return fmt.Errorf("%s is in the manifest but not the lock; run `skills update %s`", n, n)
+ }
+ opts.progressf("Fetching %s\n", ws.manifest.Sources[n].Source)
+ dir, err := checkout(ctx, l.URL, l.Rev)
+ if err != nil {
+ return fmt.Errorf("%s: %w", n, err)
+ }
+ for s, p := range l.Skills {
+ dirs[s] = filepath.Join(dir, filepath.FromSlash(p))
+ }
+ }
+ changes, conflicts, err := ws.link(dirs, agents)
+ reportChanges(opts, changes)
+ if err != nil {
+ return err
+ }
+ if len(conflicts) > 0 {
+ return fmt.Errorf("these already exist and were not installed by skills, so they were left alone:\n %s\nmove them away and run `skills install`",
+ strings.Join(conflicts, "\n "))
+ }
+ if len(changes) == 0 {
+ opts.printf("All skills are installed for %s\n", joinAgents(agents))
+ }
+ return nil
+}
+
+// applyNix fetches every locked source into the Nix store as this user, so
+// that the tool named by SKILLS_MANAGED_BY can install them.
+func applyNix(ctx context.Context, opts *options, ws *workspace, names []string, manager string) error {
+ dirty := false
+ for _, n := range names {
+ l := ws.lock.Sources[n]
+ opts.progressf("Fetching %s into the Nix store\n", ws.manifest.Sources[n].Source)
+ hash, err := nixFetch(ctx, l.URL, l.Rev)
+ if err != nil {
+ return fmt.Errorf("%s: %w", n, err)
+ }
+ if hash != l.NarHash {
+ l.NarHash = hash
+ ws.lock.Sources[n] = l
+ dirty = true
+ }
+ }
+ if dirty {
+ if err := ws.save(); err != nil {
+ return err
+ }
+ }
+ opts.printf("Skills are installed by %s: apply your %s configuration to install these changes.\n", manager, manager)
+ return nil
+}
+
+func reportChanges(opts *options, changes []change) {
+ agents := map[string][]string{}
+ for _, c := range changes {
+ agents[c.Action+"\x00"+c.Skill] = append(agents[c.Action+"\x00"+c.Skill], c.Agent)
+ }
+ skills := map[string][]string{}
+ var order []string
+ for k, as := range agents {
+ action, skill, _ := strings.Cut(k, "\x00")
+ line := capitalize(action) + "\x00" + strings.Join(as, ", ")
+ if _, ok := skills[line]; !ok {
+ order = append(order, line)
+ }
+ skills[line] = append(skills[line], skill)
+ }
+ sort.Strings(order)
+ for _, line := range order {
+ action, as, _ := strings.Cut(line, "\x00")
+ sort.Strings(skills[line])
+ opts.printf("%s %s for %s\n", action, strings.Join(skills[line], ", "), as)
+ }
+}
+
+func list(ws *workspace, asJSON bool) error {
+ names, _ := ws.sourceNames(nil)
+ agents, agentsErr := ws.targetAgents()
+ manager := managedBy()
+
+ type skillOut struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ Status map[string]string `json:"status,omitempty"`
+ }
+ type sourceOut struct {
+ Name string `json:"name"`
+ Source string `json:"source"`
+ URL string `json:"url"`
+ Rev string `json:"rev"`
+ Skills []skillOut `json:"skills"`
+ }
+ var out []sourceOut
+ for _, n := range names {
+ l := ws.lock.Sources[n]
+ so := sourceOut{Name: n, Source: ws.manifest.Sources[n].Source, URL: l.URL, Rev: l.Rev, Skills: []skillOut{}}
+ skills := make([]string, 0, len(l.Skills))
+ for s := range l.Skills {
+ skills = append(skills, s)
+ }
+ sort.Strings(skills)
+ for _, s := range skills {
+ sk := skillOut{Name: s, Path: l.Skills[s]}
+ if manager == "" && agentsErr == nil {
+ sk.Status = map[string]string{}
+ want := filepath.Join(dataDir(), "checkouts", repoKey(l.URL), l.Rev, filepath.FromSlash(l.Skills[s]))
+ for _, a := range agents {
+ sk.Status[a.Name] = linkState(filepath.Join(a.skillsDir(), s), want).String()
+ }
+ }
+ so.Skills = append(so.Skills, sk)
+ }
+ out = append(out, so)
+ }
+
+ if asJSON {
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ if out == nil {
+ out = []sourceOut{}
+ }
+ return enc.Encode(out)
+ }
+ if len(out) == 0 {
+ fmt.Printf("No skills in %s yet. Add some with `skills add owner/repo`.\n", ws.manifestPath)
+ return nil
+ }
+ tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
+ for _, so := range out {
+ fmt.Fprintf(tw, "%s\t%s\t%s\n", so.Name, orNone(shortRev(so.Rev)), so.Source)
+ for _, sk := range so.Skills {
+ fmt.Fprintf(tw, " %s\t%s\t%s\n", sk.Name, summarizeStatus(sk.Status, manager), sk.Path)
+ }
+ }
+ if err := tw.Flush(); err != nil {
+ return err
+ }
+ if agentsErr != nil && manager == "" {
+ fmt.Fprintln(os.Stderr, "skills:", agentsErr)
+ }
+ return nil
+}
+
+func summarizeStatus(status map[string]string, manager string) string {
+ if manager != "" {
+ return "via " + manager
+ }
+ byState := map[string][]string{}
+ for a, s := range status {
+ byState[s] = append(byState[s], a)
+ }
+ if len(byState) == 1 {
+ for s := range byState {
+ return s
+ }
+ }
+ var parts []string
+ for s, as := range byState {
+ sort.Strings(as)
+ parts = append(parts, s+" for "+strings.Join(as, ", "))
+ }
+ sort.Strings(parts)
+ return strings.Join(parts, "; ")
+}
+
+func diffSkills(old, next map[string]string) string {
+ var added, removed []string
+ for n := range next {
+ if _, ok := old[n]; !ok {
+ added = append(added, n)
+ }
+ }
+ for n := range old {
+ if _, ok := next[n]; !ok {
+ removed = append(removed, n)
+ }
+ }
+ sort.Strings(added)
+ sort.Strings(removed)
+ var parts []string
+ if len(added) > 0 {
+ parts = append(parts, "new: "+strings.Join(added, ", "))
+ }
+ if len(removed) > 0 {
+ parts = append(parts, "gone: "+strings.Join(removed, ", "))
+ }
+ if len(parts) == 0 {
+ return ""
+ }
+ return " (" + strings.Join(parts, "; ") + ")"
+}
+
+func joinAgents(agents []agent) string {
+ names := make([]string, len(agents))
+ for i, a := range agents {
+ names[i] = a.Name
+ }
+ return strings.Join(names, ", ")
+}
+
+func shortRev(rev string) string {
+ if len(rev) > 7 {
+ return rev[:7]
+ }
+ return rev
+}
+
+func orNone(s string) string {
+ if s == "" {
+ return "(none)"
+ }
+ return s
+}
+
+func capitalize(s string) string {
+ if s == "" {
+ return s
+ }
+ return strings.ToUpper(s[:1]) + s[1:]
+}
diff --git a/discover.go b/discover.go
new file mode 100644
index 0000000..ee9c2e6
--- /dev/null
+++ b/discover.go
@@ -0,0 +1,125 @@
+package main
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+)
+
+type Skill struct {
+ Name string
+ // Path is the skill's directory relative to the source root.
+ Path string
+ Description string
+}
+
+var validName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
+
+// discover finds every skill in root: a directory holding a SKILL.md. It does
+// not look for skills nested inside another skill.
+func discover(root string) ([]Skill, error) {
+ var skills []Skill
+ seen := map[string]string{}
+ err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if !d.IsDir() {
+ return nil
+ }
+ if path != root && (strings.HasPrefix(d.Name(), ".") || d.Name() == "node_modules") {
+ return filepath.SkipDir
+ }
+ data, err := os.ReadFile(filepath.Join(path, "SKILL.md"))
+ if err != nil {
+ return nil
+ }
+ rel, _ := filepath.Rel(root, path)
+ fm := frontmatter(data)
+ name := fm["name"]
+ if name == "" {
+ name = filepath.Base(path)
+ }
+ if !validName.MatchString(name) {
+ return fmt.Errorf("%s/SKILL.md: invalid skill name %q", rel, name)
+ }
+ if prev, ok := seen[name]; ok {
+ return fmt.Errorf("skill %q is defined twice, in %s and %s", name, prev, rel)
+ }
+ seen[name] = rel
+ skills = append(skills, Skill{Name: name, Path: filepath.ToSlash(rel), Description: fm["description"]})
+ return filepath.SkipDir
+ })
+ sort.Slice(skills, func(i, j int) bool { return skills[i].Name < skills[j].Name })
+ return skills, err
+}
+
+// frontmatter reads the top-level scalar keys of a SKILL.md's YAML front
+// matter. That is all skills need from it, so it skips a YAML dependency.
+func frontmatter(data []byte) map[string]string {
+ fm := map[string]string{}
+ sc := bufio.NewScanner(bytes.NewReader(data))
+ if !sc.Scan() || strings.TrimSpace(sc.Text()) != "---" {
+ return fm
+ }
+ for sc.Scan() {
+ line := sc.Text()
+ if strings.TrimSpace(line) == "---" {
+ break
+ }
+ if line == "" || line[0] == ' ' || line[0] == '\t' {
+ continue
+ }
+ k, v, ok := strings.Cut(line, ":")
+ if !ok {
+ continue
+ }
+ v = strings.TrimSpace(v)
+ if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') && v[len(v)-1] == v[0] {
+ v = v[1 : len(v)-1]
+ }
+ fm[strings.TrimSpace(k)] = v
+ }
+ return fm
+}
+
+func selectSkills(found []Skill, only []string) ([]Skill, error) {
+ if len(only) == 0 {
+ return found, nil
+ }
+ byName := map[string]Skill{}
+ for _, s := range found {
+ byName[s.Name] = s
+ }
+ var out []Skill
+ for _, n := range only {
+ s, ok := byName[n]
+ if !ok {
+ return nil, fmt.Errorf("no skill named %q (found: %s)", n, skillNames(found))
+ }
+ out = append(out, s)
+ }
+ return out, nil
+}
+
+func skillNames(skills []Skill) string {
+ names := make([]string, len(skills))
+ for i, s := range skills {
+ names[i] = s.Name
+ }
+ return strings.Join(names, ", ")
+}
+
+func skillPaths(skills []Skill) map[string]string {
+ m := make(map[string]string, len(skills))
+ for _, s := range skills {
+ m[s.Name] = s.Path
+ }
+ return m
+}
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..66668d3
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,27 @@
+{
+ "nodes": {
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1792812153,
+ "narHash": "sha256-T2+XBfNbf9pecLUWS4+RtuVNZ3W8K5taG5QdV71mWlk=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "674a2028661e550a97400c1cc992b9cca9a668a1",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixpkgs-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "nixpkgs": "nixpkgs"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..ee122eb
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,34 @@
+{
+ description = "Install and update agent skills from git repositories";
+
+ inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
+
+ outputs =
+ { self, nixpkgs }:
+ let
+ version = "0.1.0";
+ systems = [
+ "aarch64-darwin"
+ "x86_64-darwin"
+ "aarch64-linux"
+ "x86_64-linux"
+ ];
+ forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
+ in
+ {
+ packages = forAllSystems (pkgs: {
+ default = pkgs.callPackage ./nix/package.nix { inherit version; src = self; };
+ });
+
+ homeManagerModules.default = import ./nix/home-manager.nix self;
+
+ devShells = forAllSystems (pkgs: {
+ default = pkgs.mkShell {
+ packages = [
+ pkgs.go
+ pkgs.gopls
+ ];
+ };
+ });
+ };
+}
diff --git a/git.go b/git.go
new file mode 100644
index 0000000..8a48250
--- /dev/null
+++ b/git.go
@@ -0,0 +1,195 @@
+package main
+
+import (
+ "archive/tar"
+ "bufio"
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+// resolve finds the commit ref points at in the repository at url. An empty
+// ref means the default branch; a full commit hash resolves to itself.
+func resolve(ctx context.Context, url, ref string) (string, error) {
+ if fullRev.MatchString(ref) {
+ return ref, nil
+ }
+ query := []string{"HEAD"}
+ if ref != "" {
+ query = []string{"refs/heads/" + ref, "refs/tags/" + ref, "refs/tags/" + ref + "^{}"}
+ }
+ out, err := git(ctx, "", append([]string{"ls-remote", url}, query...)...)
+ if err != nil {
+ return "", err
+ }
+ found := map[string]string{}
+ sc := bufio.NewScanner(bytes.NewReader(out))
+ for sc.Scan() {
+ rev, name, ok := strings.Cut(sc.Text(), "\t")
+ if ok {
+ found[name] = rev
+ }
+ }
+ for _, name := range []string{"HEAD", "refs/heads/" + ref, "refs/tags/" + ref + "^{}", "refs/tags/" + ref} {
+ if rev, ok := found[name]; ok {
+ return rev, nil
+ }
+ }
+ if ref == "" {
+ return "", fmt.Errorf("%s has no default branch", url)
+ }
+ return "", fmt.Errorf("%s has no branch or tag named %q", url, ref)
+}
+
+// checkout makes rev of url available as a plain directory and returns it.
+// Checkouts are immutable and shared by everything that locks the same commit.
+func checkout(ctx context.Context, url, rev string) (string, error) {
+ key := repoKey(url)
+ dir := filepath.Join(dataDir(), "checkouts", key, rev)
+ if _, err := os.Stat(dir); err == nil {
+ return dir, nil
+ }
+
+ repo := filepath.Join(dataDir(), "repos", key+".git")
+ if _, err := os.Stat(repo); err != nil {
+ if _, err := git(ctx, "", "init", "--bare", "--quiet", repo); err != nil {
+ return "", err
+ }
+ }
+ if !hasCommit(ctx, repo, rev) {
+ // Servers hand out branch and tag tips by hash; older commits may
+ // need the whole history.
+ if _, err := git(ctx, repo, "fetch", "--quiet", "--depth=1", url, rev); err != nil || !hasCommit(ctx, repo, rev) {
+ args := []string{"fetch", "--quiet", url, "+refs/heads/*:refs/remotes/origin/*", "+refs/tags/*:refs/tags/*"}
+ if _, err := os.Stat(filepath.Join(repo, "shallow")); err == nil {
+ args = append(args, "--unshallow")
+ }
+ if _, err := git(ctx, repo, args...); err != nil {
+ return "", err
+ }
+ }
+ if !hasCommit(ctx, repo, rev) {
+ return "", fmt.Errorf("%s has no commit %s", url, rev)
+ }
+ }
+
+ tmp := fmt.Sprintf("%s.tmp-%d", dir, os.Getpid())
+ defer os.RemoveAll(tmp)
+ if err := os.MkdirAll(tmp, 0o755); err != nil {
+ return "", err
+ }
+ cmd := exec.CommandContext(ctx, "git", "-C", repo, "archive", "--format=tar", rev)
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return "", err
+ }
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ if err := cmd.Start(); err != nil {
+ return "", gitError(err, &stderr)
+ }
+ extractErr := untar(stdout, tmp)
+ if err := cmd.Wait(); err != nil {
+ return "", gitError(err, &stderr)
+ }
+ if extractErr != nil {
+ return "", extractErr
+ }
+ if err := os.Rename(tmp, dir); err != nil {
+ return "", err
+ }
+ return dir, nil
+}
+
+func hasCommit(ctx context.Context, repo, rev string) bool {
+ _, err := git(ctx, repo, "cat-file", "-e", rev+"^{commit}")
+ return err == nil
+}
+
+func untar(r io.Reader, dst string) error {
+ tr := tar.NewReader(r)
+ for {
+ h, err := tr.Next()
+ if errors.Is(err, io.EOF) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ name := filepath.Clean(h.Name)
+ if filepath.IsAbs(name) || name == ".." || strings.HasPrefix(name, "../") {
+ return fmt.Errorf("refusing to extract %q", h.Name)
+ }
+ path := filepath.Join(dst, name)
+ switch h.Typeflag {
+ case tar.TypeDir:
+ err = os.MkdirAll(path, 0o755)
+ case tar.TypeReg:
+ err = writeFile(path, tr, os.FileMode(h.Mode)&0o755|0o644)
+ case tar.TypeSymlink:
+ err = os.Symlink(h.Linkname, path)
+ }
+ if err != nil {
+ return err
+ }
+ }
+}
+
+func writeFile(path string, r io.Reader, mode os.FileMode) error {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
+ if err != nil {
+ return err
+ }
+ if _, err := io.Copy(f, r); err != nil {
+ f.Close()
+ return err
+ }
+ return f.Close()
+}
+
+func repoKey(url string) string {
+ sum := sha256.Sum256([]byte(url))
+ return hex.EncodeToString(sum[:8])
+}
+
+func git(ctx context.Context, repo string, args ...string) ([]byte, error) {
+ if repo != "" {
+ args = append([]string{"-C", repo}, args...)
+ }
+ cmd := exec.CommandContext(ctx, "git", args...)
+ // Fail instead of hanging on a password prompt nobody can see.
+ cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ out, err := cmd.Output()
+ if err != nil {
+ return nil, gitError(err, &stderr)
+ }
+ return out, nil
+}
+
+func gitError(err error, stderr *bytes.Buffer) error {
+ if errors.Is(err, exec.ErrNotFound) {
+ return errors.New("git is not installed; skills needs it to fetch sources (https://git-scm.com)")
+ }
+ msg := strings.TrimSpace(stderr.String())
+ switch {
+ case msg == "":
+ return err
+ case strings.Contains(msg, "Repository not found"), strings.Contains(msg, "not found"),
+ strings.Contains(msg, "could not read Username"), strings.Contains(msg, "Permission denied"):
+ return fmt.Errorf("%s\n(check the URL, and that you can reach it with `git ls-remote`)", msg)
+ }
+ return errors.New(msg)
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..76f36e4
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,10 @@
+module simenandre.no/skills
+
+go 1.26
+
+require github.com/spf13/cobra v1.10.2
+
+require (
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/spf13/pflag v1.0.9 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..a6ee3e0
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,10 @@
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..b1674c6
--- /dev/null
+++ b/main.go
@@ -0,0 +1,128 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/signal"
+ "runtime/debug"
+ "strings"
+ "syscall"
+
+ "github.com/spf13/cobra"
+)
+
+// version is set at build time; `go install` builds fall back to the module
+// version.
+var version = "dev"
+
+func init() {
+ if bi, ok := debug.ReadBuildInfo(); ok && version == "dev" && bi.Main.Version != "" && bi.Main.Version != "(devel)" {
+ version = bi.Main.Version
+ }
+}
+
+const docsURL = "https://git.simenandre.no/skills/about/"
+
+type usageError struct{ error }
+
+type options struct {
+ manifest string
+ quiet bool
+}
+
+func main() {
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ err := newRoot().ExecuteContext(ctx)
+ interrupted := ctx.Err() != nil
+ stop()
+ if err == nil {
+ return
+ }
+ fmt.Fprintln(os.Stderr, "skills:", err)
+ var ue usageError
+ switch {
+ case interrupted:
+ os.Exit(130)
+ case errors.As(err, &ue), strings.HasPrefix(err.Error(), "unknown command"):
+ os.Exit(2)
+ }
+ os.Exit(1)
+}
+
+func newRoot() *cobra.Command {
+ opts := &options{}
+ root := &cobra.Command{
+ Use: "skills",
+ Short: "Install and update agent skills from git repositories",
+ Long: `skills installs agent skills (directories holding a SKILL.md) from git
+repositories into Claude Code, Codex and OpenCode, and keeps them updated.
+
+What you add is recorded in a manifest, and the exact commit of each source
+in a lock file next to it. Commit both to reproduce the same skills on
+another machine with ` + "`skills install`" + `.`,
+ Example: ` # Add every skill in a repository
+ skills add anthropics/skills
+
+ # Add one skill, from a private repository, at a tag
+ skills add git@example.com:team/skills.git#v1.2.0 --skill deploy
+
+ # See what is installed, then move everything to the newest commits
+ skills list
+ skills update`,
+ Version: version,
+ SilenceUsage: true,
+ SilenceErrors: true,
+ }
+ root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { return usageError{err} })
+ root.PersistentFlags().StringVar(&opts.manifest, "manifest", "",
+ "manifest to use (default $SKILLS_MANIFEST, else $XDG_CONFIG_HOME/skills/skills.json)")
+ root.PersistentFlags().BoolVarP(&opts.quiet, "quiet", "q", false, "print only errors")
+ root.SetHelpTemplate(root.HelpTemplate() + "\nDocumentation and source: " + docsURL + "\n")
+
+ root.AddCommand(addCmd(opts), removeCmd(opts), updateCmd(opts), installCmd(opts), listCmd(opts))
+ return root
+}
+
+func (o *options) workspace() (*workspace, error) {
+ path, err := manifestPath(o.manifest)
+ if err != nil {
+ return nil, err
+ }
+ return openWorkspace(path)
+}
+
+func (o *options) printf(format string, a ...any) {
+ if !o.quiet {
+ fmt.Printf(format, a...)
+ }
+}
+
+// progressf reports slow work on stderr, only to a person watching.
+func (o *options) progressf(format string, a ...any) {
+ if o.quiet {
+ return
+ }
+ if fi, err := os.Stderr.Stat(); err == nil && fi.Mode()&os.ModeCharDevice != 0 {
+ fmt.Fprintf(os.Stderr, format, a...)
+ }
+}
+
+func exactArgs(n int) cobra.PositionalArgs {
+ return func(cmd *cobra.Command, args []string) error {
+ if len(args) != n {
+ return usageError{fmt.Errorf("%s takes %d argument(s), got %d (see `%s --help`)", cmd.Name(), n, len(args), cmd.CommandPath())}
+ }
+ return nil
+ }
+}
+
+func minArgs(n int) cobra.PositionalArgs {
+ return func(cmd *cobra.Command, args []string) error {
+ if len(args) < n {
+ return usageError{fmt.Errorf("%s needs at least %d argument(s) (see `%s --help`)", cmd.Name(), n, cmd.CommandPath())}
+ }
+ return nil
+ }
+}
diff --git a/nix.go b/nix.go
new file mode 100644
index 0000000..ddfccf7
--- /dev/null
+++ b/nix.go
@@ -0,0 +1,41 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+)
+
+// managedBy names the tool that installs skills instead of this one, such as
+// home-manager. skills then only maintains the lock and the Nix store.
+func managedBy() string { return os.Getenv("SKILLS_MANAGED_BY") }
+
+// nixFetch copies a locked source into the Nix store as the current user and
+// returns its narHash. A later evaluation, even by root without this user's
+// SSH keys, then finds it there instead of fetching it.
+func nixFetch(ctx context.Context, url, rev string) (string, error) {
+ ref := "git+" + url + "?rev=" + rev + "&allRefs=1"
+ cmd := exec.CommandContext(ctx, "nix", "--extra-experimental-features", "nix-command flakes",
+ "flake", "prefetch", "--json", ref)
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ out, err := cmd.Output()
+ if errors.Is(err, exec.ErrNotFound) {
+ return "", fmt.Errorf("SKILLS_MANAGED_BY=%s needs nix, which is not installed", managedBy())
+ }
+ if err != nil {
+ return "", fmt.Errorf("nix could not fetch %s: %s", url, strings.TrimSpace(stderr.String()))
+ }
+ var p struct {
+ Hash string `json:"hash"`
+ }
+ if err := json.Unmarshal(out, &p); err != nil || p.Hash == "" {
+ return "", fmt.Errorf("unexpected output from nix flake prefetch: %s", out)
+ }
+ return p.Hash, nil
+}
diff --git a/nix/home-manager.nix b/nix/home-manager.nix
new file mode 100644
index 0000000..53db658
--- /dev/null
+++ b/nix/home-manager.nix
@@ -0,0 +1,98 @@
+self:
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+
+let
+ cfg = config.programs.skills;
+ lock = lib.importJSON cfg.lockFile;
+
+ fetch =
+ src:
+ builtins.fetchTree (
+ {
+ type = "git";
+ inherit (src) url rev;
+ allRefs = true;
+ }
+ # With a narHash Nix reuses what `skills` already put in the store, so
+ # private sources work even when root evaluates this without SSH keys.
+ // lib.optionalAttrs (src ? narHash) { inherit (src) narHash; }
+ );
+
+ skills = lib.concatMapAttrs (
+ _: src:
+ let
+ tree = fetch src;
+ in
+ lib.mapAttrs (_: path: "${tree}/${path}") src.skills
+ ) lock.sources;
+
+ linkInto = dir: lib.mapAttrs' (name: path: lib.nameValuePair "${dir}/${name}" { source = path; }) skills;
+in
+{
+ options.programs.skills = {
+ enable = lib.mkEnableOption "agent skills from a skills lock file";
+
+ package = lib.mkOption {
+ type = lib.types.package;
+ default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
+ defaultText = lib.literalExpression "skills.packages.\${system}.default";
+ description = "The skills CLI.";
+ };
+
+ lockFile = lib.mkOption {
+ type = lib.types.path;
+ example = lib.literalExpression "./skills.lock";
+ description = "The skills.lock whose skills to install.";
+ };
+
+ manifest = lib.mkOption {
+ type = lib.types.nullOr lib.types.str;
+ default = null;
+ example = "/home/me/dotfiles/skills.json";
+ description = ''
+ Absolute path of the writable skills.json next to {option}`lockFile`,
+ so that `skills add` and `skills update` edit your configuration.
+ '';
+ };
+
+ agents = lib.mkOption {
+ type = lib.types.listOf (
+ lib.types.enum [
+ "claude"
+ "codex"
+ "opencode"
+ ]
+ );
+ default = [ "claude" ];
+ description = "Agents to install the skills into.";
+ };
+ };
+
+ config = lib.mkIf cfg.enable {
+ assertions = [
+ {
+ assertion = lock.version or null == 1;
+ message = "programs.skills.lockFile has an unsupported version; upgrade skills.";
+ }
+ ];
+
+ home.packages = [ cfg.package ];
+
+ home.sessionVariables = {
+ SKILLS_MANAGED_BY = "home-manager";
+ }
+ // lib.optionalAttrs (cfg.manifest != null) { SKILLS_MANIFEST = cfg.manifest; };
+
+ home.file = lib.mkMerge [
+ (lib.mkIf (lib.elem "claude" cfg.agents) (linkInto ".claude/skills"))
+ (lib.mkIf (lib.elem "codex" cfg.agents) (linkInto ".codex/skills"))
+ ];
+
+ xdg.configFile = lib.mkIf (lib.elem "opencode" cfg.agents) (linkInto "opencode/skills");
+ };
+}
diff --git a/nix/package.nix b/nix/package.nix
new file mode 100644
index 0000000..414f173
--- /dev/null
+++ b/nix/package.nix
@@ -0,0 +1,45 @@
+{
+ lib,
+ stdenv,
+ buildGoModule,
+ git,
+ makeWrapper,
+ installShellFiles,
+ version,
+ src,
+}:
+
+buildGoModule {
+ pname = "skills";
+ inherit version src;
+
+ vendorHash = "sha256-7K17JaXFsjf163g5PXCb5ng2gYdotnZ2IDKk8KFjNj0=";
+
+ ldflags = [
+ "-s"
+ "-w"
+ "-X main.version=${version}"
+ ];
+
+ nativeBuildInputs = [
+ makeWrapper
+ installShellFiles
+ ];
+
+ postInstall = ''
+ wrapProgram $out/bin/skills --prefix PATH : ${lib.makeBinPath [ git ]}
+ ''
+ + lib.optionalString (stdenv.buildPlatform.canExecute stdenv.hostPlatform) ''
+ installShellCompletion --cmd skills \
+ --bash <($out/bin/skills completion bash) \
+ --fish <($out/bin/skills completion fish) \
+ --zsh <($out/bin/skills completion zsh)
+ '';
+
+ meta = {
+ description = "Install and update agent skills from git repositories";
+ homepage = "https://git.simenandre.no/skills/about/";
+ license = lib.licenses.mit;
+ mainProgram = "skills";
+ };
+}
diff --git a/skill/skills/SKILL.md b/skill/skills/SKILL.md
new file mode 100644
index 0000000..14f611c
--- /dev/null
+++ b/skill/skills/SKILL.md
@@ -0,0 +1,50 @@
+---
+name: skills
+description: Install, update, list and remove agent skills with the `skills` CLI, which fetches skills from git repositories, locks them to a commit and links them into Claude Code, Codex and OpenCode. Use when the user wants to add a skill from a repository, update or remove installed skills, see which skills are installed and where they came from, or set skills up on a new machine.
+allowed-tools: Bash(skills *), Bash(skills)
+---
+
+# skills
+
+`skills` is a package manager for agent skills. A skill is a directory holding
+a `SKILL.md`; a source is a git repository holding one or more skills.
+
+What the user added is in a manifest, `skills.json`, and the exact commit of
+each source in `skills.lock` next to it. Find them with `skills list`, which
+prints the manifest path when it is empty. `--manifest <path>` or
+`$SKILLS_MANIFEST` points at another one.
+
+## Commands
+
+| Task | Command |
+|---|---|
+| See sources, skills and install state | `skills list` (`--json` for scripts) |
+| Add every skill in a repository | `skills add owner/repo` |
+| Add some skills | `skills add owner/repo --skill pdf --skill xlsx` |
+| Follow a branch or tag, or pin a commit | `skills add owner/repo#v1.2.0` |
+| Name the source | `skills add owner/repo --name mine` |
+| Move to the newest commits | `skills update [source...]` |
+| Remove a source and its skills | `skills remove <source>...` |
+| Install exactly what is locked (new machine) | `skills install` |
+
+Sources are `owner/repo` (GitHub), any git URL, `user@host:path`, or a local
+path. Private repositories work as long as `git ls-remote <url>` works.
+
+## How to work with it
+
+- Run `skills list` first; source names, not skill names, are what `update`
+ and `remove` take.
+- A source without `--skill` follows the repository: skills it gains on
+ update are installed too. Mention this when adding a large collection.
+- `skills` only touches symlinks it created. If it reports a conflict, an
+ existing directory has the skill's name; ask the user before moving it.
+- Exit codes: 0 success, 1 failure, 2 invalid usage, 130 interrupted.
+
+## Managed by Nix
+
+When `SKILLS_MANAGED_BY` is set (the home-manager module sets it to
+`home-manager`), `skills` edits the manifest and lock and fetches sources into
+the Nix store, but does not link anything. Tell the user to apply their
+home-manager configuration (for example `home-manager switch` or their
+`darwin-rebuild`/`nixos-rebuild` alias) for the change to take effect, and
+that the manifest and lock are files in their configuration to commit.
diff --git a/skills_test.go b/skills_test.go
new file mode 100644
index 0000000..eedf8ae
--- /dev/null
+++ b/skills_test.go
@@ -0,0 +1,95 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestParseSpec(t *testing.T) {
+ for in, want := range map[string]Spec{
+ "anthropics/skills": {URL: "https://github.com/anthropics/skills"},
+ "github:anthropics/skills#main": {URL: "https://github.com/anthropics/skills", Ref: "main"},
+ "git@example.com:me/skills.git#v1": {URL: "ssh://git@example.com/me/skills.git", Ref: "v1"},
+ "example.com:skills": {URL: "ssh://example.com/skills"},
+ "https://example.com/skills.git/": {URL: "https://example.com/skills.git"},
+ "git+https://example.com/skills": {URL: "https://example.com/skills"},
+ "/srv/skills": {URL: "file:///srv/skills"},
+ } {
+ got, err := parseSpec(in)
+ if err != nil || got != want {
+ t.Errorf("parseSpec(%q) = %+v, %v; want %+v", in, got, err, want)
+ }
+ }
+ for _, in := range []string{"", "#main", "skills", "a/b/c"} {
+ if _, err := parseSpec(in); err == nil {
+ t.Errorf("parseSpec(%q) succeeded, want an error", in)
+ }
+ }
+}
+
+func TestFrontmatter(t *testing.T) {
+ fm := frontmatter([]byte("---\nname: \"deploy\"\ndescription: Ship it: now\nmetadata:\n name: nested\n---\n# body\nname: no\n"))
+ if fm["name"] != "deploy" || fm["description"] != "Ship it: now" {
+ t.Errorf("frontmatter = %v", fm)
+ }
+ if fm := frontmatter([]byte("# no front matter\n")); len(fm) != 0 {
+ t.Errorf("frontmatter without front matter = %v", fm)
+ }
+}
+
+func TestDiscover(t *testing.T) {
+ root := t.TempDir()
+ write(t, root, "skills/pdf/SKILL.md", "---\nname: pdf\n---\n")
+ write(t, root, "skills/pdf/nested/SKILL.md", "---\nname: inner\n---\n")
+ write(t, root, "deploy/SKILL.md", "no front matter\n")
+ write(t, root, ".hidden/SKILL.md", "---\nname: hidden\n---\n")
+
+ got, err := discover(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 2 || got[0].Name != "deploy" || got[1].Name != "pdf" || got[1].Path != "skills/pdf" {
+ t.Errorf("discover = %+v", got)
+ }
+
+ write(t, root, "other/SKILL.md", "---\nname: pdf\n---\n")
+ if _, err := discover(root); err == nil {
+ t.Error("discover allowed two skills named pdf")
+ }
+}
+
+func TestLink(t *testing.T) {
+ t.Setenv("XDG_DATA_HOME", t.TempDir())
+ checkout := filepath.Join(dataDir(), "checkouts", "repo", "rev")
+ write(t, checkout, "pdf/SKILL.md", "")
+ write(t, checkout, "xlsx/SKILL.md", "")
+ a := agent{Name: "claude", Home: t.TempDir()}
+ write(t, a.skillsDir(), "mine/SKILL.md", "")
+ ws := &workspace{}
+
+ dirs := map[string]string{"pdf": filepath.Join(checkout, "pdf"), "xlsx": filepath.Join(checkout, "xlsx")}
+ if changes, conflicts, err := ws.link(dirs, []agent{a}); err != nil || len(changes) != 2 || len(conflicts) != 0 {
+ t.Fatalf("link = %v, %v, %v", changes, conflicts, err)
+ }
+ delete(dirs, "xlsx")
+ dirs["mine"] = filepath.Join(checkout, "pdf")
+ changes, conflicts, err := ws.link(dirs, []agent{a})
+ if err != nil || len(changes) != 1 || changes[0].Action != "removed" || len(conflicts) != 1 {
+ t.Fatalf("relink = %v, %v, %v", changes, conflicts, err)
+ }
+ if _, err := os.Stat(filepath.Join(a.skillsDir(), "mine", "SKILL.md")); err != nil {
+ t.Errorf("link touched a skill it did not install: %v", err)
+ }
+}
+
+func write(t *testing.T, root, rel, content string) {
+ t.Helper()
+ path := filepath.Join(root, rel)
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/source.go b/source.go
new file mode 100644
index 0000000..577d00a
--- /dev/null
+++ b/source.go
@@ -0,0 +1,80 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+// Spec is a parsed source as the user wrote it: a git URL and an optional ref.
+type Spec struct {
+ URL string
+ Ref string
+}
+
+var (
+ ghShorthand = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`)
+ scpLike = regexp.MustCompile(`^([A-Za-z0-9_.-]+@)?([A-Za-z0-9_.-]+):([^/].*)$`)
+ fullRev = regexp.MustCompile(`^[0-9a-f]{40}$`)
+)
+
+// parseSpec turns what a user typed into a git URL. It accepts:
+//
+// owner/repo, github:owner/repo GitHub
+// https://…, ssh://…, git://…, file://…
+// user@host:path scp-like, as git understands it
+// ./path, /path, ~/path a local repository
+//
+// each optionally followed by #ref (a branch, tag or commit).
+func parseSpec(s string) (Spec, error) {
+ raw, ref, _ := strings.Cut(s, "#")
+ if raw == "" {
+ return Spec{}, fmt.Errorf("empty source %q", s)
+ }
+ var url string
+ switch {
+ case strings.HasPrefix(raw, "github:"):
+ url = "https://github.com/" + strings.TrimPrefix(raw, "github:")
+ case strings.Contains(raw, "://"):
+ // Accept Nix's git+https:// spelling too.
+ url = strings.TrimPrefix(raw, "git+")
+ case strings.HasPrefix(raw, "/"), strings.HasPrefix(raw, "."), strings.HasPrefix(raw, "~"):
+ p := raw
+ if strings.HasPrefix(p, "~") {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return Spec{}, err
+ }
+ p = filepath.Join(home, strings.TrimPrefix(p, "~"))
+ }
+ abs, err := filepath.Abs(p)
+ if err != nil {
+ return Spec{}, err
+ }
+ url = "file://" + abs
+ case scpLike.MatchString(raw):
+ // Nix only takes URLs, and the lock is shared with it.
+ m := scpLike.FindStringSubmatch(raw)
+ url = "ssh://" + m[1] + m[2] + "/" + m[3]
+ case ghShorthand.MatchString(raw):
+ url = "https://github.com/" + raw
+ default:
+ return Spec{}, fmt.Errorf("%q is not a source; use owner/repo, a git URL or a path", s)
+ }
+ return Spec{URL: strings.TrimSuffix(url, "/"), Ref: ref}, nil
+}
+
+// defaultSourceName names a source after its only skill, or else after its
+// repository.
+func defaultSourceName(spec Spec, selected []Skill) string {
+ if len(selected) == 1 {
+ return selected[0].Name
+ }
+ u := spec.URL
+ if i := strings.LastIndexAny(u, "/:"); i >= 0 {
+ u = u[i+1:]
+ }
+ return strings.TrimSuffix(u, ".git")
+}
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)
+}