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
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user