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 }