chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
package pluginpkg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// InstalledNames returns installed plugin package names sorted for completion
|
||||
// menus and lightweight management views.
|
||||
func InstalledNames(reasonixHome string) ([]string, error) {
|
||||
st, err := LoadState(reasonixHome)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(st.Plugins))
|
||||
for _, p := range st.Plugins {
|
||||
names = append(names, p.Name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// InstalledListText returns a compact session-facing view of installed plugins.
|
||||
func InstalledListText(reasonixHome string) (string, error) {
|
||||
st, err := LoadState(reasonixHome)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(st.Plugins) == 0 {
|
||||
return "plugins: none installed\ninstall: reasonix plugin install <source> --yes, or use Settings -> Plugins", nil
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "plugins (%d):\n", len(st.Plugins))
|
||||
for _, p := range st.Plugins {
|
||||
state := "disabled"
|
||||
if p.Enabled {
|
||||
state = "enabled"
|
||||
}
|
||||
summary := p.Description
|
||||
counts := pluginCapabilityText(reasonixHome, p)
|
||||
if summary != "" && counts != "" {
|
||||
summary = counts + " - " + oneLine(summary)
|
||||
} else if counts != "" {
|
||||
summary = counts
|
||||
} else if summary != "" {
|
||||
summary = oneLine(summary)
|
||||
}
|
||||
if summary != "" {
|
||||
fmt.Fprintf(&b, " %s [%s] - %s\n", p.Name, state, summary)
|
||||
} else {
|
||||
fmt.Fprintf(&b, " %s [%s]\n", p.Name, state)
|
||||
}
|
||||
}
|
||||
b.WriteString("show details: /plugins show <name>")
|
||||
return strings.TrimRight(b.String(), "\n"), nil
|
||||
}
|
||||
|
||||
// InstalledShowText returns the usage-oriented details for one installed plugin.
|
||||
func InstalledShowText(reasonixHome, name string) (string, error) {
|
||||
p, ok, err := FindInstalled(reasonixHome, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Sprintf("plugin %q is not installed", name), nil
|
||||
}
|
||||
root := ResolveRoot(reasonixHome, p.Root)
|
||||
pkg, warnings, err := ParseDir(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
skills, commands, hooks, mcp := pkg.CapabilityCounts()
|
||||
state := "disabled"
|
||||
if p.Enabled {
|
||||
state = "enabled"
|
||||
}
|
||||
version := p.Version
|
||||
if version == "" {
|
||||
version = "-"
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "plugin %s [%s]\n", p.Name, state)
|
||||
fmt.Fprintf(&b, "version: %s\nkind: %s\nroot: %s\nsource: %s\ncapabilities: %d skills, %d commands, %d hooks, %d MCP servers\n", version, p.ManifestKind, filepath.Clean(root), p.Source, skills, commands, hooks, mcp)
|
||||
if p.Enabled {
|
||||
b.WriteString("usage: enabled plugins load into new sessions; use /skills, invoke /<plugin>:<skill> or /<plugin>:<command>, or ask naturally.\n")
|
||||
} else {
|
||||
b.WriteString("usage: enable this plugin before its skills, commands, hooks, or MCP servers participate in sessions.\n")
|
||||
}
|
||||
appendInventoryText(&b, p.Name, pkg.Inventory())
|
||||
for _, warning := range warnings {
|
||||
fmt.Fprintf(&b, "warning: %s\n", warning)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n"), nil
|
||||
}
|
||||
|
||||
func FindInstalled(reasonixHome, name string) (InstalledPlugin, bool, error) {
|
||||
st, err := LoadState(reasonixHome)
|
||||
if err != nil {
|
||||
return InstalledPlugin{}, false, err
|
||||
}
|
||||
for _, p := range st.Plugins {
|
||||
if p.Name == name {
|
||||
return p, true, nil
|
||||
}
|
||||
}
|
||||
return InstalledPlugin{}, false, nil
|
||||
}
|
||||
|
||||
func pluginCapabilityText(reasonixHome string, p InstalledPlugin) string {
|
||||
root := ResolveRoot(reasonixHome, p.Root)
|
||||
pkg, _, err := ParseDir(root)
|
||||
if err != nil {
|
||||
return "invalid: " + err.Error()
|
||||
}
|
||||
skills, commands, hooks, mcp := pkg.CapabilityCounts()
|
||||
parts := []string{}
|
||||
if skills > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d skills", skills))
|
||||
}
|
||||
if commands > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d commands", commands))
|
||||
}
|
||||
if hooks > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d hooks", hooks))
|
||||
}
|
||||
if mcp > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d MCP", mcp))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "no exported capabilities"
|
||||
}
|
||||
return strings.Join(parts, " / ")
|
||||
}
|
||||
|
||||
func appendInventoryText(b *strings.Builder, pluginName string, inv Inventory) {
|
||||
if len(inv.Commands) > 0 {
|
||||
b.WriteString("commands:\n")
|
||||
for _, cmd := range inv.Commands {
|
||||
desc := oneLine(cmd.Description)
|
||||
if desc == "" {
|
||||
desc = "(no description)"
|
||||
}
|
||||
invocation := "/" + pluginName + ":" + cmd.Name
|
||||
if cmd.ArgHint != "" {
|
||||
fmt.Fprintf(b, " %s %s - %s\n", invocation, cmd.ArgHint, desc)
|
||||
} else {
|
||||
fmt.Fprintf(b, " %s - %s\n", invocation, desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(inv.Skills) > 0 {
|
||||
b.WriteString("skills:\n")
|
||||
for _, sk := range inv.Skills {
|
||||
desc := oneLine(sk.Description)
|
||||
if desc == "" {
|
||||
desc = "(no description)"
|
||||
}
|
||||
invocation := "/" + pluginName + ":" + sk.Name
|
||||
if sk.RunAs != "" {
|
||||
fmt.Fprintf(b, " %s [%s] - %s\n", invocation, sk.RunAs, desc)
|
||||
} else {
|
||||
fmt.Fprintf(b, " %s - %s\n", invocation, desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(inv.Hooks) > 0 {
|
||||
b.WriteString("hooks:\n")
|
||||
for _, hook := range inv.Hooks {
|
||||
target := hook.Command
|
||||
if target == "" {
|
||||
target = hook.ContextFile
|
||||
}
|
||||
match := hook.Match
|
||||
if match == "" {
|
||||
match = "*"
|
||||
}
|
||||
desc := oneLine(hook.Description)
|
||||
if desc != "" {
|
||||
fmt.Fprintf(b, " %s match=%s - %s - %s\n", hook.Event, match, target, desc)
|
||||
} else {
|
||||
fmt.Fprintf(b, " %s match=%s - %s\n", hook.Event, match, target)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(inv.MCPServers) > 0 {
|
||||
b.WriteString("mcpServers:\n")
|
||||
for _, server := range inv.MCPServers {
|
||||
target := server.Command
|
||||
if target == "" {
|
||||
target = server.URL
|
||||
}
|
||||
fmt.Fprintf(b, " %s [%s] - %s\n", server.Name, server.Transport, target)
|
||||
}
|
||||
}
|
||||
if len(inv.Skills) == 0 && len(inv.Commands) == 0 && len(inv.Hooks) == 0 && len(inv.MCPServers) == 0 {
|
||||
b.WriteString("capabilities: no detailed inventory available\n")
|
||||
}
|
||||
}
|
||||
|
||||
func oneLine(s string) string {
|
||||
return strings.Join(strings.Fields(s), " ")
|
||||
}
|
||||
@@ -0,0 +1,997 @@
|
||||
// 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"
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
package pluginpkg
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
fileencoding "reasonix/internal/fileutil/encoding"
|
||||
)
|
||||
|
||||
func TestParseCodexSuperpowersManifest(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, CodexManifest), `{
|
||||
"name": "superpowers",
|
||||
"version": "6.1.0",
|
||||
"description": "Planning workflows",
|
||||
"skills": "./skills/"
|
||||
}`)
|
||||
writeTestFile(t, filepath.Join(root, "skills", "plan", "SKILL.md"), "---\ndescription: Plan work\n---\nbody")
|
||||
writeTestFile(t, filepath.Join(root, "hooks", "session-start-codex"), "#!/usr/bin/env bash\n")
|
||||
|
||||
pkg, warnings, err := ParseDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDir: %v", err)
|
||||
}
|
||||
if len(warnings) != 0 {
|
||||
t.Fatalf("warnings = %v, want none", warnings)
|
||||
}
|
||||
if pkg.ManifestKind != "codex" || pkg.Manifest.Name != "superpowers" || pkg.Manifest.Version != "6.1.0" {
|
||||
t.Fatalf("pkg = %+v", pkg)
|
||||
}
|
||||
if got := pkg.SkillRoots(); len(got) != 1 || got[0] != filepath.Join(root, "skills") {
|
||||
t.Fatalf("SkillRoots = %#v", got)
|
||||
}
|
||||
if hooks := pkg.Manifest.Hooks["SessionStart"]; len(hooks) != 1 || hooks[0].Command != filepath.Join(root, "hooks", "session-start-codex") {
|
||||
t.Fatalf("SessionStart hooks = %+v", hooks)
|
||||
}
|
||||
inv := pkg.Inventory()
|
||||
if len(inv.Skills) != 1 || inv.Skills[0].Name != "plan" || inv.Skills[0].Invocation != "/plan" {
|
||||
t.Fatalf("Inventory().Skills = %+v", inv.Skills)
|
||||
}
|
||||
if skills, _, hooks, _ := pkg.CapabilityCounts(); skills != 1 || hooks != 1 {
|
||||
t.Fatalf("CapabilityCounts skills=%d hooks=%d", skills, hooks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDirDecodesGB18030Manifest(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest := `{"name":"cn-plugin","version":"1.0.0","description":"中文插件"}`
|
||||
path := filepath.Join(root, NativeManifest)
|
||||
if err := os.WriteFile(path, fileencoding.Encode(manifest, fileencoding.GB18030), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pkg, warnings, err := ParseDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDir: %v", err)
|
||||
}
|
||||
if len(warnings) != 0 {
|
||||
t.Fatalf("warnings = %v", warnings)
|
||||
}
|
||||
if pkg.Manifest.Description != "中文插件" {
|
||||
t.Fatalf("decoded manifest = %+v", pkg.Manifest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCodexClaudeCompatibility(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, CodexManifest), `{
|
||||
"name": "claude-pack",
|
||||
"version": "1.0.0",
|
||||
"skills": "skills"
|
||||
}`)
|
||||
writeTestFile(t, filepath.Join(root, "CLAUDE.md"), "Always use the bundled workflow.")
|
||||
writeTestFile(t, filepath.Join(root, ".claude", "settings.json"), `{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "bash|write_file",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node hooks/post-tool.js",
|
||||
"description": "post tool check",
|
||||
"timeout": 3,
|
||||
"env": { "MODE": "check" }
|
||||
},
|
||||
{ "type": "prompt", "command": "ignored" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "node hooks/prompt.js" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
|
||||
pkg, warnings, err := ParseDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDir: %v", err)
|
||||
}
|
||||
if len(warnings) != 1 || warnings[0] == "" {
|
||||
t.Fatalf("warnings = %v, want unsupported hook warning", warnings)
|
||||
}
|
||||
if got := pkg.Manifest.Hooks["SessionStart"]; len(got) != 1 || got[0].ContextFile != "CLAUDE.md" {
|
||||
t.Fatalf("SessionStart hooks = %+v, want CLAUDE.md context hook", got)
|
||||
}
|
||||
if got := pkg.Manifest.Hooks["PostToolUse"]; len(got) != 1 || got[0].Match != "bash|write_file" || got[0].Command != "node hooks/post-tool.js" || got[0].Timeout != 3000 || got[0].Env["MODE"] != "check" {
|
||||
t.Fatalf("PostToolUse hooks = %+v", got)
|
||||
}
|
||||
if got := pkg.Manifest.Hooks["UserPromptSubmit"]; len(got) != 1 || got[0].Command != "node hooks/prompt.js" {
|
||||
t.Fatalf("UserPromptSubmit hooks = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudePluginManifest(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, ClaudeManifest), `{
|
||||
"name": "ui-ux-pro-max",
|
||||
"version": "2.6.2",
|
||||
"description": "UI/UX design intelligence",
|
||||
"skills": "./.claude/skills/"
|
||||
}`)
|
||||
writeTestFile(t, filepath.Join(root, ".claude", "skills", "ui-ux-pro-max", "SKILL.md"), "---\ndescription: UI design helper\n---\nbody")
|
||||
writeTestFile(t, filepath.Join(root, "CLAUDE.md"), "Use the bundled UI workflow.")
|
||||
|
||||
pkg, warnings, err := ParseDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDir: %v", err)
|
||||
}
|
||||
if len(warnings) != 0 {
|
||||
t.Fatalf("warnings = %v, want none", warnings)
|
||||
}
|
||||
if pkg.ManifestKind != "claude" || pkg.Manifest.Name != "ui-ux-pro-max" || pkg.Manifest.Version != "2.6.2" {
|
||||
t.Fatalf("pkg = %+v", pkg)
|
||||
}
|
||||
if got := pkg.SkillRoots(); len(got) != 1 || got[0] != filepath.Join(root, ".claude", "skills") {
|
||||
t.Fatalf("SkillRoots = %#v", got)
|
||||
}
|
||||
inv := pkg.Inventory()
|
||||
if len(inv.Skills) != 1 || inv.Skills[0].Name != "ui-ux-pro-max" || inv.Skills[0].Invocation != "/ui-ux-pro-max" {
|
||||
t.Fatalf("Inventory().Skills = %+v", inv.Skills)
|
||||
}
|
||||
if hooks := pkg.Manifest.Hooks["SessionStart"]; len(hooks) != 1 || hooks[0].ContextFile != "CLAUDE.md" {
|
||||
t.Fatalf("SessionStart hooks = %+v, want CLAUDE.md context hook", hooks)
|
||||
}
|
||||
if ManifestPath(pkg.ManifestKind) != ClaudeManifest {
|
||||
t.Fatalf("ManifestPath(%q) = %q, want %q", pkg.ManifestKind, ManifestPath(pkg.ManifestKind), ClaudeManifest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCodexWithoutSessionStartHookDoesNotWarn(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, CodexManifest), `{
|
||||
"name": "skills-only",
|
||||
"skills": "skills"
|
||||
}`)
|
||||
|
||||
_, warnings, err := ParseDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDir: %v", err)
|
||||
}
|
||||
if len(warnings) != 0 {
|
||||
t.Fatalf("warnings = %v, want none", warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsEscapingSkillPath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, NativeManifest), `{
|
||||
"name": "bad",
|
||||
"skills": "../skills"
|
||||
}`)
|
||||
if _, _, err := ParseDir(root); err == nil {
|
||||
t.Fatal("ParseDir should reject escaping skill path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateRoundTripSortsPlugins(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
if err := Upsert(home, InstalledPlugin{Name: "zeta", Root: "plugins/zeta", Enabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Upsert(home, InstalledPlugin{Name: "alpha", Root: "plugins/alpha", Enabled: false}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := LoadState(home)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(st.Plugins) != 2 || st.Plugins[0].Name != "alpha" || st.Plugins[1].Name != "zeta" {
|
||||
t.Fatalf("state plugins = %+v", st.Plugins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstalledTextDescribesUsageInventory(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
root := filepath.Join(home, "plugins", "superpowers")
|
||||
writeTestFile(t, filepath.Join(root, CodexManifest), `{
|
||||
"name": "superpowers",
|
||||
"version": "6.1.0",
|
||||
"description": "Planning workflows",
|
||||
"skills": "skills"
|
||||
}`)
|
||||
writeTestFile(t, filepath.Join(root, "skills", "plan", "SKILL.md"), "---\ndescription: Plan work\nrunAs: subagent\n---\nbody")
|
||||
writeTestFile(t, filepath.Join(root, "hooks", "session-start-codex"), "#!/usr/bin/env bash\n")
|
||||
if err := Upsert(home, InstalledPlugin{Name: "superpowers", Root: "plugins/superpowers", Version: "6.1.0", Description: "Planning workflows", ManifestKind: "codex", Enabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err := InstalledListText(home)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"plugins (1):", "superpowers [enabled]", "1 skills / 1 hooks", "/plugins show <name>"} {
|
||||
if !strings.Contains(list, want) {
|
||||
t.Fatalf("InstalledListText missing %q:\n%s", want, list)
|
||||
}
|
||||
}
|
||||
details, err := InstalledShowText(home, "superpowers")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"plugin superpowers [enabled]", "usage: enabled plugins load into new sessions", "/superpowers:plan [subagent] - Plan work", "SessionStart"} {
|
||||
if !strings.Contains(details, want) {
|
||||
t.Fatalf("InstalledShowText missing %q:\n%s", want, details)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestFile(t *testing.T, path, body string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseClaudePluginConventionSkillDirs pins the standard Claude plugin
|
||||
// shape: plugin.json carries metadata only, and skills live in the
|
||||
// conventional skills/ directory that Claude auto-discovers. Without the
|
||||
// fallback such a package installed as zero capabilities with no warning.
|
||||
func TestParseClaudePluginConventionSkillDirs(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, ClaudeManifest), `{
|
||||
"name": "design-pack",
|
||||
"version": "1.0.0",
|
||||
"description": "metadata-only manifest"
|
||||
}`)
|
||||
writeTestFile(t, filepath.Join(root, "skills", "design-review", "SKILL.md"), "---\ndescription: review designs\n---\nbody")
|
||||
|
||||
pkg, warnings, err := ParseDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDir: %v", err)
|
||||
}
|
||||
if len(warnings) != 0 {
|
||||
t.Fatalf("warnings = %v, want none", warnings)
|
||||
}
|
||||
if pkg.ManifestKind != "claude" {
|
||||
t.Fatalf("kind = %q", pkg.ManifestKind)
|
||||
}
|
||||
if got := pkg.SkillRoots(); len(got) != 1 || got[0] != filepath.Join(root, "skills") {
|
||||
t.Fatalf("SkillRoots = %#v, want conventional skills dir", got)
|
||||
}
|
||||
if inv := pkg.Inventory(); len(inv.Skills) != 1 || inv.Skills[0].Name != "design-review" {
|
||||
t.Fatalf("Inventory().Skills = %+v", inv.Skills)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudePluginDotClaudeConventionDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, ClaudeManifest), `{"name": "pack"}`)
|
||||
writeTestFile(t, filepath.Join(root, ".claude", "skills", "helper", "SKILL.md"), "---\ndescription: helper\n---\nbody")
|
||||
|
||||
pkg, _, err := ParseDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDir: %v", err)
|
||||
}
|
||||
if got := pkg.SkillRoots(); len(got) != 1 || got[0] != filepath.Join(root, ".claude", "skills") {
|
||||
t.Fatalf("SkillRoots = %#v, want .claude/skills", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudePluginIgnoresEmptyConventionDirAndExplicitSkillsWin(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Empty conventional dir (no SKILL.md inside) must not be adopted.
|
||||
writeTestFile(t, filepath.Join(root, ClaudeManifest), `{"name": "empty-pack"}`)
|
||||
if err := os.MkdirAll(filepath.Join(root, "skills", "stub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pkg, _, err := ParseDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDir: %v", err)
|
||||
}
|
||||
if got := pkg.SkillRoots(); len(got) != 0 {
|
||||
t.Fatalf("SkillRoots = %#v, want none for a skill-less conventional dir", got)
|
||||
}
|
||||
|
||||
// Explicit skills declaration disables the fallback entirely.
|
||||
root2 := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root2, ClaudeManifest), `{"name": "explicit-pack", "skills": "./custom/"}`)
|
||||
writeTestFile(t, filepath.Join(root2, "custom", "one", "SKILL.md"), "---\ndescription: one\n---\nbody")
|
||||
writeTestFile(t, filepath.Join(root2, "skills", "two", "SKILL.md"), "---\ndescription: two\n---\nbody")
|
||||
pkg2, _, err := ParseDir(root2)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDir explicit: %v", err)
|
||||
}
|
||||
if got := pkg2.SkillRoots(); len(got) != 1 || got[0] != filepath.Join(root2, "custom") {
|
||||
t.Fatalf("SkillRoots = %#v, want only the declared custom dir", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudePluginWarnsOnUnmappedCapabilities(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, ClaudeManifest), `{"name": "big-pack"}`)
|
||||
writeTestFile(t, filepath.Join(root, "skills", "s", "SKILL.md"), "---\ndescription: s\n---\nbody")
|
||||
writeTestFile(t, filepath.Join(root, "commands", "deploy.md"), "run deploy")
|
||||
writeTestFile(t, filepath.Join(root, "hooks", "hooks.json"), `{}`)
|
||||
writeTestFile(t, filepath.Join(root, ".mcp.json"), `{}`)
|
||||
|
||||
_, warnings, err := ParseDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDir: %v", err)
|
||||
}
|
||||
joined := strings.Join(warnings, "\n")
|
||||
for _, want := range []string{"hooks/hooks.json", ".mcp.json"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Fatalf("warnings = %v, want mention of %s", warnings, want)
|
||||
}
|
||||
}
|
||||
// commands map to custom slash commands now — they must not warn.
|
||||
if strings.Contains(joined, "commands") {
|
||||
t.Fatalf("warnings = %v, must not warn about the mapped commands dir", warnings)
|
||||
}
|
||||
if strings.Contains(joined, "agents") {
|
||||
t.Fatalf("warnings = %v, must not mention absent agents dir", warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseClaudePluginMapsCommandsDir pins the commands mapping: a Claude
|
||||
// plugin's conventional commands/ dir becomes a Manifest.Commands root — even
|
||||
// when the manifest declares skills explicitly — and its flat <name>.md
|
||||
// templates surface in the inventory as /<name> invocations.
|
||||
func TestParseClaudePluginMapsCommandsDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, ClaudeManifest), `{"name": "pwf-pack"}`)
|
||||
writeTestFile(t, filepath.Join(root, "skills", "planner", "SKILL.md"), "---\ndescription: planner skill\n---\nbody")
|
||||
writeTestFile(t, filepath.Join(root, "commands", "plan.md"), "---\ndescription: \"Start planning\"\nargument-hint: \"[task]\"\n---\nPlan: $ARGUMENTS")
|
||||
writeTestFile(t, filepath.Join(root, "commands", "status.md"), "Show status")
|
||||
|
||||
pkg, warnings, err := ParseDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDir: %v", err)
|
||||
}
|
||||
if len(warnings) != 0 {
|
||||
t.Fatalf("warnings = %v, want none for a fully mapped plugin", warnings)
|
||||
}
|
||||
if got := pkg.CommandRoots(); len(got) != 1 || got[0] != filepath.Join(root, "commands") {
|
||||
t.Fatalf("CommandRoots = %#v, want the conventional commands dir", got)
|
||||
}
|
||||
inv := pkg.Inventory()
|
||||
if len(inv.Commands) != 2 {
|
||||
t.Fatalf("inventory commands = %#v, want plan and status", inv.Commands)
|
||||
}
|
||||
byName := map[string]CommandRef{}
|
||||
for _, c := range inv.Commands {
|
||||
byName[c.Name] = c
|
||||
}
|
||||
plan, ok := byName["plan"]
|
||||
if !ok || plan.Invocation != "/plan" || plan.Description != "Start planning" || plan.ArgHint != "[task]" {
|
||||
t.Fatalf("plan command = %+v, want /plan with description and arg hint", plan)
|
||||
}
|
||||
if _, ok := byName["status"]; !ok {
|
||||
t.Fatalf("inventory commands = %#v, want frontmatter-less status command included", inv.Commands)
|
||||
}
|
||||
skills, commands, hooks, mcp := pkg.CapabilityCounts()
|
||||
if skills != 1 || commands != 2 || hooks != 0 || mcp != 0 {
|
||||
t.Fatalf("CapabilityCounts = %d skills %d commands %d hooks %d mcp, want 1/2/0/0", skills, commands, hooks, mcp)
|
||||
}
|
||||
|
||||
// Explicit skills declaration must not disable command adoption.
|
||||
root2 := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root2, ClaudeManifest), `{"name": "explicit-pack", "skills": "./custom/"}`)
|
||||
writeTestFile(t, filepath.Join(root2, "custom", "one", "SKILL.md"), "---\ndescription: one\n---\nbody")
|
||||
writeTestFile(t, filepath.Join(root2, "commands", "go.md"), "go")
|
||||
pkg2, _, err := ParseDir(root2)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDir explicit: %v", err)
|
||||
}
|
||||
if got := pkg2.CommandRoots(); len(got) != 1 || got[0] != filepath.Join(root2, "commands") {
|
||||
t.Fatalf("CommandRoots = %#v, want commands adopted alongside explicit skills", got)
|
||||
}
|
||||
|
||||
// A docs-only commands dir (no installable <name>.md) is not adopted.
|
||||
root3 := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root3, ClaudeManifest), `{"name": "docs-pack"}`)
|
||||
writeTestFile(t, filepath.Join(root3, "skills", "s", "SKILL.md"), "---\ndescription: s\n---\nbody")
|
||||
writeTestFile(t, filepath.Join(root3, "commands", "notes.txt"), "not a command")
|
||||
pkg3, _, err := ParseDir(root3)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDir docs-only: %v", err)
|
||||
}
|
||||
if got := pkg3.CommandRoots(); len(got) != 0 {
|
||||
t.Fatalf("CommandRoots = %#v, want none for a commands dir without .md files", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNativeManifestCommandsField pins the explicit "commands" declaration in
|
||||
// reasonix-plugin.json, including path validation.
|
||||
func TestNativeManifestCommandsField(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, NativeManifest), `{"name": "native-pack", "commands": ["cmds"]}`)
|
||||
writeTestFile(t, filepath.Join(root, "cmds", "ship.md"), "---\ndescription: ship it\n---\nShip $1")
|
||||
|
||||
pkg, _, err := ParseDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDir: %v", err)
|
||||
}
|
||||
if got := pkg.CommandRoots(); len(got) != 1 || got[0] != filepath.Join(root, "cmds") {
|
||||
t.Fatalf("CommandRoots = %#v, want declared cmds dir", got)
|
||||
}
|
||||
inv := pkg.Inventory()
|
||||
if len(inv.Commands) != 1 || inv.Commands[0].Name != "ship" {
|
||||
t.Fatalf("inventory commands = %#v, want ship", inv.Commands)
|
||||
}
|
||||
|
||||
rootBad := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(rootBad, NativeManifest), `{"name": "bad-pack", "commands": ["../escape"]}`)
|
||||
if _, _, err := ParseDir(rootBad); err == nil {
|
||||
t.Fatal("ParseDir must reject a commands path escaping the plugin root")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseClaudePluginDoesNotRegisterCodexSessionStartHook pins the security
|
||||
// boundary of the includeCodexSessionStartHook flag: a claude-kind package
|
||||
// shipping a hooks/session-start-codex file must NOT get it registered as an
|
||||
// executable SessionStart hook (that convention belongs to codex manifests).
|
||||
func TestParseClaudePluginDoesNotRegisterCodexSessionStartHook(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, ClaudeManifest), `{"name": "sneaky-pack"}`)
|
||||
writeTestFile(t, filepath.Join(root, "skills", "s", "SKILL.md"), "---\ndescription: s\n---\nbody")
|
||||
writeTestFile(t, filepath.Join(root, "hooks", "session-start-codex"), "#!/bin/sh\necho pwned\n")
|
||||
|
||||
pkg, _, err := ParseDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDir: %v", err)
|
||||
}
|
||||
for _, h := range pkg.Manifest.Hooks["SessionStart"] {
|
||||
if h.Command != "" {
|
||||
t.Fatalf("claude package registered executable SessionStart hook: %+v", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseCodexManifestNotAffectedByClaudeFallback: the convention-dir
|
||||
// fallback is claude-only; a codex manifest without a skills field keeps its
|
||||
// existing "no skills" behavior even when a skills/ directory exists.
|
||||
func TestParseCodexManifestNotAffectedByClaudeFallback(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, CodexManifest), `{"name": "codex-pack"}`)
|
||||
writeTestFile(t, filepath.Join(root, "skills", "s", "SKILL.md"), "---\ndescription: s\n---\nbody")
|
||||
|
||||
pkg, _, err := ParseDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDir: %v", err)
|
||||
}
|
||||
if pkg.ManifestKind != "codex" {
|
||||
t.Fatalf("kind = %q", pkg.ManifestKind)
|
||||
}
|
||||
if got := pkg.SkillRoots(); len(got) != 0 {
|
||||
t.Fatalf("SkillRoots = %#v, codex parsing must not adopt convention dirs", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseClaudePluginAdoptsNestedCommands pins that namespace layouts like
|
||||
// commands/git/commit.md — which the runtime loader walks — also gate command
|
||||
// root adoption, and surface in the inventory under their namespaced name.
|
||||
func TestParseClaudePluginAdoptsNestedCommands(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, ClaudeManifest), `{"name": "nested-pack"}`)
|
||||
writeTestFile(t, filepath.Join(root, "skills", "s", "SKILL.md"), "---\ndescription: s\n---\nbody")
|
||||
writeTestFile(t, filepath.Join(root, "commands", "git", "commit.md"), "---\ndescription: commit helper\n---\nCommit: $ARGUMENTS")
|
||||
|
||||
pkg, warnings, err := ParseDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDir: %v", err)
|
||||
}
|
||||
if len(warnings) != 0 {
|
||||
t.Fatalf("warnings = %v, want none", warnings)
|
||||
}
|
||||
if got := pkg.CommandRoots(); len(got) != 1 || got[0] != filepath.Join(root, "commands") {
|
||||
t.Fatalf("CommandRoots = %#v, want commands adopted for nested-only layout", got)
|
||||
}
|
||||
inv := pkg.Inventory()
|
||||
if len(inv.Commands) != 1 || inv.Commands[0].Name != "git:commit" || inv.Commands[0].Invocation != "/git:commit" {
|
||||
t.Fatalf("inventory commands = %#v, want namespaced git:commit", inv.Commands)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInventoryTextCommandsOnly pins that a commands-only inventory does not
|
||||
// also claim "no detailed inventory available".
|
||||
func TestInventoryTextCommandsOnly(t *testing.T) {
|
||||
var b strings.Builder
|
||||
appendInventoryText(&b, "superpowers", Inventory{Commands: []CommandRef{{Name: "plan", Invocation: "/plan", Description: "plan things"}}})
|
||||
out := b.String()
|
||||
if !strings.Contains(out, "commands:") || !strings.Contains(out, "/superpowers:plan") {
|
||||
t.Fatalf("output = %q, want the commands listing", out)
|
||||
}
|
||||
if strings.Contains(out, "no detailed inventory available") {
|
||||
t.Fatalf("output = %q, must not claim an empty inventory after listing commands", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseClaudePluginAdoptsDeeplyNestedCommands pins that adoption gating
|
||||
// shares the runtime loader's discovery semantics with no depth ceiling: a
|
||||
// plugin whose only command sits six levels deep is still adopted.
|
||||
func TestParseClaudePluginAdoptsDeeplyNestedCommands(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, ClaudeManifest), `{"name": "deep-pack"}`)
|
||||
writeTestFile(t, filepath.Join(root, "skills", "s", "SKILL.md"), "---\ndescription: s\n---\nbody")
|
||||
writeTestFile(t, filepath.Join(root, "commands", "a", "b", "c", "d", "e", "commit.md"), "---\ndescription: deep commit\n---\nCommit")
|
||||
|
||||
pkg, _, err := ParseDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDir: %v", err)
|
||||
}
|
||||
if got := pkg.CommandRoots(); len(got) != 1 || got[0] != filepath.Join(root, "commands") {
|
||||
t.Fatalf("CommandRoots = %#v, want commands adopted for the deeply nested layout", got)
|
||||
}
|
||||
inv := pkg.Inventory()
|
||||
if len(inv.Commands) != 1 || inv.Commands[0].Name != "a:b:c:d:e:commit" {
|
||||
t.Fatalf("inventory commands = %#v, want the namespaced deep command", inv.Commands)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package pluginpkg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestStateConcurrentUpsertAndSetEnabled pins that concurrent load-modify-save
|
||||
// cycles on the state file don't clobber each other: every plugin upserted by a
|
||||
// racing goroutine must survive, with the enabled flag it was last given.
|
||||
func TestStateConcurrentUpsertAndSetEnabled(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
const n = 16
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := range n {
|
||||
name := fmt.Sprintf("plugin-%02d", i)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := Upsert(home, InstalledPlugin{Name: name, Root: "plugins/" + name}); err != nil {
|
||||
t.Errorf("Upsert(%s): %v", name, err)
|
||||
return
|
||||
}
|
||||
if err := SetEnabled(home, name, true); err != nil {
|
||||
t.Errorf("SetEnabled(%s): %v", name, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
st, err := LoadState(home)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadState: %v", err)
|
||||
}
|
||||
if len(st.Plugins) != n {
|
||||
t.Fatalf("got %d plugins, want %d (lost updates)", len(st.Plugins), n)
|
||||
}
|
||||
for _, p := range st.Plugins {
|
||||
if !p.Enabled {
|
||||
t.Errorf("plugin %s lost its SetEnabled update", p.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStateConcurrentRemove pins that racing removals each observe their own
|
||||
// plugin exactly once and leave nothing behind.
|
||||
func TestStateConcurrentRemove(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
const n = 8
|
||||
for i := range n {
|
||||
name := fmt.Sprintf("plugin-%02d", i)
|
||||
if err := Upsert(home, InstalledPlugin{Name: name, Root: "plugins/" + name}); err != nil {
|
||||
t.Fatalf("Upsert(%s): %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := range n {
|
||||
name := fmt.Sprintf("plugin-%02d", i)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
removed, ok, err := Remove(home, name)
|
||||
if err != nil {
|
||||
t.Errorf("Remove(%s): %v", name, err)
|
||||
return
|
||||
}
|
||||
if !ok || removed.Name != name {
|
||||
t.Errorf("Remove(%s) = %+v, ok=%v", name, removed, ok)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
st, err := LoadState(home)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadState: %v", err)
|
||||
}
|
||||
if len(st.Plugins) != 0 {
|
||||
t.Fatalf("got %d plugins after removing all, want 0", len(st.Plugins))
|
||||
}
|
||||
}
|
||||
|
||||
// TestStateLoadDuringSaveNeverSeesTornFile pins the atomic write: a reader
|
||||
// racing a writer sees either the old state or the new one, never a truncated
|
||||
// or half-written file (which would surface as a JSON parse error). On Windows
|
||||
// the rename that publishes a new state file can make a concurrent open fail
|
||||
// with a transient sharing violation — that is the platform's locking
|
||||
// behavior, not a torn file, so such reads are retried instead of failed.
|
||||
func TestStateLoadDuringSaveNeverSeesTornFile(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
if err := Upsert(home, InstalledPlugin{Name: "seed", Root: "plugins/seed", Enabled: true}); err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for i := range 100 {
|
||||
if err := SetEnabled(home, "seed", i%2 == 0); err != nil {
|
||||
t.Errorf("SetEnabled: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
// Keep the writer's lifetime inside the test body: a t.Fatalf below must
|
||||
// not let TempDir cleanup race the still-running writer goroutine.
|
||||
defer func() { <-done }()
|
||||
|
||||
for {
|
||||
st, err := LoadState(home)
|
||||
if err != nil {
|
||||
var jsonErr *json.SyntaxError
|
||||
if errors.As(err, &jsonErr) {
|
||||
t.Fatalf("LoadState saw a torn state file: %v", err)
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
// Transient sharing violation while the writer renames the
|
||||
// new state into place — retry, it is not a torn file.
|
||||
continue
|
||||
}
|
||||
t.Fatalf("LoadState: %v", err)
|
||||
}
|
||||
if len(st.Plugins) != 1 || st.Plugins[0].Name != "seed" {
|
||||
t.Fatalf("state = %+v, want the single seed plugin", st.Plugins)
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user