Files
2026-08-14 23:41:57 +08:00

151 lines
5.3 KiB
Go

// This file classifies a fetched pi package into its type(s) — extension,
// skill, prompt, or theme (#157). Classification reads the package's
// package.json: pi packages carry a "pi" metadata block declaring what they
// provide, and pigo also falls back to structural signals (a bin entry, a
// SKILL.md, a commands/ dir) so a package that omits explicit metadata but
// clearly is one type is still recognized.
//
// A single package may be several types at once — the npm catalog has combined
// "extensionskill" entries — so Classify returns a set. When nothing matches,
// it returns an error rather than guessing, so `pigo install` fails clearly on
// a package that isn't a pi package.
//
// NOTE on metadata shape: the exact pi metadata field names are taken from the
// pi package conventions (a top-level "pi" object with a "type" string or
// "types" array, and/or per-capability keys). Both the explicit "pi.type(s)"
// form and structural fallbacks are honored so classification is robust to
// packages that under-declare. See docs/issue#0157.html for the assumptions.
package pkgmgr
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"slices"
)
// packageJSON is the subset of an npm package.json pigo reads for classification
// and versioning. Unknown fields are ignored.
type packageJSON struct {
Name string `json:"name"`
Version string `json:"version"`
// Bin is npm's executable declaration: either a string path or a
// {name: path} object. Its presence signals an extension.
Bin json.RawMessage `json:"bin,omitempty"`
// Pi is the pi-specific metadata block declaring the package's capabilities.
Pi *piMeta `json:"pi,omitempty"`
}
// piMeta is the "pi" block of a package.json. It supports either a single
// "type" or a "types" list, plus per-capability keys so a package can declare,
// e.g., both an extension and a skill.
//
// The pi ecosystem convention (observed across published packages such as
// pi-simplify, pi-mcp-adapter, pi-spark, pi-ask-user) declares capabilities as
// PLURAL arrays of paths — "extensions", "skills", "prompts", "themes" — each
// listing the files/dirs that provide that capability. We also accept the
// singular forms ("extension", "skill", ...) so a package that declares a single
// capability object is still recognized. Any present value (array or object)
// registers that type; only its presence matters for classification.
type piMeta struct {
Type string `json:"type,omitempty"`
Types []string `json:"types,omitempty"`
Extension json.RawMessage `json:"extension,omitempty"`
Extensions json.RawMessage `json:"extensions,omitempty"`
Skill json.RawMessage `json:"skill,omitempty"`
Skills json.RawMessage `json:"skills,omitempty"`
Prompt json.RawMessage `json:"prompt,omitempty"`
Prompts json.RawMessage `json:"prompts,omitempty"`
Theme json.RawMessage `json:"theme,omitempty"`
Themes json.RawMessage `json:"themes,omitempty"`
}
// Classify inspects the fetched package directory and returns the set of pi
// package types it provides, along with the package name and version read from
// package.json. It returns an error when package.json is missing/unreadable or
// when no known pi type can be determined.
func Classify(pkgDir string) (name, version string, types []PackageType, err error) {
pjPath := filepath.Join(pkgDir, "package.json")
data, err := os.ReadFile(pjPath)
if err != nil {
return "", "", nil, fmt.Errorf("pkgmgr: read package.json: %w", err)
}
var pj packageJSON
if err := json.Unmarshal(data, &pj); err != nil {
return "", "", nil, fmt.Errorf("pkgmgr: parse package.json: %w", err)
}
set := map[PackageType]bool{}
// 1. Explicit pi metadata wins.
if pj.Pi != nil {
for _, t := range append(pj.Pi.Types, pj.Pi.Type) {
if pt, ok := normalizeType(t); ok {
set[pt] = true
}
}
if len(pj.Pi.Extension) > 0 || len(pj.Pi.Extensions) > 0 {
set[TypeExtension] = true
}
if len(pj.Pi.Skill) > 0 || len(pj.Pi.Skills) > 0 {
set[TypeSkill] = true
}
if len(pj.Pi.Prompt) > 0 || len(pj.Pi.Prompts) > 0 {
set[TypePrompt] = true
}
if len(pj.Pi.Theme) > 0 || len(pj.Pi.Themes) > 0 {
set[TypeTheme] = true
}
}
// 2. Structural fallbacks for packages that under-declare.
if len(pj.Bin) > 0 {
set[TypeExtension] = true
}
if fileExists(filepath.Join(pkgDir, "SKILL.md")) {
set[TypeSkill] = true
}
if dirExists(filepath.Join(pkgDir, "commands")) {
set[TypePrompt] = true
}
if len(set) == 0 {
return "", "", nil, fmt.Errorf("unrecognized pi package: no known pi metadata")
}
types = make([]PackageType, 0, len(set))
for t := range set {
types = append(types, t)
}
slices.Sort(types)
return pj.Name, pj.Version, types, nil
}
// normalizeType maps a pi metadata type string to a PackageType, reporting
// whether it is recognized.
func normalizeType(s string) (PackageType, bool) {
switch PackageType(s) {
case TypeExtension:
return TypeExtension, true
case TypeSkill:
return TypeSkill, true
case TypePrompt:
return TypePrompt, true
case TypeTheme:
return TypeTheme, true
default:
return "", false
}
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
func dirExists(path string) bool {
info, err := os.Stat(path)
return err == nil && info.IsDir()
}