191 lines
5.9 KiB
Go
191 lines
5.9 KiB
Go
package main
|
|
|
|
// The skill and extension picker, for Paperclip agents.
|
|
//
|
|
// pi-config gives a person a picker that turns skills and extensions on for one
|
|
// Pi session. A Paperclip agent has no such session to reach into — Paperclip
|
|
// builds the run's skill directory at run time — so the equivalent has to be
|
|
// set before the run, in Paperclip's own model:
|
|
//
|
|
// skills POST /api/agents/{id}/skills/sync, one list per agent
|
|
// extensions a per-agent wrapper passed as adapterConfig.command
|
|
//
|
|
// This file holds the data both tabs need and the writes they make. It reads
|
|
// the same source the picker reads: ~/.agents.
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// agentsHome is where the person's own skills and extensions live.
|
|
func agentsHome() string {
|
|
home, _ := os.UserHomeDir()
|
|
return filepath.Join(home, ".agents")
|
|
}
|
|
|
|
// managedSkillsDir is the one place Paperclip will import a local skill from.
|
|
// Anywhere else is refused with skill_workspace_boundary_denied.
|
|
func managedSkillsDir(company string) string {
|
|
home, _ := os.UserHomeDir()
|
|
return filepath.Join(home, ".paperclip", "instances", "default", "skills", company)
|
|
}
|
|
|
|
// skillItem is one line in the picker: a skill, what it is, and where it stands.
|
|
type skillItem struct {
|
|
slug string
|
|
name string
|
|
key string // the Paperclip key, empty when Paperclip does not know it
|
|
onAgent bool
|
|
inCompany bool
|
|
global bool // exists in ~/.agents/skills
|
|
}
|
|
|
|
// localSkills lists the skills in ~/.agents/skills, which is what pi-config shows.
|
|
func localSkills() []string {
|
|
entries, err := os.ReadDir(filepath.Join(agentsHome(), "skills"))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, e := range entries {
|
|
if e.IsDir() && !strings.HasPrefix(e.Name(), ".") && e.Name() != "node_modules" {
|
|
out = append(out, e.Name())
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// extensionItem is one extension that could be added to an agent's wrapper.
|
|
type extensionItem struct {
|
|
name string
|
|
path string
|
|
on bool
|
|
}
|
|
|
|
// localExtensions lists what ~/.agents/extensions holds: directories with an
|
|
// index.ts or a package.json that names pi extensions, and loose .ts files.
|
|
func localExtensions() []extensionItem {
|
|
dir := filepath.Join(agentsHome(), "extensions")
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var out []extensionItem
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if strings.HasPrefix(name, ".") || name == "node_modules" || name == "package.json" {
|
|
continue
|
|
}
|
|
if e.IsDir() {
|
|
index := filepath.Join(dir, name, "index.ts")
|
|
if _, err := os.Stat(index); err == nil {
|
|
out = append(out, extensionItem{name: name, path: index})
|
|
}
|
|
continue
|
|
}
|
|
if strings.HasSuffix(name, ".ts") {
|
|
out = append(out, extensionItem{name: strings.TrimSuffix(name, ".ts"), path: filepath.Join(dir, name)})
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name })
|
|
return out
|
|
}
|
|
|
|
// wrapperPath is where an agent's extension wrapper lives. It is outside the
|
|
// project on purpose: a worktree is deleted, and a wrapper must not be.
|
|
func wrapperPath(agentID string) string {
|
|
home, _ := os.UserHomeDir()
|
|
return filepath.Join(home, ".local", "share", "paperclip-harness", "wrappers", agentID+".sh")
|
|
}
|
|
|
|
// wrapperExtensions reads back which extensions a wrapper currently passes.
|
|
func wrapperExtensions(agentID string) map[string]bool {
|
|
out := map[string]bool{}
|
|
b, err := os.ReadFile(wrapperPath(agentID))
|
|
if err != nil {
|
|
return out
|
|
}
|
|
for _, line := range strings.Split(string(b), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if !strings.HasPrefix(line, "--extension ") {
|
|
continue
|
|
}
|
|
p := strings.TrimSpace(strings.TrimPrefix(line, "--extension "))
|
|
// The line ends with a shell continuation backslash.
|
|
p = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(p), "\\"))
|
|
p = strings.Trim(p, `"`)
|
|
base := filepath.Base(p)
|
|
if base == "index.ts" || base == "index.js" {
|
|
// A folder extension: the name that matters is the folder's.
|
|
base = filepath.Base(filepath.Dir(p))
|
|
} else if i := strings.Index(base, ".ts"); i > 0 {
|
|
base = base[:i]
|
|
}
|
|
out[base] = true
|
|
}
|
|
return out
|
|
}
|
|
|
|
// writeWrapper builds the wrapper from the chosen extensions and points the
|
|
// agent's command at it. With none chosen, the command goes back to plain pi.
|
|
func writeWrapper(c *agentPicker, agentID string, chosen []extensionItem) error {
|
|
if len(chosen) == 0 {
|
|
if err := os.Remove(wrapperPath(agentID)); err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
return c.setCommand(agentID, "pi")
|
|
}
|
|
path := wrapperPath(agentID)
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString("#!/bin/sh\n")
|
|
b.WriteString("# Written by the harness picker. Paperclip appends its own arguments,\n")
|
|
b.WriteString("# so this adds extensions and then hands over to pi unchanged.\n")
|
|
b.WriteString("exec pi \\\n")
|
|
for _, e := range chosen {
|
|
fmt.Fprintf(&b, " --extension %s \\\n", e.path)
|
|
}
|
|
b.WriteString(` "$@"` + "\n")
|
|
if err := os.WriteFile(path, []byte(b.String()), 0o755); err != nil {
|
|
return err
|
|
}
|
|
return c.setCommand(agentID, path)
|
|
}
|
|
|
|
// agentPicker is the bit of the model the wrapper writer needs: a way to change
|
|
// one agent's command without disturbing the rest of its configuration.
|
|
type agentPicker struct {
|
|
client interface {
|
|
AgentRaw(string) (map[string]any, error)
|
|
UpdateAgent(string, map[string]any) error
|
|
}
|
|
}
|
|
|
|
func (p *agentPicker) setCommand(agentID, command string) error {
|
|
full, err := p.client.AgentRaw(agentID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg, _ := full["adapterConfig"].(map[string]any)
|
|
merged := map[string]any{}
|
|
for k, v := range cfg {
|
|
merged[k] = v
|
|
}
|
|
merged["command"] = command
|
|
return p.client.UpdateAgent(agentID, map[string]any{"adapterConfig": merged})
|
|
}
|
|
|
|
// canRun reports whether a tool exists, so a picker can say so before offering.
|
|
func canRun(name string) bool {
|
|
_, err := exec.LookPath(name)
|
|
return err == nil
|
|
}
|