Files
paperclip-harness/tui/picker.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
}