// ABOUTME: `skills` command group: install and list the AgentsView skill // ABOUTME: files that teach coding-agent harnesses to search session history. package main import ( "context" "encoding/json" "fmt" "io" "os" "path/filepath" "time" "github.com/spf13/cobra" gitrepo "go.kenn.io/kit/git/repo" "go.kenn.io/agentsview/internal/skills" ) // skillFileName is the file every harness's skill directory installs, as // documented on skills.TargetDir. const skillFileName = "SKILL.md" func newSkillsCommand() *cobra.Command { cmd := &cobra.Command{ Use: "skills", Short: "Install and list AgentsView skills for coding-agent harnesses", GroupID: groupMeta, SilenceUsage: true, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }, } cmd.AddCommand(newSkillsInstallCommand()) cmd.AddCommand(newSkillsListCommand()) return cmd } func newSkillsInstallCommand() *cobra.Command { var ( harnessNames []string project bool force bool ) cmd := &cobra.Command{ Use: "install", Short: "Install AgentsView skill files for coding-agent harnesses", Args: cobra.NoArgs, SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { harnesses, err := resolveSkillHarnesses(harnessNames) if err != nil { return err } base, err := skillsBaseDir(cmd.Context(), project) if err != nil { return err } return runSkillsInstall(cmd.OutOrStdout(), harnesses, base, force) }, } flags := cmd.Flags() flags.StringArrayVar(&harnessNames, "harness", nil, "Harness to install for (claude or agents); repeatable, default both") flags.BoolVar(&project, "project", false, "Install into the project (git root of the current directory) "+ "instead of the user home directory") flags.BoolVar(&force, "force", false, "Overwrite files that were modified or were not generated by agentsview") return cmd } // runSkillsInstall renders and writes each harness's skill file under base, // printing one line per target. It processes every target before returning // so a refusal on one harness never blocks another, then reports a non-nil // error if any target was refused. func runSkillsInstall(out io.Writer, harnesses []skills.Harness, base string, force bool) error { var refused []string for _, h := range harnesses { rendered, err := skills.Render(h, version) if err != nil { return err } dir := skills.TargetDir(h, base) path := filepath.Join(dir, skillFileName) existing, err := readSkillFile(path) if err != nil { return err } state := skills.Classify(existing, rendered) if !force && (state == skills.StateModified || state == skills.StateForeign) { fmt.Fprintf(out, "%s was modified (or not generated); use --force to overwrite\n", path) refused = append(refused, path) continue } if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("skills: create %s: %w", dir, err) } if err := os.WriteFile(path, []byte(rendered.Content), 0o644); err != nil { return fmt.Errorf("skills: write %s: %w", path, err) } switch state { case skills.StateMissing: fmt.Fprintf(out, "installed %s\n", path) case skills.StateCurrent: fmt.Fprintf(out, "up to date %s\n", path) default: // StateStale, or StateModified/StateForeign forced fmt.Fprintf(out, "updated %s\n", path) } } if len(refused) > 0 { return fmt.Errorf( "skills install: %d target(s) refused; rerun with --force to overwrite", len(refused), ) } return nil } func newSkillsListCommand() *cobra.Command { var project bool cmd := &cobra.Command{ Use: "list", Short: "List AgentsView skill files and their install state", Args: cobra.NoArgs, SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { base, err := skillsBaseDir(cmd.Context(), project) if err != nil { return err } rows, err := listSkillRows(base, project) if err != nil { return err } if outputFormat(cmd) == "json" { return json.NewEncoder(cmd.OutOrStdout()).Encode(rows) } return printSkillListHuman(cmd.OutOrStdout(), rows) }, } flags := cmd.Flags() flags.BoolVar(&project, "project", false, "List project-level (git root of the current directory) installs "+ "instead of the user home directory") registerFormatFlags(flags) return cmd } // skillListRow is one row of `skills list` output, in both human and JSON form. type skillListRow struct { Harness string `json:"harness"` Level string `json:"level"` State string `json:"state"` Path string `json:"path"` } // listSkillRows classifies every harness's skill file under base against a // fresh render. func listSkillRows(base string, project bool) ([]skillListRow, error) { level := "user" if project { level = "project" } harnesses := skills.AllHarnesses() rows := make([]skillListRow, 0, len(harnesses)) for _, h := range harnesses { rendered, err := skills.Render(h, version) if err != nil { return nil, err } dir := skills.TargetDir(h, base) path := filepath.Join(dir, skillFileName) existing, err := readSkillFile(path) if err != nil { return nil, err } state := skills.Classify(existing, rendered) rows = append(rows, skillListRow{ Harness: string(h), Level: level, State: skillStateString(state), Path: path, }) } return rows, nil } func printSkillListHuman(w io.Writer, rows []skillListRow) error { fmt.Fprintf(w, "%-8s %-8s %-8s %s\n", "HARNESS", "LEVEL", "STATE", "PATH") for _, r := range rows { fmt.Fprintf(w, "%-8s %-8s %-8s %s\n", r.Harness, r.Level, r.State, r.Path) } return nil } func skillStateString(s skills.InstalledState) string { switch s { case skills.StateMissing: return "missing" case skills.StateCurrent: return "current" case skills.StateStale: return "stale" case skills.StateModified: return "modified" case skills.StateForeign: return "foreign" default: return "unknown" } } // readSkillFile reads path, returning nil (not an empty slice) when the file // does not exist so the result matches skills.Classify's "missing" contract. func readSkillFile(path string) ([]byte, error) { content, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("skills: read %s: %w", path, err) } return content, nil } // resolveSkillHarnesses maps --harness flag values to skills.Harness, // defaulting to every harness when none were given. func resolveSkillHarnesses(names []string) ([]skills.Harness, error) { if len(names) == 0 { return skills.AllHarnesses(), nil } out := make([]skills.Harness, 0, len(names)) for _, name := range names { switch name { case string(skills.HarnessClaude): out = append(out, skills.HarnessClaude) case string(skills.HarnessAgents): out = append(out, skills.HarnessAgents) default: return nil, fmt.Errorf("skills: unknown --harness %q (want claude or agents)", name) } } return out, nil } // skillsBaseDir resolves the install base: the user home directory by // default, or the git root of the current directory (falling back to the // current directory itself outside a repo) when project is true. func skillsBaseDir(ctx context.Context, project bool) (string, error) { if !project { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("skills: resolve home directory: %w", err) } return home, nil } cwd, err := os.Getwd() if err != nil { return "", fmt.Errorf("skills: resolve working directory: %w", err) } opCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() root, err := gitrepo.Root(opCtx, cwd) if err != nil || root == "" { return cwd, nil } return root, nil }