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

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

No files matched your search

@@ -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)
}
}