f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
281 lines
7.6 KiB
Go
281 lines
7.6 KiB
Go
// 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
|
|
}
|