From c596038adc7cdbd64988e61fee856d533b58c25d Mon Sep 17 00:00:00 2001 From: "Simen A. W. Olsen" Date: Sun, 25 Oct 2026 12:41:28 +0100 Subject: feat: skills, a package manager for agent skills --- .gitignore | 3 + LICENSE | 21 +++ README.md | 124 ++++++++++++++ commands.go | 462 ++++++++++++++++++++++++++++++++++++++++++++++++++ discover.go | 125 ++++++++++++++ flake.lock | 27 +++ flake.nix | 34 ++++ git.go | 195 +++++++++++++++++++++ go.mod | 10 ++ go.sum | 10 ++ install.go | 173 +++++++++++++++++++ main.go | 128 ++++++++++++++ nix.go | 41 +++++ nix/home-manager.nix | 98 +++++++++++ nix/package.nix | 45 +++++ skill/skills/SKILL.md | 50 ++++++ skills_test.go | 95 +++++++++++ source.go | 80 +++++++++ workspace.go | 186 ++++++++++++++++++++ 19 files changed, 1907 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 commands.go create mode 100644 discover.go create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 git.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 install.go create mode 100644 main.go create mode 100644 nix.go create mode 100644 nix/home-manager.nix create mode 100644 nix/package.nix create mode 100644 skill/skills/SKILL.md create mode 100644 skills_test.go create mode 100644 source.go create mode 100644 workspace.go 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 ... # uninstall +skills install # install exactly what is locked +``` + +`skills --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 ", + Short: "Add the skills in a git repository", + Long: `Add the skills in a git repository and install them. + + 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 ...", + 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 [...]", + 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 ` 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 ...` | +| 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 ` 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) +} -- cgit v1.3.1