998 lines
28 KiB
Go
998 lines
28 KiB
Go
// Package pluginpkg handles installed Reasonix plugin packages.
|
|
//
|
|
// Plugin packages are higher-level bundles that can contribute skills, hooks,
|
|
// and MCP servers. They are intentionally parsed into package-local structs so
|
|
// config/hook/desktop callers can adapt them without creating import cycles.
|
|
package pluginpkg
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"reasonix/internal/command"
|
|
"reasonix/internal/fileutil"
|
|
fileencoding "reasonix/internal/fileutil/encoding"
|
|
"reasonix/internal/frontmatter"
|
|
)
|
|
|
|
const (
|
|
NativeManifest = "reasonix-plugin.json"
|
|
CodexManifest = ".codex-plugin/plugin.json"
|
|
ClaudeManifest = ".claude-plugin/plugin.json"
|
|
StateFilename = "plugin-packages.json"
|
|
|
|
claudeSettingsPath = ".claude/settings.json"
|
|
claudeInstructions = "CLAUDE.md"
|
|
)
|
|
|
|
var validName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
|
|
|
|
// Package is one parsed plugin package rooted on disk.
|
|
type Package struct {
|
|
Root string
|
|
ManifestKind string
|
|
Manifest Manifest
|
|
}
|
|
|
|
type Inventory struct {
|
|
Skills []SkillRef
|
|
Commands []CommandRef
|
|
Hooks []HookRef
|
|
MCPServers []MCPServerRef
|
|
}
|
|
|
|
type SkillRef struct {
|
|
Name string
|
|
Description string
|
|
Path string
|
|
Invocation string
|
|
RunAs string
|
|
}
|
|
|
|
// CommandRef is one custom slash command a plugin contributes: a flat <name>.md
|
|
// prompt template invoked as /<name> (Claude plugin commands map here 1:1).
|
|
type CommandRef struct {
|
|
Name string
|
|
Description string
|
|
ArgHint string
|
|
Path string
|
|
Invocation string
|
|
}
|
|
|
|
type HookRef struct {
|
|
Event string
|
|
Match string
|
|
Command string
|
|
ContextFile string
|
|
Description string
|
|
}
|
|
|
|
type MCPServerRef struct {
|
|
Name string
|
|
Transport string
|
|
Command string
|
|
URL string
|
|
}
|
|
|
|
// Manifest is the normalized manifest shape used by Reasonix.
|
|
type Manifest struct {
|
|
Name string
|
|
Version string
|
|
Description string
|
|
Homepage string
|
|
Repository string
|
|
Skills []string
|
|
// Commands are directories of flat <name>.md slash-command prompt templates
|
|
// (rendered with $ARGUMENTS/$1..$N on /<name>). Declared explicitly in a
|
|
// manifest or adopted from a Claude plugin's conventional commands/ dir.
|
|
Commands []string
|
|
Hooks map[string][]Hook
|
|
MCPServers map[string]MCPServer
|
|
}
|
|
|
|
type Hook struct {
|
|
Match string `json:"match,omitempty"`
|
|
Command string `json:"command,omitempty"`
|
|
ContextFile string `json:"contextFile,omitempty"`
|
|
ShellCommand bool `json:"shellCommand,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
Timeout int `json:"timeout,omitempty"`
|
|
Cwd string `json:"cwd,omitempty"`
|
|
Env map[string]string `json:"env,omitempty"`
|
|
}
|
|
|
|
type MCPServer struct {
|
|
Type string `json:"type,omitempty"`
|
|
Command string `json:"command,omitempty"`
|
|
Args []string `json:"args,omitempty"`
|
|
Env map[string]string `json:"env,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
Headers map[string]string `json:"headers,omitempty"`
|
|
AutoStart *bool `json:"auto_start,omitempty"`
|
|
Tier string `json:"tier,omitempty"`
|
|
}
|
|
|
|
// State is persisted at <Reasonix home>/plugin-packages.json.
|
|
type State struct {
|
|
Version int `json:"version"`
|
|
Plugins []InstalledPlugin `json:"plugins"`
|
|
}
|
|
|
|
type InstalledPlugin struct {
|
|
Name string `json:"name"`
|
|
Source string `json:"source,omitempty"`
|
|
Root string `json:"root"`
|
|
Version string `json:"version,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
ManifestKind string `json:"manifestKind,omitempty"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
type InstalledPackage struct {
|
|
Installed InstalledPlugin
|
|
Package Package
|
|
Warnings []string
|
|
}
|
|
|
|
func IsValidName(name string) bool { return validName.MatchString(strings.TrimSpace(name)) }
|
|
|
|
func StatePath(reasonixHome string) string {
|
|
return filepath.Join(reasonixHome, StateFilename)
|
|
}
|
|
|
|
func PluginsDir(reasonixHome string) string {
|
|
return filepath.Join(reasonixHome, "plugins")
|
|
}
|
|
|
|
func InstallRoot(reasonixHome, name string) string {
|
|
return filepath.Join(PluginsDir(reasonixHome), name)
|
|
}
|
|
|
|
func LoadState(reasonixHome string) (State, error) {
|
|
var st State
|
|
b, err := fileencoding.ReadFileUTF8(StatePath(reasonixHome))
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return State{Version: 1}, nil
|
|
}
|
|
return State{}, err
|
|
}
|
|
if err := json.Unmarshal(b, &st); err != nil {
|
|
return State{}, err
|
|
}
|
|
if st.Version == 0 {
|
|
st.Version = 1
|
|
}
|
|
sort.SliceStable(st.Plugins, func(i, j int) bool { return st.Plugins[i].Name < st.Plugins[j].Name })
|
|
return st, nil
|
|
}
|
|
|
|
func SaveState(reasonixHome string, st State) error {
|
|
if st.Version == 0 {
|
|
st.Version = 1
|
|
}
|
|
sort.SliceStable(st.Plugins, func(i, j int) bool { return st.Plugins[i].Name < st.Plugins[j].Name })
|
|
b, err := json.MarshalIndent(st, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b = append(b, '\n')
|
|
return fileutil.AtomicWriteFile(StatePath(reasonixHome), b, 0o644)
|
|
}
|
|
|
|
// stateMu serialises the read-modify-write of the state file within this
|
|
// process. SaveState writes atomically (tmpfile + rename), so concurrent
|
|
// callers never see a half-written file; this lock additionally prevents two
|
|
// in-process load-modify-save cycles from clobbering each other's edit. It is
|
|
// not a cross-process lock — concurrent Reasonix processes can still race.
|
|
var stateMu sync.Mutex
|
|
|
|
func Upsert(reasonixHome string, p InstalledPlugin) error {
|
|
if !IsValidName(p.Name) {
|
|
return fmt.Errorf("invalid plugin name %q", p.Name)
|
|
}
|
|
stateMu.Lock()
|
|
defer stateMu.Unlock()
|
|
st, err := LoadState(reasonixHome)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i := range st.Plugins {
|
|
if st.Plugins[i].Name == p.Name {
|
|
st.Plugins[i] = p
|
|
return SaveState(reasonixHome, st)
|
|
}
|
|
}
|
|
st.Plugins = append(st.Plugins, p)
|
|
return SaveState(reasonixHome, st)
|
|
}
|
|
|
|
func Remove(reasonixHome, name string) (InstalledPlugin, bool, error) {
|
|
stateMu.Lock()
|
|
defer stateMu.Unlock()
|
|
st, err := LoadState(reasonixHome)
|
|
if err != nil {
|
|
return InstalledPlugin{}, false, err
|
|
}
|
|
for i, p := range st.Plugins {
|
|
if p.Name != name {
|
|
continue
|
|
}
|
|
st.Plugins = append(st.Plugins[:i], st.Plugins[i+1:]...)
|
|
return p, true, SaveState(reasonixHome, st)
|
|
}
|
|
return InstalledPlugin{}, false, nil
|
|
}
|
|
|
|
func SetEnabled(reasonixHome, name string, enabled bool) error {
|
|
stateMu.Lock()
|
|
defer stateMu.Unlock()
|
|
st, err := LoadState(reasonixHome)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i := range st.Plugins {
|
|
if st.Plugins[i].Name == name {
|
|
st.Plugins[i].Enabled = enabled
|
|
return SaveState(reasonixHome, st)
|
|
}
|
|
}
|
|
return fmt.Errorf("plugin %q is not installed", name)
|
|
}
|
|
|
|
func LoadInstalled(reasonixHome string) ([]InstalledPackage, []string) {
|
|
st, err := LoadState(reasonixHome)
|
|
if err != nil {
|
|
return nil, []string{err.Error()}
|
|
}
|
|
var out []InstalledPackage
|
|
var warnings []string
|
|
for _, installed := range st.Plugins {
|
|
if !installed.Enabled {
|
|
continue
|
|
}
|
|
root := ResolveRoot(reasonixHome, installed.Root)
|
|
pkg, pkgWarnings, err := ParseDir(root)
|
|
if err != nil {
|
|
warnings = append(warnings, fmt.Sprintf("%s: %v", installed.Name, err))
|
|
continue
|
|
}
|
|
out = append(out, InstalledPackage{Installed: installed, Package: pkg, Warnings: pkgWarnings})
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool { return out[i].Installed.Name < out[j].Installed.Name })
|
|
return out, warnings
|
|
}
|
|
|
|
func ResolveRoot(reasonixHome, root string) string {
|
|
if filepath.IsAbs(root) {
|
|
return filepath.Clean(root)
|
|
}
|
|
return filepath.Join(reasonixHome, filepath.Clean(root))
|
|
}
|
|
|
|
func RelativeRoot(reasonixHome, root string) string {
|
|
if rel, err := filepath.Rel(reasonixHome, root); err == nil && rel != "." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".." {
|
|
return filepath.ToSlash(rel)
|
|
}
|
|
return filepath.Clean(root)
|
|
}
|
|
|
|
func ParseDir(root string) (Package, []string, error) {
|
|
root = filepath.Clean(root)
|
|
if pkg, warnings, err := parseNative(filepath.Join(root, NativeManifest), root); err == nil {
|
|
return pkg, warnings, nil
|
|
}
|
|
if pkg, warnings, err := parseCodex(filepath.Join(root, CodexManifest), root); err == nil {
|
|
return pkg, warnings, nil
|
|
}
|
|
if pkg, warnings, err := parseClaudePlugin(filepath.Join(root, ClaudeManifest), root); err == nil {
|
|
return pkg, warnings, nil
|
|
}
|
|
return Package{}, nil, fmt.Errorf("no %s, %s, or %s found", NativeManifest, CodexManifest, ClaudeManifest)
|
|
}
|
|
|
|
func parseNative(path, root string) (Package, []string, error) {
|
|
var raw struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
Description string `json:"description"`
|
|
Homepage string `json:"homepage"`
|
|
Repository string `json:"repository"`
|
|
Skills json.RawMessage `json:"skills"`
|
|
Commands json.RawMessage `json:"commands"`
|
|
Hooks map[string][]Hook `json:"hooks"`
|
|
MCPServers map[string]MCPServer `json:"mcpServers"`
|
|
}
|
|
if err := readJSONFile(path, &raw); err != nil {
|
|
return Package{}, nil, err
|
|
}
|
|
skills, err := parseSkillPaths(raw.Skills)
|
|
if err != nil {
|
|
return Package{}, nil, err
|
|
}
|
|
commands, err := parseSkillPaths(raw.Commands)
|
|
if err != nil {
|
|
return Package{}, nil, err
|
|
}
|
|
manifest := Manifest{
|
|
Name: strings.TrimSpace(raw.Name),
|
|
Version: strings.TrimSpace(raw.Version),
|
|
Description: strings.TrimSpace(raw.Description),
|
|
Homepage: strings.TrimSpace(raw.Homepage),
|
|
Repository: strings.TrimSpace(raw.Repository),
|
|
Skills: skills,
|
|
Commands: commands,
|
|
Hooks: normalizeHooks(raw.Hooks),
|
|
MCPServers: raw.MCPServers,
|
|
}
|
|
if err := validateManifest(root, &manifest); err != nil {
|
|
return Package{}, nil, err
|
|
}
|
|
warnings := applyClaudeCompatibility(root, &manifest)
|
|
if err := validateManifest(root, &manifest); err != nil {
|
|
return Package{}, warnings, err
|
|
}
|
|
return Package{Root: root, ManifestKind: "reasonix", Manifest: manifest}, warnings, nil
|
|
}
|
|
|
|
func parseCodex(path, root string) (Package, []string, error) {
|
|
return parseCodexLike(path, root, "codex", true)
|
|
}
|
|
|
|
func parseClaudePlugin(path, root string) (Package, []string, error) {
|
|
return parseCodexLike(path, root, "claude", false)
|
|
}
|
|
|
|
func parseCodexLike(path, root, kind string, includeCodexSessionStartHook bool) (Package, []string, error) {
|
|
var raw struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
Description string `json:"description"`
|
|
Homepage string `json:"homepage"`
|
|
Repository string `json:"repository"`
|
|
Skills json.RawMessage `json:"skills"`
|
|
Commands json.RawMessage `json:"commands"`
|
|
}
|
|
if err := readJSONFile(path, &raw); err != nil {
|
|
return Package{}, nil, err
|
|
}
|
|
skills, err := parseSkillPaths(raw.Skills)
|
|
if err != nil {
|
|
return Package{}, nil, err
|
|
}
|
|
commands, err := parseSkillPaths(raw.Commands)
|
|
if err != nil {
|
|
return Package{}, nil, err
|
|
}
|
|
manifest := Manifest{
|
|
Name: strings.TrimSpace(raw.Name),
|
|
Version: strings.TrimSpace(raw.Version),
|
|
Description: strings.TrimSpace(raw.Description),
|
|
Homepage: strings.TrimSpace(raw.Homepage),
|
|
Repository: strings.TrimSpace(raw.Repository),
|
|
Skills: skills,
|
|
Commands: commands,
|
|
}
|
|
hookPath := filepath.Join(root, "hooks", "session-start-codex")
|
|
if includeCodexSessionStartHook {
|
|
if info, err := os.Stat(hookPath); err == nil && info.Mode().IsRegular() {
|
|
manifest.Hooks = map[string][]Hook{
|
|
"SessionStart": {{
|
|
Command: hookPath,
|
|
Cwd: root,
|
|
Description: "Codex-compatible session start hook from " + manifest.Name,
|
|
}},
|
|
}
|
|
}
|
|
}
|
|
var warnings []string
|
|
if kind == "claude" {
|
|
warnings = append(warnings, applyClaudeConventionDirs(root, &manifest)...)
|
|
}
|
|
warnings = append(warnings, applyClaudeCompatibility(root, &manifest)...)
|
|
if err := validateManifest(root, &manifest); err != nil {
|
|
return Package{}, warnings, err
|
|
}
|
|
return Package{Root: root, ManifestKind: kind, Manifest: manifest}, warnings, nil
|
|
}
|
|
|
|
// claudeConventionSkillDirs are the directories a Claude plugin loads skills
|
|
// from BY CONVENTION — the official plugin layout auto-discovers skills/ (and
|
|
// packs in the wild use .claude/skills/) without declaring them in
|
|
// plugin.json, whose manifest usually carries metadata only.
|
|
var claudeConventionSkillDirs = []string{"skills", ".claude/skills"}
|
|
|
|
// claudeConventionCommandDirs are the directories a Claude plugin loads slash
|
|
// commands from by convention. A command is a flat <name>.md prompt template
|
|
// the user invokes as /<name> — exactly Reasonix's custom-command shape
|
|
// (internal/command) — so these directories map onto Manifest.Commands and
|
|
// join command discovery at the lowest priority. Unlike skill dirs they are
|
|
// adopted even when the manifest declares skills explicitly, because
|
|
// plugin.json never lists commands.
|
|
var claudeConventionCommandDirs = []string{"commands", ".claude/commands"}
|
|
|
|
// claudeUnmappedCapabilities are the conventional Claude plugin surfaces
|
|
// Reasonix does not map yet. Their presence is worth a warning: silently
|
|
// installing a package while dropping half its capabilities reads as "install
|
|
// succeeded" when it mostly didn't.
|
|
var claudeUnmappedCapabilities = []string{"agents", "hooks/hooks.json", ".mcp.json"}
|
|
|
|
// applyClaudeConventionDirs fills manifest.Skills from the conventional skill
|
|
// directories when the manifest declares none (the standard Claude plugin
|
|
// shape), adopts conventional command directories into manifest.Commands, and
|
|
// reports the conventional capabilities Reasonix cannot map.
|
|
func applyClaudeConventionDirs(root string, manifest *Manifest) []string {
|
|
var warnings []string
|
|
if len(manifest.Skills) == 0 {
|
|
for _, rel := range claudeConventionSkillDirs {
|
|
dir := filepath.Join(root, filepath.FromSlash(rel))
|
|
if dirContainsSkill(dir) {
|
|
manifest.Skills = append(manifest.Skills, rel)
|
|
}
|
|
}
|
|
}
|
|
for _, rel := range claudeConventionCommandDirs {
|
|
dir := filepath.Join(root, filepath.FromSlash(rel))
|
|
if dirContainsCommandMd(dir) && !containsPathEntry(manifest.Commands, rel) {
|
|
manifest.Commands = append(manifest.Commands, rel)
|
|
}
|
|
}
|
|
for _, rel := range claudeUnmappedCapabilities {
|
|
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); err == nil {
|
|
warnings = append(warnings, fmt.Sprintf("claude plugin declares %s, which Reasonix does not map yet; that capability will not be installed", rel))
|
|
}
|
|
}
|
|
return warnings
|
|
}
|
|
|
|
// containsPathEntry reports whether the manifest path list already names rel
|
|
// (slash-normalized), so convention adoption never duplicates an explicit entry.
|
|
func containsPathEntry(paths []string, rel string) bool {
|
|
for _, p := range paths {
|
|
if filepath.ToSlash(filepath.Clean(filepath.FromSlash(p))) == rel {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// dirContainsSkill reports whether dir holds at least one skill definition
|
|
// (<dir>/<name>/SKILL.md), so an empty conventional directory is not adopted
|
|
// as a skill root.
|
|
func dirContainsSkill(dir string) bool {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
if info, err := os.Stat(filepath.Join(dir, e.Name(), "SKILL.md")); err == nil && info.Mode().IsRegular() {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// dirContainsCommandMd reports whether dir holds at least one command
|
|
// definition. It delegates to the runtime loader (internal/command), so the
|
|
// adoption gate and what /<name> actually loads can never diverge — including
|
|
// arbitrarily nested namespace layouts like commands/a/b/c/commit.md.
|
|
func dirContainsCommandMd(dir string) bool {
|
|
cmds, _ := command.Load(dir) // best-effort: a missing dir or malformed files load nothing
|
|
return len(cmds) > 0
|
|
}
|
|
|
|
func ManifestPath(kind string) string {
|
|
switch kind {
|
|
case "reasonix":
|
|
return NativeManifest
|
|
case "codex":
|
|
return CodexManifest
|
|
case "claude":
|
|
return ClaudeManifest
|
|
default:
|
|
return NativeManifest
|
|
}
|
|
}
|
|
|
|
func ManifestPaths() []string {
|
|
return []string{NativeManifest, CodexManifest, ClaudeManifest}
|
|
}
|
|
|
|
func applyClaudeCompatibility(root string, manifest *Manifest) []string {
|
|
appendRootClaudeInstructions(root, manifest)
|
|
return appendClaudeSettingsHooks(root, manifest)
|
|
}
|
|
|
|
func appendRootClaudeInstructions(root string, manifest *Manifest) {
|
|
path := filepath.Join(root, claudeInstructions)
|
|
info, err := os.Stat(path)
|
|
if err != nil || !info.Mode().IsRegular() {
|
|
return
|
|
}
|
|
if manifest.Hooks == nil {
|
|
manifest.Hooks = map[string][]Hook{}
|
|
}
|
|
manifest.Hooks["SessionStart"] = append(manifest.Hooks["SessionStart"], Hook{
|
|
ContextFile: claudeInstructions,
|
|
Cwd: ".",
|
|
Description: "Plugin CLAUDE.md startup context from " + manifest.Name,
|
|
})
|
|
}
|
|
|
|
func appendClaudeSettingsHooks(root string, manifest *Manifest) []string {
|
|
path := filepath.Join(root, claudeSettingsPath)
|
|
body, err := fileencoding.ReadFileUTF8(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var raw struct {
|
|
Hooks map[string][]struct {
|
|
Matcher string `json:"matcher"`
|
|
Match string `json:"match"`
|
|
Hooks []struct {
|
|
Type string `json:"type"`
|
|
Command string `json:"command"`
|
|
Description string `json:"description"`
|
|
Timeout int `json:"timeout"`
|
|
Env map[string]string `json:"env"`
|
|
} `json:"hooks"`
|
|
} `json:"hooks"`
|
|
}
|
|
if err := json.Unmarshal(body, &raw); err != nil {
|
|
return []string{fmt.Sprintf("%s: %v", claudeSettingsPath, err)}
|
|
}
|
|
if len(raw.Hooks) == 0 {
|
|
return nil
|
|
}
|
|
if manifest.Hooks == nil {
|
|
manifest.Hooks = map[string][]Hook{}
|
|
}
|
|
var warnings []string
|
|
for event, blocks := range raw.Hooks {
|
|
event = strings.TrimSpace(event)
|
|
if event == "" {
|
|
continue
|
|
}
|
|
for _, block := range blocks {
|
|
match := strings.TrimSpace(block.Matcher)
|
|
if match == "" {
|
|
match = strings.TrimSpace(block.Match)
|
|
}
|
|
for _, item := range block.Hooks {
|
|
typ := strings.TrimSpace(item.Type)
|
|
command := strings.TrimSpace(item.Command)
|
|
if typ != "" && typ != "command" {
|
|
warnings = append(warnings, fmt.Sprintf("%s: skipped unsupported hook type %q for %s", claudeSettingsPath, typ, event))
|
|
continue
|
|
}
|
|
if command == "" {
|
|
continue
|
|
}
|
|
manifest.Hooks[event] = append(manifest.Hooks[event], Hook{
|
|
Match: match,
|
|
Command: command,
|
|
ShellCommand: true,
|
|
Description: firstNonEmpty(strings.TrimSpace(item.Description), "Claude-compatible hook from "+claudeSettingsPath),
|
|
Timeout: claudeTimeoutMillis(item.Timeout),
|
|
Cwd: ".",
|
|
Env: cloneHookEnv(item.Env),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return warnings
|
|
}
|
|
|
|
func claudeTimeoutMillis(seconds int) int {
|
|
if seconds <= 0 {
|
|
return 0
|
|
}
|
|
return seconds * 1000
|
|
}
|
|
|
|
func cloneHookEnv(in map[string]string) map[string]string {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := map[string]string{}
|
|
for k, v := range in {
|
|
if strings.TrimSpace(k) != "" {
|
|
out[k] = v
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func readJSONFile(path string, v any) error {
|
|
b, err := fileencoding.ReadFileUTF8(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return json.Unmarshal(b, v)
|
|
}
|
|
|
|
func parseSkillPaths(raw json.RawMessage) ([]string, error) {
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
return nil, nil
|
|
}
|
|
var one string
|
|
if err := json.Unmarshal(raw, &one); err == nil {
|
|
return cleanPathList([]string{one})
|
|
}
|
|
var manyStrings []string
|
|
if err := json.Unmarshal(raw, &manyStrings); err == nil {
|
|
return cleanPathList(manyStrings)
|
|
}
|
|
var manyObjects []struct {
|
|
Path string `json:"path"`
|
|
}
|
|
if err := json.Unmarshal(raw, &manyObjects); err == nil {
|
|
paths := make([]string, 0, len(manyObjects))
|
|
for _, item := range manyObjects {
|
|
paths = append(paths, item.Path)
|
|
}
|
|
return cleanPathList(paths)
|
|
}
|
|
return nil, fmt.Errorf("skills must be a path string, string array, or object array")
|
|
}
|
|
|
|
func cleanPathList(paths []string) ([]string, error) {
|
|
var out []string
|
|
seen := map[string]bool{}
|
|
for _, p := range paths {
|
|
p = filepath.Clean(strings.TrimSpace(p))
|
|
if p == "." || p == "" {
|
|
p = "."
|
|
}
|
|
if filepath.IsAbs(p) || strings.HasPrefix(p, ".."+string(filepath.Separator)) || p == ".." {
|
|
return nil, fmt.Errorf("plugin path %q must be relative and stay inside the plugin root", p)
|
|
}
|
|
slash := filepath.ToSlash(p)
|
|
if !seen[slash] {
|
|
seen[slash] = true
|
|
out = append(out, slash)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out, nil
|
|
}
|
|
|
|
func normalizeHooks(in map[string][]Hook) map[string][]Hook {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := map[string][]Hook{}
|
|
for event, hooks := range in {
|
|
event = strings.TrimSpace(event)
|
|
for _, h := range hooks {
|
|
h.Command = strings.TrimSpace(h.Command)
|
|
h.ContextFile = strings.TrimSpace(h.ContextFile)
|
|
h.Cwd = strings.TrimSpace(h.Cwd)
|
|
if h.Command == "" && h.ContextFile == "" {
|
|
continue
|
|
}
|
|
out[event] = append(out[event], h)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func validateManifest(root string, m *Manifest) error {
|
|
if !IsValidName(m.Name) {
|
|
return fmt.Errorf("invalid plugin name %q", m.Name)
|
|
}
|
|
for _, p := range m.Skills {
|
|
if err := validateRelativePath(p); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, p := range m.Commands {
|
|
if err := validateRelativePath(p); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for event, hooks := range m.Hooks {
|
|
if strings.TrimSpace(event) == "" {
|
|
return fmt.Errorf("hook event is required")
|
|
}
|
|
for _, h := range hooks {
|
|
if h.Command == "" && h.ContextFile == "" {
|
|
return fmt.Errorf("hook command or contextFile is required")
|
|
}
|
|
if h.Command != "" && !h.ShellCommand && !filepath.IsAbs(h.Command) {
|
|
if err := validateRelativePath(h.Command); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if h.ContextFile != "" {
|
|
if err := validateRelativePath(h.ContextFile); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if h.Cwd != "" && !filepath.IsAbs(h.Cwd) {
|
|
if err := validateRelativePath(h.Cwd); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for name := range m.MCPServers {
|
|
if !IsValidName(name) {
|
|
return fmt.Errorf("invalid MCP server name %q", name)
|
|
}
|
|
}
|
|
if _, err := os.Stat(root); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateRelativePath(p string) error {
|
|
p = filepath.Clean(strings.TrimSpace(p))
|
|
if p == "" {
|
|
return fmt.Errorf("plugin path is required")
|
|
}
|
|
if filepath.IsAbs(p) || strings.HasPrefix(p, ".."+string(filepath.Separator)) || p == ".." {
|
|
return fmt.Errorf("plugin path %q must be relative and stay inside the plugin root", p)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p Package) SkillRoots() []string {
|
|
var out []string
|
|
for _, rel := range p.Manifest.Skills {
|
|
out = append(out, filepath.Join(p.Root, filepath.FromSlash(rel)))
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// CommandRoots returns the absolute command directories this package
|
|
// contributes to custom slash-command discovery.
|
|
func (p Package) CommandRoots() []string {
|
|
var out []string
|
|
for _, rel := range p.Manifest.Commands {
|
|
out = append(out, filepath.Join(p.Root, filepath.FromSlash(rel)))
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func (p Package) CapabilityCounts() (skills, commands, hooks, mcp int) {
|
|
skills = len(p.skillRefs())
|
|
commands = len(p.commandRefs())
|
|
for _, hs := range p.Manifest.Hooks {
|
|
hooks += len(hs)
|
|
}
|
|
mcp = len(p.Manifest.MCPServers)
|
|
return
|
|
}
|
|
|
|
func (p Package) Inventory() Inventory {
|
|
return Inventory{
|
|
Skills: p.skillRefs(),
|
|
Commands: p.commandRefs(),
|
|
Hooks: p.hookRefs(),
|
|
MCPServers: p.mcpServerRefs(),
|
|
}
|
|
}
|
|
|
|
// commandRefs loads the package's command dirs through the same loader the
|
|
// runtime uses (internal/command), so names, namespacing, and frontmatter
|
|
// semantics can never drift between the inventory and actual invocation.
|
|
func (p Package) commandRefs() []CommandRef {
|
|
roots := p.CommandRoots()
|
|
if len(roots) == 0 {
|
|
return nil
|
|
}
|
|
cmds, _ := command.Load(roots...) // best-effort: malformed files are surfaced at load time elsewhere
|
|
out := make([]CommandRef, 0, len(cmds))
|
|
for _, c := range cmds {
|
|
out = append(out, CommandRef{
|
|
Name: c.Name,
|
|
Description: c.Description,
|
|
ArgHint: c.ArgHint,
|
|
Path: c.Source,
|
|
Invocation: "/" + c.Name,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (p Package) skillRefs() []SkillRef {
|
|
var out []SkillRef
|
|
seen := map[string]bool{}
|
|
for _, rel := range p.Manifest.Skills {
|
|
root := filepath.Join(p.Root, filepath.FromSlash(rel))
|
|
p.scanSkillPath(root, 1, map[string]bool{}, &out)
|
|
}
|
|
filtered := out[:0]
|
|
for _, sk := range out {
|
|
key := sk.Path
|
|
if key == "" {
|
|
key = sk.Name
|
|
}
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
filtered = append(filtered, sk)
|
|
}
|
|
sort.SliceStable(filtered, func(i, j int) bool {
|
|
if filtered[i].Name != filtered[j].Name {
|
|
return filtered[i].Name < filtered[j].Name
|
|
}
|
|
return filtered[i].Path < filtered[j].Path
|
|
})
|
|
return filtered
|
|
}
|
|
|
|
func (p Package) scanSkillPath(path string, depth int, seen map[string]bool, out *[]SkillRef) {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if !info.IsDir() {
|
|
if info.Mode().IsRegular() && strings.EqualFold(filepath.Ext(path), ".md") {
|
|
if sk, ok := parseSkillRef(path, strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))); ok {
|
|
*out = append(*out, sk)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
key := filepath.Clean(path)
|
|
if resolved, err := filepath.EvalSymlinks(path); err == nil {
|
|
key = filepath.Clean(resolved)
|
|
}
|
|
if seen[key] {
|
|
return
|
|
}
|
|
seen[key] = true
|
|
|
|
if sk, ok := parseSkillRef(filepath.Join(path, "SKILL.md"), filepath.Base(path)); ok {
|
|
*out = append(*out, sk)
|
|
return
|
|
}
|
|
if depth >= 5 {
|
|
return
|
|
}
|
|
entries, err := os.ReadDir(path)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if shouldSkipSkillScanDir(name) {
|
|
continue
|
|
}
|
|
full := filepath.Join(path, name)
|
|
if entry.IsDir() {
|
|
p.scanSkillPath(full, depth+1, seen, out)
|
|
continue
|
|
}
|
|
if entry.Type().IsRegular() && strings.EqualFold(filepath.Ext(name), ".md") {
|
|
if sk, ok := parseSkillRef(full, strings.TrimSuffix(name, filepath.Ext(name))); ok {
|
|
*out = append(*out, sk)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func shouldSkipSkillScanDir(name string) bool {
|
|
if strings.HasPrefix(name, ".") {
|
|
return true
|
|
}
|
|
switch strings.ToLower(name) {
|
|
case "assets", "node_modules", "references", "scripts":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func parseSkillRef(path, stem string) (SkillRef, bool) {
|
|
if !IsValidName(stem) {
|
|
return SkillRef{}, false
|
|
}
|
|
b, err := fileencoding.ReadFileUTF8(path)
|
|
if err != nil {
|
|
return SkillRef{}, false
|
|
}
|
|
content := strings.TrimPrefix(strings.ReplaceAll(string(b), "\r\n", "\n"), "\uFEFF")
|
|
fm, _ := frontmatter.Split(content)
|
|
name := stem
|
|
if v := strings.TrimSpace(fm["name"]); IsValidName(v) {
|
|
name = v
|
|
}
|
|
return SkillRef{
|
|
Name: name,
|
|
Description: strings.TrimSpace(fm["description"]),
|
|
Path: filepath.Clean(path),
|
|
Invocation: "/" + name,
|
|
RunAs: pluginSkillRunMode(fm),
|
|
}, true
|
|
}
|
|
|
|
func pluginSkillRunMode(fm map[string]string) string {
|
|
if strings.TrimSpace(fm["runas"]) == "subagent" {
|
|
return "subagent"
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(fm["context"]), "fork") {
|
|
return "subagent"
|
|
}
|
|
if strings.TrimSpace(fm["agent"]) != "" {
|
|
return "subagent"
|
|
}
|
|
return "inline"
|
|
}
|
|
|
|
func (p Package) hookRefs() []HookRef {
|
|
events := make([]string, 0, len(p.Manifest.Hooks))
|
|
for event := range p.Manifest.Hooks {
|
|
events = append(events, event)
|
|
}
|
|
sort.Strings(events)
|
|
var out []HookRef
|
|
for _, event := range events {
|
|
for _, hook := range p.Manifest.Hooks[event] {
|
|
out = append(out, HookRef{
|
|
Event: event,
|
|
Match: hook.Match,
|
|
Command: hook.Command,
|
|
ContextFile: hook.ContextFile,
|
|
Description: hook.Description,
|
|
})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (p Package) mcpServerRefs() []MCPServerRef {
|
|
names := make([]string, 0, len(p.Manifest.MCPServers))
|
|
for name := range p.Manifest.MCPServers {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
out := make([]MCPServerRef, 0, len(names))
|
|
for _, name := range names {
|
|
server := p.Manifest.MCPServers[name]
|
|
out = append(out, MCPServerRef{
|
|
Name: name,
|
|
Transport: pluginMCPTransport(server),
|
|
Command: strings.TrimSpace(server.Command),
|
|
URL: strings.TrimSpace(server.URL),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func pluginMCPTransport(server MCPServer) string {
|
|
if typ := strings.TrimSpace(server.Type); typ != "" {
|
|
return typ
|
|
}
|
|
if strings.TrimSpace(server.URL) != "" {
|
|
return "http"
|
|
}
|
|
return "stdio"
|
|
}
|