diff options
| author | Simen A. W. Olsen <hello@simenandre.no> | 2026-10-25 12:41:28 +0100 |
|---|---|---|
| committer | Simen A. W. Olsen <hello@simenandre.no> | 2026-10-25 12:41:28 +0100 |
| commit | c596038adc7cdbd64988e61fee856d533b58c25d (patch) | |
| tree | a02fde25835416d143ff56b807e640f6a7e9cf82 | |
| download | skills-main.tar.gz skills-main.zip | |
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | LICENSE | 21 | ||||
| -rw-r--r-- | README.md | 124 | ||||
| -rw-r--r-- | commands.go | 462 | ||||
| -rw-r--r-- | discover.go | 125 | ||||
| -rw-r--r-- | flake.lock | 27 | ||||
| -rw-r--r-- | flake.nix | 34 | ||||
| -rw-r--r-- | git.go | 195 | ||||
| -rw-r--r-- | go.mod | 10 | ||||
| -rw-r--r-- | go.sum | 10 | ||||
| -rw-r--r-- | install.go | 173 | ||||
| -rw-r--r-- | main.go | 128 | ||||
| -rw-r--r-- | nix.go | 41 | ||||
| -rw-r--r-- | nix/home-manager.nix | 98 | ||||
| -rw-r--r-- | nix/package.nix | 45 | ||||
| -rw-r--r-- | skill/skills/SKILL.md | 50 | ||||
| -rw-r--r-- | skills_test.go | 95 | ||||
| -rw-r--r-- | source.go | 80 | ||||
| -rw-r--r-- | workspace.go | 186 |
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 @@ -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 + ]; + }; + }); + }; +} @@ -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) +} @@ -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 +) @@ -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 +} @@ -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 + } +} @@ -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) +} |
