Why: Tools, hooks, and providers need a way in without a Go rebuild, and packages need install, update, and removal. Evidence: go test -count=1 ./runtime/ext/ Impact: Extensions run as subprocesses with the caller's privileges. They are not a sandbox. Refs: feature/pi-headless N5 Co-authored-by: Cursor <cursoragent@cursor.com>
182 lines
4.5 KiB
Go
182 lines
4.5 KiB
Go
package pkg
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// Manifest is the agent-native.json discovered in a package.
|
|
type Manifest struct {
|
|
Name string `json:"name"`
|
|
Extensions []string `json:"extensions,omitempty"`
|
|
Config map[string]string `json:"config,omitempty"`
|
|
}
|
|
|
|
// Manager installs packages under Dir.
|
|
type Manager struct {
|
|
Dir string
|
|
}
|
|
|
|
func (m Manager) root() string {
|
|
if m.Dir != "" {
|
|
return m.Dir
|
|
}
|
|
home, _ := os.UserHomeDir()
|
|
return filepath.Join(home, ".agent-native", "packages")
|
|
}
|
|
|
|
// Install copies a local directory, clones a git URL, or runs npm pack
|
|
// when src starts with "npm:". The package name comes from its manifest.
|
|
func (m Manager) Install(src string) (Manifest, error) {
|
|
dir, err := os.MkdirTemp("", "agent-native-pkg-*")
|
|
if err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
defer os.RemoveAll(dir)
|
|
switch {
|
|
case strings.HasPrefix(src, "npm:"):
|
|
if _, err := exec.LookPath("npm"); err != nil {
|
|
return Manifest{}, fmt.Errorf("pkg: npm is not installed")
|
|
}
|
|
cmd := exec.Command("npm", "pack", strings.TrimPrefix(src, "npm:"))
|
|
cmd.Dir = dir
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
return Manifest{}, fmt.Errorf("pkg: npm pack: %s", out)
|
|
}
|
|
case strings.HasPrefix(src, "git+") || strings.HasSuffix(src, ".git") || strings.Contains(src, "://"):
|
|
cmd := exec.Command("git", "clone", "--depth", "1", src, filepath.Join(dir, "src"))
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
return Manifest{}, fmt.Errorf("pkg: git clone: %s", out)
|
|
}
|
|
dir = filepath.Join(dir, "src")
|
|
default:
|
|
if err := copyDir(src, filepath.Join(dir, "src")); err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
dir = filepath.Join(dir, "src")
|
|
}
|
|
manifest, err := ReadManifest(dir)
|
|
if err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
dest := filepath.Join(m.root(), manifest.Name)
|
|
if err := os.RemoveAll(dest); err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
if err := copyDir(dir, dest); err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
return manifest, nil
|
|
}
|
|
|
|
// Remove deletes an installed package.
|
|
func (m Manager) Remove(name string) error {
|
|
return os.RemoveAll(filepath.Join(m.root(), name))
|
|
}
|
|
|
|
// Update reinstalls from the recorded source when present, otherwise no-ops.
|
|
func (m Manager) Update(name string) (Manifest, error) {
|
|
manifest, err := ReadManifest(filepath.Join(m.root(), name))
|
|
if err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
src := manifest.Config["source"]
|
|
if src == "" {
|
|
return manifest, nil
|
|
}
|
|
return m.Install(src)
|
|
}
|
|
|
|
// List returns installed manifests.
|
|
func (m Manager) List() ([]Manifest, error) {
|
|
entries, err := os.ReadDir(m.root())
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
var out []Manifest
|
|
for _, ent := range entries {
|
|
if !ent.IsDir() {
|
|
continue
|
|
}
|
|
manifest, err := ReadManifest(filepath.Join(m.root(), ent.Name()))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out = append(out, manifest)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Config sets one package config key.
|
|
func (m Manager) Config(name, key, value string) error {
|
|
path := filepath.Join(m.root(), name)
|
|
manifest, err := ReadManifest(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if manifest.Config == nil {
|
|
manifest.Config = map[string]string{}
|
|
}
|
|
manifest.Config[key] = value
|
|
raw, err := json.MarshalIndent(manifest, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(filepath.Join(path, "agent-native.json"), raw, 0o644)
|
|
}
|
|
|
|
// ReadManifest loads agent-native.json from a directory.
|
|
func ReadManifest(dir string) (Manifest, error) {
|
|
raw, err := os.ReadFile(filepath.Join(dir, "agent-native.json"))
|
|
if err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
var manifest Manifest
|
|
if err := json.Unmarshal(raw, &manifest); err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
if manifest.Name == "" {
|
|
return Manifest{}, fmt.Errorf("pkg: manifest name is empty")
|
|
}
|
|
return manifest, nil
|
|
}
|
|
|
|
func copyDir(src, dst string) error {
|
|
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(src, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target := filepath.Join(dst, rel)
|
|
if info.IsDir() {
|
|
return os.MkdirAll(target, 0o755)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
|
return err
|
|
}
|
|
in, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
_, err = io.Copy(out, in)
|
|
return err
|
|
})
|
|
}
|