mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
* fix(mcp): trust installed readers in subagents Problem: User-installed MCP tools that declared readOnlyHint could be exposed as readers but then fail before dispatch in planners and read-only subagents. Users were told to configure a second per-tool reader policy after already installing or authorizing the server. Root cause: Strict reader trust did not treat explicit install or an exact project launch grant as authority for non-destructive server metadata. Direct, lazy, and on-demand paths also duplicated cached-to-live safety reconciliation, while trusted_read_only_tools remained wired through configuration and Desktop contracts. Fix: Authorize non-destructive readOnlyHint tools for installed or launch-authorized servers, carry reader intent only for trusted readers, and share one cached-to-live safety reconciliation boundary across lazy and on-demand adapters. Remove trusted_read_only_tools from runtime, Desktop, examples, and docs while accepting and dropping legacy input on the next save. Verification: - env -u DEEPSEEK_API_KEY go test -count=1 ./... - cd desktop && go test -count=1 ./... - cd desktop/frontend && pnpm test:typecheck - cd desktop/frontend && pnpm exec tsx src/__tests__/capabilities-panel-actions.test.ts - ./scripts/cache-guard.sh - ./scripts/check-cache-impact.sh - git diff --check * refactor(mcp): simplify installed server authorization Problem: User-installed MCP servers still depended on official-catalog and per-tool reader trust layers, so direct, lazy, and on-demand paths could disagree and strict child sessions could reject an already authorized reader. Root cause: Server authorization, signed package status, cached reader trust, and legacy plan-mode allowlists were modeled as separate overlapping decisions. Fix: Remove the official MCP catalog, signature/revocation plumbing, package verification, per-tool cached trust, and obsolete reader settings. Use one server-level authorization source for user installs, host-session installs, and exact project grants while retaining live read-only, destructive, and capability-fingerprint checks. Verification: - go test ./internal/plugin ./internal/agent ./internal/boot ./internal/config ./internal/installsource ./internal/pluginpkg ./internal/mcplaunch ./internal/planmode - go test ./internal/acp ./internal/agent ./internal/plugin ./internal/boot - cd desktop && go test ./... - ./scripts/cache-guard.sh - git diff --check * refactor(mcp): make install authorization final Problem: Installed MCP servers could still encounter server, per-tool, writer, or destructive approval branches, and users had to understand settings that duplicated the install decision. Root cause: MCP authorization, cached reader trust, approval policy, and process sandboxing evolved as separate paths across direct, lazy, and on-demand execution. Fix: Treat install or exact project identity confirmation as the server authorization boundary, keep only explicit global deny plus Plan/read-only and sandbox safety checks, remove catalog and approval configuration, and unify the persistent MCP process sandbox. Verification: env -u DEEPSEEK_API_KEY go test ./... go build ./... cd desktop && go test ./... cd desktop/frontend && pnpm typecheck cd desktop/frontend && pnpm exec tsx src/__tests__/capabilities-panel-actions.test.ts cd desktop/frontend && pnpm exec tsx src/__tests__/decision-surface.test.tsx * chore(mcp): remove retired cache helper Problem: CI lint found writeBoolKV left unused after per-tool reader trust was removed. Root cause: The cached reader-map fingerprint was deleted, but its serialization helper remained. Fix: Remove the dead helper. Verification: go test ./internal/plugin go vet ./internal/plugin go build ./... * refactor(mcp): stop blocking schema-only drift Problem: Authorized MCP calls could be rejected when a cached tool schema differed from the live server even though authorization and safety classification were unchanged. Root cause: A capability fingerprint mixed schema compatibility with read-only and destructive execution safety across direct, lazy, and on-demand paths. Fix: Remove the tool-level schema fingerprint chain, keep live server authorization and read-only/destructive reclassification checks, and refresh changed schemas for the next session without interrupting the current authorized call. Verification: - go test ./internal/plugin ./internal/agent ./internal/tool - env -u DEEPSEEK_API_KEY go test ./... - go vet ./... - go build ./... - ./scripts/cache-guard.sh - pnpm --dir site test - cd desktop && go test ./... * fix(mcp): preserve short Windows temp paths Problem: Windows stdio MCP servers inherited a deeply nested Reasonix private TEMP path. Servers such as MATLAB create watchdog Unix sockets below TEMP and exit before initialization when the path exceeds the platform limit. Root cause: MCP private-state preparation redirected TMP, TEMP, and TMPDIR below the workspace-scoped mcp-state directory on every platform even though Reasonix does not currently provide an OS sandbox on Windows. Fix: Keep the host temporary-directory environment for Windows stdio MCP processes while retaining private cache and state directories. Preserve the existing private temporary directory behavior on other platforms and add focused regression coverage for both branches. Verification: - go test ./internal/plugin -count=1 - GOOS=windows GOARCH=amd64 go test -c ./internal/plugin - go test ./... - go vet ./internal/plugin - git diff --check * Simplify MCP runtime authorization state Use Spec.Authorized as the sole resolved server authorization result so installed and host-session MCP servers no longer depend on launch-manager presence. Keep exact identity grants for project sources, align planner and strict read-only registries with non-destructive readOnlyHint metadata, and document the cooperative trust boundary. Add source and agent-surface matrix coverage while preserving the main provider-visible MCP tool surface. * Limit MCP identity digests to project servers Problem: User-installed and host-session MCP servers still resolved and hashed their executable identity during every startup even though installation was already the authorization decision. Root cause: Project launch authorization and schema-cache ownership shared generic fingerprint terminology, and start() computed the project identity before checking whether launch approval was required. Fix: Skip project identity resolution for installed servers, remove dead and non-participating identity state, and rename the remaining concepts to ProjectLaunchIdentityDigest and SchemaCacheKey while preserving existing JSON keys and digest bytes. Verification: go test ./internal/mcplaunch ./internal/plugin ./internal/capability ./internal/boot ./internal/control ./internal/agent; go test ./...; cd desktop && go test .; go vet ./...; ./scripts/cache-guard.sh; git diff --check. * Restore MCP 0.53 compatibility surfaces Problem: MCP authorization was simplified, but the current client still lacked four compatibility surfaces present in the 0.53 line: legacy SSE, workspace roots, progress notifications, and registry browsing/install. Root cause: the Go transport abstraction dropped inbound notifications and rejected legacy SSE; runtime specs did not carry a separate workspace root; the registry UI and CLI had no official MCP Registry client. Fix: add inbound request/progress routing across transports, implement legacy SSE with same-origin endpoint protection, advertise roots/list, and add an explicit cached official Registry marketplace to CLI and desktop. Remove stale approval documentation and the dead helper that broke lint. Verification: go test ./...; focused go test -race for plugin/registry/CLI/control; go vet in root and desktop; desktop go test ./...; frontend production build and marketplace UI test; Windows amd64 cross-build; official Registry browse smoke test. * Avoid MCP progress capacity overflow * Align MCP authorization documentation Problem: CLI help and Chinese documentation still contained ambiguous or obsolete wording that implied installed MCP writer or destructive calls could require per-call approval. Root cause: The server-level authorization simplification removed MCP approval modes, but not every user-facing explanation was updated consistently. Fix: State explicitly that install or exact project identity confirmation authorizes the server, that writer and destructive hints do not prompt per call, and that deny, Plan, and strict read-only filtering remain separate boundaries. Verification: go test ./internal/cli; go vet ./internal/cli; reasonix mcp help smoke test; stale-wording scan; git diff --check. * Harden MCP registry installs and SSE replies Problem: Registry cache freshness was shared across queries, cached entries could flow into install paths, and legacy SSE server requests spawned an unbounded reply goroutine per request. Root cause: The cache stored one global timestamp, Resolve reused Search's offline fallback, the desktop installed browse payloads directly, and the SSE reader delegated every reply to a new goroutine. Fix: Track freshness per Registry query with legacy-cache compatibility, require live resolution before CLI and desktop installs, and serialize SSE replies through a bounded single-worker queue with finite deadlines. Verification: - env -u DEEPSEEK_API_KEY go test -count=1 ./... - env -u DEEPSEEK_API_KEY go test -race -count=1 ./internal/mcpregistry ./internal/plugin - env -u DEEPSEEK_API_KEY go test -count=1 ./... (desktop) - env -u DEEPSEEK_API_KEY go vet ./... (root and desktop) - pnpm run test:all - pnpm run build - env -u DEEPSEEK_API_KEY ./scripts/cache-guard.sh - git diff --check * Reject unavailable live registry installs Problem: A Registry entry could become non-installable between browsing and the desktop's live re-resolution, leaving the generic add flow with an invalid empty connection. The SSE concurrency regression also used a fixed delay after routing the client response. Root cause: The desktop resolver verified freshness but not the resolved entry's installability, and the test inferred worker startup from elapsed time. Fix: Validate the live entry through PluginEntry before returning it to the installer, and synchronize the SSE test on the first reply POST instead of sleeping. Verification: - env -u DEEPSEEK_API_KEY go test -count=5 ./internal/plugin - env -u DEEPSEEK_API_KEY go test -race -count=3 ./internal/plugin - env -u DEEPSEEK_API_KEY go test -count=1 ./... - cd desktop && env -u DEEPSEEK_API_KEY go test -count=1 ./... - git diff --check
994 lines
28 KiB
Go
994 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
|
|
Compatibility Compatibility
|
|
}
|
|
|
|
type Inventory struct {
|
|
Skills []SkillRef
|
|
Agents []AgentRef
|
|
Commands []CommandRef
|
|
Hooks []HookRef
|
|
MCPServers []MCPServerRef
|
|
}
|
|
|
|
type Compatibility struct {
|
|
Status string `json:"status"`
|
|
Mapped []string `json:"mapped,omitempty"`
|
|
Skipped []CompatibilityIssue `json:"skipped,omitempty"`
|
|
}
|
|
|
|
type CompatibilityIssue struct {
|
|
Capability string `json:"capability"`
|
|
Path string `json:"path,omitempty"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
type SkillRef struct {
|
|
Name string
|
|
Description string
|
|
Path string
|
|
Invocation string
|
|
RunAs string
|
|
}
|
|
|
|
type AgentRef struct {
|
|
Name string
|
|
Description string
|
|
Path string
|
|
Invocation string
|
|
Model string
|
|
AllowedTools []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
|
|
DisplayName string
|
|
Description string
|
|
Transport string
|
|
Command string
|
|
URL string
|
|
AutoStart bool
|
|
}
|
|
|
|
// Manifest is the normalized manifest shape used by Reasonix.
|
|
type Manifest struct {
|
|
Name string
|
|
Version string
|
|
Description string
|
|
Homepage string
|
|
Repository string
|
|
Skills []string
|
|
// Agents are directories of Claude-style flat agent Markdown files. They are
|
|
// loaded as plugin-owned, manually invoked Reasonix subagent profiles.
|
|
Agents []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"`
|
|
Args []string `json:"args,omitempty"`
|
|
ContextFile string `json:"contextFile,omitempty"`
|
|
ShellCommand bool `json:"shellCommand,omitempty"`
|
|
Async bool `json:"async,omitempty"`
|
|
PayloadFormat string `json:"payloadFormat,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"`
|
|
DisplayName string `json:"display_name,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
Imported bool `json:"imported,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"`
|
|
Commit string `json:"commit,omitempty"`
|
|
}
|
|
|
|
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, issues := applyClaudeCompatibility(root, &manifest)
|
|
if err := validateManifest(root, &manifest); err != nil {
|
|
return Package{}, warnings, err
|
|
}
|
|
pkg := Package{Root: root, ManifestKind: "reasonix", Manifest: manifest}
|
|
pkg.Compatibility = compatibilityFor(pkg, issues)
|
|
return pkg, 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
|
|
var issues []CompatibilityIssue
|
|
if kind == "claude" {
|
|
warnings = append(warnings, applyClaudeConventionDirs(root, &manifest)...)
|
|
}
|
|
compatWarnings, compatIssues := applyClaudeCompatibility(root, &manifest)
|
|
warnings = append(warnings, compatWarnings...)
|
|
issues = append(issues, compatIssues...)
|
|
if err := validateManifest(root, &manifest); err != nil {
|
|
return Package{}, warnings, err
|
|
}
|
|
pkg := Package{Root: root, ManifestKind: kind, Manifest: manifest}
|
|
pkg.Compatibility = compatibilityFor(pkg, issues)
|
|
return pkg, 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"}
|
|
|
|
var claudeConventionAgentDirs = []string{"agents"}
|
|
|
|
// 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 claudeConventionAgentDirs {
|
|
dir := filepath.Join(root, filepath.FromSlash(rel))
|
|
if dirContainsAgentMd(dir) && !containsPathEntry(manifest.Agents, rel) {
|
|
manifest.Agents = append(manifest.Agents, 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, []CompatibilityIssue) {
|
|
appendRootClaudeInstructions(root, manifest)
|
|
return appendClaudeCompatibility(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 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 _, p := range m.Agents {
|
|
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
|
|
}
|
|
|
|
func (p Package) AgentRoots() []string {
|
|
var out []string
|
|
for _, rel := range p.Manifest.Agents {
|
|
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) AgentCount() int { return len(p.agentRefs()) }
|
|
|
|
func (p Package) Inventory() Inventory {
|
|
return Inventory{
|
|
Skills: p.skillRefs(),
|
|
Agents: p.agentRefs(),
|
|
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,
|
|
DisplayName: firstNonEmpty(strings.TrimSpace(server.DisplayName), name),
|
|
Description: strings.TrimSpace(server.Description),
|
|
Transport: pluginMCPTransport(server),
|
|
Command: strings.TrimSpace(server.Command),
|
|
URL: strings.TrimSpace(server.URL),
|
|
AutoStart: server.AutoStart == nil || *server.AutoStart,
|
|
})
|
|
}
|
|
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"
|
|
}
|