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:] }