Cletus plugin kit: rules for coding agents, specification, host API, style guide, worker SDK and cletus-plugin tool
This commit is contained in:
commit
9b19d3a8be
41 files changed
+4121
No files matched your search
+201
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`)
|
||||
}
|
||||
@@ -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, " ")
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.eidonix.com/eidonix/cletus-plugins/sdk
|
||||
|
||||
go 1.24
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user