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 /commands.go | |
| download | skills-0.1.0.tar.gz skills-0.1.0.zip | |
Diffstat (limited to 'commands.go')
| -rw-r--r-- | commands.go | 462 |
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:] +} |
