Skill and extension picker for Paperclip agents; session notes and cleanup plan

This commit is contained in:
Sam
2026-11-11 09:15:38 +11:00
parent e44b0dff51
commit 1f8731fd49
13 changed files with 1091 additions and 5 deletions

View File

@@ -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
}