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

+296
View File
@@ -0,0 +1,296 @@
// Package manifest reads and checks plugin.json, and holds the permission
// table: which host methods each permission unlocks (docs/specification.md,
// "Permissions"; docs/host-api.md).
package manifest
import (
"encoding/json"
"errors"
"fmt"
"os"
"regexp"
"sort"
"strings"
)
// HostAPI is the host API version this SDK targets.
const HostAPI = 2
// Plugin is plugin.json.
type Plugin struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
MinAppVersion string `json:"min_app_version"`
HostAPI int `json:"host_api"`
Color string `json:"color,omitempty"`
Permissions []string `json:"permissions,omitempty"`
FileTypes []string `json:"file_types,omitempty"`
OpensFolders bool `json:"opens_folders,omitempty"`
Tools []Tool `json:"tools,omitempty"`
Provides map[string]Service `json:"provides,omitempty"`
Uses []string `json:"uses,omitempty"`
Dependencies []string `json:"dependencies,omitempty"`
// Fields a publisher may not set, or that wait for a later release.
UI json.RawMessage `json:"ui,omitempty"`
UIHostAPI json.RawMessage `json:"ui_host_api,omitempty"`
Free *bool `json:"free,omitempty"`
Capability string `json:"capability,omitempty"`
HostSeams []string `json:"host_seams,omitempty"`
}
// Tool is one entry of plugin.json "tools".
type Tool struct {
Name string `json:"name"`
Mutating bool `json:"mutating"`
RequiresApproval bool `json:"requires_approval,omitempty"`
}
// Service is one entry of plugin.json "provides".
type Service struct {
Methods map[string]Method `json:"methods"`
}
// Method is one method of a provided service.
type Method struct {
Description string `json:"description"`
Params json.RawMessage `json:"params"`
Result json.RawMessage `json:"result"`
Mutating bool `json:"mutating,omitempty"`
Long bool `json:"long,omitempty"`
}
// Read reads and decodes plugin.json, refusing unknown fields.
func Read(path string) (*Plugin, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
dec := json.NewDecoder(strings.NewReader(string(data)))
dec.DisallowUnknownFields()
var p Plugin
if err := dec.Decode(&p); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return &p, nil
}
// Always are the host methods every plugin may call.
var Always = []string{
"host.auth.has_capability",
"host.data.publish", "host.data.revoke", "host.data.leases", "host.data.remove",
"host.event", "host.screen.open",
"host.dialog.open", "host.dialog.folder", "host.dialog.save", "host.dialog.message",
"host.plugin.call",
}
// Permission is one permission a plugin may declare.
type Permission struct {
Words string // shown to the admin and the user
Methods []string // host methods it unlocks (empty: enforced by review)
}
// Permissions are the fixed permission tokens. net:<domain> is separate.
var Permissions = map[string]Permission{
"files": {"Open and save files you choose", []string{"host.files.resolve", "host.files.can_write"}},
"files.computer": {"Read and write other files on this computer", nil},
"workspace.read": {"Read files in shared folders you're a member of", []string{"host.workspace.folders", "host.workspace.list", "host.workspace.fetch", "host.workspace.read"}},
"workspace.write": {"Add, change and delete files in shared folders", []string{"host.workspace.put", "host.workspace.delete", "host.workspace.create"}},
"agents": {"Run Cletus in the background", []string{"host.agent.start", "host.agent.status", "host.agent.cancel"}},
"chat.handoff": {"Send work to chat", nil},
"identity": {"See your name and organization", []string{"host.auth.whoami"}},
"net.configured": {"Connect to devices and servers you set up in this plugin", nil},
"processes": {"Run programs on this computer", nil},
}
// NetPrefix starts a net:<domain> permission.
const NetPrefix = "net:"
// MaxNetDomains bounds the net:<domain> permissions.
const MaxNetDomains = 16
var (
idRE = regexp.MustCompile(`^[a-z][a-z0-9-]{1,31}\.[a-z][a-z0-9-]{0,47}$`)
semverRE = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$`)
toolRE = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
serviceRE = regexp.MustCompile(`^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$`)
methodRE = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
useRE = regexp.MustCompile(`^[a-z0-9.-]+:[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*\.([a-z][a-z0-9_]*|\*)$`)
domainRE = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$`)
extRE = regexp.MustCompile(`^\.[a-z0-9]{1,16}$`)
)
var colors = map[string]bool{"": true, "red": true, "orange": true, "amber": true, "yellow": true, "green": true,
"teal": true, "blue": true, "indigo": true, "violet": true, "pink": true, "grey": true}
// Publisher returns the publisher half of the id.
func (p *Plugin) Publisher() string { publisher, _, _ := strings.Cut(p.ID, "."); return publisher }
// Validate checks the manifest. Every problem is a plain sentence.
func (p *Plugin) Validate() []error {
var errs []error
add := func(format string, args ...any) { errs = append(errs, fmt.Errorf(format, args...)) }
if !idRE.MatchString(p.ID) {
add("id %q must be <publisher>.<name>: lowercase letters, digits and hyphens, starting with a letter", p.ID)
}
if strings.TrimSpace(p.Name) == "" {
add("name is empty")
}
if strings.TrimSpace(p.Description) == "" {
add("description is empty")
}
if !semverRE.MatchString(p.Version) {
add("version %q must be a version like 1.2.0", p.Version)
}
if !semverRE.MatchString(p.MinAppVersion) {
add("min_app_version %q must be a version like 0.5.0", p.MinAppVersion)
}
if p.HostAPI != HostAPI {
add("host_api must be %d", HostAPI)
}
if !colors[p.Color] {
add("color %q is not one of the Cletus colours", p.Color)
}
if p.Free != nil || p.Capability != "" {
add("free and capability are for Eidonix plugins; organizations turn your plugin on instead")
}
if len(p.HostSeams) > 0 {
add("host_seams is for Eidonix plugins; use permissions")
}
if len(p.UI) > 0 || len(p.UIHostAPI) > 0 {
add("screens (ui, ui_host_api) aren't available yet; deliver the feature as chat tools")
}
if len(p.FileTypes) > 0 {
add("file_types need a screen, which isn't available yet")
}
seen := map[string]bool{}
nets := 0
for _, perm := range p.Permissions {
if seen[perm] {
add("permission %q is listed twice", perm)
}
seen[perm] = true
if domain, ok := strings.CutPrefix(perm, NetPrefix); ok {
nets++
if !domainRE.MatchString(domain) {
add("%q: a net permission names one host, like net:api.example.com (no wildcards, ports or IP addresses)", perm)
}
continue
}
if _, ok := Permissions[perm]; !ok {
add("unknown permission %q; see docs/specification.md, Permissions", perm)
}
}
if nets > MaxNetDomains {
add("at most %d net:<domain> permissions", MaxNetDomains)
}
if seen["chat.handoff"] {
add("chat.handoff is for screens, which aren't available yet")
}
tools := map[string]bool{}
for _, t := range p.Tools {
if !toolRE.MatchString(t.Name) {
add("tool name %q must be lowercase letters, digits and underscores", t.Name)
}
if tools[t.Name] {
add("tool %q is listed twice", t.Name)
}
tools[t.Name] = true
}
for name, svc := range p.Provides {
if !serviceRE.MatchString(name) {
add("service name %q must be lowercase, optionally dotted", name)
}
if name == "screen" {
add("the screen service needs a screen, which isn't available yet")
}
for method, m := range svc.Methods {
if !methodRE.MatchString(method) {
add("method %s.%s: names are lowercase letters, digits and underscores", name, method)
}
for label, schema := range map[string]json.RawMessage{"params": m.Params, "result": m.Result} {
var v map[string]any
if len(schema) == 0 || json.Unmarshal(schema, &v) != nil {
add("method %s.%s: %s must be a JSON schema object", name, method, label)
} else if len(schema) > 16<<10 {
add("method %s.%s: %s is larger than 16 KiB", name, method, label)
} else if strings.Contains(string(schema), `"$ref":"http`) || strings.Contains(string(schema), `"$ref": "http`) {
add("method %s.%s: %s may not use a remote $ref", name, method, label)
}
}
}
}
for _, use := range p.Uses {
if !useRE.MatchString(use) {
add("use %q must be <plugin>:<service>.<method> or <plugin>:<service>.*", use)
} else if strings.HasPrefix(use, p.ID+":") {
add("a plugin can't use its own service (%s)", use)
}
}
for _, ext := range p.FileTypes {
if !extRE.MatchString(ext) {
add("file type %q must look like .ext", ext)
}
}
return errs
}
// Allowed reports whether the plugin may call method, and if not, the
// permission that would allow it.
func (p *Plugin) Allowed(method string) (bool, string) {
for _, m := range Always {
if m == method {
return true, ""
}
}
for _, perm := range p.Permissions {
for _, m := range Permissions[perm].Methods {
if m == method {
return true, ""
}
}
}
for perm, def := range Permissions {
for _, m := range def.Methods {
if m == method {
return false, perm
}
}
}
return false, ""
}
// Has reports whether the plugin declares perm.
func (p *Plugin) Has(perm string) bool {
for _, have := range p.Permissions {
if have == perm {
return true
}
}
return false
}
// HasNet reports whether any net permission is declared.
func (p *Plugin) HasNet() bool {
for _, have := range p.Permissions {
if have == "net.configured" || strings.HasPrefix(have, NetPrefix) {
return true
}
}
return false
}
// UsedMethods returns, sorted, the host methods the declared permissions unlock.
func (p *Plugin) UsedMethods() []string {
var out []string
for _, perm := range p.Permissions {
out = append(out, Permissions[perm].Methods...)
}
sort.Strings(out)
return out
}
// ErrNoManifest is returned when there is no plugin.json.
var ErrNoManifest = errors.New("no plugin.json here; run cletus-plugin in the plugin's folder")
+61
View File
@@ -0,0 +1,61 @@
package manifest
import (
"strings"
"testing"
)
func valid() *Plugin {
return &Plugin{ID: "acme.gauge-check", Name: "Gauge Check", Description: "Checks gauges.", Version: "1.0.0",
MinAppVersion: "0.5.0", HostAPI: HostAPI, Permissions: []string{"files", "net:api.example.com"},
Tools: []Tool{{Name: "gauge_check_run"}}}
}
func TestValidManifest(t *testing.T) {
if errs := valid().Validate(); len(errs) != 0 {
t.Fatalf("valid manifest refused: %v", errs)
}
}
func TestRefusals(t *testing.T) {
cases := map[string]func(*Plugin){
"must be <publisher>.<name>": func(p *Plugin) { p.ID = "gauge" },
"version": func(p *Plugin) { p.Version = "1.0" },
"host_api": func(p *Plugin) { p.HostAPI = 1 },
"unknown permission": func(p *Plugin) { p.Permissions = []string{"workspace"} },
"no wildcards": func(p *Plugin) { p.Permissions = []string{"net:10.0.0.1"} },
"Eidonix plugins": func(p *Plugin) { p.Capability = "quoting" },
"aren't available yet": func(p *Plugin) { p.UI = []byte(`{}`) },
"listed twice": func(p *Plugin) { p.Tools = append(p.Tools, Tool{Name: "gauge_check_run"}) },
"its own service": func(p *Plugin) { p.Uses = []string{"acme.gauge-check:gauge.check"} },
}
for want, change := range cases {
p := valid()
change(p)
errs := p.Validate()
found := false
for _, err := range errs {
found = found || strings.Contains(err.Error(), want)
}
if !found {
t.Errorf("%s: got %v", want, errs)
}
}
}
func TestAllowed(t *testing.T) {
p := valid()
if ok, _ := p.Allowed("host.files.resolve"); !ok {
t.Fatal("files permission did not allow host.files.resolve")
}
if ok, _ := p.Allowed("host.event"); !ok {
t.Fatal("host.event should always be allowed")
}
ok, need := p.Allowed("host.workspace.list")
if ok || need != "workspace.read" {
t.Fatalf("workspace.list = %v, %q", ok, need)
}
if ok, need := p.Allowed("host.code.llm.start"); ok || need != "" {
t.Fatalf("first-party method = %v, %q", ok, need)
}
}