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,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
|
||||
Reference in new issue
Block a user