Cletus plugin kit: rules for coding agents, specification, host API, style guide, worker SDK and cletus-plugin tool

This commit is contained in:
Eidonix committed 2026-11-10 19:50:09 -05:00
commit 9b19d3a8be
41 files changed
+4121

No files matched your search

+323
View File
@@ -0,0 +1,323 @@
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"git.eidonix.com/eidonix/cletus-plugins/sdk/manifest"
)
type message struct {
ID string `json:"id,omitempty"`
ParentID string `json:"parent_id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *struct {
Message string `json:"message"`
} `json:"error,omitempty"`
}
// testHost runs a worker and answers its host calls the way Cletus does,
// within the plugin's declared permissions. Every host call is logged.
type testHost struct {
plugin *manifest.Plugin
filesDir string // the folder the user "opened"
workspaceDir string // backs the one simulated shared folder ("" = none)
dataDir string
edit bool
capabilities map[string]bool
log io.Writer
cmd *exec.Cmd
stdin io.WriteCloser
writeMu sync.Mutex
mu sync.Mutex
pending map[string]chan message
next int
exited chan struct{}
}
const testWorkspaceID = "ws:00000000-0000-0000-0000-000000000001"
func (h *testHost) start(bin string) error {
if err := os.MkdirAll(h.dataDir, 0o755); err != nil {
return err
}
h.cmd = exec.Command(bin)
h.cmd.Dir = h.dataDir
h.cmd.Env = []string{"CLETUS_PLUGIN_ID=" + h.plugin.ID, "CLETUS_PLUGIN_DATA=" + h.dataDir}
for _, name := range []string{"HOME", "USERPROFILE", "PATH", "TMPDIR", "TEMP", "TMP", "LANG", "SystemRoot"} {
if v, ok := os.LookupEnv(name); ok {
h.cmd.Env = append(h.cmd.Env, name+"="+v)
}
}
h.cmd.Stderr = prefixWriter{w: h.log, prefix: "worker "}
var err error
if h.stdin, err = h.cmd.StdinPipe(); err != nil {
return err
}
stdout, err := h.cmd.StdoutPipe()
if err != nil {
return err
}
h.pending = map[string]chan message{}
h.exited = make(chan struct{})
if err := h.cmd.Start(); err != nil {
return err
}
go h.read(stdout)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var hs struct {
APIVersion int `json:"api_version"`
}
if err := h.call(ctx, "plugin.handshake", nil, &hs); err != nil {
return fmt.Errorf("handshake: %w", err)
}
if hs.APIVersion != 1 {
return fmt.Errorf("handshake: the worker speaks api_version %d; Cletus needs 1", hs.APIVersion)
}
return nil
}
func (h *testHost) stop() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
_ = h.call(ctx, "plugin.shutdown", nil, nil)
cancel()
h.stdin.Close()
select {
case <-h.exited:
case <-time.After(2 * time.Second):
_ = h.cmd.Process.Kill()
<-h.exited
}
}
func (h *testHost) read(stdout io.Reader) {
defer close(h.exited)
defer func() { _ = h.cmd.Wait() }()
sc := bufio.NewScanner(stdout)
sc.Buffer(make([]byte, 64<<10), 64<<20+1)
for sc.Scan() {
var m message
if err := json.Unmarshal(sc.Bytes(), &m); err != nil {
fmt.Fprintf(h.log, "host the worker wrote something that isn't a message (is it printing to stdout?): %.200s\n", sc.Text())
continue
}
switch {
case m.Method == "$/cancel":
case m.Method != "":
go h.serve(m)
default:
h.mu.Lock()
ch := h.pending[m.ID]
delete(h.pending, m.ID)
h.mu.Unlock()
if ch != nil {
ch <- m
}
}
}
h.mu.Lock()
for id, ch := range h.pending {
close(ch)
delete(h.pending, id)
}
h.mu.Unlock()
}
func (h *testHost) send(m message) error {
data, err := json.Marshal(m)
if err != nil {
return err
}
h.writeMu.Lock()
defer h.writeMu.Unlock()
_, err = h.stdin.Write(append(data, '\n'))
return err
}
func (h *testHost) call(ctx context.Context, method string, params, out any) error {
var raw json.RawMessage
if params != nil {
var err error
if raw, err = json.Marshal(params); err != nil {
return err
}
}
ch := make(chan message, 1)
h.mu.Lock()
h.next++
id := fmt.Sprintf("h%d", h.next)
h.pending[id] = ch
h.mu.Unlock()
if err := h.send(message{ID: id, Method: method, Params: raw}); err != nil {
return fmt.Errorf("the worker has stopped: %w", err)
}
select {
case reply, ok := <-ch:
if !ok {
return errors.New("the worker stopped before answering")
}
if reply.Error != nil {
return errors.New(reply.Error.Message)
}
if out != nil {
return json.Unmarshal(reply.Result, out)
}
return nil
case <-ctx.Done():
cancelID, _ := json.Marshal(id)
_ = h.send(message{Method: "$/cancel", Params: cancelID})
return ctx.Err()
}
}
// serve answers one host call from the worker.
func (h *testHost) serve(m message) {
result, err := h.answer(m.Method, m.Params)
reply := message{ID: m.ID}
outcome := "ok"
if err != nil {
reply.Error = &struct {
Message string `json:"message"`
}{err.Error()}
outcome = "error: " + err.Error()
} else {
reply.Result, _ = json.Marshal(result)
}
fmt.Fprintf(h.log, "host %s %s → %s\n", m.Method, clip(string(m.Params), 200), outcome)
if m.ID != "" {
_ = h.send(reply)
}
}
func clip(s string, n int) string {
if len(s) > n {
return s[:n] + "…"
}
return s
}
func (h *testHost) answer(method string, raw json.RawMessage) (any, error) {
if ok, need := h.plugin.Allowed(method); !ok {
if need != "" {
return nil, fmt.Errorf("this plugin is not allowed to use %s; it needs the %s permission", method, need)
}
return nil, fmt.Errorf("unknown plugin host operation %q", method)
}
var p struct {
Path, Key, Workspace, Prefix, Name, Data, ContentType string
TTL int
Args []any
}
_ = json.Unmarshal(raw, &p)
switch method {
case "host.auth.has_capability":
var c struct{ Capability string }
_ = json.Unmarshal(raw, &c)
return map[string]bool{"granted": h.capabilities[c.Capability]}, nil
case "host.auth.whoami":
return map[string]any{"key_id": 1, "name": "Test User", "org": "Test Organization", "org_id": "00000000-0000-0000-0000-000000000000"}, nil
case "host.files.resolve":
return h.resolve(p.Path)
case "host.files.can_write":
path, err := h.resolve(p.Path)
if err != nil {
path, err = h.within(p.Path)
}
return err == nil && h.edit && path != "", nil
case "host.event":
if !strings.HasPrefix(p.Name, h.plugin.ID+":") {
return nil, errors.New("plugin event is not permitted")
}
return nil, nil
case "host.screen.open":
return nil, errors.New("screens aren't available yet")
case "host.dialog.open", "host.dialog.folder", "host.dialog.save", "host.dialog.message":
return nil, errors.New("the test host has no dialogs; pass paths as tool arguments while testing")
case "host.plugin.call":
return nil, errors.New("the test host runs no other plugins")
case "host.data.publish":
path, err := h.inData(p.Path)
if err != nil {
return nil, err
}
if p.TTL < 1 || p.TTL > 3600 {
return nil, errors.New("artifact ttl must be 1 to 3600 seconds")
}
return map[string]string{"token": "test-token", "url": "file://" + filepath.ToSlash(path), "expiresAt": time.Now().Add(time.Duration(p.TTL) * time.Second).UTC().Format(time.RFC3339)}, nil
case "host.data.revoke":
return nil, nil
case "host.data.leases":
return 0, nil
case "host.data.remove":
path, err := h.inData(p.Path)
if err != nil {
return nil, err
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return nil, err
}
return nil, nil
case "host.agent.start", "host.agent.status", "host.agent.cancel":
return nil, errors.New("background runs aren't available in the test host")
}
if strings.HasPrefix(method, "host.workspace.") {
return h.workspace(method, p.Workspace, p.Key, p.Prefix, p.Path, p.Data)
}
return nil, fmt.Errorf("unknown plugin host operation %q", method)
}
// resolve answers host.files.resolve: a path inside the opened folder.
func (h *testHost) resolve(path string) (string, error) {
resolved, err := h.within(path)
if err != nil {
return "", err
}
if _, err := os.Stat(resolved); err != nil {
return "", fmt.Errorf("%s does not exist", path)
}
return resolved, nil
}
func (h *testHost) within(path string) (string, error) {
if path == "" {
return "", errors.New("path is empty")
}
if !filepath.IsAbs(path) {
path = filepath.Join(h.filesDir, path)
}
path = filepath.Clean(path)
if !inside(h.filesDir, path) {
return "", fmt.Errorf("%s is outside the files the user opened", path)
}
return path, nil
}
func (h *testHost) inData(path string) (string, error) {
if !filepath.IsAbs(path) {
path = filepath.Join(h.dataDir, path)
}
path = filepath.Clean(path)
if !inside(h.dataDir, path) {
return "", errors.New("the path must be inside the plugin's data folder")
}
return path, nil
}
func inside(root, path string) bool {
rel, err := filepath.Rel(root, path)
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel)
}