708 lines
22 KiB
Go
708 lines
22 KiB
Go
// Package paperclip is a thin read/write client for the Paperclip control plane.
|
|
//
|
|
// It holds no state. Every value it returns came from Paperclip on this call.
|
|
// Nothing here caches, and nothing here writes to disk.
|
|
package paperclip
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Client talks to one Paperclip instance.
|
|
type Client struct {
|
|
BaseURL string
|
|
Company string
|
|
token string
|
|
http *http.Client
|
|
}
|
|
|
|
// New builds a client from the environment.
|
|
//
|
|
// The token is read from PAPERCLIP_BOARD_KEY (the person's key) and only falls
|
|
// back to PAPERCLIP_API_KEY for read-only use. The token is never logged.
|
|
// fromSecrets reads one variable out of the person's own secrets file, for the
|
|
// same reason as the shell fallback: a terminal started by a desktop session
|
|
// does not inherit systemd's environment.d. The file is the person's; this
|
|
// only reads it, and the value is never logged.
|
|
func fromSecrets(name string) string {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
dir := filepath.Join(home, ".config", "environment.d")
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".conf") {
|
|
continue
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
k, v, ok := strings.Cut(line, "=")
|
|
if ok && strings.TrimSpace(k) == name {
|
|
return strings.Trim(strings.TrimSpace(v), `"'`)
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func varOr(name, fallback string) string {
|
|
if v := os.Getenv(name); v != "" {
|
|
return v
|
|
}
|
|
if v := fromSecrets(name); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// reachable says whether something answers at this address right now.
|
|
func reachable(url string) bool {
|
|
c := &http.Client{Timeout: 2 * time.Second}
|
|
resp, err := c.Get(url)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
resp.Body.Close()
|
|
return resp.StatusCode == 200
|
|
}
|
|
|
|
func New() (*Client, error) {
|
|
base := varOr("PAPERCLIP_API_URL", "")
|
|
if base == "" {
|
|
base = "http://127.0.0.1:3100"
|
|
if !reachable(base + "/api/health") {
|
|
// Paperclip lives on one machine; a session on another has to ask it.
|
|
base = "http://192.168.20.13:3100"
|
|
}
|
|
}
|
|
company := varOr("PAPERCLIP_COMPANY_ID", "d4921013-e5da-4d88-8016-57d5746e2d03")
|
|
token := varOr("PAPERCLIP_BOARD_KEY", "")
|
|
if token == "" {
|
|
token = varOr("PAPERCLIP_API_KEY", "")
|
|
}
|
|
if token == "" {
|
|
return nil, errors.New("no key found: set PAPERCLIP_BOARD_KEY, or put it in ~/.config/environment.d/*.conf")
|
|
}
|
|
return &Client{
|
|
BaseURL: strings.TrimRight(base, "/"),
|
|
Company: company,
|
|
token: token,
|
|
http: &http.Client{Timeout: 20 * time.Second},
|
|
}, nil
|
|
}
|
|
|
|
// UsingUserKey reports whether the person's key is in use. Writes need it.
|
|
func (c *Client) UsingUserKey() bool { return os.Getenv("PAPERCLIP_BOARD_KEY") != "" }
|
|
|
|
type Issue struct {
|
|
ID string `json:"id"`
|
|
Description string `json:"description"`
|
|
Identifier string `json:"identifier"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
Priority string `json:"priority"`
|
|
ProjectID string `json:"projectId"`
|
|
ParentID string `json:"parentId"`
|
|
AssigneeAgent string `json:"assigneeAgentId"`
|
|
AssigneeUser string `json:"assigneeUserId"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
|
|
// Workspace is where Paperclip put this task's own copy of the code. It is
|
|
// null when the instance does not make worktrees.
|
|
Workspace *struct {
|
|
CWD string `json:"cwd"`
|
|
Branch string `json:"branchName"`
|
|
BaseRef string `json:"baseRef"`
|
|
Strategy string `json:"strategyType"`
|
|
} `json:"currentExecutionWorkspace"`
|
|
}
|
|
|
|
// Run is one heartbeat run: when it started, how it ended, what it cost.
|
|
type Run struct {
|
|
ID string `json:"runId"`
|
|
Status string `json:"status"`
|
|
Source string `json:"invocationSource"`
|
|
AgentID string `json:"agentId"`
|
|
StartedAt string `json:"startedAt"`
|
|
FinishedAt string `json:"finishedAt"`
|
|
ErrorCode string `json:"errorCode"`
|
|
}
|
|
|
|
// Activity is one structural event on a task.
|
|
type Activity struct {
|
|
ID string `json:"id"`
|
|
ActorType string `json:"actorType"`
|
|
Action string `json:"action"`
|
|
CreatedAt string `json:"createdAt"`
|
|
}
|
|
|
|
// Runs returns a task's run history, newest first.
|
|
func (c *Client) Runs(issueID string) ([]Run, error) {
|
|
var out []Run
|
|
err := c.do("GET", "/api/issues/"+issueID+"/runs", nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Activity returns a task's structural events, newest first.
|
|
func (c *Client) Activity(issueID string) ([]Activity, error) {
|
|
var out []Activity
|
|
err := c.do("GET", "/api/issues/"+issueID+"/activity", nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// CreateTask creates a task, unassigned. Blockers are never accepted at
|
|
// creation: they must be a separate call, or the write is ignored.
|
|
type NewTask struct {
|
|
Title string
|
|
Description string
|
|
ProjectID string
|
|
}
|
|
|
|
func (c *Client) CreateTask(t NewTask) (Issue, error) {
|
|
if !c.UsingUserKey() {
|
|
return Issue{}, errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY")
|
|
}
|
|
var out Issue
|
|
body := map[string]any{
|
|
"title": t.Title,
|
|
"description": t.Description,
|
|
"projectId": t.ProjectID,
|
|
"status": "todo",
|
|
}
|
|
err := c.do("POST", "/api/companies/"+c.Company+"/issues", body, &out)
|
|
return out, err
|
|
}
|
|
|
|
// SetBlockers replaces a task's blocked-by list.
|
|
func (c *Client) SetBlockers(issueID string, blockerIDs []string) (Issue, error) {
|
|
if !c.UsingUserKey() {
|
|
return Issue{}, errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY")
|
|
}
|
|
var out Issue
|
|
err := c.do("PATCH", "/api/issues/"+issueID, map[string]any{"blockedByIssueIds": blockerIDs}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Assign gives a task to an agent. It is always a separate call from creation:
|
|
// an assigned task is claimed by that agent's run within seconds, and the run
|
|
// then owns the task's fields.
|
|
func (c *Client) Assign(issueID, agentID string) (Issue, error) {
|
|
if !c.UsingUserKey() {
|
|
return Issue{}, errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY")
|
|
}
|
|
var out Issue
|
|
err := c.do("PATCH", "/api/issues/"+issueID, map[string]any{"assigneeAgentId": agentID}, &out)
|
|
return out, err
|
|
}
|
|
|
|
type Project struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
URLKey string `json:"urlKey"`
|
|
Status string `json:"status"`
|
|
|
|
// The worktree policy is fixed when the project is created, so the screen
|
|
// reports it rather than offering to change it.
|
|
ExecutionWorkspacePolicy *struct {
|
|
Enabled bool `json:"enabled"`
|
|
DefaultMode string `json:"defaultMode"`
|
|
Strategy struct {
|
|
Type string `json:"type"`
|
|
Base string `json:"baseRef"`
|
|
} `json:"workspaceStrategy"`
|
|
} `json:"executionWorkspacePolicy"`
|
|
|
|
Workspaces []struct {
|
|
SourceType string `json:"sourceType"`
|
|
Cwd string `json:"cwd"`
|
|
DefaultRef string `json:"defaultRef"`
|
|
} `json:"workspaces"`
|
|
}
|
|
|
|
// Comment is one entry on a task's thread.
|
|
type Comment struct {
|
|
ID string `json:"id"`
|
|
Body string `json:"body"`
|
|
AuthorType string `json:"authorType"`
|
|
AuthorID string `json:"authorAgentId"`
|
|
AuthorUser string `json:"authorUserId"`
|
|
CreatedAt string `json:"createdAt"`
|
|
}
|
|
|
|
// BlockerInfo is what Paperclip says about one task's blockers.
|
|
//
|
|
// The list endpoint does not carry blockers, so the tree asks per task.
|
|
type BlockerInfo struct {
|
|
IssueID string
|
|
UnresolvedCount int
|
|
AllBlockersDone bool
|
|
DependencyReady bool
|
|
Diagnosis string
|
|
BlockingIdentities []string
|
|
}
|
|
|
|
type blockerResponse struct {
|
|
Diagnosis string `json:"diagnosis"`
|
|
Readiness struct {
|
|
AllBlockersDone bool `json:"allBlockersDone"`
|
|
DependencyReady bool `json:"isDependencyReady"`
|
|
UnresolvedBlockerCount int `json:"unresolvedBlockerCount"`
|
|
} `json:"readiness"`
|
|
Blockers []struct {
|
|
Identifier string `json:"identifier"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
} `json:"blockers"`
|
|
}
|
|
|
|
func (c *Client) do(method, path string, body any, out any) error {
|
|
var rd io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rd = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequest(method, c.BaseURL+path, rd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
req.Header.Set("Accept", "application/json")
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
var ue *url.Error
|
|
if errors.As(err, &ue) {
|
|
return fmt.Errorf("cannot reach Paperclip at %s: %w", c.BaseURL, ue.Err)
|
|
}
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
|
return fmt.Errorf("%s %s returned %d: %s", method, path, resp.StatusCode, snippet(raw))
|
|
}
|
|
if out == nil || len(raw) == 0 {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(raw, out); err != nil {
|
|
return fmt.Errorf("%s %s returned unreadable JSON: %w", method, path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func snippet(b []byte) string {
|
|
s := strings.TrimSpace(string(b))
|
|
if len(s) > 300 {
|
|
s = s[:300] + "…"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Projects lists the company's projects.
|
|
func (c *Client) Projects() ([]Project, error) {
|
|
var out []Project
|
|
err := c.do("GET", "/api/companies/"+c.Company+"/projects", nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// ResolveProject accepts a project id or url key and returns the project.
|
|
func (c *Client) ResolveProject(ref string) (Project, error) {
|
|
if ref == "" {
|
|
return Project{}, errors.New("no project given: use -project <id or urlKey>")
|
|
}
|
|
ps, err := c.Projects()
|
|
if err != nil {
|
|
return Project{}, err
|
|
}
|
|
for _, p := range ps {
|
|
if p.ID == ref || p.URLKey == ref {
|
|
return p, nil
|
|
}
|
|
}
|
|
return Project{}, fmt.Errorf("no project matches %q", ref)
|
|
}
|
|
|
|
// Issues returns every task in the company. The caller filters by project,
|
|
// because the list endpoint takes no project filter.
|
|
func (c *Client) Issues() ([]Issue, error) {
|
|
var out []Issue
|
|
err := c.do("GET", "/api/companies/"+c.Company+"/issues", nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Blockers asks Paperclip what blocks one task.
|
|
func (c *Client) Blockers(issueID string) (BlockerInfo, error) {
|
|
var raw blockerResponse
|
|
err := c.do("GET", "/api/issues/"+issueID+"/diagnostics/blockers", nil, &raw)
|
|
info := BlockerInfo{
|
|
IssueID: issueID,
|
|
UnresolvedCount: raw.Readiness.UnresolvedBlockerCount,
|
|
AllBlockersDone: raw.Readiness.AllBlockersDone,
|
|
DependencyReady: raw.Readiness.DependencyReady,
|
|
Diagnosis: raw.Diagnosis,
|
|
}
|
|
for _, b := range raw.Blockers {
|
|
info.BlockingIdentities = append(info.BlockingIdentities, b.Identifier+" "+b.Status)
|
|
}
|
|
return info, err
|
|
}
|
|
|
|
// Comments returns a task's thread, oldest first.
|
|
func (c *Client) Comments(issueID string) ([]Comment, error) {
|
|
var out []Comment
|
|
err := c.do("GET", "/api/issues/"+issueID+"/comments?order=asc", nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// PostComment writes a comment as the person, which requires the user key.
|
|
func (c *Client) PostComment(issueID, body string) (Comment, error) {
|
|
if !c.UsingUserKey() {
|
|
return Comment{}, errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY")
|
|
}
|
|
var out Comment
|
|
err := c.do("POST", "/api/issues/"+issueID+"/comments", map[string]string{"body": body}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// SetStatus changes one task's status as the person.
|
|
func (c *Client) SetStatus(issueID, status, comment string) (Issue, error) {
|
|
if !c.UsingUserKey() {
|
|
return Issue{}, errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY")
|
|
}
|
|
payload := map[string]any{"status": status}
|
|
if comment != "" {
|
|
payload["comment"] = comment
|
|
}
|
|
var out Issue
|
|
err := c.do("PATCH", "/api/issues/"+issueID, payload, &out)
|
|
return out, err
|
|
}
|
|
|
|
type Agent struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
BudgetCents int `json:"budgetMonthlyCents"`
|
|
SpentCents int `json:"spentMonthlyCents"`
|
|
}
|
|
|
|
// Agents lists the company's agents, used only to put a name on a task.
|
|
func (c *Client) Agents() ([]Agent, error) {
|
|
var out []Agent
|
|
err := c.do("GET", "/api/companies/"+c.Company+"/agents", nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Issue returns one task by id.
|
|
func (c *Client) Issue(issueID string) (Issue, error) {
|
|
var out Issue
|
|
err := c.do("GET", "/api/issues/"+issueID, nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Cost is what one task has cost so far.
|
|
type Cost struct {
|
|
CostCents int `json:"costCents"`
|
|
InputTokens int `json:"inputTokens"`
|
|
OutputTokens int `json:"outputTokens"`
|
|
RunCount int `json:"runCount"`
|
|
RuntimeMs int `json:"runtimeMs"`
|
|
}
|
|
|
|
// CostSummary returns the task's recorded spend.
|
|
func (c *Client) CostSummary(issueID string) (Cost, error) {
|
|
var out Cost
|
|
err := c.do("GET", "/api/issues/"+issueID+"/cost-summary", nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Health asks the control plane whether it is answering at all.
|
|
func (c *Client) Health() error {
|
|
return c.do("GET", "/api/health", nil, nil)
|
|
}
|
|
|
|
// AgentRun is one heartbeat run in the company, whichever agent ran it.
|
|
type AgentRun struct {
|
|
ID string `json:"id"`
|
|
AgentID string `json:"agentId"`
|
|
Status string `json:"status"`
|
|
Source string `json:"invocationSource"`
|
|
StartedAt string `json:"createdAt"`
|
|
FinishedAt string `json:"finishedAt"`
|
|
ErrorCode string `json:"errorCode"`
|
|
LastOutputAt string `json:"lastOutputAt"`
|
|
LastOutputBytes int `json:"lastOutputBytes"`
|
|
// The task a run worked on lives inside the run's context snapshot, not on
|
|
// the run itself.
|
|
Snapshot struct {
|
|
IssueID string `json:"issueId"`
|
|
TaskKey string `json:"taskKey"`
|
|
} `json:"contextSnapshot"`
|
|
}
|
|
|
|
// Runs returns every run in the company, newest first.
|
|
func (c *Client) CompanyRuns() ([]AgentRun, error) {
|
|
var out []AgentRun
|
|
err := c.do("GET", "/api/companies/"+c.Company+"/heartbeat-runs", nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// RunLog returns the recorded output of one run.
|
|
func (c *Client) RunLog(runID string, maxBytes int) (string, error) {
|
|
var raw []byte
|
|
req, err := http.NewRequest("GET", c.BaseURL+"/api/heartbeat-runs/"+runID+"/log", nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 400))
|
|
return "", fmt.Errorf("run log returned %d: %s", resp.StatusCode, snippet(b))
|
|
}
|
|
raw, err = io.ReadAll(io.LimitReader(resp.Body, int64(maxBytes)))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(raw) == maxBytes {
|
|
return string(raw) + "\n… (only the last " + fmt.Sprint(maxBytes) + " bytes are shown)", nil
|
|
}
|
|
return string(raw), nil
|
|
}
|
|
|
|
// PauseAgent and ResumeAgent are the two ways to stop and start an agent.
|
|
func (c *Client) PauseAgent(agentID string) error {
|
|
return c.do("POST", "/api/agents/"+agentID+"/pause", map[string]any{}, nil)
|
|
}
|
|
|
|
func (c *Client) ResumeAgent(agentID string) error {
|
|
return c.do("POST", "/api/agents/"+agentID+"/resume", map[string]any{}, nil)
|
|
}
|
|
|
|
// WakeAgent asks Paperclip to start a run for this agent now.
|
|
func (c *Client) WakeAgent(agentID string) error {
|
|
return c.do("POST", "/api/agents/"+agentID+"/wakeup",
|
|
map[string]any{"source": "on_demand", "triggerDetail": "manual", "forceFreshSession": false}, nil)
|
|
}
|
|
|
|
// UpdateAgent changes an agent's own record. Paperclip owns it, so this is a
|
|
// PATCH with only the fields that changed; a field left out is not touched.
|
|
func (c *Client) UpdateAgent(agentID string, body map[string]any) error {
|
|
if !c.UsingUserKey() {
|
|
return errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY")
|
|
}
|
|
return c.do("PATCH", "/api/agents/"+agentID, body, nil)
|
|
}
|
|
|
|
// AgentRaw reads one agent's full record, including the configuration blocks,
|
|
// so an edit can change one field and leave the rest exactly as it was.
|
|
func (c *Client) AgentRaw(agentID string) (map[string]any, error) {
|
|
var out map[string]any
|
|
err := c.do("GET", "/api/agents/"+agentID, nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Dashboard is the company's own summary: counts, spend and recent run activity.
|
|
// One call, 1.8 KB, cheap enough to poll.
|
|
type Dashboard struct {
|
|
Agents struct {
|
|
Active int `json:"active"`
|
|
Running int `json:"running"`
|
|
Paused int `json:"paused"`
|
|
Error int `json:"error"`
|
|
} `json:"agents"`
|
|
Tasks struct {
|
|
Open int `json:"open"`
|
|
InProgress int `json:"inProgress"`
|
|
Blocked int `json:"blocked"`
|
|
Done int `json:"done"`
|
|
} `json:"tasks"`
|
|
Costs struct {
|
|
MonthSpendCents int `json:"monthSpendCents"`
|
|
MonthBudgetCents int `json:"monthBudgetCents"`
|
|
MonthUtilizationPercent float64 `json:"monthUtilizationPercent"`
|
|
} `json:"costs"`
|
|
PendingApprovals int `json:"pendingApprovals"`
|
|
Budgets struct {
|
|
ActiveIncidents int `json:"activeIncidents"`
|
|
PausedAgents int `json:"pausedAgents"`
|
|
PausedProjects int `json:"pausedProjects"`
|
|
} `json:"budgets"`
|
|
RunActivity []struct {
|
|
Date string `json:"date"`
|
|
Total int `json:"total"`
|
|
Succeeded int `json:"succeeded"`
|
|
Failed int `json:"failed"`
|
|
} `json:"runActivity"`
|
|
}
|
|
|
|
// DashboardSummary reads the company's dashboard.
|
|
func (c *Client) DashboardSummary() (Dashboard, error) {
|
|
var out Dashboard
|
|
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")
|
|
}
|
|
|
|
// ExecutionWorkspace is one task's own directory: a git worktree, or a shared
|
|
// folder when the project does not use worktrees. Paperclip makes these, not us.
|
|
type ExecutionWorkspace struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
ProjectID string `json:"projectId"`
|
|
SourceIssueID string `json:"sourceIssueId"`
|
|
BranchName string `json:"branchName"`
|
|
BaseRef string `json:"baseRef"`
|
|
ProviderType string `json:"providerType"`
|
|
ProviderRef string `json:"providerRef"`
|
|
Mode string `json:"mode"`
|
|
Status string `json:"status"`
|
|
DeliveryState string `json:"deliveryState"`
|
|
Cwd string `json:"cwd"`
|
|
LastUsedAt string `json:"lastUsedAt"`
|
|
}
|
|
|
|
// CloseReadiness is Paperclip's own git scan of one execution workspace, plus
|
|
// what it would do if the workspace were closed. The git half is the only place
|
|
// in Paperclip that reports branch, dirtiness and how far behind a worktree is.
|
|
type CloseReadiness struct {
|
|
State string `json:"state"`
|
|
DeliveryState string `json:"deliveryState"`
|
|
BlockingReasons []string `json:"blockingReasons"`
|
|
Warnings []string `json:"warnings"`
|
|
Git struct {
|
|
RepoRoot string `json:"repoRoot"`
|
|
WorkspacePath string `json:"workspacePath"`
|
|
BranchName string `json:"branchName"`
|
|
BaseRef string `json:"baseRef"`
|
|
HasDirtyTrackedFiles bool `json:"hasDirtyTrackedFiles"`
|
|
HasUntrackedFiles bool `json:"hasUntrackedFiles"`
|
|
DirtyEntryCount int `json:"dirtyEntryCount"`
|
|
UntrackedEntryCount int `json:"untrackedEntryCount"`
|
|
AheadCount int `json:"aheadCount"`
|
|
BehindCount int `json:"behindCount"`
|
|
IsMergedIntoBase bool `json:"isMergedIntoBase"`
|
|
CreatedByRuntime bool `json:"createdByRuntime"`
|
|
} `json:"git"`
|
|
}
|
|
|
|
// ExecutionWorkspaces lists every task workspace in the company. They are
|
|
// filtered per project here rather than by the API, which has no such filter.
|
|
func (c *Client) ExecutionWorkspaces() ([]ExecutionWorkspace, error) {
|
|
var raw json.RawMessage
|
|
if err := c.do("GET", "/api/companies/"+c.Company+"/execution-workspaces", nil, &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
var list []ExecutionWorkspace
|
|
if err := json.Unmarshal(raw, &list); err == nil {
|
|
return list, nil
|
|
}
|
|
var wrapper struct {
|
|
Workspaces []ExecutionWorkspace `json:"workspaces"`
|
|
Data []ExecutionWorkspace `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(raw, &wrapper); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(wrapper.Workspaces) > 0 {
|
|
return wrapper.Workspaces, nil
|
|
}
|
|
return wrapper.Data, nil
|
|
}
|
|
|
|
// CloseReadinessFor asks Paperclip to scan one execution workspace's git state.
|
|
func (c *Client) CloseReadinessFor(id string) (CloseReadiness, error) {
|
|
var out CloseReadiness
|
|
err := c.do("GET", "/api/execution-workspaces/"+id+"/close-readiness", nil, &out)
|
|
return out, err
|
|
}
|