Skill and extension picker for Paperclip agents; session notes and cleanup plan
This commit is contained in:
302
tui/agents.go
302
tui/agents.go
@@ -16,6 +16,8 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -41,6 +43,13 @@ type runsMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type pickerMsg struct {
|
||||
skills []skillItem
|
||||
exts []extensionItem
|
||||
note string
|
||||
err error
|
||||
}
|
||||
|
||||
type runLogMsg struct {
|
||||
runID string
|
||||
text string
|
||||
@@ -65,7 +74,14 @@ type agentsModel struct {
|
||||
runAt int
|
||||
settingAt int
|
||||
scroll int
|
||||
showHelp bool
|
||||
|
||||
skills []skillItem
|
||||
skillsAt int
|
||||
exts []extensionItem
|
||||
extsAt int
|
||||
picking bool
|
||||
pickerNote string
|
||||
showHelp bool
|
||||
|
||||
// runs are limited to this project by default; A shows every run in the
|
||||
// company, which is a different question and a much longer list.
|
||||
@@ -77,7 +93,7 @@ type agentsModel struct {
|
||||
width, height int
|
||||
}
|
||||
|
||||
var agentTabs = []string{"roster", "runs", "log", "settings"}
|
||||
var agentTabs = []string{"roster", "runs", "log", "settings", "skills", "extensions"}
|
||||
|
||||
func newAgentsModel(c *paperclip.Client, folder string) agentsModel {
|
||||
return agentsModel{client: c, folder: folder}
|
||||
@@ -138,6 +154,150 @@ func (m agentsModel) loadRuns(showAll bool) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
// loadPicker reads the three lists the picker needs: the person's own skills,
|
||||
// what Paperclip knows, and what this agent has.
|
||||
func (m agentsModel) loadPicker() tea.Cmd {
|
||||
c := m.client
|
||||
agent := m.chosenAgent()
|
||||
if agent == nil {
|
||||
return func() tea.Msg { return pickerMsg{err: fmt.Errorf("no agent chosen")} }
|
||||
}
|
||||
id := agent.ID
|
||||
return func() tea.Msg {
|
||||
company, err := c.CompanySkills()
|
||||
if err != nil {
|
||||
return pickerMsg{err: err}
|
||||
}
|
||||
have, err := c.AgentSkills(id)
|
||||
if err != nil {
|
||||
return pickerMsg{err: err}
|
||||
}
|
||||
bySlug := map[string]string{}
|
||||
for _, s := range company {
|
||||
bySlug[s.Slug] = s.Key
|
||||
}
|
||||
on := map[string]bool{}
|
||||
for _, k := range have.DesiredSkills {
|
||||
on[k] = true
|
||||
}
|
||||
|
||||
var skills []skillItem
|
||||
seen := map[string]bool{}
|
||||
for _, name := range localSkills() {
|
||||
item := skillItem{slug: name, name: name, global: true}
|
||||
if key, ok := bySlug[name]; ok {
|
||||
item.key, item.inCompany = key, true
|
||||
item.onAgent = on[key]
|
||||
}
|
||||
skills = append(skills, item)
|
||||
seen[name] = true
|
||||
}
|
||||
// Paperclip-only skills, which the person never put in ~/.agents.
|
||||
for _, s := range company {
|
||||
if seen[s.Slug] {
|
||||
continue
|
||||
}
|
||||
skills = append(skills, skillItem{
|
||||
slug: s.Slug, name: s.Name, key: s.Key,
|
||||
inCompany: true, onAgent: on[s.Key],
|
||||
})
|
||||
}
|
||||
|
||||
wrapped := wrapperExtensions(id)
|
||||
var exts []extensionItem
|
||||
for _, e := range localExtensions() {
|
||||
e.on = wrapped[e.name]
|
||||
exts = append(exts, e)
|
||||
}
|
||||
return pickerMsg{skills: skills, exts: exts}
|
||||
}
|
||||
}
|
||||
|
||||
// toggleSkill applies one skill change to the chosen agent: import it if
|
||||
// Paperclip does not know it, then add or remove it for that agent alone.
|
||||
func (m agentsModel) toggleSkill(item skillItem) tea.Cmd {
|
||||
c, company := m.client, m.client.Company
|
||||
agent := m.chosenAgent()
|
||||
if agent == nil {
|
||||
return func() tea.Msg { return pickerMsg{err: fmt.Errorf("no agent chosen")} }
|
||||
}
|
||||
return func() tea.Msg {
|
||||
key := item.key
|
||||
if item.onAgent {
|
||||
if err := c.SyncSkills(agent.ID, "remove", []string{key}); err != nil {
|
||||
return pickerMsg{err: err, note: "could not remove " + item.slug}
|
||||
}
|
||||
return pickerMsg{note: item.slug + " removed"}
|
||||
}
|
||||
if key == "" {
|
||||
// Local only: copy into the company's managed folder, then import.
|
||||
dst := filepath.Join(managedSkillsDir(company), item.slug)
|
||||
if err := copyTree(filepath.Join(agentsHome(), "skills", item.slug), dst); err != nil {
|
||||
return pickerMsg{err: err, note: "could not copy " + item.slug}
|
||||
}
|
||||
k, err := c.ImportSkill(dst)
|
||||
if err != nil {
|
||||
return pickerMsg{err: err, note: "could not import " + item.slug}
|
||||
}
|
||||
key = k
|
||||
}
|
||||
if err := c.SyncSkills(agent.ID, "add", []string{key}); err != nil {
|
||||
return pickerMsg{err: err, note: "could not add " + item.slug}
|
||||
}
|
||||
return pickerMsg{note: item.slug + " added"}
|
||||
}
|
||||
}
|
||||
|
||||
// toggleExtension flips one extension for the chosen agent and rewrites its
|
||||
// wrapper. Paperclip has no extension model of its own, so the wrapper is it.
|
||||
func (m agentsModel) toggleExtension(i int) tea.Cmd {
|
||||
agent := m.chosenAgent()
|
||||
exts := append([]extensionItem(nil), m.exts...)
|
||||
if agent == nil || i >= len(exts) {
|
||||
return func() tea.Msg { return pickerMsg{} }
|
||||
}
|
||||
exts[i].on = !exts[i].on
|
||||
var chosen []extensionItem
|
||||
for _, e := range exts {
|
||||
if e.on {
|
||||
chosen = append(chosen, e)
|
||||
}
|
||||
}
|
||||
p := &agentPicker{client: m.client}
|
||||
return func() tea.Msg {
|
||||
if err := writeWrapper(p, agent.ID, chosen); err != nil {
|
||||
return pickerMsg{err: err, note: "could not write the wrapper"}
|
||||
}
|
||||
verb := "added"
|
||||
if !exts[i].on {
|
||||
verb = "removed"
|
||||
}
|
||||
return pickerMsg{note: exts[i].name + " " + verb}
|
||||
}
|
||||
}
|
||||
|
||||
// copyTree copies a skill folder into the company's managed directory.
|
||||
func copyTree(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)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(target, data, info.Mode())
|
||||
})
|
||||
}
|
||||
|
||||
func (m agentsModel) loadLog(runID string) tea.Cmd {
|
||||
c := m.client
|
||||
return func() tea.Msg {
|
||||
@@ -234,6 +394,9 @@ func (m agentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.tab == 1 && len(m.runs) == 0 {
|
||||
return m, m.loadRuns(m.showAll)
|
||||
}
|
||||
if m.tab == 4 || m.tab == 5 {
|
||||
return m, m.loadPicker()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
case "up", "k":
|
||||
@@ -254,8 +417,25 @@ func (m agentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.moveTo(m.listLen() - 1)
|
||||
}
|
||||
return m, nil
|
||||
case " ":
|
||||
switch m.tab {
|
||||
case 4:
|
||||
if m.skillsAt < len(m.skills) {
|
||||
return m, m.toggleSkill(m.skills[m.skillsAt])
|
||||
}
|
||||
case 5:
|
||||
return m, m.toggleExtension(m.extsAt)
|
||||
}
|
||||
return m, nil
|
||||
case "enter":
|
||||
switch m.tab {
|
||||
case 4:
|
||||
if m.skillsAt < len(m.skills) {
|
||||
return m, m.toggleSkill(m.skills[m.skillsAt])
|
||||
}
|
||||
return m, nil
|
||||
case 5:
|
||||
return m, m.toggleExtension(m.extsAt)
|
||||
case 3:
|
||||
if m.settingAt >= 0 && m.settingAt < len(settingFields) {
|
||||
f := settingFields[m.settingAt]
|
||||
@@ -322,6 +502,27 @@ func (m agentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.readAt = time.Now()
|
||||
return m, nil
|
||||
|
||||
case pickerMsg:
|
||||
if msg.skills != nil {
|
||||
m.skills = msg.skills
|
||||
m.skillsAt = min(m.skillsAt, max(0, len(m.skills)-1))
|
||||
}
|
||||
if msg.exts != nil {
|
||||
m.exts = msg.exts
|
||||
m.extsAt = min(m.extsAt, max(0, len(m.exts)-1))
|
||||
}
|
||||
m.pickerNote = msg.note
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
} else {
|
||||
m.err = nil
|
||||
}
|
||||
// After a change the lists are stale; read them again.
|
||||
if msg.note != "" && msg.err == nil {
|
||||
return m, m.loadPicker()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case runsMsg:
|
||||
if msg.runs != nil {
|
||||
m.runs = msg.runs
|
||||
@@ -361,6 +562,10 @@ func (m agentsModel) listLen() int {
|
||||
return len(m.agents)
|
||||
case 1:
|
||||
return len(m.runs)
|
||||
case 4:
|
||||
return len(m.skills)
|
||||
case 5:
|
||||
return len(m.exts)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -379,6 +584,10 @@ func (m agentsModel) move(delta int) agentsModel {
|
||||
m.runAt = max(0, min(m.runAt+delta, len(m.runs)-1))
|
||||
case 3:
|
||||
m.settingAt = max(0, min(m.settingAt+delta, len(settingFields)-1))
|
||||
case 4:
|
||||
m.skillsAt = max(0, min(m.skillsAt+delta, len(m.skills)-1))
|
||||
case 5:
|
||||
m.extsAt = max(0, min(m.extsAt+delta, len(m.exts)-1))
|
||||
}
|
||||
return m.follow()
|
||||
}
|
||||
@@ -622,6 +831,10 @@ func (m agentsModel) View() string {
|
||||
body = m.runLines(m.width - 4)
|
||||
case 3:
|
||||
body = m.settingLines(m.width - 4)
|
||||
case 4:
|
||||
body = m.skillLines(m.width - 4)
|
||||
case 5:
|
||||
body = m.extensionLines(m.width - 4)
|
||||
default:
|
||||
body = m.logLines(m.width - 4)
|
||||
}
|
||||
@@ -639,13 +852,22 @@ func (m agentsModel) View() string {
|
||||
from := m.scroll
|
||||
to := min(len(body), from+visible)
|
||||
|
||||
footer := hintLine([]string{
|
||||
keyHint("[ ]", "roster, runs, log"),
|
||||
hints := []string{
|
||||
keyHint("[ ]", "roster, runs, log, settings, skills, extensions"),
|
||||
keyHint("enter", "open a run"),
|
||||
keyHint("p P w", "pause, resume, wake"),
|
||||
keyHint("r", "read again"),
|
||||
keyHint("?", "keys"),
|
||||
}, m.width)
|
||||
}
|
||||
if m.tab == 4 || m.tab == 5 {
|
||||
hints = []string{
|
||||
keyHint("[ ]", "switch tab"),
|
||||
keyHint("space", "turn this skill or extension on or off"),
|
||||
keyHint("↑↓ j k", "move"),
|
||||
keyHint("r", "read again"),
|
||||
}
|
||||
}
|
||||
footer := hintLine(hints, m.width)
|
||||
if m.notice != "" {
|
||||
footer = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render(m.notice)
|
||||
}
|
||||
@@ -947,3 +1169,73 @@ func (m agentsModel) logLines(width int) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// skillLines is the skills tab: every skill the person has, what Paperclip
|
||||
// knows about it, and whether this agent has it. Space or enter toggles.
|
||||
func (m agentsModel) skillLines(width int) []string {
|
||||
agent := m.chosenAgent()
|
||||
name := "no agent chosen"
|
||||
if agent != nil {
|
||||
name = agent.Name
|
||||
}
|
||||
out := []string{"", dimStyle().Render(" " + name + " · space toggles the chosen skill")}
|
||||
if len(m.skills) == 0 {
|
||||
return append(out, mutedStyle2().Render(" reading the skill lists…"))
|
||||
}
|
||||
for i, s := range m.skills {
|
||||
state := "not in Paperclip"
|
||||
colour := active.Warning
|
||||
if s.onAgent {
|
||||
state = "on this agent"
|
||||
colour = active.Success
|
||||
} else if s.inCompany {
|
||||
state = "in Paperclip"
|
||||
colour = active.Muted
|
||||
}
|
||||
line := fmt.Sprintf("%-26s %s", truncateVisible(s.slug, 26),
|
||||
lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(state))
|
||||
if i == m.skillsAt {
|
||||
out = append(out, selectedStyle2().Render(padTo("▸ "+line, width)))
|
||||
continue
|
||||
}
|
||||
out = append(out, " "+line)
|
||||
}
|
||||
if m.pickerNote != "" {
|
||||
out = append(out, "", " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render(m.pickerNote))
|
||||
}
|
||||
out = append(out, "", dimStyle().Render(" a skill only this agent has is the point of having agents."))
|
||||
return out
|
||||
}
|
||||
|
||||
// extensionLines is the extensions tab. An extension is turned on for one agent
|
||||
// by writing a wrapper and pointing that agent's command at it.
|
||||
func (m agentsModel) extensionLines(width int) []string {
|
||||
agent := m.chosenAgent()
|
||||
if agent == nil {
|
||||
return []string{"", mutedStyle2().Render(" no agent chosen")}
|
||||
}
|
||||
out := []string{"", dimStyle().Render(" " + agent.Name + " · space toggles the chosen extension")}
|
||||
if len(m.exts) == 0 {
|
||||
return append(out, mutedStyle2().Render(" nothing in ~/.agents/extensions"))
|
||||
}
|
||||
for i, e := range m.exts {
|
||||
mark := "off"
|
||||
colour := active.Muted
|
||||
if e.on {
|
||||
mark, colour = "on", active.Success
|
||||
}
|
||||
line := fmt.Sprintf("%-26s %s", truncateVisible(e.name, 26),
|
||||
lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(mark))
|
||||
if i == m.extsAt {
|
||||
out = append(out, selectedStyle2().Render(padTo("▸ "+line, width)))
|
||||
continue
|
||||
}
|
||||
out = append(out, " "+line)
|
||||
}
|
||||
if m.pickerNote != "" {
|
||||
out = append(out, "", " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render(m.pickerNote))
|
||||
}
|
||||
out = append(out, "", dimStyle().Render(" written as a wrapper at ~/.local/share/paperclip-harness/wrappers/"))
|
||||
out = append(out, dimStyle().Render(" and passed to Paperclip as this agent's command."))
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -499,3 +499,60 @@ func (c *Client) DashboardSummary() (Dashboard, error) {
|
||||
err := c.do("GET", "/api/companies/"+c.Company+"/dashboard", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// AgentSkills is what an agent may use: the desired list and what is installed.
|
||||
type AgentSkills struct {
|
||||
DesiredSkills []string `json:"desiredSkills"`
|
||||
Entries []struct {
|
||||
Key string `json:"key"`
|
||||
RuntimeName string `json:"runtimeName"`
|
||||
State string `json:"state"`
|
||||
} `json:"entries"`
|
||||
}
|
||||
|
||||
func (c *Client) AgentSkills(agentID string) (AgentSkills, error) {
|
||||
var out AgentSkills
|
||||
err := c.do("GET", "/api/agents/"+agentID+"/skills", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// SyncSkills sets an agent's skills. mode is add, remove or replace.
|
||||
func (c *Client) SyncSkills(agentID, mode string, keys []string) error {
|
||||
if !c.UsingUserKey() {
|
||||
return errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY")
|
||||
}
|
||||
return c.do("POST", "/api/agents/"+agentID+"/skills/sync",
|
||||
map[string]any{"mode": mode, "desiredSkills": keys}, nil)
|
||||
}
|
||||
|
||||
// CompanySkill is one skill Paperclip knows about for this company.
|
||||
type CompanySkill struct {
|
||||
Key string `json:"key"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
SourceType string `json:"sourceType"`
|
||||
}
|
||||
|
||||
func (c *Client) CompanySkills() ([]CompanySkill, error) {
|
||||
var out []CompanySkill
|
||||
err := c.do("GET", "/api/companies/"+c.Company+"/skills", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ImportSkill adds a skill that lives in the company's managed folder.
|
||||
func (c *Client) ImportSkill(source string) (string, error) {
|
||||
if !c.UsingUserKey() {
|
||||
return "", errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY")
|
||||
}
|
||||
var out struct {
|
||||
Imported []CompanySkill `json:"imported"`
|
||||
}
|
||||
if err := c.do("POST", "/api/companies/"+c.Company+"/skills/import",
|
||||
map[string]string{"source": source}, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(out.Imported) > 0 {
|
||||
return out.Imported[0].Key, nil
|
||||
}
|
||||
return "", fmt.Errorf("import returned no skill")
|
||||
}
|
||||
|
||||
190
tui/picker.go
Normal file
190
tui/picker.go
Normal file
@@ -0,0 +1,190 @@
|
||||
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
|
||||
}
|
||||
46
tui/picker_test.go
Normal file
46
tui/picker_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWrapperExtensionsReadsBackWhatWasWritten(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
id := "agent-1"
|
||||
chosen := []extensionItem{
|
||||
{name: "ask-user-question", path: "/x/ask-user-question/index.ts"},
|
||||
{name: "web-fetch", path: "/x/web-fetch.ts"},
|
||||
}
|
||||
fc := &fakeClient{}
|
||||
p := &agentPicker{client: fc}
|
||||
if err := writeWrapper(p, id, chosen); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := wrapperExtensions(id)
|
||||
if !got["ask-user-question"] || !got["web-fetch"] {
|
||||
t.Fatalf("read back %v, want both", got)
|
||||
}
|
||||
if err := writeWrapper(p, id, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".local", "share", "paperclip-harness", "wrappers", id+".sh")); !os.IsNotExist(err) {
|
||||
t.Fatalf("wrapper should be removed when nothing is chosen")
|
||||
}
|
||||
if fc.lastCommand != "pi" {
|
||||
t.Fatalf("command should go back to pi, got %q", fc.lastCommand)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeClient struct{ lastCommand string }
|
||||
|
||||
func (f *fakeClient) AgentRaw(string) (map[string]any, error) {
|
||||
return map[string]any{"adapterConfig": map[string]any{}}, nil
|
||||
}
|
||||
func (f *fakeClient) UpdateAgent(_ string, body map[string]any) error {
|
||||
cfg, _ := body["adapterConfig"].(map[string]any)
|
||||
f.lastCommand, _ = cfg["command"].(string)
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user