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

+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+5
View File
@@ -0,0 +1,5 @@
Cletus plugin kit
Copyright 2026 Eidonix
Licensed under the Apache License, Version 2.0 (see LICENSE).
Cletus and Eidonix are trademarks of Eidonix and are not licensed by it.
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
)
// goEnv is the environment every build and test runs with: no cgo, no
// enclosing go.work, the plugin's vendor folder.
func goEnv(goos, goarch string) []string {
env := append(os.Environ(), "CGO_ENABLED=0", "GOWORK=off")
if goos != "" {
env = append(env, "GOOS="+goos, "GOARCH="+goarch)
}
return env
}
// goRun runs a go command in dir and returns its combined output.
func goRun(dir string, env []string, args ...string) (string, error) {
cmd := exec.Command("go", args...)
cmd.Dir = dir
cmd.Env = env
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
err := cmd.Run()
return out.String(), err
}
// buildWorker builds ./cmd/worker for this machine into a temporary folder.
func buildWorker(dir string) (string, func(), error) {
tmp, err := os.MkdirTemp("", "cletus-plugin-")
if err != nil {
return "", nil, err
}
cleanup := func() { os.RemoveAll(tmp) }
name := "worker"
if runtime.GOOS == "windows" {
name += ".exe"
}
bin := filepath.Join(tmp, name)
if out, err := goRun(dir, goEnv("", ""), "build", "-trimpath", "-o", bin, "./cmd/worker"); err != nil {
cleanup()
return "", nil, fmt.Errorf("the worker doesn't build:\n%s", out)
}
return bin, cleanup, nil
}
+483
View File
@@ -0,0 +1,483 @@
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"git.eidonix.com/eidonix/cletus-plugins/sdk/manifest"
)
// report collects the outcome of each check.
type report struct {
failures, warnings int
}
func (r *report) ok(name string) { fmt.Printf("ok %s\n", name) }
func (r *report) fail(name, detail string) {
r.failures++
fmt.Printf("FAIL %s\n%s", name, indent(detail))
}
func (r *report) warn(name, detail string) {
r.warnings++
fmt.Printf("warn %s\n%s", name, indent(detail))
}
func indent(s string) string {
s = strings.TrimRight(s, "\n")
if s == "" {
return ""
}
return " " + strings.ReplaceAll(s, "\n", "\n ") + "\n"
}
func runCheck(args []string) error {
if len(args) > 0 {
return errors.New("check takes no arguments; run it in the plugin's folder")
}
r := &report{}
plugin, err := readPlugin(".")
if err != nil {
return err
}
if errs := plugin.Validate(); len(errs) > 0 {
var b strings.Builder
for _, e := range errs {
fmt.Fprintln(&b, e)
}
r.fail("plugin.json", b.String())
} else {
r.ok("plugin.json")
}
checkFiles(r, plugin)
own := ownGoFiles()
checkSources(r, own)
checkGoMod(r)
if out, err := goRun(".", goEnv("", ""), "vet", "./..."); err != nil {
r.fail("go vet", out)
} else {
r.ok("go vet")
}
checkImports(r, plugin)
checkDependencies(r)
for _, target := range [][2]string{{"darwin", "arm64"}, {"windows", "amd64"}} {
name := "build " + target[0] + "/" + target[1]
if out, err := goRun(".", goEnv(target[0], target[1]), "build", "-trimpath", "-o", os.DevNull, "./cmd/worker"); err != nil {
r.fail(name, out)
} else {
r.ok(name)
}
}
if out, err := goRun(".", goEnv("", ""), "test", "-count=1", "./..."); err != nil {
r.fail("go test", out)
} else {
r.ok("go test")
}
optionalTool(r, "staticcheck", "./...")
optionalTool(r, "govulncheck", "./...")
checkRuntime(r, plugin)
fmt.Println()
switch {
case r.failures > 0:
return fmt.Errorf("%d check(s) failed, %d warning(s)", r.failures, r.warnings)
case r.warnings > 0:
fmt.Printf("All checks passed with %d warning(s). Explain each in docs/plan.md; reviewers read them.\n", r.warnings)
default:
fmt.Println("All checks passed.")
}
return nil
}
func checkFiles(r *report, plugin *manifest.Plugin) {
var problems []string
changelog, err := os.ReadFile("CHANGELOG.md")
if err != nil {
problems = append(problems, "CHANGELOG.md is missing")
} else if !regexp.MustCompile(`(?m)^#+\s*v?` + regexp.QuoteMeta(plugin.Version) + `\b`).Match(changelog) {
problems = append(problems, "CHANGELOG.md has no heading for version "+plugin.Version)
}
for _, path := range []string{"docs/plan.md", "docs/guide.md", "go.mod", "cmd/worker"} {
if _, err := os.Stat(path); err != nil {
problems = append(problems, path+" is missing")
}
}
if plan, err := os.ReadFile("docs/plan.md"); err == nil {
for _, perm := range plugin.Permissions {
if !strings.Contains(string(plan), perm) {
problems = append(problems, "docs/plan.md doesn't explain the "+perm+" permission")
}
}
}
if len(problems) > 0 {
r.fail("repository files", strings.Join(problems, "\n"))
} else {
r.ok("repository files")
}
}
// ownGoFiles lists the plugin's own Go files (not vendor/, not hidden folders).
func ownGoFiles() []string {
var files []string
_ = filepath.WalkDir(".", func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() && path != "." && (d.Name() == "vendor" || strings.HasPrefix(d.Name(), ".") || d.Name() == "node_modules") {
return filepath.SkipDir
}
if !d.IsDir() && strings.HasSuffix(path, ".go") {
files = append(files, path)
}
return nil
})
return files
}
var (
secretRE = regexp.MustCompile(`-----BEGIN [A-Z ]*PRIVATE KEY-----|AKIA[0-9A-Z]{16}|\bsk-[A-Za-z0-9_-]{20,}|\bghp_[A-Za-z0-9]{30,}|\bxox[abp]-[A-Za-z0-9-]{10,}`)
fileOpRE = regexp.MustCompile(`\bos\.(Open|OpenFile|ReadFile|WriteFile|Create|Remove|RemoveAll|ReadDir|Rename|Mkdir|MkdirAll)\(`)
linknameRE = regexp.MustCompile(`//go:linkname`)
)
func checkSources(r *report, files []string) {
var gofmtBad, cgo, secrets, linkname, fileOps []string
if len(files) > 0 {
out, _ := exec.Command("gofmt", append([]string{"-l"}, files...)...).Output()
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line != "" {
gofmtBad = append(gofmtBad, line)
}
}
}
_ = filepath.WalkDir(".", func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
if d != nil && d.IsDir() && path != "." && strings.HasPrefix(d.Name(), ".") {
return filepath.SkipDir
}
return nil
}
info, err := d.Info()
if err != nil || info.Size() > 4<<20 {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
text := string(data)
vendored := strings.HasPrefix(filepath.ToSlash(path), "vendor/")
if strings.HasSuffix(path, ".go") && regexp.MustCompile(`(?m)^import\s+"C"`).MatchString(text) {
cgo = append(cgo, path)
}
if !vendored && secretRE.MatchString(text) {
secrets = append(secrets, path)
}
if strings.HasSuffix(path, ".go") && !vendored {
if linknameRE.MatchString(text) {
linkname = append(linkname, path)
}
if !strings.HasSuffix(path, "_test.go") && fileOpRE.MatchString(text) {
fileOps = append(fileOps, path)
}
}
return nil
})
if len(gofmtBad) > 0 {
r.fail("gofmt", "not formatted (run gofmt -w):\n"+strings.Join(gofmtBad, "\n"))
} else {
r.ok("gofmt")
}
if len(cgo) > 0 {
r.fail("no cgo", "these files use cgo, which Cletus plugins can't:\n"+strings.Join(cgo, "\n"))
} else {
r.ok("no cgo")
}
if len(secrets) > 0 {
r.fail("no secrets", "these files look like they hold a key or token:\n"+strings.Join(secrets, "\n"))
} else {
r.ok("no secrets")
}
if len(linkname) > 0 {
r.fail("no go:linkname", strings.Join(linkname, "\n"))
}
if len(fileOps) > 0 {
r.warn("direct file access", "these files open, write or delete files directly. Each path must come from\nhost.files.resolve, a dialog, or pluginsdk.DataDir(); anything else needs files.computer:\n"+strings.Join(fileOps, "\n"))
}
}
func checkGoMod(r *report) {
data, err := os.ReadFile("go.mod")
if err != nil {
return
}
text := string(data)
var problems []string
if regexp.MustCompile(`(?m)^\s*replace\b|=>`).MatchString(text) {
problems = append(problems, "go.mod has replace directives; the store builds only from go.mod and vendor/")
}
if strings.Contains(text, "require") {
if _, err := os.Stat("vendor/modules.txt"); err != nil {
problems = append(problems, "vendor/ is missing; run go mod vendor and commit it")
} else if out, err := goRun(".", goEnv("", ""), "build", "-mod=vendor", "-o", os.DevNull, "./cmd/worker"); err != nil {
problems = append(problems, "vendor/ doesn't match go.mod; run go mod vendor:\n"+out)
}
}
if len(problems) > 0 {
r.fail("go.mod and vendor", strings.Join(problems, "\n"))
} else {
r.ok("go.mod and vendor")
}
}
type goPackage struct {
ImportPath string
Imports []string
Standard bool
Module *struct{ Path string }
}
func listPackages(args ...string) ([]goPackage, error) {
cmd := exec.Command("go", append([]string{"list", "-json"}, args...)...)
cmd.Env = goEnv("", "")
out, err := cmd.Output()
if err != nil {
var exit *exec.ExitError
if errors.As(err, &exit) {
return nil, fmt.Errorf("%s", exit.Stderr)
}
return nil, err
}
var pkgs []goPackage
dec := json.NewDecoder(strings.NewReader(string(out)))
for {
var p goPackage
if err := dec.Decode(&p); err == io.EOF {
break
} else if err != nil {
return nil, err
}
pkgs = append(pkgs, p)
}
return pkgs, nil
}
var netPackages = map[string]bool{"net": true, "net/http": true, "net/rpc": true, "net/smtp": true, "crypto/tls": true, "net/http/httputil": true}
func checkImports(r *report, plugin *manifest.Plugin) {
own, err := listPackages("./...")
if err != nil {
r.fail("imports", err.Error())
return
}
var failures, notes []string
for _, p := range own {
for _, imp := range p.Imports {
switch {
case imp == "plugin":
failures = append(failures, p.ImportPath+" imports plugin (loading code at run time is not allowed)")
case imp == "os/exec" && !plugin.Has("processes"):
failures = append(failures, p.ImportPath+" imports os/exec but the plugin doesn't declare processes")
case netPackages[imp] && !plugin.HasNet():
failures = append(failures, p.ImportPath+" imports "+imp+" but the plugin declares no net permission")
case imp == "unsafe" || imp == "reflect" || imp == "syscall" || strings.HasPrefix(imp, "golang.org/x/sys/"):
notes = append(notes, p.ImportPath+" imports "+imp)
}
}
}
if len(failures) > 0 {
r.fail("imports match permissions", strings.Join(failures, "\n"))
} else {
r.ok("imports match permissions")
}
if len(notes) > 0 {
r.warn("low-level imports", "explain each in docs/plan.md:\n"+strings.Join(notes, "\n"))
}
deps, err := listPackages("-deps", "./cmd/worker")
if err != nil {
return
}
var depNotes []string
for _, p := range deps {
if p.Standard || p.Module == nil || isOwnModule(p.Module.Path) {
continue
}
for _, imp := range p.Imports {
if imp == "os/exec" && !plugin.Has("processes") || netPackages[imp] && !plugin.HasNet() {
depNotes = append(depNotes, p.ImportPath+" imports "+imp)
}
}
}
if len(depNotes) > 0 {
sort.Strings(depNotes)
r.warn("dependencies that can start programs or connect", "your plugin doesn't declare this; make sure these code paths are never used:\n"+strings.Join(depNotes, "\n"))
}
}
func isOwnModule(path string) bool {
data, err := os.ReadFile("go.mod")
if err != nil {
return false
}
m := regexp.MustCompile(`(?m)^module\s+(\S+)`).FindStringSubmatch(string(data))
return len(m) == 2 && m[1] == path
}
func checkDependencies(r *report) {
f, err := os.Open("vendor/modules.txt")
if err != nil {
return
}
defer f.Close()
var missing []string
sc := bufio.NewScanner(f)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 3 || fields[0] != "#" || fields[2] == "=>" {
continue
}
dir := filepath.Join("vendor", filepath.FromSlash(fields[1]))
if _, err := os.Stat(dir); err != nil {
continue // a module with no packages in the build
}
if !hasLicense(dir) {
missing = append(missing, fields[1]+" "+fields[2])
}
}
if len(missing) > 0 {
r.warn("dependency licences", "no licence file found; the store must be able to confirm each licence allows distribution:\n"+strings.Join(missing, "\n"))
} else {
r.ok("dependency licences present")
}
}
func hasLicense(dir string) bool {
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
for _, e := range entries {
name := strings.ToUpper(e.Name())
if strings.HasPrefix(name, "LICENSE") || strings.HasPrefix(name, "LICENCE") || strings.HasPrefix(name, "COPYING") {
return true
}
}
return false
}
func optionalTool(r *report, tool string, args ...string) {
if _, err := exec.LookPath(tool); err != nil {
fmt.Printf("skip %s (not installed; the store runs it)\n", tool)
return
}
cmd := exec.Command(tool, args...)
cmd.Env = goEnv("", "")
out, err := cmd.CombinedOutput()
if err != nil {
r.fail(tool, string(out))
} else {
r.ok(tool)
}
}
// checkRuntime starts the worker and compares its tools with plugin.json.
func checkRuntime(r *report, plugin *manifest.Plugin) {
wd, _ := os.Getwd()
bin, cleanup, err := buildWorker(wd)
if err != nil {
r.fail("worker starts", err.Error())
return
}
defer cleanup()
tmp, err := os.MkdirTemp("", "cletus-plugin-check-")
if err != nil {
r.fail("worker starts", err.Error())
return
}
defer os.RemoveAll(tmp)
log := &syncBuffer{}
host := &testHost{plugin: plugin, filesDir: tmp, dataDir: filepath.Join(tmp, "data"), capabilities: map[string]bool{}, log: log}
if err := host.start(bin); err != nil {
r.fail("worker starts", err.Error()+"\n"+log.String())
return
}
defer host.stop()
r.ok("worker starts and answers the handshake")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var tools []struct {
Def struct {
Function struct {
Name string `json:"name"`
Description string `json:"description"`
} `json:"function"`
} `json:"def"`
Mutating bool `json:"mutating"`
RequiresApproval bool `json:"requires_approval"`
}
if err := host.call(ctx, "tools.list", nil, &tools); err != nil {
r.fail("tools match plugin.json", "tools.list: "+err.Error())
return
}
declared := map[string]manifest.Tool{}
for _, t := range plugin.Tools {
declared[t.Name] = t
}
var problems []string
for _, t := range tools {
name := t.Def.Function.Name
d, ok := declared[name]
switch {
case !ok:
problems = append(problems, "the worker offers "+name+", which plugin.json doesn't list")
case d.Mutating != t.Mutating || d.RequiresApproval != t.RequiresApproval:
problems = append(problems, name+": mutating or requires_approval differs between plugin.json and the worker")
}
if strings.TrimSpace(t.Def.Function.Description) == "" {
problems = append(problems, name+" has no description")
}
delete(declared, name)
}
for name := range declared {
problems = append(problems, "plugin.json lists "+name+", which the worker doesn't offer")
}
if len(problems) > 0 {
sort.Strings(problems)
r.fail("tools match plugin.json", strings.Join(problems, "\n"))
} else {
r.ok("tools match plugin.json")
}
}
// syncBuffer is a strings.Builder safe for the worker's log and the host's
// call log to write at once.
type syncBuffer struct {
mu sync.Mutex
b strings.Builder
}
func (s *syncBuffer) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.Write(p)
}
func (s *syncBuffer) String() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.String()
}
+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)
}
+101
View File
@@ -0,0 +1,101 @@
package main
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"git.eidonix.com/eidonix/cletus-plugins/sdk/manifest"
)
func newTestHost(t *testing.T, perms ...string) *testHost {
t.Helper()
root := t.TempDir()
files, ws := filepath.Join(root, "files"), filepath.Join(root, "ws")
for _, d := range []string{files, ws} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(files, "a.txt"), []byte("a"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(ws, "ref.csv"), []byte("x,y"), 0o644); err != nil {
t.Fatal(err)
}
return &testHost{plugin: &manifest.Plugin{ID: "acme.demo", Permissions: perms}, filesDir: files, workspaceDir: ws,
dataDir: filepath.Join(root, "data"), capabilities: map[string]bool{}}
}
func ask(t *testing.T, h *testHost, method string, params any) (any, error) {
t.Helper()
raw, _ := json.Marshal(params)
return h.answer(method, raw)
}
func TestPermissionsGateHostCalls(t *testing.T) {
h := newTestHost(t)
_, err := ask(t, h, "host.files.resolve", map[string]string{"path": "a.txt"})
if err == nil || !strings.Contains(err.Error(), "needs the files permission") {
t.Fatalf("undeclared files call = %v", err)
}
if _, err := ask(t, h, "host.code.llm.start", nil); err == nil || !strings.Contains(err.Error(), "unknown plugin host operation") {
t.Fatalf("first-party call = %v", err)
}
if _, err := ask(t, h, "host.event", map[string]any{"name": "other:x"}); err == nil {
t.Fatal("an event outside the plugin's prefix was allowed")
}
if _, err := ask(t, h, "host.event", map[string]any{"name": "acme.demo:x"}); err != nil {
t.Fatalf("own event = %v", err)
}
}
func TestFilesStayInsideTheOpenedFolder(t *testing.T) {
h := newTestHost(t, "files")
got, err := ask(t, h, "host.files.resolve", map[string]string{"path": "a.txt"})
if err != nil || got != filepath.Join(h.filesDir, "a.txt") {
t.Fatalf("resolve = %v, %v", got, err)
}
for _, path := range []string{"../escape.txt", "/etc/hosts"} {
if _, err := ask(t, h, "host.files.resolve", map[string]string{"path": path}); err == nil {
t.Errorf("resolve(%s) was allowed", path)
}
}
if got, _ := ask(t, h, "host.files.can_write", map[string]string{"path": "new.txt"}); got != false {
t.Fatal("can_write without -edit should be false")
}
h.edit = true
if got, _ := ask(t, h, "host.files.can_write", map[string]string{"path": "new.txt"}); got != true {
t.Fatal("can_write with -edit should be true")
}
}
func TestWorkspaceSimulation(t *testing.T) {
h := newTestHost(t, "files", "workspace.read", "workspace.write")
listed, err := ask(t, h, "host.workspace.list", map[string]string{"workspace": testWorkspaceName})
if err != nil || len(listed.([]map[string]any)) != 1 {
t.Fatalf("list = %v, %v", listed, err)
}
key := testWorkspaceID + "/files/ref.csv"
fetched, err := ask(t, h, "host.workspace.fetch", map[string]string{"key": key})
if err != nil {
t.Fatal(err)
}
path := fetched.(map[string]string)["path"]
if _, err := ask(t, h, "host.files.resolve", map[string]string{"path": path}); err != nil {
t.Fatalf("a fetched copy should resolve: %v", err)
}
data := base64.StdEncoding.EncodeToString([]byte("new"))
if _, err := ask(t, h, "host.workspace.put", map[string]string{"workspace": testWorkspaceID, "key": "out/r.txt", "data": data}); err != nil {
t.Fatal(err)
}
if _, err := ask(t, h, "host.workspace.put", map[string]string{"workspace": testWorkspaceID, "key": "../x", "data": data}); err == nil {
t.Fatal("a key with .. was accepted")
}
if _, err := ask(t, h, "host.workspace.read", map[string]string{"key": testWorkspaceID + "/files/out/r.txt"}); err != nil {
t.Fatal(err)
}
}
+61
View File
@@ -0,0 +1,61 @@
// Command cletus-plugin creates, checks and runs Cletus plugins.
//
// cletus-plugin new <publisher>.<name> [folder]
// cletus-plugin check
// cletus-plugin run [flags] tools | invoke <tool> [json] | ui <method> [json array] | service <service.method> [json]
package main
import (
"fmt"
"os"
)
// sdkVersion is the SDK release new plugins require (tag sdk/<version>).
const sdkVersion = "v0.1.0"
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
var err error
switch os.Args[1] {
case "new":
err = runNew(os.Args[2:])
case "check":
err = runCheck(os.Args[2:])
case "run":
err = runRun(os.Args[2:])
case "version":
fmt.Println("cletus-plugin", sdkVersion)
case "help", "-h", "--help":
usage()
default:
usage()
os.Exit(2)
}
if err != nil {
fmt.Fprintln(os.Stderr, "cletus-plugin:", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprint(os.Stderr, `cletus-plugin builds Cletus plugins.
cletus-plugin new <publisher>.<name> [folder] create a plugin from the template
cletus-plugin check run the store's automated checks here
cletus-plugin run [flags] <command> run the worker under the test host
tools list the chat tools
invoke <tool> [json] call a chat tool
ui <method> [json array] call a screen method
service <service.method> [json] call a provided service
flags: -files <dir> the folder the user "opened" (default: testdata)
-workspace <dir> simulate one shared folder backed by this directory
-edit the user allows edits (default: read-only)
-capability <name> a Cletus feature the user has (repeatable)
cletus-plugin version
Docs: https://git.eidonix.com/eidonix/cletus-plugins
`)
}
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"bytes"
"embed"
"errors"
"flag"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"text/template"
"unicode"
"git.eidonix.com/eidonix/cletus-plugins/sdk/manifest"
)
//go:embed all:template
var templateFS embed.FS
const sdkModule = "git.eidonix.com/eidonix/cletus-plugins/sdk"
type templateData struct {
ID, Publisher, Name, Title, Short, Module, SDKModule, SDKVersion string
}
func runNew(args []string) error {
fs := flag.NewFlagSet("new", flag.ContinueOnError)
sdkDir := fs.String("sdk-dir", "", "use a local copy of the SDK (for developing the SDK itself)")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() < 1 || fs.NArg() > 2 {
return errors.New("usage: cletus-plugin new <publisher>.<name> [folder]")
}
id := fs.Arg(0)
probe := manifest.Plugin{ID: id, Name: "x", Description: "x", Version: "0.1.0", MinAppVersion: "0.5.0", HostAPI: manifest.HostAPI}
if errs := probe.Validate(); len(errs) > 0 {
return errs[0]
}
publisher, name, _ := strings.Cut(id, ".")
dir := name
if fs.NArg() == 2 {
dir = fs.Arg(1)
}
if _, err := os.Stat(dir); err == nil {
return fmt.Errorf("%s already exists", dir)
}
data := templateData{ID: id, Publisher: publisher, Name: name, Title: title(name),
Short: strings.ReplaceAll(name, "-", "_"), Module: publisher + ".plugins/" + name,
SDKModule: sdkModule, SDKVersion: sdkVersion}
if err := writeTemplate(dir, data); err != nil {
return err
}
fmt.Printf("Created %s in %s.\n", id, dir)
if *sdkDir != "" {
abs, err := filepath.Abs(*sdkDir)
if err != nil {
return err
}
// Only for developing the SDK: check refuses replace directives.
if out, err := goRun(dir, goEnv("", ""), "mod", "edit", "-replace", sdkModule+"="+abs); err != nil {
return fmt.Errorf("go mod edit: %s", out)
}
}
for _, step := range [][]string{{"mod", "tidy"}, {"mod", "vendor"}} {
if out, err := goRun(dir, goEnv("", ""), step...); err != nil {
return fmt.Errorf("go %s failed. Fix the problem below and run it in %s yourself:\n%s", strings.Join(step, " "), dir, out)
}
}
fmt.Printf("\nNext:\n cd %s\n cletus-plugin check\n cletus-plugin run tools\n cletus-plugin run invoke %s_count_lines '{\"path\":\"sample.txt\"}'\n", dir, data.Short)
return nil
}
func writeTemplate(dir string, data templateData) error {
return fs.WalkDir(templateFS, "template", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
rel := strings.TrimPrefix(path, "template/")
rel = strings.TrimSuffix(rel, ".tmpl")
rel = strings.ReplaceAll(rel, "_dot_", ".")
src, err := templateFS.ReadFile(path)
if err != nil {
return err
}
t, err := template.New(rel).Delims("[[", "]]").Parse(string(src))
if err != nil {
return fmt.Errorf("template %s: %w", rel, err)
}
var out bytes.Buffer
if err := t.Execute(&out, data); err != nil {
return fmt.Errorf("template %s: %w", rel, err)
}
dst := filepath.Join(dir, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
return os.WriteFile(dst, out.Bytes(), 0o644)
})
}
func title(name string) string {
words := strings.Split(name, "-")
for i, w := range words {
if w != "" {
r := []rune(w)
r[0] = unicode.ToUpper(r[0])
words[i] = string(r)
}
}
return strings.Join(words, " ")
}
+134
View File
@@ -0,0 +1,134 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"git.eidonix.com/eidonix/cletus-plugins/sdk/manifest"
)
type listFlag []string
func (l *listFlag) String() string { return strings.Join(*l, ",") }
func (l *listFlag) Set(v string) error { *l = append(*l, v); return nil }
func runRun(args []string) error {
fs := flag.NewFlagSet("run", flag.ContinueOnError)
files := fs.String("files", "testdata", "the folder the user opened")
workspace := fs.String("workspace", "", "a directory to serve as one shared folder")
edit := fs.Bool("edit", false, "the user allows edits")
timeout := fs.Duration("timeout", 2*time.Minute, "how long to wait for the answer")
var caps listFlag
fs.Var(&caps, "capability", "a Cletus feature the user has (repeatable)")
if err := fs.Parse(args); err != nil {
return err
}
rest := fs.Args()
if len(rest) == 0 {
return errors.New("run needs a command: tools, invoke, ui or service")
}
plugin, err := readPlugin(".")
if err != nil {
return err
}
wd, _ := os.Getwd()
filesDir, err := filepath.Abs(*files)
if err != nil {
return err
}
if err := os.MkdirAll(filesDir, 0o755); err != nil {
return err
}
wsDir := ""
if *workspace != "" {
if wsDir, err = filepath.Abs(*workspace); err != nil {
return err
}
}
bin, cleanup, err := buildWorker(wd)
if err != nil {
return err
}
defer cleanup()
host := &testHost{plugin: plugin, filesDir: filesDir, workspaceDir: wsDir, edit: *edit,
dataDir: filepath.Join(wd, ".cletus-dev", "data"), capabilities: map[string]bool{}, log: os.Stderr}
for _, c := range caps {
host.capabilities[c] = true
}
if err := host.start(bin); err != nil {
return err
}
defer host.stop()
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
arg := func(i int, fallback string) string {
if len(rest) > i {
return rest[i]
}
return fallback
}
var out json.RawMessage
switch rest[0] {
case "tools":
err = host.call(ctx, "tools.list", nil, &out)
case "invoke":
if len(rest) < 2 {
return errors.New("invoke needs a tool name")
}
err = host.call(ctx, "tools.invoke", map[string]any{"name": rest[1], "args": arg(2, "{}"), "working_dir": filesDir, "edit_mode": *edit}, &out)
case "ui":
if len(rest) < 2 {
return errors.New("ui needs a method name")
}
err = host.call(ctx, "ui."+strings.TrimPrefix(rest[1], "ui."), json.RawMessage(arg(2, "[]")), &out)
case "service":
if len(rest) < 2 {
return errors.New("service needs <service>.<method>")
}
err = host.call(ctx, "svc."+rest[1], map[string]any{"caller": "cletus-plugin", "mutating": false, "args": json.RawMessage(arg(2, "{}"))}, &out)
default:
return fmt.Errorf("unknown run command %q", rest[0])
}
if err != nil {
return err
}
var pretty bytes.Buffer
if json.Indent(&pretty, out, "", " ") != nil {
pretty.Write(out)
}
fmt.Println(pretty.String())
return nil
}
func readPlugin(dir string) (*manifest.Plugin, error) {
path := filepath.Join(dir, "plugin.json")
if _, err := os.Stat(path); err != nil {
return nil, manifest.ErrNoManifest
}
return manifest.Read(path)
}
// prefixWriter prefixes every line written to w.
type prefixWriter struct {
w io.Writer
prefix string
}
func (p prefixWriter) Write(b []byte) (int, error) {
for _, line := range strings.SplitAfter(string(b), "\n") {
if line != "" {
fmt.Fprint(p.w, p.prefix+line)
}
}
return len(b), nil
}
@@ -0,0 +1,21 @@
# Rules for coding agents
This is a Cletus plugin. Before changing anything, read the plugin kit and
follow its rules exactly:
- Rules: https://git.eidonix.com/eidonix/cletus-plugins/src/branch/main/AGENTS.md
- Specification: https://git.eidonix.com/eidonix/cletus-plugins/src/branch/main/docs/specification.md
- Host API: https://git.eidonix.com/eidonix/cletus-plugins/src/branch/main/docs/host-api.md
- Style guide: https://git.eidonix.com/eidonix/cletus-plugins/src/branch/main/docs/style-guide.md
- Review checklist: https://git.eidonix.com/eidonix/cletus-plugins/src/branch/main/docs/review-checklist.md
If you can't read them, stop and ask the user for a copy of the kit.
Commands:
cletus-plugin check # must pass before you say you're done
cletus-plugin run tools # list the chat tools
cletus-plugin run invoke <tool> '<json>'
go test ./...
Keep docs/plan.md, plugin.json and CHANGELOG.md in step with the code.
@@ -0,0 +1,5 @@
# Changelog
## 0.1.0
- First version: count the lines in a text file, and save a summary next to it.
@@ -0,0 +1 @@
@AGENTS.md
@@ -0,0 +1,2 @@
/.cletus-dev/
/testdata/.cletus/
@@ -0,0 +1,18 @@
// Command worker is the [[.Title]] plugin's worker.
package main
import (
"fmt"
"os"
"[[.SDKModule]]/pluginsdk"
"[[.Module]]/internal/tools"
)
func main() {
if err := pluginsdk.Serve(pluginsdk.Handlers{Tools: tools.All()}); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
@@ -0,0 +1,8 @@
# [[.Title]]
Ask Cletus to count the lines in a file, or to save a summary of it.
- "How many lines are in report.txt?"
- "Save a line-count summary next to report.txt."
Saving needs Cletus to allow edits in the folder.
@@ -0,0 +1,25 @@
# [[.Title]]: plan
## What it does
Counts the lines in a text file the user chooses, and saves a short summary
next to it.
## Chat tools
- `[[.Short]]_count_lines`: reads a file and returns its line count. Read-only.
- `[[.Short]]_save_summary`: writes `<file>.summary.txt` next to the file.
Mutating; checks `host.files.can_write` first.
## Permissions
- `files`: to read the file the user chose and write the summary beside it.
## Dependencies
- `[[.SDKModule]]`: the Cletus plugin SDK.
## Notes for review
- Direct file access: `internal/tools` reads and writes only paths returned
by `host.files.resolve`, after `host.files.can_write` for writes.
@@ -0,0 +1,5 @@
module [[.Module]]
go 1.24
require [[.SDKModule]] [[.SDKVersion]]
@@ -0,0 +1,88 @@
// Package tools holds the plugin's chat tools.
package tools
import (
"bytes"
"context"
"fmt"
"os"
"[[.SDKModule]]/pluginsdk"
)
// All returns every chat tool. Each is also listed in plugin.json.
func All() []pluginsdk.Tool {
return []pluginsdk.Tool{countLines, saveSummary}
}
var pathParam = map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string", "description": "The text file, as the user named it."},
},
"required": []string{"path"},
}
var countLines = pluginsdk.Tool{
Name: "[[.Short]]_count_lines",
Description: "Count the lines in a text file. Use when the user asks how long a text file is. Returns the line count.",
Parameters: pathParam,
Run: func(ctx context.Context, h *pluginsdk.Host, call pluginsdk.ToolCall) pluginsdk.ToolResult {
path, lines, err := count(ctx, h, call)
if err != nil {
return pluginsdk.Fail("%v", err)
}
return pluginsdk.Text("%s has %d lines.", path, lines)
},
}
var saveSummary = pluginsdk.Tool{
Name: "[[.Short]]_save_summary",
Description: "Save a one-line summary (the line count) next to a text file, as <file>.summary.txt. Use only when the user asks to save it.",
Parameters: pathParam,
Mutating: true,
Run: func(ctx context.Context, h *pluginsdk.Host, call pluginsdk.ToolCall) pluginsdk.ToolResult {
path, lines, err := count(ctx, h, call)
if err != nil {
return pluginsdk.Fail("%v", err)
}
out := path + ".summary.txt"
allowed, err := h.CanWrite(ctx, out)
if err != nil {
return pluginsdk.Fail("%v", err)
}
if !allowed || !call.EditMode {
return pluginsdk.Fail("Cletus doesn't allow edits in this folder. Ask the user to allow edits, then try again.")
}
if err := os.WriteFile(out, []byte(fmt.Sprintf("%d lines\n", lines)), 0o644); err != nil {
return pluginsdk.Fail("Couldn't save the summary: %v", err)
}
return pluginsdk.Text("Saved %s.", out)
},
}
// count resolves the path through the host and counts its lines.
func count(ctx context.Context, h *pluginsdk.Host, call pluginsdk.ToolCall) (string, int, error) {
var args struct {
Path string `json:"path"`
}
if err := call.Decode(&args); err != nil {
return "", 0, err
}
if args.Path == "" {
return "", 0, fmt.Errorf("give the path of a text file")
}
path, err := h.ResolvePath(ctx, args.Path)
if err != nil {
return "", 0, err
}
data, err := os.ReadFile(path)
if err != nil {
return "", 0, fmt.Errorf("couldn't read %s: %v", args.Path, err)
}
lines := bytes.Count(data, []byte("\n"))
if len(data) > 0 && data[len(data)-1] != '\n' {
lines++
}
return path, lines, nil
}
@@ -0,0 +1,73 @@
package tools
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"[[.SDKModule]]/pluginsdk"
)
// fakeHost answers host.files.* for files inside dir, like Cletus does for
// the folder the user opened.
func fakeHost(dir string, canWrite bool) *pluginsdk.Host {
return pluginsdk.NewTestHost(func(method string, params json.RawMessage) (any, error) {
var p struct{ Path string }
_ = json.Unmarshal(params, &p)
path := p.Path
if !filepath.IsAbs(path) {
path = filepath.Join(dir, path)
}
switch method {
case "host.files.resolve":
if _, err := os.Stat(path); err != nil {
return nil, errors.New(p.Path + " does not exist")
}
return path, nil
case "host.files.can_write":
return canWrite, nil
}
return nil, errors.New("unexpected host call " + method)
})
}
func run(t *testing.T, tool pluginsdk.Tool, h *pluginsdk.Host, args string, edit bool) pluginsdk.ToolResult {
t.Helper()
return tool.Run(context.Background(), h, pluginsdk.ToolCall{Args: json.RawMessage(args), EditMode: edit})
}
func TestCountLines(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("one\ntwo\nthree"), 0o644); err != nil {
t.Fatal(err)
}
got := run(t, countLines, fakeHost(dir, false), `{"path":"a.txt"}`, false)
if got.Error != "" || !strings.Contains(got.Text, "3 lines") {
t.Fatalf("got %+v", got)
}
if got := run(t, countLines, fakeHost(dir, false), `{"path":"missing.txt"}`, false); got.Error == "" {
t.Fatal("a missing file should fail")
}
}
func TestSaveSummaryNeedsEdits(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("one\n"), 0o644); err != nil {
t.Fatal(err)
}
if got := run(t, saveSummary, fakeHost(dir, false), `{"path":"a.txt"}`, false); got.Error == "" {
t.Fatal("saving without edits allowed should fail")
}
got := run(t, saveSummary, fakeHost(dir, true), `{"path":"a.txt"}`, true)
if got.Error != "" {
t.Fatalf("got %+v", got)
}
data, err := os.ReadFile(filepath.Join(dir, "a.txt.summary.txt"))
if err != nil || string(data) != "1 lines\n" {
t.Fatalf("summary = %q, %v", data, err)
}
}
@@ -0,0 +1,8 @@
{
"summary": "Counts the lines in a text file.",
"category": "Productivity",
"visibility": "store",
"organizations": [],
"support": "",
"price": "free"
}
@@ -0,0 +1,14 @@
{
"id": "[[.ID]]",
"name": "[[.Title]]",
"description": "Counts the lines in a text file and saves a summary next to it. Replace this with what your plugin does.",
"version": "0.1.0",
"min_app_version": "0.5.0",
"host_api": 2,
"color": "teal",
"permissions": ["files"],
"tools": [
{"name": "[[.Short]]_count_lines", "mutating": false},
{"name": "[[.Short]]_save_summary", "mutating": true}
]
}
@@ -0,0 +1,3 @@
first line
second line
third line
+190
View File
@@ -0,0 +1,190 @@
package main
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
)
const testWorkspaceName = "Test folder"
// workspace answers host.workspace.* from one directory, as one shared
// folder the user owns.
func (h *testHost) workspace(method, workspace, key, prefix, src, data string) (any, error) {
if h.workspaceDir == "" {
return nil, errors.New("no shared folders in the test host; pass -workspace <dir> to simulate one")
}
keyPath := func(key string) (string, error) {
rel, ok := strings.CutPrefix(key, testWorkspaceID+"/files/")
if !ok || rel == "" {
return "", fmt.Errorf("%q is not a file in a shared folder (keys look like %s/files/<path>)", key, testWorkspaceID)
}
return h.workspaceFile(rel)
}
folder := func() error {
if workspace != testWorkspaceID && workspace != testWorkspaceName {
return fmt.Errorf("you are not a member of a shared folder %q", workspace)
}
return nil
}
switch method {
case "host.workspace.folders":
return []map[string]string{{"id": testWorkspaceID, "name": testWorkspaceName, "role": "owner"}}, nil
case "host.workspace.list":
if err := folder(); err != nil {
return nil, err
}
var out []map[string]any
err := filepath.WalkDir(h.workspaceDir, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || len(out) >= 1000 {
return err
}
rel := filepath.ToSlash(mustRel(h.workspaceDir, p))
if !strings.HasPrefix(rel, prefix) {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
etag, err := fileETag(p)
if err != nil {
return err
}
out = append(out, map[string]any{"key": testWorkspaceID + "/files/" + rel, "size": info.Size(), "etag": etag, "modified": info.ModTime().UTC()})
return nil
})
return out, err
case "host.workspace.fetch":
file, err := keyPath(key)
if err != nil {
return nil, err
}
etag, err := fileETag(file)
if err != nil {
return nil, fmt.Errorf("%s does not exist", key)
}
// Like Cletus: a cached copy in the working folder's scratch area,
// which host.files.resolve accepts.
dst := filepath.Join(h.filesDir, ".cletus", "tmp", "workspace", filepath.FromSlash(strings.TrimPrefix(key, testWorkspaceID+"/files/")))
if err := copyFile(file, dst); err != nil {
return nil, err
}
return map[string]string{"path": dst, "etag": etag}, nil
case "host.workspace.read":
file, err := keyPath(key)
if err != nil {
return nil, err
}
info, err := os.Stat(file)
if err != nil {
return nil, fmt.Errorf("%s does not exist", key)
}
if info.Size() > 2<<20 {
return nil, fmt.Errorf("%s is larger than 2 MiB; use host.workspace.fetch", key)
}
body, err := os.ReadFile(file)
if err != nil {
return nil, err
}
etag, _ := fileETag(file)
return map[string]any{"data": base64.StdEncoding.EncodeToString(body), "etag": etag, "size": len(body)}, nil
case "host.workspace.put":
if err := folder(); err != nil {
return nil, err
}
if key == "" || strings.Contains(key, "..") || strings.HasPrefix(key, "/") || strings.Contains(key, "//") {
return nil, fmt.Errorf("key %q must be a relative path with no empty or .. segments", key)
}
dst, err := h.workspaceFile(key)
if err != nil {
return nil, err
}
var body []byte
if src != "" {
resolved, err := h.resolve(src)
if err != nil {
return nil, err
}
if body, err = os.ReadFile(resolved); err != nil {
return nil, err
}
} else if body, err = base64.StdEncoding.DecodeString(data); err != nil {
return nil, errors.New("data must be base64")
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return nil, err
}
if err := os.WriteFile(dst, body, 0o644); err != nil {
return nil, err
}
etag, _ := fileETag(dst)
return map[string]any{"key": testWorkspaceID + "/files/" + key, "size": len(body), "etag": etag}, nil
case "host.workspace.delete":
file, err := keyPath(key)
if err != nil {
return nil, err
}
if err := os.Remove(file); err != nil && !os.IsNotExist(err) {
return nil, err
}
return map[string]string{"key": key}, nil
case "host.workspace.create":
return nil, errors.New("the test host has one shared folder; it can't create another")
}
return nil, fmt.Errorf("unknown plugin host operation %q", method)
}
func (h *testHost) workspaceFile(rel string) (string, error) {
file := filepath.Join(h.workspaceDir, filepath.FromSlash(path.Clean("/"+rel)))
if !inside(h.workspaceDir, file) {
return "", errors.New("the key is outside the shared folder")
}
return file, nil
}
func mustRel(root, p string) string {
rel, _ := filepath.Rel(root, p)
return rel
}
func fileETag(p string) (string, error) {
f, err := os.Open(p)
if err != nil {
return "", err
}
defer f.Close()
sum := sha256.New()
if _, err := io.Copy(sum, f); err != nil {
return "", err
}
return hex.EncodeToString(sum.Sum(nil))[:32], nil
}
func copyFile(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}
+3
View File
@@ -0,0 +1,3 @@
module git.eidonix.com/eidonix/cletus-plugins/sdk
go 1.24
+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)
}
}
+90
View File
@@ -0,0 +1,90 @@
package pluginsdk
import (
"context"
"encoding/json"
"fmt"
"time"
)
// Host makes calls to Cletus on behalf of the request being handled. Use
// it only inside the handler it was passed to.
type Host struct {
call func(ctx context.Context, method string, params any) (json.RawMessage, error)
}
// NewTestHost returns a Host whose calls are answered by fn, for unit tests
// of handlers. fn receives the method and the params as JSON.
func NewTestHost(fn func(method string, params json.RawMessage) (any, error)) *Host {
return &Host{call: func(_ context.Context, method string, params any) (json.RawMessage, error) {
raw, err := json.Marshal(params)
if err != nil {
return nil, err
}
result, err := fn(method, raw)
if err != nil {
return nil, err
}
return json.Marshal(result)
}}
}
// Call makes any host call in docs/host-api.md and decodes its result into
// out (which may be nil).
func (h *Host) Call(ctx context.Context, method string, params, out any) error {
if params == nil {
params = struct{}{}
}
raw, err := h.call(ctx, method, params)
if err != nil {
return err
}
if out == nil || len(raw) == 0 {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("%s: unexpected result: %w", method, err)
}
return nil
}
// ResolvePath returns the real path of a file the user chose
// (host.files.resolve; needs the files permission). Resolve every path
// before reading it.
func (h *Host) ResolvePath(ctx context.Context, path string) (string, error) {
var resolved string
err := h.Call(ctx, "host.files.resolve", map[string]string{"path": path}, &resolved)
return resolved, err
}
// CanWrite reports whether the user's current rules allow writing path
// (host.files.can_write; needs the files permission).
func (h *Host) CanWrite(ctx context.Context, path string) (bool, error) {
var allowed bool
err := h.Call(ctx, "host.files.can_write", map[string]string{"path": path}, &allowed)
return allowed, err
}
// Event sends an event to the plugin's screen. name must start with the
// plugin id and a colon ("acme.gauge-check:progress").
func (h *Host) Event(ctx context.Context, name string, args ...any) error {
if args == nil {
args = []any{}
}
return h.Call(ctx, "host.event", map[string]any{"name": name, "args": args}, nil)
}
// Published is a link to a file in the data folder that the screen can fetch.
type Published struct {
Token string `json:"token"`
URL string `json:"url"`
ExpiresAt string `json:"expiresAt"`
}
// Publish makes a file in the data folder fetchable by the screen for ttl
// (1 second to 1 hour). The file must not change while published.
func (h *Host) Publish(ctx context.Context, path, contentType string, ttl time.Duration) (Published, error) {
var out Published
err := h.Call(ctx, "host.data.publish", map[string]any{"path": path, "contentType": contentType, "ttl": int(ttl / time.Second)}, &out)
return out, err
}
+337
View File
@@ -0,0 +1,337 @@
// Package pluginsdk is the worker side of a Cletus plugin: the message
// protocol, chat tools, screen and service handlers, and calls to the host.
// See docs/specification.md and docs/host-api.md.
package pluginsdk
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"sync"
)
// APIVersion is the worker protocol version the host expects at handshake.
const APIVersion = 1
// MaxMessageBytes is the largest message (one line) either side may send.
const MaxMessageBytes = 64 << 20
// Func handles one screen (ui.*) request. params is the JSON array of the
// screen's arguments; decode it with Args.
type Func func(ctx context.Context, h *Host, params json.RawMessage) (any, error)
// ServiceFunc handles one service method another plugin calls.
type ServiceFunc func(ctx context.Context, h *Host, call ServiceCall) (any, error)
// ServiceCall is a service request the host routed from another plugin.
type ServiceCall struct {
Caller string `json:"caller"`
Mutating bool `json:"mutating"`
Args json.RawMessage `json:"args"`
}
// Handlers is everything a worker answers.
type Handlers struct {
// Tools are the chat tools. Each must also be listed in plugin.json.
Tools []Tool
// UI maps a screen method name ("check", answered as ui.check) to its handler.
UI map[string]Func
// Services maps "<service>.<method>" ("gauge.check") to its handler.
// Each must be declared under "provides" in plugin.json.
Services map[string]ServiceFunc
// Busy reports whether work is running that must not be interrupted.
// Nil means never busy.
Busy func() bool
}
// ID returns the plugin id the host started the worker as.
func ID() string { return os.Getenv("CLETUS_PLUGIN_ID") }
// DataDir returns the worker's data folder for the signed-in account. It is
// also the worker's working directory. Empty when not started by a host.
func DataDir() string { return os.Getenv("CLETUS_PLUGIN_DATA") }
// Serve runs the worker on standard input and output until the host shuts
// it down or closes the pipe. Standard output belongs to the protocol, so
// Serve points os.Stdout at standard error: a stray fmt.Println lands in
// the worker log instead of breaking the connection.
func Serve(h Handlers) error {
out := os.Stdout
os.Stdout = os.Stderr
return ServeIO(context.Background(), os.Stdin, out, h)
}
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 *wireError `json:"error,omitempty"`
}
type wireError struct {
Message string `json:"message"`
}
type server struct {
handlers Handlers
tools map[string]Tool
out io.Writer
writeMu sync.Mutex
mu sync.Mutex
active map[string]context.CancelFunc
pending map[string]chan message
next int
wg sync.WaitGroup
}
// ServeIO runs the worker on the given streams. It returns when ctx ends,
// the input closes, or the host sends plugin.shutdown.
func ServeIO(ctx context.Context, in io.Reader, out io.Writer, h Handlers) error {
s := &server{handlers: h, tools: map[string]Tool{}, out: out,
active: map[string]context.CancelFunc{}, pending: map[string]chan message{}}
for _, t := range h.Tools {
if _, dup := s.tools[t.Name]; dup {
return fmt.Errorf("tool %q is registered twice", t.Name)
}
s.tools[t.Name] = t
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
defer s.wg.Wait()
defer s.cancelAll()
lines := make(chan []byte)
readErr := make(chan error, 1)
go func() {
reader := bufio.NewReaderSize(in, 64<<10)
for {
line, err := readLine(reader)
if len(line) > 0 {
select {
case lines <- line:
case <-ctx.Done():
return
}
}
if err != nil {
readErr <- err
return
}
}
}()
for {
select {
case <-ctx.Done():
return ctx.Err()
case err := <-readErr:
if errors.Is(err, io.EOF) {
return nil
}
return err
case line := <-lines:
var m message
if err := json.Unmarshal(line, &m); err != nil {
return fmt.Errorf("invalid message from host: %w", err)
}
if stop := s.dispatch(ctx, m); stop {
return nil
}
}
}
}
func readLine(r *bufio.Reader) ([]byte, error) {
var line []byte
for {
chunk, err := r.ReadSlice('\n')
line = append(line, chunk...)
if len(line) > MaxMessageBytes+1 {
return nil, errors.New("message from host is too large")
}
if err == bufio.ErrBufferFull {
continue
}
return []byte(strings.TrimSpace(string(line))), err
}
}
// dispatch handles one incoming message and reports whether to stop.
func (s *server) dispatch(ctx context.Context, m message) bool {
switch {
case m.Method == "$/cancel":
var id string
if json.Unmarshal(m.Params, &id) == nil {
s.mu.Lock()
if cancel := s.active[id]; cancel != nil {
cancel()
}
s.mu.Unlock()
}
case m.Method == "plugin.shutdown":
s.reply(m.ID, map[string]bool{"ok": true}, nil)
return true
case m.Method != "":
if m.ID == "" {
return false // a notification this worker doesn't use
}
reqCtx, cancel := context.WithCancel(ctx)
s.mu.Lock()
s.active[m.ID] = cancel
s.mu.Unlock()
s.wg.Add(1)
go func() {
defer s.wg.Done()
defer func() {
s.mu.Lock()
delete(s.active, m.ID)
s.mu.Unlock()
cancel()
}()
result, err := s.handle(reqCtx, m)
s.reply(m.ID, result, err)
}()
case m.ID != "":
s.mu.Lock()
ch := s.pending[m.ID]
delete(s.pending, m.ID)
s.mu.Unlock()
if ch != nil {
ch <- m
}
}
return false
}
func (s *server) handle(ctx context.Context, m message) (result any, err error) {
defer func() {
if r := recover(); r != nil {
result, err = nil, fmt.Errorf("the plugin failed while handling %s: %v", m.Method, r)
}
}()
h := &Host{call: func(ctx context.Context, method string, params any) (json.RawMessage, error) {
return s.callHost(ctx, m.ID, method, params)
}}
switch {
case m.Method == "plugin.handshake":
return map[string]int{"api_version": APIVersion}, nil
case m.Method == "plugin.busy":
return s.handlers.Busy != nil && s.handlers.Busy(), nil
case m.Method == "tools.list":
return s.listTools(), nil
case m.Method == "tools.invoke":
return s.invokeTool(ctx, h, m.Params)
case strings.HasPrefix(m.Method, "ui."):
fn := s.handlers.UI[strings.TrimPrefix(m.Method, "ui.")]
if fn == nil {
return nil, fmt.Errorf("unknown method %s", m.Method)
}
return fn(ctx, h, m.Params)
case strings.HasPrefix(m.Method, "svc."):
fn := s.handlers.Services[strings.TrimPrefix(m.Method, "svc.")]
if fn == nil {
return nil, fmt.Errorf("unknown method %s", m.Method)
}
var call ServiceCall
if err := json.Unmarshal(m.Params, &call); err != nil {
return nil, fmt.Errorf("invalid service request: %w", err)
}
return fn(ctx, h, call)
}
return nil, fmt.Errorf("unknown method %s", m.Method)
}
func (s *server) reply(id string, result any, err error) {
if id == "" {
return
}
m := message{ID: id}
if err != nil {
m.Error = &wireError{Message: err.Error()}
} else {
raw, marshalErr := json.Marshal(result)
if marshalErr != nil {
m.Error = &wireError{Message: "the plugin returned a result that is not JSON: " + marshalErr.Error()}
} else {
m.Result = raw
}
}
s.send(m)
}
func (s *server) send(m message) {
data, err := json.Marshal(m)
if err != nil || len(data) > MaxMessageBytes {
if m.ID != "" && m.Method == "" {
data, _ = json.Marshal(message{ID: m.ID, Error: &wireError{Message: "the plugin's reply is too large"}})
} else {
return
}
}
s.writeMu.Lock()
defer s.writeMu.Unlock()
_, _ = s.out.Write(append(data, '\n'))
}
func (s *server) callHost(ctx context.Context, parent, method string, params any) (json.RawMessage, error) {
raw, err := json.Marshal(params)
if err != nil {
return nil, fmt.Errorf("%s: %w", method, err)
}
ch := make(chan message, 1)
s.mu.Lock()
s.next++
id := fmt.Sprintf("w%d", s.next)
s.pending[id] = ch
s.mu.Unlock()
s.send(message{ID: id, ParentID: parent, Method: method, Params: raw})
select {
case reply := <-ch:
if reply.Error != nil {
return nil, errors.New(reply.Error.Message)
}
return reply.Result, nil
case <-ctx.Done():
s.mu.Lock()
delete(s.pending, id)
s.mu.Unlock()
cancel, _ := json.Marshal(id)
s.send(message{Method: "$/cancel", Params: cancel})
return nil, ctx.Err()
}
}
func (s *server) cancelAll() {
s.mu.Lock()
defer s.mu.Unlock()
for _, cancel := range s.active {
cancel()
}
}
// Args decodes a screen request's argument array into targets, in order.
// Missing trailing arguments leave their targets unchanged.
func Args(params json.RawMessage, targets ...any) error {
var items []json.RawMessage
if len(params) > 0 && string(params) != "null" {
if err := json.Unmarshal(params, &items); err != nil {
return fmt.Errorf("screen arguments must be a JSON array: %w", err)
}
}
for i, target := range targets {
if i >= len(items) {
break
}
if err := json.Unmarshal(items[i], target); err != nil {
return fmt.Errorf("argument %d: %w", i+1, err)
}
}
return nil
}
+168
View File
@@ -0,0 +1,168 @@
package pluginsdk
import (
"bufio"
"context"
"encoding/json"
"io"
"strings"
"testing"
"time"
)
// pipeHost drives a worker over in-memory pipes, as Cletus does.
type pipeHost struct {
t *testing.T
toW *io.PipeWriter
fromW *bufio.Scanner
done chan error
cancel context.CancelFunc
}
func startWorker(t *testing.T, h Handlers) *pipeHost {
t.Helper()
inR, inW := io.Pipe()
outR, outW := io.Pipe()
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- ServeIO(ctx, inR, outW, h); outW.Close() }()
sc := bufio.NewScanner(outR)
sc.Buffer(make([]byte, 1<<20), MaxMessageBytes)
p := &pipeHost{t: t, toW: inW, fromW: sc, done: done, cancel: cancel}
t.Cleanup(func() { cancel(); inW.Close() })
return p
}
func (p *pipeHost) send(m message) {
p.t.Helper()
data, _ := json.Marshal(m)
if _, err := p.toW.Write(append(data, '\n')); err != nil {
p.t.Fatal(err)
}
}
func (p *pipeHost) read() message {
p.t.Helper()
if !p.fromW.Scan() {
p.t.Fatalf("worker closed: %v", p.fromW.Err())
}
var m message
if err := json.Unmarshal(p.fromW.Bytes(), &m); err != nil {
p.t.Fatal(err)
}
return m
}
func TestHandshakeAndShutdown(t *testing.T) {
p := startWorker(t, Handlers{})
p.send(message{ID: "1", Method: "plugin.handshake"})
if got := p.read(); string(got.Result) != `{"api_version":1}` {
t.Fatalf("handshake = %s", got.Result)
}
p.send(message{ID: "2", Method: "plugin.shutdown"})
if got := p.read(); got.ID != "2" || got.Error != nil {
t.Fatalf("shutdown reply = %+v", got)
}
select {
case err := <-p.done:
if err != nil {
t.Fatalf("ServeIO = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("worker did not stop after plugin.shutdown")
}
}
func TestToolWithHostCall(t *testing.T) {
tool := Tool{Name: "demo_read", Description: "Reads a file.", Run: func(ctx context.Context, h *Host, call ToolCall) ToolResult {
var args struct{ Path string }
if err := call.Decode(&args); err != nil {
return Fail("%v", err)
}
path, err := h.ResolvePath(ctx, args.Path)
if err != nil {
return Fail("%v", err)
}
return Text("resolved %s", path)
}}
p := startWorker(t, Handlers{Tools: []Tool{tool}})
p.send(message{ID: "1", Method: "tools.list"})
var list []toolDescriptor
if err := json.Unmarshal(p.read().Result, &list); err != nil || len(list) != 1 || list[0].Def.Function.Name != "demo_read" {
t.Fatalf("tools.list = %+v, %v", list, err)
}
params, _ := json.Marshal(toolInvocation{Name: "demo_read", Args: `{"Path":"a.txt"}`})
p.send(message{ID: "2", Method: "tools.invoke", Params: params})
call := p.read()
if call.Method != "host.files.resolve" || call.ParentID != "2" || !strings.Contains(string(call.Params), "a.txt") {
t.Fatalf("host call = %+v", call)
}
p.send(message{ID: call.ID, Result: json.RawMessage(`"/work/a.txt"`)})
var result ToolResult
if err := json.Unmarshal(p.read().Result, &result); err != nil || result.Text != "resolved /work/a.txt" {
t.Fatalf("result = %+v, %v", result, err)
}
}
func TestCancelStopsHandler(t *testing.T) {
started := make(chan struct{})
p := startWorker(t, Handlers{UI: map[string]Func{"wait": func(ctx context.Context, h *Host, params json.RawMessage) (any, error) {
close(started)
<-ctx.Done()
return nil, ctx.Err()
}}})
p.send(message{ID: "9", Method: "ui.wait", Params: json.RawMessage(`[]`)})
<-started
cancelID, _ := json.Marshal("9")
p.send(message{Method: "$/cancel", Params: cancelID})
if got := p.read(); got.ID != "9" || got.Error == nil {
t.Fatalf("cancelled reply = %+v", got)
}
}
func TestPanicBecomesError(t *testing.T) {
p := startWorker(t, Handlers{UI: map[string]Func{"boom": func(context.Context, *Host, json.RawMessage) (any, error) {
panic("bad input")
}}})
p.send(message{ID: "1", Method: "ui.boom"})
if got := p.read(); got.Error == nil || !strings.Contains(got.Error.Message, "bad input") {
t.Fatalf("reply = %+v", got)
}
}
func TestServiceAndUnknownMethod(t *testing.T) {
p := startWorker(t, Handlers{Services: map[string]ServiceFunc{"gauge.check": func(_ context.Context, _ *Host, call ServiceCall) (any, error) {
return map[string]string{"caller": call.Caller}, nil
}}})
p.send(message{ID: "1", Method: "svc.gauge.check", Params: json.RawMessage(`{"caller":"acme.other","args":{}}`)})
if got := p.read(); string(got.Result) != `{"caller":"acme.other"}` {
t.Fatalf("service = %+v", got)
}
p.send(message{ID: "2", Method: "nope"})
if got := p.read(); got.Error == nil {
t.Fatalf("unknown method answered %+v", got)
}
}
func TestArgs(t *testing.T) {
var name string
var count int
if err := Args(json.RawMessage(`["x", 3]`), &name, &count); err != nil || name != "x" || count != 3 {
t.Fatalf("Args = %q %d %v", name, count, err)
}
if err := Args(json.RawMessage(`{"a":1}`), &name); err == nil {
t.Fatal("Args accepted an object")
}
}
func TestNewTestHost(t *testing.T) {
h := NewTestHost(func(method string, params json.RawMessage) (any, error) {
return method == "host.files.can_write", nil
})
ok, err := h.CanWrite(context.Background(), "x")
if err != nil || !ok {
t.Fatalf("CanWrite = %v %v", ok, err)
}
}
+116
View File
@@ -0,0 +1,116 @@
package pluginsdk
import (
"context"
"encoding/json"
"fmt"
"sort"
)
// Tool is a chat tool: a function Cletus's AI can call. Name, Mutating and
// RequiresApproval must match the plugin's entry in plugin.json "tools".
type Tool struct {
Name string
Description string
// Parameters is the JSON schema of the arguments (an object schema).
Parameters map[string]any
// Mutating is true if the tool writes anything anywhere.
Mutating bool
// RequiresApproval makes Cletus ask the user before every call.
RequiresApproval bool
Run func(ctx context.Context, h *Host, call ToolCall) ToolResult
}
// ToolCall is one call of a chat tool.
type ToolCall struct {
// Args is the JSON object of arguments the AI sent.
Args json.RawMessage
// WorkingDir is the user's working folder ("" when none is open).
WorkingDir string
// EditMode is false when the user has Cletus read-only: a mutating tool
// must check Host.CanWrite before each write and refuse when it's false.
EditMode bool
}
// Decode decodes the arguments into v.
func (c ToolCall) Decode(v any) error {
if len(c.Args) == 0 {
return json.Unmarshal([]byte("{}"), v)
}
if err := json.Unmarshal(c.Args, v); err != nil {
return fmt.Errorf("the arguments don't match the tool's parameters: %w", err)
}
return nil
}
// ToolResult is what the AI reads back. Set Text on success, Error on
// failure (a plain sentence with the next step). Images are optional
// (PNG or JPEG bytes).
type ToolResult struct {
Text string `json:"Text,omitempty"`
Error string `json:"Error,omitempty"`
Images [][]byte `json:"Images,omitempty"`
}
// Text returns a successful result.
func Text(format string, args ...any) ToolResult {
return ToolResult{Text: fmt.Sprintf(format, args...)}
}
// Fail returns a failed result.
func Fail(format string, args ...any) ToolResult {
return ToolResult{Error: fmt.Sprintf(format, args...)}
}
type toolDescriptor struct {
Def toolDef `json:"def"`
Mutating bool `json:"mutating"`
RequiresApproval bool `json:"requires_approval"`
}
type toolDef struct {
Type string `json:"type"`
Function functionDef `json:"function"`
}
type functionDef struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]any `json:"parameters"`
}
func (s *server) listTools() []toolDescriptor {
out := make([]toolDescriptor, 0, len(s.tools))
for _, t := range s.tools {
params := t.Parameters
if params == nil {
params = map[string]any{"type": "object", "properties": map[string]any{}}
}
out = append(out, toolDescriptor{
Def: toolDef{Type: "function", Function: functionDef{Name: t.Name, Description: t.Description, Parameters: params}},
Mutating: t.Mutating,
RequiresApproval: t.RequiresApproval,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Def.Function.Name < out[j].Def.Function.Name })
return out
}
type toolInvocation struct {
Name string `json:"name"`
Args string `json:"args"`
WorkingDir string `json:"working_dir"`
EditMode bool `json:"edit_mode"`
}
func (s *server) invokeTool(ctx context.Context, h *Host, params json.RawMessage) (ToolResult, error) {
var inv toolInvocation
if err := json.Unmarshal(params, &inv); err != nil {
return ToolResult{}, fmt.Errorf("invalid tool call: %w", err)
}
t, ok := s.tools[inv.Name]
if !ok || t.Run == nil {
return Fail("This plugin has no tool named %q.", inv.Name), nil
}
return t.Run(ctx, h, ToolCall{Args: json.RawMessage(inv.Args), WorkingDir: inv.WorkingDir, EditMode: inv.EditMode}), nil
}