summaryrefslogtreecommitdiffstats
path: root/commands.go
diff options
context:
space:
mode:
authorSimen A. W. Olsen <hello@simenandre.no>2026-10-25 12:41:28 +0100
committerSimen A. W. Olsen <hello@simenandre.no>2026-10-25 12:41:28 +0100
commitc596038adc7cdbd64988e61fee856d533b58c25d (patch)
treea02fde25835416d143ff56b807e640f6a7e9cf82 /commands.go
downloadskills-0.1.0.tar.gz
skills-0.1.0.zip
feat: skills, a package manager for agent skillsHEADv0.1.0main
Diffstat (limited to 'commands.go')
-rw-r--r--commands.go462
1 files changed, 462 insertions, 0 deletions
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:]
+}