mirror of
https://github.com/foomo/posh.git
synced 2026-10-04 14:06:58 +00:00
160 lines
4.7 KiB
Go
160 lines
4.7 KiB
Go
package plugin
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/pkg/errors"
|
|
)
|
|
|
|
// List asks plg for its command catalog, requiring the optional Lister
|
|
// interface. It returns a descriptive error when the plugin does not implement
|
|
// it, rather than leaving callers to repeat the type assertion.
|
|
//
|
|
// Resolving the plugin itself is the caller's job: the posh CLI holds its
|
|
// provider in package state, and a downstream shell may have one in hand
|
|
// already.
|
|
func List(ctx context.Context, plg any) ([]CommandInfo, error) {
|
|
lister, ok := plg.(Lister)
|
|
if !ok {
|
|
return nil, errors.New("this project's posh shell does not support the agent command catalog")
|
|
}
|
|
|
|
return lister.List(ctx), nil
|
|
}
|
|
|
|
// ListSkill asks plg for its command catalog together with the extra SKILL.md
|
|
// markdown each command contributes.
|
|
//
|
|
// A plugin implementing only Lister falls back to a catalog without prose, so
|
|
// the skill still renders for shells that have not opted in.
|
|
func ListSkill(ctx context.Context, plg any) ([]SkillCommand, error) {
|
|
if lister, ok := plg.(SkillLister); ok {
|
|
return lister.ListSkill(ctx), nil
|
|
}
|
|
|
|
commands, err := List(ctx, plg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ret := make([]SkillCommand, 0, len(commands))
|
|
for _, value := range commands {
|
|
ret = append(ret, SkillCommand{CommandInfo: value})
|
|
}
|
|
|
|
return ret, nil
|
|
}
|
|
|
|
// SkillMetadataOf returns the root SKILL.md frontmatter plg supplies, or the
|
|
// zero value - which renders posh's defaults - when it does not implement
|
|
// SkillMetadataer.
|
|
//
|
|
// This is the plugin-level frontmatter, covering the root skill only. A command
|
|
// supplies its own through command.SkillMetadataer.
|
|
func SkillMetadataOf(ctx context.Context, plg any) SkillMetadata {
|
|
if value, ok := plg.(SkillMetadataer); ok {
|
|
return value.SkillMetadata(ctx)
|
|
}
|
|
|
|
return SkillMetadata{}
|
|
}
|
|
|
|
// WriteSkill generates the root skill and one skill per command into the skills
|
|
// directory at path, creating it if needed. Passing an empty path writes to
|
|
// DefaultSkillsPath.
|
|
//
|
|
// One skill per command rather than one file for all of them: a single file
|
|
// means an agent pays for every command in the project in order to use one. Only
|
|
// commands that Describes() get their own; the rest are covered by the root
|
|
// skill's index.
|
|
//
|
|
// Previously generated skills are removed first, so a command that was renamed
|
|
// or dropped since the last run leaves nothing behind - regenerating is what
|
|
// keeps the result honest, and a stale directory would otherwise survive
|
|
// indefinitely because nothing ever revisits it. Hand-written skills alongside
|
|
// them are untouched, see CommandSkillPrefix.
|
|
//
|
|
// It returns the paths written, in the order written.
|
|
func WriteSkill(path string, meta SkillMetadata, commands []SkillCommand) ([]string, error) {
|
|
root := SkillsPath(path)
|
|
|
|
if err := RemoveSkill(root); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ret := []string{filepath.Join(root, RootSkillName, "SKILL.md")}
|
|
|
|
contents := []string{RenderRootSkill(meta, commands)}
|
|
|
|
for _, c := range commands {
|
|
if !c.Describes() {
|
|
continue
|
|
}
|
|
|
|
ret = append(ret, filepath.Join(root, c.SkillName(), "SKILL.md"))
|
|
contents = append(contents, RenderCommandSkill(c))
|
|
}
|
|
|
|
for i, name := range ret {
|
|
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
|
return nil, errors.Wrapf(err, "failed to create directory for %s", name)
|
|
}
|
|
|
|
if err := os.WriteFile(name, []byte(contents[i]), 0o644); err != nil { //nolint:gosec
|
|
return nil, errors.Wrapf(err, "failed to write %s", name)
|
|
}
|
|
}
|
|
|
|
return ret, nil
|
|
}
|
|
|
|
// RemoveSkill deletes every generated skill directory under the skills
|
|
// directory at path, falling back to DefaultSkillsPath when empty.
|
|
//
|
|
// Only the root skill and directories carrying CommandSkillPrefix are removed,
|
|
// so a hand-written skill in the same directory survives. A missing directory is
|
|
// not an error, so uninstall is idempotent - and it deliberately needs no
|
|
// config or resolved plugin, so cleanup still works when the shell itself is
|
|
// broken.
|
|
func RemoveSkill(path string) error {
|
|
root := SkillsPath(path)
|
|
|
|
entries, err := os.ReadDir(root)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
|
|
return errors.Wrapf(err, "failed to read %s", root)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
if name := entry.Name(); name != RootSkillName && !strings.HasPrefix(name, CommandSkillPrefix) {
|
|
continue
|
|
}
|
|
|
|
if err := os.RemoveAll(filepath.Join(root, entry.Name())); err != nil {
|
|
return errors.Wrapf(err, "failed to remove %s", filepath.Join(root, entry.Name()))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SkillsPath resolves a user supplied skills directory, falling back to
|
|
// DefaultSkillsPath when empty.
|
|
func SkillsPath(path string) string {
|
|
if path == "" {
|
|
return DefaultSkillsPath
|
|
}
|
|
|
|
return path
|
|
}
|